zero2route3d-sdk 0.2.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.
Files changed (42) hide show
  1. zero2route3d/__init__.py +212 -0
  2. zero2route3d/accessibility_equity.py +214 -0
  3. zero2route3d/ahp_engine.py +117 -0
  4. zero2route3d/api.py +244 -0
  5. zero2route3d/basemap.py +44 -0
  6. zero2route3d/cli.py +251 -0
  7. zero2route3d/copernicus_dem.py +76 -0
  8. zero2route3d/copernicus_eo_suite.py +443 -0
  9. zero2route3d/dem_fetcher.py +223 -0
  10. zero2route3d/environmental_raster.py +411 -0
  11. zero2route3d/evacuation.py +121 -0
  12. zero2route3d/html_bundler.py +603 -0
  13. zero2route3d/input_validation.py +100 -0
  14. zero2route3d/integrations.py +260 -0
  15. zero2route3d/isochrone_engine.py +220 -0
  16. zero2route3d/kinematics.py +383 -0
  17. zero2route3d/local_server.py +116 -0
  18. zero2route3d/map_matching_3d.py +374 -0
  19. zero2route3d/micro_elevation.py +264 -0
  20. zero2route3d/mobility_profiles.py +517 -0
  21. zero2route3d/multimodal.py +172 -0
  22. zero2route3d/network_source.py +512 -0
  23. zero2route3d/osm_downloader.py +328 -0
  24. zero2route3d/osm_styling.py +351 -0
  25. zero2route3d/pareto_router.py +331 -0
  26. zero2route3d/plotting.py +292 -0
  27. zero2route3d/profile_dxf.py +231 -0
  28. zero2route3d/profile_stats.py +648 -0
  29. zero2route3d/py.typed +1 -0
  30. zero2route3d/qml_generator.py +189 -0
  31. zero2route3d/raster_source.py +206 -0
  32. zero2route3d/report_generator.py +154 -0
  33. zero2route3d/route_corridor_3d.py +479 -0
  34. zero2route3d/routing_engine.py +747 -0
  35. zero2route3d/solar_shadow.py +157 -0
  36. zero2route3d/tsp_solver.py +78 -0
  37. zero2route3d_sdk-0.2.0.dist-info/METADATA +251 -0
  38. zero2route3d_sdk-0.2.0.dist-info/RECORD +42 -0
  39. zero2route3d_sdk-0.2.0.dist-info/WHEEL +5 -0
  40. zero2route3d_sdk-0.2.0.dist-info/entry_points.txt +2 -0
  41. zero2route3d_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
  42. zero2route3d_sdk-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,374 @@
1
+ """Real-Time GPS Track & GPX 3D Map Matching Engine.
2
+
3
+ Implements:
4
+ 1. Hidden Markov Model (HMM) with Newson-Krumm emission & transition probabilities.
5
+ 2. Viterbi Trellis Dynamic Programming for optimal topological path decoding.
6
+ 3. Sub-pixel 3D elevation reconstruction and slope synthesis from high-res DEM.
7
+ 4. Comprehensive GPX 1.1 parser and matched track telemetry exporter.
8
+
9
+ Author: Yusuf Eminoglu
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import math
16
+ import re
17
+ from dataclasses import dataclass
18
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
19
+
20
+ from .kinematics import haversine_distance_2d
21
+ from .micro_elevation import MicroElevationEngine
22
+
23
+
24
+ @dataclass
25
+ class GPXPoint:
26
+ """Raw or filtered GPS track point."""
27
+
28
+ lon: float
29
+ lat: float
30
+ elevation_raw_m: float = 0.0
31
+ timestamp_iso: str = ""
32
+ speed_ms: Optional[float] = None
33
+ hdop: Optional[float] = None
34
+
35
+
36
+ @dataclass
37
+ class MatchedTrackPoint3D:
38
+ """Topologically snapped 3D track point with reconstructed kinematics."""
39
+
40
+ lon: float
41
+ lat: float
42
+ elevation_m: float
43
+ snapped_edge_u: int
44
+ snapped_edge_v: int
45
+ lateral_residual_m: float
46
+ reconstructed_slope_pct: float
47
+ speed_kmh: float
48
+
49
+ def to_dict(self) -> Dict[str, Any]:
50
+ return {
51
+ "lon": round(self.lon, 6),
52
+ "lat": round(self.lat, 6),
53
+ "elevation_m": round(self.elevation_m, 2),
54
+ "residual_m": round(self.lateral_residual_m, 2),
55
+ "slope_pct": round(self.reconstructed_slope_pct, 2),
56
+ "speed_kmh": round(self.speed_kmh, 1),
57
+ }
58
+
59
+
60
+ @dataclass
61
+ class MapMatching3DResult:
62
+ """Complete map-matched 3D trajectory result with diagnostics."""
63
+
64
+ matched_points: List[MatchedTrackPoint3D]
65
+ matched_nodes_path: List[int]
66
+ total_raw_points: int
67
+ mean_snapping_error_m: float
68
+ total_matched_distance_m: float
69
+ elevation_gain_m: float
70
+ status_message: str
71
+
72
+ def to_gpx(self) -> str:
73
+ """Export snapped 3D trajectory to GPX 1.1 with rich telemetry extensions."""
74
+ trkpts = []
75
+ for p in self.matched_points:
76
+ trkpts.append(
77
+ f' <trkpt lat="{p.lat:.6f}" lon="{p.lon:.6f}">\n'
78
+ f" <ele>{p.elevation_m:.2f}</ele>\n"
79
+ f" <extensions>\n"
80
+ f" <residual_m>{p.lateral_residual_m:.2f}</residual_m>\n"
81
+ f" <slope_pct>{p.reconstructed_slope_pct:.1f}</slope_pct>\n"
82
+ f" </extensions>\n"
83
+ f" </trkpt>"
84
+ )
85
+ pts_xml = "\n".join(trkpts)
86
+ return f"""<?xml version="1.0" encoding="UTF-8"?>
87
+ <gpx version="1.1" creator="02Route 3D Map Matcher" xmlns="http://www.topografix.com/GPX/1/1">
88
+ <metadata><name>02Route 3D Matched Trajectory</name></metadata>
89
+ <trk>
90
+ <name>3D Snapped Track</name>
91
+ <trkseg>
92
+ {pts_xml}
93
+ </trkseg>
94
+ </trk>
95
+ </gpx>"""
96
+
97
+
98
+ class HMMMapMatcher3D:
99
+ """Hidden Markov Model (HMM) 3D Map Matching with Viterbi decoding."""
100
+
101
+ def __init__(
102
+ self,
103
+ nodes: Dict[int, Tuple[float, float, float]],
104
+ adj: Dict[int, List[Tuple[int, float, float, Dict[str, Any]]]],
105
+ micro_elevation: Optional[MicroElevationEngine] = None,
106
+ sigma_z: float = 4.07,
107
+ beta: float = 3.0,
108
+ ) -> None:
109
+ self.nodes = nodes
110
+ self.adj = adj
111
+ self.micro_elevation = micro_elevation or MicroElevationEngine()
112
+ self.sigma_z = sigma_z
113
+ self.beta = beta
114
+
115
+ def _project_point_to_edge(
116
+ self,
117
+ pt: Tuple[float, float],
118
+ u: int,
119
+ v: int,
120
+ ) -> Tuple[Tuple[float, float], float, float]:
121
+ """Project (lon, lat) onto segment (u -> v). Returns (snapped_pt, distance_m, fraction)."""
122
+ if u not in self.nodes or v not in self.nodes:
123
+ return (pt[0], pt[1]), float("inf"), 0.0
124
+
125
+ p1 = self.nodes[u]
126
+ p2 = self.nodes[v]
127
+
128
+ dx = p2[0] - p1[0]
129
+ dy = p2[1] - p1[1]
130
+ seg_len_sq = dx * dx + dy * dy
131
+
132
+ if not math.isfinite(seg_len_sq) or seg_len_sq < 1e-12:
133
+ d = haversine_distance_2d(pt, p1)
134
+ return (p1[0], p1[1]), d, 0.0
135
+
136
+ num = (pt[0] - p1[0]) * dx + (pt[1] - p1[1]) * dy
137
+ if not math.isfinite(num):
138
+ d = haversine_distance_2d(pt, p1)
139
+ return (p1[0], p1[1]), d, 0.0
140
+
141
+ t = max(0.0, min(1.0, num / seg_len_sq))
142
+ snap_lon = p1[0] + t * dx
143
+ snap_lat = p1[1] + t * dy
144
+ d_m = haversine_distance_2d(pt, (snap_lon, snap_lat))
145
+ return (snap_lon, snap_lat), d_m, t
146
+
147
+ def _emission_log_prob(self, dist_m: float) -> float:
148
+ """Log emission probability under Gaussian sensor error model."""
149
+ sigma = max(1e-4, float(self.sigma_z)) if math.isfinite(self.sigma_z) else 4.07
150
+ d = float(dist_m) if math.isfinite(dist_m) and dist_m >= 0 else 100.0
151
+ return -0.5 * ((d / sigma) ** 2) - math.log(math.sqrt(2.0 * math.pi) * sigma)
152
+
153
+ def _transition_log_prob(self, great_circle_dist_m: float, network_dist_m: float) -> float:
154
+ """Log transition probability under exponential network difference model."""
155
+ beta = max(1e-4, float(self.beta)) if math.isfinite(self.beta) else 3.0
156
+ d_gc = (
157
+ float(great_circle_dist_m)
158
+ if math.isfinite(great_circle_dist_m) and great_circle_dist_m >= 0
159
+ else 0.0
160
+ )
161
+ d_net = (
162
+ float(network_dist_m) if math.isfinite(network_dist_m) and network_dist_m >= 0 else 0.0
163
+ )
164
+ delta = abs(d_gc - d_net)
165
+ return -(delta / beta) - math.log(beta)
166
+
167
+ def match_gps_track(
168
+ self,
169
+ raw_points: Sequence[GPXPoint],
170
+ search_radius_m: float = 35.0,
171
+ ) -> MapMatching3DResult:
172
+ """Execute Viterbi HMM decoding to match noisy GPS track onto topological 3D graph."""
173
+ if not self.nodes:
174
+ return MapMatching3DResult(
175
+ [], [], len(raw_points) if raw_points else 0, 0.0, 0.0, 0.0, "Graph is empty."
176
+ )
177
+
178
+ valid_raw = [
179
+ p
180
+ for p in raw_points
181
+ if p and math.isfinite(float(p.lon)) and math.isfinite(float(p.lat))
182
+ ]
183
+ if len(valid_raw) < 2:
184
+ return MapMatching3DResult(
185
+ [],
186
+ [],
187
+ len(raw_points) if raw_points else 0,
188
+ 0.0,
189
+ 0.0,
190
+ 0.0,
191
+ "Insufficient GPS points.",
192
+ )
193
+
194
+ s_rad = max(1.0, float(search_radius_m)) if math.isfinite(search_radius_m) else 35.0
195
+ candidates_per_time: List[List[Dict[str, Any]]] = []
196
+
197
+ for pt in valid_raw:
198
+ cands = []
199
+ pt_coord = (pt.lon, pt.lat)
200
+ for u in self.nodes:
201
+ for v, length_m, _slope, _meta in self.adj.get(u, []):
202
+ snap_pt, d_m, frac = self._project_point_to_edge(pt_coord, u, v)
203
+ if d_m <= s_rad:
204
+ cands.append(
205
+ {
206
+ "u": u,
207
+ "v": v,
208
+ "snap_lon": snap_pt[0],
209
+ "snap_lat": snap_pt[1],
210
+ "dist_m": d_m,
211
+ "frac": frac,
212
+ "edge_len": length_m,
213
+ }
214
+ )
215
+ if not cands:
216
+ nearest_node = min(
217
+ self.nodes, key=lambda n: haversine_distance_2d(pt_coord, self.nodes[n])
218
+ )
219
+ n_coord = self.nodes[nearest_node]
220
+ cands.append(
221
+ {
222
+ "u": nearest_node,
223
+ "v": nearest_node,
224
+ "snap_lon": n_coord[0],
225
+ "snap_lat": n_coord[1],
226
+ "dist_m": haversine_distance_2d(pt_coord, n_coord),
227
+ "frac": 0.0,
228
+ "edge_len": 1.0,
229
+ }
230
+ )
231
+ candidates_per_time.append(cands[:10])
232
+
233
+ T = len(valid_raw)
234
+ viterbi_log: List[Dict[int, float]] = [{} for _ in range(T)]
235
+ backpointer: List[Dict[int, int]] = [{} for _ in range(T)]
236
+
237
+ for c_idx, cand in enumerate(candidates_per_time[0]):
238
+ viterbi_log[0][c_idx] = self._emission_log_prob(cand["dist_m"])
239
+
240
+ for t in range(1, T):
241
+ pt_prev = (valid_raw[t - 1].lon, valid_raw[t - 1].lat)
242
+ pt_curr = (valid_raw[t].lon, valid_raw[t].lat)
243
+ d_gc = haversine_distance_2d(pt_prev, pt_curr)
244
+
245
+ for c_curr_idx, c_curr in enumerate(candidates_per_time[t]):
246
+ best_prob = -float("inf")
247
+ best_prev = 0
248
+ e_prob = self._emission_log_prob(c_curr["dist_m"])
249
+
250
+ for c_prev_idx, c_prev in enumerate(candidates_per_time[t - 1]):
251
+ d_net = haversine_distance_2d(
252
+ (c_prev["snap_lon"], c_prev["snap_lat"]),
253
+ (c_curr["snap_lon"], c_curr["snap_lat"]),
254
+ )
255
+ t_prob = self._transition_log_prob(d_gc, d_net)
256
+ total_p = viterbi_log[t - 1].get(c_prev_idx, -float("inf")) + t_prob + e_prob
257
+
258
+ if total_p > best_prob:
259
+ best_prob = total_p
260
+ best_prev = c_prev_idx
261
+
262
+ viterbi_log[t][c_curr_idx] = best_prob
263
+ backpointer[t][c_curr_idx] = best_prev
264
+
265
+ if viterbi_log[T - 1]:
266
+ last_step = viterbi_log[T - 1]
267
+ best_last_idx = max(last_step, key=lambda idx: last_step[idx])
268
+ else:
269
+ best_last_idx = 0
270
+
271
+ optimal_cands = [candidates_per_time[T - 1][best_last_idx]]
272
+ curr_idx = best_last_idx
273
+
274
+ for t in range(T - 1, 0, -1):
275
+ curr_idx = backpointer[t].get(curr_idx, 0)
276
+ optimal_cands.append(candidates_per_time[t - 1][curr_idx])
277
+
278
+ optimal_cands.reverse()
279
+
280
+ matched_points: List[MatchedTrackPoint3D] = []
281
+ total_residual = 0.0
282
+ total_dist = 0.0
283
+ elevation_gain = 0.0
284
+ prev_z: Optional[float] = None
285
+ node_path: List[int] = []
286
+
287
+ for idx, cand in enumerate(optimal_cands):
288
+ lon = cand["snap_lon"]
289
+ lat = cand["snap_lat"]
290
+ grad = self.micro_elevation.sample_micro_gradient(lon, lat)
291
+ z = grad.elevation_m
292
+ residual = cand["dist_m"]
293
+ total_residual += residual
294
+
295
+ if prev_z is not None:
296
+ dz = z - prev_z
297
+ if dz > 0:
298
+ elevation_gain += dz
299
+ seg_d = haversine_distance_2d(
300
+ (optimal_cands[idx - 1]["snap_lon"], optimal_cands[idx - 1]["snap_lat"]),
301
+ (lon, lat),
302
+ )
303
+ total_dist += seg_d
304
+
305
+ prev_z = z
306
+ node_path.append(cand["u"])
307
+
308
+ spd_ms = valid_raw[idx].speed_ms
309
+ speed_val = (
310
+ spd_ms * 3.6
311
+ if spd_ms is not None and math.isfinite(spd_ms) and spd_ms > 0
312
+ else 15.0
313
+ )
314
+
315
+ matched_points.append(
316
+ MatchedTrackPoint3D(
317
+ lon=lon,
318
+ lat=lat,
319
+ elevation_m=z,
320
+ snapped_edge_u=cand["u"],
321
+ snapped_edge_v=cand["v"],
322
+ lateral_residual_m=residual,
323
+ reconstructed_slope_pct=grad.slope_pct,
324
+ speed_kmh=speed_val,
325
+ )
326
+ )
327
+
328
+ mean_err = total_residual / max(1, len(matched_points))
329
+ return MapMatching3DResult(
330
+ matched_points=matched_points,
331
+ matched_nodes_path=node_path,
332
+ total_raw_points=len(raw_points),
333
+ mean_snapping_error_m=mean_err,
334
+ total_matched_distance_m=total_dist,
335
+ elevation_gain_m=elevation_gain,
336
+ status_message=f"Successfully snapped {T} GPS observations with mean residual {mean_err:.1f}m.",
337
+ )
338
+
339
+ @staticmethod
340
+ def parse_gpx_string(gpx_xml_content: str) -> List[GPXPoint]:
341
+ """Parse raw GPX string into structured sequence of GPXPoints using safe regex."""
342
+ points: List[GPXPoint] = []
343
+ with contextlib.suppress(Exception):
344
+ pattern = re.compile(
345
+ r"<trkpt\s+[^>]*lat=[\"']([^\"']+)[\"']\s+[^>]*lon=[\"']([^\"']+)[\"'][^>]*>(.*?)</trkpt>",
346
+ re.DOTALL | re.IGNORECASE,
347
+ )
348
+ pattern_reverse = re.compile(
349
+ r"<trkpt\s+[^>]*lon=[\"']([^\"']+)[\"']\s+[^>]*lat=[\"']([^\"']+)[\"'][^>]*>(.*?)</trkpt>",
350
+ re.DOTALL | re.IGNORECASE,
351
+ )
352
+ matches = list(pattern.finditer(gpx_xml_content))
353
+ if not matches:
354
+ matches = list(pattern_reverse.finditer(gpx_xml_content))
355
+
356
+ for m in matches:
357
+ lat = float(m.group(1))
358
+ lon = float(m.group(2))
359
+ inner = m.group(3)
360
+
361
+ ele = 0.0
362
+ m_ele = re.search(r"<ele>([^<]+)</ele>", inner, re.IGNORECASE)
363
+ if m_ele:
364
+ ele = float(m_ele.group(1))
365
+
366
+ time_iso = ""
367
+ m_time = re.search(r"<time>([^<]+)</time>", inner, re.IGNORECASE)
368
+ if m_time:
369
+ time_iso = m_time.group(1).strip()
370
+
371
+ points.append(
372
+ GPXPoint(lon=lon, lat=lat, elevation_raw_m=ele, timestamp_iso=time_iso)
373
+ )
374
+ return points
@@ -0,0 +1,264 @@
1
+ """Sub-pixel DEM surface micro-elevation, continuous slope/aspect, and IDW interpolator.
2
+
3
+ Implements:
4
+ 1. Keys' 16-point Bicubic Convolution Spline (C1 continuity) for continuous raster interpolation.
5
+ 2. Exact analytical gradient (dz/dx, dz/dy) derivation for terrace-free slope and aspect.
6
+ 3. Modified Shepard's Inverse Distance Weighting (IDW) with smoothing nugget.
7
+ 4. Micro-elevation profile reconstruction with adaptive curvature densification.
8
+
9
+ Author: Yusuf Eminoglu
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import math
16
+ from dataclasses import dataclass
17
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
18
+
19
+
20
+ @dataclass
21
+ class SurfaceGradient3D:
22
+ """Continuous 3D terrain surface gradient at a specific coordinate."""
23
+
24
+ elevation_m: float
25
+ slope_pct: float
26
+ slope_degrees: float
27
+ aspect_degrees: float
28
+ dz_dx: float
29
+ dz_dy: float
30
+ curvature: float
31
+
32
+
33
+ class BicubicSurfaceInterpolator:
34
+ """Keys' 16-point Bicubic Convolution Kernel with C1 continuous derivatives."""
35
+
36
+ def __init__(self, a: float = -0.5) -> None:
37
+ # a = -0.5 corresponds to the Catmull-Rom cubic spline
38
+ self.a = a
39
+
40
+ def _cubic_weight(self, x: float) -> float:
41
+ """Evaluate Keys' cubic convolution weight W(x)."""
42
+ ax = abs(x)
43
+ if ax <= 1.0:
44
+ return (self.a + 2.0) * (ax**3) - (self.a + 3.0) * (ax**2) + 1.0
45
+ elif ax < 2.0:
46
+ return self.a * (ax**3) - 5.0 * self.a * (ax**2) + 8.0 * self.a * ax - 4.0 * self.a
47
+ return 0.0
48
+
49
+ def _cubic_weight_derivative(self, x: float) -> float:
50
+ """Evaluate derivative dW/dx of Keys' cubic convolution kernel."""
51
+ ax = abs(x)
52
+ sgn = 1.0 if x >= 0 else -1.0
53
+ if ax <= 1.0:
54
+ return sgn * (3.0 * (self.a + 2.0) * (ax**2) - 2.0 * (self.a + 3.0) * ax)
55
+ elif ax < 2.0:
56
+ return sgn * (3.0 * self.a * (ax**2) - 10.0 * self.a * ax + 8.0 * self.a)
57
+ return 0.0
58
+
59
+ def interpolate_patch_4x4(
60
+ self,
61
+ grid_4x4: Sequence[Sequence[float]],
62
+ u: float,
63
+ v: float,
64
+ cell_size_x_m: float = 1.0,
65
+ cell_size_y_m: float = 1.0,
66
+ ) -> SurfaceGradient3D:
67
+ """Interpolate elevation, continuous slope, and aspect from a 4x4 raster window."""
68
+ wx = [self._cubic_weight(u - (col - 1)) for col in range(4)]
69
+ wy = [self._cubic_weight(v - (row - 1)) for row in range(4)]
70
+ dwx = [self._cubic_weight_derivative(u - (col - 1)) for col in range(4)]
71
+ dwy = [self._cubic_weight_derivative(v - (row - 1)) for row in range(4)]
72
+
73
+ # Elevation
74
+ z = 0.0
75
+ for r in range(4):
76
+ for c in range(4):
77
+ z += grid_4x4[r][c] * wy[r] * wx[c]
78
+
79
+ # Analytical partial derivatives
80
+ dz_du = 0.0
81
+ dz_dv = 0.0
82
+ for r in range(4):
83
+ for c in range(4):
84
+ dz_du += grid_4x4[r][c] * wy[r] * dwx[c]
85
+ dz_dv += grid_4x4[r][c] * dwy[r] * wx[c]
86
+
87
+ dz_dx = dz_du / max(0.001, cell_size_x_m)
88
+ dz_dy = dz_dv / max(0.001, cell_size_y_m)
89
+
90
+ gradient_magnitude = math.hypot(dz_dx, dz_dy)
91
+ slope_pct = gradient_magnitude * 100.0
92
+ slope_deg = math.degrees(math.atan(gradient_magnitude))
93
+
94
+ aspect_rad = math.atan2(-dz_dx, dz_dy)
95
+ aspect_deg = (math.degrees(aspect_rad) + 360.0) % 360.0
96
+
97
+ # Profile Curvature proxy
98
+ curvature = (dz_du**2 + dz_dv**2) / max(0.001, (1.0 + gradient_magnitude**2) ** 1.5)
99
+
100
+ return SurfaceGradient3D(
101
+ elevation_m=z,
102
+ slope_pct=slope_pct,
103
+ slope_degrees=slope_deg,
104
+ aspect_degrees=aspect_deg,
105
+ dz_dx=dz_dx,
106
+ dz_dy=dz_dy,
107
+ curvature=curvature,
108
+ )
109
+
110
+
111
+ class IDWSurfaceInterpolator:
112
+ """Modified Shepard's Inverse Distance Weighting (IDW) with smoothing nugget."""
113
+
114
+ def __init__(self, power: float = 2.0, smoothing_epsilon: float = 1e-6) -> None:
115
+ self.power = power
116
+ self.epsilon = smoothing_epsilon
117
+
118
+ def interpolate_point(
119
+ self,
120
+ target_x: float,
121
+ target_y: float,
122
+ known_points: Sequence[Tuple[float, float, float]],
123
+ max_search_radius: Optional[float] = None,
124
+ ) -> float:
125
+ """Interpolate elevation at (target_x, target_y) using weighted known point observations."""
126
+ if not known_points:
127
+ return 0.0
128
+
129
+ sum_w = 0.0
130
+ sum_wz = 0.0
131
+
132
+ for px, py, pz in known_points:
133
+ dist = math.hypot(target_x - px, target_y - py)
134
+ if dist < self.epsilon:
135
+ return pz
136
+
137
+ if max_search_radius is not None and dist > max_search_radius:
138
+ continue
139
+
140
+ w = 1.0 / (dist**self.power)
141
+ sum_w += w
142
+ sum_wz += w * pz
143
+
144
+ if sum_w <= 0.0:
145
+ return min(known_points, key=lambda p: math.hypot(target_x - p[0], target_y - p[1]))[2]
146
+
147
+ return sum_wz / sum_w
148
+
149
+
150
+ class MicroElevationEngine:
151
+ """High-fidelity micro-elevation and profile reconstruction engine for QGIS."""
152
+
153
+ def __init__(self, dem_layer: Optional[Any] = None) -> None:
154
+ self.dem_layer = dem_layer
155
+ self.bicubic = BicubicSurfaceInterpolator(a=-0.5)
156
+ self.idw = IDWSurfaceInterpolator(power=2.0)
157
+ self._cache: Dict[Tuple[float, float], SurfaceGradient3D] = {}
158
+
159
+ def sample_micro_gradient(self, lon: float, lat: float) -> SurfaceGradient3D:
160
+ """Sample elevation, continuous slope %, and aspect with sub-pixel bicubic accuracy."""
161
+ cache_key = (round(lon, 6), round(lat, 6))
162
+ if cache_key in self._cache:
163
+ return self._cache[cache_key]
164
+
165
+ if self.dem_layer is not None:
166
+ with contextlib.suppress(Exception):
167
+ from qgis.core import (
168
+ QgsCoordinateReferenceSystem,
169
+ QgsCoordinateTransform,
170
+ QgsPointXY,
171
+ QgsProject,
172
+ )
173
+
174
+ pt = QgsPointXY(lon, lat)
175
+ crs_src = QgsCoordinateReferenceSystem("EPSG:4326")
176
+ crs_dest = self.dem_layer.crs()
177
+ if crs_src != crs_dest:
178
+ transform = QgsCoordinateTransform(crs_src, crs_dest, QgsProject.instance())
179
+ pt = transform.transform(pt)
180
+
181
+ provider = self.dem_layer.dataProvider()
182
+ extent = self.dem_layer.extent()
183
+ res_x = self.dem_layer.rasterUnitsPerPixelX()
184
+ res_y = self.dem_layer.rasterUnitsPerPixelY()
185
+
186
+ if extent.contains(pt):
187
+ grid = []
188
+ col_base = (pt.x() - extent.xMinimum()) / res_x
189
+ row_base = (extent.yMaximum() - pt.y()) / res_y
190
+
191
+ c_int = int(math.floor(col_base))
192
+ r_int = int(math.floor(row_base))
193
+ u = col_base - c_int
194
+ v = row_base - r_int
195
+
196
+ for dr in range(-1, 3):
197
+ row_vals = []
198
+ for dc in range(-1, 3):
199
+ sample_x = extent.xMinimum() + (c_int + dc + 0.5) * res_x
200
+ sample_y = extent.yMaximum() - (r_int + dr + 0.5) * res_y
201
+ val, success = provider.sample(QgsPointXY(sample_x, sample_y), 1)
202
+ if success and val is not None and not math.isnan(val):
203
+ row_vals.append(float(val))
204
+ else:
205
+ row_vals.append(0.0)
206
+ grid.append(row_vals)
207
+
208
+ lat_rad = math.radians(lat)
209
+ meters_per_deg_lat = 111320.0
210
+ meters_per_deg_lon = 111320.0 * math.cos(lat_rad)
211
+ cell_x_m = res_x * meters_per_deg_lon if crs_dest.isGeographic() else res_x
212
+ cell_y_m = res_y * meters_per_deg_lat if crs_dest.isGeographic() else res_y
213
+
214
+ grad = self.bicubic.interpolate_patch_4x4(
215
+ grid, u, v, cell_size_x_m=cell_x_m, cell_size_y_m=cell_y_m
216
+ )
217
+ self._cache[cache_key] = grad
218
+ return grad
219
+
220
+ # No DEM coverage: return an explicit flat/missing surface rather than
221
+ # fabricating relief from the coordinate values.
222
+ z = 0.0
223
+ grad = SurfaceGradient3D(
224
+ elevation_m=z,
225
+ slope_pct=0.0,
226
+ slope_degrees=0.0,
227
+ aspect_degrees=0.0,
228
+ dz_dx=0.0,
229
+ dz_dy=0.0,
230
+ curvature=0.0,
231
+ )
232
+ self._cache[cache_key] = grad
233
+ return grad
234
+
235
+ def reconstruct_3d_profile(
236
+ self,
237
+ coords_2d_or_3d: Sequence[Sequence[float]],
238
+ densification_interval_m: float = 5.0,
239
+ ) -> List[Tuple[float, float, float, SurfaceGradient3D]]:
240
+ """Reconstruct ultra-smooth 3D profile with continuous micro-elevation gradients."""
241
+ if len(coords_2d_or_3d) < 2:
242
+ return []
243
+
244
+ from .kinematics import haversine_distance_2d
245
+
246
+ reconstructed: List[Tuple[float, float, float, SurfaceGradient3D]] = []
247
+
248
+ for i in range(len(coords_2d_or_3d) - 1):
249
+ p1 = coords_2d_or_3d[i]
250
+ p2 = coords_2d_or_3d[i + 1]
251
+ seg_dist = haversine_distance_2d(p1, p2)
252
+ steps = max(1, int(math.ceil(seg_dist / densification_interval_m)))
253
+
254
+ for s in range(steps):
255
+ frac = s / steps
256
+ lon = p1[0] + (p2[0] - p1[0]) * frac
257
+ lat = p1[1] + (p2[1] - p1[1]) * frac
258
+ grad = self.sample_micro_gradient(lon, lat)
259
+ reconstructed.append((lon, lat, grad.elevation_m, grad))
260
+
261
+ p_last = coords_2d_or_3d[-1]
262
+ grad_last = self.sample_micro_gradient(p_last[0], p_last[1])
263
+ reconstructed.append((p_last[0], p_last[1], grad_last.elevation_m, grad_last))
264
+ return reconstructed