Skip to main content

urbanshadow

PyPI version Python versions CI License: MIT

Building shadows and sun hours from 2D footprints and heights.

urbanshadow answers the overshadowing questions that come up in urban planning and design review, working from building footprints (GeoJSON) and heights:

  • Where the sun is. Azimuth, elevation, sunrise, solar noon and sunset with the NOAA Solar Calculator algorithm (Meeus). The tests check it against values from the NOAA calculator code.
  • Where the shadows fall. One exact shadow polygon per building and time step, and the union of all shadows at a time step.
  • How long a spot gets sun. Hours of direct sun at points, on a ground grid or on facades, for one day, a date range or a time window, with every building acting as an obstacle.
  • Planning checks. The share of an open space in shadow at given times. The BRE 209 test: does at least half of a garden get at least 2 hours of sun on 21 March? If not, is the sunlit area below 0.8 times its former value?
  • A command line tool that turns a GeoJSON of buildings into GeoJSON shadows, sun-hours grids (GeoJSON or CSV) or a BRE verdict.

numpy is the only required dependency. shapely is optional: with it you get dissolved shadow polygons and exact area overlays. Without it, point-in-shadow queries, sun hours and the area metrics still run, through vectorised numpy or pure Python, so the package also works where shapely is hard to install.

pip install urbanshadow              # numpy only
pip install "urbanshadow[shapely]"   # + dissolved polygons, exact areas
pip install "urbanshadow[tz]"        # + IANA time-zone data on Windows

Requires Python 3.9 or newer. (The PyPI name shadowcast was already taken.)


Quick start (Python)

from datetime import date, datetime
from urbanshadow import Building, Scene

buildings = [
    Building.box(0, 0, 40, 12, 20, id="slab"),   # x, y, width, depth, height (m)
    Building.box(50, 0, 15, 30, 9, id="east"),
]
garden = [(0, 15), (40, 15), (40, 45), (0, 45)]  # planar metres

scene = Scene(buildings, latitude=41.0082, longitude=28.9784, tz=3)  # Istanbul, UTC+3

sun = scene.sun(datetime(2026, 3, 21, 12, 0))
print(sun.azimuth, sun.elevation)                 # 153.76, 46.23

for s in scene.shadows(datetime(2026, 3, 21, 12, 0)):
    print(s.building.id, round(s.length, 1), round(s.area))   # slab 19.2 1269 ...

scene.shadow_fraction(garden, datetime(2026, 3, 21, 12, 0))   # share of garden in shadow
scene.sun_hours([20.0], [20.0], date(2026, 3, 21))            # array([3.0])
print(scene.bre_check(garden, year=2026).summary())
# 94.6% of the area receives >= 2 h of sun on 2026-03-21 (BR 209 target 50%): PASS

grid = scene.sun_hours_grid(date(2026, 3, 21), cell_size=2.0, margin=20.0)
grid.to_csv("sunhours.csv")

From GeoJSON (lon/lat or a projected metric CRS):

scene = Scene.from_geojson("buildings.geojson", tz="Europe/Istanbul")  # lon/lat input
scene = Scene.from_geojson("buildings_utm35n.geojson", latitude=41.01, longitude=28.98,
                           tz=3, crs="projected")                      # metric input
fc = scene.shadows_geojson([datetime(2026, 6, 21, h) for h in range(8, 19)])

See examples/quickstart.py for a runnable script.

Quick start (command line)

# Solar position and sun times
urbanshadow sunpos --lat 41.0082 --lon 28.9784 --time 2026-03-21T12:00 --tz 3

# Hourly shadow polygons, 08:00-18:00
urbanshadow shadows buildings.geojson --date 2026-03-21 --tz Europe/Istanbul \
    --start 08:00 --end 18:00 --step 60 -o shadows.geojson

# One dissolved feature per time step (needs shapely)
urbanshadow shadows buildings.geojson --date 2026-12-21 --time 12:00 --dissolve -o noon.geojson

# Sun hours on a 2 m grid (GeoJSON squares, or .csv), mean of a date range
urbanshadow sunhours buildings.geojson --date 2026-06-01 --last-date 2026-06-30 --average \
    --cell 2 --tz 3 -o june_sunhours.geojson

# BRE 209 garden test, proposed vs existing (exit code 1 if the area fails)
urbanshadow bre proposed.geojson --area garden.geojson --baseline existing.geojson --tz 0

Common options: --height-field, --levels-field with --level-height (default 3 m per storey), --default-height, --crs auto|geographic|projected, --lat/--lon (required for projected input) and --grid-rotation.

API overview

Function / class What it does
solar_position(when, lat, lon, tz=None, refraction=True) NOAA azimuth / elevation / declination / equation of time
sun_times(day, lat, lon, tz) sunrise, solar noon, sunset (polar day/night aware)
Building(exterior, height, holes=[], base=0), Building.box(...) flat-roofed prism in planar metres
buildings_from_geojson(src, ...), read_buildings(path) GeoJSON to buildings (height / storeys fields, lon/lat detection)
LocalProjection(lon0, lat0) WGS84 local tangent-plane projection (forward / inverse)
building_shadow(b, az, el, z=0) → BuildingShadow exact shadow: .parts, .contains(), .area, .to_shapely(), .geojson_geometry()
cast_shadows(...), union_shadows(shadows) all shadows for one sun position; dissolved union (shapely)
point_in_shadow(x, y, buildings, az, el, z=0) pure-Python point query
shadow_mask(xs, ys, buildings, az, el, z=0) vectorised numpy point query
sun_hours(xs, ys, buildings, lat, lon, day, last_day=None, ...) hours of direct sun at points
sun_hours_grid(...) → SunHoursGrid ground grid; .to_geojson(), .to_csv(), .fraction_at_least(h)
facade_sun_hours(...) → [FacadePoint] sun hours on walls (own building and neighbours as obstacles)
shadow_fraction(area, buildings, az, el) share of an open space in shadow
sunlit_area_fraction(area, buildings, lat, lon, day, min_hours=2) share of an area with at least N hours of sun
bre_sun_on_ground(area, buildings, lat, lon, baseline_buildings=...) → BREResult BR 209 test
Scene bundles buildings, location, time zone and map rotation behind the calls above

Conventions: azimuth is measured in degrees clockwise from true north. Elevation is the apparent elevation above the horizon, with refraction included. Planar coordinates are metres, with x pointing east and y north. A naive datetime is read in the scene's or the call's tz; an aware datetime is used as it is. tz can be an hour offset (3, -5.5), an offset string ("+03:00") or an IANA name ("Europe/London").

Method and sources

Solar position. The package uses the equations of the NOAA Global Monitoring Laboratory Solar Calculator (https://gml.noaa.gov/grad/solcalc/, details at https://gml.noaa.gov/grad/solcalc/calcdetails.html). That calculator implements the low-accuracy solar coordinates from Jean Meeus, Astronomical Algorithms, 2nd ed. (Willmann-Bell, 1998): the Julian Day (ch. 7), the obliquity (ch. 22), the solar coordinates (ch. 25) and the equation of time (ch. 28). Refraction follows NOAA's piecewise model, and sunrise and sunset use a 0.833° solar depression. NOAA describes the method as very accurate for 1800–2100 and sunrise/sunset as accurate to about one minute within ±72° latitude.

The tests check six cases (both hemispheres, solstices, equinoxes, low sun) against the functions of the NOAA calculator's own JavaScript (main.js), to 1e-6°. Sunrise and sunset agree to under 0.01 s. The tests also reproduce Meeus Examples 7.a and 25.a. Solar noon differs from NOAA's by about 10 s, because NOAA evaluates its second equation-of-time pass 12 hours early and urbanshadow evaluates it at noon. Like the NOAA calculator, urbanshadow ignores ΔT (TT − UT), which costs less than 0.01°.

Shadow geometry. A building is a vertical prism over footprint P, running from height base to height, on flat ground. w is the unit vector pointing away from the sun. A receptor at height z is shaded when p − s·w lies inside P for some s between L₁ and L₂, where L₁ = max(base − z, 0)/tan E and L₂ = (height − z)/tan E. The shadow polygon is therefore the Minkowski sum of P with the segment [L₁w, L₂w]. That sum is the union of the two translated footprints and the quadrilateral swept by each edge (holes included, so courtyards come out right). For a convex footprint it reduces to the convex hull of the two translated footprints. Point queries test the segment from p − L₁w to p − L₂w against P: an end point inside P, or a proper crossing of an edge. This needs no polygon union, so it runs in pure Python or vectorised numpy. The raster approach of Ratti & Richens (2004, Environment and Planning B 31(5), 743–757) answers the same question on a DEM.

Sun hours. Each day, or each local time window, is split into step_minutes intervals (10 minutes by default) and integrated with the midpoint rule. An interval counts as sunlit when the apparent sun is above min_elevation and no prism blocks the ray. The tests compare the result with a closed form for the equinox. On that day the sun's profile angle in the north–south plane is a constant 90° − φ, so a point at distance D north of a long east–west wall of height H is shaded all day when D < H·tan φ.

Facades. Receptors are placed along every wall and pushed 5 cm outwards. A receptor is sunlit when the sun is in front of the wall and no building, its own included, blocks the ray. Including the receptor's own building handles L- and U-shaped blocks.

BRE 209. The test follows P. J. Littlefair, Site layout planning for daylight and sunlight: a guide to good practice (BR 209, 3rd ed., BRE Press, 2022, section 3.3, "Gardens and open spaces"). For a garden or amenity area to appear adequately sunlit throughout the year, at least half of it should receive at least two hours of sunlight on 21 March. If new development leaves an existing area failing this, and the area that can get two hours of sun on 21 March falls below 0.8 times its former value, the loss of sunlight is likely to be noticeable. The 2 h, 50 % and 0.8 values are parameters (BRE_REQUIRED_HOURS, BRE_REQUIRED_FRACTION, BRE_RATIO_THRESHOLD) and can be overridden. BR 209 is advisory guidance, not a regulation, and planning authorities apply it flexibly.

Lon/lat input. Coordinates are projected to a local tangent plane at the layer centre, using the WGS84 meridional and prime-vertical radii of curvature (Snyder, Map Projections: A Working Manual, USGS PP 1395, 1987). In this plane grid north is true north. The scale error is about 0.01–0.02 % at 1 km, which suits district-scale studies. Results are written back in lon/lat.

Limitations

  • Flat ground, flat roofs and vertical walls (LOD1 blocks). Terrain, pitched roofs, trees and other non-building obstacles are not modelled. Translucent obstacles are treated as opaque.
  • Only direct sun is computed. Diffuse daylight, VSC, APSH and the BRE window tests are out of scope.
  • In a projected CRS, map north can differ from true north (meridian convergence, up to a few degrees in UTM). Pass grid_rotation, the clockwise angle from grid north to true north, to correct for it.
  • Without shapely, non-convex shadow polygons are exported as a MultiPolygon of overlapping parts. Their union is exact, but they are not dissolved, and include_footprint=False only affects point queries. Shadow areas of non-convex footprints are then estimated on a 200 × 200 grid.
  • Area shares and the BRE test are computed by sampling. Points inside buildings are left out. The default spacing gives about 10 000 samples per area.
  • Sun hours use wall-clock steps. On a daylight-saving change day the intervals follow local clock time.
  • crs="auto" treats any layer whose coordinates fit lon/lat ranges and span less than 5° as geographic. Pass crs="projected" for small local engineering grids.

Development

python -m pip install -e ".[dev,shapely]"
python -m pytest
python -m ruff check . && python -m ruff format --check src tests
python -m mypy src

Academic citation

@software{eminoglu_urbanshadow,
  author  = {Eminoğlu, Yusuf},
  title   = {urbanshadow},
  year    = {2026},
  version = {0.1.1},
  url     = {https://github.com/YusufEminoglu/urbanshadow_sdk}
}

License

MIT © 2026 Yusuf Eminoğlu. See LICENSE.

Metadata

Release files for urbanshadow 0.1.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for urbanshadow 0.1.1
File Size Uploaded Provenance Actions
urbanshadow-0.1.1.tar.gz 58.3 kB With provenance Details for urbanshadow-0.1.1.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for urbanshadow 0.1.1
File Interpreter ABI Platform Actions
urbanshadow-0.1.1-py3-none-any.whl Python 3 none any Details for urbanshadow-0.1.1-py3-none-any.whl

Total release size: 102.2 kB

Release files / urbanshadow-0.1.1.tar.gz

Download URL urbanshadow-0.1.1.tar.gz
Size 58.3 kB
Tags Source
SHA-256 checksum
How to use checksums
37912acad7d5a78c0c3b078c9f84dcf17d5772a97ef12cbeafa7e85bb0ecec0d
BLAKE2b-256 checksum
How to use checksums
e90ad3b0f8c45291e0c5460a4554273ba86121cb81ff9292adab40983289783c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.

Transparency log

Release files / urbanshadow-0.1.1-py3-none-any.whl

Download URL urbanshadow-0.1.1-py3-none-any.whl
Size 43.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5d627d8a10ada2e1cf25af3fcafc59687e407739550ca0438ff06d2013db03e4
BLAKE2b-256 checksum
How to use checksums
c60b1dff56110104c193267ea4b23b6dff9d023a1ba9852e5a47b21707b8cc11
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.

Transparency log

Security for urbanshadow 0.1.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for urbanshadow-0.1.1.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page