urbanshadow 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. urbanshadow-0.1.0/CHANGELOG.md +24 -0
  2. urbanshadow-0.1.0/CITATION.cff +21 -0
  3. urbanshadow-0.1.0/LICENSE +21 -0
  4. urbanshadow-0.1.0/MANIFEST.in +9 -0
  5. urbanshadow-0.1.0/PKG-INFO +293 -0
  6. urbanshadow-0.1.0/README.md +252 -0
  7. urbanshadow-0.1.0/examples/quickstart.py +41 -0
  8. urbanshadow-0.1.0/pyproject.toml +121 -0
  9. urbanshadow-0.1.0/setup.cfg +4 -0
  10. urbanshadow-0.1.0/src/urbanshadow/__init__.py +88 -0
  11. urbanshadow-0.1.0/src/urbanshadow/__main__.py +7 -0
  12. urbanshadow-0.1.0/src/urbanshadow/_version.py +1 -0
  13. urbanshadow-0.1.0/src/urbanshadow/buildings.py +367 -0
  14. urbanshadow-0.1.0/src/urbanshadow/cli.py +280 -0
  15. urbanshadow-0.1.0/src/urbanshadow/geometry.py +310 -0
  16. urbanshadow-0.1.0/src/urbanshadow/metrics.py +283 -0
  17. urbanshadow-0.1.0/src/urbanshadow/py.typed +0 -0
  18. urbanshadow-0.1.0/src/urbanshadow/scene.py +256 -0
  19. urbanshadow-0.1.0/src/urbanshadow/shadow.py +442 -0
  20. urbanshadow-0.1.0/src/urbanshadow/solar.py +353 -0
  21. urbanshadow-0.1.0/src/urbanshadow/sunhours.py +396 -0
  22. urbanshadow-0.1.0/src/urbanshadow/timeutil.py +132 -0
  23. urbanshadow-0.1.0/src/urbanshadow.egg-info/PKG-INFO +293 -0
  24. urbanshadow-0.1.0/src/urbanshadow.egg-info/SOURCES.txt +31 -0
  25. urbanshadow-0.1.0/src/urbanshadow.egg-info/dependency_links.txt +1 -0
  26. urbanshadow-0.1.0/src/urbanshadow.egg-info/entry_points.txt +2 -0
  27. urbanshadow-0.1.0/src/urbanshadow.egg-info/requires.txt +16 -0
  28. urbanshadow-0.1.0/src/urbanshadow.egg-info/top_level.txt +1 -0
  29. urbanshadow-0.1.0/tests/test_io_cli.py +307 -0
  30. urbanshadow-0.1.0/tests/test_metrics.py +141 -0
  31. urbanshadow-0.1.0/tests/test_shadow.py +251 -0
  32. urbanshadow-0.1.0/tests/test_solar.py +186 -0
  33. urbanshadow-0.1.0/tests/test_sunhours.py +177 -0
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.0] - 2026-11-09
8
+ ### Added
9
+ - Solar position after the NOAA Solar Calculator (Meeus low-accuracy solar coordinates,
10
+ equation of time, NOAA refraction model): azimuth, elevation, declination, hour angle;
11
+ sunrise, solar noon and sunset with polar day/night handling. Tested against values from
12
+ the NOAA calculator code and Meeus worked examples.
13
+ - `Building` prisms (footprint with holes, height, optional raised base) and a GeoJSON reader
14
+ with height / storey fields, lon/lat auto-detection and a WGS84 local tangent-plane
15
+ projection.
16
+ - Shadow polygons per building and time (footprint + translated footprint + edge sweeps;
17
+ convex hull for convex footprints), dissolved with shapely when installed.
18
+ - Point-in-shadow queries in pure Python and vectorised with numpy, for any receptor height.
19
+ - Sun hours at points, on ground grids (GeoJSON / CSV export) and on facades, for one day,
20
+ a date range or a local time window.
21
+ - Planning metrics: share of an open space in shadow at a time (exact with shapely or by
22
+ sampling), share of an area with at least N hours of sun, and the BRE 209 (2022)
23
+ two-hours-on-21-March garden test with the 0.8 x former-value check.
24
+ - `urbanshadow` command line: `sunpos`, `shadows`, `sunhours`, `bre`.
@@ -0,0 +1,21 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use this software, please cite it as below."
3
+ authors:
4
+ - family-names: "Eminoğlu"
5
+ given-names: "Yusuf"
6
+ email: "geospacephilo@gmail.com"
7
+ orcid: "https://orcid.org/0009-0005-6000-2934"
8
+ title: "urbanshadow"
9
+ version: "0.1.0"
10
+ date-released: "2026-11-09"
11
+ license: "MIT"
12
+ repository-code: "https://github.com/YusufEminoglu/urbanshadow_sdk"
13
+ url: "https://pypi.org/project/urbanshadow/"
14
+ abstract: "Building shadow casting and sun-hours analysis from 2D footprints and heights: NOAA/Meeus solar position, exact shadow polygons of extruded footprints, vectorised point-in-shadow queries with a shapely-free fallback, sun-hours grids and facade sun, open-space shadow shares and the BRE 209 two-hours-of-sun-on-21-March test."
15
+ keywords:
16
+ - shadow analysis
17
+ - sun hours
18
+ - solar position
19
+ - urban planning
20
+ - BRE 209
21
+ - gis
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yusuf Eminoğlu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,9 @@
1
+ include LICENSE
2
+ include README.md
3
+ include CHANGELOG.md
4
+ include CITATION.cff
5
+ include pyproject.toml
6
+ include src/urbanshadow/py.typed
7
+ recursive-include src/urbanshadow *.py
8
+ recursive-include tests *.py
9
+ recursive-include examples *.py
@@ -0,0 +1,293 @@
1
+ Metadata-Version: 2.4
2
+ Name: urbanshadow
3
+ Version: 0.1.0
4
+ 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.
5
+ Author-email: Yusuf Eminoğlu <geospacephilo@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/YusufEminoglu/urbanshadow_sdk
8
+ Project-URL: Repository, https://github.com/YusufEminoglu/urbanshadow_sdk
9
+ Project-URL: Issues, https://github.com/YusufEminoglu/urbanshadow_sdk/issues
10
+ Project-URL: Changelog, https://github.com/YusufEminoglu/urbanshadow_sdk/blob/main/CHANGELOG.md
11
+ Keywords: shadow,shadow-analysis,sun-hours,solar-position,noaa,daylight,sunlight,bre-209,urban-planning,buildings,geojson,gis,overshadowing
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Environment :: Console
21
+ Classifier: Topic :: Scientific/Engineering :: GIS
22
+ Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
23
+ Classifier: Intended Audience :: Science/Research
24
+ Classifier: Intended Audience :: Developers
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: numpy>=1.20
30
+ Provides-Extra: shapely
31
+ Requires-Dist: shapely>=2.0; extra == "shapely"
32
+ Provides-Extra: tz
33
+ Requires-Dist: tzdata; platform_system == "Windows" and extra == "tz"
34
+ Provides-Extra: dev
35
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
36
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
37
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
38
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
39
+ Requires-Dist: tzdata; extra == "dev"
40
+ Dynamic: license-file
41
+
42
+ # urbanshadow
43
+
44
+ [![PyPI version](https://img.shields.io/pypi/v/urbanshadow.svg)](https://pypi.org/project/urbanshadow/)
45
+ [![Python versions](https://img.shields.io/pypi/pyversions/urbanshadow.svg)](https://pypi.org/project/urbanshadow/)
46
+ [![CI](https://github.com/YusufEminoglu/urbanshadow_sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/YusufEminoglu/urbanshadow_sdk/actions/workflows/ci.yml)
47
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/YusufEminoglu/urbanshadow_sdk/blob/main/LICENSE)
48
+
49
+ **Building shadows and sun hours from 2D footprints and heights.**
50
+
51
+ `urbanshadow` answers the overshadowing questions that come up in urban
52
+ planning and design review, working from building footprints (GeoJSON) and
53
+ heights:
54
+
55
+ - **Where the sun is.** Azimuth, elevation, sunrise, solar noon and sunset
56
+ with the NOAA Solar Calculator algorithm (Meeus). The tests check it
57
+ against values from the NOAA calculator code.
58
+ - **Where the shadows fall.** One exact shadow polygon per building and time
59
+ step, and the union of all shadows at a time step.
60
+ - **How long a spot gets sun.** Hours of direct sun at points, on a ground
61
+ grid or on facades, for one day, a date range or a time window, with every
62
+ building acting as an obstacle.
63
+ - **Planning checks.** The share of an open space in shadow at given times.
64
+ The BRE 209 test: does at least half of a garden get at least
65
+ 2 hours of sun on 21 March? If not, is the sunlit area below 0.8 times its
66
+ former value?
67
+ - **A command line tool** that turns a GeoJSON of buildings into GeoJSON
68
+ shadows, sun-hours grids (GeoJSON or CSV) or a BRE verdict.
69
+
70
+ numpy is the only required dependency. shapely is optional: with it you get
71
+ dissolved shadow polygons and exact area overlays. Without it, point-in-shadow
72
+ queries, sun hours and the area metrics still run, through vectorised numpy
73
+ or pure Python, so the package also works where shapely is hard to install.
74
+
75
+ ```bash
76
+ pip install urbanshadow # numpy only
77
+ pip install "urbanshadow[shapely]" # + dissolved polygons, exact areas
78
+ pip install "urbanshadow[tz]" # + IANA time-zone data on Windows
79
+ ```
80
+
81
+ Requires Python 3.9 or newer. (The PyPI name `shadowcast` was already taken.)
82
+
83
+ ---
84
+
85
+ ## Quick start (Python)
86
+
87
+ ```python
88
+ from datetime import date, datetime
89
+ from urbanshadow import Building, Scene
90
+
91
+ buildings = [
92
+ Building.box(0, 0, 40, 12, 20, id="slab"), # x, y, width, depth, height (m)
93
+ Building.box(50, 0, 15, 30, 9, id="east"),
94
+ ]
95
+ garden = [(0, 15), (40, 15), (40, 45), (0, 45)] # planar metres
96
+
97
+ scene = Scene(buildings, latitude=41.0082, longitude=28.9784, tz=3) # Istanbul, UTC+3
98
+
99
+ sun = scene.sun(datetime(2026, 3, 21, 12, 0))
100
+ print(sun.azimuth, sun.elevation) # 153.76, 46.23
101
+
102
+ for s in scene.shadows(datetime(2026, 3, 21, 12, 0)):
103
+ print(s.building.id, round(s.length, 1), round(s.area)) # slab 19.2 1269 ...
104
+
105
+ scene.shadow_fraction(garden, datetime(2026, 3, 21, 12, 0)) # share of garden in shadow
106
+ scene.sun_hours([20.0], [20.0], date(2026, 3, 21)) # array([3.0])
107
+ print(scene.bre_check(garden, year=2026).summary())
108
+ # 94.6% of the area receives >= 2 h of sun on 2026-03-21 (BR 209 target 50%): PASS
109
+
110
+ grid = scene.sun_hours_grid(date(2026, 3, 21), cell_size=2.0, margin=20.0)
111
+ grid.to_csv("sunhours.csv")
112
+ ```
113
+
114
+ From GeoJSON (lon/lat or a projected metric CRS):
115
+
116
+ ```python
117
+ scene = Scene.from_geojson("buildings.geojson", tz="Europe/Istanbul") # lon/lat input
118
+ scene = Scene.from_geojson("buildings_utm35n.geojson", latitude=41.01, longitude=28.98,
119
+ tz=3, crs="projected") # metric input
120
+ fc = scene.shadows_geojson([datetime(2026, 6, 21, h) for h in range(8, 19)])
121
+ ```
122
+
123
+ See [`examples/quickstart.py`](https://github.com/YusufEminoglu/urbanshadow_sdk/blob/main/examples/quickstart.py) for a runnable script.
124
+
125
+ ## Quick start (command line)
126
+
127
+ ```bash
128
+ # Solar position and sun times
129
+ urbanshadow sunpos --lat 41.0082 --lon 28.9784 --time 2026-03-21T12:00 --tz 3
130
+
131
+ # Hourly shadow polygons, 08:00-18:00
132
+ urbanshadow shadows buildings.geojson --date 2026-03-21 --tz Europe/Istanbul \
133
+ --start 08:00 --end 18:00 --step 60 -o shadows.geojson
134
+
135
+ # One dissolved feature per time step (needs shapely)
136
+ urbanshadow shadows buildings.geojson --date 2026-12-21 --time 12:00 --dissolve -o noon.geojson
137
+
138
+ # Sun hours on a 2 m grid (GeoJSON squares, or .csv), mean of a date range
139
+ urbanshadow sunhours buildings.geojson --date 2026-06-01 --last-date 2026-06-30 --average \
140
+ --cell 2 --tz 3 -o june_sunhours.geojson
141
+
142
+ # BRE 209 garden test, proposed vs existing (exit code 1 if the area fails)
143
+ urbanshadow bre proposed.geojson --area garden.geojson --baseline existing.geojson --tz 0
144
+ ```
145
+
146
+ Common options: `--height-field`, `--levels-field` with `--level-height`
147
+ (default 3 m per storey), `--default-height`, `--crs auto|geographic|projected`,
148
+ `--lat/--lon` (required for projected input) and `--grid-rotation`.
149
+
150
+ ## API overview
151
+
152
+ | Function / class | What it does |
153
+ |---|---|
154
+ | `solar_position(when, lat, lon, tz=None, refraction=True)` | NOAA azimuth / elevation / declination / equation of time |
155
+ | `sun_times(day, lat, lon, tz)` | sunrise, solar noon, sunset (polar day/night aware) |
156
+ | `Building(exterior, height, holes=[], base=0)`, `Building.box(...)` | flat-roofed prism in planar metres |
157
+ | `buildings_from_geojson(src, ...)`, `read_buildings(path)` | GeoJSON to buildings (height / storeys fields, lon/lat detection) |
158
+ | `LocalProjection(lon0, lat0)` | WGS84 local tangent-plane projection (forward / inverse) |
159
+ | `building_shadow(b, az, el, z=0)` → `BuildingShadow` | exact shadow: `.parts`, `.contains()`, `.area`, `.to_shapely()`, `.geojson_geometry()` |
160
+ | `cast_shadows(...)`, `union_shadows(shadows)` | all shadows for one sun position; dissolved union (shapely) |
161
+ | `point_in_shadow(x, y, buildings, az, el, z=0)` | pure-Python point query |
162
+ | `shadow_mask(xs, ys, buildings, az, el, z=0)` | vectorised numpy point query |
163
+ | `sun_hours(xs, ys, buildings, lat, lon, day, last_day=None, ...)` | hours of direct sun at points |
164
+ | `sun_hours_grid(...)` → `SunHoursGrid` | ground grid; `.to_geojson()`, `.to_csv()`, `.fraction_at_least(h)` |
165
+ | `facade_sun_hours(...)` → `[FacadePoint]` | sun hours on walls (own building and neighbours as obstacles) |
166
+ | `shadow_fraction(area, buildings, az, el)` | share of an open space in shadow |
167
+ | `sunlit_area_fraction(area, buildings, lat, lon, day, min_hours=2)` | share of an area with at least N hours of sun |
168
+ | `bre_sun_on_ground(area, buildings, lat, lon, baseline_buildings=...)` → `BREResult` | BR 209 test |
169
+ | `Scene` | bundles buildings, location, time zone and map rotation behind the calls above |
170
+
171
+ Conventions: azimuth is measured in degrees clockwise from true north.
172
+ Elevation is the apparent elevation above the horizon, with refraction
173
+ included. Planar coordinates are metres, with x pointing east and y north.
174
+ A naive `datetime` is read in the scene's or the call's `tz`; an aware
175
+ `datetime` is used as it is. `tz` can be an hour offset (`3`, `-5.5`), an
176
+ offset string (`"+03:00"`) or an IANA name (`"Europe/London"`).
177
+
178
+ ## Method and sources
179
+
180
+ **Solar position.** The package uses the equations of the NOAA Global
181
+ Monitoring Laboratory Solar Calculator
182
+ (<https://gml.noaa.gov/grad/solcalc/>, details at
183
+ <https://gml.noaa.gov/grad/solcalc/calcdetails.html>). That calculator
184
+ implements the low-accuracy solar coordinates from Jean Meeus,
185
+ *Astronomical Algorithms*, 2nd ed. (Willmann-Bell, 1998): the Julian Day
186
+ (ch. 7), the obliquity (ch. 22), the solar coordinates (ch. 25) and the
187
+ equation of time (ch. 28). Refraction follows NOAA's piecewise model, and
188
+ sunrise and sunset use a 0.833° solar depression. NOAA describes the method
189
+ as very accurate for 1800–2100 and sunrise/sunset as accurate to about one
190
+ minute within ±72° latitude.
191
+
192
+ The tests check six cases (both hemispheres, solstices, equinoxes, low sun)
193
+ against the functions of the NOAA calculator's own JavaScript (`main.js`), to
194
+ 1e-6°. Sunrise and sunset agree to under 0.01 s. The tests also reproduce
195
+ Meeus Examples 7.a and 25.a. Solar noon differs from NOAA's by about 10 s,
196
+ because NOAA evaluates its second equation-of-time pass 12 hours early and
197
+ urbanshadow evaluates it at noon. Like the NOAA calculator, urbanshadow
198
+ ignores ΔT (TT − UT), which costs less than 0.01°.
199
+
200
+ **Shadow geometry.** A building is a vertical prism over footprint *P*,
201
+ running from height `base` to `height`, on flat ground. *w* is the unit
202
+ vector pointing away from the sun. A receptor at height *z* is shaded when
203
+ *p − s·w* lies inside *P* for some *s* between *L₁* and *L₂*, where
204
+ *L₁ = max(base − z, 0)/tan E* and *L₂ = (height − z)/tan E*. The shadow
205
+ polygon is therefore the Minkowski sum of *P* with the segment
206
+ [*L₁w*, *L₂w*]. That sum is the union of the two translated footprints and
207
+ the quadrilateral swept by each edge (holes included, so courtyards come out
208
+ right). For a convex footprint it reduces to the convex hull of the two
209
+ translated footprints. Point queries test the segment from *p − L₁w* to
210
+ *p − L₂w* against *P*: an end point inside *P*, or a proper crossing of an
211
+ edge. This needs no polygon union, so it runs in pure Python or vectorised
212
+ numpy. The raster approach of Ratti & Richens (2004, *Environment and
213
+ Planning B* 31(5), 743–757) answers the same question on a DEM.
214
+
215
+ **Sun hours.** Each day, or each local time window, is split into
216
+ `step_minutes` intervals (10 minutes by default) and integrated with the
217
+ midpoint rule. An interval counts as sunlit when the apparent sun is above
218
+ `min_elevation` and no prism blocks the ray. The tests compare the result
219
+ with a closed form for the equinox. On that day the sun's profile angle in
220
+ the north–south plane is a constant 90° − φ, so a point at distance *D* north
221
+ of a long east–west wall of height *H* is shaded all day when *D < H·tan φ*.
222
+
223
+ **Facades.** Receptors are placed along every wall and pushed 5 cm outwards.
224
+ A receptor is sunlit when the sun is in front of the wall and no building,
225
+ its own included, blocks the ray. Including the receptor's own building
226
+ handles L- and U-shaped blocks.
227
+
228
+ **BRE 209.** The test follows P. J. Littlefair, *Site layout planning for
229
+ daylight and sunlight: a guide to good practice* (BR 209, 3rd ed., BRE
230
+ Press, 2022, section 3.3, "Gardens and open spaces"). For a garden or
231
+ amenity area to appear adequately sunlit throughout the year, at least half
232
+ of it should receive at least two hours of sunlight on 21 March. If new
233
+ development leaves an existing area failing this, and the area that can get
234
+ two hours of sun on 21 March falls below 0.8 times its former value, the loss
235
+ of sunlight is likely to be noticeable. The 2 h, 50 % and 0.8 values are
236
+ parameters (`BRE_REQUIRED_HOURS`, `BRE_REQUIRED_FRACTION`,
237
+ `BRE_RATIO_THRESHOLD`) and can be overridden. BR 209 is advisory guidance,
238
+ not a regulation, and planning authorities apply it flexibly.
239
+
240
+ **Lon/lat input.** Coordinates are projected to a local tangent plane at the
241
+ layer centre, using the WGS84 meridional and prime-vertical radii of
242
+ curvature (Snyder, *Map Projections: A Working Manual*, USGS PP 1395,
243
+ 1987). In this plane grid north is true north. The scale error is about
244
+ 0.01–0.02 % at 1 km, which suits district-scale studies. Results are written
245
+ back in lon/lat.
246
+
247
+ ## Limitations
248
+
249
+ - Flat ground, flat roofs and vertical walls (LOD1 blocks). Terrain, pitched
250
+ roofs, trees and other non-building obstacles are not modelled.
251
+ Translucent obstacles are treated as opaque.
252
+ - Only direct sun is computed. Diffuse daylight, VSC, APSH and the BRE
253
+ window tests are out of scope.
254
+ - In a projected CRS, map north can differ from true north (meridian
255
+ convergence, up to a few degrees in UTM). Pass `grid_rotation`, the
256
+ clockwise angle from grid north to true north, to correct for it.
257
+ - Without shapely, non-convex shadow polygons are exported as a MultiPolygon
258
+ of overlapping parts. Their union is exact, but they are not dissolved, and
259
+ `include_footprint=False` only affects point queries. Shadow areas of
260
+ non-convex footprints are then estimated on a 200 × 200 grid.
261
+ - Area shares and the BRE test are computed by sampling. Points inside
262
+ buildings are left out. The default spacing gives about 10 000 samples
263
+ per area.
264
+ - Sun hours use wall-clock steps. On a daylight-saving change day the
265
+ intervals follow local clock time.
266
+ - `crs="auto"` treats any layer whose coordinates fit lon/lat ranges and span
267
+ less than 5° as geographic. Pass `crs="projected"` for small local
268
+ engineering grids.
269
+
270
+ ## Development
271
+
272
+ ```bash
273
+ python -m pip install -e ".[dev,shapely]"
274
+ python -m pytest
275
+ python -m ruff check . && python -m ruff format --check src tests
276
+ python -m mypy src
277
+ ```
278
+
279
+ ## Academic citation
280
+
281
+ ```bibtex
282
+ @software{eminoglu_urbanshadow,
283
+ author = {Eminoğlu, Yusuf},
284
+ title = {urbanshadow},
285
+ year = {2026},
286
+ version = {0.1.0},
287
+ url = {https://github.com/YusufEminoglu/urbanshadow_sdk}
288
+ }
289
+ ```
290
+
291
+ ## License
292
+
293
+ MIT © 2026 Yusuf Eminoğlu. See [LICENSE](https://github.com/YusufEminoglu/urbanshadow_sdk/blob/main/LICENSE).
@@ -0,0 +1,252 @@
1
+ # urbanshadow
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/urbanshadow.svg)](https://pypi.org/project/urbanshadow/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/urbanshadow.svg)](https://pypi.org/project/urbanshadow/)
5
+ [![CI](https://github.com/YusufEminoglu/urbanshadow_sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/YusufEminoglu/urbanshadow_sdk/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/YusufEminoglu/urbanshadow_sdk/blob/main/LICENSE)
7
+
8
+ **Building shadows and sun hours from 2D footprints and heights.**
9
+
10
+ `urbanshadow` answers the overshadowing questions that come up in urban
11
+ planning and design review, working from building footprints (GeoJSON) and
12
+ heights:
13
+
14
+ - **Where the sun is.** Azimuth, elevation, sunrise, solar noon and sunset
15
+ with the NOAA Solar Calculator algorithm (Meeus). The tests check it
16
+ against values from the NOAA calculator code.
17
+ - **Where the shadows fall.** One exact shadow polygon per building and time
18
+ step, and the union of all shadows at a time step.
19
+ - **How long a spot gets sun.** Hours of direct sun at points, on a ground
20
+ grid or on facades, for one day, a date range or a time window, with every
21
+ building acting as an obstacle.
22
+ - **Planning checks.** The share of an open space in shadow at given times.
23
+ The BRE 209 test: does at least half of a garden get at least
24
+ 2 hours of sun on 21 March? If not, is the sunlit area below 0.8 times its
25
+ former value?
26
+ - **A command line tool** that turns a GeoJSON of buildings into GeoJSON
27
+ shadows, sun-hours grids (GeoJSON or CSV) or a BRE verdict.
28
+
29
+ numpy is the only required dependency. shapely is optional: with it you get
30
+ dissolved shadow polygons and exact area overlays. Without it, point-in-shadow
31
+ queries, sun hours and the area metrics still run, through vectorised numpy
32
+ or pure Python, so the package also works where shapely is hard to install.
33
+
34
+ ```bash
35
+ pip install urbanshadow # numpy only
36
+ pip install "urbanshadow[shapely]" # + dissolved polygons, exact areas
37
+ pip install "urbanshadow[tz]" # + IANA time-zone data on Windows
38
+ ```
39
+
40
+ Requires Python 3.9 or newer. (The PyPI name `shadowcast` was already taken.)
41
+
42
+ ---
43
+
44
+ ## Quick start (Python)
45
+
46
+ ```python
47
+ from datetime import date, datetime
48
+ from urbanshadow import Building, Scene
49
+
50
+ buildings = [
51
+ Building.box(0, 0, 40, 12, 20, id="slab"), # x, y, width, depth, height (m)
52
+ Building.box(50, 0, 15, 30, 9, id="east"),
53
+ ]
54
+ garden = [(0, 15), (40, 15), (40, 45), (0, 45)] # planar metres
55
+
56
+ scene = Scene(buildings, latitude=41.0082, longitude=28.9784, tz=3) # Istanbul, UTC+3
57
+
58
+ sun = scene.sun(datetime(2026, 3, 21, 12, 0))
59
+ print(sun.azimuth, sun.elevation) # 153.76, 46.23
60
+
61
+ for s in scene.shadows(datetime(2026, 3, 21, 12, 0)):
62
+ print(s.building.id, round(s.length, 1), round(s.area)) # slab 19.2 1269 ...
63
+
64
+ scene.shadow_fraction(garden, datetime(2026, 3, 21, 12, 0)) # share of garden in shadow
65
+ scene.sun_hours([20.0], [20.0], date(2026, 3, 21)) # array([3.0])
66
+ print(scene.bre_check(garden, year=2026).summary())
67
+ # 94.6% of the area receives >= 2 h of sun on 2026-03-21 (BR 209 target 50%): PASS
68
+
69
+ grid = scene.sun_hours_grid(date(2026, 3, 21), cell_size=2.0, margin=20.0)
70
+ grid.to_csv("sunhours.csv")
71
+ ```
72
+
73
+ From GeoJSON (lon/lat or a projected metric CRS):
74
+
75
+ ```python
76
+ scene = Scene.from_geojson("buildings.geojson", tz="Europe/Istanbul") # lon/lat input
77
+ scene = Scene.from_geojson("buildings_utm35n.geojson", latitude=41.01, longitude=28.98,
78
+ tz=3, crs="projected") # metric input
79
+ fc = scene.shadows_geojson([datetime(2026, 6, 21, h) for h in range(8, 19)])
80
+ ```
81
+
82
+ See [`examples/quickstart.py`](https://github.com/YusufEminoglu/urbanshadow_sdk/blob/main/examples/quickstart.py) for a runnable script.
83
+
84
+ ## Quick start (command line)
85
+
86
+ ```bash
87
+ # Solar position and sun times
88
+ urbanshadow sunpos --lat 41.0082 --lon 28.9784 --time 2026-03-21T12:00 --tz 3
89
+
90
+ # Hourly shadow polygons, 08:00-18:00
91
+ urbanshadow shadows buildings.geojson --date 2026-03-21 --tz Europe/Istanbul \
92
+ --start 08:00 --end 18:00 --step 60 -o shadows.geojson
93
+
94
+ # One dissolved feature per time step (needs shapely)
95
+ urbanshadow shadows buildings.geojson --date 2026-12-21 --time 12:00 --dissolve -o noon.geojson
96
+
97
+ # Sun hours on a 2 m grid (GeoJSON squares, or .csv), mean of a date range
98
+ urbanshadow sunhours buildings.geojson --date 2026-06-01 --last-date 2026-06-30 --average \
99
+ --cell 2 --tz 3 -o june_sunhours.geojson
100
+
101
+ # BRE 209 garden test, proposed vs existing (exit code 1 if the area fails)
102
+ urbanshadow bre proposed.geojson --area garden.geojson --baseline existing.geojson --tz 0
103
+ ```
104
+
105
+ Common options: `--height-field`, `--levels-field` with `--level-height`
106
+ (default 3 m per storey), `--default-height`, `--crs auto|geographic|projected`,
107
+ `--lat/--lon` (required for projected input) and `--grid-rotation`.
108
+
109
+ ## API overview
110
+
111
+ | Function / class | What it does |
112
+ |---|---|
113
+ | `solar_position(when, lat, lon, tz=None, refraction=True)` | NOAA azimuth / elevation / declination / equation of time |
114
+ | `sun_times(day, lat, lon, tz)` | sunrise, solar noon, sunset (polar day/night aware) |
115
+ | `Building(exterior, height, holes=[], base=0)`, `Building.box(...)` | flat-roofed prism in planar metres |
116
+ | `buildings_from_geojson(src, ...)`, `read_buildings(path)` | GeoJSON to buildings (height / storeys fields, lon/lat detection) |
117
+ | `LocalProjection(lon0, lat0)` | WGS84 local tangent-plane projection (forward / inverse) |
118
+ | `building_shadow(b, az, el, z=0)` → `BuildingShadow` | exact shadow: `.parts`, `.contains()`, `.area`, `.to_shapely()`, `.geojson_geometry()` |
119
+ | `cast_shadows(...)`, `union_shadows(shadows)` | all shadows for one sun position; dissolved union (shapely) |
120
+ | `point_in_shadow(x, y, buildings, az, el, z=0)` | pure-Python point query |
121
+ | `shadow_mask(xs, ys, buildings, az, el, z=0)` | vectorised numpy point query |
122
+ | `sun_hours(xs, ys, buildings, lat, lon, day, last_day=None, ...)` | hours of direct sun at points |
123
+ | `sun_hours_grid(...)` → `SunHoursGrid` | ground grid; `.to_geojson()`, `.to_csv()`, `.fraction_at_least(h)` |
124
+ | `facade_sun_hours(...)` → `[FacadePoint]` | sun hours on walls (own building and neighbours as obstacles) |
125
+ | `shadow_fraction(area, buildings, az, el)` | share of an open space in shadow |
126
+ | `sunlit_area_fraction(area, buildings, lat, lon, day, min_hours=2)` | share of an area with at least N hours of sun |
127
+ | `bre_sun_on_ground(area, buildings, lat, lon, baseline_buildings=...)` → `BREResult` | BR 209 test |
128
+ | `Scene` | bundles buildings, location, time zone and map rotation behind the calls above |
129
+
130
+ Conventions: azimuth is measured in degrees clockwise from true north.
131
+ Elevation is the apparent elevation above the horizon, with refraction
132
+ included. Planar coordinates are metres, with x pointing east and y north.
133
+ A naive `datetime` is read in the scene's or the call's `tz`; an aware
134
+ `datetime` is used as it is. `tz` can be an hour offset (`3`, `-5.5`), an
135
+ offset string (`"+03:00"`) or an IANA name (`"Europe/London"`).
136
+
137
+ ## Method and sources
138
+
139
+ **Solar position.** The package uses the equations of the NOAA Global
140
+ Monitoring Laboratory Solar Calculator
141
+ (<https://gml.noaa.gov/grad/solcalc/>, details at
142
+ <https://gml.noaa.gov/grad/solcalc/calcdetails.html>). That calculator
143
+ implements the low-accuracy solar coordinates from Jean Meeus,
144
+ *Astronomical Algorithms*, 2nd ed. (Willmann-Bell, 1998): the Julian Day
145
+ (ch. 7), the obliquity (ch. 22), the solar coordinates (ch. 25) and the
146
+ equation of time (ch. 28). Refraction follows NOAA's piecewise model, and
147
+ sunrise and sunset use a 0.833° solar depression. NOAA describes the method
148
+ as very accurate for 1800–2100 and sunrise/sunset as accurate to about one
149
+ minute within ±72° latitude.
150
+
151
+ The tests check six cases (both hemispheres, solstices, equinoxes, low sun)
152
+ against the functions of the NOAA calculator's own JavaScript (`main.js`), to
153
+ 1e-6°. Sunrise and sunset agree to under 0.01 s. The tests also reproduce
154
+ Meeus Examples 7.a and 25.a. Solar noon differs from NOAA's by about 10 s,
155
+ because NOAA evaluates its second equation-of-time pass 12 hours early and
156
+ urbanshadow evaluates it at noon. Like the NOAA calculator, urbanshadow
157
+ ignores ΔT (TT − UT), which costs less than 0.01°.
158
+
159
+ **Shadow geometry.** A building is a vertical prism over footprint *P*,
160
+ running from height `base` to `height`, on flat ground. *w* is the unit
161
+ vector pointing away from the sun. A receptor at height *z* is shaded when
162
+ *p − s·w* lies inside *P* for some *s* between *L₁* and *L₂*, where
163
+ *L₁ = max(base − z, 0)/tan E* and *L₂ = (height − z)/tan E*. The shadow
164
+ polygon is therefore the Minkowski sum of *P* with the segment
165
+ [*L₁w*, *L₂w*]. That sum is the union of the two translated footprints and
166
+ the quadrilateral swept by each edge (holes included, so courtyards come out
167
+ right). For a convex footprint it reduces to the convex hull of the two
168
+ translated footprints. Point queries test the segment from *p − L₁w* to
169
+ *p − L₂w* against *P*: an end point inside *P*, or a proper crossing of an
170
+ edge. This needs no polygon union, so it runs in pure Python or vectorised
171
+ numpy. The raster approach of Ratti & Richens (2004, *Environment and
172
+ Planning B* 31(5), 743–757) answers the same question on a DEM.
173
+
174
+ **Sun hours.** Each day, or each local time window, is split into
175
+ `step_minutes` intervals (10 minutes by default) and integrated with the
176
+ midpoint rule. An interval counts as sunlit when the apparent sun is above
177
+ `min_elevation` and no prism blocks the ray. The tests compare the result
178
+ with a closed form for the equinox. On that day the sun's profile angle in
179
+ the north–south plane is a constant 90° − φ, so a point at distance *D* north
180
+ of a long east–west wall of height *H* is shaded all day when *D < H·tan φ*.
181
+
182
+ **Facades.** Receptors are placed along every wall and pushed 5 cm outwards.
183
+ A receptor is sunlit when the sun is in front of the wall and no building,
184
+ its own included, blocks the ray. Including the receptor's own building
185
+ handles L- and U-shaped blocks.
186
+
187
+ **BRE 209.** The test follows P. J. Littlefair, *Site layout planning for
188
+ daylight and sunlight: a guide to good practice* (BR 209, 3rd ed., BRE
189
+ Press, 2022, section 3.3, "Gardens and open spaces"). For a garden or
190
+ amenity area to appear adequately sunlit throughout the year, at least half
191
+ of it should receive at least two hours of sunlight on 21 March. If new
192
+ development leaves an existing area failing this, and the area that can get
193
+ two hours of sun on 21 March falls below 0.8 times its former value, the loss
194
+ of sunlight is likely to be noticeable. The 2 h, 50 % and 0.8 values are
195
+ parameters (`BRE_REQUIRED_HOURS`, `BRE_REQUIRED_FRACTION`,
196
+ `BRE_RATIO_THRESHOLD`) and can be overridden. BR 209 is advisory guidance,
197
+ not a regulation, and planning authorities apply it flexibly.
198
+
199
+ **Lon/lat input.** Coordinates are projected to a local tangent plane at the
200
+ layer centre, using the WGS84 meridional and prime-vertical radii of
201
+ curvature (Snyder, *Map Projections: A Working Manual*, USGS PP 1395,
202
+ 1987). In this plane grid north is true north. The scale error is about
203
+ 0.01–0.02 % at 1 km, which suits district-scale studies. Results are written
204
+ back in lon/lat.
205
+
206
+ ## Limitations
207
+
208
+ - Flat ground, flat roofs and vertical walls (LOD1 blocks). Terrain, pitched
209
+ roofs, trees and other non-building obstacles are not modelled.
210
+ Translucent obstacles are treated as opaque.
211
+ - Only direct sun is computed. Diffuse daylight, VSC, APSH and the BRE
212
+ window tests are out of scope.
213
+ - In a projected CRS, map north can differ from true north (meridian
214
+ convergence, up to a few degrees in UTM). Pass `grid_rotation`, the
215
+ clockwise angle from grid north to true north, to correct for it.
216
+ - Without shapely, non-convex shadow polygons are exported as a MultiPolygon
217
+ of overlapping parts. Their union is exact, but they are not dissolved, and
218
+ `include_footprint=False` only affects point queries. Shadow areas of
219
+ non-convex footprints are then estimated on a 200 × 200 grid.
220
+ - Area shares and the BRE test are computed by sampling. Points inside
221
+ buildings are left out. The default spacing gives about 10 000 samples
222
+ per area.
223
+ - Sun hours use wall-clock steps. On a daylight-saving change day the
224
+ intervals follow local clock time.
225
+ - `crs="auto"` treats any layer whose coordinates fit lon/lat ranges and span
226
+ less than 5° as geographic. Pass `crs="projected"` for small local
227
+ engineering grids.
228
+
229
+ ## Development
230
+
231
+ ```bash
232
+ python -m pip install -e ".[dev,shapely]"
233
+ python -m pytest
234
+ python -m ruff check . && python -m ruff format --check src tests
235
+ python -m mypy src
236
+ ```
237
+
238
+ ## Academic citation
239
+
240
+ ```bibtex
241
+ @software{eminoglu_urbanshadow,
242
+ author = {Eminoğlu, Yusuf},
243
+ title = {urbanshadow},
244
+ year = {2026},
245
+ version = {0.1.0},
246
+ url = {https://github.com/YusufEminoglu/urbanshadow_sdk}
247
+ }
248
+ ```
249
+
250
+ ## License
251
+
252
+ MIT © 2026 Yusuf Eminoğlu. See [LICENSE](https://github.com/YusufEminoglu/urbanshadow_sdk/blob/main/LICENSE).
@@ -0,0 +1,41 @@
1
+ """urbanshadow quick start: a small block in Istanbul on the March equinox.
2
+
3
+ Run from a clone: python examples/quickstart.py
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from datetime import date, datetime
9
+
10
+ from urbanshadow import Building, Scene
11
+
12
+ # Planar coordinates in metres (x east, y north); a 20 m slab south of a courtyard
13
+ # garden and a lower block to the east.
14
+ buildings = [
15
+ Building.box(0, 0, 40, 12, 20, id="slab"),
16
+ Building.box(50, 0, 15, 30, 9, id="east"),
17
+ ]
18
+ garden = [(0, 15), (40, 15), (40, 45), (0, 45)]
19
+
20
+ scene = Scene(buildings, latitude=41.0082, longitude=28.9784, tz=3) # Istanbul, UTC+3
21
+
22
+ when = datetime(2026, 3, 21, 12, 0)
23
+ sun = scene.sun(when)
24
+ print(f"Sun at {when:%Y-%m-%d %H:%M}: azimuth {sun.azimuth:.2f}, elevation {sun.elevation:.2f}")
25
+ times = scene.sun_times(date(2026, 3, 21))
26
+ print(f"Sunrise {times.sunrise:%H:%M}, solar noon {times.solar_noon:%H:%M}, sunset {times.sunset:%H:%M}")
27
+
28
+ for s in scene.shadows(when):
29
+ print(f" {s.building.id}: shadow length {s.length:.1f} m, area {s.area:.0f} m2")
30
+
31
+ share = scene.shadow_fraction(garden, when, method="grid", resolution=0.5)
32
+ print(f"Garden in shadow at noon: {share:.0%}")
33
+
34
+ hours = scene.sun_hours([20.0, 20.0], [20.0, 40.0], date(2026, 3, 21))
35
+ print(f"Sun hours on 21 March at (20, 20): {hours[0]:.1f} h, at (20, 40): {hours[1]:.1f} h")
36
+
37
+ result = scene.bre_check(garden, year=2026, resolution=1.0)
38
+ print(result.summary())
39
+
40
+ grid = scene.sun_hours_grid(date(2026, 3, 21), cell_size=2.0, margin=20.0)
41
+ print(f"Grid {grid.hours.shape}: {grid.fraction_at_least(2.0):.0%} of open cells get >= 2 h")