ObjectNat 1.4.0__tar.gz → 2.0.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 (46) hide show
  1. objectnat-2.0.0/.gitignore +59 -0
  2. {objectnat-1.4.0 → objectnat-2.0.0}/PKG-INFO +32 -40
  3. {objectnat-1.4.0 → objectnat-2.0.0}/README.rst +21 -23
  4. objectnat-2.0.0/objectnat/_api.py +20 -0
  5. {objectnat-1.4.0/src → objectnat-2.0.0}/objectnat/_config.py +2 -2
  6. objectnat-2.0.0/objectnat/_version.py +1 -0
  7. objectnat-2.0.0/objectnat/methods/accessibility/__init__.py +3 -0
  8. objectnat-2.0.0/objectnat/methods/accessibility/_utils.py +285 -0
  9. objectnat-2.0.0/objectnat/methods/accessibility/coverage.py +337 -0
  10. objectnat-2.0.0/objectnat/methods/accessibility/isochrones.py +321 -0
  11. objectnat-1.4.0/src/objectnat/methods/coverage_zones/radius_voronoi_coverage.py → objectnat-2.0.0/objectnat/methods/accessibility/radius.py +3 -0
  12. {objectnat-1.4.0/src → objectnat-2.0.0}/objectnat/methods/noise/__init__.py +1 -1
  13. {objectnat-1.4.0/src → objectnat-2.0.0}/objectnat/methods/noise/noise_reduce.py +23 -0
  14. {objectnat-1.4.0/src → objectnat-2.0.0}/objectnat/methods/noise/noise_simulation.py +181 -46
  15. {objectnat-1.4.0/src → objectnat-2.0.0}/objectnat/methods/noise/noise_simulation_simplified.py +2 -2
  16. objectnat-2.0.0/objectnat/methods/provision/__init__.py +7 -0
  17. objectnat-2.0.0/objectnat/methods/provision/provision.py +95 -0
  18. objectnat-2.0.0/objectnat/methods/provision/provision_calculating.py +388 -0
  19. {objectnat-1.4.0/src → objectnat-2.0.0}/objectnat/methods/provision/provision_exceptions.py +8 -0
  20. objectnat-2.0.0/objectnat/methods/provision/provision_result.py +174 -0
  21. objectnat-2.0.0/objectnat/methods/utils/__init__.py +0 -0
  22. {objectnat-1.4.0/src → objectnat-2.0.0}/objectnat/methods/utils/geom_utils.py +11 -20
  23. {objectnat-1.4.0/src → objectnat-2.0.0}/objectnat/methods/visibility/visibility_analysis.py +5 -239
  24. objectnat-2.0.0/pyproject.toml +174 -0
  25. objectnat-1.4.0/pyproject.toml +0 -81
  26. objectnat-1.4.0/src/objectnat/_api.py +0 -9
  27. objectnat-1.4.0/src/objectnat/_version.py +0 -1
  28. objectnat-1.4.0/src/objectnat/methods/coverage_zones/__init__.py +0 -3
  29. objectnat-1.4.0/src/objectnat/methods/coverage_zones/graph_coverage.py +0 -105
  30. objectnat-1.4.0/src/objectnat/methods/coverage_zones/stepped_coverage.py +0 -136
  31. objectnat-1.4.0/src/objectnat/methods/isochrones/__init__.py +0 -1
  32. objectnat-1.4.0/src/objectnat/methods/isochrones/isochrone_utils.py +0 -167
  33. objectnat-1.4.0/src/objectnat/methods/isochrones/isochrones.py +0 -282
  34. objectnat-1.4.0/src/objectnat/methods/point_clustering/__init__.py +0 -1
  35. objectnat-1.4.0/src/objectnat/methods/point_clustering/cluster_points_in_polygons.py +0 -115
  36. objectnat-1.4.0/src/objectnat/methods/provision/__init__.py +0 -1
  37. objectnat-1.4.0/src/objectnat/methods/provision/provision.py +0 -213
  38. objectnat-1.4.0/src/objectnat/methods/provision/provision_model.py +0 -323
  39. objectnat-1.4.0/src/objectnat/methods/utils/__init__.py +0 -1
  40. objectnat-1.4.0/src/objectnat/methods/utils/graph_utils.py +0 -306
  41. objectnat-1.4.0/src/objectnat/methods/utils/math_utils.py +0 -32
  42. {objectnat-1.4.0 → objectnat-2.0.0}/LICENSE.txt +0 -0
  43. {objectnat-1.4.0/src → objectnat-2.0.0}/objectnat/__init__.py +0 -0
  44. {objectnat-1.4.0/src → objectnat-2.0.0}/objectnat/methods/__init__.py +0 -0
  45. {objectnat-1.4.0/src → objectnat-2.0.0}/objectnat/methods/noise/noise_init_data.py +0 -0
  46. {objectnat-1.4.0/src → objectnat-2.0.0}/objectnat/methods/visibility/__init__.py +0 -0
@@ -0,0 +1,59 @@
1
+ # Project caches
2
+ .iduedu_cache/
3
+ cache/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+
9
+ # Python bytecode
10
+ *.py[cod]
11
+ *$py.class
12
+
13
+ # Build / dist artifacts
14
+ /dist/
15
+ /build/
16
+ /*.egg-info/
17
+
18
+ # Virtual envs
19
+ /.venv/
20
+ /.env
21
+
22
+ # IDE / tools
23
+ /.idea/
24
+ /.vscode/
25
+ *.swp
26
+ *.swo
27
+
28
+ # Coverage / test artifacts
29
+ .coverage
30
+ .coverage.*
31
+ htmlcov/
32
+ coverage.xml
33
+
34
+ # Sphinx docs
35
+ docs/_build/
36
+ docs/_autoapi/
37
+ docs/api/generated/
38
+ docs/_autosummary/
39
+ docs/_doctrees/
40
+ docs/api/generated
41
+ _build/
42
+ _autosummary/
43
+
44
+ # Local dev stuff
45
+ /dev
46
+ .claude
47
+ .codex
48
+
49
+ # OS junk
50
+ .DS_Store
51
+ Thumbs.db
52
+
53
+ # test artifacts (rendered plots + downloaded/cached OSM graph)
54
+ /tests/test_output/
55
+ /tests/test_cache/
56
+
57
+ # graphify knowledge graph (generated locally, not committed)
58
+ /graphify-out/
59
+
@@ -1,23 +1,18 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ObjectNat
3
- Version: 1.4.0
3
+ Version: 2.0.0
4
4
  Summary: ObjectNat is an open-source library created for geospatial analysis created by IDU team
5
- License: BSD-3-Clause
5
+ Author-email: DDonnyy <63115678+DDonnyy@users.noreply.github.com>
6
+ License-Expression: BSD-3-Clause
6
7
  License-File: LICENSE.txt
7
- Author: DDonnyy
8
- Author-email: 63115678+DDonnyy@users.noreply.github.com
9
- Requires-Python: >=3.11,<3.13
10
- Classifier: License :: OSI Approved :: BSD License
11
- Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.11
13
- Classifier: Programming Language :: Python :: 3.12
14
- Requires-Dist: geopandas (>=1.0.1,<2.0.0)
15
- Requires-Dist: loguru (>=0.7.3,<0.8.0)
16
- Requires-Dist: networkx (>=3.4.2,<4.0.0)
17
- Requires-Dist: numpy (>=2.1.3,<3.0.0)
18
- Requires-Dist: pandas (>=2.2.0,<3.0.0)
19
- Requires-Dist: scikit-learn (>=1.4.0,<2.0.0)
20
- Requires-Dist: tqdm (>=4.66.2,<5.0.0)
8
+ Requires-Python: <3.13,>=3.11
9
+ Requires-Dist: geopandas<2.0.0,>=1.1.0
10
+ Requires-Dist: iduedu<3.0.0,>=2.0.0
11
+ Requires-Dist: loguru<0.8.0,>=0.7.3
12
+ Requires-Dist: numpy<3.0.0,>=2.4.0
13
+ Requires-Dist: pandas<4.0.0,>=3.0.0
14
+ Requires-Dist: scipy<2.0.0,>=1.17.0
15
+ Requires-Dist: tqdm<5.0.0,>=4.66.2
21
16
  Description-Content-Type: text/x-rst
22
17
 
23
18
  ObjectNat
@@ -34,12 +29,12 @@ Object-oriented Network Analysis Tools
34
29
  :target: https://pypi.org/project/objectnat/
35
30
  :alt: PyPI version
36
31
 
37
- .. |badge-ci| image:: https://github.com/IDUclub/ObjectNat/actions/workflows/ci_pipeline.yml/badge.svg
38
- :target: https://github.com/IDUclub/ObjectNat/actions/workflows/ci_pipeline.yml
39
- :alt: CI
32
+ .. |badge-ci| image:: https://github.com/IDUclub/ObjectNat/actions/workflows/quality.yml/badge.svg
33
+ :target: https://github.com/IDUclub/ObjectNat/actions/workflows/quality.yml
34
+ :alt: Tests and coverage
40
35
 
41
- .. |badge-codecov| image:: https://codecov.io/gh/DDonnyy/ObjectNat/graph/badge.svg?token=K6JFSJ02GU
42
- :target: https://codecov.io/gh/DDonnyy/ObjectNat
36
+ .. |badge-codecov| image:: https://codecov.io/gh/IDUclub/ObjectNat/graph/badge.svg?token=K6JFSJ02GU
37
+ :target: https://codecov.io/gh/IDUclub/ObjectNat
43
38
  :alt: Coverage
44
39
 
45
40
  .. |badge-license| image:: https://img.shields.io/badge/license-BSD--3--Clause-blue.svg
@@ -75,7 +70,7 @@ Each feature includes a **Jupyter Notebook example** and **full documentation**.
75
70
 
76
71
  Isochrones represent areas reachable from an origin point within a specified time along a transport network.
77
72
  This feature allows the analysis of transport accessibility using pedestrian, road,
78
- public transport, or multimodal graphs.
73
+ public transport, or multimodal ``UrbanGraph`` objects prepared by IduEdu.
79
74
 
80
75
  The library supports several methods for building isochrones:
81
76
 
@@ -102,6 +97,8 @@ Each feature includes a **Jupyter Notebook example** and **full documentation**.
102
97
  and a defined **accessibility threshold** (in minutes or meters).
103
98
  The function models the **balance between supply and demand**,
104
99
  assessing how well services meet the needs of nearby buildings within an acceptable time.
100
+ ObjectNat 2.0 returns a structured ``ProvisionResult`` with a sparse flow matrix
101
+ and materialization helpers for buildings, services, and link geometries.
105
102
 
106
103
  📘 `Example <https://iduclub.github.io/ObjectNat/methods/examples/provision.html>`__
107
104
  🔗 `Documentation <https://iduclub.github.io/ObjectNat/methods/provision.html>`__
@@ -110,9 +107,8 @@ Each feature includes a **Jupyter Notebook example** and **full documentation**.
110
107
 
111
108
  A function for evaluating visibility from a given point or set of points to nearby buildings within a given radius.
112
109
  It is used to assess visual accessibility in urban environments.
113
- A module is also implemented for computing **visibility coverage zones**
114
- using a dense observer grid (recommended ~1000 points with a 10–20 m spacing).
115
- Points can be generated along the transport network and distributed across its edges.
110
+ The unified ``get_visibility`` API supports accurate and simplified methods,
111
+ including parallel execution for batches of observer points.
116
112
 
117
113
  📘 `Example <https://iduclub.github.io/ObjectNat/methods/examples/visibility.html>`__
118
114
  🔗 `Documentation <https://iduclub.github.io/ObjectNat/methods/visibility.html>`__
@@ -126,19 +122,6 @@ Each feature includes a **Jupyter Notebook example** and **full documentation**.
126
122
  🔗 `Documentation <https://iduclub.github.io/ObjectNat/methods/noise.html>`__
127
123
  🧠 `Detailed theory <https://github.com/DDonnyy/ObjectNat/wiki/Noise-simulation>`__
128
124
 
129
- 6. **Point Clusterization**
130
-
131
- A function for constructing **cluster polygons** based on a set of points using:
132
-
133
- - Minimum **distance** between points.
134
- - Minimum **number of points** in a cluster.
135
-
136
- The function can also compute the **ratio of service types** in each cluster
137
- for spatial analysis of service composition.
138
-
139
- 📘 `Example <https://iduclub.github.io/ObjectNat/methods/examples/clustering.html>`__
140
- 🔗 `Documentation <https://iduclub.github.io/ObjectNat/methods/clustering.html>`__
141
-
142
125
  ----
143
126
 
144
127
  City Graphs via *IduEdu*
@@ -146,6 +129,8 @@ City Graphs via *IduEdu*
146
129
 
147
130
  For optimal performance, **ObjectNat** is recommended to be used with graphs
148
131
  created by the `IduEdu <https://github.com/IDUclub/IduEdu>`_ library.
132
+ Graph-based ObjectNat methods consume ``iduedu.UrbanGraph`` directly; ObjectNat
133
+ does not build NetworkX graphs internally.
149
134
 
150
135
  **IduEdu** is an open-source Python library designed for building and processing
151
136
  complex urban networks based on OpenStreetMap data.
@@ -160,7 +145,7 @@ Example usage::
160
145
  from iduedu import get_4326_boundary, get_intermodal_graph
161
146
 
162
147
  poly = get_4326_boundary(osm_id=1114252)
163
- G_intermodal = get_intermodal_graph(territory=poly, clip_by_territory=True)
148
+ urban_graph = get_intermodal_graph(territory=poly, clip_by_territory=True)
164
149
 
165
150
  ----
166
151
 
@@ -183,6 +168,14 @@ You can adjust logging and progress bar output using the config module::
183
168
  config.change_logger_lvl("INFO") # mute debug logs
184
169
  config.set_enable_tqdm(False) # disable tqdm progress bars
185
170
 
171
+ Migration to ObjectNat 2.0
172
+ --------------------------
173
+
174
+ ObjectNat 2.0 replaces NetworkX-based graph inputs with ``iduedu.UrbanGraph``,
175
+ renames accessibility functions, and returns ``ProvisionResult`` from service
176
+ provision. See the migration guide in the documentation:
177
+ `Migration 1.x to 2.0 <https://iduclub.github.io/ObjectNat/migration_1_to_2.html>`__.
178
+
186
179
  ----
187
180
 
188
181
  Contacts
@@ -199,4 +192,3 @@ Publications
199
192
  ------------
200
193
 
201
194
  Coming soon.
202
-
@@ -12,12 +12,12 @@ Object-oriented Network Analysis Tools
12
12
  :target: https://pypi.org/project/objectnat/
13
13
  :alt: PyPI version
14
14
 
15
- .. |badge-ci| image:: https://github.com/IDUclub/ObjectNat/actions/workflows/ci_pipeline.yml/badge.svg
16
- :target: https://github.com/IDUclub/ObjectNat/actions/workflows/ci_pipeline.yml
17
- :alt: CI
15
+ .. |badge-ci| image:: https://github.com/IDUclub/ObjectNat/actions/workflows/quality.yml/badge.svg
16
+ :target: https://github.com/IDUclub/ObjectNat/actions/workflows/quality.yml
17
+ :alt: Tests and coverage
18
18
 
19
- .. |badge-codecov| image:: https://codecov.io/gh/DDonnyy/ObjectNat/graph/badge.svg?token=K6JFSJ02GU
20
- :target: https://codecov.io/gh/DDonnyy/ObjectNat
19
+ .. |badge-codecov| image:: https://codecov.io/gh/IDUclub/ObjectNat/graph/badge.svg?token=K6JFSJ02GU
20
+ :target: https://codecov.io/gh/IDUclub/ObjectNat
21
21
  :alt: Coverage
22
22
 
23
23
  .. |badge-license| image:: https://img.shields.io/badge/license-BSD--3--Clause-blue.svg
@@ -53,7 +53,7 @@ Each feature includes a **Jupyter Notebook example** and **full documentation**.
53
53
 
54
54
  Isochrones represent areas reachable from an origin point within a specified time along a transport network.
55
55
  This feature allows the analysis of transport accessibility using pedestrian, road,
56
- public transport, or multimodal graphs.
56
+ public transport, or multimodal ``UrbanGraph`` objects prepared by IduEdu.
57
57
 
58
58
  The library supports several methods for building isochrones:
59
59
 
@@ -80,6 +80,8 @@ Each feature includes a **Jupyter Notebook example** and **full documentation**.
80
80
  and a defined **accessibility threshold** (in minutes or meters).
81
81
  The function models the **balance between supply and demand**,
82
82
  assessing how well services meet the needs of nearby buildings within an acceptable time.
83
+ ObjectNat 2.0 returns a structured ``ProvisionResult`` with a sparse flow matrix
84
+ and materialization helpers for buildings, services, and link geometries.
83
85
 
84
86
  📘 `Example <https://iduclub.github.io/ObjectNat/methods/examples/provision.html>`__
85
87
  🔗 `Documentation <https://iduclub.github.io/ObjectNat/methods/provision.html>`__
@@ -88,9 +90,8 @@ Each feature includes a **Jupyter Notebook example** and **full documentation**.
88
90
 
89
91
  A function for evaluating visibility from a given point or set of points to nearby buildings within a given radius.
90
92
  It is used to assess visual accessibility in urban environments.
91
- A module is also implemented for computing **visibility coverage zones**
92
- using a dense observer grid (recommended ~1000 points with a 10–20 m spacing).
93
- Points can be generated along the transport network and distributed across its edges.
93
+ The unified ``get_visibility`` API supports accurate and simplified methods,
94
+ including parallel execution for batches of observer points.
94
95
 
95
96
  📘 `Example <https://iduclub.github.io/ObjectNat/methods/examples/visibility.html>`__
96
97
  🔗 `Documentation <https://iduclub.github.io/ObjectNat/methods/visibility.html>`__
@@ -104,19 +105,6 @@ Each feature includes a **Jupyter Notebook example** and **full documentation**.
104
105
  🔗 `Documentation <https://iduclub.github.io/ObjectNat/methods/noise.html>`__
105
106
  🧠 `Detailed theory <https://github.com/DDonnyy/ObjectNat/wiki/Noise-simulation>`__
106
107
 
107
- 6. **Point Clusterization**
108
-
109
- A function for constructing **cluster polygons** based on a set of points using:
110
-
111
- - Minimum **distance** between points.
112
- - Minimum **number of points** in a cluster.
113
-
114
- The function can also compute the **ratio of service types** in each cluster
115
- for spatial analysis of service composition.
116
-
117
- 📘 `Example <https://iduclub.github.io/ObjectNat/methods/examples/clustering.html>`__
118
- 🔗 `Documentation <https://iduclub.github.io/ObjectNat/methods/clustering.html>`__
119
-
120
108
  ----
121
109
 
122
110
  City Graphs via *IduEdu*
@@ -124,6 +112,8 @@ City Graphs via *IduEdu*
124
112
 
125
113
  For optimal performance, **ObjectNat** is recommended to be used with graphs
126
114
  created by the `IduEdu <https://github.com/IDUclub/IduEdu>`_ library.
115
+ Graph-based ObjectNat methods consume ``iduedu.UrbanGraph`` directly; ObjectNat
116
+ does not build NetworkX graphs internally.
127
117
 
128
118
  **IduEdu** is an open-source Python library designed for building and processing
129
119
  complex urban networks based on OpenStreetMap data.
@@ -138,7 +128,7 @@ Example usage::
138
128
  from iduedu import get_4326_boundary, get_intermodal_graph
139
129
 
140
130
  poly = get_4326_boundary(osm_id=1114252)
141
- G_intermodal = get_intermodal_graph(territory=poly, clip_by_territory=True)
131
+ urban_graph = get_intermodal_graph(territory=poly, clip_by_territory=True)
142
132
 
143
133
  ----
144
134
 
@@ -161,6 +151,14 @@ You can adjust logging and progress bar output using the config module::
161
151
  config.change_logger_lvl("INFO") # mute debug logs
162
152
  config.set_enable_tqdm(False) # disable tqdm progress bars
163
153
 
154
+ Migration to ObjectNat 2.0
155
+ --------------------------
156
+
157
+ ObjectNat 2.0 replaces NetworkX-based graph inputs with ``iduedu.UrbanGraph``,
158
+ renames accessibility functions, and returns ``ProvisionResult`` from service
159
+ provision. See the migration guide in the documentation:
160
+ `Migration 1.x to 2.0 <https://iduclub.github.io/ObjectNat/migration_1_to_2.html>`__.
161
+
164
162
  ----
165
163
 
166
164
  Contacts
@@ -0,0 +1,20 @@
1
+ # pylint: disable=unused-import,wildcard-import,unused-wildcard-import
2
+
3
+ from .methods.accessibility import (
4
+ get_graph_coverage,
5
+ get_graph_isochrones,
6
+ get_radius_coverage,
7
+ get_stepped_graph_coverage,
8
+ get_stepped_graph_isochrones,
9
+ )
10
+ from .methods.noise import calculate_simplified_noise_frame, simulate_noise
11
+ from .methods.provision import (
12
+ ProvisionResult,
13
+ clip_provision,
14
+ get_provision_buildings,
15
+ get_provision_links,
16
+ get_provision_services,
17
+ get_service_provision,
18
+ recalculate_links,
19
+ )
20
+ from .methods.visibility import get_visibility
@@ -31,7 +31,7 @@ class Config:
31
31
  self.enable_tqdm_bar = enable_tqdm_bar
32
32
  self.logger = logger
33
33
 
34
- def change_logger_lvl(self, lvl: Literal["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]):
34
+ def change_logger_lvl(self, lvl: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"]):
35
35
  self.logger.remove()
36
36
  self.logger.add(sys.stderr, level=lvl)
37
37
 
@@ -40,4 +40,4 @@ class Config:
40
40
 
41
41
 
42
42
  config = Config()
43
- config.change_logger_lvl("INFO")
43
+ config.change_logger_lvl("WARNING")
@@ -0,0 +1 @@
1
+ VERSION = "2.0.0"
@@ -0,0 +1,3 @@
1
+ from .coverage import get_graph_coverage, get_stepped_graph_coverage
2
+ from .isochrones import get_graph_isochrones, get_stepped_graph_isochrones
3
+ from .radius import get_radius_coverage
@@ -0,0 +1,285 @@
1
+ from typing import Any, Literal
2
+
3
+ import geopandas as gpd
4
+ import numpy as np
5
+ import pandas as pd
6
+ from iduedu import UrbanGraph
7
+ from shapely import convex_hull
8
+ from shapely.geometry import GeometryCollection, MultiPolygon, Polygon
9
+ from shapely.ops import polygonize, unary_union
10
+
11
+ from objectnat.methods.utils.geom_utils import polygons_to_multilinestring
12
+
13
+ SteppedGeometryType = Literal["radius", "ways", "separate"] | None
14
+
15
+ # Graph types whose "ways" geometry must be built from pedestrian edges only.
16
+ # On these graphs non-walk edges (transit legs) are straight lines between
17
+ # distant stops; buffering them would create spurious corridors.
18
+ _WALK_ONLY_WAYS_GRAPH_TYPES = {"intermodal", "walk"}
19
+
20
+
21
+ def road_edges_for_ways(urban_graph: UrbanGraph) -> gpd.GeoDataFrame:
22
+ """Return the edge subset used to build ``geometry_type="ways"`` geometry.
23
+
24
+ For intermodal/walk graphs only pedestrian (``type == "walk"``) edges are
25
+ kept, so transit legs do not distort the resulting shape. Other graph types
26
+ (or graphs without a ``type`` column) use every edge.
27
+ """
28
+ edges = urban_graph.edges_gdf
29
+ if urban_graph.type in _WALK_ONLY_WAYS_GRAPH_TYPES and "type" in edges.columns:
30
+ walk_edges = edges[edges["type"] == "walk"]
31
+ if not walk_edges.empty:
32
+ return walk_edges
33
+ return edges
34
+
35
+
36
+ def build_voronoi_cells(
37
+ urban_graph: UrbanGraph,
38
+ reachable_graph_nodes_gdf: gpd.GeoDataFrame,
39
+ *,
40
+ value_column: str,
41
+ local_crs: Any,
42
+ clip_geom=None,
43
+ ) -> gpd.GeoDataFrame:
44
+ reachable_node_index = pd.Index(reachable_graph_nodes_gdf.index)
45
+ if clip_geom is None:
46
+ edges = urban_graph.edges_gdf[["u", "v"]]
47
+ edges_near_reachable = edges[edges["u"].isin(reachable_node_index) | edges["v"].isin(reachable_node_index)]
48
+ neighbor_nodes = pd.Index(edges_near_reachable["u"]).append(pd.Index(edges_near_reachable["v"])).unique()
49
+ boundary_nodes = neighbor_nodes.difference(reachable_node_index)
50
+ voronoi_node_index = reachable_node_index.append(boundary_nodes).unique()
51
+ else:
52
+ clip_mask = urban_graph.nodes_gdf.geometry.intersects(clip_geom)
53
+ voronoi_node_index = urban_graph.nodes_gdf.index[clip_mask].append(reachable_node_index).unique()
54
+ voronoi_nodes = urban_graph.nodes_gdf.loc[voronoi_node_index, ["geometry"]]
55
+ voronois = gpd.GeoDataFrame(geometry=list(voronoi_nodes.geometry.voronoi_polygons()), crs=local_crs)
56
+ reachable_cell_index = reachable_graph_nodes_gdf.reset_index(names="node").sjoin(voronois, how="inner")
57
+ return gpd.GeoDataFrame(
58
+ reachable_cell_index[["node", value_column]].reset_index(drop=True),
59
+ geometry=voronois.geometry.iloc[reachable_cell_index["index_right"].to_numpy()].reset_index(drop=True),
60
+ crs=local_crs,
61
+ )
62
+
63
+
64
+ def edge_speed_m_per_min(urban_graph: UrbanGraph) -> float:
65
+ edge_speeds = urban_graph.edges_gdf["length_meter"] / urban_graph.edges_gdf["time_min"]
66
+ edge_speeds = edge_speeds.replace([np.inf, -np.inf], np.nan).dropna()
67
+ edge_speeds = edge_speeds[edge_speeds > 0]
68
+ if edge_speeds.empty:
69
+ raise ValueError("Cannot calculate speed_m_per_min from edge length_meter/time_min")
70
+ return float(edge_speeds.median())
71
+
72
+
73
+ def build_radius_clip_geometry(
74
+ reachable_graph_nodes_gdf: gpd.GeoDataFrame,
75
+ *,
76
+ weight_value: float,
77
+ weight_type: str,
78
+ speed_m_per_min: float | None,
79
+ ):
80
+ buffer_size = (float(weight_value) - reachable_graph_nodes_gdf["dist"].astype(float)).clip(lower=0.0)
81
+
82
+ if weight_type == "time_min":
83
+ if speed_m_per_min is None:
84
+ raise ValueError("speed_m_per_min is required for weight_type='time_min'")
85
+ buffer_size = buffer_size * float(speed_m_per_min)
86
+ return reachable_graph_nodes_gdf.geometry.buffer(buffer_size, resolution=4).union_all()
87
+
88
+
89
+ def remove_inner_geom(geom):
90
+ if geom.is_empty:
91
+ return geom
92
+ if geom.geom_type == "Polygon":
93
+ return Polygon(geom.exterior)
94
+ if geom.geom_type == "MultiPolygon":
95
+ return MultiPolygon([Polygon(polygon.exterior) for polygon in geom.geoms])
96
+ if geom.geom_type == "GeometryCollection":
97
+ polygon_geoms = [
98
+ remove_inner_geom(part) for part in geom.geoms if part.geom_type in {"Polygon", "MultiPolygon"}
99
+ ]
100
+ if not polygon_geoms:
101
+ return GeometryCollection()
102
+ return unary_union(polygon_geoms)
103
+ return geom
104
+
105
+
106
+ def build_ways_clip_geometry(
107
+ urban_graph: UrbanGraph,
108
+ reachable_graph_nodes_gdf: gpd.GeoDataFrame,
109
+ *,
110
+ weight_value: float,
111
+ weight_type: str,
112
+ speed_m_per_min: float | None,
113
+ road_buffer_size: float,
114
+ ):
115
+ radius_geom = build_radius_clip_geometry(
116
+ reachable_graph_nodes_gdf,
117
+ weight_value=weight_value,
118
+ weight_type=weight_type,
119
+ speed_m_per_min=speed_m_per_min,
120
+ )
121
+ reachable_node_index = pd.Index(reachable_graph_nodes_gdf.index)
122
+ edges = road_edges_for_ways(urban_graph)
123
+ edge_mask = edges["u"].isin(reachable_node_index) | edges["v"].isin(reachable_node_index)
124
+ if not edge_mask.any():
125
+ return GeometryCollection()
126
+
127
+ roads = gpd.GeoDataFrame(
128
+ geometry=[edges.loc[edge_mask].geometry.buffer(float(road_buffer_size), resolution=1).union_all()],
129
+ crs=urban_graph.edges_gdf.crs,
130
+ )
131
+ clipped_roads = roads.clip(radius_geom, keep_geom_type=True).explode(ignore_index=True)
132
+ if clipped_roads.empty:
133
+ return GeometryCollection()
134
+
135
+ geom_to_keep = clipped_roads.sjoin(reachable_graph_nodes_gdf[["geometry"]], how="inner").index.unique()
136
+ if len(geom_to_keep) == 0:
137
+ return GeometryCollection()
138
+ return remove_inner_geom(clipped_roads.loc[geom_to_keep].union_all())
139
+
140
+
141
+ def build_separated_dist_polygons(
142
+ points: gpd.GeoDataFrame,
143
+ *,
144
+ weight_value: float,
145
+ weight_type: str,
146
+ step: float,
147
+ speed_m_per_min: float | None = None,
148
+ buffer_ratio: float = 0.7,
149
+ ) -> gpd.GeoDataFrame:
150
+ """
151
+ Build independent per-step buffer polygons over reachable graph nodes.
152
+
153
+ ``points`` must contain point geometry and a ``dist`` column in units of
154
+ ``weight_type``. For ``time_min`` the buffer size is converted to meters via
155
+ ``speed_m_per_min``.
156
+ """
157
+
158
+ def empty_result() -> gpd.GeoDataFrame:
159
+ return gpd.GeoDataFrame({"dist": []}, geometry=[], crs=points.crs)
160
+
161
+ if points.empty:
162
+ return empty_result()
163
+ if "dist" not in points.columns:
164
+ raise KeyError("points must contain 'dist' column")
165
+ if weight_value < 0:
166
+ raise ValueError(f"weight_value must be >= 0, got {weight_value}")
167
+ if step <= 0:
168
+ raise ValueError(f"step must be > 0, got {step}")
169
+ if weight_type == "time_min" and (speed_m_per_min is None or speed_m_per_min <= 0):
170
+ raise ValueError("speed_m_per_min must be positive for weight_type='time_min'")
171
+
172
+ points = points.loc[points["dist"].notna(), ["dist", points.geometry.name]].copy()
173
+ if points.empty:
174
+ return empty_result()
175
+
176
+ dist_for_buffer = points["dist"].astype(float).clip(lower=0.1)
177
+ upper_step = np.minimum((np.floor(dist_for_buffer / float(step)) + 1.0) * float(step), float(weight_value))
178
+ buffer_size = np.maximum(upper_step - dist_for_buffer, 0.0) * float(buffer_ratio)
179
+ if weight_type == "time_min":
180
+ buffer_size = buffer_size * float(speed_m_per_min)
181
+
182
+ points["dist"] = np.minimum(
183
+ np.ceil(points["dist"].astype(float).to_numpy() / float(step)) * float(step),
184
+ float(weight_value),
185
+ )
186
+ points.geometry = points.geometry.buffer(buffer_size)
187
+ points = points[~points.geometry.is_empty]
188
+ if points.empty:
189
+ return empty_result()
190
+
191
+ dissolved = points.dissolve(by="dist", as_index=False)
192
+ lines = dissolved.geometry.apply(polygons_to_multilinestring).union_all()
193
+ polygons = gpd.GeoDataFrame(geometry=list(polygonize(lines)), crs=points.crs)
194
+ if polygons.empty:
195
+ return empty_result()
196
+
197
+ polygon_points = polygons.copy()
198
+ polygon_points.geometry = polygons.representative_point()
199
+ stepped = polygon_points.sjoin(dissolved[["dist", "geometry"]], predicate="within")
200
+ if stepped.empty:
201
+ return empty_result()
202
+
203
+ stepped_dist = stepped.groupby(level=0)["dist"].mean()
204
+ stepped_dist = np.minimum(np.floor(stepped_dist / float(step)) * float(step), float(weight_value))
205
+ stepped_polygons = gpd.GeoDataFrame(
206
+ {"dist": stepped_dist.to_numpy()},
207
+ geometry=polygons.geometry.loc[stepped_dist.index].to_numpy(),
208
+ crs=points.crs,
209
+ )
210
+ return stepped_polygons.dissolve(by="dist", as_index=False).explode(ignore_index=True)
211
+
212
+
213
+ def build_stepped_accessibility_geometry(
214
+ urban_graph: UrbanGraph,
215
+ reachable_graph_nodes_gdf: gpd.GeoDataFrame,
216
+ *,
217
+ geometry_type: SteppedGeometryType,
218
+ weight_value: float,
219
+ weight_type: str,
220
+ step: float,
221
+ local_crs: Any,
222
+ zone: gpd.GeoDataFrame | gpd.GeoSeries | None,
223
+ buffer_factor: float,
224
+ road_buffer_size: float,
225
+ ) -> gpd.GeoDataFrame:
226
+ if reachable_graph_nodes_gdf.empty:
227
+ return gpd.GeoDataFrame()
228
+
229
+ speed_m_per_min = edge_speed_m_per_min(urban_graph) if weight_type == "time_min" else None
230
+
231
+ if geometry_type == "separate":
232
+ result = build_separated_dist_polygons(
233
+ reachable_graph_nodes_gdf,
234
+ weight_value=float(weight_value),
235
+ weight_type=weight_type,
236
+ step=float(step),
237
+ speed_m_per_min=speed_m_per_min,
238
+ buffer_ratio=buffer_factor,
239
+ )
240
+ if zone is not None and not result.empty:
241
+ result = result.clip(zone.to_crs(local_crs))
242
+ return result
243
+
244
+ if geometry_type is None:
245
+ if zone is None:
246
+ clip_geom = convex_hull(reachable_graph_nodes_gdf.union_all())
247
+ else:
248
+ clip_geom = zone.to_crs(local_crs).union_all()
249
+ elif geometry_type == "radius":
250
+ clip_geom = build_radius_clip_geometry(
251
+ reachable_graph_nodes_gdf,
252
+ weight_value=float(weight_value),
253
+ weight_type=weight_type,
254
+ speed_m_per_min=speed_m_per_min,
255
+ )
256
+ elif geometry_type == "ways":
257
+ clip_geom = build_ways_clip_geometry(
258
+ urban_graph,
259
+ reachable_graph_nodes_gdf,
260
+ weight_value=float(weight_value),
261
+ weight_type=weight_type,
262
+ speed_m_per_min=speed_m_per_min,
263
+ road_buffer_size=road_buffer_size,
264
+ )
265
+ else:
266
+ raise ValueError(f"Unsupported geometry_type={geometry_type!r}")
267
+
268
+ if geometry_type is not None and zone is not None:
269
+ clip_geom = clip_geom.intersection(zone.to_crs(local_crs).union_all())
270
+
271
+ nodes_for_voronoi = reachable_graph_nodes_gdf.copy()
272
+ nodes_for_voronoi["dist"] = np.minimum(
273
+ np.ceil(nodes_for_voronoi["dist"].astype(float).to_numpy() / float(step)) * float(step),
274
+ float(weight_value),
275
+ )
276
+
277
+ cells = build_voronoi_cells(
278
+ urban_graph,
279
+ nodes_for_voronoi,
280
+ value_column="dist",
281
+ local_crs=local_crs,
282
+ clip_geom=clip_geom if geometry_type is not None else None,
283
+ )
284
+ result = cells.dissolve(by="dist", as_index=False).drop(columns=["node"])
285
+ return result.clip(clip_geom)