pyreclaim 0.1.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.
@@ -0,0 +1,455 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ import geopandas as gpd
4
+ import networkx as nx # NetworkX library import
5
+
6
+ from shapely.geometry import shape, Point, LineString, Polygon, GeometryCollection, MultiLineString, MultiPolygon
7
+ from shapely.ops import transform, split, linemerge, unary_union
8
+ from shapely.geometry.polygon import orient
9
+ from shapely.strtree import STRtree
10
+ from shapely.validation import explain_validity
11
+
12
+
13
+ def find_optimal_resolution(geometry, min_resolution=0.0001, max_resolution=0.1, complexity_weight=0.38):
14
+ """
15
+ Estimate an optimal grid resolution for pathfinding within a given geometry.
16
+
17
+ Parameters:
18
+ - geometry (Polygon or MultiPolygon): The geometry to analyze.
19
+ - min_resolution (float): Minimum allowable resolution.
20
+ - max_resolution (float): Maximum allowable resolution.
21
+ - complexity_weight (float): Weight that determines how much the resolution adapts to complexity.
22
+
23
+ Returns:
24
+ - float: Suggested resolution.
25
+ """
26
+ # Handle MultiPolygon by taking the union
27
+ if isinstance(geometry, MultiPolygon):
28
+ geometry = geometry.buffer(0) # Clean union
29
+
30
+ area = geometry.area
31
+ perimeter = geometry.length
32
+
33
+ if area == 0:
34
+ raise ValueError("Geometry has zero area.")
35
+
36
+ avg_scale = np.sqrt(area)
37
+ complexity = perimeter / avg_scale # Higher means more detail
38
+
39
+ # Base resolution proportional to avg scale
40
+ base_resolution = avg_scale * complexity_weight / complexity
41
+
42
+ # Clamp to user-defined bounds
43
+ resolution = max(min_resolution, min(base_resolution, max_resolution))
44
+
45
+ return round(resolution, 5)
46
+
47
+ def get_largest_polygon(geometry):
48
+ """Return the largest polygon from a Polygon or MultiPolygon"""
49
+ if isinstance(geometry, Polygon):
50
+ return geometry
51
+ elif isinstance(geometry, MultiPolygon):
52
+ return max(geometry.geoms, key=lambda p: p.area)
53
+ else:
54
+ raise ValueError("Input must be a Polygon or MultiPolygon.")
55
+
56
+ def extract_valid_lines(geometry):
57
+ """Ensure that only valid LineString segments are returned, regardless of input geometry type."""
58
+ if isinstance(geometry, LineString):
59
+ return [geometry]
60
+ elif isinstance(geometry, (GeometryCollection, MultiLineString)):
61
+ valid_lines = []
62
+ for geom in geometry.geoms:
63
+ if isinstance(geom, LineString):
64
+ valid_lines.append(geom)
65
+ return valid_lines
66
+ return []
67
+
68
+ def shape_index(polygon):
69
+ return (polygon.length ** 2) / polygon.area
70
+
71
+ def compute_initial_tolerance(geometry, fraction=0.005):
72
+ """
73
+ Compute a tolerance based on the size of the geometry.
74
+ Smaller fraction = higher fidelity.
75
+ """
76
+ minx, miny, maxx, maxy = geometry.bounds
77
+ diag = ((maxx - minx)**2 + (maxy - miny)**2)**0.5
78
+ return diag * fraction # 0.5% of diagonal as tolerance
79
+
80
+ def clean_narrow_necks(geometry, buffer_fraction=0.002):
81
+ """
82
+ Buffer in and out to remove narrow slivers or necks without overly changing geometry.
83
+
84
+ Parameters:
85
+ - geometry: Polygon or MultiPolygon
86
+ - buffer_fraction: Fraction of geometry diagonal to use as buffer width.
87
+
88
+ Returns:
89
+ - cleaned geometry
90
+ """
91
+ minx, miny, maxx, maxy = geometry.bounds
92
+ diag = ((maxx - minx)**2 + (maxy - miny)**2)**0.5
93
+ buffer_dist = diag * buffer_fraction
94
+
95
+ # Inward then outward buffer to remove narrow parts
96
+ cleaned = geometry.buffer(-buffer_dist).buffer(buffer_dist)
97
+
98
+ # Preserve original geometry if buffer killed it
99
+ if cleaned.is_empty or cleaned.area == 0:
100
+ return geometry
101
+
102
+ # If result is MultiPolygon, merge it
103
+ return unary_union(cleaned)
104
+
105
+ def filter_narrow_removed_parts(removed, min_area=0.000001, min_aspect_ratio=2):
106
+ """
107
+ Filter out noisy edge slivers by area and shape.
108
+
109
+ Parameters:
110
+ - removed: a Polygon or MultiPolygon
111
+ - min_area: minimum area to be considered significant
112
+ - min_aspect_ratio: minimum elongation to count as a narrow feature
113
+
114
+ Returns:
115
+ - MultiPolygon of retained significant narrow parts
116
+ """
117
+ filtered_parts = []
118
+
119
+ if isinstance(removed, Polygon):
120
+ candidates = [removed]
121
+ elif isinstance(removed, MultiPolygon):
122
+ candidates = list(removed.geoms)
123
+ else:
124
+ return None
125
+
126
+ for geom in candidates:
127
+ if geom.area < min_area:
128
+ continue
129
+
130
+ if min_aspect_ratio:
131
+ minx, miny, maxx, maxy = geom.bounds
132
+ width = maxx - minx
133
+ height = maxy - miny
134
+ aspect_ratio = max(width, height) / max(min(width, height), 1e-6)
135
+
136
+ if aspect_ratio >= min_aspect_ratio:
137
+ filtered_parts.append(geom)
138
+ else:
139
+ pass
140
+ else:
141
+ filtered_parts.append(geom)
142
+
143
+ return unary_union(filtered_parts) if filtered_parts else None
144
+
145
+ def compute_adaptive_buffer_dist(polygon, fraction=0.004):
146
+ """
147
+ Compute an adaptive buffer distance based on polygon scale.
148
+
149
+ Parameters:
150
+ - polygon: shapely Polygon or MultiPolygon
151
+ - fraction: proportion of diagonal length (e.g. 0.004 = 0.4%)
152
+
153
+ Returns:
154
+ - buffer distance (float)
155
+ """
156
+ minx, miny, maxx, maxy = polygon.bounds
157
+ width = maxx - minx
158
+ height = maxy - miny
159
+ diag = (width**2 + height**2)**0.5
160
+
161
+ return fraction * diag
162
+
163
+ def widen_narrow_parts(polygon, fraction=0.0043, min_area_fraction=1e-8, min_aspect_ratio=None):
164
+ """
165
+ Widen narrow regions removed during negative buffering using adaptive buffer distance.
166
+
167
+ Parameters:
168
+ - polygon: shapely Polygon or MultiPolygon
169
+ - fraction: % of bbox diagonal to use for buffer distance
170
+ - min_area_fraction: threshold to remove small slivers (as % of polygon area)
171
+ - min_aspect_ratio: minimum elongation to classify as narrow feature. If None, it is ignored. Default is None.
172
+
173
+ Returns:
174
+ - widened polygon
175
+ """
176
+ if polygon.is_empty or polygon.area == 0:
177
+ return polygon
178
+
179
+ buffer_dist = compute_adaptive_buffer_dist(polygon, fraction=fraction)
180
+
181
+ # Step 1: Apply inward buffer
182
+ simplified = polygon.buffer(-buffer_dist)
183
+
184
+ if simplified.is_empty:
185
+ return polygon # Avoid error if all geometry disappears
186
+
187
+ # Step 2: Re-expand it back
188
+ expanded = simplified.buffer(buffer_dist)
189
+
190
+ # Step 3: Find what got lost during shrink-expand (mostly narrow parts)
191
+ removed_narrow_parts = polygon.difference(expanded)
192
+
193
+ if removed_narrow_parts.is_empty:
194
+ return polygon
195
+
196
+ # Step 4: Filter the narrow parts from removed ones and Slightly buffer them wider
197
+ # 🔍 Filter out noisy slivers
198
+ filtered = filter_narrow_removed_parts(removed_narrow_parts, min_area=min_area_fraction * polygon.area, min_aspect_ratio=min_aspect_ratio)
199
+
200
+ if filtered:
201
+ widened = filtered.buffer(buffer_dist)
202
+ widened_polygon = unary_union([polygon, widened])
203
+ else:
204
+ widened_polygon = polygon
205
+
206
+ return widened_polygon
207
+
208
+ def simplify_geometry(polygon, shape_index_threshold=800, max_tolerance_fraction=0.05, narrow_portion='widen'):
209
+ """
210
+ Simplify polygon while preserving topology and ensuring shape index is under control.
211
+
212
+ Parameters:
213
+ - polygon: shapely.geometry.Polygon or MultiPolygon
214
+ - shape_index_threshold: Target upper bound for shape index (lower = simpler)
215
+ - max_tolerance_fraction: Max allowed simplification (as % of bounding box diagonal)
216
+ - narrow_portion: 'clean' to remove narrow connections before simplification or 'widen' to widen the narrow connections. Default is 'widen'.
217
+
218
+ Returns:
219
+ - simplified polygon
220
+ """
221
+ if polygon.is_empty or polygon.area == 0:
222
+ return polygon
223
+
224
+ if narrow_portion=='clean':
225
+ polygon = clean_narrow_necks(polygon)
226
+ else:
227
+ polygon = widen_narrow_parts(polygon)
228
+
229
+ original_si = shape_index(polygon)
230
+ if original_si <= shape_index_threshold:
231
+ return polygon # Already simple enough
232
+
233
+ # Initial tolerance from geometry scale
234
+ initial_tolerance = compute_initial_tolerance(polygon, fraction=0.005)
235
+ max_tolerance = compute_initial_tolerance(polygon, fraction=max_tolerance_fraction)
236
+ tolerance = initial_tolerance
237
+
238
+ # Iteratively simplify until SI drops or max tolerance is reached
239
+ while tolerance <= max_tolerance:
240
+ simplified = polygon.simplify(tolerance, preserve_topology=True)
241
+ si = shape_index(simplified)
242
+
243
+ if si <= shape_index_threshold:
244
+ return simplified
245
+
246
+ if simplified.equals_exact(polygon, tolerance / 10):
247
+ break
248
+
249
+ tolerance *= 2
250
+
251
+ return polygon
252
+
253
+ def simplify_path(points, geometry):
254
+ if not points:
255
+ return points
256
+ simplified = [points[0]]
257
+ i = 0
258
+ while i < len(points) - 1:
259
+ j = len(points) - 1
260
+ while j > i + 1:
261
+ if geometry.contains(LineString([points[i], points[j]])):
262
+ break
263
+ j -= 1
264
+ simplified.append(points[j])
265
+ i = j
266
+ return simplified
267
+
268
+ def create_continuous_linestring(a, b, geometry, resolution=0.01):
269
+ """
270
+ Create a continuous LineString between two points that lies entirely within a given geometry.
271
+
272
+ Parameters
273
+ ----------
274
+ a : tuple
275
+ The coordinates of the first point (x, y).
276
+
277
+ b : tuple
278
+ The coordinates of the second point (x, y).
279
+
280
+ geometry : shapely.geometry.Polygon or shapely.geometry.MultiPolygon
281
+ The geometry within which the LineString should be created.
282
+
283
+ resolution : float, optional
284
+ The resolution of the grid used for pathfinding. Default is 0.5.
285
+
286
+ Returns
287
+ -------
288
+ shapely.geometry.LineString or None
289
+ A continuous LineString between points `a` and `b` that does not cross the exterior
290
+ boundary of the geometry. If it is not possible to create such a LineString, `None` is returned.
291
+ """
292
+
293
+ # Ensure the geometry is oriented counter-clockwise
294
+ geometry = orient(geometry, sign=1.0)
295
+
296
+ # Create a grid over the bounding box of the geometry
297
+ minx, miny, maxx, maxy = geometry.bounds
298
+
299
+ # Get bounding box dimensions
300
+ width = maxx - minx
301
+ height = maxy - miny
302
+
303
+ # Compute aspect ratio
304
+ longer, shorter = max(width, height), min(width, height)
305
+ aspect_ratio = longer / shorter if shorter != 0 else 1
306
+
307
+ # Threshold to trigger anisotropic resolution (you can tune this)
308
+ aspect_ratio_threshold = 2
309
+
310
+ if aspect_ratio > aspect_ratio_threshold:
311
+ # Elongated shape — adjust resolution
312
+ if width > height:
313
+ x_res = resolution * 2 # make x_res coarser
314
+ y_res = resolution
315
+ else:
316
+ y_res = resolution * 2 # make y_res coarser
317
+ x_res = resolution
318
+ else:
319
+ x_res = resolution
320
+ y_res = resolution
321
+
322
+ x_coords = np.arange(minx, maxx + x_res, x_res)
323
+ y_coords = np.arange(miny, maxy + y_res, y_res)
324
+
325
+ # Create points on the grid and filter those within the geometry
326
+ points = []
327
+ for x in x_coords:
328
+ for y in y_coords:
329
+ p = Point(x, y)
330
+ if geometry.contains(p):
331
+ points.append(p)
332
+
333
+ # Debug: #Print number of valid points
334
+ #print(f"Number of valid points within the geometry: {len(points)}")
335
+
336
+ # Check if any valid points exist
337
+ if not points:
338
+ raise ValueError("No valid points within the geometry. Adjust resolution or check the geometry.")
339
+
340
+ # Create a spatial index for the points
341
+ tree = STRtree(points)
342
+
343
+ # Build a dict of coordinate → Point
344
+ point_dict = {(round(p.x, 6), round(p.y, 6)): p for p in points}
345
+
346
+ # Create a graph for pathfinding using Dijkstra's algorithm
347
+ graph = nx.Graph()
348
+
349
+ # Add nodes to the graph
350
+ for point in points:
351
+ graph.add_node((point.x, point.y))
352
+
353
+ # Connect adjacent points in the grid that are within the geometry (without STRtree: Old method; not working for sharp narrow turns in polygons)
354
+ # for point in points:
355
+ # x, y = point.x, point.y
356
+ # # possible_neighbors = [(x + dx, y + dy) for dx in [-resolution, 0, resolution] for dy in [-resolution, 0, resolution] if (dx, dy) != (0, 0)]
357
+ # radius = 5 # or 3, depending on resolution and geometry size
358
+ # possible_neighbors = [
359
+ # (x + dx * x_res, y + dy * y_res)
360
+ # for dx in range(-radius, radius + 1)
361
+ # for dy in range(-radius, radius + 1)
362
+ # if not (dx == 0 and dy == 0)
363
+ # ]
364
+ # for (neighbor_x, neighbor_y) in possible_neighbors:
365
+ # neighbor = Point(neighbor_x, neighbor_y)
366
+ # # Then during neighbor search:
367
+ # coord = (round(neighbor_x, 6), round(neighbor_y, 6))
368
+ # if coord in point_dict:
369
+ # line = LineString([point, neighbor])
370
+ # if geometry.covers(line):
371
+ # distance = point.distance(neighbor)
372
+ # #print(f"Adding edge between {point} and {neighbor} with distance {distance}")
373
+ # graph.add_edge((point.x, point.y), (neighbor.x, neighbor.y), weight=distance)
374
+
375
+ for point in points:
376
+ # Find nearby geometries within a buffer radius
377
+ search_radius = max(x_res, y_res) * 2.5
378
+ nearby = tree.query(point.buffer(search_radius))
379
+ for idx in nearby:
380
+ neighbor = points[idx] # Index into your original list
381
+ if point.equals(neighbor):
382
+ continue
383
+ line = LineString([point, neighbor])
384
+ if geometry.covers(line):
385
+ distance = point.distance(neighbor)
386
+ graph.add_edge((point.x, point.y), (neighbor.x, neighbor.y), weight=distance)
387
+
388
+
389
+ # If the graph is empty, return None
390
+ if graph.number_of_nodes() == 0:
391
+ raise ValueError("Graph is empty. No valid paths could be constructed within the geometry.")
392
+
393
+ # Convert points a and b to nearest grid points
394
+ start = Point(a)
395
+ end = Point(b)
396
+
397
+ # Check if the exact point exists in the graph, if not, find the nearest one
398
+ nearest_start = min(graph.nodes, key=lambda n: start.distance(Point(n)), default=None)
399
+ nearest_end = min(graph.nodes, key=lambda n: end.distance(Point(n)), default=None)
400
+
401
+ if nearest_start is None or nearest_end is None:
402
+ raise ValueError("Could not find nearest grid points within the graph. Adjust resolution or check the geometry.")
403
+
404
+ # Find the shortest path within the graph using Dijkstra's algorithm
405
+ try:
406
+ path_coords = nx.dijkstra_path(graph, nearest_start, nearest_end, weight='weight')
407
+ path = [Point(x, y) for x, y in path_coords]
408
+ path.insert(0, start) # Add the start point
409
+ path.append(end) # Add the end point
410
+ simplified_path = simplify_path(path, geometry)
411
+ return LineString(simplified_path), graph
412
+ # return LineString(path), graph
413
+ except nx.NetworkXNoPath:
414
+ return None, graph
415
+
416
+
417
+
418
+ def find_actual_flow_path(dam_point, reservoir_polygon, inlet_point=None, resolution=None):
419
+ """
420
+ This function finds the actual flow path between a dam point and the inlet of the reservoir boundary within the polygon.
421
+
422
+ Args:
423
+ dam_point (Point): The point representing the dam location.
424
+ reservoir_polygon (Polygon): The polygon representing the reservoir boundary.
425
+ inlet_point (Point, optional): The point representing the inlet of the reservoir. If not provided, the farthest point on the simplified reservoir boundary is used.
426
+ resolution (float, optional): The resolution to use for simplifying the reservoir boundary. If not provided, the optimal resolution is found.
427
+
428
+ Returns:
429
+ simplified_reservoir (Polygon): The simplified reservoir boundary.
430
+ far_end_point (Point): The farthest point on the simplified reservoir boundary.
431
+ path (LineString): The shortest path between the dam point and the far end point.
432
+ graph (Graph): The graph used for pathfinding.
433
+ """
434
+ # Step 1: Simplify the reservoir boundary
435
+ simplified_reservoir = simplify_geometry(reservoir_polygon)
436
+
437
+ # Step 2: Find optimal resolution for simplified geometry
438
+ if resolution:
439
+ optimal_resolution = resolution
440
+ else:
441
+ optimal_resolution = find_optimal_resolution(simplified_reservoir)
442
+
443
+ # Step 3: Identify the farthest point on the simplified boundary if inlet point not given.
444
+ if inlet_point:
445
+ farthest_point = inlet_point
446
+ far_end_point = Point(farthest_point)
447
+ else:
448
+ main_polygon = get_largest_polygon(simplified_reservoir)
449
+ farthest_point = max(main_polygon.exterior.coords, key=lambda coord: dam_point.distance(Point(coord)))
450
+ far_end_point = Point(farthest_point)
451
+
452
+ # Step4: Find the shortest path within geometry between dam_point and far_end_point
453
+ path, graph = create_continuous_linestring(dam_point,far_end_point, simplified_reservoir, optimal_resolution)
454
+
455
+ return simplified_reservoir,far_end_point, path, graph