spacematrix 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.
- spacematrix/__init__.py +107 -0
- spacematrix/__main__.py +7 -0
- spacematrix/_shapely.py +35 -0
- spacematrix/_version.py +1 -0
- spacematrix/analysis.py +255 -0
- spacematrix/classify.py +129 -0
- spacematrix/cli.py +254 -0
- spacematrix/compare.py +175 -0
- spacematrix/geometry.py +435 -0
- spacematrix/indicators.py +144 -0
- spacematrix/io.py +246 -0
- spacematrix/models.py +190 -0
- spacematrix/plot.py +124 -0
- spacematrix/py.typed +0 -0
- spacematrix-0.1.0.dist-info/METADATA +239 -0
- spacematrix-0.1.0.dist-info/RECORD +20 -0
- spacematrix-0.1.0.dist-info/WHEEL +5 -0
- spacematrix-0.1.0.dist-info/entry_points.txt +2 -0
- spacematrix-0.1.0.dist-info/licenses/LICENSE +21 -0
- spacematrix-0.1.0.dist-info/top_level.txt +1 -0
spacematrix/__init__.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""spacematrix: urban density indicators after Berghauser Pont & Haupt (2010).
|
|
2
|
+
|
|
3
|
+
FSI, GSI, OSR, L, network density N and tare T at the lot, island, fabric and
|
|
4
|
+
district levels; building-type classification; the Spacemate diagram; and
|
|
5
|
+
comparisons with dwelling density and Turkish TAKS / KAKS.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
from .analysis import SpacematrixResult, aggregate, analyze, measure
|
|
12
|
+
from .classify import (
|
|
13
|
+
DEFAULT_THRESHOLDS,
|
|
14
|
+
THRESHOLDS_VERIFIED,
|
|
15
|
+
BuildingType,
|
|
16
|
+
TypeThresholds,
|
|
17
|
+
classify,
|
|
18
|
+
)
|
|
19
|
+
from .compare import (
|
|
20
|
+
EMSAL_EXEMPT_MAX_SHARE,
|
|
21
|
+
TAKS_KAKS_NOTE,
|
|
22
|
+
TaksKaksEquivalent,
|
|
23
|
+
coverage_percent,
|
|
24
|
+
dwelling_density,
|
|
25
|
+
dwellings_from_fsi,
|
|
26
|
+
fsi_from_dwelling_density,
|
|
27
|
+
fsi_from_kaks,
|
|
28
|
+
kaks_from_fsi,
|
|
29
|
+
population_density,
|
|
30
|
+
taks_kaks_equivalent,
|
|
31
|
+
)
|
|
32
|
+
from .geometry import Polygon, intersection_area, shape_area, split_line_length
|
|
33
|
+
from .indicators import (
|
|
34
|
+
fsi,
|
|
35
|
+
fsi_from_gsi_l,
|
|
36
|
+
fsi_from_gsi_osr,
|
|
37
|
+
grid_network_density,
|
|
38
|
+
grid_tare,
|
|
39
|
+
gsi,
|
|
40
|
+
layers,
|
|
41
|
+
mesh_size,
|
|
42
|
+
network_density,
|
|
43
|
+
osr,
|
|
44
|
+
profile_width,
|
|
45
|
+
tare,
|
|
46
|
+
upscale,
|
|
47
|
+
)
|
|
48
|
+
from .io import read_buildings, read_network, read_zones, write_csv, write_geojson
|
|
49
|
+
from .models import LEVELS, Building, Density, Network, Zone, floors_from_height
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"__version__",
|
|
53
|
+
# models
|
|
54
|
+
"LEVELS",
|
|
55
|
+
"Building",
|
|
56
|
+
"Zone",
|
|
57
|
+
"Network",
|
|
58
|
+
"Density",
|
|
59
|
+
"Polygon",
|
|
60
|
+
"floors_from_height",
|
|
61
|
+
# indicators
|
|
62
|
+
"fsi",
|
|
63
|
+
"gsi",
|
|
64
|
+
"osr",
|
|
65
|
+
"layers",
|
|
66
|
+
"network_density",
|
|
67
|
+
"tare",
|
|
68
|
+
"mesh_size",
|
|
69
|
+
"grid_network_density",
|
|
70
|
+
"grid_tare",
|
|
71
|
+
"profile_width",
|
|
72
|
+
"upscale",
|
|
73
|
+
"fsi_from_gsi_l",
|
|
74
|
+
"fsi_from_gsi_osr",
|
|
75
|
+
# analysis
|
|
76
|
+
"measure",
|
|
77
|
+
"analyze",
|
|
78
|
+
"aggregate",
|
|
79
|
+
"SpacematrixResult",
|
|
80
|
+
# classification
|
|
81
|
+
"classify",
|
|
82
|
+
"TypeThresholds",
|
|
83
|
+
"BuildingType",
|
|
84
|
+
"DEFAULT_THRESHOLDS",
|
|
85
|
+
"THRESHOLDS_VERIFIED",
|
|
86
|
+
# comparison
|
|
87
|
+
"dwelling_density",
|
|
88
|
+
"coverage_percent",
|
|
89
|
+
"dwellings_from_fsi",
|
|
90
|
+
"fsi_from_dwelling_density",
|
|
91
|
+
"population_density",
|
|
92
|
+
"fsi_from_kaks",
|
|
93
|
+
"kaks_from_fsi",
|
|
94
|
+
"taks_kaks_equivalent",
|
|
95
|
+
"TaksKaksEquivalent",
|
|
96
|
+
"EMSAL_EXEMPT_MAX_SHARE",
|
|
97
|
+
"TAKS_KAKS_NOTE",
|
|
98
|
+
# geometry and io
|
|
99
|
+
"shape_area",
|
|
100
|
+
"intersection_area",
|
|
101
|
+
"split_line_length",
|
|
102
|
+
"read_buildings",
|
|
103
|
+
"read_zones",
|
|
104
|
+
"read_network",
|
|
105
|
+
"write_csv",
|
|
106
|
+
"write_geojson",
|
|
107
|
+
]
|
spacematrix/__main__.py
ADDED
spacematrix/_shapely.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Optional bridge to shapely (``pip install "spacematrix[shapely]"``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .geometry import Polygon, Shape, as_polygons
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def to_shapely(shape: Shape) -> Any:
|
|
11
|
+
"""Convert a :class:`Polygon` or multipolygon to a shapely geometry."""
|
|
12
|
+
try:
|
|
13
|
+
from shapely.geometry import MultiPolygon as SMultiPolygon
|
|
14
|
+
from shapely.geometry import Polygon as SPolygon
|
|
15
|
+
except ImportError as exc: # pragma: no cover - exercised only without shapely
|
|
16
|
+
raise ImportError(
|
|
17
|
+
"the shapely backend needs shapely: pip install 'spacematrix[shapely]'"
|
|
18
|
+
) from exc
|
|
19
|
+
polys = [SPolygon(p.exterior, [list(h) for h in p.holes]) for p in as_polygons(shape)]
|
|
20
|
+
return polys[0] if len(polys) == 1 else SMultiPolygon(polys)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def from_shapely(geom: Any) -> tuple[Polygon, ...]:
|
|
24
|
+
"""Convert a shapely Polygon or MultiPolygon to a tuple of :class:`Polygon`."""
|
|
25
|
+
kind = geom.geom_type
|
|
26
|
+
if kind == "Polygon":
|
|
27
|
+
parts = [geom]
|
|
28
|
+
elif kind == "MultiPolygon":
|
|
29
|
+
parts = list(geom.geoms)
|
|
30
|
+
else:
|
|
31
|
+
raise TypeError(f"expected a polygonal geometry, got {kind}")
|
|
32
|
+
return tuple(
|
|
33
|
+
Polygon.from_coords(list(p.exterior.coords), [list(r.coords) for r in p.interiors])
|
|
34
|
+
for p in parts
|
|
35
|
+
)
|
spacematrix/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
spacematrix/analysis.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Measure Spacematrix indicators for lots, islands, fabrics and districts.
|
|
2
|
+
|
|
3
|
+
Levels (Berghauser Pont and Haupt, 2010, ch. "Density and scale"):
|
|
4
|
+
|
|
5
|
+
* **lot** (*l*): a plot; tare does not apply.
|
|
6
|
+
* **island** (*i*): an urban block bounded by the building/property lines along
|
|
7
|
+
the streets; island tare ``T_i`` is the island area not covered by lots
|
|
8
|
+
(internal paths, communal gardens, ...).
|
|
9
|
+
* **fabric** (*f*): islands plus the streets that serve them, bounded by the
|
|
10
|
+
street centrelines; fabric tare ``T_f`` is mostly street space, and network
|
|
11
|
+
density ``N_f`` counts the streets inside the fabric fully and the bounding
|
|
12
|
+
(edge) streets half.
|
|
13
|
+
* **district** (*d*): fabrics plus large non-built areas (parks, water, rail,
|
|
14
|
+
large infrastructure); district tare ``T_d``.
|
|
15
|
+
|
|
16
|
+
Building floor area and footprint are allocated to a zone either by
|
|
17
|
+
**overlap** (default: the share of the footprint inside the zone, so a building
|
|
18
|
+
straddling two lots is split between them) or by **centroid** (the whole
|
|
19
|
+
building goes to the zone containing its footprint centroid).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
26
|
+
|
|
27
|
+
from . import indicators as ind
|
|
28
|
+
from .classify import DEFAULT_THRESHOLDS, TypeThresholds, classify
|
|
29
|
+
from .geometry import bbox, intersection_area, point_in_shape, split_line_length
|
|
30
|
+
from .models import LEVELS, Building, Density, Network, Zone
|
|
31
|
+
|
|
32
|
+
__all__ = ["measure", "analyze", "aggregate", "SpacematrixResult"]
|
|
33
|
+
|
|
34
|
+
_Box = Tuple[float, float, float, float]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _overlaps(a: _Box, b: _Box) -> bool:
|
|
38
|
+
return a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _line_box(line: Sequence[Tuple[float, float]]) -> _Box:
|
|
42
|
+
xs = [p[0] for p in line]
|
|
43
|
+
ys = [p[1] for p in line]
|
|
44
|
+
return min(xs), min(ys), max(xs), max(ys)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def measure(
|
|
48
|
+
zone: Zone,
|
|
49
|
+
buildings: Iterable[Building],
|
|
50
|
+
*,
|
|
51
|
+
level: str = "island",
|
|
52
|
+
allocation: str = "overlap",
|
|
53
|
+
network: Optional[Network] = None,
|
|
54
|
+
lower_level: Optional[Iterable[Zone]] = None,
|
|
55
|
+
backend: str = "python",
|
|
56
|
+
edge_tolerance: float = 0.01,
|
|
57
|
+
thresholds: Optional[TypeThresholds] = DEFAULT_THRESHOLDS,
|
|
58
|
+
) -> Density:
|
|
59
|
+
"""Compute the indicators of one zone.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
zone: The plan area ``A``.
|
|
63
|
+
buildings: Candidate buildings (those that do not touch the zone are ignored).
|
|
64
|
+
level: One of ``"lot"``, ``"island"``, ``"fabric"``, ``"district"``.
|
|
65
|
+
allocation: ``"overlap"`` or ``"centroid"`` (see module docstring).
|
|
66
|
+
network: Street centrelines; network density ``N`` is computed when given.
|
|
67
|
+
lower_level: Zones of the next lower level (lots for an island, islands for a
|
|
68
|
+
fabric, fabrics for a district). Tare is computed from the part of their
|
|
69
|
+
area that falls inside ``zone``.
|
|
70
|
+
backend: ``"python"`` or ``"shapely"`` for polygon overlaps.
|
|
71
|
+
edge_tolerance: Distance (m) within which a street centreline counts as running
|
|
72
|
+
along the zone boundary (an edge street, weighted 1/2 in ``N``).
|
|
73
|
+
thresholds: Type thresholds for :func:`spacematrix.classify`, or ``None`` to skip
|
|
74
|
+
classification. Types are assigned at lot and island level and, when the
|
|
75
|
+
fabric tare is known, at fabric level from the net (island-equivalent)
|
|
76
|
+
values ``FSI / (1 - T)`` and ``GSI / (1 - T)``.
|
|
77
|
+
"""
|
|
78
|
+
if level not in LEVELS:
|
|
79
|
+
raise ValueError(f"level must be one of {LEVELS}")
|
|
80
|
+
if allocation not in ("overlap", "centroid"):
|
|
81
|
+
raise ValueError("allocation must be 'overlap' or 'centroid'")
|
|
82
|
+
area = zone.area
|
|
83
|
+
zbox = bbox(zone.geometry)
|
|
84
|
+
floor = built = 0.0
|
|
85
|
+
dwellings: Optional[float] = None
|
|
86
|
+
count = 0
|
|
87
|
+
for b in buildings:
|
|
88
|
+
if not _overlaps(bbox(b.geometry), zbox):
|
|
89
|
+
continue
|
|
90
|
+
footprint = b.footprint
|
|
91
|
+
if footprint <= 0:
|
|
92
|
+
continue
|
|
93
|
+
if allocation == "centroid":
|
|
94
|
+
share = 1.0 if point_in_shape(b.centroid, zone.geometry) else 0.0
|
|
95
|
+
else:
|
|
96
|
+
share = min(1.0, intersection_area(b.geometry, zone.geometry, backend) / footprint)
|
|
97
|
+
if share <= 1e-12:
|
|
98
|
+
continue
|
|
99
|
+
count += 1
|
|
100
|
+
built += footprint * share
|
|
101
|
+
floor += b.gross_floor_area * share
|
|
102
|
+
if b.dwellings is not None:
|
|
103
|
+
dwellings = (dwellings or 0.0) + b.dwellings * share
|
|
104
|
+
f = ind.fsi(floor, area)
|
|
105
|
+
g = ind.gsi(built, area)
|
|
106
|
+
rec = Density(
|
|
107
|
+
level=level,
|
|
108
|
+
id=zone.id,
|
|
109
|
+
area=area,
|
|
110
|
+
floor_area=floor,
|
|
111
|
+
built_area=built,
|
|
112
|
+
fsi=f,
|
|
113
|
+
gsi=g,
|
|
114
|
+
osr=ind.osr(f, g),
|
|
115
|
+
l=ind.layers(f, g),
|
|
116
|
+
dwellings=dwellings,
|
|
117
|
+
building_count=count,
|
|
118
|
+
)
|
|
119
|
+
if lower_level is not None:
|
|
120
|
+
covered = sum(
|
|
121
|
+
intersection_area(z.geometry, zone.geometry, backend)
|
|
122
|
+
for z in lower_level
|
|
123
|
+
if _overlaps(bbox(z.geometry), zbox)
|
|
124
|
+
)
|
|
125
|
+
rec.lower_level_area = covered
|
|
126
|
+
rec.tare = ind.tare(area, covered)
|
|
127
|
+
if network is not None:
|
|
128
|
+
li = le = 0.0
|
|
129
|
+
for line in network.lines:
|
|
130
|
+
if len(line) < 2 or not _overlaps(_line_box(line), _grow(zbox, edge_tolerance)):
|
|
131
|
+
continue
|
|
132
|
+
a, e = split_line_length(line, zone.geometry, edge_tolerance)
|
|
133
|
+
li += a
|
|
134
|
+
le += e
|
|
135
|
+
rec.network_interior = li
|
|
136
|
+
rec.network_edge = le
|
|
137
|
+
rec.n = ind.network_density(li, le, area)
|
|
138
|
+
if thresholds is not None:
|
|
139
|
+
if level in ("lot", "island"):
|
|
140
|
+
rec.type = classify(f, g, thresholds).label
|
|
141
|
+
elif rec.tare is not None and rec.tare < 1:
|
|
142
|
+
k = 1.0 - rec.tare
|
|
143
|
+
rec.type = classify(f / k, g / k, thresholds).label
|
|
144
|
+
return rec
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _grow(box: _Box, d: float) -> _Box:
|
|
148
|
+
return box[0] - d, box[1] - d, box[2] + d, box[3] + d
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass
|
|
152
|
+
class SpacematrixResult:
|
|
153
|
+
"""Per-level results of :func:`analyze`."""
|
|
154
|
+
|
|
155
|
+
lots: List[Density] = field(default_factory=list)
|
|
156
|
+
islands: List[Density] = field(default_factory=list)
|
|
157
|
+
fabrics: List[Density] = field(default_factory=list)
|
|
158
|
+
districts: List[Density] = field(default_factory=list)
|
|
159
|
+
|
|
160
|
+
def level(self, name: str) -> List[Density]:
|
|
161
|
+
"""Records of one level (``"lot"``, ``"island"``, ``"fabric"``, ``"district"``)."""
|
|
162
|
+
if name not in LEVELS:
|
|
163
|
+
raise ValueError(f"level must be one of {LEVELS}")
|
|
164
|
+
return {
|
|
165
|
+
"lot": self.lots,
|
|
166
|
+
"island": self.islands,
|
|
167
|
+
"fabric": self.fabrics,
|
|
168
|
+
"district": self.districts,
|
|
169
|
+
}[name]
|
|
170
|
+
|
|
171
|
+
def records(self) -> List[Density]:
|
|
172
|
+
"""All records, lots first."""
|
|
173
|
+
return [*self.lots, *self.islands, *self.fabrics, *self.districts]
|
|
174
|
+
|
|
175
|
+
def rows(self) -> List[Dict[str, Any]]:
|
|
176
|
+
"""All records as flat dictionaries."""
|
|
177
|
+
return [r.as_dict() for r in self.records()]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def analyze(
|
|
181
|
+
buildings: Sequence[Building],
|
|
182
|
+
*,
|
|
183
|
+
lots: Optional[Sequence[Zone]] = None,
|
|
184
|
+
islands: Optional[Sequence[Zone]] = None,
|
|
185
|
+
fabrics: Optional[Sequence[Zone]] = None,
|
|
186
|
+
districts: Optional[Sequence[Zone]] = None,
|
|
187
|
+
network: Optional[Network] = None,
|
|
188
|
+
allocation: str = "overlap",
|
|
189
|
+
backend: str = "python",
|
|
190
|
+
edge_tolerance: float = 0.01,
|
|
191
|
+
thresholds: Optional[TypeThresholds] = DEFAULT_THRESHOLDS,
|
|
192
|
+
) -> SpacematrixResult:
|
|
193
|
+
"""Measure every supplied level; tare uses the next lower level that was supplied
|
|
194
|
+
(lots for islands, islands for fabrics, fabrics for districts).
|
|
195
|
+
|
|
196
|
+
Network density is computed for fabrics and districts when ``network`` is given.
|
|
197
|
+
See :func:`measure` for the other arguments.
|
|
198
|
+
"""
|
|
199
|
+
kw: Dict[str, Any] = {
|
|
200
|
+
"allocation": allocation,
|
|
201
|
+
"backend": backend,
|
|
202
|
+
"edge_tolerance": edge_tolerance,
|
|
203
|
+
"thresholds": thresholds,
|
|
204
|
+
}
|
|
205
|
+
res = SpacematrixResult()
|
|
206
|
+
for z in lots or ():
|
|
207
|
+
res.lots.append(measure(z, buildings, level="lot", **kw))
|
|
208
|
+
for z in islands or ():
|
|
209
|
+
res.islands.append(measure(z, buildings, level="island", lower_level=lots, **kw))
|
|
210
|
+
for z in fabrics or ():
|
|
211
|
+
res.fabrics.append(
|
|
212
|
+
measure(z, buildings, level="fabric", lower_level=islands, network=network, **kw)
|
|
213
|
+
)
|
|
214
|
+
for z in districts or ():
|
|
215
|
+
res.districts.append(
|
|
216
|
+
measure(z, buildings, level="district", lower_level=fabrics, network=network, **kw)
|
|
217
|
+
)
|
|
218
|
+
return res
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def aggregate(records: Sequence[Density], level: str = "island", id: Any = "aggregate") -> Density:
|
|
222
|
+
"""Combine several samples of the same level into one (area-weighted).
|
|
223
|
+
|
|
224
|
+
FSI and GSI of the union are ``ΣF / ΣA`` and ``ΣB / ΣA`` (not the mean of the
|
|
225
|
+
ratios). Tare is ``1 - Σ A_y / Σ A_x`` when all records carry it; N is the
|
|
226
|
+
area-weighted mean, which is exact when the samples share no edge streets.
|
|
227
|
+
"""
|
|
228
|
+
if not records:
|
|
229
|
+
raise ValueError("nothing to aggregate")
|
|
230
|
+
area = sum(r.area for r in records)
|
|
231
|
+
floor = sum(r.floor_area for r in records)
|
|
232
|
+
built = sum(r.built_area for r in records)
|
|
233
|
+
f = ind.fsi(floor, area)
|
|
234
|
+
g = ind.gsi(built, area)
|
|
235
|
+
out = Density(
|
|
236
|
+
level=level,
|
|
237
|
+
id=id,
|
|
238
|
+
area=area,
|
|
239
|
+
floor_area=floor,
|
|
240
|
+
built_area=built,
|
|
241
|
+
fsi=f,
|
|
242
|
+
gsi=g,
|
|
243
|
+
osr=ind.osr(f, g),
|
|
244
|
+
l=ind.layers(f, g),
|
|
245
|
+
building_count=sum(r.building_count for r in records),
|
|
246
|
+
)
|
|
247
|
+
if all(r.lower_level_area is not None for r in records):
|
|
248
|
+
lower = sum(r.lower_level_area or 0.0 for r in records)
|
|
249
|
+
out.lower_level_area = lower
|
|
250
|
+
out.tare = ind.tare(area, lower)
|
|
251
|
+
if all(r.n is not None for r in records):
|
|
252
|
+
out.n = sum((r.n or 0.0) * r.area for r in records) / area
|
|
253
|
+
if any(r.dwellings is not None for r in records):
|
|
254
|
+
out.dwellings = sum(r.dwellings or 0.0 for r in records)
|
|
255
|
+
return out
|
spacematrix/classify.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Classification of samples into Spacematrix building types.
|
|
2
|
+
|
|
3
|
+
Berghauser Pont and Haupt (2010) show that samples of the same building type
|
|
4
|
+
cluster in the Spacemate diagram (GSI on the x axis, FSI on the y axis) and
|
|
5
|
+
name nine types by height (low-rise, mid-rise, high-rise) and form (point,
|
|
6
|
+
strip, block). In the Spacemate, height classes are bands between iso-L lines
|
|
7
|
+
(rays ``FSI = L · GSI``) and form classes are, in a first approximation, bands
|
|
8
|
+
of coverage (GSI): detached "point" buildings cover little ground, perimeter
|
|
9
|
+
"block" buildings cover a lot, row/slab "strip" types sit in between.
|
|
10
|
+
|
|
11
|
+
.. warning::
|
|
12
|
+
The numeric boundaries in :data:`DEFAULT_THRESHOLDS` are **approximations**
|
|
13
|
+
chosen for this package. They were **not verified** against the tables or
|
|
14
|
+
figures of the book, which delineates the type clusters graphically from
|
|
15
|
+
Dutch reference samples rather than as fixed cut-offs. Calibrate them to your
|
|
16
|
+
own reference samples (or a cluster analysis on FSI, GSI and N, see
|
|
17
|
+
Berghauser Pont and Olsson, 2017, "Typology based on three density variables
|
|
18
|
+
central to Spacematrix using cluster analysis", 24th ISUF conference,
|
|
19
|
+
Valencia) and pass your own :class:`TypeThresholds`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Optional, Tuple
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"TypeThresholds",
|
|
29
|
+
"BuildingType",
|
|
30
|
+
"DEFAULT_THRESHOLDS",
|
|
31
|
+
"THRESHOLDS_VERIFIED",
|
|
32
|
+
"classify",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
#: The default thresholds have not been checked against the primary source.
|
|
36
|
+
THRESHOLDS_VERIFIED = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class TypeThresholds:
|
|
41
|
+
"""Boundaries used by :func:`classify` (all user-overridable).
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
low_rise_max_l: Samples with ``L`` up to this value are low-rise
|
|
45
|
+
(default 3.5: one to three storeys on average). APPROXIMATION.
|
|
46
|
+
mid_rise_max_l: Samples with ``L`` up to this value (and above the previous
|
|
47
|
+
one) are mid-rise (default 7.5: four to seven storeys). APPROXIMATION.
|
|
48
|
+
point_max_gsi: ``GSI`` below this value is a point (detached) type
|
|
49
|
+
(default 0.20). APPROXIMATION.
|
|
50
|
+
strip_max_gsi: ``GSI`` below this value (and at least ``point_max_gsi``) is a
|
|
51
|
+
strip (row / slab) type; at or above it, a block type (default 0.35).
|
|
52
|
+
APPROXIMATION.
|
|
53
|
+
min_gsi: Samples with less coverage than this are reported as
|
|
54
|
+
``"unbuilt"`` (default 0.0 only rejects empty samples).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
low_rise_max_l: float = 3.5
|
|
58
|
+
mid_rise_max_l: float = 7.5
|
|
59
|
+
point_max_gsi: float = 0.20
|
|
60
|
+
strip_max_gsi: float = 0.35
|
|
61
|
+
min_gsi: float = 0.0
|
|
62
|
+
|
|
63
|
+
def __post_init__(self) -> None:
|
|
64
|
+
if not 0 < self.low_rise_max_l < self.mid_rise_max_l:
|
|
65
|
+
raise ValueError("need 0 < low_rise_max_l < mid_rise_max_l")
|
|
66
|
+
if not 0 < self.point_max_gsi < self.strip_max_gsi <= 1:
|
|
67
|
+
raise ValueError("need 0 < point_max_gsi < strip_max_gsi <= 1")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
DEFAULT_THRESHOLDS = TypeThresholds()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class BuildingType:
|
|
75
|
+
"""Result of :func:`classify`."""
|
|
76
|
+
|
|
77
|
+
height: str # "low-rise" | "mid-rise" | "high-rise" | "unbuilt"
|
|
78
|
+
form: str # "point" | "strip" | "block" | "unbuilt"
|
|
79
|
+
l: Optional[float] # noqa: E741
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def label(self) -> str:
|
|
83
|
+
"""For example ``"mid-rise block"``; ``"unbuilt"`` for empty samples."""
|
|
84
|
+
if self.height == "unbuilt":
|
|
85
|
+
return "unbuilt"
|
|
86
|
+
return f"{self.height} {self.form}"
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def code(self) -> str:
|
|
90
|
+
"""Short code such as ``"MB"`` (mid-rise block); ``"-"`` if unbuilt."""
|
|
91
|
+
if self.height == "unbuilt":
|
|
92
|
+
return "-"
|
|
93
|
+
return self.height[0].upper() + self.form[0].upper()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
HEIGHT_CLASSES: Tuple[str, ...] = ("low-rise", "mid-rise", "high-rise")
|
|
97
|
+
FORM_CLASSES: Tuple[str, ...] = ("point", "strip", "block")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def classify(
|
|
101
|
+
fsi: float, gsi: float, thresholds: TypeThresholds = DEFAULT_THRESHOLDS
|
|
102
|
+
) -> BuildingType:
|
|
103
|
+
"""Classify a sample from its FSI and GSI.
|
|
104
|
+
|
|
105
|
+
Use values measured at the **island** (or lot) level: tare at the fabric and
|
|
106
|
+
district levels lowers GSI and FSI by the same factor, which leaves L
|
|
107
|
+
unchanged but shifts GSI across the form boundaries.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
fsi: Floor space index of the sample.
|
|
111
|
+
gsi: Ground space index of the sample.
|
|
112
|
+
thresholds: Boundaries; see the warning in this module's docstring.
|
|
113
|
+
"""
|
|
114
|
+
if gsi <= thresholds.min_gsi or gsi <= 0 or fsi <= 0:
|
|
115
|
+
return BuildingType("unbuilt", "unbuilt", None)
|
|
116
|
+
l_value = fsi / gsi
|
|
117
|
+
if l_value <= thresholds.low_rise_max_l:
|
|
118
|
+
height = "low-rise"
|
|
119
|
+
elif l_value <= thresholds.mid_rise_max_l:
|
|
120
|
+
height = "mid-rise"
|
|
121
|
+
else:
|
|
122
|
+
height = "high-rise"
|
|
123
|
+
if gsi < thresholds.point_max_gsi:
|
|
124
|
+
form = "point"
|
|
125
|
+
elif gsi < thresholds.strip_max_gsi:
|
|
126
|
+
form = "strip"
|
|
127
|
+
else:
|
|
128
|
+
form = "block"
|
|
129
|
+
return BuildingType(height, form, l_value)
|