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.
- zero2route3d/__init__.py +212 -0
- zero2route3d/accessibility_equity.py +214 -0
- zero2route3d/ahp_engine.py +117 -0
- zero2route3d/api.py +244 -0
- zero2route3d/basemap.py +44 -0
- zero2route3d/cli.py +251 -0
- zero2route3d/copernicus_dem.py +76 -0
- zero2route3d/copernicus_eo_suite.py +443 -0
- zero2route3d/dem_fetcher.py +223 -0
- zero2route3d/environmental_raster.py +411 -0
- zero2route3d/evacuation.py +121 -0
- zero2route3d/html_bundler.py +603 -0
- zero2route3d/input_validation.py +100 -0
- zero2route3d/integrations.py +260 -0
- zero2route3d/isochrone_engine.py +220 -0
- zero2route3d/kinematics.py +383 -0
- zero2route3d/local_server.py +116 -0
- zero2route3d/map_matching_3d.py +374 -0
- zero2route3d/micro_elevation.py +264 -0
- zero2route3d/mobility_profiles.py +517 -0
- zero2route3d/multimodal.py +172 -0
- zero2route3d/network_source.py +512 -0
- zero2route3d/osm_downloader.py +328 -0
- zero2route3d/osm_styling.py +351 -0
- zero2route3d/pareto_router.py +331 -0
- zero2route3d/plotting.py +292 -0
- zero2route3d/profile_dxf.py +231 -0
- zero2route3d/profile_stats.py +648 -0
- zero2route3d/py.typed +1 -0
- zero2route3d/qml_generator.py +189 -0
- zero2route3d/raster_source.py +206 -0
- zero2route3d/report_generator.py +154 -0
- zero2route3d/route_corridor_3d.py +479 -0
- zero2route3d/routing_engine.py +747 -0
- zero2route3d/solar_shadow.py +157 -0
- zero2route3d/tsp_solver.py +78 -0
- zero2route3d_sdk-0.2.0.dist-info/METADATA +251 -0
- zero2route3d_sdk-0.2.0.dist-info/RECORD +42 -0
- zero2route3d_sdk-0.2.0.dist-info/WHEEL +5 -0
- zero2route3d_sdk-0.2.0.dist-info/entry_points.txt +2 -0
- zero2route3d_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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
|