ObjectNat 0.2.7__py3-none-any.whl → 1.0.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.

Potentially problematic release.


This version of ObjectNat might be problematic. Click here for more details.

Files changed (31) hide show
  1. objectnat/_api.py +5 -8
  2. objectnat/_config.py +0 -24
  3. objectnat/_version.py +1 -1
  4. objectnat/methods/coverage_zones/__init__.py +2 -0
  5. objectnat/methods/coverage_zones/graph_coverage.py +118 -0
  6. objectnat/methods/coverage_zones/radius_voronoi.py +45 -0
  7. objectnat/methods/isochrones/__init__.py +1 -0
  8. objectnat/methods/isochrones/isochrone_utils.py +130 -0
  9. objectnat/methods/isochrones/isochrones.py +325 -0
  10. objectnat/methods/noise/__init__.py +2 -2
  11. objectnat/methods/noise/noise_sim.py +14 -9
  12. objectnat/methods/point_clustering/__init__.py +1 -0
  13. objectnat/methods/{cluster_points_in_polygons.py → point_clustering/cluster_points_in_polygons.py} +22 -28
  14. objectnat/methods/provision/__init__.py +1 -0
  15. objectnat/methods/provision/provision.py +4 -4
  16. objectnat/methods/provision/provision_model.py +17 -18
  17. objectnat/methods/utils/geom_utils.py +54 -3
  18. objectnat/methods/utils/graph_utils.py +127 -0
  19. objectnat/methods/utils/math_utils.py +32 -0
  20. objectnat/methods/visibility/__init__.py +6 -0
  21. objectnat/methods/{visibility_analysis.py → visibility/visibility_analysis.py} +167 -208
  22. objectnat-1.0.0.dist-info/METADATA +143 -0
  23. objectnat-1.0.0.dist-info/RECORD +32 -0
  24. objectnat/methods/balanced_buildings.py +0 -69
  25. objectnat/methods/coverage_zones.py +0 -90
  26. objectnat/methods/isochrones.py +0 -143
  27. objectnat/methods/living_buildings_osm.py +0 -168
  28. objectnat-0.2.7.dist-info/METADATA +0 -118
  29. objectnat-0.2.7.dist-info/RECORD +0 -26
  30. {objectnat-0.2.7.dist-info → objectnat-1.0.0.dist-info}/LICENSE.txt +0 -0
  31. {objectnat-0.2.7.dist-info → objectnat-1.0.0.dist-info}/WHEEL +0 -0
objectnat/_api.py CHANGED
@@ -1,14 +1,11 @@
1
1
  # pylint: disable=unused-import,wildcard-import,unused-wildcard-import
2
- from iduedu import *
3
2
 
4
- from .methods.balanced_buildings import get_balanced_buildings
5
- from .methods.cluster_points_in_polygons import get_clusters_polygon
6
- from .methods.coverage_zones import get_isochrone_zone_coverage, get_radius_zone_coverage
7
- from .methods.isochrones import get_accessibility_isochrones
8
- from .methods.living_buildings_osm import download_buildings
3
+ from .methods.coverage_zones import get_graph_coverage, get_radius_coverage
4
+ from .methods.isochrones import get_accessibility_isochrone_stepped, get_accessibility_isochrones
9
5
  from .methods.noise import simulate_noise
10
- from .methods.provision.provision import clip_provision, get_service_provision, recalculate_links
11
- from .methods.visibility_analysis import (
6
+ from .methods.point_clustering import get_clusters_polygon
7
+ from .methods.provision import clip_provision, get_service_provision, recalculate_links
8
+ from .methods.visibility import (
12
9
  calculate_visibility_catchment_area,
13
10
  get_visibilities_from_points,
14
11
  get_visibility,
objectnat/_config.py CHANGED
@@ -1,7 +1,6 @@
1
1
  import sys
2
2
  from typing import Literal
3
3
 
4
- from iduedu import config as iduedu_config
5
4
  from loguru import logger
6
5
 
7
6
 
@@ -12,10 +11,6 @@ class Config:
12
11
 
13
12
  Attributes
14
13
  ----------
15
- overpass_url : str
16
- URL for accessing the Overpass API. Defaults to "http://lz4.overpass-api.de/api/interpreter".
17
- timeout : int or None
18
- Timeout in seconds for API requests. If None, no timeout is applied.
19
14
  enable_tqdm_bar : bool
20
15
  Enables or disables progress bars (via tqdm). Defaults to True.
21
16
  logger : Logger
@@ -25,43 +20,24 @@ class Config:
25
20
  -------
26
21
  change_logger_lvl(lvl: Literal["TRACE", "DEBUG", "INFO", "WARN", "ERROR"])
27
22
  Changes the logging level to the specified value.
28
- set_overpass_url(url: str)
29
- Sets a new Overpass API URL.
30
- set_timeout(timeout: int)
31
- Sets the timeout for API requests.
32
23
  set_enable_tqdm(enable: bool)
33
24
  Enables or disables progress bars in the application.
34
25
  """
35
26
 
36
27
  def __init__(
37
28
  self,
38
- overpass_url="http://lz4.overpass-api.de/api/interpreter",
39
- timeout=None,
40
29
  enable_tqdm_bar=True,
41
30
  ):
42
- self.overpass_url = overpass_url
43
- self.timeout = timeout
44
31
  self.enable_tqdm_bar = enable_tqdm_bar
45
32
  self.logger = logger
46
- self.iduedu_config = iduedu_config
47
33
  self.pandarallel_use_file_system = False
48
34
 
49
35
  def change_logger_lvl(self, lvl: Literal["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]):
50
36
  self.logger.remove()
51
37
  self.logger.add(sys.stderr, level=lvl)
52
- self.iduedu_config.change_logger_lvl(lvl)
53
-
54
- def set_overpass_url(self, url: str):
55
- self.overpass_url = url
56
- self.iduedu_config.set_overpass_url(url)
57
-
58
- def set_timeout(self, timeout: int):
59
- self.timeout = timeout
60
- self.iduedu_config.set_timeout(timeout)
61
38
 
62
39
  def set_enable_tqdm(self, enable: bool):
63
40
  self.enable_tqdm_bar = enable
64
- self.iduedu_config.set_enable_tqdm(enable)
65
41
 
66
42
  def set_pandarallel_use_file_system(self, enable: bool):
67
43
  self.pandarallel_use_file_system = enable
objectnat/_version.py CHANGED
@@ -1 +1 @@
1
- VERSION = "0.2.7"
1
+ VERSION = "1.0.0"
@@ -0,0 +1,2 @@
1
+ from .graph_coverage import get_graph_coverage
2
+ from .radius_voronoi import get_radius_coverage
@@ -0,0 +1,118 @@
1
+ from typing import Literal
2
+
3
+ import geopandas as gpd
4
+ import networkx as nx
5
+ import pandas as pd
6
+ from pyproj.exceptions import CRSError
7
+ from shapely import Point, concave_hull
8
+
9
+ from objectnat.methods.utils.graph_utils import get_closest_nodes_from_gdf, remove_weakly_connected_nodes
10
+
11
+
12
+ def get_graph_coverage(
13
+ gdf_from: gpd.GeoDataFrame,
14
+ nx_graph: nx.Graph,
15
+ weight_type: Literal["time_min", "length_meter"],
16
+ weight_value_cutoff: float = None,
17
+ zone: gpd.GeoDataFrame = None,
18
+ ):
19
+ """
20
+ Calculate coverage zones from source points through a graph network using Dijkstra's algorithm
21
+ and Voronoi diagrams.
22
+
23
+ The function works by:
24
+ 1. Finding nearest graph nodes for each input point
25
+ 2. Calculating all reachable nodes within cutoff distance using Dijkstra
26
+ 3. Creating Voronoi polygons around graph nodes
27
+ 4. Combining reachability information with Voronoi cells
28
+ 5. Clipping results to specified zone boundary
29
+
30
+ Parameters
31
+ ----------
32
+ gdf_from : gpd.GeoDataFrame
33
+ Source points from which coverage is calculated.
34
+ nx_graph : nx.Graph
35
+ NetworkX graph representing the transportation network.
36
+ weight_type : Literal["time_min", "length_meter"]
37
+ Edge attribute to use as weight for path calculations.
38
+ weight_value_cutoff : float, optional
39
+ Maximum weight value for path calculations (e.g., max travel time/distance).
40
+ zone : gpd.GeoDataFrame, optional
41
+ Boundary polygon to clip the resulting coverage zones. If None, concave hull of reachable nodes will be used.
42
+
43
+ Returns
44
+ -------
45
+ gpd.GeoDataFrame
46
+ GeoDataFrame with coverage zones polygons, each associated with its source point, returns in the same CRS as
47
+ original gdf_from.
48
+
49
+ Notes
50
+ -----
51
+ - The graph must have a valid CRS attribute in its graph properties
52
+ - MultiGraph/MultiDiGraph inputs will be converted to simple Graph/DiGraph
53
+
54
+ Examples
55
+ --------
56
+ >>> from iduedu import get_intermodal_graph # pip install iduedu to get OSM city network graph
57
+ >>> points = gpd.read_file('points.geojson')
58
+ >>> graph = get_intermodal_graph(osm_id=1114252)
59
+ >>> coverage = get_graph_coverage(points, graph, "time_min", 15)
60
+ """
61
+ original_crs = gdf_from.crs
62
+ try:
63
+ local_crs = nx_graph.graph["crs"]
64
+ except KeyError as exc:
65
+ raise ValueError("Graph does not have crs attribute") from exc
66
+
67
+ try:
68
+ points = gdf_from.copy()
69
+ points.to_crs(local_crs, inplace=True)
70
+ except CRSError as e:
71
+ raise CRSError(f"Graph crs ({local_crs}) has invalid format.") from e
72
+
73
+ if nx_graph.is_multigraph():
74
+ if nx_graph.is_directed():
75
+ nx_graph = nx.DiGraph(nx_graph)
76
+ else:
77
+ nx_graph = nx.Graph(nx_graph)
78
+ nx_graph = remove_weakly_connected_nodes(nx_graph)
79
+ sparse_matrix = nx.to_scipy_sparse_array(nx_graph, weight=weight_type)
80
+ transposed_matrix = sparse_matrix.transpose()
81
+ reversed_graph = nx.from_scipy_sparse_array(
82
+ transposed_matrix, edge_attribute=weight_type, create_using=type(nx_graph)
83
+ )
84
+
85
+ points.geometry = points.representative_point()
86
+
87
+ _, nearest_nodes = get_closest_nodes_from_gdf(points, nx_graph)
88
+
89
+ points["nearest_node"] = nearest_nodes
90
+
91
+ _, nearest_paths = nx.multi_source_dijkstra(
92
+ reversed_graph, nearest_nodes, weight=weight_type, cutoff=weight_value_cutoff
93
+ )
94
+ reachable_nodes = list(nearest_paths.keys())
95
+ graph_points = pd.DataFrame(
96
+ data=[{"node": node, "geometry": Point(data["x"], data["y"])} for node, data in nx_graph.nodes(data=True)]
97
+ ).set_index("node")
98
+ nearest_nodes = pd.DataFrame(
99
+ data=[path[0] for path in nearest_paths.values()], index=reachable_nodes, columns=["node_to"]
100
+ )
101
+ graph_nodes_gdf = gpd.GeoDataFrame(
102
+ graph_points.merge(nearest_nodes, left_index=True, right_index=True, how="left"),
103
+ geometry="geometry",
104
+ crs=local_crs,
105
+ )
106
+ graph_nodes_gdf["node_to"] = graph_nodes_gdf["node_to"].fillna("non_reachable")
107
+ voronois = gpd.GeoDataFrame(geometry=graph_nodes_gdf.voronoi_polygons(), crs=local_crs)
108
+ graph_nodes_gdf = graph_nodes_gdf[graph_nodes_gdf["node_to"] != "non_reachable"]
109
+ zone_coverages = voronois.sjoin(graph_nodes_gdf).dissolve(by="node_to").reset_index().drop(columns=["node"])
110
+ zone_coverages = zone_coverages.merge(
111
+ points.drop(columns="geometry"), left_on="node_to", right_on="nearest_node", how="inner"
112
+ ).reset_index(drop=True)
113
+ zone_coverages.drop(columns=["node_to", "nearest_node"], inplace=True)
114
+ if zone is None:
115
+ zone = concave_hull(graph_nodes_gdf[~graph_nodes_gdf["node_to"].isna()].union_all(), ratio=0.5)
116
+ else:
117
+ zone = zone.to_crs(local_crs)
118
+ return zone_coverages.clip(zone).to_crs(original_crs)
@@ -0,0 +1,45 @@
1
+ import geopandas as gpd
2
+ import numpy as np
3
+
4
+
5
+ def get_radius_coverage(gdf_from: gpd.GeoDataFrame, radius: float, resolution: int = 32):
6
+ """
7
+ Calculate radius-based coverage zones using Voronoi polygons.
8
+
9
+ Parameters
10
+ ----------
11
+ gdf_from : gpd.GeoDataFrame
12
+ Source points for which coverage zones are calculated.
13
+ radius : float
14
+ Maximum coverage radius in meters.
15
+ resolution : int, optional
16
+ Number of segments used to approximate quarter-circle in buffer (default=32).
17
+
18
+ Returns
19
+ -------
20
+ gpd.GeoDataFrame
21
+ GeoDataFrame with smoothed coverage zone polygons in the same CRS as original gdf_from.
22
+
23
+ Notes
24
+ -----
25
+ - Automatically converts to local UTM CRS for accurate distance measurements
26
+ - Final zones are slightly contracted then expanded for smoothing effect
27
+
28
+ Examples
29
+ --------
30
+ >>> facilities = gpd.read_file('healthcare.shp')
31
+ >>> coverage = get_radius_coverage(facilities, radius=500)
32
+ """
33
+ original_crs = gdf_from.crs
34
+ local_crs = gdf_from.estimate_utm_crs()
35
+ gdf_from = gdf_from.to_crs(local_crs)
36
+ bounds = gdf_from.buffer(radius).union_all()
37
+ coverage_polys = gpd.GeoDataFrame(geometry=gdf_from.voronoi_polygons().clip(bounds, keep_geom_type=True))
38
+ coverage_polys = coverage_polys.sjoin(gdf_from)
39
+ coverage_polys["area"] = coverage_polys.area
40
+ coverage_polys["buffer"] = np.pow(coverage_polys["area"], 1 / 3)
41
+ coverage_polys.geometry = coverage_polys.buffer(-coverage_polys["buffer"], resolution=1, join_style="mitre").buffer(
42
+ coverage_polys["buffer"] * 0.9, resolution=resolution
43
+ )
44
+ coverage_polys.drop(columns=["buffer", "area"], inplace=True)
45
+ return coverage_polys.to_crs(original_crs)
@@ -0,0 +1 @@
1
+ from .isochrones import get_accessibility_isochrones, get_accessibility_isochrone_stepped
@@ -0,0 +1,130 @@
1
+ from typing import Literal
2
+
3
+ import geopandas as gpd
4
+ import networkx as nx
5
+ import numpy as np
6
+ import pandas as pd
7
+ from pyproj.exceptions import CRSError
8
+
9
+ from objectnat import config
10
+ from objectnat.methods.utils.graph_utils import get_closest_nodes_from_gdf, remove_weakly_connected_nodes
11
+
12
+ logger = config.logger
13
+
14
+
15
+ def _validate_inputs(
16
+ points: gpd.GeoDataFrame, weight_value: float, weight_type: Literal["time_min", "length_meter"], nx_graph: nx.Graph
17
+ ) -> tuple[str, str]:
18
+ """Validate common inputs for accessibility functions."""
19
+ if weight_value <= 0:
20
+ raise ValueError("Weight value must be greater than 0")
21
+ if weight_type not in ["time_min", "length_meter"]:
22
+ raise UserWarning("Weight type should be either 'time_min' or 'length_meter'")
23
+
24
+ try:
25
+ local_crs = nx_graph.graph["crs"]
26
+ except KeyError as exc:
27
+ raise ValueError("Graph does not have crs attribute") from exc
28
+ try:
29
+ graph_type = nx_graph.graph["type"]
30
+ except KeyError as exc:
31
+ raise ValueError("Graph does not have type attribute") from exc
32
+
33
+ try:
34
+ points.to_crs(local_crs, inplace=True)
35
+ except CRSError as e:
36
+ raise CRSError(f"Graph crs ({local_crs}) has invalid format.") from e
37
+
38
+ return local_crs, graph_type
39
+
40
+
41
+ def _prepare_graph_and_nodes(
42
+ points: gpd.GeoDataFrame, nx_graph: nx.Graph, graph_type: str, weight_type: str, weight_value: float
43
+ ) -> tuple[nx.Graph, gpd.GeoDataFrame, pd.DataFrame, float]:
44
+ """Prepare graph and calculate nearest nodes with distances."""
45
+ nx_graph = remove_weakly_connected_nodes(nx_graph)
46
+ distances, nearest_nodes = get_closest_nodes_from_gdf(points, nx_graph)
47
+ points["nearest_node"] = nearest_nodes
48
+
49
+ dist_nearest = pd.DataFrame(data=distances, index=nearest_nodes, columns=["dist"]).drop_duplicates()
50
+
51
+ # Calculate speed adjustment if needed
52
+ speed = 0
53
+ if graph_type in ["walk", "intermodal"] and weight_type == "time_min":
54
+ try:
55
+ speed = nx_graph.graph["walk_speed"]
56
+ except KeyError:
57
+ logger.warning("There is no walk_speed in graph, set to the default speed - 83.33 m/min")
58
+ speed = 83.33
59
+ dist_nearest = dist_nearest / speed
60
+ elif weight_type == "time_min":
61
+ speed = 20 * 1000 / 60
62
+ dist_nearest = dist_nearest / speed
63
+
64
+ if (dist_nearest > weight_value).all().all():
65
+ raise RuntimeError(
66
+ "The point(s) lie further from the graph than weight_value, it's impossible to "
67
+ "construct isochrones. Check the coordinates of the point(s)/their projection"
68
+ )
69
+
70
+ return nx_graph, points, dist_nearest, speed
71
+
72
+
73
+ def _process_pt_data(
74
+ nodes: gpd.GeoDataFrame, edges: gpd.GeoDataFrame, graph_type: str
75
+ ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame] | tuple[None, None]:
76
+ """Process public transport data if available."""
77
+ if "desc" in nodes.columns and "stop" in nodes["desc"].unique():
78
+ pt_nodes = nodes[nodes["desc"] == "stop"]
79
+ if graph_type == "intermodal":
80
+ edges = edges[~edges["type"].isin(["walk", "boarding"])]
81
+ pt_nodes = pt_nodes[["desc", "route", "geometry"]]
82
+ edges = edges[["type", "route", "geometry"]]
83
+ return pt_nodes, edges
84
+ return None, None
85
+
86
+
87
+ def _calculate_distance_matrix(
88
+ nx_graph: nx.Graph,
89
+ nearest_nodes: np.ndarray,
90
+ weight_type: str,
91
+ weight_value: float,
92
+ dist_nearest: pd.DataFrame,
93
+ ) -> tuple[pd.DataFrame, nx.Graph]:
94
+ """Calculate distance matrix from nearest nodes."""
95
+
96
+ data = {}
97
+ for source in nearest_nodes:
98
+ dist = nx.single_source_dijkstra_path_length(nx_graph, source, weight=weight_type, cutoff=weight_value)
99
+ data.update({source: dist})
100
+
101
+ dist_matrix = pd.DataFrame.from_dict(data, orient="index")
102
+ dist_matrix = dist_matrix.add(dist_nearest.dist, axis=0)
103
+ dist_matrix = dist_matrix.mask(dist_matrix > weight_value, np.nan)
104
+ dist_matrix.dropna(how="all", inplace=True)
105
+ dist_matrix.dropna(how="all", axis=1, inplace=True)
106
+
107
+ subgraph = nx_graph.subgraph(dist_matrix.columns.to_list())
108
+
109
+ return dist_matrix, subgraph
110
+
111
+
112
+ def _create_isochrones_gdf(
113
+ points: gpd.GeoDataFrame,
114
+ results: list,
115
+ dist_matrix: pd.DataFrame,
116
+ local_crs: str,
117
+ weight_type: str,
118
+ weight_value: float,
119
+ ) -> gpd.GeoDataFrame:
120
+ """Create final isochrones GeoDataFrame."""
121
+ isochrones = gpd.GeoDataFrame(geometry=results, index=dist_matrix.index, crs=local_crs)
122
+ isochrones = (
123
+ points.drop(columns="geometry")
124
+ .merge(isochrones, left_on="nearest_node", right_index=True, how="left")
125
+ .drop(columns="nearest_node")
126
+ )
127
+ isochrones = gpd.GeoDataFrame(isochrones, geometry="geometry", crs=local_crs)
128
+ isochrones["weight_type"] = weight_type
129
+ isochrones["weight_value"] = weight_value
130
+ return isochrones