urbanshadow
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=Falseonly 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. Passcrs="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)
| 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)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| 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 logRelease 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 logSecurity 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.
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 logA 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.