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/__init__.py +102 -0
- odflows/__main__.py +7 -0
- odflows/_version.py +1 -0
- odflows/cli.py +340 -0
- odflows/clustering.py +286 -0
- odflows/errors.py +11 -0
- odflows/filtering.py +131 -0
- odflows/indicators.py +119 -0
- odflows/io.py +326 -0
- odflows/lines.py +163 -0
- odflows/matrix.py +245 -0
- odflows/pandas_ext.py +43 -0
- odflows/py.typed +0 -0
- odflows/regions.py +178 -0
- odflows/zones.py +198 -0
- odflows-0.1.0.dist-info/METADATA +256 -0
- odflows-0.1.0.dist-info/RECORD +21 -0
- odflows-0.1.0.dist-info/WHEEL +5 -0
- odflows-0.1.0.dist-info/entry_points.txt +2 -0
- odflows-0.1.0.dist-info/licenses/LICENSE +21 -0
- odflows-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
}
|