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 +99 -0
- skyview/__main__.py +7 -0
- skyview/_version.py +1 -0
- skyview/analytic.py +153 -0
- skyview/canyon.py +281 -0
- skyview/cli.py +212 -0
- skyview/io.py +212 -0
- skyview/py.typed +0 -0
- skyview/raster.py +269 -0
- skyview/vector.py +381 -0
- skyview-0.1.0.dist-info/METADATA +259 -0
- skyview-0.1.0.dist-info/RECORD +16 -0
- skyview-0.1.0.dist-info/WHEEL +5 -0
- skyview-0.1.0.dist-info/entry_points.txt +2 -0
- skyview-0.1.0.dist-info/licenses/LICENSE +21 -0
- skyview-0.1.0.dist-info/top_level.txt +1 -0
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
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
|