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 ADDED
@@ -0,0 +1,102 @@
1
+ """odflows: origin-destination flow toolkit for mobility and commuting analysis.
2
+
3
+ Quick start::
4
+
5
+ from odflows import ODMatrix, desire_lines, intramax, zone_summary
6
+
7
+ od = ODMatrix.from_long([("A", "B", 120), ("B", "A", 40), ("A", "A", 300)])
8
+ print(zone_summary(od))
9
+ fc = desire_lines(od, {"A": (0.0, 0.0), "B": (1000.0, 0.0)}, style="curved")
10
+ """
11
+
12
+ from ._version import __version__
13
+ from .clustering import (
14
+ FlowSet,
15
+ bundle_flows,
16
+ flow_dbscan,
17
+ flow_distance_matrix,
18
+ flow_neighbour_counts,
19
+ hierarchical_clusters,
20
+ )
21
+ from .errors import MissingDependencyError, ODFlowsError
22
+ from .filtering import (
23
+ dominant_flows,
24
+ filter_cumulative,
25
+ filter_min_flow,
26
+ filter_share,
27
+ filter_top_k,
28
+ )
29
+ from .indicators import (
30
+ degree,
31
+ job_ratio,
32
+ matrix_summary,
33
+ net_balance,
34
+ self_containment,
35
+ zone_summary,
36
+ )
37
+ from .io import (
38
+ centroids_from_geojson,
39
+ geometry_centroid,
40
+ load_geojson,
41
+ read_centroids_csv,
42
+ read_matrix_csv,
43
+ read_od_csv,
44
+ write_flowmap_blue,
45
+ write_geojson,
46
+ write_kepler_csv,
47
+ write_matrix_csv,
48
+ write_od_csv,
49
+ )
50
+ from .lines import desire_lines, euclidean, haversine_km, line_coordinates
51
+ from .matrix import ODMatrix
52
+ from .pandas_ext import from_dataframe, to_dataframe
53
+ from .regions import IntramaxResult, MergeStep, intramax, region_summary
54
+ from .zones import ZoneLookup, aggregate, reaggregate
55
+
56
+ __all__ = [
57
+ "FlowSet",
58
+ "IntramaxResult",
59
+ "MergeStep",
60
+ "MissingDependencyError",
61
+ "ODFlowsError",
62
+ "ODMatrix",
63
+ "ZoneLookup",
64
+ "__version__",
65
+ "aggregate",
66
+ "bundle_flows",
67
+ "centroids_from_geojson",
68
+ "degree",
69
+ "desire_lines",
70
+ "dominant_flows",
71
+ "euclidean",
72
+ "filter_cumulative",
73
+ "filter_min_flow",
74
+ "filter_share",
75
+ "filter_top_k",
76
+ "flow_dbscan",
77
+ "flow_distance_matrix",
78
+ "flow_neighbour_counts",
79
+ "from_dataframe",
80
+ "geometry_centroid",
81
+ "haversine_km",
82
+ "hierarchical_clusters",
83
+ "intramax",
84
+ "job_ratio",
85
+ "line_coordinates",
86
+ "load_geojson",
87
+ "matrix_summary",
88
+ "net_balance",
89
+ "read_centroids_csv",
90
+ "read_matrix_csv",
91
+ "read_od_csv",
92
+ "reaggregate",
93
+ "region_summary",
94
+ "self_containment",
95
+ "to_dataframe",
96
+ "write_flowmap_blue",
97
+ "write_geojson",
98
+ "write_kepler_csv",
99
+ "write_matrix_csv",
100
+ "write_od_csv",
101
+ "zone_summary",
102
+ ]
odflows/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """``python -m odflows``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
odflows/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
odflows/cli.py ADDED
@@ -0,0 +1,340 @@
1
+ """Command-line interface: ``odflows summary | lines | filter | transform | aggregate |
2
+ regions | cluster | export``."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import csv
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional, Sequence
12
+
13
+ from ._version import __version__
14
+ from .clustering import (
15
+ FlowSet,
16
+ bundle_flows,
17
+ flow_dbscan,
18
+ flow_distance_matrix,
19
+ hierarchical_clusters,
20
+ )
21
+ from .errors import MissingDependencyError, ODFlowsError
22
+ from .filtering import filter_cumulative, filter_min_flow, filter_share, filter_top_k
23
+ from .indicators import matrix_summary, zone_summary
24
+ from .io import (
25
+ Centroids,
26
+ centroids_from_geojson,
27
+ read_centroids_csv,
28
+ read_matrix_csv,
29
+ read_od_csv,
30
+ write_flowmap_blue,
31
+ write_geojson,
32
+ write_kepler_csv,
33
+ write_matrix_csv,
34
+ write_od_csv,
35
+ )
36
+ from .lines import desire_lines
37
+ from .matrix import ODMatrix
38
+ from .regions import intramax, region_summary
39
+ from .zones import ZoneLookup, reaggregate
40
+
41
+ EXIT_OK = 0
42
+ EXIT_ERROR = 1
43
+ EXIT_USAGE = 2
44
+
45
+
46
+ def _add_input(p: argparse.ArgumentParser) -> None:
47
+ p.add_argument("input", help="OD table (CSV, long form unless --matrix)")
48
+ p.add_argument("--matrix", action="store_true", help="input is a square matrix CSV")
49
+ p.add_argument("--origin-col", default="origin", help="origin column (long form)")
50
+ p.add_argument("--dest-col", default="destination", help="destination column (long form)")
51
+ p.add_argument("--flow-col", default="flow", help="flow column (long form)")
52
+ p.add_argument("--delimiter", default=None, help="CSV delimiter (default: sniffed)")
53
+
54
+
55
+ def _add_centroids(p: argparse.ArgumentParser) -> None:
56
+ p.add_argument(
57
+ "--centroids", required=True, help="zones as GeoJSON (points or polygons) or CSV"
58
+ )
59
+ p.add_argument("--id-field", default=None, help="zone id property / CSV column")
60
+ p.add_argument("--x-col", default="lon", help="x column of a centroid CSV")
61
+ p.add_argument("--y-col", default="lat", help="y column of a centroid CSV")
62
+
63
+
64
+ def _read_od(args: argparse.Namespace) -> ODMatrix:
65
+ if args.matrix:
66
+ return read_matrix_csv(args.input, delimiter=args.delimiter)
67
+ return read_od_csv(
68
+ args.input,
69
+ origin=args.origin_col,
70
+ destination=args.dest_col,
71
+ flow=args.flow_col,
72
+ delimiter=args.delimiter,
73
+ )
74
+
75
+
76
+ def _read_centroids(args: argparse.Namespace) -> Centroids:
77
+ path = Path(args.centroids)
78
+ if path.suffix.lower() in (".csv", ".txt", ".tsv"):
79
+ return read_centroids_csv(
80
+ path, id_col=args.id_field or "id", x_col=args.x_col, y_col=args.y_col
81
+ )
82
+ return centroids_from_geojson(path, id_field=args.id_field)
83
+
84
+
85
+ def _write_rows(rows: List[Dict[str, Any]], out: Optional[str]) -> None:
86
+ if not rows:
87
+ return
88
+ fh = open(out, "w", encoding="utf-8", newline="") if out else sys.stdout
89
+ try:
90
+ w = csv.DictWriter(fh, fieldnames=list(rows[0]), lineterminator="\n")
91
+ w.writeheader()
92
+ for r in rows:
93
+ w.writerow({k: (f"{v:.6g}" if isinstance(v, float) else v) for k, v in r.items()})
94
+ finally:
95
+ if out:
96
+ fh.close()
97
+
98
+
99
+ def _write_od(od: ODMatrix, args: argparse.Namespace) -> None:
100
+ if getattr(args, "output_matrix", False):
101
+ write_matrix_csv(od, args.output)
102
+ else:
103
+ write_od_csv(od, args.output)
104
+
105
+
106
+ def _cmd_summary(args: argparse.Namespace) -> int:
107
+ od = _read_od(args)
108
+ if args.json:
109
+ print(json.dumps(matrix_summary(od), indent=2))
110
+ else:
111
+ _write_rows(zone_summary(od), args.output)
112
+ return EXIT_OK
113
+
114
+
115
+ def _cmd_lines(args: argparse.Namespace) -> int:
116
+ od = _read_od(args)
117
+ fc = desire_lines(
118
+ od,
119
+ _read_centroids(args),
120
+ min_flow=args.min_flow,
121
+ flows=args.flows,
122
+ style=args.style,
123
+ bend=args.bend,
124
+ )
125
+ write_geojson(fc, args.output)
126
+ print(f"{len(fc['features'])} desire lines -> {args.output}", file=sys.stderr)
127
+ return EXIT_OK
128
+
129
+
130
+ def _cmd_filter(args: argparse.Namespace) -> int:
131
+ od = _read_od(args)
132
+ chosen = [x is not None for x in (args.top_k, args.min_flow, args.min_share, args.cumulative)]
133
+ if sum(chosen) != 1:
134
+ raise ODFlowsError("give exactly one of --top-k, --min-flow, --min-share, --cumulative")
135
+ keep_diag = args.include_intrazonal
136
+ if args.top_k is not None:
137
+ res = filter_top_k(od, args.top_k, include_intrazonal=keep_diag)
138
+ elif args.min_flow is not None:
139
+ res = filter_min_flow(od, args.min_flow, include_intrazonal=keep_diag)
140
+ elif args.min_share is not None:
141
+ res = filter_share(od, args.min_share, basis=args.basis, include_intrazonal=keep_diag)
142
+ else:
143
+ res = filter_cumulative(od, args.cumulative, include_intrazonal=keep_diag)
144
+ if args.drop_intrazonal:
145
+ res = res.without_intrazonal()
146
+ _write_od(res, args)
147
+ return EXIT_OK
148
+
149
+
150
+ def _cmd_transform(args: argparse.Namespace) -> int:
151
+ od = _read_od(args)
152
+ ops = {
153
+ "net": od.net,
154
+ "gross": od.gross,
155
+ "symmetric": od.symmetric,
156
+ "transpose": od.transpose,
157
+ "no-intrazonal": od.without_intrazonal,
158
+ }
159
+ res = ops[args.op]()
160
+ if args.op == "gross" and not args.output_matrix:
161
+ with open(args.output, "w", encoding="utf-8", newline="") as fh:
162
+ w = csv.writer(fh, lineterminator="\n")
163
+ w.writerow(["origin", "destination", "flow"])
164
+ for o, d, f in res.iter_flows(upper_triangle=True):
165
+ w.writerow([o, d, f"{f:g}"])
166
+ return EXIT_OK
167
+ _write_od(res, args)
168
+ return EXIT_OK
169
+
170
+
171
+ def _cmd_aggregate(args: argparse.Namespace) -> int:
172
+ od = _read_od(args)
173
+ lookup = ZoneLookup.from_csv(
174
+ args.lookup, source=args.source_col, target=args.target_col, weight=args.weight_col
175
+ )
176
+ _write_od(reaggregate(od, lookup), args)
177
+ return EXIT_OK
178
+
179
+
180
+ def _cmd_regions(args: argparse.Namespace) -> int:
181
+ od = _read_od(args)
182
+ adjacency: Optional[Dict[str, List[str]]] = None
183
+ if args.adjacency:
184
+ adjacency = {}
185
+ with open(args.adjacency, encoding="utf-8-sig", newline="") as fh:
186
+ for row in csv.reader(fh):
187
+ if len(row) >= 2 and row[0].strip() and row[0].strip().lower() != "zone":
188
+ adjacency.setdefault(row[0].strip(), []).append(row[1].strip())
189
+ res = intramax(od, adjacency=adjacency, stop_at=args.n_regions)
190
+ if res.min_regions > args.n_regions:
191
+ print(
192
+ f"warning: stopped at {res.min_regions} regions (no linked pairs left)",
193
+ file=sys.stderr,
194
+ )
195
+ member = res.membership(args.n_regions)
196
+ _write_rows([{"zone": z, "region": r} for z, r in member.items()], args.output)
197
+ if args.summary:
198
+ _write_rows(region_summary(od, member), args.summary)
199
+ return EXIT_OK
200
+
201
+
202
+ def _cmd_cluster(args: argparse.Namespace) -> int:
203
+ od = _read_od(args)
204
+ flows = FlowSet.from_od(od, _read_centroids(args), min_flow=args.min_flow)
205
+ dist = flow_distance_matrix(
206
+ flows, alpha=args.alpha, beta=2.0 - args.alpha, normalized=args.normalized
207
+ )
208
+ if args.method == "dbscan":
209
+ if args.eps is None:
210
+ raise ODFlowsError("--eps is required for dbscan")
211
+ labels = flow_dbscan(
212
+ dist, args.eps, args.min_samples, weights=flows.weights if args.weighted else None
213
+ )
214
+ else:
215
+ labels = hierarchical_clusters(
216
+ dist, n_clusters=args.n_clusters, threshold=args.eps, linkage=args.linkage
217
+ )
218
+ fc = bundle_flows(flows, labels, include_noise=args.include_noise)
219
+ write_geojson(fc, args.output)
220
+ print(f"{len(flows)} flows -> {len(fc['features'])} bundles -> {args.output}", file=sys.stderr)
221
+ return EXIT_OK
222
+
223
+
224
+ def _cmd_export(args: argparse.Namespace) -> int:
225
+ od = _read_od(args)
226
+ cents = _read_centroids(args)
227
+ if args.format == "flowmap":
228
+ base = Path(args.output)
229
+ stem = base.with_suffix("") if base.suffix.lower() == ".csv" else base
230
+ loc = Path(f"{stem}-locations.csv")
231
+ flo = Path(f"{stem}-flows.csv")
232
+ write_flowmap_blue(od, cents, loc, flo)
233
+ print(f"wrote {loc} and {flo}", file=sys.stderr)
234
+ elif args.format == "kepler":
235
+ write_kepler_csv(od, cents, args.output)
236
+ else:
237
+ write_geojson(desire_lines(od, cents), args.output)
238
+ return EXIT_OK
239
+
240
+
241
+ def build_parser() -> argparse.ArgumentParser:
242
+ """The ``odflows`` argument parser."""
243
+ parser = argparse.ArgumentParser(
244
+ prog="odflows", description="Origin-destination flow toolkit for mobility and commuting."
245
+ )
246
+ parser.add_argument("--version", action="version", version=f"odflows {__version__}")
247
+ sub = parser.add_subparsers(dest="command", required=True)
248
+
249
+ p = sub.add_parser("summary", help="per-zone indicators (CSV) or matrix statistics (--json)")
250
+ _add_input(p)
251
+ p.add_argument("-o", "--output", help="output CSV (default: stdout)")
252
+ p.add_argument("--json", action="store_true", help="print matrix-level statistics as JSON")
253
+ p.set_defaults(func=_cmd_summary)
254
+
255
+ p = sub.add_parser("lines", help="desire lines as GeoJSON")
256
+ _add_input(p)
257
+ _add_centroids(p)
258
+ p.add_argument("-o", "--output", required=True)
259
+ p.add_argument("--flows", choices=["directed", "net", "gross"], default="directed")
260
+ p.add_argument("--style", choices=["straight", "curved", "offset"], default="straight")
261
+ p.add_argument("--bend", type=float, default=0.15, help="curvature / offset (x length)")
262
+ p.add_argument("--min-flow", type=float, default=0.0)
263
+ p.set_defaults(func=_cmd_lines)
264
+
265
+ p = sub.add_parser("filter", help="keep the important flows")
266
+ _add_input(p)
267
+ p.add_argument("-o", "--output", required=True)
268
+ p.add_argument("--output-matrix", action="store_true", help="write a square matrix")
269
+ p.add_argument("--top-k", type=int)
270
+ p.add_argument("--min-flow", type=float)
271
+ p.add_argument("--min-share", type=float)
272
+ p.add_argument(
273
+ "--basis", choices=["origin", "destination", "either", "total"], default="origin"
274
+ )
275
+ p.add_argument("--cumulative", type=float, help="keep flows carrying this share of total")
276
+ p.add_argument(
277
+ "--include-intrazonal", action="store_true", help="let intrazonal flows compete too"
278
+ )
279
+ p.add_argument("--drop-intrazonal", action="store_true", help="remove intrazonal flows")
280
+ p.set_defaults(func=_cmd_filter)
281
+
282
+ p = sub.add_parser("transform", help="net, gross, symmetric, transpose, no-intrazonal")
283
+ _add_input(p)
284
+ p.add_argument("op", choices=["net", "gross", "symmetric", "transpose", "no-intrazonal"])
285
+ p.add_argument("-o", "--output", required=True)
286
+ p.add_argument("--output-matrix", action="store_true")
287
+ p.set_defaults(func=_cmd_transform)
288
+
289
+ p = sub.add_parser("aggregate", help="move flows to another zone system via a lookup")
290
+ _add_input(p)
291
+ p.add_argument("--lookup", required=True, help="CSV source,target[,weight]")
292
+ p.add_argument("--source-col", default="source")
293
+ p.add_argument("--target-col", default="target")
294
+ p.add_argument("--weight-col", default="weight")
295
+ p.add_argument("-o", "--output", required=True)
296
+ p.add_argument("--output-matrix", action="store_true")
297
+ p.set_defaults(func=_cmd_aggregate)
298
+
299
+ p = sub.add_parser("regions", help="functional regions with Intramax")
300
+ _add_input(p)
301
+ p.add_argument("-n", "--n-regions", type=int, required=True)
302
+ p.add_argument("--adjacency", help="CSV of zone,neighbour pairs (contiguity constraint)")
303
+ p.add_argument("-o", "--output", help="zone,region CSV (default: stdout)")
304
+ p.add_argument("--summary", help="also write per-region self-containment CSV")
305
+ p.set_defaults(func=_cmd_regions)
306
+
307
+ p = sub.add_parser("cluster", help="cluster similar flows and bundle them")
308
+ _add_input(p)
309
+ _add_centroids(p)
310
+ p.add_argument("-o", "--output", required=True, help="bundled flows GeoJSON")
311
+ p.add_argument("--method", choices=["hierarchical", "dbscan"], default="hierarchical")
312
+ p.add_argument("--n-clusters", type=int)
313
+ p.add_argument("--eps", type=float, help="distance threshold (hierarchical) / eps (dbscan)")
314
+ p.add_argument("--min-samples", type=float, default=2)
315
+ p.add_argument("--weighted", action="store_true", help="dbscan: count volume, not flows")
316
+ p.add_argument("--linkage", choices=["single", "complete", "average"], default="average")
317
+ p.add_argument("--alpha", type=float, default=1.0, help="origin weight (beta = 2 - alpha)")
318
+ p.add_argument("--normalized", action="store_true", help="use length-normalised FDS")
319
+ p.add_argument("--min-flow", type=float, default=0.0)
320
+ p.add_argument("--include-noise", action="store_true")
321
+ p.set_defaults(func=_cmd_cluster)
322
+
323
+ p = sub.add_parser("export", help="flowmap.blue CSVs, kepler.gl CSV or GeoJSON")
324
+ _add_input(p)
325
+ _add_centroids(p)
326
+ p.add_argument("--format", choices=["flowmap", "kepler", "geojson"], required=True)
327
+ p.add_argument("-o", "--output", required=True, help="file (flowmap: prefix)")
328
+ p.set_defaults(func=_cmd_export)
329
+ return parser
330
+
331
+
332
+ def main(argv: Optional[Sequence[str]] = None) -> int:
333
+ """Entry point for the ``odflows`` command."""
334
+ parser = build_parser()
335
+ args = parser.parse_args(argv)
336
+ try:
337
+ return int(args.func(args))
338
+ except (ODFlowsError, MissingDependencyError, OSError) as exc:
339
+ print(f"odflows: error: {exc}", file=sys.stderr)
340
+ return EXIT_ERROR