odflows 0.1.0__tar.gz

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.
Files changed (43) hide show
  1. odflows-0.1.0/CHANGELOG.md +27 -0
  2. odflows-0.1.0/CITATION.cff +20 -0
  3. odflows-0.1.0/LICENSE +21 -0
  4. odflows-0.1.0/MANIFEST.in +9 -0
  5. odflows-0.1.0/PKG-INFO +256 -0
  6. odflows-0.1.0/README.md +216 -0
  7. odflows-0.1.0/examples/commute.csv +21 -0
  8. odflows-0.1.0/examples/quickstart.py +67 -0
  9. odflows-0.1.0/examples/zones.geojson +171 -0
  10. odflows-0.1.0/pyproject.toml +122 -0
  11. odflows-0.1.0/setup.cfg +4 -0
  12. odflows-0.1.0/src/odflows/__init__.py +102 -0
  13. odflows-0.1.0/src/odflows/__main__.py +7 -0
  14. odflows-0.1.0/src/odflows/_version.py +1 -0
  15. odflows-0.1.0/src/odflows/cli.py +340 -0
  16. odflows-0.1.0/src/odflows/clustering.py +286 -0
  17. odflows-0.1.0/src/odflows/errors.py +11 -0
  18. odflows-0.1.0/src/odflows/filtering.py +131 -0
  19. odflows-0.1.0/src/odflows/indicators.py +119 -0
  20. odflows-0.1.0/src/odflows/io.py +326 -0
  21. odflows-0.1.0/src/odflows/lines.py +163 -0
  22. odflows-0.1.0/src/odflows/matrix.py +245 -0
  23. odflows-0.1.0/src/odflows/pandas_ext.py +43 -0
  24. odflows-0.1.0/src/odflows/py.typed +0 -0
  25. odflows-0.1.0/src/odflows/regions.py +178 -0
  26. odflows-0.1.0/src/odflows/zones.py +198 -0
  27. odflows-0.1.0/src/odflows.egg-info/PKG-INFO +256 -0
  28. odflows-0.1.0/src/odflows.egg-info/SOURCES.txt +41 -0
  29. odflows-0.1.0/src/odflows.egg-info/dependency_links.txt +1 -0
  30. odflows-0.1.0/src/odflows.egg-info/entry_points.txt +2 -0
  31. odflows-0.1.0/src/odflows.egg-info/requires.txt +13 -0
  32. odflows-0.1.0/src/odflows.egg-info/top_level.txt +1 -0
  33. odflows-0.1.0/tests/conftest.py +21 -0
  34. odflows-0.1.0/tests/test_cli.py +160 -0
  35. odflows-0.1.0/tests/test_clustering.py +157 -0
  36. odflows-0.1.0/tests/test_filtering.py +91 -0
  37. odflows-0.1.0/tests/test_indicators.py +60 -0
  38. odflows-0.1.0/tests/test_io.py +212 -0
  39. odflows-0.1.0/tests/test_lines.py +90 -0
  40. odflows-0.1.0/tests/test_matrix.py +103 -0
  41. odflows-0.1.0/tests/test_pandas.py +27 -0
  42. odflows-0.1.0/tests/test_regions.py +61 -0
  43. odflows-0.1.0/tests/test_zones.py +105 -0
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.0] - 2026-11-09
8
+ ### Added
9
+ - `ODMatrix`: square OD matrix with zone labels; long/matrix conversion, outflows,
10
+ inflows, intrazonal flows, net / gross / symmetric flows, asymmetry index, subsets.
11
+ - Readers and writers: long and matrix CSV, zone centroids from GeoJSON (points or
12
+ polygon area centroids) or CSV, GeoJSON, flowmap.blue `locations`/`flows` CSVs,
13
+ kepler.gl arc-layer CSV.
14
+ - `desire_lines`: directed, net or gross desire lines; straight, curved (quadratic
15
+ Bezier) or offset geometry separating the two directions of a pair.
16
+ - `ZoneLookup` and `reaggregate`: (dis)aggregation between zone systems with
17
+ row-normalised weighted lookups, including area weights from polygons (shapely).
18
+ - Filters: minimum flow, top-k, share of origin/destination/total, cumulative share,
19
+ dominant flows.
20
+ - Indicators: degree and strength, supply- and demand-side self-containment, commuting
21
+ job ratio, net balance, zone and matrix summaries.
22
+ - Tao & Thill flow distance and dissimilarity, flow neighbour counts, agglomerative
23
+ (single/complete/average) and DBSCAN flow clustering, volume-weighted flow bundling.
24
+ - `intramax`: Masser & Brown Intramax hierarchy with optional contiguity, region
25
+ self-containment summary.
26
+ - `odflows` command-line interface.
27
+ - Optional pandas helpers `from_dataframe` / `to_dataframe`.
@@ -0,0 +1,20 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use this software, please cite it as below."
3
+ authors:
4
+ - family-names: "Eminoğlu"
5
+ given-names: "Yusuf"
6
+ email: "geospacephilo@gmail.com"
7
+ orcid: "https://orcid.org/0009-0005-6000-2934"
8
+ title: "odflows"
9
+ version: "0.1.0"
10
+ date-released: "2026-11-09"
11
+ license: "MIT"
12
+ repository-code: "https://github.com/YusufEminoglu/odflows_sdk"
13
+ url: "https://pypi.org/project/odflows/"
14
+ abstract: "Origin-destination flow toolkit for mobility and commuting analysis: OD tables, desire lines with curved two-way lines, area-weighted re-aggregation between zone systems, net and gross flows, flow filtering, Tao and Thill flow distance with hierarchical and DBSCAN flow clustering, self-containment and job-ratio indicators, Intramax functional regions, and export to GeoJSON, flowmap.blue and kepler.gl."
15
+ keywords:
16
+ - origin-destination
17
+ - commuting
18
+ - mobility
19
+ - functional regions
20
+ - flow mapping
odflows-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yusuf Eminoğlu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,9 @@
1
+ include LICENSE
2
+ include README.md
3
+ include CHANGELOG.md
4
+ include CITATION.cff
5
+ include pyproject.toml
6
+ include src/odflows/py.typed
7
+ recursive-include src/odflows *.py
8
+ recursive-include tests *.py
9
+ recursive-include examples *.py *.csv *.geojson
odflows-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,256 @@
1
+ Metadata-Version: 2.4
2
+ Name: odflows
3
+ Version: 0.1.0
4
+ Summary: Origin-destination flow toolkit for mobility and commuting analysis: OD tables, desire lines, zone re-aggregation, net/gross flows, filtering, Tao & Thill flow clustering, self-containment indicators, Intramax functional regions, and flowmap.blue / kepler.gl export.
5
+ Author-email: Yusuf Eminoğlu <geospacephilo@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/YusufEminoglu/odflows_sdk
8
+ Project-URL: Repository, https://github.com/YusufEminoglu/odflows_sdk
9
+ Project-URL: Issues, https://github.com/YusufEminoglu/odflows_sdk/issues
10
+ Project-URL: Changelog, https://github.com/YusufEminoglu/odflows_sdk/blob/main/CHANGELOG.md
11
+ Keywords: origin-destination,od-matrix,flows,desire-lines,commuting,mobility,functional-regions,intramax,self-containment,flow-clustering,flowmap,kepler-gl,geojson,gis,transport-planning
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Environment :: Console
21
+ Classifier: Topic :: Scientific/Engineering :: GIS
22
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
23
+ Classifier: Intended Audience :: Science/Research
24
+ Classifier: Intended Audience :: Developers
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: numpy>=1.21
30
+ Provides-Extra: pandas
31
+ Requires-Dist: pandas>=1.3.0; extra == "pandas"
32
+ Provides-Extra: geo
33
+ Requires-Dist: shapely>=2.0; extra == "geo"
34
+ Provides-Extra: dev
35
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
36
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
37
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
38
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
39
+ Dynamic: license-file
40
+
41
+ # odflows
42
+
43
+ [![PyPI version](https://img.shields.io/pypi/v/odflows.svg)](https://pypi.org/project/odflows/)
44
+ [![Python versions](https://img.shields.io/pypi/pyversions/odflows.svg)](https://pypi.org/project/odflows/)
45
+ [![CI](https://github.com/YusufEminoglu/odflows_sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/YusufEminoglu/odflows_sdk/actions/workflows/ci.yml)
46
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/YusufEminoglu/odflows_sdk/blob/main/LICENSE)
47
+
48
+ **Origin-destination flow toolkit for mobility and commuting analysis.**
49
+
50
+ `odflows` covers the usual steps of an OD study in one small package that needs only
51
+ numpy:
52
+
53
+ - **Read** OD tables in long form (`origin,destination,flow`) or as a square matrix, and
54
+ zone centroids from GeoJSON (points, or area centroids of polygons) or CSV.
55
+ - **Desire lines** as GeoJSON LineStrings: directed, net or gross; straight, **curved**
56
+ or **offset**, so both directions of a two-way pair stay visible.
57
+ - **Change zone systems**: aggregate and disaggregate with weighted lookup tables,
58
+ including area weights computed from two polygon layers (optional `shapely`).
59
+ - **Net and gross flows**, symmetric part, asymmetry index, intrazonal handling.
60
+ - **Filter** flows: minimum size, top-k, share of origin / destination / total,
61
+ cumulative share, dominant (largest outgoing) flows.
62
+ - **Cluster and bundle** flows with the Tao & Thill flow distance: agglomerative
63
+ (single / complete / average linkage) and DBSCAN, in pure numpy.
64
+ - **Indicators**: in/out degree and strength, supply- and demand-side
65
+ self-containment, commuting job ratio, net balance.
66
+ - **Functional regions** (commuting zones) with **Intramax** (Masser & Brown 1975),
67
+ optionally contiguity-constrained.
68
+ - **Export** to GeoJSON, **flowmap.blue** (`locations` + `flows` sheets) and a
69
+ **kepler.gl** arc-layer CSV.
70
+ - A command-line tool, `odflows`.
71
+
72
+ ```bash
73
+ pip install odflows # numpy only
74
+ pip install "odflows[pandas]" # + DataFrame helpers
75
+ pip install "odflows[geo]" # + shapely, for area-weighted lookups from polygons
76
+ ```
77
+
78
+ Requires Python 3.9 or newer.
79
+
80
+ ## Quick start
81
+
82
+ ```python
83
+ from odflows import (
84
+ ODMatrix, read_od_csv, centroids_from_geojson, desire_lines, write_geojson,
85
+ zone_summary, intramax, region_summary, filter_top_k,
86
+ )
87
+
88
+ od = read_od_csv("commute.csv") # origin,destination,flow
89
+ zones = centroids_from_geojson("zones.geojson", id_field="zone")
90
+
91
+ for row in zone_summary(od): # self-containment, job ratio, ...
92
+ print(row["zone"], round(row["supply_sc"], 3), round(row["job_ratio"], 2))
93
+
94
+ lines = desire_lines(filter_top_k(od, 20), zones, style="curved")
95
+ write_geojson(lines, "desire_lines.geojson")
96
+
97
+ hierarchy = intramax(od) # full Intramax hierarchy
98
+ membership = hierarchy.membership(n_regions=4) # cut it at 4 regions
99
+ print(region_summary(od, membership))
100
+ ```
101
+
102
+ A runnable example with synthetic data is in
103
+ [`examples/quickstart.py`](examples/quickstart.py).
104
+
105
+ Matrices can also be built in memory:
106
+
107
+ ```python
108
+ od = ODMatrix.from_long([("A", "B", 120), ("B", "A", 40), ("A", "A", 300)])
109
+ od = ODMatrix(["A", "B"], [[300, 120], [40, 0]]) # rows = origins, columns = destinations
110
+ od.net().to_long() # [('A', 'B', 80.0)]
111
+ ```
112
+
113
+ ## Command line
114
+
115
+ ```bash
116
+ odflows summary commute.csv -o zones.csv # per-zone indicators
117
+ odflows summary commute.csv --json # matrix statistics
118
+ odflows lines commute.csv --centroids zones.geojson --id-field zone \
119
+ --style curved --min-flow 50 -o lines.geojson
120
+ odflows filter commute.csv --min-share 0.1 --basis origin -o main_flows.csv
121
+ odflows transform commute.csv net -o net.csv # net | gross | symmetric | ...
122
+ odflows aggregate commute.csv --lookup lookup.csv -o districts.csv
123
+ odflows regions commute.csv -n 5 -o regions.csv --summary regions_sc.csv
124
+ odflows cluster commute.csv --centroids zones.geojson --id-field zone \
125
+ --n-clusters 8 -o bundles.geojson
126
+ odflows export commute.csv --centroids zones.geojson --id-field zone \
127
+ --format flowmap -o mymap # mymap-locations.csv, mymap-flows.csv
128
+ ```
129
+
130
+ Every command accepts `--matrix` for square-matrix input and `--origin-col`,
131
+ `--dest-col`, `--flow-col` for other column names. Centroids may be a GeoJSON file or a
132
+ CSV (`--id-field`, `--x-col`, `--y-col`).
133
+
134
+ ## API overview
135
+
136
+ | Area | Functions / classes |
137
+ |---|---|
138
+ | Container | `ODMatrix` (`from_long`, `to_long`, `outflows`, `inflows`, `intrazonal`, `without_intrazonal`, `net`, `gross`, `symmetric`, `transpose`, `asymmetry_index`, `subset`) |
139
+ | Input / output | `read_od_csv`, `read_matrix_csv`, `write_od_csv`, `write_matrix_csv`, `centroids_from_geojson`, `read_centroids_csv`, `geometry_centroid`, `write_geojson` |
140
+ | Desire lines | `desire_lines`, `line_coordinates`, `haversine_km` |
141
+ | Zone systems | `ZoneLookup` (`from_records`, `from_mapping`, `from_csv`, `from_geojson`), `reaggregate`, `aggregate` |
142
+ | Filtering | `filter_min_flow`, `filter_top_k`, `filter_share`, `filter_cumulative`, `dominant_flows` |
143
+ | Indicators | `degree`, `self_containment`, `job_ratio`, `net_balance`, `zone_summary`, `matrix_summary` |
144
+ | Clustering | `FlowSet`, `flow_distance_matrix`, `flow_neighbour_counts`, `hierarchical_clusters`, `flow_dbscan`, `bundle_flows` |
145
+ | Regions | `intramax`, `IntramaxResult`, `region_summary` |
146
+ | Visualisation export | `write_flowmap_blue`, `write_kepler_csv` |
147
+ | pandas | `from_dataframe`, `to_dataframe` |
148
+
149
+ All filters and transformations return a new `ODMatrix`, so they chain:
150
+ `filter_top_k(filter_share(od, 0.1), 50).without_intrazonal()`.
151
+
152
+ ## Methods and sources
153
+
154
+ **Conventions.** `T[i, j]` is the flow from zone `i` to zone `j`. For commuting, rows
155
+ are places of residence and columns places of work, so `O_i = Σ_j T_ij` are resident
156
+ workers and `D_j = Σ_i T_ij` are jobs; both include intrazonal flows `T_ii`.
157
+
158
+ **Net and gross flows.** Gross `G_ij = T_ij + T_ji`; net `N_ij = max(T_ij − T_ji, 0)`.
159
+ The asymmetry index is `Σ_{i<j} |T_ij − T_ji| / Σ_{i<j} (T_ij + T_ji)`.
160
+
161
+ **Desire lines.** Curved lines are quadratic Bézier curves whose control point is the
162
+ midpoint moved to the right of the direction of travel by `bend × length`; offset lines
163
+ are the segment shifted to the right by the same amount. A→B and B→A therefore fall on
164
+ opposite sides. Lengths are haversine kilometres for longitude/latitude centroids
165
+ (mean Earth radius 6371.0088 km) and planar units otherwise.
166
+
167
+ **Polygon centroids** use the shoelace (area-weighted) formula on the exterior ring
168
+ minus holes, treating coordinates as planar.
169
+
170
+ **Zone re-aggregation.** With a row-normalised lookup `W` (`W[s, t]` = share of source
171
+ zone `s` in target zone `t`), `T' = Wᵀ T W`. Flows are split assuming origins and
172
+ destinations are spread within each source zone in proportion to the weights - areal
173
+ weighting (Goodchild & Lam 1980). Different origin and destination lookups (e.g.
174
+ population vs. employment weights) are supported; totals are preserved.
175
+
176
+ **Filtering.** `filter_share(basis="origin")` keeps `T_ij / O_i ≥ s`;
177
+ `dominant_flows` keeps each origin's largest outgoing flow, the first step of nodal
178
+ region analysis (Nystuen & Dacey 1961).
179
+
180
+ **Flow distance.** Tao & Thill (2016) define, for flows `i` and `j` with origin
181
+ distance `d_O` and destination distance `d_D`,
182
+ `FD = sqrt(α d_O² + β d_D²)` with `α + β = 2`, and the length-normalised
183
+ dissimilarity `FDS = sqrt((α d_O² + β d_D²) / (L_i L_j))`. Coordinates must be
184
+ projected. `flow_neighbour_counts` gives the local counting term of their flow
185
+ K-function (no edge correction or significance test).
186
+
187
+ **Clustering.** Agglomerative clustering uses the Lance-Williams (1967) update for
188
+ single, complete and average (UPGMA) linkage; DBSCAN follows Ester et al. (1996), with
189
+ an option to count flow volume instead of flow numbers. `bundle_flows` replaces every
190
+ cluster by the flow from its volume-weighted mean origin to its volume-weighted mean
191
+ destination.
192
+
193
+ **Self-containment.** Supply-side `SC_s = T_ii / O_i` (share of resident workers who
194
+ work locally) and demand-side `SC_d = T_ii / D_i` (share of local jobs filled by
195
+ residents), as used for local labour market areas (Goodman 1970; Casado-Díaz 2000).
196
+ The commuting job ratio is `D_i / O_i`.
197
+
198
+ **Intramax.** Hierarchical aggregation (Masser & Brown 1975) that repeatedly merges
199
+ the pair of current regions maximising `T_ij / (O_i D_j) + T_ji / (O_j D_i)` on the
200
+ aggregated matrix; merged flows become intrazonal. The full hierarchy is stored and can
201
+ be cut at any number of regions. An optional contiguity constraint only merges adjacent
202
+ regions (Drobne & Lakner 2016). This is Intramax, not a modularity / Louvain method.
203
+
204
+ **Export formats.** flowmap.blue reads a Google Sheet with a `locations` sheet
205
+ (`id,name,lat,lon`) and a `flows` sheet (`origin,dest,count`); `write_flowmap_blue`
206
+ writes one CSV per sheet. `write_kepler_csv` writes `origin_lat/origin_lng` and
207
+ `dest_lat/dest_lng` column pairs, which kepler.gl detects as point fields for its arc
208
+ and line layers.
209
+
210
+ ### References
211
+
212
+ - Casado-Díaz, J. M. (2000). Local labour market areas in Spain: a case study. *Regional Studies*, 34(9), 843-856.
213
+ - Drobne, S., & Lakner, M. (2016). Use of constraints in the hierarchical aggregation procedure Intramax. *Business Systems Research*, 7(2), 5-22.
214
+ - Ester, M., Kriegel, H.-P., Sander, J., & Xu, X. (1996). A density-based algorithm for discovering clusters in large spatial databases with noise. *Proceedings of KDD-96*, 226-231.
215
+ - Goodchild, M. F., & Lam, N. S.-N. (1980). Areal interpolation: a variant of the traditional spatial problem. *Geo-Processing*, 1, 297-312.
216
+ - Goodman, J. F. B. (1970). The definition and analysis of local labour markets: some empirical problems. *British Journal of Industrial Relations*, 8(2), 179-196.
217
+ - Lance, G. N., & Williams, W. T. (1967). A general theory of classificatory sorting strategies: 1. Hierarchical systems. *The Computer Journal*, 9(4), 373-380.
218
+ - Masser, I., & Brown, P. J. B. (1975). Hierarchical aggregation procedures for interaction data. *Environment and Planning A*, 7(5), 509-523.
219
+ - Nystuen, J. D., & Dacey, M. F. (1961). A graph theory interpretation of nodal regions. *Papers of the Regional Science Association*, 7, 29-42.
220
+ - Tao, R., & Thill, J.-C. (2016). Spatial cluster detection in spatial flow data. *Geographical Analysis*, 48(4), 355-372.
221
+
222
+ ## Limitations
223
+
224
+ - Centroids, Bézier offsets, polygon centroids and the flow distance are planar. Use a
225
+ projected CRS for clustering and for exact curve shapes; `desire_lines` guesses
226
+ longitude/latitude from coordinate ranges (override with `geographic=`), which can
227
+ misfire for small projected coordinates.
228
+ - Re-aggregation assumes uniform spreading within source zones (areal weighting).
229
+ Use population or employment weights where you have them.
230
+ - Intramax and agglomerative clustering are O(n³); fine for hundreds of zones and a few
231
+ thousand flows, not for full national matrices. Pairs with no interaction are never
232
+ merged, so Intramax may stop before reaching the requested number of regions.
233
+ - Flow statistics stop at the flow distance, dissimilarity and neighbour counts: the
234
+ flow K-function with edge correction, and significance testing, are not implemented.
235
+ - No travel-to-work-area (TTWA) thresholds are built in; check region self-containment
236
+ against the criteria of your own study.
237
+
238
+ ## Academic citation
239
+
240
+ If you use `odflows` in research, please cite it:
241
+
242
+ ```bibtex
243
+ @software{eminoglu_odflows,
244
+ author = {Eminoğlu, Yusuf},
245
+ title = {odflows},
246
+ year = {2026},
247
+ version = {0.1.0},
248
+ url = {https://github.com/YusufEminoglu/odflows_sdk}
249
+ }
250
+ ```
251
+
252
+ See also [`CITATION.cff`](CITATION.cff).
253
+
254
+ ## License
255
+
256
+ MIT © 2026 Yusuf Eminoğlu. Contact: geospacephilo@gmail.com
@@ -0,0 +1,216 @@
1
+ # odflows
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/odflows.svg)](https://pypi.org/project/odflows/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/odflows.svg)](https://pypi.org/project/odflows/)
5
+ [![CI](https://github.com/YusufEminoglu/odflows_sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/YusufEminoglu/odflows_sdk/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/YusufEminoglu/odflows_sdk/blob/main/LICENSE)
7
+
8
+ **Origin-destination flow toolkit for mobility and commuting analysis.**
9
+
10
+ `odflows` covers the usual steps of an OD study in one small package that needs only
11
+ numpy:
12
+
13
+ - **Read** OD tables in long form (`origin,destination,flow`) or as a square matrix, and
14
+ zone centroids from GeoJSON (points, or area centroids of polygons) or CSV.
15
+ - **Desire lines** as GeoJSON LineStrings: directed, net or gross; straight, **curved**
16
+ or **offset**, so both directions of a two-way pair stay visible.
17
+ - **Change zone systems**: aggregate and disaggregate with weighted lookup tables,
18
+ including area weights computed from two polygon layers (optional `shapely`).
19
+ - **Net and gross flows**, symmetric part, asymmetry index, intrazonal handling.
20
+ - **Filter** flows: minimum size, top-k, share of origin / destination / total,
21
+ cumulative share, dominant (largest outgoing) flows.
22
+ - **Cluster and bundle** flows with the Tao & Thill flow distance: agglomerative
23
+ (single / complete / average linkage) and DBSCAN, in pure numpy.
24
+ - **Indicators**: in/out degree and strength, supply- and demand-side
25
+ self-containment, commuting job ratio, net balance.
26
+ - **Functional regions** (commuting zones) with **Intramax** (Masser & Brown 1975),
27
+ optionally contiguity-constrained.
28
+ - **Export** to GeoJSON, **flowmap.blue** (`locations` + `flows` sheets) and a
29
+ **kepler.gl** arc-layer CSV.
30
+ - A command-line tool, `odflows`.
31
+
32
+ ```bash
33
+ pip install odflows # numpy only
34
+ pip install "odflows[pandas]" # + DataFrame helpers
35
+ pip install "odflows[geo]" # + shapely, for area-weighted lookups from polygons
36
+ ```
37
+
38
+ Requires Python 3.9 or newer.
39
+
40
+ ## Quick start
41
+
42
+ ```python
43
+ from odflows import (
44
+ ODMatrix, read_od_csv, centroids_from_geojson, desire_lines, write_geojson,
45
+ zone_summary, intramax, region_summary, filter_top_k,
46
+ )
47
+
48
+ od = read_od_csv("commute.csv") # origin,destination,flow
49
+ zones = centroids_from_geojson("zones.geojson", id_field="zone")
50
+
51
+ for row in zone_summary(od): # self-containment, job ratio, ...
52
+ print(row["zone"], round(row["supply_sc"], 3), round(row["job_ratio"], 2))
53
+
54
+ lines = desire_lines(filter_top_k(od, 20), zones, style="curved")
55
+ write_geojson(lines, "desire_lines.geojson")
56
+
57
+ hierarchy = intramax(od) # full Intramax hierarchy
58
+ membership = hierarchy.membership(n_regions=4) # cut it at 4 regions
59
+ print(region_summary(od, membership))
60
+ ```
61
+
62
+ A runnable example with synthetic data is in
63
+ [`examples/quickstart.py`](examples/quickstart.py).
64
+
65
+ Matrices can also be built in memory:
66
+
67
+ ```python
68
+ od = ODMatrix.from_long([("A", "B", 120), ("B", "A", 40), ("A", "A", 300)])
69
+ od = ODMatrix(["A", "B"], [[300, 120], [40, 0]]) # rows = origins, columns = destinations
70
+ od.net().to_long() # [('A', 'B', 80.0)]
71
+ ```
72
+
73
+ ## Command line
74
+
75
+ ```bash
76
+ odflows summary commute.csv -o zones.csv # per-zone indicators
77
+ odflows summary commute.csv --json # matrix statistics
78
+ odflows lines commute.csv --centroids zones.geojson --id-field zone \
79
+ --style curved --min-flow 50 -o lines.geojson
80
+ odflows filter commute.csv --min-share 0.1 --basis origin -o main_flows.csv
81
+ odflows transform commute.csv net -o net.csv # net | gross | symmetric | ...
82
+ odflows aggregate commute.csv --lookup lookup.csv -o districts.csv
83
+ odflows regions commute.csv -n 5 -o regions.csv --summary regions_sc.csv
84
+ odflows cluster commute.csv --centroids zones.geojson --id-field zone \
85
+ --n-clusters 8 -o bundles.geojson
86
+ odflows export commute.csv --centroids zones.geojson --id-field zone \
87
+ --format flowmap -o mymap # mymap-locations.csv, mymap-flows.csv
88
+ ```
89
+
90
+ Every command accepts `--matrix` for square-matrix input and `--origin-col`,
91
+ `--dest-col`, `--flow-col` for other column names. Centroids may be a GeoJSON file or a
92
+ CSV (`--id-field`, `--x-col`, `--y-col`).
93
+
94
+ ## API overview
95
+
96
+ | Area | Functions / classes |
97
+ |---|---|
98
+ | Container | `ODMatrix` (`from_long`, `to_long`, `outflows`, `inflows`, `intrazonal`, `without_intrazonal`, `net`, `gross`, `symmetric`, `transpose`, `asymmetry_index`, `subset`) |
99
+ | Input / output | `read_od_csv`, `read_matrix_csv`, `write_od_csv`, `write_matrix_csv`, `centroids_from_geojson`, `read_centroids_csv`, `geometry_centroid`, `write_geojson` |
100
+ | Desire lines | `desire_lines`, `line_coordinates`, `haversine_km` |
101
+ | Zone systems | `ZoneLookup` (`from_records`, `from_mapping`, `from_csv`, `from_geojson`), `reaggregate`, `aggregate` |
102
+ | Filtering | `filter_min_flow`, `filter_top_k`, `filter_share`, `filter_cumulative`, `dominant_flows` |
103
+ | Indicators | `degree`, `self_containment`, `job_ratio`, `net_balance`, `zone_summary`, `matrix_summary` |
104
+ | Clustering | `FlowSet`, `flow_distance_matrix`, `flow_neighbour_counts`, `hierarchical_clusters`, `flow_dbscan`, `bundle_flows` |
105
+ | Regions | `intramax`, `IntramaxResult`, `region_summary` |
106
+ | Visualisation export | `write_flowmap_blue`, `write_kepler_csv` |
107
+ | pandas | `from_dataframe`, `to_dataframe` |
108
+
109
+ All filters and transformations return a new `ODMatrix`, so they chain:
110
+ `filter_top_k(filter_share(od, 0.1), 50).without_intrazonal()`.
111
+
112
+ ## Methods and sources
113
+
114
+ **Conventions.** `T[i, j]` is the flow from zone `i` to zone `j`. For commuting, rows
115
+ are places of residence and columns places of work, so `O_i = Σ_j T_ij` are resident
116
+ workers and `D_j = Σ_i T_ij` are jobs; both include intrazonal flows `T_ii`.
117
+
118
+ **Net and gross flows.** Gross `G_ij = T_ij + T_ji`; net `N_ij = max(T_ij − T_ji, 0)`.
119
+ The asymmetry index is `Σ_{i<j} |T_ij − T_ji| / Σ_{i<j} (T_ij + T_ji)`.
120
+
121
+ **Desire lines.** Curved lines are quadratic Bézier curves whose control point is the
122
+ midpoint moved to the right of the direction of travel by `bend × length`; offset lines
123
+ are the segment shifted to the right by the same amount. A→B and B→A therefore fall on
124
+ opposite sides. Lengths are haversine kilometres for longitude/latitude centroids
125
+ (mean Earth radius 6371.0088 km) and planar units otherwise.
126
+
127
+ **Polygon centroids** use the shoelace (area-weighted) formula on the exterior ring
128
+ minus holes, treating coordinates as planar.
129
+
130
+ **Zone re-aggregation.** With a row-normalised lookup `W` (`W[s, t]` = share of source
131
+ zone `s` in target zone `t`), `T' = Wᵀ T W`. Flows are split assuming origins and
132
+ destinations are spread within each source zone in proportion to the weights - areal
133
+ weighting (Goodchild & Lam 1980). Different origin and destination lookups (e.g.
134
+ population vs. employment weights) are supported; totals are preserved.
135
+
136
+ **Filtering.** `filter_share(basis="origin")` keeps `T_ij / O_i ≥ s`;
137
+ `dominant_flows` keeps each origin's largest outgoing flow, the first step of nodal
138
+ region analysis (Nystuen & Dacey 1961).
139
+
140
+ **Flow distance.** Tao & Thill (2016) define, for flows `i` and `j` with origin
141
+ distance `d_O` and destination distance `d_D`,
142
+ `FD = sqrt(α d_O² + β d_D²)` with `α + β = 2`, and the length-normalised
143
+ dissimilarity `FDS = sqrt((α d_O² + β d_D²) / (L_i L_j))`. Coordinates must be
144
+ projected. `flow_neighbour_counts` gives the local counting term of their flow
145
+ K-function (no edge correction or significance test).
146
+
147
+ **Clustering.** Agglomerative clustering uses the Lance-Williams (1967) update for
148
+ single, complete and average (UPGMA) linkage; DBSCAN follows Ester et al. (1996), with
149
+ an option to count flow volume instead of flow numbers. `bundle_flows` replaces every
150
+ cluster by the flow from its volume-weighted mean origin to its volume-weighted mean
151
+ destination.
152
+
153
+ **Self-containment.** Supply-side `SC_s = T_ii / O_i` (share of resident workers who
154
+ work locally) and demand-side `SC_d = T_ii / D_i` (share of local jobs filled by
155
+ residents), as used for local labour market areas (Goodman 1970; Casado-Díaz 2000).
156
+ The commuting job ratio is `D_i / O_i`.
157
+
158
+ **Intramax.** Hierarchical aggregation (Masser & Brown 1975) that repeatedly merges
159
+ the pair of current regions maximising `T_ij / (O_i D_j) + T_ji / (O_j D_i)` on the
160
+ aggregated matrix; merged flows become intrazonal. The full hierarchy is stored and can
161
+ be cut at any number of regions. An optional contiguity constraint only merges adjacent
162
+ regions (Drobne & Lakner 2016). This is Intramax, not a modularity / Louvain method.
163
+
164
+ **Export formats.** flowmap.blue reads a Google Sheet with a `locations` sheet
165
+ (`id,name,lat,lon`) and a `flows` sheet (`origin,dest,count`); `write_flowmap_blue`
166
+ writes one CSV per sheet. `write_kepler_csv` writes `origin_lat/origin_lng` and
167
+ `dest_lat/dest_lng` column pairs, which kepler.gl detects as point fields for its arc
168
+ and line layers.
169
+
170
+ ### References
171
+
172
+ - Casado-Díaz, J. M. (2000). Local labour market areas in Spain: a case study. *Regional Studies*, 34(9), 843-856.
173
+ - Drobne, S., & Lakner, M. (2016). Use of constraints in the hierarchical aggregation procedure Intramax. *Business Systems Research*, 7(2), 5-22.
174
+ - Ester, M., Kriegel, H.-P., Sander, J., & Xu, X. (1996). A density-based algorithm for discovering clusters in large spatial databases with noise. *Proceedings of KDD-96*, 226-231.
175
+ - Goodchild, M. F., & Lam, N. S.-N. (1980). Areal interpolation: a variant of the traditional spatial problem. *Geo-Processing*, 1, 297-312.
176
+ - Goodman, J. F. B. (1970). The definition and analysis of local labour markets: some empirical problems. *British Journal of Industrial Relations*, 8(2), 179-196.
177
+ - Lance, G. N., & Williams, W. T. (1967). A general theory of classificatory sorting strategies: 1. Hierarchical systems. *The Computer Journal*, 9(4), 373-380.
178
+ - Masser, I., & Brown, P. J. B. (1975). Hierarchical aggregation procedures for interaction data. *Environment and Planning A*, 7(5), 509-523.
179
+ - Nystuen, J. D., & Dacey, M. F. (1961). A graph theory interpretation of nodal regions. *Papers of the Regional Science Association*, 7, 29-42.
180
+ - Tao, R., & Thill, J.-C. (2016). Spatial cluster detection in spatial flow data. *Geographical Analysis*, 48(4), 355-372.
181
+
182
+ ## Limitations
183
+
184
+ - Centroids, Bézier offsets, polygon centroids and the flow distance are planar. Use a
185
+ projected CRS for clustering and for exact curve shapes; `desire_lines` guesses
186
+ longitude/latitude from coordinate ranges (override with `geographic=`), which can
187
+ misfire for small projected coordinates.
188
+ - Re-aggregation assumes uniform spreading within source zones (areal weighting).
189
+ Use population or employment weights where you have them.
190
+ - Intramax and agglomerative clustering are O(n³); fine for hundreds of zones and a few
191
+ thousand flows, not for full national matrices. Pairs with no interaction are never
192
+ merged, so Intramax may stop before reaching the requested number of regions.
193
+ - Flow statistics stop at the flow distance, dissimilarity and neighbour counts: the
194
+ flow K-function with edge correction, and significance testing, are not implemented.
195
+ - No travel-to-work-area (TTWA) thresholds are built in; check region self-containment
196
+ against the criteria of your own study.
197
+
198
+ ## Academic citation
199
+
200
+ If you use `odflows` in research, please cite it:
201
+
202
+ ```bibtex
203
+ @software{eminoglu_odflows,
204
+ author = {Eminoğlu, Yusuf},
205
+ title = {odflows},
206
+ year = {2026},
207
+ version = {0.1.0},
208
+ url = {https://github.com/YusufEminoglu/odflows_sdk}
209
+ }
210
+ ```
211
+
212
+ See also [`CITATION.cff`](CITATION.cff).
213
+
214
+ ## License
215
+
216
+ MIT © 2026 Yusuf Eminoğlu. Contact: geospacephilo@gmail.com
@@ -0,0 +1,21 @@
1
+ origin,destination,flow
2
+ Centre,Centre,900
3
+ Centre,North,60
4
+ Centre,East,80
5
+ Centre,South,40
6
+ Centre,West,30
7
+ North,Centre,420
8
+ North,North,300
9
+ North,East,50
10
+ North,West,20
11
+ East,Centre,380
12
+ East,North,40
13
+ East,East,350
14
+ East,South,25
15
+ South,Centre,260
16
+ South,East,30
17
+ South,South,280
18
+ South,West,90
19
+ West,Centre,210
20
+ West,South,110
21
+ West,West,240
@@ -0,0 +1,67 @@
1
+ """odflows quick start on a small synthetic commuting matrix.
2
+
3
+ python examples/quickstart.py
4
+
5
+ Reads ``commute.csv`` (rows = residence, columns = workplace) and ``zones.geojson``
6
+ (five fictional square zones in projected metres), prints zone indicators and an
7
+ Intramax regionalisation, and writes curved desire lines and flow bundles to
8
+ ``examples/out/``. All data are synthetic.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from odflows import (
16
+ FlowSet,
17
+ bundle_flows,
18
+ centroids_from_geojson,
19
+ desire_lines,
20
+ filter_share,
21
+ flow_distance_matrix,
22
+ hierarchical_clusters,
23
+ intramax,
24
+ matrix_summary,
25
+ read_od_csv,
26
+ region_summary,
27
+ write_geojson,
28
+ zone_summary,
29
+ )
30
+
31
+ HERE = Path(__file__).parent
32
+ OUT = HERE / "out"
33
+
34
+
35
+ def main() -> None:
36
+ od = read_od_csv(HERE / "commute.csv")
37
+ centroids = centroids_from_geojson(HERE / "zones.geojson", id_field="zone")
38
+ print(od)
39
+ print({k: round(v, 3) for k, v in matrix_summary(od).items()})
40
+
41
+ print("\nzone SC(supply) SC(demand) job ratio")
42
+ for row in zone_summary(od):
43
+ print(
44
+ f"{row['zone']:<10} {row['supply_sc']:>10.3f} {row['demand_sc']:>10.3f}"
45
+ f" {row['job_ratio']:>9.3f}"
46
+ )
47
+
48
+ hierarchy = intramax(od)
49
+ for step in hierarchy.steps:
50
+ print(f"Intramax: {step.absorbed} -> {step.region} (score {step.score:.5f})")
51
+ membership = hierarchy.membership(2)
52
+ for row in region_summary(od, membership):
53
+ print(f"region {row['region']}: {row['n_zones']} zones, supply SC {row['supply_sc']:.3f}")
54
+
55
+ OUT.mkdir(exist_ok=True)
56
+ main_flows = filter_share(od, 0.05) # flows >= 5 % of their origin's outflow
57
+ lines = desire_lines(main_flows, centroids, style="curved", geographic=False)
58
+ write_geojson(lines, OUT / "desire_lines.geojson")
59
+
60
+ flows = FlowSet.from_od(od, centroids)
61
+ labels = hierarchical_clusters(flow_distance_matrix(flows), threshold=8000.0)
62
+ write_geojson(bundle_flows(flows, labels), OUT / "bundles.geojson")
63
+ print(f"\n{len(lines['features'])} desire lines, {labels.max() + 1} flow bundles -> {OUT}")
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()