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.

Files changed (35) hide show
  1. objectnat/__init__.py +9 -13
  2. objectnat/_api.py +14 -13
  3. objectnat/_config.py +47 -47
  4. objectnat/_version.py +1 -1
  5. objectnat/methods/coverage_zones/__init__.py +3 -3
  6. objectnat/methods/coverage_zones/graph_coverage.py +98 -108
  7. objectnat/methods/coverage_zones/radius_voronoi_coverage.py +37 -45
  8. objectnat/methods/coverage_zones/stepped_coverage.py +126 -142
  9. objectnat/methods/isochrones/__init__.py +1 -1
  10. objectnat/methods/isochrones/isochrone_utils.py +167 -167
  11. objectnat/methods/isochrones/isochrones.py +262 -299
  12. objectnat/methods/noise/__init__.py +3 -3
  13. objectnat/methods/noise/noise_init_data.py +10 -10
  14. objectnat/methods/noise/noise_reduce.py +155 -155
  15. objectnat/methods/noise/{noise_sim.py → noise_simulation.py} +452 -448
  16. objectnat/methods/noise/noise_simulation_simplified.py +209 -0
  17. objectnat/methods/point_clustering/__init__.py +1 -1
  18. objectnat/methods/point_clustering/cluster_points_in_polygons.py +115 -116
  19. objectnat/methods/provision/__init__.py +1 -1
  20. objectnat/methods/provision/provision.py +117 -110
  21. objectnat/methods/provision/provision_exceptions.py +59 -59
  22. objectnat/methods/provision/provision_model.py +337 -337
  23. objectnat/methods/utils/__init__.py +1 -0
  24. objectnat/methods/utils/geom_utils.py +173 -130
  25. objectnat/methods/utils/graph_utils.py +306 -206
  26. objectnat/methods/utils/math_utils.py +32 -32
  27. objectnat/methods/visibility/__init__.py +6 -6
  28. objectnat/methods/visibility/visibility_analysis.py +470 -511
  29. {objectnat-1.1.0.dist-info → objectnat-1.2.1.dist-info}/LICENSE.txt +28 -28
  30. objectnat-1.2.1.dist-info/METADATA +115 -0
  31. objectnat-1.2.1.dist-info/RECORD +33 -0
  32. objectnat/methods/noise/noise_exceptions.py +0 -14
  33. objectnat-1.1.0.dist-info/METADATA +0 -148
  34. objectnat-1.1.0.dist-info/RECORD +0 -33
  35. {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
- points : gpd.GeoDataFrame
45
- GeoDataFrame containing the points to be clustered.
46
- Must include a 'service_code' column for service ratio calculations.
47
- min_dist : float | int, optional
48
- Minimum distance between points to be considered part of the same cluster. Defaults to 100.
49
- min_point : int, optional
50
- Minimum number of points required to form a cluster. Defaults to 5.
51
- method : Literal["DBSCAN", "HDBSCAN"], optional
52
- The clustering method to use. Must be either "DBSCAN" or "HDBSCAN". Defaults to "HDBSCAN".
53
- service_code_column : str, optional
54
- Column, containing service type for relative ratio in clasterized polygons. Defaults to "service_code".
55
- Returns
56
- -------
57
- tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]
58
- A tuple containing the clustered polygons GeoDataFrame and the original points GeoDataFrame with cluster labels.
59
- """
60
- if method not in ["DBSCAN", "HDBSCAN"]:
61
- raise ValueError("Method must be either 'DBSCAN' or 'HDBSCAN'")
62
- original_crs = points.crs
63
- local_crs = points.estimate_utm_crs()
64
- points = points.to_crs(local_crs)
65
- services_select = _get_cluster(points, min_dist, min_point, method)
66
-
67
- if service_code_column not in points.columns:
68
- logger.warning(
69
- f"No {service_code_column} column in provided GeoDataFrame, cluster polygons will be without relative ratio"
70
- )
71
- points[service_code_column] = service_code_column
72
-
73
- points_normal = services_select[services_select["cluster"] != -1].copy()
74
- points_outlier = services_select[services_select["cluster"] == -1].copy()
75
-
76
- if len(points_normal) > 0:
77
- cluster_service = points_normal.groupby("cluster", group_keys=True).apply(
78
- _get_service_ratio, service_code_column=service_code_column
79
- )
80
- if isinstance(cluster_service, pd.Series):
81
- cluster_service = cluster_service.unstack(level=1, fill_value=0)
82
-
83
- polygons_normal = points_normal.dissolve("cluster").concave_hull(ratio=0.1, allow_holes=True)
84
- df_clusters_normal = pd.concat([cluster_service, polygons_normal.rename("geometry")], axis=1)
85
- cluster_normal = df_clusters_normal.index.max()
86
- points_normal["outlier"] = False
87
- df_clusters_normal["outlier"] = False
88
- else:
89
- df_clusters_normal = None
90
- cluster_normal = 0
91
-
92
- if len(points_outlier) > 0:
93
- clusters_outlier = cluster_normal + 1
94
- new_clusters = list(range(clusters_outlier, clusters_outlier + len(points_outlier)))
95
- points_outlier.loc[:, "cluster"] = new_clusters
96
-
97
- cluster_service = points_outlier.groupby("cluster", group_keys=True).apply(
98
- _get_service_ratio, service_code_column=service_code_column
99
- )
100
- if isinstance(cluster_service, pd.Series):
101
- cluster_service = cluster_service.unstack(level=1, fill_value=0)
102
-
103
- df_clusters_outlier = cluster_service.join(points_outlier.set_index("cluster")["geometry"])
104
- points_outlier["outlier"] = True
105
- df_clusters_outlier["outlier"] = True
106
- else:
107
- points_outlier = None
108
- df_clusters_outlier = None
109
-
110
- df_clusters = pd.concat([df_clusters_normal, df_clusters_outlier]).fillna(0).set_geometry("geometry")
111
- df_clusters["geometry"] = df_clusters["geometry"].buffer(min_dist / 2)
112
- df_clusters = df_clusters.reset_index().rename(columns={"index": "cluster"})
113
-
114
- points = pd.concat([points_normal, points_outlier])
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