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.
@@ -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
+ ]
@@ -0,0 +1,7 @@
1
+ """``python -m spacematrix``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
@@ -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
+ )
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -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
@@ -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)