skyview 0.1.0__py3-none-any.whl

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.
skyview/__init__.py ADDED
@@ -0,0 +1,99 @@
1
+ """skyview: sky view factor, openness and street-canyon metrics.
2
+
3
+ Quick start::
4
+
5
+ import numpy as np
6
+ from skyview import sky_view_factor, canyon_svf, Building, BuildingSet
7
+
8
+ dsm = np.zeros((50, 50))
9
+ dsm[:, 20] = dsm[:, 30] = 10.0 # two walls, 10 m high
10
+ svf = sky_view_factor(dsm, pixel_size=1.0, n_directions=32, max_radius=60)
11
+
12
+ canyon_svf(1.0) # Oke: cos(atan(2)) = 0.447
13
+
14
+ bs = BuildingSet([Building.box(0, 0, 10, 100, 20), Building.box(30, 0, 40, 100, 20)])
15
+ bs.sky_view_factor([20.0], [50.0], n_directions=72, weighting="cosine")
16
+
17
+ For hours of direct sun and building shadows see the companion package
18
+ ``urbanshadow``.
19
+ """
20
+
21
+ from ._version import __version__
22
+ from .analytic import (
23
+ canyon_floor_mean_svf,
24
+ canyon_horizon_angle,
25
+ canyon_svf,
26
+ canyon_wall_mean_svf,
27
+ )
28
+ from .canyon import (
29
+ CanyonStation,
30
+ StreetCanyon,
31
+ lines_from_geojson,
32
+ orientation_entropy,
33
+ orientation_histogram,
34
+ orientation_order,
35
+ segment_bearings,
36
+ street_canyon,
37
+ street_canyons,
38
+ )
39
+ from .io import (
40
+ RasterData,
41
+ canyons_to_geojson,
42
+ points_to_geojson,
43
+ read_geojson,
44
+ read_raster,
45
+ write_geojson,
46
+ write_raster,
47
+ )
48
+ from .raster import (
49
+ elevation_angle_extremes,
50
+ horizon_angles,
51
+ openness,
52
+ ray_offsets,
53
+ sky_view_factor,
54
+ svf_from_horizon,
55
+ )
56
+ from .vector import (
57
+ Building,
58
+ BuildingSet,
59
+ buildings_from_geojson,
60
+ point_grid,
61
+ points_in_buildings,
62
+ rasterize_buildings,
63
+ )
64
+
65
+ __all__ = [
66
+ "Building",
67
+ "BuildingSet",
68
+ "CanyonStation",
69
+ "RasterData",
70
+ "StreetCanyon",
71
+ "__version__",
72
+ "buildings_from_geojson",
73
+ "canyon_floor_mean_svf",
74
+ "canyon_horizon_angle",
75
+ "canyon_svf",
76
+ "canyon_wall_mean_svf",
77
+ "canyons_to_geojson",
78
+ "elevation_angle_extremes",
79
+ "horizon_angles",
80
+ "lines_from_geojson",
81
+ "openness",
82
+ "orientation_entropy",
83
+ "orientation_histogram",
84
+ "orientation_order",
85
+ "point_grid",
86
+ "points_in_buildings",
87
+ "points_to_geojson",
88
+ "rasterize_buildings",
89
+ "ray_offsets",
90
+ "read_geojson",
91
+ "read_raster",
92
+ "segment_bearings",
93
+ "sky_view_factor",
94
+ "street_canyon",
95
+ "street_canyons",
96
+ "svf_from_horizon",
97
+ "write_geojson",
98
+ "write_raster",
99
+ ]
skyview/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """``python -m skyview``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
skyview/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
skyview/analytic.py ADDED
@@ -0,0 +1,153 @@
1
+ """Closed-form sky view factors for the infinite symmetric street canyon.
2
+
3
+ These formulas are the classic validation cases for numerical SVF codes. The
4
+ canyon has two parallel walls of height ``H`` separated by a street of width
5
+ ``W`` and is infinitely long. The aspect ratio is ``H/W``.
6
+
7
+ Two weightings of the sky hemisphere are used in the literature:
8
+
9
+ * **cosine-weighted** (the radiative view factor, used by Oke and by
10
+ Johnson & Watson): each sky element counts with the cosine of its zenith
11
+ angle, as for a horizontal radiation receiver. For a horizon elevation
12
+ ``gamma(phi)`` the factor is the azimuthal mean of ``cos^2 gamma``.
13
+ * **isotropic** (the solid-angle fraction of the hemisphere, as in the
14
+ relief-visualisation SVF of Zakšek et al., 2011): every element of the
15
+ visible sky counts equally. The factor is the azimuthal mean of
16
+ ``1 - sin gamma``.
17
+
18
+ Sources
19
+ -------
20
+ * Oke, T. R. (1981). Canyon geometry and the nocturnal urban heat island.
21
+ *Journal of Climatology* 1, 237-254. Centre-of-floor SVF
22
+ ``psi = cos(arctan(2H/W))``.
23
+ * Oke, T. R. (1987). *Boundary Layer Climates*, 2nd ed., Routledge.
24
+ * Johnson, G. T. and Watson, I. D. (1984). The determination of view-factors
25
+ in urban canyons. *Journal of Climate and Applied Meteorology* 23, 329-335.
26
+ Floor- and wall-averaged factors (equivalent to Hottel's crossed-strings
27
+ rule for a 2D enclosure).
28
+ * Zakšek, K., Oštir, K. and Kokalj, Ž. (2011). Sky-view factor as a relief
29
+ visualization technique. *Remote Sensing* 3(2), 398-415.
30
+ https://doi.org/10.3390/rs3020398
31
+
32
+ All functions in this module are derived in closed form (see each
33
+ docstring); the test suite also checks them against direct numerical
34
+ integration of the horizon profile.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import math
40
+
41
+ __all__ = [
42
+ "WEIGHTINGS",
43
+ "canyon_floor_mean_svf",
44
+ "canyon_horizon_angle",
45
+ "canyon_svf",
46
+ "canyon_wall_mean_svf",
47
+ "check_weighting",
48
+ ]
49
+
50
+ WEIGHTINGS = ("isotropic", "cosine")
51
+
52
+
53
+ def check_weighting(weighting: str) -> str:
54
+ """Validate and normalise a weighting name (``"isotropic"`` or ``"cosine"``)."""
55
+ w = str(weighting).strip().lower()
56
+ if w not in WEIGHTINGS:
57
+ raise ValueError(f"weighting must be one of {WEIGHTINGS}, got {weighting!r}")
58
+ return w
59
+
60
+
61
+ def _check_hw(hw: float) -> float:
62
+ hw = float(hw)
63
+ if not math.isfinite(hw) or hw < 0:
64
+ raise ValueError(f"H/W must be a finite number >= 0, got {hw!r}")
65
+ return hw
66
+
67
+
68
+ def canyon_horizon_angle(hw: float, azimuth_from_axis_deg: float, position: float = 0.5) -> float:
69
+ """Horizon elevation angle (degrees) seen from a point on the canyon floor.
70
+
71
+ Parameters
72
+ ----------
73
+ hw:
74
+ Aspect ratio ``H/W``.
75
+ azimuth_from_axis_deg:
76
+ Viewing azimuth measured from the canyon axis (0 = along the street).
77
+ position:
78
+ Cross-street position of the observer as a fraction of ``W`` measured
79
+ from the left wall (0.5 = centre line). Directions with
80
+ ``0 < azimuth < 180`` look towards the right wall.
81
+
82
+ For a direction that makes angle ``phi`` with the axis, the wall at
83
+ horizontal distance ``d`` across the street is met after ``d / |sin phi|``
84
+ metres, so ``tan gamma = H |sin phi| / d``.
85
+ """
86
+ hw = _check_hw(hw)
87
+ p = float(position)
88
+ if not 0.0 < p < 1.0:
89
+ raise ValueError("position must lie strictly between 0 and 1")
90
+ s = math.sin(math.radians(azimuth_from_axis_deg))
91
+ if abs(s) < 1e-15:
92
+ return 0.0
93
+ d = (1.0 - p) if s > 0 else p # in units of W
94
+ return math.degrees(math.atan(hw * abs(s) / d))
95
+
96
+
97
+ def canyon_svf(hw: float, weighting: str = "cosine", position: float = 0.5) -> float:
98
+ """SVF of a point on the floor of an infinite symmetric canyon.
99
+
100
+ Parameters
101
+ ----------
102
+ hw:
103
+ Aspect ratio ``H/W`` (0 gives open ground, SVF = 1).
104
+ weighting:
105
+ ``"cosine"`` (radiative view factor, Oke 1981) or ``"isotropic"``
106
+ (solid-angle fraction, Zakšek et al. 2011).
107
+ position:
108
+ Cross-street position as a fraction of ``W`` from one wall
109
+ (0.5 = centre line, Oke's case).
110
+
111
+ Formulas (``a = position * W`` and ``b = W - a`` are the distances to
112
+ the two walls):
113
+
114
+ * cosine: ``(sin alpha_a + sin alpha_b) / 2`` with
115
+ ``sin alpha_a = a / sqrt(a^2 + H^2)``. At the centre line this is
116
+ Oke's ``cos(arctan(2H/W))``.
117
+ * isotropic: ``1 - (arctan(H/a) + arctan(H/b)) / pi``. At the centre line
118
+ this is ``1 - (2/pi) arctan(2H/W)``. It follows from averaging
119
+ ``sin gamma`` over azimuth: ``(1/pi) * int_0^pi k s / sqrt(1 + k^2 s^2)
120
+ dphi = (2/pi) arctan(k)`` with ``s = sin phi``.
121
+ """
122
+ hw = _check_hw(hw)
123
+ w = check_weighting(weighting)
124
+ p = float(position)
125
+ if not 0.0 < p < 1.0:
126
+ raise ValueError("position must lie strictly between 0 and 1")
127
+ a, b = p, 1.0 - p # distances to the walls in units of W
128
+ if w == "cosine":
129
+ return 0.5 * (a / math.hypot(a, hw) + b / math.hypot(b, hw))
130
+ return 1.0 - (math.atan2(hw, a) + math.atan2(hw, b)) / math.pi
131
+
132
+
133
+ def canyon_floor_mean_svf(hw: float) -> float:
134
+ """Cosine-weighted SVF averaged over the whole canyon floor.
135
+
136
+ ``psi_floor = sqrt(1 + (H/W)^2) - H/W`` (Johnson & Watson, 1984; this is
137
+ Hottel's crossed-strings rule for the floor seeing the canyon opening).
138
+ """
139
+ hw = _check_hw(hw)
140
+ return math.sqrt(1.0 + hw * hw) - hw
141
+
142
+
143
+ def canyon_wall_mean_svf(hw: float) -> float:
144
+ """Cosine-weighted SVF averaged over one canyon wall.
145
+
146
+ ``psi_wall = (1 + W/H - sqrt(1 + (W/H)^2)) / 2`` (Johnson & Watson, 1984;
147
+ crossed strings). Tends to 0.5 for an isolated wall (``H/W -> 0``).
148
+ """
149
+ hw = _check_hw(hw)
150
+ if hw == 0.0:
151
+ return 0.5
152
+ wh = 1.0 / hw
153
+ return 0.5 * (1.0 + wh - math.sqrt(1.0 + wh * wh))
skyview/canyon.py ADDED
@@ -0,0 +1,281 @@
1
+ """Street-canyon geometry from building footprints and street centrelines.
2
+
3
+ For each street centreline, stations are placed every ``spacing`` metres.
4
+ At each station two rays are cast perpendicular to the street, one to each
5
+ side, and the first building edge they cross (within ``max_distance``)
6
+ gives the distance to the building line and the height of that building.
7
+ Where both sides are enclosed the station has a canyon width
8
+ ``W = d_left + d_right``, a mean height ``H = (h_left + h_right) / 2`` and an
9
+ aspect ratio ``H/W`` (Oke, 1988, *Energy and Buildings* 11, 103-113, uses
10
+ ``H/W`` as the primary canyon descriptor).
11
+
12
+ Street orientation statistics follow Boeing, G. (2019). Urban spatial order:
13
+ street network orientation, configuration, and entropy. *Applied Network
14
+ Science* 4, 67. https://doi.org/10.1007/s41109-019-0189-1 : bearings are
15
+ binned into 36 bins of 10 degrees centred on 0, 10, ..., 350, each segment
16
+ counting both in its bearing and the reverse bearing, the Shannon entropy
17
+ ``H_o = -sum P_i ln P_i`` is computed and the orientation order is
18
+ ``phi = 1 - ((H_o - H_g) / (H_max - H_g))^2`` with ``H_max = ln 36`` and
19
+ ``H_g = ln 4`` (a perfect four-way grid).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import math
25
+ from dataclasses import asdict, dataclass
26
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
27
+
28
+ import numpy as np
29
+ from numpy.typing import ArrayLike
30
+
31
+ from .analytic import canyon_svf
32
+ from .vector import Building, BuildingSet
33
+
34
+ __all__ = [
35
+ "CanyonStation",
36
+ "StreetCanyon",
37
+ "lines_from_geojson",
38
+ "orientation_entropy",
39
+ "orientation_histogram",
40
+ "orientation_order",
41
+ "segment_bearings",
42
+ "street_canyon",
43
+ "street_canyons",
44
+ ]
45
+
46
+ Line = List[Tuple[float, float]]
47
+
48
+
49
+ @dataclass
50
+ class CanyonStation:
51
+ """Measurements at one station along a street."""
52
+
53
+ x: float
54
+ y: float
55
+ left_distance: Optional[float]
56
+ right_distance: Optional[float]
57
+ left_height: Optional[float]
58
+ right_height: Optional[float]
59
+
60
+ @property
61
+ def enclosed(self) -> bool:
62
+ return self.left_distance is not None and self.right_distance is not None
63
+
64
+ @property
65
+ def width(self) -> Optional[float]:
66
+ if self.left_distance is None or self.right_distance is None:
67
+ return None
68
+ return self.left_distance + self.right_distance
69
+
70
+ @property
71
+ def height(self) -> Optional[float]:
72
+ if self.left_height is None or self.right_height is None:
73
+ return None
74
+ return 0.5 * (self.left_height + self.right_height)
75
+
76
+ @property
77
+ def hw(self) -> Optional[float]:
78
+ w = self.width
79
+ h = self.height
80
+ if w is None or h is None or w <= 0:
81
+ return None
82
+ return h / w
83
+
84
+
85
+ @dataclass
86
+ class StreetCanyon:
87
+ """Summary of the canyon along one street centreline.
88
+
89
+ ``hw_mean`` is the mean of station ``H/W`` values over enclosed stations;
90
+ ``svf_oke`` is Oke's centre-line cosine SVF for that mean aspect ratio
91
+ (an idealised estimate that assumes an infinite symmetric canyon).
92
+ """
93
+
94
+ length: float
95
+ bearing: float
96
+ n_stations: int
97
+ enclosure: float
98
+ width_mean: Optional[float]
99
+ height_mean: Optional[float]
100
+ hw_mean: Optional[float]
101
+ hw_median: Optional[float]
102
+ svf_oke: Optional[float]
103
+ stations: List[CanyonStation]
104
+
105
+ def summary(self) -> Dict[str, Any]:
106
+ """Plain dict of the summary values (no station list)."""
107
+ d = asdict(self)
108
+ d.pop("stations")
109
+ return d
110
+
111
+
112
+ def _clean_line(coords: Iterable[Sequence[float]]) -> Line:
113
+ pts: Line = []
114
+ for c in coords:
115
+ p = (float(c[0]), float(c[1]))
116
+ if not pts or p != pts[-1]:
117
+ pts.append(p)
118
+ if len(pts) < 2:
119
+ raise ValueError("a line needs at least 2 distinct vertices")
120
+ return pts
121
+
122
+
123
+ def lines_from_geojson(data: Dict[str, Any]) -> List[Tuple[Line, Dict[str, Any]]]:
124
+ """Read LineString / MultiLineString features as ``(coords, properties)`` pairs."""
125
+ if data.get("type") == "Feature":
126
+ features = [data]
127
+ elif data.get("type") == "FeatureCollection":
128
+ features = list(data.get("features") or [])
129
+ else:
130
+ raise ValueError("expected a GeoJSON Feature or FeatureCollection")
131
+ out: List[Tuple[Line, Dict[str, Any]]] = []
132
+ for feat in features:
133
+ geom = feat.get("geometry") or {}
134
+ props = dict(feat.get("properties") or {})
135
+ if geom.get("type") == "LineString":
136
+ out.append((_clean_line(geom["coordinates"]), props))
137
+ elif geom.get("type") == "MultiLineString":
138
+ for part in geom["coordinates"]:
139
+ out.append((_clean_line(part), props))
140
+ return out
141
+
142
+
143
+ def segment_bearings(line: Sequence[Sequence[float]]) -> Tuple[np.ndarray, np.ndarray]:
144
+ """Bearing (degrees clockwise from north, 0..360) and length of each segment."""
145
+ a = np.asarray(line, dtype=np.float64)
146
+ d = np.diff(a[:, :2], axis=0)
147
+ lengths = np.hypot(d[:, 0], d[:, 1])
148
+ bearings = np.degrees(np.arctan2(d[:, 0], d[:, 1])) % 360.0
149
+ keep = lengths > 0
150
+ return bearings[keep], lengths[keep]
151
+
152
+
153
+ def _axis_bearing(line: Line) -> float:
154
+ """Length-weighted mean axial bearing (0..180) using doubled angles."""
155
+ b, ln = segment_bearings(line)
156
+ th = np.radians(2.0 * b)
157
+ c = float((ln * np.cos(th)).sum())
158
+ s = float((ln * np.sin(th)).sum())
159
+ return (math.degrees(math.atan2(s, c)) / 2.0) % 180.0
160
+
161
+
162
+ def street_canyon(
163
+ line: Sequence[Sequence[float]],
164
+ buildings: Union[BuildingSet, Iterable[Building]],
165
+ spacing: float = 10.0,
166
+ max_distance: float = 50.0,
167
+ ) -> StreetCanyon:
168
+ """Measure the street canyon along one centreline.
169
+
170
+ Parameters
171
+ ----------
172
+ line:
173
+ Centreline vertices ``[(x, y), ...]`` in metres.
174
+ buildings:
175
+ Footprints with heights.
176
+ spacing:
177
+ Station spacing in metres; the first station is at ``spacing / 2``.
178
+ max_distance:
179
+ Search distance to each side in metres; beyond it a side counts as
180
+ open.
181
+ """
182
+ bs = buildings if isinstance(buildings, BuildingSet) else BuildingSet(buildings)
183
+ if spacing <= 0 or max_distance <= 0:
184
+ raise ValueError("spacing and max_distance must be positive")
185
+ pts = np.asarray(_clean_line(line), dtype=np.float64)
186
+ seg = np.diff(pts, axis=0)
187
+ seg_len = np.hypot(seg[:, 0], seg[:, 1])
188
+ total = float(seg_len.sum())
189
+ cum = np.concatenate([[0.0], np.cumsum(seg_len)])
190
+ n = max(1, int(math.floor(total / spacing)))
191
+ pos = (np.arange(n) + 0.5) * (total / n)
192
+ stations: List[CanyonStation] = []
193
+ for p in pos:
194
+ k = int(min(np.searchsorted(cum, p, side="right") - 1, len(seg_len) - 1))
195
+ f = (p - cum[k]) / seg_len[k]
196
+ x = float(pts[k, 0] + f * seg[k, 0])
197
+ y = float(pts[k, 1] + f * seg[k, 1])
198
+ tx, ty = seg[k] / seg_len[k]
199
+ # left normal = rotate tangent by +90 degrees
200
+ ux = np.array([-ty, ty])
201
+ uy = np.array([tx, -tx])
202
+ vals: List[Tuple[Optional[float], Optional[float]]] = []
203
+ if bs.ax.size:
204
+ t, valid = bs.ray_hits(x, y, ux, uy, max_distance)
205
+ for side in range(2):
206
+ v = valid[side]
207
+ if v.any():
208
+ j = int(np.argmin(np.where(v, t[side], np.inf)))
209
+ vals.append((float(t[side, j]), float(bs.edge_height[j])))
210
+ else:
211
+ vals.append((None, None))
212
+ else:
213
+ vals = [(None, None), (None, None)]
214
+ stations.append(CanyonStation(x, y, vals[0][0], vals[1][0], vals[0][1], vals[1][1]))
215
+ enclosed = [s for s in stations if s.enclosed]
216
+ widths = [s.width for s in enclosed if s.width is not None]
217
+ heights = [s.height for s in enclosed if s.height is not None]
218
+ hws = [s.hw for s in enclosed if s.hw is not None]
219
+ hw_mean = float(np.mean(hws)) if hws else None
220
+ return StreetCanyon(
221
+ length=total,
222
+ bearing=_axis_bearing(_clean_line(line)),
223
+ n_stations=len(stations),
224
+ enclosure=len(enclosed) / len(stations),
225
+ width_mean=float(np.mean(widths)) if widths else None,
226
+ height_mean=float(np.mean(heights)) if heights else None,
227
+ hw_mean=hw_mean,
228
+ hw_median=float(np.median(hws)) if hws else None,
229
+ svf_oke=canyon_svf(hw_mean, "cosine") if hw_mean is not None else None,
230
+ stations=stations,
231
+ )
232
+
233
+
234
+ def street_canyons(
235
+ lines: Iterable[Sequence[Sequence[float]]],
236
+ buildings: Union[BuildingSet, Iterable[Building]],
237
+ spacing: float = 10.0,
238
+ max_distance: float = 50.0,
239
+ ) -> List[StreetCanyon]:
240
+ """:func:`street_canyon` for many centrelines sharing one building set."""
241
+ bs = buildings if isinstance(buildings, BuildingSet) else BuildingSet(buildings)
242
+ return [street_canyon(ln, bs, spacing, max_distance) for ln in lines]
243
+
244
+
245
+ def orientation_histogram(
246
+ lines: Iterable[Sequence[Sequence[float]]], n_bins: int = 36, weight_by_length: bool = True
247
+ ) -> Tuple[np.ndarray, np.ndarray]:
248
+ """Bidirectional bearing histogram of street segments (Boeing, 2019).
249
+
250
+ Returns ``(bin_centres_deg, counts)``. Bins are centred on
251
+ ``0, 360/n_bins, ...``; each segment counts in its bearing and in the
252
+ reverse bearing, weighted by length unless ``weight_by_length=False``.
253
+ """
254
+ if n_bins < 1:
255
+ raise ValueError("n_bins must be >= 1")
256
+ width = 360.0 / n_bins
257
+ counts = np.zeros(n_bins)
258
+ for line in lines:
259
+ b, ln = segment_bearings(line)
260
+ w = ln if weight_by_length else np.ones_like(ln)
261
+ for bb in (b, (b + 180.0) % 360.0):
262
+ idx = np.floor(((bb + width / 2.0) % 360.0) / width).astype(np.int64) % n_bins
263
+ np.add.at(counts, idx, w)
264
+ return np.arange(n_bins) * width, counts
265
+
266
+
267
+ def orientation_entropy(counts: ArrayLike) -> float:
268
+ """Shannon entropy ``-sum P ln P`` (nats) of a bearing histogram."""
269
+ c = np.asarray(counts, dtype=np.float64)
270
+ tot = c.sum()
271
+ if tot <= 0:
272
+ raise ValueError("histogram is empty")
273
+ p = c[c > 0] / tot
274
+ return float(-(p * np.log(p)).sum())
275
+
276
+
277
+ def orientation_order(entropy: float, n_bins: int = 36) -> float:
278
+ """Boeing's (2019) orientation order ``phi`` (1 = perfect grid, ~0 = disorder)."""
279
+ h_max = math.log(n_bins)
280
+ h_g = math.log(4)
281
+ return 1.0 - ((entropy - h_g) / (h_max - h_g)) ** 2