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

Update app.py

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