odflows 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.
odflows/clustering.py ADDED
@@ -0,0 +1,286 @@
1
+ """Flow similarity, clustering and bundling (pure numpy).
2
+
3
+ A flow is treated as a point ``(x_o, y_o, x_d, y_d)`` in four dimensions. Following
4
+ Tao & Thill (2016, "Spatial cluster detection in spatial flow data", Geographical
5
+ Analysis 48(4), 355-372), the distance between flows ``i`` and ``j`` is
6
+
7
+ .. math:: FD_{ij} = \\sqrt{\\alpha\\,d_O^2 + \\beta\\,d_D^2}
8
+
9
+ with ``d_O`` / ``d_D`` the distances between their origins / destinations and
10
+ ``alpha + beta = 2`` (``alpha = beta = 1`` weighs both ends equally). The
11
+ *flow dissimilarity*
12
+
13
+ .. math:: FDS_{ij} = \\sqrt{\\frac{\\alpha\\,d_O^2 + \\beta\\,d_D^2}{L_i L_j}}
14
+
15
+ divides by the flow lengths ``L``, so long flows may differ more in absolute terms and
16
+ still count as similar. Coordinates must be planar (projected); longitude/latitude
17
+ degrees are not a distance unit.
18
+
19
+ Clustering methods work on any precomputed distance matrix:
20
+
21
+ * :func:`hierarchical_clusters` - agglomerative clustering with single, complete or
22
+ average linkage (Lance & Williams 1967 update formula), cut by distance or count;
23
+ * :func:`flow_dbscan` - DBSCAN (Ester et al. 1996) applied to flows, optionally
24
+ weighting each flow by its volume when counting neighbours;
25
+ * :func:`bundle_flows` - one representative, volume-weighted flow per cluster
26
+ (a simple form of flow bundling / generalisation).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union
32
+
33
+ import numpy as np
34
+
35
+ from .errors import ODFlowsError
36
+ from .matrix import ODMatrix
37
+
38
+
39
+ class FlowSet:
40
+ """Flows with endpoint coordinates: the input to the clustering functions.
41
+
42
+ Attributes: ``origins``, ``destinations`` (zone ids), ``coords`` (``n x 4`` array
43
+ ``x_o, y_o, x_d, y_d``) and ``weights`` (flow volumes).
44
+ """
45
+
46
+ __slots__ = ("origins", "destinations", "coords", "weights")
47
+
48
+ def __init__(
49
+ self,
50
+ origins: Sequence[str],
51
+ destinations: Sequence[str],
52
+ coords: object,
53
+ weights: object,
54
+ ) -> None:
55
+ self.origins = [str(o) for o in origins]
56
+ self.destinations = [str(d) for d in destinations]
57
+ self.coords = np.asarray(coords, dtype=float).reshape(-1, 4)
58
+ self.weights = np.asarray(weights, dtype=float).reshape(-1)
59
+ n = len(self.origins)
60
+ if not (len(self.destinations) == n == len(self.coords) == len(self.weights)):
61
+ raise ODFlowsError("origins, destinations, coords and weights differ in length")
62
+
63
+ def __len__(self) -> int:
64
+ return len(self.origins)
65
+
66
+ @classmethod
67
+ def from_od(
68
+ cls,
69
+ od: ODMatrix,
70
+ centroids: Mapping[str, Sequence[float]],
71
+ min_flow: float = 0.0,
72
+ ) -> FlowSet:
73
+ """All positive off-diagonal flows ``>= min_flow`` with their centroid coordinates."""
74
+ o_ids: List[str] = []
75
+ d_ids: List[str] = []
76
+ coords: List[Tuple[float, float, float, float]] = []
77
+ w: List[float] = []
78
+ for o, d, f in od.iter_flows(include_intrazonal=False):
79
+ if f < min_flow:
80
+ continue
81
+ if o not in centroids or d not in centroids:
82
+ raise ODFlowsError(f"no centroid for {o if o not in centroids else d!r}")
83
+ a, b = centroids[o], centroids[d]
84
+ o_ids.append(o)
85
+ d_ids.append(d)
86
+ coords.append((float(a[0]), float(a[1]), float(b[0]), float(b[1])))
87
+ w.append(f)
88
+ return cls(o_ids, d_ids, np.array(coords).reshape(-1, 4), w)
89
+
90
+ @property
91
+ def lengths(self) -> np.ndarray:
92
+ """Planar length of every flow."""
93
+ c = self.coords
94
+ return np.hypot(c[:, 2] - c[:, 0], c[:, 3] - c[:, 1])
95
+
96
+
97
+ def _check_ab(alpha: float, beta: float) -> None:
98
+ if alpha < 0 or beta < 0 or not np.isclose(alpha + beta, 2.0):
99
+ raise ODFlowsError("alpha and beta must be non-negative and sum to 2")
100
+
101
+
102
+ def flow_distance_matrix(
103
+ flows: FlowSet,
104
+ alpha: float = 1.0,
105
+ beta: float = 1.0,
106
+ normalized: bool = False,
107
+ ) -> np.ndarray:
108
+ """Pairwise Tao & Thill flow distance (``normalized=False``) or dissimilarity.
109
+
110
+ The dissimilarity is undefined for zero-length flows and is returned as ``inf``
111
+ there (except on the diagonal, which is always 0).
112
+ """
113
+ _check_ab(alpha, beta)
114
+ c = flows.coords
115
+ do2 = (c[:, None, 0] - c[None, :, 0]) ** 2 + (c[:, None, 1] - c[None, :, 1]) ** 2
116
+ dd2 = (c[:, None, 2] - c[None, :, 2]) ** 2 + (c[:, None, 3] - c[None, :, 3]) ** 2
117
+ sq = alpha * do2 + beta * dd2
118
+ if not normalized:
119
+ return np.sqrt(sq)
120
+ ll = np.outer(flows.lengths, flows.lengths)
121
+ with np.errstate(divide="ignore", invalid="ignore"):
122
+ out = np.where(ll > 0, np.sqrt(sq / np.where(ll > 0, ll, 1.0)), np.inf)
123
+ np.fill_diagonal(out, 0.0)
124
+ return out
125
+
126
+
127
+ def flow_neighbour_counts(
128
+ distances: np.ndarray, radius: float, weights: Optional[np.ndarray] = None
129
+ ) -> np.ndarray:
130
+ """Number (or total weight) of *other* flows within ``radius`` of each flow.
131
+
132
+ The local counting term behind Tao & Thill's flow K-function; no edge correction or
133
+ significance test is applied.
134
+ """
135
+ d = np.asarray(distances, dtype=float)
136
+ within = d <= radius
137
+ np.fill_diagonal(within, False)
138
+ if weights is None:
139
+ return within.sum(axis=1).astype(float)
140
+ return within.astype(float) @ np.asarray(weights, dtype=float)
141
+
142
+
143
+ def hierarchical_clusters(
144
+ distances: np.ndarray,
145
+ n_clusters: Optional[int] = None,
146
+ threshold: Optional[float] = None,
147
+ linkage: str = "average",
148
+ ) -> np.ndarray:
149
+ """Agglomerative clustering of a precomputed distance matrix.
150
+
151
+ Exactly one of ``n_clusters`` (stop when that many clusters remain) or ``threshold``
152
+ (stop when the closest pair is farther apart than this) must be given. ``linkage``
153
+ is ``"single"``, ``"complete"`` or ``"average"`` (UPGMA), updated with the
154
+ Lance-Williams formula. Returns labels ``0..k-1`` numbered by first appearance.
155
+ O(n^3) time - intended for up to a few thousand flows.
156
+ """
157
+ if (n_clusters is None) == (threshold is None):
158
+ raise ODFlowsError("give exactly one of n_clusters or threshold")
159
+ if linkage not in ("single", "complete", "average"):
160
+ raise ODFlowsError(f"unknown linkage {linkage!r}")
161
+ D = np.array(distances, dtype=float)
162
+ n = D.shape[0]
163
+ if D.shape != (n, n):
164
+ raise ODFlowsError("distances must be a square matrix")
165
+ if n == 0:
166
+ return np.zeros(0, dtype=int)
167
+ target = max(1, n_clusters) if n_clusters is not None else 1
168
+ np.fill_diagonal(D, np.inf)
169
+ active = np.ones(n, dtype=bool)
170
+ size = np.ones(n)
171
+ label = np.arange(n)
172
+ k = n
173
+ while k > target:
174
+ sub = np.where(active[:, None] & active[None, :], D, np.inf)
175
+ flat = int(np.argmin(sub))
176
+ a, b = divmod(flat, n)
177
+ if a > b:
178
+ a, b = b, a
179
+ dist = sub[a, b]
180
+ if not np.isfinite(dist) or (threshold is not None and dist > threshold):
181
+ break
182
+ if linkage == "single":
183
+ new = np.minimum(D[a], D[b])
184
+ elif linkage == "complete":
185
+ new = np.maximum(D[a], D[b])
186
+ else:
187
+ new = (size[a] * D[a] + size[b] * D[b]) / (size[a] + size[b])
188
+ D[a, :] = new
189
+ D[:, a] = new
190
+ D[a, a] = np.inf
191
+ active[b] = False
192
+ D[b, :] = np.inf
193
+ D[:, b] = np.inf
194
+ size[a] += size[b]
195
+ label[label == b] = a
196
+ k -= 1
197
+ _, out = np.unique(label, return_inverse=True)
198
+ # renumber by first appearance for stable, readable labels
199
+ order: Dict[int, int] = {}
200
+ for v in out:
201
+ order.setdefault(int(v), len(order))
202
+ return np.array([order[int(v)] for v in out], dtype=int)
203
+
204
+
205
+ def flow_dbscan(
206
+ distances: np.ndarray,
207
+ eps: float,
208
+ min_samples: float = 2,
209
+ weights: Optional[np.ndarray] = None,
210
+ ) -> np.ndarray:
211
+ """DBSCAN (Ester et al. 1996) on a precomputed flow distance matrix.
212
+
213
+ A flow is a *core* flow when the flows within ``eps`` (itself included) number at
214
+ least ``min_samples`` - or, when ``weights`` are given, carry at least that much
215
+ volume (so one huge flow can be a cluster on its own). Clusters are grown from core
216
+ flows; non-core flows reachable from a core flow are border members; the rest are
217
+ noise, labelled ``-1``. Labels ``0..k-1`` follow the order of discovery.
218
+ """
219
+ d = np.asarray(distances, dtype=float)
220
+ n = d.shape[0]
221
+ neigh = d <= eps
222
+ w = np.ones(n) if weights is None else np.asarray(weights, dtype=float)
223
+ core = (neigh.astype(float) @ w) >= min_samples
224
+ labels = np.full(n, -1, dtype=int)
225
+ cluster = 0
226
+ for i in range(n):
227
+ if labels[i] != -1 or not core[i]:
228
+ continue
229
+ labels[i] = cluster
230
+ stack = [i]
231
+ while stack:
232
+ p = stack.pop()
233
+ if not core[p]:
234
+ continue
235
+ for q in np.flatnonzero(neigh[p]):
236
+ if labels[q] == -1:
237
+ labels[q] = cluster
238
+ stack.append(int(q))
239
+ cluster += 1
240
+ return labels
241
+
242
+
243
+ def bundle_flows(
244
+ flows: FlowSet, labels: Union[Sequence[int], np.ndarray], include_noise: bool = False
245
+ ) -> Dict[str, Any]:
246
+ """One representative flow per cluster as a GeoJSON FeatureCollection.
247
+
248
+ The representative runs from the volume-weighted mean origin to the volume-weighted
249
+ mean destination of the cluster's member flows and carries their total ``flow``,
250
+ ``n_flows`` and the member ``origins`` / ``destinations``. Noise (label ``-1``) is
251
+ skipped unless ``include_noise``, in which case each noise flow is its own bundle.
252
+ """
253
+ lab = np.asarray(labels, dtype=int)
254
+ if len(lab) != len(flows):
255
+ raise ODFlowsError("labels and flows differ in length")
256
+ groups: Dict[str, List[int]] = {}
257
+ for i, v in enumerate(lab):
258
+ if v < 0:
259
+ if include_noise:
260
+ groups[f"noise-{i}"] = [i]
261
+ continue
262
+ groups.setdefault(str(int(v)), []).append(i)
263
+ feats: List[Dict[str, Any]] = []
264
+ for key, idx in groups.items():
265
+ w = flows.weights[idx]
266
+ c = flows.coords[idx]
267
+ ww = w if w.sum() > 0 else np.ones_like(w)
268
+ mean = (c * ww[:, None]).sum(axis=0) / ww.sum()
269
+ feats.append(
270
+ {
271
+ "type": "Feature",
272
+ "geometry": {
273
+ "type": "LineString",
274
+ "coordinates": [[mean[0], mean[1]], [mean[2], mean[3]]],
275
+ },
276
+ "properties": {
277
+ "cluster": key,
278
+ "flow": float(w.sum()),
279
+ "n_flows": len(idx),
280
+ "origins": sorted({flows.origins[i] for i in idx}),
281
+ "destinations": sorted({flows.destinations[i] for i in idx}),
282
+ },
283
+ }
284
+ )
285
+ feats.sort(key=lambda f: f["properties"]["flow"])
286
+ return {"type": "FeatureCollection", "features": feats}
odflows/errors.py ADDED
@@ -0,0 +1,11 @@
1
+ """Exception hierarchy."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ODFlowsError(ValueError):
7
+ """Raised for invalid input data or parameters."""
8
+
9
+
10
+ class MissingDependencyError(ImportError):
11
+ """Raised when an optional dependency (pandas, shapely) is needed but not installed."""
odflows/filtering.py ADDED
@@ -0,0 +1,131 @@
1
+ """Flow filtering: keep the flows that matter for a map or an analysis.
2
+
3
+ Every filter returns a new :class:`~odflows.ODMatrix` with the same zones in which the
4
+ discarded flows are set to zero, so filters can be chained.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+ from .errors import ODFlowsError
12
+ from .matrix import ODMatrix
13
+
14
+
15
+ def _candidate_mask(od: ODMatrix, include_intrazonal: bool) -> np.ndarray:
16
+ mask = od.values > 0
17
+ if not include_intrazonal:
18
+ np.fill_diagonal(mask, False)
19
+ return mask
20
+
21
+
22
+ def _apply(od: ODMatrix, keep: np.ndarray, include_intrazonal: bool) -> ODMatrix:
23
+ arr = np.where(keep, od.values, 0.0)
24
+ if not include_intrazonal:
25
+ # Intrazonal flows are left untouched by filters that ignore them.
26
+ np.fill_diagonal(arr, np.diag(od.values))
27
+ return od.with_values(arr)
28
+
29
+
30
+ def filter_min_flow(od: ODMatrix, min_flow: float, include_intrazonal: bool = False) -> ODMatrix:
31
+ """Keep flows ``>= min_flow``.
32
+
33
+ With ``include_intrazonal=False`` (default) the diagonal is not filtered.
34
+ """
35
+ keep = _candidate_mask(od, include_intrazonal) & (od.values >= min_flow)
36
+ return _apply(od, keep, include_intrazonal)
37
+
38
+
39
+ def filter_top_k(od: ODMatrix, k: int, include_intrazonal: bool = False) -> ODMatrix:
40
+ """Keep the ``k`` largest flows.
41
+
42
+ Ties at the cut-off are broken by matrix order (row-major), so exactly ``k`` flows
43
+ survive. With ``include_intrazonal=False`` (default) only off-diagonal flows compete
44
+ and the diagonal is kept as is.
45
+ """
46
+ if k < 0:
47
+ raise ODFlowsError("k must be non-negative")
48
+ mask = _candidate_mask(od, include_intrazonal)
49
+ flat_idx = np.flatnonzero(mask)
50
+ vals = od.values.ravel()[flat_idx]
51
+ order = np.argsort(-vals, kind="stable")[:k]
52
+ keep = np.zeros(od.values.size, dtype=bool)
53
+ keep[flat_idx[order]] = True
54
+ return _apply(od, keep.reshape(od.values.shape), include_intrazonal)
55
+
56
+
57
+ def filter_share(
58
+ od: ODMatrix,
59
+ min_share: float,
60
+ basis: str = "origin",
61
+ include_intrazonal: bool = False,
62
+ ) -> ODMatrix:
63
+ """Keep flows that are at least ``min_share`` of a total.
64
+
65
+ ``basis``:
66
+
67
+ * ``"origin"`` - ``T_ij / O_i`` (share of the origin's outflow; the classic
68
+ "dominant flow" criterion of Nystuen & Dacey 1961 uses the largest such flow),
69
+ * ``"destination"`` - ``T_ij / D_j`` (share of the destination's inflow),
70
+ * ``"either"`` - kept if either share qualifies,
71
+ * ``"total"`` - ``T_ij / sum(T)``.
72
+
73
+ Totals include intrazonal flows (the usual definition of ``O_i`` and ``D_j``).
74
+ """
75
+ if not 0.0 <= min_share <= 1.0:
76
+ raise ODFlowsError("min_share must be between 0 and 1")
77
+ T = od.values
78
+ with np.errstate(divide="ignore", invalid="ignore"):
79
+ by_o = np.nan_to_num(T / T.sum(axis=1, keepdims=True))
80
+ by_d = np.nan_to_num(T / T.sum(axis=0, keepdims=True))
81
+ if basis == "origin":
82
+ share = by_o
83
+ elif basis == "destination":
84
+ share = by_d
85
+ elif basis == "either":
86
+ share = np.maximum(by_o, by_d)
87
+ elif basis == "total":
88
+ share = T / od.total if od.total > 0 else np.zeros_like(T)
89
+ else:
90
+ raise ODFlowsError(f"unknown basis {basis!r}")
91
+ keep = _candidate_mask(od, include_intrazonal) & (share >= min_share)
92
+ return _apply(od, keep, include_intrazonal)
93
+
94
+
95
+ def filter_cumulative(od: ODMatrix, share: float, include_intrazonal: bool = False) -> ODMatrix:
96
+ """Keep the largest flows until they account for ``share`` of the total.
97
+
98
+ Flows are ranked by size; the smallest prefix whose sum reaches ``share`` of the
99
+ candidate total is kept (e.g. ``share=0.8``: the flows that carry 80 % of all
100
+ trips). With ``include_intrazonal=False`` the total is the off-diagonal total.
101
+ """
102
+ if not 0.0 <= share <= 1.0:
103
+ raise ODFlowsError("share must be between 0 and 1")
104
+ mask = _candidate_mask(od, include_intrazonal)
105
+ flat_idx = np.flatnonzero(mask)
106
+ vals = od.values.ravel()[flat_idx]
107
+ order = np.argsort(-vals, kind="stable")
108
+ total = vals.sum()
109
+ keep = np.zeros(od.values.size, dtype=bool)
110
+ if total > 0 and share > 0:
111
+ csum = np.cumsum(vals[order])
112
+ n_keep = int(np.searchsorted(csum, share * total * (1 - 1e-12), side="left")) + 1
113
+ keep[flat_idx[order[:n_keep]]] = True
114
+ return _apply(od, keep.reshape(od.values.shape), include_intrazonal)
115
+
116
+
117
+ def dominant_flows(od: ODMatrix) -> ODMatrix:
118
+ """Keep, for every origin, its single largest *outgoing* (off-diagonal) flow.
119
+
120
+ The first step of Nystuen & Dacey's (1961) nodal-region analysis ("A graph theory
121
+ interpretation of nodal regions", Papers of the Regional Science Association 7,
122
+ 29-42). Ties are broken by matrix order; the diagonal is kept.
123
+ """
124
+ T = od.values.copy()
125
+ np.fill_diagonal(T, -1.0)
126
+ keep = np.zeros_like(od.values, dtype=bool)
127
+ for i in range(od.n):
128
+ j = int(np.argmax(T[i]))
129
+ if T[i, j] > 0:
130
+ keep[i, j] = True
131
+ return _apply(od, keep, include_intrazonal=False)
odflows/indicators.py ADDED
@@ -0,0 +1,119 @@
1
+ """Zone-level flow indicators for commuting and mobility analysis.
2
+
3
+ Conventions (commuting): rows = place of residence, columns = place of work.
4
+
5
+ * ``O_i = sum_j T_ij`` - resident workers / trips produced (outflow incl. intrazonal),
6
+ * ``D_j = sum_i T_ij`` - jobs / trips attracted (inflow incl. intrazonal),
7
+ * ``T_ii`` - people living and working in the same zone.
8
+
9
+ Self-containment follows the supply/demand definitions used for labour market areas,
10
+ e.g. Goodman (1970), "The definition and analysis of local labour markets: some
11
+ empirical problems", British Journal of Industrial Relations 8(2), 179-196, and
12
+ Casado-Diaz (2000), "Local labour market areas in Spain: a case study", Regional
13
+ Studies 34(9), 843-856:
14
+
15
+ * supply-side SC = ``T_ii / O_i`` (share of resident workers who work locally),
16
+ * demand-side SC = ``T_ii / D_i`` (share of local jobs filled by residents).
17
+
18
+ Labour-market-area methods such as the UK travel-to-work areas set minimum
19
+ self-containment thresholds; no threshold is hard-coded here (pass your own to the
20
+ filters or check :func:`region_summary` output after :func:`odflows.intramax`).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Dict, List
26
+
27
+ import numpy as np
28
+
29
+ from .matrix import ODMatrix
30
+
31
+
32
+ def _ratio(num: np.ndarray, den: np.ndarray) -> np.ndarray:
33
+ with np.errstate(divide="ignore", invalid="ignore"):
34
+ return np.where(den > 0, num / np.where(den > 0, den, 1.0), np.nan)
35
+
36
+
37
+ def degree(od: ODMatrix, include_intrazonal: bool = False) -> Dict[str, np.ndarray]:
38
+ """In/out degree (number of linked zones) and weighted degree (flow strength).
39
+
40
+ Returns ``out_degree``, ``in_degree`` (counts of non-zero links) and ``out_strength``,
41
+ ``in_strength`` (flow totals). Self-loops are excluded unless ``include_intrazonal``.
42
+ """
43
+ T = od.values if include_intrazonal else od.without_intrazonal().values
44
+ links = T > 0
45
+ return {
46
+ "out_degree": links.sum(axis=1).astype(int),
47
+ "in_degree": links.sum(axis=0).astype(int),
48
+ "out_strength": T.sum(axis=1),
49
+ "in_strength": T.sum(axis=0),
50
+ }
51
+
52
+
53
+ def self_containment(od: ODMatrix) -> Dict[str, np.ndarray]:
54
+ """Supply-side (``T_ii / O_i``) and demand-side (``T_ii / D_i``) self-containment.
55
+
56
+ ``nan`` where the denominator is zero.
57
+ """
58
+ t = od.intrazonal()
59
+ return {
60
+ "supply_sc": _ratio(t, od.outflows()),
61
+ "demand_sc": _ratio(t, od.inflows()),
62
+ }
63
+
64
+
65
+ def job_ratio(od: ODMatrix) -> np.ndarray:
66
+ """Jobs-to-resident-workers ratio ``D_i / O_i`` from the flow matrix.
67
+
68
+ > 1: net importer of commuters (employment centre); < 1: dormitory zone.
69
+ Only commuters in the matrix are counted, so this is the *commuting* job ratio,
70
+ not one based on full employment and labour-force statistics.
71
+ """
72
+ return _ratio(od.inflows(), od.outflows())
73
+
74
+
75
+ def net_balance(od: ODMatrix) -> np.ndarray:
76
+ """Net in-commuting ``D_i - O_i`` (inflow minus outflow)."""
77
+ return od.inflows() - od.outflows()
78
+
79
+
80
+ def zone_summary(od: ODMatrix) -> List[Dict[str, object]]:
81
+ """One dict per zone with every indicator in this module."""
82
+ deg = degree(od)
83
+ sc = self_containment(od)
84
+ jr = job_ratio(od)
85
+ nb = net_balance(od)
86
+ out_tot, in_tot, t = od.outflows(), od.inflows(), od.intrazonal()
87
+ rows: List[Dict[str, object]] = []
88
+ for i, z in enumerate(od.zones):
89
+ rows.append(
90
+ {
91
+ "zone": z,
92
+ "outflow": float(out_tot[i]),
93
+ "inflow": float(in_tot[i]),
94
+ "intrazonal": float(t[i]),
95
+ "out_degree": int(deg["out_degree"][i]),
96
+ "in_degree": int(deg["in_degree"][i]),
97
+ "supply_sc": float(sc["supply_sc"][i]),
98
+ "demand_sc": float(sc["demand_sc"][i]),
99
+ "job_ratio": float(jr[i]),
100
+ "net_balance": float(nb[i]),
101
+ }
102
+ )
103
+ return rows
104
+
105
+
106
+ def matrix_summary(od: ODMatrix) -> Dict[str, float]:
107
+ """Matrix-level figures: totals, intrazonal share, asymmetry, link density."""
108
+ n = od.n
109
+ off = od.without_intrazonal().values
110
+ links = int((off > 0).sum())
111
+ return {
112
+ "zones": float(n),
113
+ "total": od.total,
114
+ "intrazonal_total": float(od.intrazonal().sum()),
115
+ "intrazonal_share": float(od.intrazonal().sum() / od.total) if od.total else float("nan"),
116
+ "links": float(links),
117
+ "link_density": links / (n * (n - 1)) if n > 1 else float("nan"),
118
+ "asymmetry_index": od.asymmetry_index(),
119
+ }