mskit-simu 0.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mskit/__init__.py +33 -0
- mskit/_cli.py +8 -0
- mskit/cli.py +354 -0
- mskit/dem.py +231 -0
- mskit/sims/__init__.py +1 -0
- mskit/sims/agent.py +236 -0
- mskit/sims/flow.py +103 -0
- mskit/sims/projectile.py +146 -0
- mskit/sims/random_walk.py +141 -0
- mskit/simu/__init__.py +4 -0
- mskit/simu/intent.py +275 -0
- mskit/simu/simu.py +921 -0
- mskit/traffic/__init__.py +14 -0
- mskit/traffic/openctv.py +341 -0
- mskit/traffic/opentraffic.py +402 -0
- mskit/traffic/router.py +353 -0
- mskit/traffic/utd19.py +461 -0
- mskit_simu-0.6.1.dist-info/METADATA +316 -0
- mskit_simu-0.6.1.dist-info/RECORD +23 -0
- mskit_simu-0.6.1.dist-info/WHEEL +5 -0
- mskit_simu-0.6.1.dist-info/entry_points.txt +3 -0
- mskit_simu-0.6.1.dist-info/licenses/LICENSE +21 -0
- mskit_simu-0.6.1.dist-info/top_level.txt +1 -0
mskit/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MSKit — Mini Simulation Kit
|
|
3
|
+
============================
|
|
4
|
+
Terrain-based simulations powered by real-world data:
|
|
5
|
+
* JAXA AW3D30 30m elevation (global, lazy-streamed from HuggingFace)
|
|
6
|
+
* OpenTraffic / OSRM — live road speeds, global
|
|
7
|
+
* UTD19 — 40 cities, 23 541 loop detectors (ETH Zurich)
|
|
8
|
+
* OpenCTV — free public traffic cameras (SG, London, NYC)
|
|
9
|
+
* Simu — built-in AI assistant (SmolLM2-360M, open-source)
|
|
10
|
+
|
|
11
|
+
Dataset : https://huggingface.co/datasets/MegaBites-AI/AW3D30-DEM-Tiles
|
|
12
|
+
GitHub : https://github.com/MegaBites-AI/MSKit
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .dem import DEMTile, DEMLoader
|
|
16
|
+
from .sims.random_walk import RandomWalk
|
|
17
|
+
from .sims.projectile import Projectile
|
|
18
|
+
from .sims.flow import WaterFlow
|
|
19
|
+
from .sims.agent import TerrainAgent
|
|
20
|
+
from .traffic import OpenTrafficLayer, UTD19Layer, TrafficRouter, OpenCTVLayer
|
|
21
|
+
from .simu import Simu
|
|
22
|
+
|
|
23
|
+
__version__ = "0.6.0"
|
|
24
|
+
__author__ = "MegaBites AI Team"
|
|
25
|
+
__dataset__ = "MegaBites-AI/AW3D30-DEM-Tiles"
|
|
26
|
+
__github__ = "https://github.com/MegaBites-AI/MSKit"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"DEMTile", "DEMLoader",
|
|
30
|
+
"RandomWalk", "Projectile", "WaterFlow", "TerrainAgent",
|
|
31
|
+
"OpenTrafficLayer", "UTD19Layer", "TrafficRouter", "OpenCTVLayer",
|
|
32
|
+
"Simu",
|
|
33
|
+
]
|
mskit/_cli.py
ADDED
mskit/cli.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MSKit CLI — unified command-line interface.
|
|
3
|
+
Entry point: mskit
|
|
4
|
+
|
|
5
|
+
Commands:
|
|
6
|
+
mskit simu Interactive AI assistant
|
|
7
|
+
mskit run "desc..." One-shot simulation
|
|
8
|
+
mskit cameras --city NAME Traffic cameras near a city
|
|
9
|
+
mskit cameras --lat L --lon O Search cameras by coordinates
|
|
10
|
+
mskit traffic --city NAME Traffic speed/congestion
|
|
11
|
+
mskit demo Full feature demo
|
|
12
|
+
mskit info Version and data source info
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
import argparse
|
|
18
|
+
import textwrap
|
|
19
|
+
import datetime
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
# ── City table ───────────────────────────────────────────────────────────────
|
|
23
|
+
_CITIES = {
|
|
24
|
+
"tokyo": (35.6762, 139.6503),
|
|
25
|
+
"london": (51.5074, -0.1278),
|
|
26
|
+
"paris": (48.8566, 2.3522),
|
|
27
|
+
"new york": (40.7128, -74.0060),
|
|
28
|
+
"nyc": (40.7128, -74.0060),
|
|
29
|
+
"singapore": ( 1.3521, 103.8198),
|
|
30
|
+
"sydney": (-33.8688, 151.2093),
|
|
31
|
+
"berlin": (52.5200, 13.4050),
|
|
32
|
+
"zurich": (47.3769, 8.5417),
|
|
33
|
+
"hong kong": (22.3193, 114.1694),
|
|
34
|
+
"dubai": (25.2048, 55.2708),
|
|
35
|
+
"seoul": (37.5665, 127.0000),
|
|
36
|
+
"beijing": (39.9042, 116.4074),
|
|
37
|
+
"shanghai": (31.2304, 121.4737),
|
|
38
|
+
"mumbai": (19.0760, 72.8777),
|
|
39
|
+
"los angeles": (34.0522,-118.2437),
|
|
40
|
+
"san francisco": (37.7749,-122.4194),
|
|
41
|
+
"chicago": (41.8781, -87.6298),
|
|
42
|
+
"toronto": (43.6532, -79.3832),
|
|
43
|
+
"amsterdam": (52.3676, 4.9041),
|
|
44
|
+
"moscow": (55.7558, 37.6173),
|
|
45
|
+
"istanbul": (41.0082, 28.9784),
|
|
46
|
+
"bangkok": (13.7563, 100.5018),
|
|
47
|
+
"cairo": (30.0444, 31.2357),
|
|
48
|
+
"nairobi": (-1.2921, 36.8219),
|
|
49
|
+
"sao paulo": (-23.5505,-46.6333),
|
|
50
|
+
"buenos aires": (-34.6037,-58.3816),
|
|
51
|
+
"mount fuji": (35.3606, 138.7274),
|
|
52
|
+
"fuji": (35.3606, 138.7274),
|
|
53
|
+
"himalayas": (27.9881, 86.9250),
|
|
54
|
+
"everest": (27.9881, 86.9250),
|
|
55
|
+
"alps": (46.5000, 8.5000),
|
|
56
|
+
"grand canyon": (36.1069,-112.1129),
|
|
57
|
+
"yellowstone": (44.4280,-110.5885),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
BANNER = r"""
|
|
61
|
+
__ __ ___ _ ___ _
|
|
62
|
+
| \/ |/ __|| |/ (_) |_
|
|
63
|
+
| |\/| |\__ \| ' <| | _|
|
|
64
|
+
|_| |_||___/|_|\_\_|\__|
|
|
65
|
+
|
|
66
|
+
Mini Simulation Kit v{version}
|
|
67
|
+
GitHub : https://github.com/MegaBites-AI/MSKit
|
|
68
|
+
Dataset : MegaBites-AI/AW3D30-DEM-Tiles (HuggingFace)
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def _hr(w=56): return "─" * w
|
|
72
|
+
def _section(t): print(f"\n{'━'*56}\n {t}\n{'━'*56}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _resolve_location(city, lat, lon):
|
|
76
|
+
if city:
|
|
77
|
+
k = city.lower().strip()
|
|
78
|
+
if k in _CITIES:
|
|
79
|
+
la, lo = _CITIES[k]
|
|
80
|
+
return la, lo, city.title()
|
|
81
|
+
print(f"❌ Unknown city '{city}'.")
|
|
82
|
+
print(f" Known: {', '.join(sorted(_CITIES))}")
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
if lat is not None and lon is not None:
|
|
85
|
+
return lat, lon, f"({lat:.4f}, {lon:.4f})"
|
|
86
|
+
print("❌ Provide --city NAME or --lat LAT --lon LON")
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _add_loc(p):
|
|
91
|
+
g = p.add_mutually_exclusive_group()
|
|
92
|
+
g.add_argument("--city", default=None)
|
|
93
|
+
p.add_argument("--lat", type=float, default=None)
|
|
94
|
+
p.add_argument("--lon", type=float, default=None)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── Sub-command handlers ──────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def cmd_simu(args):
|
|
100
|
+
from mskit.simu.simu import _cli_main
|
|
101
|
+
_cli_main()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_run(args):
|
|
105
|
+
desc = " ".join(args.description).strip()
|
|
106
|
+
if not desc:
|
|
107
|
+
print('❌ Usage: mskit run "describe your simulation"')
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
print(f'\n🤖 MSKit — Running: "{desc}"\n')
|
|
110
|
+
from mskit.simu import Simu
|
|
111
|
+
simu = Simu(verbose=True, auto_select="untrained", auto_simmode="custom")
|
|
112
|
+
try:
|
|
113
|
+
from mskit import DEMLoader, TrafficRouter
|
|
114
|
+
dem = DEMLoader()
|
|
115
|
+
simu._dem = dem
|
|
116
|
+
simu._traffic = TrafficRouter(dem)
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
simu.chat(desc)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_cameras(args):
|
|
123
|
+
from mskit.traffic import OpenCTVLayer
|
|
124
|
+
lat, lon, name = _resolve_location(
|
|
125
|
+
getattr(args,"city",None),
|
|
126
|
+
getattr(args,"lat",None),
|
|
127
|
+
getattr(args,"lon",None),
|
|
128
|
+
)
|
|
129
|
+
radius = getattr(args,"radius",5.0) or 5.0
|
|
130
|
+
tfl_key = getattr(args,"tfl_key",None)
|
|
131
|
+
|
|
132
|
+
_section(f"📷 OpenCTV — Cameras near {name}")
|
|
133
|
+
print(f" Coords : {lat:.5f}, {lon:.5f}")
|
|
134
|
+
print(f" Radius : {radius} km")
|
|
135
|
+
|
|
136
|
+
layer = OpenCTVLayer(tfl_app_key=tfl_key)
|
|
137
|
+
src = layer.covered_at(lat, lon)
|
|
138
|
+
if src:
|
|
139
|
+
print(f" Source : {layer.SOURCES[src]}")
|
|
140
|
+
else:
|
|
141
|
+
print(" Source : Synthetic (area not covered by live feeds)")
|
|
142
|
+
print(" Covered : Singapore · London · New York City")
|
|
143
|
+
|
|
144
|
+
print("\n Fetching...", end="", flush=True)
|
|
145
|
+
report = layer.traffic_report(lat, lon, radius_km=radius)
|
|
146
|
+
print(f" {report.camera_count} cameras found.\n")
|
|
147
|
+
|
|
148
|
+
if not report.cameras:
|
|
149
|
+
print(" No cameras in this area.")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
if report.nearest:
|
|
153
|
+
n = report.nearest
|
|
154
|
+
dist = n.distance_km(lat, lon)
|
|
155
|
+
print(f" 📍 Nearest : {n.name} ({dist:.2f} km)")
|
|
156
|
+
print(f" Source : {n.source}")
|
|
157
|
+
print(f" Coords : {n.lat:.5f}, {n.lon:.5f}")
|
|
158
|
+
if n.direction: print(f" Direction: {n.direction}")
|
|
159
|
+
if n.image_url: print(f" Image URL: {n.image_url}")
|
|
160
|
+
if n.timestamp: print(f" Updated : {n.timestamp}")
|
|
161
|
+
|
|
162
|
+
shown = report.cameras[:10]
|
|
163
|
+
if len(shown) > 1:
|
|
164
|
+
print(f"\n All cameras (showing {len(shown)} of {report.camera_count}):")
|
|
165
|
+
for i, c in enumerate(shown, 1):
|
|
166
|
+
dist = c.distance_km(lat, lon)
|
|
167
|
+
line = f" [{i:2d}] {c.name} ({dist:.2f} km)"
|
|
168
|
+
if c.image_url:
|
|
169
|
+
line += f"\n {c.image_url}"
|
|
170
|
+
print(line)
|
|
171
|
+
if report.camera_count > 10:
|
|
172
|
+
print(f"\n ... and {report.camera_count-10} more.")
|
|
173
|
+
print(f"\n Timestamp: {report.timestamp}")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def cmd_traffic(args):
|
|
177
|
+
lat, lon, name = _resolve_location(
|
|
178
|
+
getattr(args,"city",None),
|
|
179
|
+
getattr(args,"lat",None),
|
|
180
|
+
getattr(args,"lon",None),
|
|
181
|
+
)
|
|
182
|
+
_section(f"🚦 Traffic Report — {name}")
|
|
183
|
+
print(f" Coords : {lat:.5f}, {lon:.5f}")
|
|
184
|
+
print(f" Time : {datetime.datetime.utcnow():%Y-%m-%d %H:%M:%S} UTC\n")
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
from mskit.traffic import TrafficRouter
|
|
188
|
+
from mskit import DEMLoader
|
|
189
|
+
info = TrafficRouter(DEMLoader()).traffic_at(lat, lon)
|
|
190
|
+
d = info.to_dict()
|
|
191
|
+
print(f" Speed : {d.get('speed_kmh',0):.1f} km/h")
|
|
192
|
+
print(f" Flow : {d.get('flow_veh_h',0):.0f} veh/h")
|
|
193
|
+
print(f" Occupancy : {d.get('occupancy_pct',0):.1f}%")
|
|
194
|
+
print(f" Congestion : {str(d.get('congestion_level','?')).upper()}")
|
|
195
|
+
print(f" Source : {d.get('source','?')}")
|
|
196
|
+
print(f" Confidence : {d.get('confidence',0)*100:.0f}%")
|
|
197
|
+
except Exception as e:
|
|
198
|
+
print(f" ⚠️ Traffic data: {e}")
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
from mskit.traffic import OpenCTVLayer
|
|
202
|
+
report = OpenCTVLayer().traffic_report(lat, lon, radius_km=5)
|
|
203
|
+
print(f"\n 📷 Cameras : {report.camera_count} within 5 km [{report.source}]")
|
|
204
|
+
if report.nearest:
|
|
205
|
+
d = report.nearest.distance_km(lat,lon)
|
|
206
|
+
print(f" Nearest : {report.nearest.name} ({d:.2f} km)")
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def cmd_info(args):
|
|
212
|
+
import mskit
|
|
213
|
+
print(BANNER.format(version=mskit.__version__))
|
|
214
|
+
print(_hr())
|
|
215
|
+
print(" DATA SOURCES")
|
|
216
|
+
print(_hr())
|
|
217
|
+
rows = [
|
|
218
|
+
("Elevation", "JAXA AW3D30 (30m global DEM)"),
|
|
219
|
+
("", "MegaBites-AI/AW3D30-DEM-Tiles on HuggingFace"),
|
|
220
|
+
("Traffic", "OpenTraffic / OSRM (global road network, priority 1)"),
|
|
221
|
+
("", "UTD19 — 40 cities, 23,541 loop detectors (priority 2)"),
|
|
222
|
+
("", "Synthetic terrain+time model (fallback)"),
|
|
223
|
+
("Cameras", "OpenCTV aggregator:"),
|
|
224
|
+
("", " • Singapore LTA — 87 cams, 20s refresh, no key"),
|
|
225
|
+
("", " • TfL JamCam — 900+ London cams, free key"),
|
|
226
|
+
("", " • NYC DOT — ~900 NYC cams, no key"),
|
|
227
|
+
("", " • Synthetic fallback everywhere else"),
|
|
228
|
+
("AI Brain", "HuggingFaceTB/SmolLM2-360M-Instruct"),
|
|
229
|
+
("", "360M params · Apache 2.0 · CPU-capable"),
|
|
230
|
+
]
|
|
231
|
+
for label, val in rows:
|
|
232
|
+
print(f" {label:<12} {val}")
|
|
233
|
+
print()
|
|
234
|
+
print(_hr())
|
|
235
|
+
print(" SIMULATIONS")
|
|
236
|
+
print(_hr())
|
|
237
|
+
for name, desc in [
|
|
238
|
+
("random_walk", "Slope-biased terrain walk"),
|
|
239
|
+
("projectile", "Ballistic trajectory over real terrain"),
|
|
240
|
+
("water_flow", "D8 runoff routing"),
|
|
241
|
+
("agent", "RL terrain navigator"),
|
|
242
|
+
("traffic", "Road speed + congestion query"),
|
|
243
|
+
("profile", "Elevation cross-section between two points"),
|
|
244
|
+
]:
|
|
245
|
+
print(f" {name:<14} {desc}")
|
|
246
|
+
print(_hr())
|
|
247
|
+
print(f" Version : {mskit.__version__}")
|
|
248
|
+
print(f" GitHub : {mskit.__github__}")
|
|
249
|
+
print()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def cmd_demo(args):
|
|
253
|
+
import mskit
|
|
254
|
+
print(BANNER.format(version=mskit.__version__))
|
|
255
|
+
print(" Running full feature demo...\n")
|
|
256
|
+
|
|
257
|
+
_section("📷 OpenCTV — Singapore cameras")
|
|
258
|
+
try:
|
|
259
|
+
from mskit.traffic import OpenCTVLayer
|
|
260
|
+
r = OpenCTVLayer().traffic_report(1.3521, 103.8198, radius_km=3)
|
|
261
|
+
print(f" Found {r.camera_count} cameras near Singapore centre.")
|
|
262
|
+
if r.nearest:
|
|
263
|
+
print(f" Nearest : {r.nearest.name}")
|
|
264
|
+
print(f" Image : {r.nearest.image_url or '(no URL — synthetic)'}")
|
|
265
|
+
except Exception as e:
|
|
266
|
+
print(f" ⚠️ {e}")
|
|
267
|
+
|
|
268
|
+
_section("🚦 Traffic — Tokyo")
|
|
269
|
+
try:
|
|
270
|
+
from mskit.traffic import TrafficRouter
|
|
271
|
+
from mskit import DEMLoader
|
|
272
|
+
info = TrafficRouter(DEMLoader()).traffic_at(35.6762, 139.6503)
|
|
273
|
+
d = info.to_dict()
|
|
274
|
+
print(f" Speed : {d['speed_kmh']:.1f} km/h")
|
|
275
|
+
print(f" Congestion : {d['congestion_level']}")
|
|
276
|
+
print(f" Source : {d['source']}")
|
|
277
|
+
except Exception as e:
|
|
278
|
+
print(f" ⚠️ {e}")
|
|
279
|
+
|
|
280
|
+
_section("🤖 Simu — All 6 sims at London (untrained brain)")
|
|
281
|
+
try:
|
|
282
|
+
from mskit.simu import Simu
|
|
283
|
+
simu = Simu(verbose=True, auto_select="untrained", auto_simmode="everything")
|
|
284
|
+
result = simu.chat("London")
|
|
285
|
+
ok = sum(1 for v in result["output"].values() if "error" not in v)
|
|
286
|
+
print(f"\n {ok}/6 simulations completed.")
|
|
287
|
+
except Exception as e:
|
|
288
|
+
print(f" ⚠️ {e}")
|
|
289
|
+
|
|
290
|
+
print(f"\n{'━'*56}")
|
|
291
|
+
print(" Demo complete! Run `mskit simu` for the full AI experience.")
|
|
292
|
+
print('━'*56 + "\n")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ── Argument parser ───────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
298
|
+
parser = argparse.ArgumentParser(
|
|
299
|
+
prog="mskit",
|
|
300
|
+
description="MSKit — Mini Simulation Kit",
|
|
301
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
302
|
+
epilog=textwrap.dedent("""
|
|
303
|
+
Examples:
|
|
304
|
+
mskit simu
|
|
305
|
+
mskit run "random walk in Tokyo 500 steps"
|
|
306
|
+
mskit cameras --city singapore
|
|
307
|
+
mskit cameras --lat 51.5 --lon -0.1 --radius 3
|
|
308
|
+
mskit traffic --city london
|
|
309
|
+
mskit traffic --lat 40.71 --lon -74.00
|
|
310
|
+
mskit demo
|
|
311
|
+
mskit info
|
|
312
|
+
"""),
|
|
313
|
+
)
|
|
314
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
315
|
+
|
|
316
|
+
p = sub.add_parser("simu", help="Start Simu AI assistant (interactive)")
|
|
317
|
+
p.set_defaults(func=cmd_simu)
|
|
318
|
+
|
|
319
|
+
p = sub.add_parser("run", help="Run a simulation from plain-English description")
|
|
320
|
+
p.add_argument("description", nargs=argparse.REMAINDER)
|
|
321
|
+
p.set_defaults(func=cmd_run)
|
|
322
|
+
|
|
323
|
+
p = sub.add_parser("cameras", help="Query OpenCTV traffic cameras")
|
|
324
|
+
_add_loc(p)
|
|
325
|
+
p.add_argument("--radius", type=float, default=5.0)
|
|
326
|
+
p.add_argument("--tfl-key", dest="tfl_key", default=None)
|
|
327
|
+
p.set_defaults(func=cmd_cameras)
|
|
328
|
+
|
|
329
|
+
p = sub.add_parser("traffic", help="Query traffic speed/congestion")
|
|
330
|
+
_add_loc(p)
|
|
331
|
+
p.set_defaults(func=cmd_traffic)
|
|
332
|
+
|
|
333
|
+
p = sub.add_parser("info", help="Version and data source info")
|
|
334
|
+
p.set_defaults(func=cmd_info)
|
|
335
|
+
|
|
336
|
+
p = sub.add_parser("demo", help="Run a quick feature demo")
|
|
337
|
+
p.set_defaults(func=cmd_demo)
|
|
338
|
+
|
|
339
|
+
return parser
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def main():
|
|
343
|
+
parser = build_parser()
|
|
344
|
+
args = parser.parse_args()
|
|
345
|
+
if not args.command:
|
|
346
|
+
import mskit
|
|
347
|
+
print(BANNER.format(version=mskit.__version__))
|
|
348
|
+
parser.print_help()
|
|
349
|
+
sys.exit(0)
|
|
350
|
+
args.func(args)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
if __name__ == "__main__":
|
|
354
|
+
main()
|
mskit/dem.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DEMTile & DEMLoader — fetch AW3D30 elevation tiles on demand from HuggingFace.
|
|
3
|
+
|
|
4
|
+
Tile naming follows JAXA convention: N{lat:03d}E{lon:03d} etc.
|
|
5
|
+
Each tile covers 1°×1° at 30m resolution (3600×3600 pixels).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import math
|
|
12
|
+
import struct
|
|
13
|
+
import tempfile
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional, Tuple
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
HF_DATASET = "MegaBites-AI/AW3D30-DEM-Tiles"
|
|
21
|
+
HF_BASE = f"https://huggingface.co/datasets/{HF_DATASET}/resolve/main"
|
|
22
|
+
CACHE_DIR = Path(os.path.expanduser("~/.cache/mskit/dem"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _tile_name(lat: float, lon: float) -> str:
|
|
26
|
+
"""Return JAXA-style tile name for a lat/lon coordinate."""
|
|
27
|
+
lat_i = int(math.floor(lat))
|
|
28
|
+
lon_i = int(math.floor(lon))
|
|
29
|
+
lat_ch = "N" if lat_i >= 0 else "S"
|
|
30
|
+
lon_ch = "E" if lon_i >= 0 else "W"
|
|
31
|
+
return f"{lat_ch}{abs(lat_i):03d}{lon_ch}{abs(lon_i):03d}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DEMTile:
|
|
35
|
+
"""
|
|
36
|
+
A single 1°×1° elevation tile fetched from the HF dataset.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
lat : float — latitude of any point inside the tile (or tile SW corner)
|
|
41
|
+
lon : float — longitude of any point inside the tile
|
|
42
|
+
token : str, optional — HuggingFace token for private repos
|
|
43
|
+
|
|
44
|
+
Examples
|
|
45
|
+
--------
|
|
46
|
+
>>> tile = DEMTile(35.6, 139.7) # Tokyo
|
|
47
|
+
>>> elev = tile.elevation_at(35.68, 139.69)
|
|
48
|
+
>>> print(f"{elev:.1f} m")
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
ROWS = 3600
|
|
52
|
+
COLS = 3600
|
|
53
|
+
|
|
54
|
+
def __init__(self, lat: float, lon: float, token: Optional[str] = None):
|
|
55
|
+
self.lat = math.floor(lat)
|
|
56
|
+
self.lon = math.floor(lon)
|
|
57
|
+
self.name = _tile_name(lat, lon)
|
|
58
|
+
self._token = token or os.environ.get("HF_TOKEN")
|
|
59
|
+
self._data: Optional[np.ndarray] = None
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# Data loading
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def _cache_path(self) -> Path:
|
|
66
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
return CACHE_DIR / f"{self.name}.npy"
|
|
68
|
+
|
|
69
|
+
def _remote_url(self) -> str:
|
|
70
|
+
"""Build URL for the tile's .npy file on HuggingFace."""
|
|
71
|
+
# tiles are stored as: data/{lat_band}/{tile_name}.npy
|
|
72
|
+
lat_band = f"N{abs(self.lat):03d}" if self.lat >= 0 else f"S{abs(self.lat):03d}"
|
|
73
|
+
return f"{HF_BASE}/data/{lat_band}/{self.name}.npy"
|
|
74
|
+
|
|
75
|
+
def load(self) -> "DEMTile":
|
|
76
|
+
"""Download (or load from cache) the elevation grid."""
|
|
77
|
+
cache = self._cache_path()
|
|
78
|
+
if cache.exists():
|
|
79
|
+
self._data = np.load(str(cache))
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
url = self._remote_url()
|
|
83
|
+
headers = {}
|
|
84
|
+
if self._token:
|
|
85
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
86
|
+
|
|
87
|
+
req = urllib.request.Request(url, headers=headers)
|
|
88
|
+
try:
|
|
89
|
+
with urllib.request.urlopen(req) as resp:
|
|
90
|
+
data = resp.read()
|
|
91
|
+
except urllib.error.HTTPError as e:
|
|
92
|
+
raise FileNotFoundError(
|
|
93
|
+
f"Tile {self.name} not found in dataset ({e.code}). "
|
|
94
|
+
"It may not have been uploaded yet."
|
|
95
|
+
) from e
|
|
96
|
+
|
|
97
|
+
# Save to cache
|
|
98
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".npy") as tmp:
|
|
99
|
+
tmp.write(data)
|
|
100
|
+
tmp_path = tmp.name
|
|
101
|
+
|
|
102
|
+
arr = np.load(tmp_path)
|
|
103
|
+
np.save(str(cache), arr)
|
|
104
|
+
os.unlink(tmp_path)
|
|
105
|
+
self._data = arr
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def data(self) -> np.ndarray:
|
|
110
|
+
if self._data is None:
|
|
111
|
+
self.load()
|
|
112
|
+
return self._data
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
# Spatial queries
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def elevation_at(self, lat: float, lon: float) -> float:
|
|
119
|
+
"""Return elevation (metres) at an exact lat/lon coordinate."""
|
|
120
|
+
row, col = self._latlon_to_rc(lat, lon)
|
|
121
|
+
return float(self.data[row, col])
|
|
122
|
+
|
|
123
|
+
def patch(
|
|
124
|
+
self,
|
|
125
|
+
lat: float,
|
|
126
|
+
lon: float,
|
|
127
|
+
radius_km: float = 5.0,
|
|
128
|
+
) -> np.ndarray:
|
|
129
|
+
"""
|
|
130
|
+
Return a square elevation patch centred on (lat, lon).
|
|
131
|
+
|
|
132
|
+
radius_km controls the half-width of the extracted square.
|
|
133
|
+
"""
|
|
134
|
+
# 30 m per pixel → pixels per km ≈ 33.33
|
|
135
|
+
px = int(radius_km * 33.33)
|
|
136
|
+
r, c = self._latlon_to_rc(lat, lon)
|
|
137
|
+
r0, r1 = max(0, r - px), min(self.ROWS, r + px)
|
|
138
|
+
c0, c1 = max(0, c - px), min(self.COLS, c + px)
|
|
139
|
+
return self.data[r0:r1, c0:c1]
|
|
140
|
+
|
|
141
|
+
def slope(self) -> np.ndarray:
|
|
142
|
+
"""Return slope grid (degrees) computed via central differences."""
|
|
143
|
+
dy, dx = np.gradient(self.data.astype(float), 30.0, 30.0)
|
|
144
|
+
return np.degrees(np.arctan(np.sqrt(dx**2 + dy**2)))
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
# Internal helpers
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def _latlon_to_rc(self, lat: float, lon: float) -> Tuple[int, int]:
|
|
151
|
+
frac_lat = lat - self.lat # 0..1
|
|
152
|
+
frac_lon = lon - self.lon # 0..1
|
|
153
|
+
# Row 0 = north edge
|
|
154
|
+
row = int((1.0 - frac_lat) * (self.ROWS - 1))
|
|
155
|
+
col = int(frac_lon * (self.COLS - 1))
|
|
156
|
+
row = max(0, min(self.ROWS - 1, row))
|
|
157
|
+
col = max(0, min(self.COLS - 1, col))
|
|
158
|
+
return row, col
|
|
159
|
+
|
|
160
|
+
def __repr__(self) -> str:
|
|
161
|
+
loaded = "loaded" if self._data is not None else "not loaded"
|
|
162
|
+
return f"<DEMTile {self.name} ({loaded})>"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class DEMLoader:
|
|
166
|
+
"""
|
|
167
|
+
Multi-tile loader with an LRU-style cache.
|
|
168
|
+
|
|
169
|
+
Automatically fetches and stitches adjacent tiles so simulations
|
|
170
|
+
can cross tile boundaries seamlessly.
|
|
171
|
+
|
|
172
|
+
Parameters
|
|
173
|
+
----------
|
|
174
|
+
max_tiles : int — max tiles to keep in memory simultaneously
|
|
175
|
+
token : str, optional — HuggingFace token
|
|
176
|
+
|
|
177
|
+
Examples
|
|
178
|
+
--------
|
|
179
|
+
>>> loader = DEMLoader()
|
|
180
|
+
>>> elev = loader.elevation_at(35.68, 139.69)
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(self, max_tiles: int = 9, token: Optional[str] = None):
|
|
184
|
+
self._max = max_tiles
|
|
185
|
+
self._token = token or os.environ.get("HF_TOKEN")
|
|
186
|
+
self._cache: dict[str, DEMTile] = {}
|
|
187
|
+
|
|
188
|
+
def _get_tile(self, lat: float, lon: float) -> DEMTile:
|
|
189
|
+
name = _tile_name(lat, lon)
|
|
190
|
+
if name not in self._cache:
|
|
191
|
+
if len(self._cache) >= self._max:
|
|
192
|
+
# evict oldest
|
|
193
|
+
oldest = next(iter(self._cache))
|
|
194
|
+
del self._cache[oldest]
|
|
195
|
+
tile = DEMTile(lat, lon, token=self._token)
|
|
196
|
+
tile.load()
|
|
197
|
+
self._cache[name] = tile
|
|
198
|
+
return self._cache[name]
|
|
199
|
+
|
|
200
|
+
def elevation_at(self, lat: float, lon: float) -> float:
|
|
201
|
+
"""Return elevation (m) at any lat/lon, fetching tiles as needed."""
|
|
202
|
+
return self._get_tile(lat, lon).elevation_at(lat, lon)
|
|
203
|
+
|
|
204
|
+
def patch(self, lat: float, lon: float, radius_km: float = 5.0) -> np.ndarray:
|
|
205
|
+
"""Return elevation patch centred on (lat, lon)."""
|
|
206
|
+
return self._get_tile(lat, lon).patch(lat, lon, radius_km)
|
|
207
|
+
|
|
208
|
+
def elevation_profile(
|
|
209
|
+
self,
|
|
210
|
+
lat1: float, lon1: float,
|
|
211
|
+
lat2: float, lon2: float,
|
|
212
|
+
steps: int = 200,
|
|
213
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
214
|
+
"""
|
|
215
|
+
Return (distances_km, elevations_m) along a straight-line transect.
|
|
216
|
+
"""
|
|
217
|
+
lats = np.linspace(lat1, lat2, steps)
|
|
218
|
+
lons = np.linspace(lon1, lon2, steps)
|
|
219
|
+
elevs = np.array([self.elevation_at(la, lo) for la, lo in zip(lats, lons)])
|
|
220
|
+
|
|
221
|
+
# Haversine distances
|
|
222
|
+
R = 6371.0
|
|
223
|
+
dists = np.zeros(steps)
|
|
224
|
+
for i in range(1, steps):
|
|
225
|
+
dlat = math.radians(lats[i] - lats[i-1])
|
|
226
|
+
dlon = math.radians(lons[i] - lons[i-1])
|
|
227
|
+
a = math.sin(dlat/2)**2 + math.cos(math.radians(lats[i-1])) * \
|
|
228
|
+
math.cos(math.radians(lats[i])) * math.sin(dlon/2)**2
|
|
229
|
+
dists[i] = dists[i-1] + 2 * R * math.asin(math.sqrt(a))
|
|
230
|
+
|
|
231
|
+
return dists, elevs
|
mskit/sims/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# mskit.sims — simulation modules
|