adema5051 commited on
Commit
38e1ad2
·
verified ·
1 Parent(s): f023554

Update spatial_queries.py

Browse files
Files changed (1) hide show
  1. spatial_queries.py +755 -754
spatial_queries.py CHANGED
@@ -1,754 +1,755 @@
1
- import ee
2
- import geopandas as gpd
3
- from shapely.geometry import Point
4
- import requests
5
- import numpy as np
6
- from functools import lru_cache
7
- import warnings
8
- import json
9
- from pyproj import CRS, Transformer
10
- import time
11
- from datetime import datetime
12
-
13
- # Initialize GEE
14
- from gee_auth import initialize_gee
15
-
16
- # Suppress shapely distance warnings
17
- warnings.filterwarnings("ignore", category=RuntimeWarning, module="shapely.measurement")
18
-
19
- # LAZY LOADING
20
- _RIVERS = None
21
- _LAKES = None
22
-
23
- def get_rivers():
24
- """Lazy load rivers dataset"""
25
- global _RIVERS
26
- if _RIVERS is None:
27
- _RIVERS = gpd.read_file('data/natural_earth/ne_10m_rivers_lake_centerlines.shp')
28
- _RIVERS = _RIVERS[_RIVERS.geometry.is_valid].copy()
29
- print("✅ Rivers shapefile loaded")
30
- return _RIVERS
31
-
32
- def get_lakes():
33
- """Lazy load lakes dataset"""
34
- global _LAKES
35
- if _LAKES is None:
36
- _LAKES = gpd.read_file('data/natural_earth/ne_10m_lakes.shp')
37
- _LAKES = _LAKES[_LAKES.geometry.is_valid].copy()
38
- print("✅ Lakes shapefile loaded")
39
- return _LAKES
40
-
41
-
42
- def get_terrain_metrics(lat, lon, buffer_m=500, force_dem=None):
43
- """
44
- Extract DEM-based metrics with hierarchical fallback strategy.
45
- """
46
- initialize_gee()
47
-
48
- if abs(lat) > 70:
49
- buffer_m = 100
50
-
51
- try:
52
- if abs(lat) > 85:
53
- print(f"Polar region {lat},{lon} - no terrain data")
54
- return {'elevation': None, 'slope': None, 'tpi': None, 'mean_elevation': None, 'dem_source': None}
55
-
56
- point = ee.Geometry.Point([lon, lat])
57
- region = point.buffer(buffer_m)
58
-
59
- # Hierarchical DEM selection OR forced DEM for validation
60
- if force_dem:
61
- dem, dem_source = _get_forced_dem(lat, lon, force_dem)
62
- if dem is None:
63
- # Forced DEM not available at this location
64
- return {'elevation': None, 'slope': None, 'tpi': None, 'mean_elevation': None, 'dem_source': None}
65
- else:
66
- dem, dem_source = _select_best_dem(lat, lon)
67
- if dem is None:
68
- print(f"All DEM sources failed for {lat},{lon}")
69
- return {'elevation': None, 'slope': None, 'tpi': None, 'mean_elevation': None, 'dem_source': None}
70
-
71
- # Point elevation with smaller buffer
72
- elevation_sample = dem.reduceRegion(
73
- reducer=ee.Reducer.mean(),
74
- geometry=point.buffer(15),
75
- scale=30,
76
- maxPixels=1e9,
77
- bestEffort=True
78
- )
79
- elevation = elevation_sample.get('elevation').getInfo()
80
-
81
- if elevation is None:
82
- print(f"GEE elevation failed for {lat},{lon} using {dem_source}")
83
- return {'elevation': None, 'slope': None, 'tpi': None, 'mean_elevation': None, 'dem_source': dem_source}
84
-
85
- try:
86
- mean_elevation_sample = dem.reduceRegion(
87
- reducer=ee.Reducer.mean(),
88
- geometry=region,
89
- scale=30,
90
- maxPixels=1e9,
91
- bestEffort=True
92
- )
93
- mean_elevation = mean_elevation_sample.get('elevation').getInfo()
94
- except Exception as me_err:
95
- print(f"GEE mean elev failed for {lat},{lon}: {me_err}")
96
- mean_elevation = None
97
-
98
- # Slope
99
- slope_img = ee.Terrain.slope(dem)
100
- slope_mean = None
101
- slope_max = None
102
-
103
- def safe_reduce(reducer_type):
104
- try:
105
- reducer = ee.Reducer.mean() if reducer_type == 'mean' else ee.Reducer.max()
106
- stats_dict = slope_img.reduceRegion(
107
- reducer=reducer,
108
- geometry=point.buffer(200),
109
- scale=30,
110
- maxPixels=1e9,
111
- bestEffort=True
112
- )
113
- return stats_dict.get('slope').getInfo()
114
- except Exception as err:
115
- if "transform edge" not in str(err):
116
- print(f"GEE slope {reducer_type} failed for {lat},{lon}: {err}")
117
- return None
118
-
119
- slope_mean = safe_reduce('mean')
120
- slope_max = safe_reduce('max')
121
- if slope_max is not None and slope_mean is not None:
122
- if slope_max >= slope_mean * 1.8:
123
- slope = slope_max
124
- else:
125
- slope = slope_mean
126
- elif slope_mean is not None:
127
- slope = slope_mean
128
- elif slope_max is not None:
129
- slope = slope_max
130
- else:
131
- slope = None
132
-
133
- # TPI
134
- tpi = None
135
- if elevation is not None and mean_elevation is not None:
136
- try:
137
- tpi = float(elevation) - float(mean_elevation)
138
- except (ValueError, TypeError):
139
- tpi = None
140
-
141
- return {
142
- 'elevation': round(float(elevation), 2) if elevation is not None else None,
143
- 'slope': round(float(slope), 2) if slope is not None else None,
144
- 'tpi': round(float(tpi), 2) if tpi is not None else None,
145
- 'mean_elevation': round(float(mean_elevation), 2) if mean_elevation is not None else None,
146
- 'dem_source': dem_source
147
- }
148
-
149
- except Exception as e:
150
- print(f"GEE error for {lat},{lon}: {e}")
151
- return {
152
- 'elevation': None,
153
- 'slope': None,
154
- 'tpi': None,
155
- 'mean_elevation': None,
156
- 'dem_source': None
157
- }
158
-
159
-
160
- def _select_best_dem(lat, lon):
161
- """
162
- Hierarchical DEM selection: prioritize highest-resolution DEM available.
163
-
164
- """
165
-
166
- point = ee.Geometry.Point([lon, lat])
167
-
168
- # Regional high-resolution DEMs
169
-
170
- # 1. USGS 3DEP 10m (USA)
171
-
172
- if -130 < lon < -60 and 20 < lat < 55:
173
- try:
174
- usgs_10m = (
175
- ee.ImageCollection("USGS/3DEP/10m_collection")
176
- .filterBounds(point)
177
- .mosaic()
178
-
179
- )
180
- # Dynamically detect elevation band
181
- elev_band = usgs_10m.bandNames().getInfo()[0]
182
- usgs_10m = usgs_10m.select(elev_band).rename("elevation")
183
- usgs_10m = usgs_10m.reproject(crs="EPSG:4326", scale=10)
184
-
185
- test = usgs_10m.reduceRegion(
186
- ee.Reducer.first(),
187
- point,
188
- 10,
189
- bestEffort=True
190
- ).get("elevation").getInfo()
191
-
192
- if test is not None:
193
- print(f"Using USGS 3DEP 10m for {lat},{lon}")
194
- return usgs_10m, "USGS_3DEP_10m_collection"
195
-
196
- except Exception:
197
- pass
198
-
199
-
200
- # Netherlands AHN2/3/ (0.5 m – best national DEM globally)
201
-
202
- if 50 < lat < 54 and 3 < lon < 8:
203
-
204
- # Priority: AHN3 > AHN2
205
-
206
- try:
207
- # AHN3 (2014–2019)
208
- ahn3 = ee.ImageCollection("AHN/AHN3").select("DTM").mosaic()
209
- test = ahn3.reduceRegion(
210
- ee.Reducer.first(), point, 1, bestEffort=True
211
- ).get("DTM").getInfo()
212
- if test is not None:
213
- print(f"Using AHN3 0.5m DTM for {lat},{lon}")
214
- return ahn3.rename("elevation"), "AHN3_0.5m"
215
- except:
216
- pass
217
-
218
- try:
219
- # AHN2 (2012)
220
- ahn2 = ee.Image("AHN/AHN2_05M_INT").select("elevation")
221
- test = ahn2.reduceRegion(
222
- ee.Reducer.first(), point, 1, bestEffort=True
223
- ).get("elevation").getInfo()
224
- if test is not None:
225
- print(f"Using AHN2 0.5m DTM for {lat},{lon}")
226
- return ahn2, "AHN2_0.5m"
227
- except:
228
- pass
229
-
230
-
231
- # 3. UK Environment Agency Composite DTM/DSM (1m)
232
-
233
- if 49 < lat < 61 and -8 < lon < 3:
234
- try:
235
- ea = ee.Image("UK/EA/ENGLAND_1M_TERRAIN/2022")
236
-
237
- # Identify available elevation band
238
- bands = ea.bandNames().getInfo()
239
- elev_candidates = [b for b in bands if b.lower() in ["dtm", "elevation", "b1"]]
240
-
241
- if not elev_candidates:
242
- raise Exception("No valid elevation band found")
243
-
244
- elev_band = elev_candidates[0]
245
-
246
- # Reproject to WGS84 before sampling
247
- ea_reproj = ea.select(elev_band).reproject(
248
- crs="EPSG:4326",
249
- scale=2
250
- )
251
-
252
- test = ea_reproj.reduceRegion(
253
- reducer=ee.Reducer.first(),
254
- geometry=point,
255
- scale=2,
256
- bestEffort=True,
257
- maxPixels=1e9
258
- ).get(elev_band).getInfo()
259
-
260
- if test is not None:
261
- print(f"Using UK EA DTM 1m for {lat},{lon}")
262
- return ea_reproj.rename("elevation"), "EA_UK_1m"
263
-
264
- except Exception as e:
265
- print(f"EA UK DEM failed for {lat},{lon}: {e}")
266
- pass
267
-
268
- # 4. Australia 5m DEM (LiDAR coastal & urban areas)
269
-
270
- if -45 < lat < -10 and 110 < lon < 155:
271
- try:
272
-
273
- aus_col = ee.ImageCollection("AU/GA/AUSTRALIA_5M_DEM")
274
-
275
- # Mosaic all tiles that intersect the point
276
- aus = aus_col.filterBounds(point).mosaic()
277
-
278
-
279
- elev_band = "elevation"
280
-
281
- test = aus.select(elev_band).reduceRegion(
282
- reducer=ee.Reducer.first(),
283
- geometry=point,
284
- scale=5,
285
- bestEffort=True,
286
- maxPixels=1e9
287
- ).get(elev_band).getInfo()
288
-
289
- if test is not None:
290
- print(f"Using Australia 5m DEM for {lat},{lon}")
291
- return aus.select(elev_band), "Australia_5m"
292
-
293
- except Exception as e:
294
- print(f"AU DEM failed for {lat},{lon}: {e}")
295
- pass
296
-
297
-
298
- # Global 30m DEMs
299
-
300
- # 5. NASADEM
301
-
302
- if -56 <= lat <= 60:
303
- try:
304
- nasadem = ee.Image("NASA/NASADEM_HGT/001").select("elevation")
305
- test = nasadem.reduceRegion(
306
- ee.Reducer.first(), point, 30, bestEffort=True
307
- ).get("elevation").getInfo()
308
-
309
- if test is not None:
310
- print(f"Using NASADEM for {lat},{lon}")
311
- return nasadem, "NASADEM"
312
- except Exception:
313
- pass
314
-
315
- # 6. Copernicus GLO-30
316
-
317
- try:
318
- cop = ee.ImageCollection("COPERNICUS/DEM/GLO30").mosaic().select("DEM").rename("elevation")
319
- test = cop.reduceRegion(
320
- ee.Reducer.first(), point, 30, bestEffort=True
321
- ).get("elevation").getInfo()
322
-
323
- if test is not None:
324
- print(f"Using Copernicus GLO-30 for {lat},{lon}")
325
- return cop, "Copernicus_GLO30"
326
- except Exception:
327
- pass
328
-
329
-
330
- # 7. ALOS World 3D-30m
331
-
332
- if abs(lat) <= 82:
333
- try:
334
- alos = ee.ImageCollection("JAXA/ALOS/AW3D30/V4_1").mosaic().select("AVE").rename("elevation")
335
- test = alos.reduceRegion(
336
- ee.Reducer.first(), point, 30, bestEffort=True
337
- ).get("elevation").getInfo()
338
-
339
- if test is not None:
340
- print(f"Using ALOS AW3D30 AVE for {lat},{lon}")
341
- return alos, 'ALOS_AW3D30_AVE'
342
- except Exception:
343
- pass
344
-
345
-
346
- # 8. SRTM fallback
347
-
348
- if -56 <= lat <= 60:
349
- try:
350
- srtm = ee.Image("USGS/SRTMGL1_003").select("elevation")
351
- test = srtm.reduceRegion(
352
- ee.Reducer.first(), point, 30, bestEffort=True
353
- ).get("elevation").getInfo()
354
-
355
- if test is not None:
356
- print(f"Using SRTM fallback for {lat},{lon}")
357
- return srtm, "SRTM_v3"
358
- except Exception:
359
- pass
360
-
361
- print(f"All DEM sources failed for {lat},{lon}")
362
- return None, None
363
-
364
- def _get_forced_dem(lat, lon, dem_name):
365
- """
366
- Force specific DEM retrieval for validation studies.
367
- Returns None if DEM unavailable at location.
368
-
369
- """
370
- point = ee.Geometry.Point([lon, lat])
371
-
372
- # Map DEM names to their retrieval logic
373
- dem_map = {
374
- 'ALOS_AW3D30': lambda: (
375
- ee.ImageCollection("JAXA/ALOS/AW3D30/V4_1").mosaic().select("AVE").rename("elevation"),
376
- 30
377
- ),
378
- 'Copernicus_GLO30': lambda: (
379
- ee.ImageCollection("COPERNICUS/DEM/GLO30").mosaic().select("DEM").rename("elevation"),
380
- 30
381
- ),
382
- 'NASADEM': lambda: (
383
- ee.Image("NASA/NASADEM_HGT/001").select("elevation"),
384
- 30
385
- ),
386
- 'SRTM_v3': lambda: (
387
- ee.Image("USGS/SRTMGL1_003").select("elevation"),
388
- 30
389
- ),
390
-
391
- 'AHN3_0.5m': lambda: (
392
- ee.ImageCollection("AHN/AHN3").select("DTM").mosaic().rename("elevation"),
393
- 1
394
- ),
395
- 'AHN2_0.5m': lambda: (
396
- ee.Image("AHN/AHN2_05M_INT").select("elevation"),
397
- 1
398
- ),
399
- 'EA_UK_1m': lambda: (
400
- ee.Image("UK/EA/ENGLAND_1M_TERRAIN/2022").select("dtm").reproject(crs="EPSG:4326", scale=2).rename("elevation"),
401
- 2
402
- ),
403
- 'Australia_5m': lambda: (
404
- ee.ImageCollection("AU/GA/AUSTRALIA_5M_DEM").filterBounds(point).mosaic().select("elevation"),
405
- 5
406
- ),
407
- 'USGS_3DEP_10m_collection': lambda: (
408
- ee.ImageCollection("USGS/3DEP/10m_collection").filterBounds(point).mosaic().select("elevation"),
409
- 10
410
- )
411
- }
412
-
413
- if dem_name not in dem_map:
414
- print(f"Unknown DEM name: {dem_name}")
415
- return None, None
416
-
417
- try:
418
- dem, scale = dem_map[dem_name]()
419
-
420
- # Test if data exists at this location
421
- test = dem.reduceRegion(
422
- ee.Reducer.first(),
423
- point,
424
- scale,
425
- bestEffort=True
426
- ).get("elevation").getInfo()
427
-
428
- if test is not None:
429
- print(f"Forced DEM {dem_name} available at {lat},{lon}")
430
- return dem, dem_name
431
- else:
432
- print(f"Forced DEM {dem_name} has no data at {lat},{lon}")
433
- return None, None
434
-
435
- except Exception as e:
436
- print(f"Failed to get forced DEM {dem_name} at {lat},{lon}: {e}")
437
- return None, None
438
-
439
- def is_significant_water_body(element):
440
- """
441
- Determine if water feature is significant for flood risk assessment
442
- """
443
- tags = element.get('tags', {})
444
- name = tags.get('name', '')
445
-
446
- # Filter by name - fountains
447
- if name and ('fuente' in name.lower() or 'fountain' in name.lower() or
448
- 'fonte' in name.lower()):
449
- return False
450
-
451
- # Filter by water type tag
452
- water_type = tags.get('water', '')
453
- if water_type in ['fountain', 'reflecting_pool', 'pond', 'ornamental']:
454
- return False
455
-
456
- # Filter by amenity tag
457
- if tags.get('amenity') == 'fountain':
458
- return False
459
-
460
- # Check if it's a waterway (rivers/streams/canals are significant)
461
- if tags.get('waterway') in ['river', 'stream', 'canal', 'drain']:
462
- return True
463
-
464
- # Calculate approximate area for unnamed water bodies
465
- if tags.get('natural') == 'water' and 'geometry' in element:
466
- coords = element.get('geometry', [])
467
-
468
- if len(coords) >= 3:
469
- lons = [c['lon'] for c in coords]
470
- lats = [c['lat'] for c in coords]
471
-
472
- width = (max(lons) - min(lons)) * 111320
473
- height = (max(lats) - min(lats)) * 111320
474
- approx_area = width * height
475
-
476
- if approx_area < 500:
477
- return False
478
-
479
- if len(coords) < 10 and approx_area < 2000:
480
- return False
481
-
482
- # Natural water bodies with names (excluding fountains)
483
- if tags.get('natural') == 'water' and name:
484
- return True
485
-
486
- # Large unnamed water bodies
487
- if tags.get('natural') == 'water' and not name:
488
- coords = element.get('geometry', [])
489
- if len(coords) > 50:
490
- return True
491
-
492
- return False
493
-
494
-
495
- def distance_to_water_osm(lat, lon, radius_m=5000, timeout=20, retry_count=2):
496
- """
497
- Query OpenStreetMap for nearby SIGNIFICANT water bodies with retry logic
498
- """
499
- overpass_url = "http://overpass-api.de/api/interpreter"
500
-
501
- query = f"""
502
- [out:json][timeout:{timeout}];
503
- (
504
- way["natural"="water"](around:{radius_m},{lat},{lon});
505
- way["waterway"="river"](around:{radius_m},{lat},{lon});
506
- way["waterway"="canal"](around:{radius_m},{lat},{lon});
507
- way["waterway"="stream"](around:{radius_m},{lat},{lon});
508
- relation["natural"="water"](around:{radius_m},{lat},{lon});
509
- way["natural"="bay"](around:{radius_m},{lat},{lon});
510
- );
511
- out geom;
512
- """
513
-
514
- for attempt in range(retry_count):
515
- try:
516
- if not (-90 <= lat <= 90 and -180 <= lon <= 180):
517
- print(f"Invalid coords for OSM: {lat},{lon}")
518
- return None
519
- response = requests.post(overpass_url, data={'data': query}, timeout=timeout)
520
-
521
- if response.status_code == 429:
522
- print(f"OSM rate limited for {lat},{lon} - waiting {2 ** attempt}s")
523
- time.sleep(2 ** attempt)
524
- continue
525
-
526
- if response.status_code == 400:
527
- print(f"OSM 400 for {lat},{lon} - bad query")
528
- return None
529
-
530
- if response.status_code != 200:
531
- print(f"OSM HTTP {response.status_code} for {lat},{lon}")
532
- if attempt < retry_count - 1:
533
- time.sleep(1)
534
- continue
535
- return None
536
-
537
- if not response.text.strip():
538
- print(f"OSM empty response for {lat},{lon}")
539
- return None
540
-
541
- try:
542
- data = response.json()
543
- except (json.JSONDecodeError, ValueError) as je:
544
- print(f"OSM JSON decode failed for {lat},{lon}: {je}")
545
- return None
546
-
547
- if not data.get('elements'):
548
- print(f"OSM no elements found for {lat},{lon}")
549
- return None
550
-
551
- point = Point(lon, lat)
552
- min_distance = float('inf')
553
-
554
- significant_features = [e for e in data['elements'] if is_significant_water_body(e)]
555
-
556
- if not significant_features and radius_m < 12500:
557
- print(f"Retrying {lat},{lon} with extended radius...")
558
- return distance_to_water_osm(lat, lon, radius_m=10000, timeout=timeout, retry_count=1)
559
-
560
- if not significant_features:
561
- print(f"OSM only ornamental features for {lat},{lon}")
562
- return None
563
-
564
- from shapely.geometry import LineString, Polygon
565
-
566
- for element in significant_features:
567
- if 'geometry' in element and len(element['geometry']) >= 2:
568
- coords = [(node['lon'], node['lat']) for node in element['geometry']]
569
-
570
- if element.get('tags', {}).get('waterway'):
571
- try:
572
- water_geom = LineString(coords)
573
- except Exception:
574
- continue
575
- else:
576
- try:
577
- water_geom = Polygon(coords)
578
- except:
579
- try:
580
- water_geom = LineString(coords)
581
- except:
582
- continue
583
-
584
- if not water_geom.is_valid:
585
- continue
586
-
587
- distance = point.distance(water_geom) * 111320
588
- if not np.isnan(distance):
589
- min_distance = min(min_distance, distance)
590
-
591
- result = min_distance if min_distance != float('inf') else None
592
- if result is not None:
593
- print(f"OSM success for {lat},{lon}: {result:.1f}m")
594
- return result
595
-
596
- except requests.exceptions.Timeout:
597
- print(f"OSM timeout for {lat},{lon} (attempt {attempt + 1}/{retry_count})")
598
- if attempt < retry_count - 1:
599
- time.sleep(1)
600
- continue
601
- return None
602
- except Exception as e:
603
- print(f"OSM exception for {lat},{lon}: {e}")
604
- if attempt < retry_count - 1:
605
- time.sleep(1)
606
- continue
607
- return None
608
-
609
- return None
610
-
611
-
612
- def distance_to_water_static(lat, lon):
613
- """
614
- Fallback: calculate distance to Natural Earth water bodies
615
- """
616
- point = Point(lon, lat)
617
-
618
- utm_zone = int((lon + 180) / 6) + 1
619
- hemisphere = 'north' if lat >= 0 else 'south'
620
- utm_crs = CRS.from_string(f"+proj=utm +zone={utm_zone} +{hemisphere} +datum=WGS84")
621
-
622
- transformer = Transformer.from_crs("EPSG:4326", utm_crs, always_xy=True)
623
- point_utm_coords = transformer.transform(lon, lat)
624
- point_utm = Point(point_utm_coords)
625
-
626
- try:
627
- # Use lazy-loaded datasets
628
- rivers_utm = get_rivers().to_crs(utm_crs)
629
- lakes_utm = get_lakes().to_crs(utm_crs)
630
-
631
- river_distances = rivers_utm.geometry.distance(point_utm)
632
- river_distances = river_distances[river_distances.notna()]
633
- min_river_dist = river_distances.min() if len(river_distances) > 0 else np.inf
634
-
635
- lake_distances = lakes_utm.geometry.distance(point_utm)
636
- lake_distances = lake_distances[lake_distances.notna()]
637
- min_lake_dist = lake_distances.min() if len(lake_distances) > 0 else np.inf
638
-
639
- min_dist = min(min_river_dist, min_lake_dist)
640
- result = min_dist if min_dist != np.inf else None
641
-
642
- if result is not None:
643
- print(f"Static fallback for {lat},{lon}: {result:.1f}m")
644
- else:
645
- print(f"Static fallback failed for {lat},{lon}")
646
-
647
- return result
648
- except Exception as p_err:
649
- print(f"Static distance error for {lat},{lon}: {p_err}")
650
- return None
651
-
652
- def check_coastal(lat, lon, timeout=15):
653
- """
654
- Adaptive coastal detection: expands search radius until coastline is found.
655
- """
656
- overpass_url = "http://overpass-api.de/api/interpreter"
657
- point = Point(lon, lat)
658
-
659
- # Sweep radii from 1 km to 5 km
660
- radii = [1000, 2000, 5000]
661
- print(f"[Coastal] Starting coastal search for {lat},{lon} ...")
662
- for r in radii:
663
- query = f"""
664
- [out:json][timeout:{timeout}];
665
- (
666
- way["natural"="coastline"](around:{r},{lat},{lon});
667
- );
668
- out geom;
669
- """
670
-
671
- try:
672
- response = requests.post(overpass_url, data={'data': query}, timeout=timeout)
673
-
674
- if not response.text.strip():
675
- continue
676
-
677
- try:
678
- data = response.json()
679
- except:
680
- continue
681
-
682
- if not data.get('elements'):
683
- print(f"[Coastal] No coastline found at {r} m")
684
- continue
685
-
686
- min_distance = float('inf')
687
- from shapely.geometry import LineString
688
-
689
- for element in data['elements']:
690
- if 'geometry' in element and len(element['geometry']) >= 2:
691
- coords = [(node['lon'], node['lat']) for node in element['geometry']]
692
- coastline = LineString(coords)
693
- distance = point.distance(coastline) * 111320
694
- min_distance = min(min_distance, distance)
695
-
696
- if min_distance != float('inf'):
697
- print(f"Coastal detected for {lat},{lon}: {min_distance:.1f}m (radius={r})")
698
- return True, min_distance
699
-
700
- except Exception as e:
701
- print(f"[Coastal] Error at radius {r}: {e}")
702
- continue
703
-
704
- # If nothing is found
705
- print(f"[Coastal] No coastline detected for {lat},{lon}. Continuing with OSM water search.")
706
- return False, None
707
-
708
-
709
- @lru_cache(maxsize=1000)
710
- def distance_to_water(lat, lon):
711
- """
712
- Combined water distance with caching for batch efficiency.
713
- Uses OSM first, then Natural Earth fallback.
714
- """
715
- lat, lon = round(float(lat), 6), round(float(lon), 6)
716
- print(f"--- Water distance query for {lat},{lon} ---")
717
-
718
- # 1. Check coastal proximity
719
- try:
720
- is_coastal, coast_distance = check_coastal(lat, lon)
721
- if is_coastal and coast_distance is not None:
722
- print(f"Coastal detected for {lat},{lon}: {coast_distance:.1f} m")
723
- return coast_distance
724
- except Exception as e:
725
- print(f"Coastal check failed for {lat},{lon}: {e}")
726
-
727
- # 2. Try OSM query with retries
728
- for radius in [3000, 5000, 8000]:
729
- for attempt in range(3):
730
- try:
731
- print(f"OSM attempt {attempt + 1}/3 at radius {radius} m for {lat},{lon}")
732
- d = distance_to_water_osm(lat, lon, radius_m=radius)
733
- if d is not None:
734
- print(f"OSM success for {lat},{lon}: {d:.1f} m (radius={radius})")
735
- return d
736
- except Exception as e:
737
- print(f"OSM exception on attempt {attempt + 1} for {lat},{lon}: {e}")
738
- time.sleep(1.5)
739
- time.sleep(1.5)
740
-
741
- # 3. Static fallback
742
- try:
743
- d_static = distance_to_water_static(lat, lon)
744
- if d_static is not None:
745
- corrected = d_static * 0.7
746
- print(f"Static fallback for {lat},{lon}: raw={d_static:.1f} m, corrected={corrected:.1f} m")
747
- return corrected
748
- else:
749
- print(f"Static fallback failed for {lat},{lon}")
750
- except Exception as e:
751
- print(f"Static distance error for {lat},{lon}: {e}")
752
-
753
- print(f"All water distance queries failed for {lat},{lon}")
754
- return None
 
 
1
+ import ee
2
+ import geopandas as gpd
3
+ from shapely.geometry import Point
4
+ import requests
5
+ import numpy as np
6
+ from functools import lru_cache
7
+ import warnings
8
+ import json
9
+ from pyproj import CRS, Transformer
10
+ import time
11
+ from datetime import datetime
12
+
13
+ # Initialize GEE
14
+ from gee_auth import initialize_gee
15
+
16
+
17
+ warnings.filterwarnings("ignore", category=RuntimeWarning, module="shapely.measurement")
18
+
19
+ # LAZY LOADING
20
+ _RIVERS = None
21
+ _LAKES = None
22
+
23
+ def get_rivers():
24
+ """Lazy load rivers dataset"""
25
+ global _RIVERS
26
+ if _RIVERS is None:
27
+ _RIVERS = gpd.read_file('data/natural_earth/ne_10m_rivers_lake_centerlines.shp')
28
+ _RIVERS = _RIVERS[_RIVERS.geometry.is_valid].copy()
29
+ print("✅ Rivers shapefile loaded")
30
+ return _RIVERS
31
+
32
+ def get_lakes():
33
+ """Lazy load lakes dataset"""
34
+ global _LAKES
35
+ if _LAKES is None:
36
+ _LAKES = gpd.read_file('data/natural_earth/ne_10m_lakes.shp')
37
+ _LAKES = _LAKES[_LAKES.geometry.is_valid].copy()
38
+ print("✅ Lakes shapefile loaded")
39
+ return _LAKES
40
+
41
+
42
+ def get_terrain_metrics(lat, lon, buffer_m=500, force_dem=None):
43
+ """
44
+ Extract DEM-based metrics with hierarchical fallback strategy.
45
+ """
46
+ initialize_gee()
47
+
48
+ if abs(lat) > 70:
49
+ buffer_m = 100
50
+
51
+ try:
52
+ if abs(lat) > 85:
53
+ print(f"Polar region {lat},{lon} - no terrain data")
54
+ return {'elevation': None, 'slope': None, 'tpi': None, 'mean_elevation': None, 'dem_source': None}
55
+
56
+ point = ee.Geometry.Point([lon, lat])
57
+ region = point.buffer(buffer_m)
58
+
59
+ # Hierarchical DEM selection OR forced DEM for validation
60
+ if force_dem:
61
+ dem, dem_source = _get_forced_dem(lat, lon, force_dem)
62
+ if dem is None:
63
+ # Forced DEM not available at this location
64
+ return {'elevation': None, 'slope': None, 'tpi': None, 'mean_elevation': None, 'dem_source': None}
65
+ else:
66
+ dem, dem_source = _select_best_dem(lat, lon)
67
+ if dem is None:
68
+ print(f"All DEM sources failed for {lat},{lon}")
69
+ return {'elevation': None, 'slope': None, 'tpi': None, 'mean_elevation': None, 'dem_source': None}
70
+
71
+ # Point elevation with smaller buffer
72
+ elevation_sample = dem.reduceRegion(
73
+ reducer=ee.Reducer.mean(),
74
+ geometry=point.buffer(15),
75
+ scale=30,
76
+ maxPixels=1e9,
77
+ bestEffort=True
78
+ )
79
+ elevation = elevation_sample.get('elevation').getInfo()
80
+
81
+ if elevation is None:
82
+ print(f"GEE elevation failed for {lat},{lon} using {dem_source}")
83
+ return {'elevation': None, 'slope': None, 'tpi': None, 'mean_elevation': None, 'dem_source': dem_source}
84
+
85
+ try:
86
+ mean_elevation_sample = dem.reduceRegion(
87
+ reducer=ee.Reducer.mean(),
88
+ geometry=region,
89
+ scale=30,
90
+ maxPixels=1e9,
91
+ bestEffort=True
92
+ )
93
+ mean_elevation = mean_elevation_sample.get('elevation').getInfo()
94
+ except Exception as me_err:
95
+ print(f"GEE mean elev failed for {lat},{lon}: {me_err}")
96
+ mean_elevation = None
97
+
98
+ # Slope
99
+ slope_img = ee.Terrain.slope(dem)
100
+ slope_mean = None
101
+ slope_max = None
102
+
103
+ def safe_reduce(reducer_type):
104
+ try:
105
+ reducer = ee.Reducer.mean() if reducer_type == 'mean' else ee.Reducer.max()
106
+ stats_dict = slope_img.reduceRegion(
107
+ reducer=reducer,
108
+ geometry=point.buffer(200),
109
+ scale=30,
110
+ maxPixels=1e9,
111
+ bestEffort=True
112
+ )
113
+ return stats_dict.get('slope').getInfo()
114
+ except Exception as err:
115
+ if "transform edge" not in str(err):
116
+ print(f"GEE slope {reducer_type} failed for {lat},{lon}: {err}")
117
+ return None
118
+
119
+ slope_mean = safe_reduce('mean')
120
+ slope_max = safe_reduce('max')
121
+ if slope_max is not None and slope_mean is not None:
122
+ if slope_max >= slope_mean * 1.8:
123
+ slope = slope_max
124
+ else:
125
+ slope = slope_mean
126
+ elif slope_mean is not None:
127
+ slope = slope_mean
128
+ elif slope_max is not None:
129
+ slope = slope_max
130
+ else:
131
+ slope = None
132
+
133
+ # TPI
134
+ tpi = None
135
+ if elevation is not None and mean_elevation is not None:
136
+ try:
137
+ tpi = float(elevation) - float(mean_elevation)
138
+ except (ValueError, TypeError):
139
+ tpi = None
140
+
141
+ return {
142
+ 'elevation': round(float(elevation), 2) if elevation is not None else None,
143
+ 'slope': round(float(slope), 2) if slope is not None else None,
144
+ 'tpi': round(float(tpi), 2) if tpi is not None else None,
145
+ 'mean_elevation': round(float(mean_elevation), 2) if mean_elevation is not None else None,
146
+ 'dem_source': dem_source
147
+ }
148
+
149
+ except Exception as e:
150
+ print(f"GEE error for {lat},{lon}: {e}")
151
+ return {
152
+ 'elevation': None,
153
+ 'slope': None,
154
+ 'tpi': None,
155
+ 'mean_elevation': None,
156
+ 'dem_source': None
157
+ }
158
+
159
+
160
+ def _select_best_dem(lat, lon):
161
+ """
162
+ Hierarchical DEM selection: prioritize highest-resolution DEM available.
163
+
164
+ """
165
+
166
+ point = ee.Geometry.Point([lon, lat])
167
+
168
+ # Regional high-resolution DEMs
169
+
170
+ # 1. USGS 3DEP 10m (USA)
171
+
172
+ if -130 < lon < -60 and 20 < lat < 55:
173
+ try:
174
+ usgs_10m = (
175
+ ee.ImageCollection("USGS/3DEP/10m_collection")
176
+ .filterBounds(point)
177
+ .mosaic()
178
+
179
+ )
180
+ # Dynamically detect elevation band
181
+ elev_band = usgs_10m.bandNames().getInfo()[0]
182
+ usgs_10m = usgs_10m.select(elev_band).rename("elevation")
183
+ usgs_10m = usgs_10m.reproject(crs="EPSG:4326", scale=10)
184
+
185
+ test = usgs_10m.reduceRegion(
186
+ ee.Reducer.first(),
187
+ point,
188
+ 10,
189
+ bestEffort=True
190
+ ).get("elevation").getInfo()
191
+
192
+ if test is not None:
193
+ print(f"Using USGS 3DEP 10m for {lat},{lon}")
194
+ return usgs_10m, "USGS_3DEP_10m_collection"
195
+
196
+ except Exception:
197
+ pass
198
+
199
+
200
+ # Netherlands AHN2/3/ (0.5 m – best national DEM globally)
201
+
202
+ if 50 < lat < 54 and 3 < lon < 8:
203
+
204
+ # Priority: AHN3 > AHN2
205
+
206
+ try:
207
+ # AHN3 (2014–2019)
208
+ ahn3 = ee.ImageCollection("AHN/AHN3").select("DTM").mosaic()
209
+ test = ahn3.reduceRegion(
210
+ ee.Reducer.first(), point, 1, bestEffort=True
211
+ ).get("DTM").getInfo()
212
+ if test is not None:
213
+ print(f"Using AHN3 0.5m DTM for {lat},{lon}")
214
+ return ahn3.rename("elevation"), "AHN3_0.5m"
215
+ except:
216
+ pass
217
+
218
+ try:
219
+ # AHN2 (2012)
220
+ ahn2 = ee.Image("AHN/AHN2_05M_INT").select("elevation")
221
+ test = ahn2.reduceRegion(
222
+ ee.Reducer.first(), point, 1, bestEffort=True
223
+ ).get("elevation").getInfo()
224
+ if test is not None:
225
+ print(f"Using AHN2 0.5m DTM for {lat},{lon}")
226
+ return ahn2, "AHN2_0.5m"
227
+ except:
228
+ pass
229
+
230
+
231
+ # 3. UK Environment Agency Composite DTM/DSM (1m)
232
+
233
+ if 49 < lat < 61 and -8 < lon < 3:
234
+ try:
235
+ ea = ee.Image("UK/EA/ENGLAND_1M_TERRAIN/2022")
236
+
237
+ # Identify available elevation band
238
+ bands = ea.bandNames().getInfo()
239
+ elev_candidates = [b for b in bands if b.lower() in ["dtm", "elevation", "b1"]]
240
+
241
+ if not elev_candidates:
242
+ raise Exception("No valid elevation band found")
243
+
244
+ elev_band = elev_candidates[0]
245
+
246
+ # Reproject to WGS84 before sampling
247
+ ea_reproj = ea.select(elev_band).reproject(
248
+ crs="EPSG:4326",
249
+ scale=2
250
+ )
251
+
252
+ test = ea_reproj.reduceRegion(
253
+ reducer=ee.Reducer.first(),
254
+ geometry=point,
255
+ scale=2,
256
+ bestEffort=True,
257
+ maxPixels=1e9
258
+ ).get(elev_band).getInfo()
259
+
260
+ if test is not None:
261
+ print(f"Using UK EA DTM 1m for {lat},{lon}")
262
+ return ea_reproj.rename("elevation"), "EA_UK_1m"
263
+
264
+ except Exception as e:
265
+ print(f"EA UK DEM failed for {lat},{lon}: {e}")
266
+ pass
267
+
268
+ # 4. Australia 5m DEM (LiDAR coastal & urban areas)
269
+
270
+ if -45 < lat < -10 and 110 < lon < 155:
271
+ try:
272
+
273
+ aus_col = ee.ImageCollection("AU/GA/AUSTRALIA_5M_DEM")
274
+
275
+ # Mosaic all tiles that intersect the point
276
+ aus = aus_col.filterBounds(point).mosaic()
277
+
278
+
279
+ elev_band = "elevation"
280
+
281
+ test = aus.select(elev_band).reduceRegion(
282
+ reducer=ee.Reducer.first(),
283
+ geometry=point,
284
+ scale=5,
285
+ bestEffort=True,
286
+ maxPixels=1e9
287
+ ).get(elev_band).getInfo()
288
+
289
+ if test is not None:
290
+ print(f"Using Australia 5m DEM for {lat},{lon}")
291
+ return aus.select(elev_band), "Australia_5m"
292
+
293
+ except Exception as e:
294
+ print(f"AU DEM failed for {lat},{lon}: {e}")
295
+ pass
296
+
297
+
298
+ # Global 30m DEMs
299
+
300
+ # 5. NASADEM
301
+
302
+ if -56 <= lat <= 60:
303
+ try:
304
+ nasadem = ee.Image("NASA/NASADEM_HGT/001").select("elevation")
305
+ test = nasadem.reduceRegion(
306
+ ee.Reducer.first(), point, 30, bestEffort=True
307
+ ).get("elevation").getInfo()
308
+
309
+ if test is not None:
310
+ print(f"Using NASADEM for {lat},{lon}")
311
+ return nasadem, "NASADEM"
312
+ except Exception:
313
+ pass
314
+
315
+ # 6. Copernicus GLO-30
316
+
317
+ try:
318
+ cop = ee.ImageCollection("COPERNICUS/DEM/GLO30").mosaic().select("DEM").rename("elevation")
319
+ test = cop.reduceRegion(
320
+ ee.Reducer.first(), point, 30, bestEffort=True
321
+ ).get("elevation").getInfo()
322
+
323
+ if test is not None:
324
+ print(f"Using Copernicus GLO-30 for {lat},{lon}")
325
+ return cop, "Copernicus_GLO30"
326
+ except Exception:
327
+ pass
328
+
329
+
330
+ # 7. ALOS World 3D-30m
331
+
332
+ if abs(lat) <= 82:
333
+ try:
334
+ alos = ee.ImageCollection("JAXA/ALOS/AW3D30/V4_1").mosaic().select("AVE").rename("elevation")
335
+ test = alos.reduceRegion(
336
+ ee.Reducer.first(), point, 30, bestEffort=True
337
+ ).get("elevation").getInfo()
338
+
339
+ if test is not None:
340
+ print(f"Using ALOS AW3D30 AVE for {lat},{lon}")
341
+ return alos, 'ALOS_AW3D30_AVE'
342
+ except Exception:
343
+ pass
344
+
345
+
346
+ # 8. SRTM fallback
347
+
348
+ if -56 <= lat <= 60:
349
+ try:
350
+ srtm = ee.Image("USGS/SRTMGL1_003").select("elevation")
351
+ test = srtm.reduceRegion(
352
+ ee.Reducer.first(), point, 30, bestEffort=True
353
+ ).get("elevation").getInfo()
354
+
355
+ if test is not None:
356
+ print(f"Using SRTM fallback for {lat},{lon}")
357
+ return srtm, "SRTM_v3"
358
+ except Exception:
359
+ pass
360
+
361
+ print(f"All DEM sources failed for {lat},{lon}")
362
+ return None, None
363
+
364
+ def _get_forced_dem(lat, lon, dem_name):
365
+ """
366
+ Force specific DEM retrieval for validation studies.
367
+ Returns None if DEM unavailable at location.
368
+
369
+ """
370
+ point = ee.Geometry.Point([lon, lat])
371
+
372
+ # Map DEM names to their retrieval logic
373
+ dem_map = {
374
+ 'ALOS_AW3D30': lambda: (
375
+ ee.ImageCollection("JAXA/ALOS/AW3D30/V4_1").mosaic().select("AVE").rename("elevation"),
376
+ 30
377
+ ),
378
+ 'Copernicus_GLO30': lambda: (
379
+ ee.ImageCollection("COPERNICUS/DEM/GLO30").mosaic().select("DEM").rename("elevation"),
380
+ 30
381
+ ),
382
+ 'NASADEM': lambda: (
383
+ ee.Image("NASA/NASADEM_HGT/001").select("elevation"),
384
+ 30
385
+ ),
386
+ 'SRTM_v3': lambda: (
387
+ ee.Image("USGS/SRTMGL1_003").select("elevation"),
388
+ 30
389
+ ),
390
+
391
+ 'AHN3_0.5m': lambda: (
392
+ ee.ImageCollection("AHN/AHN3").select("DTM").mosaic().rename("elevation"),
393
+ 1
394
+ ),
395
+ 'AHN2_0.5m': lambda: (
396
+ ee.Image("AHN/AHN2_05M_INT").select("elevation"),
397
+ 1
398
+ ),
399
+ 'EA_UK_1m': lambda: (
400
+ ee.Image("UK/EA/ENGLAND_1M_TERRAIN/2022").select("dtm").reproject(crs="EPSG:4326", scale=2).rename("elevation"),
401
+ 2
402
+ ),
403
+ 'Australia_5m': lambda: (
404
+ ee.ImageCollection("AU/GA/AUSTRALIA_5M_DEM").filterBounds(point).mosaic().select("elevation"),
405
+ 5
406
+ ),
407
+ 'USGS_3DEP_10m_collection': lambda: (
408
+ ee.ImageCollection("USGS/3DEP/10m_collection").filterBounds(point).mosaic().select("elevation"),
409
+ 10
410
+ )
411
+ }
412
+
413
+ if dem_name not in dem_map:
414
+ print(f"Unknown DEM name: {dem_name}")
415
+ return None, None
416
+
417
+ try:
418
+ dem, scale = dem_map[dem_name]()
419
+
420
+ # Test if data exists at this location
421
+ test = dem.reduceRegion(
422
+ ee.Reducer.first(),
423
+ point,
424
+ scale,
425
+ bestEffort=True
426
+ ).get("elevation").getInfo()
427
+
428
+ if test is not None:
429
+ print(f"Forced DEM {dem_name} available at {lat},{lon}")
430
+ return dem, dem_name
431
+ else:
432
+ print(f"Forced DEM {dem_name} has no data at {lat},{lon}")
433
+ return None, None
434
+
435
+ except Exception as e:
436
+ print(f"Failed to get forced DEM {dem_name} at {lat},{lon}: {e}")
437
+ return None, None
438
+
439
+ def is_significant_water_body(element):
440
+ """
441
+ Determine if water feature is significant for flood risk assessment
442
+ """
443
+ tags = element.get('tags', {})
444
+ name = tags.get('name', '')
445
+
446
+ # Filter by name - fountains
447
+ if name and ('fuente' in name.lower() or 'fountain' in name.lower() or
448
+ 'fonte' in name.lower()):
449
+ return False
450
+
451
+ # Filter by water type tag
452
+ water_type = tags.get('water', '')
453
+ if water_type in ['fountain', 'reflecting_pool', 'pond', 'ornamental']:
454
+ return False
455
+
456
+ # Filter by amenity tag
457
+ if tags.get('amenity') == 'fountain':
458
+ return False
459
+
460
+ # Check if it's a waterway (rivers/streams/canals are significant)
461
+ if tags.get('waterway') in ['river', 'stream', 'canal', 'drain']:
462
+ return True
463
+
464
+ # Calculate approximate area for unnamed water bodies
465
+ if tags.get('natural') == 'water' and 'geometry' in element:
466
+ coords = element.get('geometry', [])
467
+
468
+ if len(coords) >= 3:
469
+ lons = [c['lon'] for c in coords]
470
+ lats = [c['lat'] for c in coords]
471
+
472
+ width = (max(lons) - min(lons)) * 111320
473
+ height = (max(lats) - min(lats)) * 111320
474
+ approx_area = width * height
475
+
476
+ if approx_area < 500:
477
+ return False
478
+
479
+ if len(coords) < 10 and approx_area < 2000:
480
+ return False
481
+
482
+ # Natural water bodies with names (excluding fountains)
483
+ if tags.get('natural') == 'water' and name:
484
+ return True
485
+
486
+ # Large unnamed water bodies
487
+ if tags.get('natural') == 'water' and not name:
488
+ coords = element.get('geometry', [])
489
+ if len(coords) > 50:
490
+ return True
491
+
492
+ return False
493
+
494
+
495
+ def distance_to_water_osm(lat, lon, radius_m=5000, timeout=20, retry_count=2):
496
+ """
497
+ Query OpenStreetMap for nearby SIGNIFICANT water bodies with retry logic
498
+ """
499
+ overpass_url = "http://overpass-api.de/api/interpreter"
500
+
501
+ query = f"""
502
+ [out:json][timeout:{timeout}];
503
+ (
504
+ way["natural"="water"](around:{radius_m},{lat},{lon});
505
+ way["waterway"="river"](around:{radius_m},{lat},{lon});
506
+ way["waterway"="canal"](around:{radius_m},{lat},{lon});
507
+ way["waterway"="stream"](around:{radius_m},{lat},{lon});
508
+ relation["natural"="water"](around:{radius_m},{lat},{lon});
509
+ way["natural"="bay"](around:{radius_m},{lat},{lon});
510
+ );
511
+ out geom;
512
+ """
513
+
514
+ for attempt in range(retry_count):
515
+ try:
516
+ if not (-90 <= lat <= 90 and -180 <= lon <= 180):
517
+ print(f"Invalid coords for OSM: {lat},{lon}")
518
+ return None
519
+ response = requests.post(overpass_url, data={'data': query}, timeout=timeout)
520
+
521
+ if response.status_code == 429:
522
+ print(f"OSM rate limited for {lat},{lon} - waiting {2 ** attempt}s")
523
+ time.sleep(2 ** attempt)
524
+ continue
525
+
526
+ if response.status_code == 400:
527
+ print(f"OSM 400 for {lat},{lon} - bad query")
528
+ return None
529
+
530
+ if response.status_code != 200:
531
+ print(f"OSM HTTP {response.status_code} for {lat},{lon}")
532
+ if attempt < retry_count - 1:
533
+ time.sleep(1)
534
+ continue
535
+ return None
536
+
537
+ if not response.text.strip():
538
+ print(f"OSM empty response for {lat},{lon}")
539
+ return None
540
+
541
+ try:
542
+ data = response.json()
543
+ except (json.JSONDecodeError, ValueError) as je:
544
+ print(f"OSM JSON decode failed for {lat},{lon}: {je}")
545
+ return None
546
+
547
+ if not data.get('elements'):
548
+ print(f"OSM no elements found for {lat},{lon}")
549
+ return None
550
+
551
+ point = Point(lon, lat)
552
+ min_distance = float('inf')
553
+
554
+ significant_features = [e for e in data['elements'] if is_significant_water_body(e)]
555
+
556
+ if not significant_features and radius_m < 12500:
557
+ print(f"Retrying {lat},{lon} with extended radius...")
558
+ return distance_to_water_osm(lat, lon, radius_m=10000, timeout=timeout, retry_count=1)
559
+
560
+ if not significant_features:
561
+ print(f"OSM only ornamental features for {lat},{lon}")
562
+ return None
563
+
564
+ from shapely.geometry import LineString, Polygon
565
+
566
+ for element in significant_features:
567
+ if 'geometry' in element and len(element['geometry']) >= 2:
568
+ coords = [(node['lon'], node['lat']) for node in element['geometry']]
569
+
570
+ if element.get('tags', {}).get('waterway'):
571
+ try:
572
+ water_geom = LineString(coords)
573
+ except Exception:
574
+ continue
575
+ else:
576
+ try:
577
+ water_geom = Polygon(coords)
578
+ except:
579
+ try:
580
+ water_geom = LineString(coords)
581
+ except:
582
+ continue
583
+
584
+ if not water_geom.is_valid:
585
+ continue
586
+
587
+ distance = point.distance(water_geom) * 111320
588
+ if not np.isnan(distance):
589
+ min_distance = min(min_distance, distance)
590
+
591
+ result = min_distance if min_distance != float('inf') else None
592
+ if result is not None:
593
+ print(f"OSM success for {lat},{lon}: {result:.1f}m")
594
+ return result
595
+
596
+ except requests.exceptions.Timeout:
597
+ print(f"OSM timeout for {lat},{lon} (attempt {attempt + 1}/{retry_count})")
598
+ if attempt < retry_count - 1:
599
+ time.sleep(1)
600
+ continue
601
+ return None
602
+ except Exception as e:
603
+ print(f"OSM exception for {lat},{lon}: {e}")
604
+ if attempt < retry_count - 1:
605
+ time.sleep(1)
606
+ continue
607
+ return None
608
+
609
+ return None
610
+
611
+
612
+ def distance_to_water_static(lat, lon):
613
+ """
614
+ Fallback: calculate distance to Natural Earth water bodies
615
+ """
616
+ point = Point(lon, lat)
617
+
618
+ utm_zone = int((lon + 180) / 6) + 1
619
+ hemisphere = 'north' if lat >= 0 else 'south'
620
+ utm_crs = CRS.from_string(f"+proj=utm +zone={utm_zone} +{hemisphere} +datum=WGS84")
621
+
622
+ transformer = Transformer.from_crs("EPSG:4326", utm_crs, always_xy=True)
623
+ point_utm_coords = transformer.transform(lon, lat)
624
+ point_utm = Point(point_utm_coords)
625
+
626
+ try:
627
+ # Use lazy-loaded datasets
628
+ rivers_utm = get_rivers().to_crs(utm_crs)
629
+ lakes_utm = get_lakes().to_crs(utm_crs)
630
+
631
+ river_distances = rivers_utm.geometry.distance(point_utm)
632
+ river_distances = river_distances[river_distances.notna()]
633
+ min_river_dist = river_distances.min() if len(river_distances) > 0 else np.inf
634
+
635
+ lake_distances = lakes_utm.geometry.distance(point_utm)
636
+ lake_distances = lake_distances[lake_distances.notna()]
637
+ min_lake_dist = lake_distances.min() if len(lake_distances) > 0 else np.inf
638
+
639
+ min_dist = min(min_river_dist, min_lake_dist)
640
+ result = min_dist if min_dist != np.inf else None
641
+
642
+ if result is not None:
643
+ print(f"Static fallback for {lat},{lon}: {result:.1f}m")
644
+ else:
645
+ print(f"Static fallback failed for {lat},{lon}")
646
+
647
+ return result
648
+ except Exception as p_err:
649
+ print(f"Static distance error for {lat},{lon}: {p_err}")
650
+ return None
651
+
652
+ def check_coastal(lat, lon, timeout=15):
653
+ """
654
+ Adaptive coastal detection: expands search radius until coastline is found.
655
+ """
656
+ overpass_url = "http://overpass-api.de/api/interpreter"
657
+ point = Point(lon, lat)
658
+
659
+ # Sweep radii from 1 km to 5 km
660
+ radii = [1000, 2000, 5000]
661
+ print(f"[Coastal] Starting coastal search for {lat},{lon} ...")
662
+ for r in radii:
663
+ query = f"""
664
+ [out:json][timeout:{timeout}];
665
+ (
666
+ way["natural"="coastline"](around:{r},{lat},{lon});
667
+ );
668
+ out geom;
669
+ """
670
+
671
+ try:
672
+ response = requests.post(overpass_url, data={'data': query}, timeout=timeout)
673
+
674
+ if not response.text.strip():
675
+ continue
676
+
677
+ try:
678
+ data = response.json()
679
+ except:
680
+ continue
681
+
682
+ if not data.get('elements'):
683
+ print(f"[Coastal] No coastline found at {r} m")
684
+ continue
685
+
686
+ min_distance = float('inf')
687
+ from shapely.geometry import LineString
688
+
689
+ for element in data['elements']:
690
+ if 'geometry' in element and len(element['geometry']) >= 2:
691
+ coords = [(node['lon'], node['lat']) for node in element['geometry']]
692
+ coastline = LineString(coords)
693
+ distance = point.distance(coastline) * 111320
694
+ min_distance = min(min_distance, distance)
695
+
696
+ if min_distance != float('inf'):
697
+ print(f"Coastal detected for {lat},{lon}: {min_distance:.1f}m (radius={r})")
698
+ return True, min_distance
699
+
700
+ except Exception as e:
701
+ print(f"[Coastal] Error at radius {r}: {e}")
702
+ continue
703
+
704
+ # If nothing is found
705
+ print(f"[Coastal] No coastline detected for {lat},{lon}. Continuing with OSM water search.")
706
+ return False, None
707
+
708
+
709
+ @lru_cache(maxsize=1000)
710
+ def distance_to_water(lat, lon):
711
+ """
712
+ Combined water distance with caching for batch efficiency.
713
+ Uses OSM first, then Natural Earth fallback.
714
+ """
715
+ lat, lon = round(float(lat), 6), round(float(lon), 6)
716
+ print(f"--- Water distance query for {lat},{lon} ---")
717
+
718
+ # 1. Check coastal proximity
719
+ try:
720
+ is_coastal, coast_distance = check_coastal(lat, lon)
721
+ if is_coastal and coast_distance is not None:
722
+ print(f"Coastal detected for {lat},{lon}: {coast_distance:.1f} m")
723
+ return coast_distance
724
+ except Exception as e:
725
+ print(f"Coastal check failed for {lat},{lon}: {e}")
726
+
727
+ # 2. Try OSM query with retries
728
+ for radius in [3000, 5000, 8000]:
729
+ for attempt in range(3):
730
+ try:
731
+ print(f"OSM attempt {attempt + 1}/3 at radius {radius} m for {lat},{lon}")
732
+ d = distance_to_water_osm(lat, lon, radius_m=radius)
733
+ if d is not None:
734
+ print(f"OSM success for {lat},{lon}: {d:.1f} m (radius={radius})")
735
+ return d
736
+ except Exception as e:
737
+ print(f"OSM exception on attempt {attempt + 1} for {lat},{lon}: {e}")
738
+ time.sleep(1.5)
739
+ time.sleep(1.5)
740
+
741
+ # 3. Static fallback
742
+ try:
743
+ d_static = distance_to_water_static(lat, lon)
744
+ if d_static is not None:
745
+ corrected = d_static * 0.7
746
+ print(f"Static fallback for {lat},{lon}: raw={d_static:.1f} m, corrected={corrected:.1f} m")
747
+ return corrected
748
+ else:
749
+ print(f"Static fallback failed for {lat},{lon}")
750
+ except Exception as e:
751
+ print(f"Static distance error for {lat},{lon}: {e}")
752
+
753
+ print(f"All water distance queries failed for {lat},{lon}")
754
+ return None
755
+