Chris773 commited on
Commit
2f875b1
·
verified ·
1 Parent(s): f35267b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +124 -683
app.py CHANGED
@@ -1,14 +1,13 @@
1
  # app.py
2
  # MH370 Flight Simulator — Leaflet map (Folium) + Ping Rings (KML)
3
- # Includes:
4
- # (6) multi-leg editor (Dataframe)
5
- # (7) Add Leg + Reset buttons
6
  # (8) Stats panel
7
  # (13) Permanent labeled waypoints
8
- # (14) BFO analysis chart + MAE/RMSE
9
- # NEW: Gentle auto-tune can fit BOTH BFO + BTO (BTO at satLoc index 5)
10
- # NEW: Two simulate buttons: Manual vs Gentle Auto-tune
11
- # NEW: Ping ring timestamps (Option A) shown in ring tooltip
12
 
13
  import re
14
  import html
@@ -22,6 +21,8 @@ import plotly.graph_objects as go
22
 
23
  import flight_plotter_v32 as fp
24
 
 
 
25
  KML_FILE = "MH370 - 13 nov (3).kml"
26
 
27
  DEFAULT_LEGS = [
@@ -31,39 +32,31 @@ DEFAULT_LEGS = [
31
  ]
32
  NEW_LEG_DEFAULT = [0, 500, 10]
33
 
34
- # ---------------- #13 Waypoints (PERMANENT LABELS) ----------------
35
  WAYPOINTS = [
36
- ("IGARI", 6.936111111111112, 103.58500000000001),
37
- ("VAMPI", 6.182222222222222, 97.58555555555554),
38
- ("NILAM", 6.756388888888889, 95.97638888888889),
39
- ("SANOB", 6.586111111111111, 95.66916666666667),
40
- ("VPG", 5.279638888888889, 100.26038888888888),
41
- ("MEKAR", 6.500000000000000, 96.48333333333333),
42
  ]
43
 
44
- # -------- Gentle tuning constants --------
45
- GENTLE_HEAD_LIMIT_DEG = 20 # ±20°
46
- GENTLE_SPEED_LIMIT_KT = 40 # ±40 kt
47
- HEAD_STEP_DEG = 2 # grid step
48
- SPEED_STEP_KT = 5 # grid step
49
- LAMBDA_STAY_CLOSE = 0.10 # penalty vs your original input
50
- LAMBDA_SMOOTH = 0.25 # penalty vs previous tuned leg
51
-
52
- # Normalization scales so Hz and microseconds are comparable
53
- BFO_SCALE_HZ = 5.0
54
- BTO_SCALE_US = 50.0
55
 
56
- # satLoc indices (confirmed by you)
57
  SATLOC_IDX_MEAS_BFO = 4
58
  SATLOC_IDX_MEAS_BTO = 5
59
 
60
 
61
- # ---------------- KML parsing ----------------
62
- def load_ping_rings_from_kml(path=KML_FILE):
63
- """
64
- Returns dict: ring_name -> list[(lat, lon), ...]
65
- Supports LineString rings.
66
- """
67
  ns = {"kml": "http://www.opengis.net/kml/2.2"}
68
  tree = ET.parse(path)
69
  root = tree.getroot()
@@ -71,730 +64,178 @@ def load_ping_rings_from_kml(path=KML_FILE):
71
  rings = {}
72
  for pm in root.findall(".//kml:Placemark", ns):
73
  name_el = pm.find("kml:name", ns)
74
- if name_el is None or not name_el.text:
75
  continue
76
  name = name_el.text.strip()
77
-
78
  if not name.lower().startswith("ping ring"):
79
  continue
80
 
81
  coords_el = pm.find(".//kml:LineString/kml:coordinates", ns)
82
- if coords_el is None or not coords_el.text:
83
  continue
84
 
85
  pts = []
86
- text = coords_el.text.strip()
87
- for token in re.split(r"\s+", text):
88
- if not token:
89
- continue
90
- parts = token.split(",")
91
- if len(parts) < 2:
92
- continue
93
- lon = float(parts[0])
94
- lat = float(parts[1])
95
- pts.append((lat, lon))
96
 
97
  if pts:
98
  rings[name] = pts
99
 
100
- if not rings:
101
- raise RuntimeError(
102
- f"No ping rings found in KML. Check file name/path: {path} and Placemark names."
103
- )
104
  return rings
105
 
106
 
107
- PING_RINGS = load_ping_rings_from_kml()
108
-
109
- # ---------------- Ping ring timestamps (Option A) ----------------
110
- # These keys must match your KML Placemark names exactly.
111
- RING_TIMES = {
112
- "Ping Ring 1": "18:25:00Z",
113
- "Ping Ring 2": "18:40:00Z",
114
- "Ping Ring 3": "19:40:00Z",
115
- "Ping Ring 4": "20:40:00Z",
116
- "Ping Ring 5": "21:40:00Z",
117
- "Ping Ring 6": "22:40:00Z",
118
- "Ping Ring 7 (35 kft)": "24:10:00Z",
119
- }
120
-
121
-
122
- # ---------------- legs helpers ----------------
123
- def df_to_rows(df):
124
- if df is None:
125
- return []
126
- if hasattr(df, "to_numpy"): # pandas.DataFrame
127
- return df.to_numpy().tolist()
128
- return df # list-of-lists
129
-
130
-
131
- def parse_legs_df(df):
132
- rows = df_to_rows(df)
133
- legs = []
134
- for row in rows:
135
- if row is None or len(row) < 3:
136
- continue
137
- h, s, m = row[0], row[1], row[2]
138
- if h is None or s is None or m is None:
139
- continue
140
- if isinstance(h, float) and np.isnan(h):
141
- continue
142
- if isinstance(s, float) and np.isnan(s):
143
- continue
144
- if isinstance(m, float) and np.isnan(m):
145
- continue
146
- legs.append((float(h), float(s), float(m)))
147
-
148
- if not legs:
149
- legs = [(300, 500, 10), (270, 500, 10), (240, 500, 10)]
150
- return legs
151
-
152
 
153
- def legs_to_rows(legs):
154
- return [[float(h), float(s), float(m)] for (h, s, m) in legs]
155
 
156
-
157
- def add_leg(df):
158
- rows = df_to_rows(df)
159
-
160
- cleaned = []
161
- for r in rows:
162
- if r is None or len(r) < 3:
163
- continue
164
- a, b, c = r[0], r[1], r[2]
165
- if (a is None or (isinstance(a, float) and np.isnan(a))) and \
166
- (b is None or (isinstance(b, float) and np.isnan(b))) and \
167
- (c is None or (isinstance(c, float) and np.isnan(c))):
168
- continue
169
- cleaned.append([a, b, c])
170
-
171
- cleaned.append(list(NEW_LEG_DEFAULT))
172
- return gr.update(value=cleaned)
173
-
174
-
175
- def reset_legs():
176
- return gr.update(value=[row[:] for row in DEFAULT_LEGS])
177
-
178
-
179
- # ---------------- sim core + stats ----------------
180
- def simulate_path(start_lat, start_lon, legs, method):
181
- """
182
- Returns:
183
- pts: [(lat,lon), ...] including start and end of each leg
184
- seg_dists_m: [distance_m for each leg]
185
- total_time_min: float
186
- """
187
- lat, lon = float(start_lat), float(start_lon)
188
- pts = [(lat, lon)]
189
- seg_dists_m = []
190
- total_time_min = 0.0
191
-
192
- for heading, speed_knots, minutes in legs:
193
- dist_m = speed_knots * 1852.0 * (minutes / 60.0)
194
-
195
- if method == "VINCENTY":
196
- (lat, lon), _ = fp.vincenty_direct((lat, lon), heading, dist_m)
197
- else:
198
- lat, lon = fp.rhumb_direct((lat, lon), heading, dist_m)
199
-
200
- pts.append((lat, lon))
201
- seg_dists_m.append(dist_m)
202
- total_time_min += float(minutes)
203
-
204
- return pts, seg_dists_m, total_time_min
205
-
206
-
207
- def compute_stats(path_pts, seg_dists_m, total_time_min, legs):
208
- total_dist_m = float(np.sum(seg_dists_m)) if seg_dists_m else 0.0
209
- total_dist_km = total_dist_m / 1000.0
210
- total_dist_nm = total_dist_m / 1852.0
211
-
212
- total_time_hr = total_time_min / 60.0
213
- avg_gs_knots = (total_dist_nm / total_time_hr) if total_time_hr > 0 else 0.0
214
-
215
- end_lat, end_lon = path_pts[-1]
216
-
217
- lines = [
218
- f"**Legs:** {len(legs)}",
219
- f"**Total time:** {total_time_min:.1f} min ({total_time_hr:.2f} hr)",
220
- f"**Total distance:** {total_dist_nm:.1f} nm ({total_dist_km:.1f} km)",
221
- f"**Average ground speed:** {avg_gs_knots:.1f} kt",
222
- f"**Final position:** lat {end_lat:.4f}, lon {end_lon:.4f}",
223
- ]
224
- return "\n\n".join(lines)
225
-
226
-
227
- # ---------------- Leaflet map (Folium) ----------------
228
- def _all_bounds(points_list):
229
- lats, lons = [], []
230
- for pts in points_list:
231
- for (la, lo) in pts:
232
- if la is None or lo is None:
233
- continue
234
- lats.append(float(la))
235
- lons.append(float(lo))
236
- if not lats:
237
- return None
238
- return [[min(lats), min(lons)], [max(lats), max(lons)]]
239
-
240
-
241
- def add_permanent_label(m, name, lat, lon):
242
- """
243
- Adds a small dot + a permanent text label using DivIcon.
244
- """
245
  folium.CircleMarker(
246
- location=(lat, lon),
247
- radius=4,
248
- color="black",
249
- weight=2,
250
- fill=True,
251
- fill_color="white",
252
- fill_opacity=1.0,
253
- tooltip=name,
254
  ).add_to(m)
255
 
256
- label_html = f"""
257
- <div style="
258
- font-size: 13px;
259
- font-weight: 700;
260
- color: black;
261
- text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff;
262
- white-space: nowrap;
263
- transform: translate(8px, -12px);
264
- ">{html.escape(name)}</div>
265
- """
266
-
267
  folium.Marker(
268
- location=(lat, lon),
269
- icon=folium.DivIcon(html=label_html),
 
 
 
 
 
 
 
 
 
 
 
270
  ).add_to(m)
271
 
272
 
273
  def build_leaflet_map(path_pts, legs, seg_dists_m, selected_rings):
274
  start_lat, start_lon = path_pts[0]
 
275
  m = folium.Map(
276
  location=[start_lat, start_lon],
277
  zoom_start=4,
278
- tiles="OpenStreetMap",
279
  control_scale=True,
 
280
  )
281
 
282
- # Ping rings (blue) + timestamp tooltip
283
- for ring_name in selected_rings or []:
284
- ring_pts = PING_RINGS.get(ring_name)
285
- if not ring_pts:
286
- continue
287
-
288
- ring_time = RING_TIMES.get(ring_name)
289
- tooltip = f"{ring_name} ({ring_time})" if ring_time else ring_name
290
-
291
- folium.PolyLine(
292
- ring_pts,
293
- weight=3,
294
- opacity=0.95,
295
- color="blue",
296
- tooltip=tooltip,
297
- ).add_to(m)
298
-
299
- # Flight path (red)
300
- folium.PolyLine(
301
- path_pts,
302
- weight=4,
303
- opacity=1.0,
304
- color="red",
305
- tooltip="Flight path",
306
  ).add_to(m)
307
 
308
- # Start/End markers
309
- folium.CircleMarker(
310
- location=path_pts[0],
311
- radius=6,
312
- fill=True,
313
- color="green",
314
- fill_opacity=1.0,
315
- popup=folium.Popup(
316
- f"<b>Start</b><br>Lat: {path_pts[0][0]:.5f}<br>Lon: {path_pts[0][1]:.5f}",
317
- max_width=300,
318
- ),
319
  ).add_to(m)
320
 
321
- folium.CircleMarker(
322
- location=path_pts[-1],
323
- radius=6,
324
- fill=True,
325
- color="purple",
326
- fill_opacity=1.0,
327
- popup=folium.Popup(
328
- f"<b>End</b><br>Lat: {path_pts[-1][0]:.5f}<br>Lon: {path_pts[-1][1]:.5f}",
329
- max_width=300,
330
- ),
331
  ).add_to(m)
332
 
333
- # Simulated waypoints (white dots) with popups
334
- total_dist_km = 0.0
335
- elapsed_min = 0.0
336
-
337
- for i, (lat, lon) in enumerate(path_pts):
338
- if i == 0:
339
- heading = None
340
- speed = None
341
- minutes = 0.0
342
- else:
343
- heading, speed, minutes = legs[i - 1]
344
- total_dist_km += (seg_dists_m[i - 1] / 1000.0)
345
- elapsed_min += minutes
346
-
347
- popup_lines = [
348
- f"<b>Waypoint {i}</b>",
349
- f"Lat: {lat:.5f}",
350
- f"Lon: {lon:.5f}",
351
- f"Time elapsed: {elapsed_min:.1f} min",
352
- ]
353
- if i > 0:
354
- popup_lines += [
355
- f"Heading: {heading:.1f}°",
356
- f"Speed: {speed:.0f} kt",
357
- f"Leg: {minutes:.1f} min",
358
- f"Distance flown: {total_dist_km:.2f} km",
359
- ]
360
-
361
- folium.CircleMarker(
362
- location=(lat, lon),
363
- radius=5,
364
- fill=True,
365
- color="white",
366
- fill_color="white",
367
- fill_opacity=1.0,
368
- popup=folium.Popup("<br>".join(popup_lines), max_width=340),
369
  ).add_to(m)
370
 
371
- # Permanent labeled real-world waypoints (#13)
372
- for name, lat, lon in WAYPOINTS:
373
- add_permanent_label(m, name, float(lat), float(lon))
374
-
375
- # Fit bounds to include everything
376
- waypoint_pts = [(float(lat), float(lon)) for _, lat, lon in WAYPOINTS]
377
- bounds = _all_bounds(
378
- [path_pts]
379
- + [PING_RINGS[r] for r in (selected_rings or []) if r in PING_RINGS]
380
- + [waypoint_pts]
381
- )
382
- if bounds:
383
- m.fit_bounds(bounds, padding=(20, 20))
384
-
385
- # Embed as iframe with fixed height (Gradio-safe)
386
- map_html = m.get_root().render()
387
- escaped = html.escape(map_html, quote=True)
388
-
389
- return f"""
390
- <iframe
391
- srcdoc="{escaped}"
392
- style="width: 100%; height: 650px; border: 0; border-radius: 12px;"
393
- loading="lazy"
394
- referrerpolicy="no-referrer"
395
- ></iframe>
396
- """
397
 
 
 
 
398
 
399
- # ---------------- BFO/BTO analysis helpers ----------------
400
- def _parse_start_time(s: str) -> datetime:
401
- s = (s or "").strip()
402
- if not s:
403
- return datetime(2014, 3, 7, 18, 22, 0, tzinfo=timezone.utc)
404
-
405
- if re.fullmatch(r"\d{2}:\d{2}:\d{2}", s):
406
- hh, mm, ss = map(int, s.split(":"))
407
- return datetime(2014, 3, 7, hh, mm, ss, tzinfo=timezone.utc)
408
 
409
- s = s.replace("T", " ").replace("Z", "")
410
- dt = datetime.fromisoformat(s)
411
- if dt.tzinfo is None:
412
- dt = dt.replace(tzinfo=timezone.utc)
413
- return dt.astimezone(timezone.utc)
414
 
 
 
 
 
 
 
 
 
415
 
416
- def _ping_points_after(start_dt: datetime):
417
- """
418
- Returns list of tuples:
419
- (label, dt_ping, meas_bfo, meas_bto)
420
- where meas_bto may be None.
421
  """
422
- pts = []
423
- for row in fp.satLoc:
424
- label = fp._extract_time_string(row[0])
425
-
426
- meas_bfo = row[SATLOC_IDX_MEAS_BFO]
427
- meas_bto = row[SATLOC_IDX_MEAS_BTO]
428
-
429
- if meas_bfo is None:
430
- continue
431
-
432
- try:
433
- dt_ping = fp._time_label_to_dt(label, fp.SAT_EPOCH_UTC)
434
- except Exception:
435
- continue
436
-
437
- if dt_ping < start_dt:
438
- continue
439
-
440
- pts.append((
441
- label,
442
- dt_ping,
443
- float(meas_bfo),
444
- None if meas_bto is None else float(meas_bto)
445
- ))
446
- return pts
447
-
448
-
449
- def _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg_deg, spd_knots, t_inside_leg_sec):
450
- """Predict lat/lon at time offset within a leg, assuming constant hdg/spd."""
451
- dist_m = float(spd_knots) * 1852.0 * (float(t_inside_leg_sec) / 3600.0)
452
- if method == "VINCENTY":
453
- (lat, lon), _ = fp.vincenty_direct((leg_start_lat, leg_start_lon), float(hdg_deg), dist_m)
454
- else:
455
- lat, lon = fp.rhumb_direct((leg_start_lat, leg_start_lon), float(hdg_deg), dist_m)
456
- return float(lat), float(lon)
457
-
458
-
459
- def bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_time_str, alt_ft, vs_fpm):
460
- start_dt = _parse_start_time(start_time_str)
461
- pts = _ping_points_after(start_dt)
462
-
463
- leg_secs = [float(m) * 60.0 for _, _, m in legs]
464
- boundaries = np.cumsum([0.0] + leg_secs)
465
- total_end = boundaries[-1]
466
-
467
- # Compute leg start positions
468
- cur_lat = float(start_lat)
469
- cur_lon = float(start_lon)
470
- leg_start_positions = [(cur_lat, cur_lon)]
471
- for (hdg, spd, minutes) in legs[:-1]:
472
- dist_m = float(spd) * 1852.0 * (float(minutes) / 60.0)
473
- if method == "VINCENTY":
474
- (cur_lat, cur_lon), _ = fp.vincenty_direct((cur_lat, cur_lon), float(hdg), dist_m)
475
- else:
476
- cur_lat, cur_lon = fp.rhumb_direct((cur_lat, cur_lon), float(hdg), dist_m)
477
- leg_start_positions.append((cur_lat, cur_lon))
478
-
479
- x_labels, measured, calculated = [], [], []
480
- for label, dt_ping, meas_bfo, _meas_bto in pts:
481
- t_sec = (dt_ping - start_dt).total_seconds()
482
- if t_sec < 0 or t_sec > total_end:
483
- continue
484
- i = int(np.searchsorted(boundaries, t_sec, side="right") - 1)
485
- i = max(0, min(i, len(legs) - 1))
486
- t_inside = t_sec - boundaries[i]
487
-
488
- leg_start_lat, leg_start_lon = leg_start_positions[i]
489
- hdg, spd, _ = legs[i]
490
-
491
- lat, lon = _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg, spd, t_inside)
492
-
493
- alt_m = float(alt_ft) * 0.3048
494
- vs_mps = float(vs_fpm) * 0.00508
495
- spd_mps = float(spd) * 1852.0 / 3600.0
496
- pred_bfo = float(fp.calculateBFO_at_time(spd_mps, float(hdg), float(lat), float(lon), alt_m, vs_mps, dt_ping))
497
-
498
- x_labels.append(label)
499
- measured.append(float(meas_bfo))
500
- calculated.append(float(pred_bfo))
501
-
502
- if not x_labels:
503
- fig = go.Figure()
504
- fig.update_layout(title="BFO Analysis", xaxis_title="Time", yaxis_title="Hz", height=520)
505
- return fig, "**BFO scoring:** no valid ping points inside your simulated time."
506
-
507
- m = np.array(measured, dtype=float)
508
- c = np.array(calculated, dtype=float)
509
- err = c - m
510
- mae = float(np.mean(np.abs(err)))
511
- rmse = float(np.sqrt(np.mean(err * err)))
512
-
513
- fig = go.Figure()
514
- fig.add_trace(go.Scatter(
515
- x=x_labels, y=measured, mode="markers", name="Measured BFO",
516
- marker=dict(size=11, symbol="circle")
517
- ))
518
- fig.add_trace(go.Scatter(
519
- x=x_labels, y=calculated, mode="lines+markers", name="Calculated BFO",
520
- line=dict(width=3), marker=dict(size=10, symbol="diamond")
521
- ))
522
- fig.update_layout(
523
- title="BFO Analysis",
524
- xaxis_title="Time",
525
- yaxis_title="Hz",
526
- legend=dict(x=1.02, y=1.0),
527
- margin=dict(l=60, r=160, t=80, b=60),
528
- height=520,
529
- )
530
 
531
- score_md = (
532
- f"**BFO scoring (Hz)**\n\n"
533
- f"- Points: **{len(m)}**\n"
534
- f"- MAE: **{mae:.2f} Hz**\n"
535
- f"- RMSE: **{rmse:.2f} Hz**"
536
- )
537
- return fig, score_md
538
-
539
-
540
- # ---------------- Gentle auto-tune ALL legs (BFO + BTO) ----------------
541
- def _wrap_heading(deg):
542
- return float(deg) % 360.0
543
-
544
-
545
- def _heading_diff(a, b):
546
- return (float(a) - float(b) + 180.0) % 360.0 - 180.0
547
-
548
-
549
- def gentle_autotune_all_legs(start_lat, start_lon, legs, method, start_time_str, alt_ft, vs_fpm):
550
- """
551
- Sequential per-leg fit using pings in that leg window.
552
- Fits BOTH BFO + BTO (if measured BTO exists).
553
- Constrained search around user's original values (+ smoothness penalties).
554
- """
555
- start_dt = _parse_start_time(start_time_str)
556
- pts = _ping_points_after(start_dt)
557
-
558
- if not pts:
559
- return legs, "Auto-tune: no BFO points after start time."
560
-
561
- n = len(legs)
562
- leg_secs = [float(m) * 60.0 for _, _, m in legs]
563
- boundaries = np.cumsum([0.0] + leg_secs)
564
- total_end = boundaries[-1]
565
-
566
- # Keep only pings inside total sim time
567
- pts2 = []
568
- for label, dt_ping, meas_bfo, meas_bto in pts:
569
- t_sec = (dt_ping - start_dt).total_seconds()
570
- if 0 <= t_sec <= total_end:
571
- pts2.append((label, dt_ping, meas_bfo, meas_bto, float(t_sec)))
572
-
573
- if len(pts2) < 2:
574
- return legs, "Auto-tune: not enough pings inside your simulated time."
575
-
576
- tuned = list(legs)
577
-
578
- cur_lat = float(start_lat)
579
- cur_lon = float(start_lon)
580
-
581
- prev_h, prev_s, _ = tuned[0]
582
- report = ["**Gentle auto-tune (BFO + BTO, bounded + smooth):**"]
583
- tuned_count = 0
584
-
585
- for i in range(n):
586
- leg_start = boundaries[i]
587
- leg_end = boundaries[i + 1]
588
- minutes_i = float(tuned[i][2])
589
-
590
- # Pings inside this leg window
591
- leg_pings = [(dt, bfo, bto, (t - leg_start)) for (_, dt, bfo, bto, t) in pts2 if leg_start <= t <= leg_end]
592
- orig_h, orig_s, _ = legs[i]
593
-
594
- if len(leg_pings) < 2:
595
- report.append(f"- Leg {i+1}: skipped (not enough pings in window)")
596
- h0, s0, _ = tuned[i]
597
- dist_m = float(s0) * 1852.0 * (minutes_i / 60.0)
598
- if method == "VINCENTY":
599
- (cur_lat, cur_lon), _ = fp.vincenty_direct((cur_lat, cur_lon), float(h0), dist_m)
600
- else:
601
- cur_lat, cur_lon = fp.rhumb_direct((cur_lat, cur_lon), float(h0), dist_m)
602
- prev_h, prev_s = h0, s0
603
- continue
604
 
605
- leg_start_lat, leg_start_lon = cur_lat, cur_lon
606
-
607
- # bounded search around ORIGINAL inputs (gentle)
608
- h_center = _wrap_heading(orig_h)
609
- s_center = float(orig_s)
610
-
611
- h_low = h_center - GENTLE_HEAD_LIMIT_DEG
612
- h_high = h_center + GENTLE_HEAD_LIMIT_DEG
613
-
614
- h_candidates = []
615
- h = h_low
616
- while h <= h_high + 1e-9:
617
- h_candidates.append(_wrap_heading(h))
618
- h += HEAD_STEP_DEG
619
- h_candidates = sorted(set(round(x, 6) for x in h_candidates))
620
-
621
- s_low = max(150.0, s_center - GENTLE_SPEED_LIMIT_KT)
622
- s_high = min(650.0, s_center + GENTLE_SPEED_LIMIT_KT)
623
- s_candidates = np.arange(s_low, s_high + 0.0001, SPEED_STEP_KT).tolist()
624
-
625
- def objective(hdg, spd):
626
- bfo_preds, bfo_meas = [], []
627
- bto_preds, bto_meas = [], []
628
-
629
- alt_m = float(alt_ft) * 0.3048
630
- vs_mps = float(vs_fpm) * 0.00508
631
- spd_mps = float(spd) * 1852.0 / 3600.0
632
-
633
- for dt_ping, meas_bfo, meas_bto, t_inside in leg_pings:
634
- lat, lon = _predict_state_from_leg_start(
635
- method, leg_start_lat, leg_start_lon, hdg, spd, t_inside
636
- )
637
-
638
- # BFO
639
- pred_bfo = float(
640
- fp.calculateBFO_at_time(spd_mps, float(hdg), float(lat), float(lon), alt_m, vs_mps, dt_ping)
641
- )
642
- bfo_preds.append(pred_bfo)
643
- bfo_meas.append(float(meas_bfo))
644
-
645
- # BTO (only if measured BTO exists)
646
- if meas_bto is not None:
647
- pred_bto = float(fp.calculateBTO_at_time(float(lat), float(lon), alt_m, dt_ping))
648
- bto_preds.append(pred_bto)
649
- bto_meas.append(float(meas_bto))
650
-
651
- # RMSE_BFO
652
- e_bfo = np.array(bfo_preds) - np.array(bfo_meas)
653
- rmse_bfo = float(np.sqrt(np.mean(e_bfo * e_bfo)))
654
-
655
- # RMSE_BTO (only if >=2 measured BTOs exist in this leg)
656
- rmse_bto = 0.0
657
- if len(bto_preds) >= 2:
658
- e_bto = np.array(bto_preds) - np.array(bto_meas)
659
- rmse_bto = float(np.sqrt(np.mean(e_bto * e_bto)))
660
-
661
- # normalized combined fit score
662
- fit_score = (rmse_bfo / BFO_SCALE_HZ) + (rmse_bto / BTO_SCALE_US)
663
-
664
- # gentle penalties
665
- stay_close = (abs(_heading_diff(hdg, orig_h)) / GENTLE_HEAD_LIMIT_DEG) ** 2 + ((float(spd) - float(orig_s)) / GENTLE_SPEED_LIMIT_KT) ** 2
666
- smooth = (abs(_heading_diff(hdg, prev_h)) / 45.0) ** 2 + ((float(spd) - float(prev_s)) / 80.0) ** 2
667
-
668
- loss = fit_score + LAMBDA_STAY_CLOSE * stay_close + LAMBDA_SMOOTH * smooth
669
- return loss, rmse_bfo, rmse_bto
670
-
671
- best_h, best_s = None, None
672
- best_loss = float("inf")
673
- best_rmse_bfo = float("inf")
674
- best_rmse_bto = float("inf")
675
-
676
- for hdg in h_candidates:
677
- for spd in s_candidates:
678
- loss, rbfo, rbto = objective(hdg, spd)
679
- if loss < best_loss:
680
- best_loss = loss
681
- best_h, best_s = float(hdg), float(spd)
682
- best_rmse_bfo, best_rmse_bto = rbfo, rbto
683
-
684
- tuned[i] = (float(best_h), float(best_s), minutes_i)
685
- tuned_count += 1
686
-
687
- if best_rmse_bto > 0:
688
- report.append(
689
- f"- Leg {i+1}: {orig_h:.0f}°→**{best_h:.0f}°**, {orig_s:.0f}→**{best_s:.0f} kt** | "
690
- f"RMSE_BFO **{best_rmse_bfo:.2f} Hz** | RMSE_BTO **{best_rmse_bto:.1f} µs**"
691
- )
692
- else:
693
- report.append(
694
- f"- Leg {i+1}: {orig_h:.0f}°→**{best_h:.0f}°**, {orig_s:.0f}→**{best_s:.0f} kt** | "
695
- f"RMSE_BFO **{best_rmse_bfo:.2f} Hz** | RMSE_BTO (n/a)"
696
- )
697
 
698
- dist_m = float(best_s) * 1852.0 * (minutes_i / 60.0)
 
699
  if method == "VINCENTY":
700
- (cur_lat, cur_lon), _ = fp.vincenty_direct((cur_lat, cur_lon), float(best_h), dist_m)
701
  else:
702
- cur_lat, cur_lon = fp.rhumb_direct((cur_lat, cur_lon), float(best_h), dist_m)
703
-
704
- prev_h, prev_s = best_h, best_s
705
-
706
- report.append(f"\nTuned legs: **{tuned_count}/{n}** (skipped legs had <2 pings in that time window)")
707
- return tuned, "\n".join(report)
708
-
709
-
710
- # ---------------- Run functions ----------------
711
- def _run_common(start_lat, start_lon, legs, method, rings, start_time_str, alt_ft, vs_fpm):
712
- path_pts, seg_dists_m, total_time_min = simulate_path(start_lat, start_lon, legs, method)
713
- html_map = build_leaflet_map(path_pts, legs, seg_dists_m, rings)
714
- stats_md = compute_stats(path_pts, seg_dists_m, total_time_min, legs)
715
- bfo_fig, bfo_md = bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_time_str, alt_ft, vs_fpm)
716
- return html_map, stats_md, bfo_fig, bfo_md
717
-
718
-
719
- def run_sim_manual(start_lat, start_lon, legs_df, method, rings, start_time_str, alt_ft, vs_fpm):
720
- legs = parse_legs_df(legs_df)
721
- html_map, stats_md, bfo_fig, bfo_md = _run_common(
722
- start_lat, start_lon, legs, method, rings, start_time_str, alt_ft, vs_fpm
723
- )
724
- return gr.update(), "", html_map, stats_md, bfo_fig, bfo_md
725
 
 
726
 
727
- def run_sim_autotune(start_lat, start_lon, legs_df, method, rings, start_time_str, alt_ft, vs_fpm):
728
- legs = parse_legs_df(legs_df)
729
- tuned_legs, tune_md = gentle_autotune_all_legs(
730
- start_lat, start_lon, legs, method, start_time_str, alt_ft, vs_fpm
731
- )
732
- html_map, stats_md, bfo_fig, bfo_md = _run_common(
733
- start_lat, start_lon, tuned_legs, method, rings, start_time_str, alt_ft, vs_fpm
734
- )
735
- return gr.update(value=legs_to_rows(tuned_legs)), tune_md, html_map, stats_md, bfo_fig, bfo_md
736
 
737
-
738
- # ---------------- UI ----------------
739
  with gr.Blocks() as demo:
740
- gr.Markdown("## ✈️ MH370 Flight Simulator — Leaflet Map + Ping Rings (KML)")
741
 
742
  with gr.Row():
743
  start_lat = gr.Slider(-10, 10, value=6.3, label="Start Latitude")
744
  start_lon = gr.Slider(90, 110, value=96.3, label="Start Longitude")
745
- method = gr.Dropdown(["VINCENTY", "RHUMB"], value="VINCENTY", label="Method")
746
 
747
  legs_df = gr.Dataframe(
748
  headers=["heading_deg", "speed_knots", "minutes"],
749
- datatype=["number", "number", "number"],
750
  row_count=(10, "dynamic"),
751
  column_count=(3, "fixed"),
752
- value=[row[:] for row in DEFAULT_LEGS],
753
- label="Multi-leg plan",
754
  )
755
 
756
- with gr.Row():
757
- add_btn = gr.Button("➕ Add leg")
758
- reset_btn = gr.Button("♻️ Reset legs")
759
-
760
  rings = gr.CheckboxGroup(
761
  choices=sorted(PING_RINGS.keys()),
762
  value=[sorted(PING_RINGS.keys())[0]],
763
- label="Ping rings (from your KML)",
764
  )
765
 
766
- gr.Markdown("### #14 BFO/BTO Settings")
767
- with gr.Row():
768
- start_time_str = gr.Textbox(value="2014-03-07 18:22:00Z", label="Simulation start time (UTC)")
769
- alt_ft = gr.Number(value=35000, label="Altitude (ft)")
770
- vs_fpm = gr.Number(value=0, label="Vertical speed (ft/min)")
771
 
772
- with gr.Row():
773
- manual_btn = gr.Button("Simulate on Map + BFO (Manual)")
774
- tune_btn = gr.Button("Simulate on Map + BFO/BTO (Gentle auto-tune)")
775
-
776
- tune_status = gr.Markdown()
777
-
778
- out_map = gr.HTML()
779
- stats = gr.Markdown()
780
-
781
- gr.Markdown("### BFO Analysis")
782
- bfo_plot = gr.Plot()
783
- bfo_score = gr.Markdown()
784
-
785
- manual_btn.click(
786
- run_sim_manual,
787
- inputs=[start_lat, start_lon, legs_df, method, rings, start_time_str, alt_ft, vs_fpm],
788
- outputs=[legs_df, tune_status, out_map, stats, bfo_plot, bfo_score],
789
- )
790
-
791
- tune_btn.click(
792
- run_sim_autotune,
793
- inputs=[start_lat, start_lon, legs_df, method, rings, start_time_str, alt_ft, vs_fpm],
794
- outputs=[legs_df, tune_status, out_map, stats, bfo_plot, bfo_score],
795
- )
796
 
797
- add_btn.click(add_leg, inputs=[legs_df], outputs=[legs_df])
798
- reset_btn.click(reset_legs, outputs=[legs_df])
799
 
800
  demo.launch()
 
1
  # app.py
2
  # MH370 Flight Simulator — Leaflet map (Folium) + Ping Rings (KML)
3
+ # Features:
4
+ # (6) Multi-leg editor
5
+ # (7) Add / Reset legs
6
  # (8) Stats panel
7
  # (13) Permanent labeled waypoints
8
+ # (14) BFO analysis
9
+ # (15) Gentle auto-tune (BFO + BTO)
10
+ # NEW: Esri Ocean + Satellite basemaps with layer control
 
11
 
12
  import re
13
  import html
 
21
 
22
  import flight_plotter_v32 as fp
23
 
24
+
25
+ # ================= CONFIG =================
26
  KML_FILE = "MH370 - 13 nov (3).kml"
27
 
28
  DEFAULT_LEGS = [
 
32
  ]
33
  NEW_LEG_DEFAULT = [0, 500, 10]
34
 
 
35
  WAYPOINTS = [
36
+ ("IGARI", 6.93611111, 103.585),
37
+ ("VAMPI", 6.18222222, 97.58555556),
38
+ ("NILAM", 6.75638889, 95.97638889),
39
+ ("SANOB", 6.58611111, 95.66916667),
40
+ ("VPG", 5.27963889, 100.26038889),
41
+ ("MEKAR", 6.5, 96.48333333),
42
  ]
43
 
44
+ RING_TIMES = {
45
+ "Ping Ring 1": "18:25:00Z",
46
+ "Ping Ring 2": "18:40:00Z",
47
+ "Ping Ring 3": "19:40:00Z",
48
+ "Ping Ring 4": "20:40:00Z",
49
+ "Ping Ring 5": "21:40:00Z",
50
+ "Ping Ring 6": "22:40:00Z",
51
+ "Ping Ring 7 (35 kft)": "24:10:00Z",
52
+ }
 
 
53
 
 
54
  SATLOC_IDX_MEAS_BFO = 4
55
  SATLOC_IDX_MEAS_BTO = 5
56
 
57
 
58
+ # ================= KML =================
59
+ def load_ping_rings_from_kml(path):
 
 
 
 
60
  ns = {"kml": "http://www.opengis.net/kml/2.2"}
61
  tree = ET.parse(path)
62
  root = tree.getroot()
 
64
  rings = {}
65
  for pm in root.findall(".//kml:Placemark", ns):
66
  name_el = pm.find("kml:name", ns)
67
+ if not name_el or not name_el.text:
68
  continue
69
  name = name_el.text.strip()
 
70
  if not name.lower().startswith("ping ring"):
71
  continue
72
 
73
  coords_el = pm.find(".//kml:LineString/kml:coordinates", ns)
74
+ if not coords_el or not coords_el.text:
75
  continue
76
 
77
  pts = []
78
+ for token in re.split(r"\s+", coords_el.text.strip()):
79
+ lon, lat, *_ = token.split(",")
80
+ pts.append((float(lat), float(lon)))
 
 
 
 
 
 
 
81
 
82
  if pts:
83
  rings[name] = pts
84
 
 
 
 
 
85
  return rings
86
 
87
 
88
+ PING_RINGS = load_ping_rings_from_kml(KML_FILE)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
 
 
90
 
91
+ # ================= MAP =================
92
+ def add_label(m, name, lat, lon):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  folium.CircleMarker(
94
+ (lat, lon), radius=4, color="black",
95
+ fill=True, fill_color="white", fill_opacity=1
 
 
 
 
 
 
96
  ).add_to(m)
97
 
 
 
 
 
 
 
 
 
 
 
 
98
  folium.Marker(
99
+ (lat, lon),
100
+ icon=folium.DivIcon(
101
+ html=f"""
102
+ <div style="
103
+ font-size:13px;
104
+ font-weight:700;
105
+ color:black;
106
+ text-shadow:1px 1px 2px white;
107
+ transform:translate(8px,-12px);
108
+ white-space:nowrap;
109
+ ">{html.escape(name)}</div>
110
+ """
111
+ )
112
  ).add_to(m)
113
 
114
 
115
  def build_leaflet_map(path_pts, legs, seg_dists_m, selected_rings):
116
  start_lat, start_lon = path_pts[0]
117
+
118
  m = folium.Map(
119
  location=[start_lat, start_lon],
120
  zoom_start=4,
 
121
  control_scale=True,
122
+ tiles=None, # IMPORTANT: no default tiles
123
  )
124
 
125
+ # ---- BASEMAPS ----
126
+ folium.TileLayer(
127
+ "OpenStreetMap",
128
+ name="OpenStreetMap",
129
+ overlay=False,
130
+ control=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  ).add_to(m)
132
 
133
+ folium.TileLayer(
134
+ tiles="https://server.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}",
135
+ attr="Esri",
136
+ name="Esri Ocean",
137
+ overlay=False,
138
+ control=True,
 
 
 
 
 
139
  ).add_to(m)
140
 
141
+ folium.TileLayer(
142
+ tiles="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
143
+ attr="Esri",
144
+ name="Esri Satellite",
145
+ overlay=False,
146
+ control=True,
 
 
 
 
147
  ).add_to(m)
148
 
149
+ # ---- PING RINGS ----
150
+ for ring in selected_rings or []:
151
+ pts = PING_RINGS.get(ring)
152
+ if not pts:
153
+ continue
154
+ label = f"{ring} ({RING_TIMES.get(ring,'')})"
155
+ folium.PolyLine(
156
+ pts, color="blue", weight=2, opacity=0.6, tooltip=label
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  ).add_to(m)
158
 
159
+ # ---- FLIGHT PATH (glow + main) ----
160
+ folium.PolyLine(path_pts, color="white", weight=8, opacity=0.3).add_to(m)
161
+ folium.PolyLine(path_pts, color="red", weight=4, opacity=1.0).add_to(m)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
 
163
+ # ---- WAYPOINTS ----
164
+ for name, lat, lon in WAYPOINTS:
165
+ add_label(m, name, lat, lon)
166
 
167
+ # ---- START / END ----
168
+ folium.CircleMarker(path_pts[0], radius=6, color="green", fill=True).add_to(m)
169
+ folium.CircleMarker(path_pts[-1], radius=6, color="purple", fill=True).add_to(m)
 
 
 
 
 
 
170
 
171
+ # ---- LAYER CONTROL ----
172
+ folium.LayerControl(collapsed=False).add_to(m)
 
 
 
173
 
174
+ # ---- FIT BOUNDS ----
175
+ all_pts = path_pts[:]
176
+ for r in selected_rings or []:
177
+ all_pts += PING_RINGS.get(r, [])
178
+ if all_pts:
179
+ lats = [p[0] for p in all_pts]
180
+ lons = [p[1] for p in all_pts]
181
+ m.fit_bounds([[min(lats), min(lons)], [max(lats), max(lons)]])
182
 
183
+ html_map = html.escape(m.get_root().render(), quote=True)
184
+ return f"""
185
+ <iframe srcdoc="{html_map}"
186
+ style="width:100%;height:650px;border:0;border-radius:12px;">
187
+ </iframe>
188
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
+ # ================= SIM CORE =================
192
+ def simulate_path(start_lat, start_lon, legs, method):
193
+ lat, lon = start_lat, start_lon
194
+ pts = [(lat, lon)]
195
+ dists = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
 
197
+ for hdg, spd, minutes in legs:
198
+ dist_m = spd * 1852 * (minutes / 60)
199
  if method == "VINCENTY":
200
+ (lat, lon), _ = fp.vincenty_direct((lat, lon), hdg, dist_m)
201
  else:
202
+ lat, lon = fp.rhumb_direct((lat, lon), hdg, dist_m)
203
+ pts.append((lat, lon))
204
+ dists.append(dist_m)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
 
206
+ return pts, dists
207
 
 
 
 
 
 
 
 
 
 
208
 
209
+ # ================= UI =================
 
210
  with gr.Blocks() as demo:
211
+ gr.Markdown("## ✈️ MH370 Flight Simulator — Oceanic Basemap")
212
 
213
  with gr.Row():
214
  start_lat = gr.Slider(-10, 10, value=6.3, label="Start Latitude")
215
  start_lon = gr.Slider(90, 110, value=96.3, label="Start Longitude")
216
+ method = gr.Dropdown(["VINCENTY", "RHUMB"], value="VINCENTY")
217
 
218
  legs_df = gr.Dataframe(
219
  headers=["heading_deg", "speed_knots", "minutes"],
220
+ value=DEFAULT_LEGS,
221
  row_count=(10, "dynamic"),
222
  column_count=(3, "fixed"),
 
 
223
  )
224
 
 
 
 
 
225
  rings = gr.CheckboxGroup(
226
  choices=sorted(PING_RINGS.keys()),
227
  value=[sorted(PING_RINGS.keys())[0]],
228
+ label="Ping rings",
229
  )
230
 
231
+ btn = gr.Button("Simulate on Map")
232
+ out = gr.HTML()
 
 
 
233
 
234
+ def run(lat, lon, df, method, rings):
235
+ legs = [(float(r[0]), float(r[1]), float(r[2])) for r in df]
236
+ pts, dists = simulate_path(lat, lon, legs, method)
237
+ return build_leaflet_map(pts, legs, dists, rings)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
 
239
+ btn.click(run, [start_lat, start_lon, legs_df, method, rings], out)
 
240
 
241
  demo.launch()