Metadata-Version: 2.4
Name: urbanshadow
Version: 0.1.0
Summary: Building shadow casting and sun-hours analysis from 2D footprints and heights: NOAA solar position, shadow polygons, sun-hours grids, facade sun and the BRE 209 two-hours-on-21-March test.
Author-email: Yusuf Eminoğlu <geospacephilo@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/YusufEminoglu/urbanshadow_sdk
Project-URL: Repository, https://github.com/YusufEminoglu/urbanshadow_sdk
Project-URL: Issues, https://github.com/YusufEminoglu/urbanshadow_sdk/issues
Project-URL: Changelog, https://github.com/YusufEminoglu/urbanshadow_sdk/blob/main/CHANGELOG.md
Keywords: shadow,shadow-analysis,sun-hours,solar-position,noaa,daylight,sunlight,bre-209,urban-planning,buildings,geojson,gis,overshadowing
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Environment :: Console
Classifier: Topic :: Scientific/Engineering :: GIS
Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
Classifier: Intended Audience :: Science/Research
Classifier: Intended Audience :: Developers
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.20
Provides-Extra: shapely
Requires-Dist: shapely>=2.0; extra == "shapely"
Provides-Extra: tz
Requires-Dist: tzdata; platform_system == "Windows" and extra == "tz"
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
Requires-Dist: ruff>=0.1.0; extra == "dev"
Requires-Dist: mypy>=1.0.0; extra == "dev"
Requires-Dist: tzdata; extra == "dev"
Dynamic: license-file

# urbanshadow

[![PyPI version](https://img.shields.io/pypi/v/urbanshadow.svg)](https://pypi.org/project/urbanshadow/)
[![Python versions](https://img.shields.io/pypi/pyversions/urbanshadow.svg)](https://pypi.org/project/urbanshadow/)
[![CI](https://github.com/YusufEminoglu/urbanshadow_sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/YusufEminoglu/urbanshadow_sdk/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/YusufEminoglu/urbanshadow_sdk/blob/main/LICENSE)

**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.

```bash
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)

```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):

```python
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`](https://github.com/YusufEminoglu/urbanshadow_sdk/blob/main/examples/quickstart.py) for a runnable script.

## Quick start (command line)

```bash
# 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

```bash
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

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

## License

MIT © 2026 Yusuf Eminoğlu. See [LICENSE](https://github.com/YusufEminoglu/urbanshadow_sdk/blob/main/LICENSE).
