ObjectNat 1.1.0__py3-none-any.whl → 1.2.1__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.
- objectnat/__init__.py +9 -13
- objectnat/_api.py +14 -13
- objectnat/_config.py +47 -47
- objectnat/_version.py +1 -1
- objectnat/methods/coverage_zones/__init__.py +3 -3
- objectnat/methods/coverage_zones/graph_coverage.py +98 -108
- objectnat/methods/coverage_zones/radius_voronoi_coverage.py +37 -45
- objectnat/methods/coverage_zones/stepped_coverage.py +126 -142
- objectnat/methods/isochrones/__init__.py +1 -1
- objectnat/methods/isochrones/isochrone_utils.py +167 -167
- objectnat/methods/isochrones/isochrones.py +262 -299
- objectnat/methods/noise/__init__.py +3 -3
- objectnat/methods/noise/noise_init_data.py +10 -10
- objectnat/methods/noise/noise_reduce.py +155 -155
- objectnat/methods/noise/{noise_sim.py → noise_simulation.py} +452 -448
- objectnat/methods/noise/noise_simulation_simplified.py +209 -0
- objectnat/methods/point_clustering/__init__.py +1 -1
- objectnat/methods/point_clustering/cluster_points_in_polygons.py +115 -116
- objectnat/methods/provision/__init__.py +1 -1
- objectnat/methods/provision/provision.py +117 -110
- objectnat/methods/provision/provision_exceptions.py +59 -59
- objectnat/methods/provision/provision_model.py +337 -337
- objectnat/methods/utils/__init__.py +1 -0
- objectnat/methods/utils/geom_utils.py +173 -130
- objectnat/methods/utils/graph_utils.py +306 -206
- objectnat/methods/utils/math_utils.py +32 -32
- objectnat/methods/visibility/__init__.py +6 -6
- objectnat/methods/visibility/visibility_analysis.py +470 -511
- {objectnat-1.1.0.dist-info → objectnat-1.2.1.dist-info}/LICENSE.txt +28 -28
- objectnat-1.2.1.dist-info/METADATA +115 -0
- objectnat-1.2.1.dist-info/RECORD +33 -0
- objectnat/methods/noise/noise_exceptions.py +0 -14
- objectnat-1.1.0.dist-info/METADATA +0 -148
- objectnat-1.1.0.dist-info/RECORD +0 -33
- {objectnat-1.1.0.dist-info → objectnat-1.2.1.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# simplified version
|
|
2
|
+
import geopandas as gpd
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from shapely.ops import polygonize, unary_union
|
|
5
|
+
from tqdm.auto import tqdm
|
|
6
|
+
|
|
7
|
+
from objectnat.methods.noise.noise_reduce import dist_to_target_db
|
|
8
|
+
from objectnat.methods.utils.geom_utils import (
|
|
9
|
+
distribute_points_on_linestrings,
|
|
10
|
+
distribute_points_on_polygons,
|
|
11
|
+
polygons_to_multilinestring,
|
|
12
|
+
)
|
|
13
|
+
from objectnat.methods.visibility.visibility_analysis import get_visibility_accurate
|
|
14
|
+
|
|
15
|
+
MAX_DB_VALUE = 194
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def calculate_simplified_noise_frame(
|
|
19
|
+
noise_sources: gpd.GeoDataFrame, obstacles: gpd.GeoDataFrame, air_temperature, **kwargs
|
|
20
|
+
) -> gpd.GeoDataFrame:
|
|
21
|
+
"""
|
|
22
|
+
Calculates a simplified environmental noise frame using static noise source geometries without simulating
|
|
23
|
+
full sound wave propagation or reflections.
|
|
24
|
+
|
|
25
|
+
This function provides a fast approximation of noise dispersion from a variety of source geometries, including
|
|
26
|
+
points (e.g., traffic noise measurement points), lines (e.g., roads or railways), and polygons (e.g., industrial
|
|
27
|
+
zones or buildings). Instead of simulating detailed wave interactions and reflections, it constructs an
|
|
28
|
+
envelope of potential noise exposure by buffering the source geometry and applying simplified decay formulas
|
|
29
|
+
based on sound power, frequency and temperature.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
noise_sources (gpd.GeoDataFrame): A GeoDataFrame containing geometries of noise sources (Point, LineString,
|
|
33
|
+
or Polygon). Each feature must have the following two columns:
|
|
34
|
+
- 'source_noise_db': Initial sound level at the source, in decibels (dB).
|
|
35
|
+
- 'geometric_mean_freq_hz': Characteristic sound frequency (Hz) used to model distance-based
|
|
36
|
+
attenuation.
|
|
37
|
+
Values in 'source_noise_db' must not exceed the physical maximum of 194 dB. Missing or NaN values in
|
|
38
|
+
required fields will raise an error.
|
|
39
|
+
|
|
40
|
+
obstacles (gpd.GeoDataFrame): A GeoDataFrame representing physical obstructions in the environment
|
|
41
|
+
(e.g., buildings, walls, terrain). These are used to build visibility masks that affect where sound can
|
|
42
|
+
propagate. Geometry will be simplified for performance using a default tolerance of 1 unit.
|
|
43
|
+
|
|
44
|
+
air_temperature (float): The ambient air temperature in degrees Celsius. This value influences the
|
|
45
|
+
attenuation model of sound in the atmosphere. Temperatures significantly outside the typical 0–30°C
|
|
46
|
+
range may lead to inaccurate results.
|
|
47
|
+
|
|
48
|
+
Optional kwargs:
|
|
49
|
+
- target_noise_db (float, optional): The minimum sound level threshold (in dB) to be modeled. Any value below
|
|
50
|
+
this threshold is considered insignificant and will be excluded from the resulting noise frame.
|
|
51
|
+
Default is 40 dB.
|
|
52
|
+
- db_sim_step (float, optional): The simulation step size (in dB) used to discretize sound levels into
|
|
53
|
+
spatial layers. Default is 5. Smaller values produce more detailed output but increase computation time.
|
|
54
|
+
- linestring_point_radius (float, optional): The spacing radius (in meters) used when converting LineString
|
|
55
|
+
geometries into distributed point sources for simulation. Default is 30. Reducing this value improves
|
|
56
|
+
detail along long lines.
|
|
57
|
+
- polygon_point_radius (float, optional): The point spacing (in meters) for distributing sources within
|
|
58
|
+
Polygon geometries. Default is 15. Points are sampled across the polygon’s surface and perimeter to
|
|
59
|
+
represent the full sound-emitting area.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
(gpd.GeoDataFrame): A GeoDataFrame representing simplified noise distribution areas. The output geometries
|
|
63
|
+
are polygons where each polygon is associated with the maximum sound level (in dB) present in that area,
|
|
64
|
+
as derived from overlapping source zones. The resulting data is dissolved by noise level and returned in
|
|
65
|
+
the original coordinate reference system (CRS) of the input sources.
|
|
66
|
+
|
|
67
|
+
Notes:
|
|
68
|
+
- The function does not model reflections or complex diffraction effects. It uses straight-line
|
|
69
|
+
visibility (line-of-sight) and a layered distance-decay approach for rapid estimation.
|
|
70
|
+
- Obstacles are used for visibility masking only, not as reflectors or absorbers.
|
|
71
|
+
- Output resolution and accuracy depend heavily on the geometry type and point distribution settings.
|
|
72
|
+
- Results are useful for quick noise mapping or for generating initial noise envelopes prior to more
|
|
73
|
+
detailed simulations.
|
|
74
|
+
"""
|
|
75
|
+
target_noise_db = kwargs.get("target_noise_db", 40)
|
|
76
|
+
db_sim_step = kwargs.get("db_sim_step", 5)
|
|
77
|
+
linestring_point_radius = kwargs.get("linestring_point_radius", 30)
|
|
78
|
+
polygon_point_radius = kwargs.get("polygon_point_radius", 15)
|
|
79
|
+
|
|
80
|
+
required_columns = ["source_noise_db", "geometric_mean_freq_hz"]
|
|
81
|
+
for col in required_columns:
|
|
82
|
+
if col not in noise_sources.columns:
|
|
83
|
+
raise ValueError(f"'{col}' column is missing in provided GeoDataFrame")
|
|
84
|
+
if noise_sources[col].isnull().any():
|
|
85
|
+
raise ValueError(f"Column '{col}' contains missing (NaN) values")
|
|
86
|
+
if (noise_sources["source_noise_db"] > MAX_DB_VALUE).any():
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"One or more values in 'source_noise_db' column exceed the physical limit of {MAX_DB_VALUE} dB."
|
|
89
|
+
)
|
|
90
|
+
original_crs = noise_sources.crs
|
|
91
|
+
if len(obstacles) > 0:
|
|
92
|
+
obstacles = obstacles.copy()
|
|
93
|
+
obstacles.geometry = obstacles.geometry.simplify(tolerance=1)
|
|
94
|
+
local_crs = obstacles.estimate_utm_crs()
|
|
95
|
+
obstacles.to_crs(local_crs, inplace=True)
|
|
96
|
+
noise_sources.to_crs(local_crs, inplace=True)
|
|
97
|
+
else:
|
|
98
|
+
local_crs = noise_sources.estimate_utm_crs()
|
|
99
|
+
noise_sources.to_crs(local_crs, inplace=True)
|
|
100
|
+
noise_sources.reset_index(drop=True)
|
|
101
|
+
|
|
102
|
+
noise_sources = noise_sources.explode(ignore_index=True)
|
|
103
|
+
noise_sources["geom_type"] = noise_sources.geom_type
|
|
104
|
+
|
|
105
|
+
grouped_sources = noise_sources.groupby(by=["source_noise_db", "geometric_mean_freq_hz", "geom_type"])
|
|
106
|
+
|
|
107
|
+
frame_result = []
|
|
108
|
+
total_tasks = 0
|
|
109
|
+
with tqdm(total=total_tasks, desc="Simulating noise") as pbar:
|
|
110
|
+
for (source_db, freq_hz, geom_type), group_gdf in grouped_sources:
|
|
111
|
+
# calculating layer dist and db values
|
|
112
|
+
dist_db = [(0, source_db)]
|
|
113
|
+
cur_db = source_db - db_sim_step
|
|
114
|
+
max_dist = 0
|
|
115
|
+
while cur_db > target_noise_db - db_sim_step:
|
|
116
|
+
if cur_db - db_sim_step < target_noise_db:
|
|
117
|
+
cur_db = target_noise_db
|
|
118
|
+
max_dist = dist_to_target_db(source_db, cur_db, freq_hz, air_temperature)
|
|
119
|
+
dist_db.append((max_dist, cur_db))
|
|
120
|
+
cur_db -= db_sim_step
|
|
121
|
+
|
|
122
|
+
# increasing max_dist for extra view
|
|
123
|
+
max_dist = max_dist * 1.2
|
|
124
|
+
|
|
125
|
+
if geom_type == "Point":
|
|
126
|
+
total_tasks += len(group_gdf)
|
|
127
|
+
pbar.total = total_tasks
|
|
128
|
+
pbar.refresh()
|
|
129
|
+
for _, row in group_gdf.iterrows():
|
|
130
|
+
point_from = row.geometry
|
|
131
|
+
point_buffer = point_from.buffer(max_dist, resolution=16)
|
|
132
|
+
local_obstacles = obstacles[obstacles.intersects(point_buffer)]
|
|
133
|
+
vis_poly = get_visibility_accurate(point_from, obstacles=local_obstacles, view_distance=max_dist)
|
|
134
|
+
noise_from_feature = _eval_donuts_gdf(point_from, dist_db, local_crs, vis_poly)
|
|
135
|
+
frame_result.append(noise_from_feature)
|
|
136
|
+
pbar.update(1)
|
|
137
|
+
|
|
138
|
+
elif geom_type == "LineString":
|
|
139
|
+
layer_points = distribute_points_on_linestrings(
|
|
140
|
+
group_gdf, radius=linestring_point_radius, lloyd_relax_n=1
|
|
141
|
+
)
|
|
142
|
+
total_tasks += len(layer_points)
|
|
143
|
+
pbar.total = total_tasks
|
|
144
|
+
pbar.refresh()
|
|
145
|
+
noise_from_feature = _process_lines_or_polygons(
|
|
146
|
+
group_gdf, max_dist, obstacles, layer_points, dist_db, local_crs, pbar
|
|
147
|
+
)
|
|
148
|
+
frame_result.append(noise_from_feature)
|
|
149
|
+
elif geom_type == "Polygon":
|
|
150
|
+
group_gdf.geometry = group_gdf.buffer(0.1, resolution=1)
|
|
151
|
+
layer_points = distribute_points_on_polygons(
|
|
152
|
+
group_gdf, only_exterior=False, radius=polygon_point_radius, lloyd_relax_n=1
|
|
153
|
+
)
|
|
154
|
+
total_tasks += len(layer_points)
|
|
155
|
+
pbar.total = total_tasks
|
|
156
|
+
pbar.refresh()
|
|
157
|
+
noise_from_feature = _process_lines_or_polygons(
|
|
158
|
+
group_gdf, max_dist, obstacles, layer_points, dist_db, local_crs, pbar
|
|
159
|
+
)
|
|
160
|
+
frame_result.append(noise_from_feature)
|
|
161
|
+
else:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
noise_gdf = gpd.GeoDataFrame(pd.concat(frame_result, ignore_index=True), crs=local_crs)
|
|
165
|
+
polygons = gpd.GeoDataFrame(
|
|
166
|
+
geometry=list(polygonize(noise_gdf.geometry.apply(polygons_to_multilinestring).union_all())), crs=local_crs
|
|
167
|
+
)
|
|
168
|
+
polygons_points = polygons.copy()
|
|
169
|
+
polygons_points.geometry = polygons.representative_point()
|
|
170
|
+
sim_result = polygons_points.sjoin(noise_gdf, predicate="within").reset_index()
|
|
171
|
+
sim_result = sim_result.groupby("index").agg({"noise_level": "max"})
|
|
172
|
+
sim_result["geometry"] = polygons
|
|
173
|
+
sim_result = (
|
|
174
|
+
gpd.GeoDataFrame(sim_result, geometry="geometry", crs=local_crs).dissolve(by="noise_level").reset_index()
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return sim_result.to_crs(original_crs)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _process_lines_or_polygons(
|
|
181
|
+
group_gdf, max_dist, obstacles, layer_points, dist_db, local_crs, pbar
|
|
182
|
+
) -> gpd.GeoDataFrame:
|
|
183
|
+
features_vision_polys = []
|
|
184
|
+
layer_buffer = group_gdf.buffer(max_dist, resolution=16).union_all()
|
|
185
|
+
local_obstacles = obstacles[obstacles.intersects(layer_buffer)]
|
|
186
|
+
for _, row in layer_points.iterrows():
|
|
187
|
+
point_from = row.geometry
|
|
188
|
+
vis_poly = get_visibility_accurate(point_from, obstacles=local_obstacles, view_distance=max_dist)
|
|
189
|
+
features_vision_polys.append(vis_poly)
|
|
190
|
+
pbar.update(1)
|
|
191
|
+
features_vision_polys = unary_union(features_vision_polys)
|
|
192
|
+
return _eval_donuts_gdf(group_gdf.union_all(), dist_db, local_crs, features_vision_polys)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _eval_donuts_gdf(initial_geometry, dist_db, local_crs, clip_poly) -> gpd.GeoDataFrame:
|
|
196
|
+
donuts = []
|
|
197
|
+
don_values = []
|
|
198
|
+
to_cut_off = initial_geometry
|
|
199
|
+
for i in range(len(dist_db[:-1])):
|
|
200
|
+
cur_buffer = initial_geometry.buffer(dist_db[i + 1][0])
|
|
201
|
+
donuts.append(cur_buffer.difference(to_cut_off))
|
|
202
|
+
don_values.append(dist_db[i][1])
|
|
203
|
+
to_cut_off = cur_buffer
|
|
204
|
+
noise_from_feature = (
|
|
205
|
+
gpd.GeoDataFrame(geometry=donuts, data={"noise_level": don_values}, crs=local_crs)
|
|
206
|
+
.clip(clip_poly, keep_geom_type=True)
|
|
207
|
+
.explode(ignore_index=True)
|
|
208
|
+
)
|
|
209
|
+
return noise_from_feature
|
|
@@ -1 +1 @@
|
|
|
1
|
-
from .cluster_points_in_polygons import get_clusters_polygon
|
|
1
|
+
from .cluster_points_in_polygons import get_clusters_polygon
|
|
@@ -1,116 +1,115 @@
|
|
|
1
|
-
from typing import Literal
|
|
2
|
-
|
|
3
|
-
import geopandas as gpd
|
|
4
|
-
import pandas as pd
|
|
5
|
-
from sklearn.cluster import DBSCAN, HDBSCAN
|
|
6
|
-
|
|
7
|
-
from objectnat import config
|
|
8
|
-
|
|
9
|
-
logger = config.logger
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def _get_cluster(services_select, min_dist, min_point, method):
|
|
13
|
-
services_coords = pd.DataFrame(
|
|
14
|
-
{"x": services_select.geometry.representative_point().x, "y": services_select.geometry.representative_point().y}
|
|
15
|
-
)
|
|
16
|
-
if method == "DBSCAN":
|
|
17
|
-
db = DBSCAN(eps=min_dist, min_samples=min_point).fit(services_coords.to_numpy())
|
|
18
|
-
else:
|
|
19
|
-
db = HDBSCAN(min_cluster_size=min_point, cluster_selection_epsilon=min_dist).fit(services_coords.to_numpy())
|
|
20
|
-
services_select["cluster"] = db.labels_
|
|
21
|
-
return services_select
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def _get_service_ratio(loc, service_code_column):
|
|
25
|
-
all_services = loc.shape[0]
|
|
26
|
-
loc[service_code_column] = loc[service_code_column].astype(str)
|
|
27
|
-
services_count = loc.groupby(service_code_column).size()
|
|
28
|
-
return (services_count / all_services).round(2)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def get_clusters_polygon(
|
|
32
|
-
points: gpd.GeoDataFrame,
|
|
33
|
-
min_dist: float | int = 100,
|
|
34
|
-
min_point: int = 5,
|
|
35
|
-
method: Literal["DBSCAN", "HDBSCAN"] = "HDBSCAN",
|
|
36
|
-
service_code_column: str = "service_code",
|
|
37
|
-
) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
|
|
38
|
-
"""
|
|
39
|
-
Generate cluster polygons for given points based on a specified minimum distance and minimum points per cluster.
|
|
40
|
-
Optionally, calculate the relative ratio between types of points within the clusters.
|
|
41
|
-
|
|
42
|
-
Parameters
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
Returns
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"""
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
df_clusters =
|
|
111
|
-
df_clusters
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return df_clusters.to_crs(original_crs), points.to_crs(original_crs)
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
import geopandas as gpd
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from sklearn.cluster import DBSCAN, HDBSCAN
|
|
6
|
+
|
|
7
|
+
from objectnat import config
|
|
8
|
+
|
|
9
|
+
logger = config.logger
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get_cluster(services_select, min_dist, min_point, method):
|
|
13
|
+
services_coords = pd.DataFrame(
|
|
14
|
+
{"x": services_select.geometry.representative_point().x, "y": services_select.geometry.representative_point().y}
|
|
15
|
+
)
|
|
16
|
+
if method == "DBSCAN":
|
|
17
|
+
db = DBSCAN(eps=min_dist, min_samples=min_point).fit(services_coords.to_numpy())
|
|
18
|
+
else:
|
|
19
|
+
db = HDBSCAN(min_cluster_size=min_point, cluster_selection_epsilon=min_dist).fit(services_coords.to_numpy())
|
|
20
|
+
services_select["cluster"] = db.labels_
|
|
21
|
+
return services_select
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_service_ratio(loc, service_code_column):
|
|
25
|
+
all_services = loc.shape[0]
|
|
26
|
+
loc[service_code_column] = loc[service_code_column].astype(str)
|
|
27
|
+
services_count = loc.groupby(service_code_column).size()
|
|
28
|
+
return (services_count / all_services).round(2)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_clusters_polygon(
|
|
32
|
+
points: gpd.GeoDataFrame,
|
|
33
|
+
min_dist: float | int = 100,
|
|
34
|
+
min_point: int = 5,
|
|
35
|
+
method: Literal["DBSCAN", "HDBSCAN"] = "HDBSCAN",
|
|
36
|
+
service_code_column: str = "service_code",
|
|
37
|
+
) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
|
|
38
|
+
"""
|
|
39
|
+
Generate cluster polygons for given points based on a specified minimum distance and minimum points per cluster.
|
|
40
|
+
Optionally, calculate the relative ratio between types of points within the clusters.
|
|
41
|
+
|
|
42
|
+
Parameters:
|
|
43
|
+
points (gpd.GeoDataFrame):
|
|
44
|
+
GeoDataFrame containing the points to be clustered.
|
|
45
|
+
Must include a 'service_code' column for service ratio calculations.
|
|
46
|
+
min_dist (float | int, optional):
|
|
47
|
+
Minimum distance between points to be considered part of the same cluster. Defaults to 100.
|
|
48
|
+
min_point (int, optional):
|
|
49
|
+
Minimum number of points required to form a cluster. Defaults to 5.
|
|
50
|
+
method (Literal["DBSCAN", "HDBSCAN"], optional):
|
|
51
|
+
The clustering method to use. Must be either "DBSCAN" or "HDBSCAN". Defaults to "HDBSCAN".
|
|
52
|
+
service_code_column (str, optional):
|
|
53
|
+
Column, containing service type for relative ratio in clasterized polygons. Defaults to "service_code".
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
(tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]):
|
|
57
|
+
A tuple containing the clustered polygons GeoDataFrame and the original points GeoDataFrame with cluster labels.
|
|
58
|
+
"""
|
|
59
|
+
if method not in ["DBSCAN", "HDBSCAN"]:
|
|
60
|
+
raise ValueError("Method must be either 'DBSCAN' or 'HDBSCAN'")
|
|
61
|
+
original_crs = points.crs
|
|
62
|
+
local_crs = points.estimate_utm_crs()
|
|
63
|
+
points = points.to_crs(local_crs)
|
|
64
|
+
services_select = _get_cluster(points, min_dist, min_point, method)
|
|
65
|
+
|
|
66
|
+
if service_code_column not in points.columns:
|
|
67
|
+
logger.warning(
|
|
68
|
+
f"No {service_code_column} column in provided GeoDataFrame, cluster polygons will be without relative ratio"
|
|
69
|
+
)
|
|
70
|
+
points[service_code_column] = service_code_column
|
|
71
|
+
|
|
72
|
+
points_normal = services_select[services_select["cluster"] != -1].copy()
|
|
73
|
+
points_outlier = services_select[services_select["cluster"] == -1].copy()
|
|
74
|
+
|
|
75
|
+
if len(points_normal) > 0:
|
|
76
|
+
cluster_service = points_normal.groupby("cluster", group_keys=True).apply(
|
|
77
|
+
_get_service_ratio, service_code_column=service_code_column
|
|
78
|
+
)
|
|
79
|
+
if isinstance(cluster_service, pd.Series):
|
|
80
|
+
cluster_service = cluster_service.unstack(level=1, fill_value=0)
|
|
81
|
+
|
|
82
|
+
polygons_normal = points_normal.dissolve("cluster").concave_hull(ratio=0.1, allow_holes=True)
|
|
83
|
+
df_clusters_normal = pd.concat([cluster_service, polygons_normal.rename("geometry")], axis=1)
|
|
84
|
+
cluster_normal = df_clusters_normal.index.max()
|
|
85
|
+
points_normal["outlier"] = False
|
|
86
|
+
df_clusters_normal["outlier"] = False
|
|
87
|
+
else:
|
|
88
|
+
df_clusters_normal = None
|
|
89
|
+
cluster_normal = 0
|
|
90
|
+
|
|
91
|
+
if len(points_outlier) > 0:
|
|
92
|
+
clusters_outlier = cluster_normal + 1
|
|
93
|
+
new_clusters = list(range(clusters_outlier, clusters_outlier + len(points_outlier)))
|
|
94
|
+
points_outlier.loc[:, "cluster"] = new_clusters
|
|
95
|
+
|
|
96
|
+
cluster_service = points_outlier.groupby("cluster", group_keys=True).apply(
|
|
97
|
+
_get_service_ratio, service_code_column=service_code_column
|
|
98
|
+
)
|
|
99
|
+
if isinstance(cluster_service, pd.Series):
|
|
100
|
+
cluster_service = cluster_service.unstack(level=1, fill_value=0)
|
|
101
|
+
|
|
102
|
+
df_clusters_outlier = cluster_service.join(points_outlier.set_index("cluster")["geometry"])
|
|
103
|
+
points_outlier["outlier"] = True
|
|
104
|
+
df_clusters_outlier["outlier"] = True
|
|
105
|
+
else:
|
|
106
|
+
points_outlier = None
|
|
107
|
+
df_clusters_outlier = None
|
|
108
|
+
|
|
109
|
+
df_clusters = pd.concat([df_clusters_normal, df_clusters_outlier]).fillna(0).set_geometry("geometry")
|
|
110
|
+
df_clusters["geometry"] = df_clusters["geometry"].buffer(min_dist / 2)
|
|
111
|
+
df_clusters = df_clusters.reset_index().rename(columns={"index": "cluster"})
|
|
112
|
+
|
|
113
|
+
points = pd.concat([points_normal, points_outlier])
|
|
114
|
+
|
|
115
|
+
return df_clusters.to_crs(original_crs), points.to_crs(original_crs)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
from .provision import clip_provision, get_service_provision, recalculate_links
|
|
1
|
+
from .provision import clip_provision, get_service_provision, recalculate_links
|