Chris773 commited on
Commit
c6e00a0
·
verified ·
1 Parent(s): dd0f110

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +289 -156
app.py CHANGED
@@ -1,32 +1,19 @@
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 chart + MAE/RMSE
9
- # Guided mode scenarios:
10
- # A) Straight (single heading+speed) with FIXED schedule
11
- # A2) Straight (nominal heading) + small per-leg drift (±deg) with penalty
12
- # (REMOVED) Turn after Ping Ring 6
13
- # (REMOVED) Radar fix → turn completion @18:28Z → southern solve
14
  #
15
- # NEW: Altitude vs Time chart (visualize descent profile)
16
- # NEW: Validation table at schedule timestamps (lat/lon/hdg/spd + BFO/BTO measured vs calculated + errors)
17
- # NEW: Explicit weighting between BFO and BTO in guided solver (to emphasize BTO)
18
- # NEW: Heading-drift penalty (keeps drift small + defensible)
19
  #
20
- # === YOUR REQUESTED CHANGES (IMPLEMENTED) ===
21
- # 1) Overlay "Search areas.kmz" on the map (PERMANENT, always shown)
22
- # 2) Make ALL ping rings permanent (always shown)
23
- # 3) Remove ring toggles (no CheckboxGroup)
24
- # 4) Remove LayerControl entirely
25
- # 5) Color-code search items by name (Ocean Infinity / Australia / Malaysia / China / etc.)
26
- # 6) Remove guided scenarios:
27
- # - "Turn after Ping Ring 6 (two headings)"
28
- # - "Radar fix → turn completion @18:28Z → southern solve"
29
- # + Remove the radar-fix UI controls
30
 
31
  import os
32
  import re
@@ -80,80 +67,80 @@ BTO_SCALE_US = 50.0
80
 
81
  # EXPLICIT WEIGHTS
82
  DEFAULT_WEIGHT_BFO = 1.0
83
- DEFAULT_WEIGHT_BTO = 2.0 # emphasize BTO by default (increase to care more about BTO)
84
 
85
  # satLoc indices (confirmed by you)
86
  SATLOC_IDX_MEAS_BFO = 4
87
  SATLOC_IDX_MEAS_BTO = 5
88
 
89
- # Guided heading search (south-ish)
90
- GUIDED_HEADING_MIN = 150.0
91
- GUIDED_HEADING_MAX = 210.0
 
 
 
 
92
 
93
- # Guided time window (southern leg window)
94
- GUIDED_START_LABEL = "18:28:00" # 18:28:00Z
95
- GUIDED_END_LABEL = "24:20:00" # 00:20:00Z represented as 24:20:00
96
 
97
- # Guided schedule (exact for the southern leg starting 18:28Z)
98
- GUIDED_LEG_SCHEDULE_MIN = [12.0, 60.0, 60.0, 60.0, 60.0, 90.0, 10.0] # total 352 min
 
 
 
99
 
100
- # Guided scenario-specific search resolution
 
 
101
  STRAIGHT_STEP_DEG = 0.5
102
 
 
 
 
 
 
103
  # ================= HEADING DRIFT =================
104
- DEFAULT_DRIFT_MAX_DEG = 2.0 # typical: 1–2 degrees
105
- DRIFT_STEP_DEG = 0.5 # discrete search step for deltas
106
- DEFAULT_WEIGHT_DRIFT = 0.30 # penalty strength in "combined score" units
107
- DEFAULT_DRIFT_ITERS = 2 # coordinate descent passes
108
- DRIFT_SCALE_DEG = 1.0 # normalization for drift penalty (rms_deg / scale)
 
109
 
110
  # ================= SEARCH AREA COLORS =================
111
  SEARCH_AREA_COLORS = {
112
  "Ocean Infinity": "#ff3b3b", # red
113
  "Australia": "#1f77b4", # blue
114
- "Malays": "#2ca02c", # green (covers Malaysia/Malaysian)
115
  "China": "#ff7f0e", # orange
116
  "Seabed Constructor": "#9467bd", # purple
117
  "Fugro": "#8c564b", # brown
118
  }
119
- DEFAULT_SEARCH_COLOR = "#00ffff" # fallback cyan
120
 
121
 
122
  # ================= PATH RESOLUTION =================
123
  def resolve_local_or_mnt(path_like: str) -> str:
124
- """
125
- Try:
126
- 1) as given
127
- 2) same dir as app.py
128
- 3) /mnt/data/<filename> (common upload mount)
129
- 4) /mnt/data/<path_like> (if they passed a relative path with dirs)
130
- Returns best guess path (may still not exist).
131
- """
132
  p = (path_like or "").strip()
133
  if not p:
134
  return p
135
-
136
  if os.path.exists(p):
137
  return p
138
-
139
  here = os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals() else os.getcwd()
140
  p2 = os.path.join(here, p)
141
  if os.path.exists(p2):
142
  return p2
143
-
144
  base = os.path.basename(p)
145
  p3 = os.path.join("/mnt/data", base)
146
  if os.path.exists(p3):
147
  return p3
148
-
149
  p4 = os.path.join("/mnt/data", p)
150
  if os.path.exists(p4):
151
  return p4
152
-
153
  return p
154
 
155
 
156
- # ================= SEARCH AREA COLOR PICKER =================
157
  def color_for_search_area(name: str) -> str:
158
  lname = (name or "").lower()
159
  for key, color in SEARCH_AREA_COLORS.items():
@@ -165,7 +152,6 @@ def color_for_search_area(name: str) -> str:
165
  # ================= KML =================
166
  def load_ping_rings_from_kml(path: str):
167
  path = resolve_local_or_mnt(path)
168
-
169
  if not os.path.exists(path):
170
  raise FileNotFoundError(
171
  f"KML file not found: '{path}'. Put it in the Space root (same folder as app.py)."
@@ -241,15 +227,7 @@ def _parse_kml_coordinates_text(txt: str):
241
 
242
 
243
  def load_search_areas_from_kmz(kmz_path: str):
244
- """
245
- Load features from a KMZ (KML inside zip). Supports:
246
- - Placemark -> Point
247
- - Placemark -> LineString
248
- - Placemark -> Polygon
249
- - MultiGeometry versions of the above
250
- """
251
  kmz_path = resolve_local_or_mnt(kmz_path)
252
-
253
  if not os.path.exists(kmz_path):
254
  raise FileNotFoundError(
255
  f"KMZ file not found: '{kmz_path}'. Put it next to app.py (Space root) or ensure it's mounted in /mnt/data."
@@ -263,7 +241,6 @@ def load_search_areas_from_kmz(kmz_path: str):
263
  break
264
  if not kml_name:
265
  raise ValueError("KMZ contains no .kml file.")
266
-
267
  root = ET.fromstring(zf.read(kml_name))
268
 
269
  ns = _kml_ns_from_root(root)
@@ -273,9 +250,7 @@ def load_search_areas_from_kmz(kmz_path: str):
273
  name_el = pm.find("kml:name", ns)
274
  pm_name = (name_el.text.strip() if (name_el is not None and name_el.text) else "Search area")
275
 
276
- points = []
277
- lines = []
278
- polys = []
279
 
280
  for pt in pm.findall(".//kml:Point", ns):
281
  coords = pt.find(".//kml:coordinates", ns)
@@ -370,6 +345,116 @@ def reset_legs():
370
  return gr.update(value=[row[:] for row in DEFAULT_LEGS])
371
 
372
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  # ================= SIM CORE + STATS =================
374
  def simulate_path(start_lat, start_lon, legs, method):
375
  lat, lon = float(start_lat), float(start_lon)
@@ -378,11 +463,11 @@ def simulate_path(start_lat, start_lon, legs, method):
378
  total_time_min = 0.0
379
 
380
  for heading, speed_knots, minutes in legs:
381
- dist_m = speed_knots * 1852.0 * (minutes / 60.0)
382
  if method == "VINCENTY":
383
- (lat, lon), _ = fp.vincenty_direct((lat, lon), heading, dist_m)
384
  else:
385
- lat, lon = fp.rhumb_direct((lat, lon), heading, dist_m)
386
 
387
  pts.append((lat, lon))
388
  seg_dists_m.append(dist_m)
@@ -457,6 +542,7 @@ def build_leaflet_map(path_pts, legs, seg_dists_m):
457
  control_scale=True,
458
  )
459
 
 
460
  folium.TileLayer("OpenStreetMap", name="OpenStreetMap", overlay=False, control=True).add_to(m)
461
  folium.TileLayer(
462
  tiles="https://server.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}",
@@ -660,14 +746,6 @@ def _parse_start_time(s: str) -> datetime:
660
  return dt.astimezone(timezone.utc)
661
 
662
 
663
- def dt_guided_start_utc() -> datetime:
664
- return fp._time_label_to_dt(GUIDED_START_LABEL, fp.SAT_EPOCH_UTC)
665
-
666
-
667
- def dt_guided_end_utc() -> datetime:
668
- return fp._time_label_to_dt(GUIDED_END_LABEL, fp.SAT_EPOCH_UTC)
669
-
670
-
671
  def dt_ping6_utc() -> datetime:
672
  return fp._time_label_to_dt("22:40:00", fp.SAT_EPOCH_UTC)
673
 
@@ -713,7 +791,7 @@ def build_altitude_plot(start_dt: datetime, total_time_min: float, alt0_ft: floa
713
  return fig
714
 
715
 
716
- # ================= GEO HELPERS =================
717
  def _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg_deg, spd_knots, t_inside_leg_sec):
718
  dist_m = float(spd_knots) * 1852.0 * (float(t_inside_leg_sec) / 3600.0)
719
  if method == "VINCENTY":
@@ -754,6 +832,7 @@ def bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_time_str, a
754
  t_sec = (dt_ping - start_dt).total_seconds()
755
  if t_sec < 0 or t_sec > total_end:
756
  continue
 
757
  i = int(np.searchsorted(boundaries, t_sec, side="right") - 1)
758
  i = max(0, min(i, len(legs) - 1))
759
  t_inside = t_sec - boundaries[i]
@@ -851,7 +930,6 @@ def make_legs_from_schedule(headings, speed_knots, schedule_minutes):
851
  legs = []
852
  if isinstance(headings, (int, float)):
853
  headings = [float(headings)] * len(schedule_minutes)
854
-
855
  for hdg, m in zip(headings, schedule_minutes):
856
  m = float(m)
857
  if m <= 0:
@@ -899,11 +977,11 @@ def score_legs_bfo_bto(
899
 
900
  leg_start_lat, leg_start_lon = leg_start_positions[i]
901
  hdg, spd, _ = legs[i]
902
-
903
  lat, lon = _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg, spd, t_inside)
904
 
905
  alt_now_ft = altitude_ft_at_dt(dt_ping, start_dt, float(alt_ft), float(vs_fpm), descent_start_dt)
906
  alt_m = alt_now_ft * 0.3048
 
907
  vs_mps = float(vs_fpm) * 0.00508
908
  if descent_start_dt is not None and dt_ping < descent_start_dt:
909
  vs_mps = 0.0
@@ -970,15 +1048,8 @@ def score_legs_bfo_bto_with_drift(
970
  return s
971
 
972
 
973
- def _wrap_heading_deg(h: float) -> float:
974
- return float(h) % 360.0
975
-
976
-
977
  def _apply_base_plus_deltas(base_headings, deltas_deg):
978
- headings = []
979
- for b, d in zip(base_headings, deltas_deg):
980
- headings.append(_wrap_heading_deg(float(b) + float(d)))
981
- return headings
982
 
983
 
984
  def _coordinate_descent_deltas(
@@ -998,49 +1069,58 @@ def _coordinate_descent_deltas(
998
 
999
  best_s = None
1000
  best_d = deltas[:]
 
1001
 
1002
  for _it in range(max(1, int(iters))):
1003
  improved = False
1004
  for j in range(n):
1005
  best_local_s = None
1006
  best_local_dj = deltas[j]
 
 
1007
  for dv in cand:
1008
  trial = deltas[:]
1009
  trial[j] = float(dv)
1010
  trial_headings = _apply_base_plus_deltas(base_headings, trial)
1011
- legs = make_legs_from_schedule(trial_headings, float(speed_knots), schedule_minutes)
 
 
 
 
 
 
 
 
 
 
 
1012
 
1013
  s = score_legs_bfo_bto_with_drift(
1014
- start_lat, start_lon, method, start_dt, end_dt, legs,
1015
  alt_ft, vs_fpm, descent_start_dt,
1016
  weight_bfo, weight_bto,
1017
  drift_weight, trial
1018
  )
1019
  if s is None:
1020
  continue
 
1021
  if best_local_s is None or s["combined"] < best_local_s["combined"]:
1022
  best_local_s = s
1023
  best_local_dj = float(dv)
 
1024
 
1025
  deltas[j] = best_local_dj
1026
 
1027
  if best_local_s is not None and (best_s is None or best_local_s["combined"] < best_s["combined"]):
1028
  best_s = best_local_s
1029
  best_d = deltas[:]
 
1030
  improved = True
1031
 
1032
  if not improved:
1033
  break
1034
 
1035
- final_headings = _apply_base_plus_deltas(base_headings, best_d)
1036
- final_legs = make_legs_from_schedule(final_headings, float(speed_knots), schedule_minutes)
1037
- final_s = score_legs_bfo_bto_with_drift(
1038
- start_lat, start_lon, method, start_dt, end_dt, final_legs,
1039
- alt_ft, vs_fpm, descent_start_dt,
1040
- weight_bfo, weight_bto,
1041
- drift_weight, best_d
1042
- )
1043
- return final_legs, final_s, best_d
1044
 
1045
 
1046
  # ================= VALIDATION TABLE =================
@@ -1083,15 +1163,18 @@ def build_validation_table(
1083
  elif (descent_start_choice or "").startswith("After Ping Ring 7"):
1084
  descent_start_dt = dt_ping7_utc()
1085
 
 
1086
  times = [start_dt]
1087
  cum = 0.0
1088
  for (_h, _s, m) in legs:
1089
  cum += float(m)
1090
  times.append(start_dt + timedelta(minutes=cum))
1091
 
 
1092
  leg_secs = [float(m) * 60.0 for _, _, m in legs]
1093
  boundaries = np.cumsum([0.0] + leg_secs)
1094
 
 
1095
  cur_lat = float(start_lat)
1096
  cur_lon = float(start_lon)
1097
  leg_start_positions = [(cur_lat, cur_lon)]
@@ -1128,6 +1211,7 @@ def build_validation_table(
1128
 
1129
  bfo_meas, bto_meas = _lookup_measured_at_dt(dt_k)
1130
 
 
1131
  bfo_err = None if bfo_meas is None else (float(bfo_calc) - float(bfo_meas))
1132
  bto_err = None if bto_meas is None else (float(bto_calc) - float(bto_meas))
1133
 
@@ -1154,12 +1238,21 @@ def build_validation_table(
1154
 
1155
 
1156
  # ================= GUIDED SOLVERS =================
 
 
 
 
 
 
 
 
 
1157
  def solve_guided_straight(
1158
- start_lat, start_lon, method, speed_knots, alt_ft, vs_fpm,
1159
  descent_start_choice, weight_bfo, weight_bto
1160
  ):
1161
- start_dt = dt_guided_start_utc()
1162
- end_dt = dt_guided_end_utc()
1163
 
1164
  descent_start_dt = None
1165
  if (descent_start_choice or "").startswith("After Ping Ring 6"):
@@ -1167,59 +1260,76 @@ def solve_guided_straight(
1167
  elif (descent_start_choice or "").startswith("After Ping Ring 7"):
1168
  descent_start_dt = dt_ping7_utc()
1169
 
1170
- total_sched_min = float(np.sum(GUIDED_LEG_SCHEDULE_MIN))
1171
- total_window_min = (end_dt - start_dt).total_seconds() / 60.0
1172
- if abs(total_sched_min - total_window_min) > 0.01:
1173
- return None, (
1174
- f"Guided schedule mismatch: schedule totals {total_sched_min:.1f} min but window is {total_window_min:.1f} min."
1175
- ), None
1176
-
1177
  best = None
1178
  best_h = None
 
 
1179
 
1180
  h = GUIDED_HEADING_MIN
1181
  while h <= GUIDED_HEADING_MAX + 1e-9:
1182
- legs = make_legs_from_schedule(h, float(speed_knots), GUIDED_LEG_SCHEDULE_MIN)
 
 
 
 
 
 
 
 
1183
  s = score_legs_bfo_bto(
1184
- start_lat, start_lon, method, start_dt, end_dt, legs,
1185
  alt_ft, vs_fpm, descent_start_dt,
1186
  weight_bfo, weight_bto
1187
  )
1188
  if s is not None and (best is None or s["combined"] < best["combined"]):
1189
  best = s
1190
  best_h = float(h)
1191
- h += STRAIGHT_STEP_DEG
 
1192
 
1193
- if best is None:
1194
- return None, f"Guided solve: not enough ping points between {GUIDED_START_LABEL} and {GUIDED_END_LABEL} to score.", None
1195
 
1196
- legs = make_legs_from_schedule(best_h, float(speed_knots), GUIDED_LEG_SCHEDULE_MIN)
 
1197
 
1198
  rbto = best["rmse_bto"]
1199
  rbto_txt = f"{rbto:.1f} µs" if rbto is not None else "n/a"
1200
  sched_txt = ", ".join([f"{m:.0f}" for m in GUIDED_LEG_SCHEDULE_MIN]) + " min"
1201
 
 
1202
  report = "\n".join([
1203
- "**Guided mode: Straight (single heading)**",
1204
- f"- Window: **{GUIDED_START_LABEL}Z → 00:20:00Z**",
1205
- f"- Start: lat **{float(start_lat):.2f}**, lon **{float(start_lon):.2f}**",
 
 
 
 
 
 
 
 
 
 
 
1206
  f"- Legs: **{sched_txt}**",
1207
- f"- Speed: **{float(speed_knots):.1f} kt**",
1208
- f"- Best heading: **{best_h:.1f}°** (searched {GUIDED_HEADING_MIN:.0f}–{GUIDED_HEADING_MAX:.0f}°, step {STRAIGHT_STEP_DEG}°)",
1209
  f"- Weights: BFO **{float(weight_bfo):.2f}**, BTO **{float(weight_bto):.2f}**",
1210
  f"- BFO points: **{best['n_bfo']}** | RMSE_BFO: **{best['rmse_bfo']:.2f} Hz**",
1211
  f"- BTO points: **{best['n_bto']}** | RMSE_BTO: **{rbto_txt}**",
1212
  ])
1213
- return legs, report, start_dt
 
1214
 
1215
 
1216
  def solve_guided_straight_with_drift(
1217
- start_lat, start_lon, method, speed_knots, alt_ft, vs_fpm,
1218
  descent_start_choice, weight_bfo, weight_bto,
1219
  drift_max_deg, drift_weight
1220
  ):
1221
- start_dt = dt_guided_start_utc()
1222
- end_dt = dt_guided_end_utc()
1223
 
1224
  descent_start_dt = None
1225
  if (descent_start_choice or "").startswith("After Ping Ring 6"):
@@ -1227,17 +1337,11 @@ def solve_guided_straight_with_drift(
1227
  elif (descent_start_choice or "").startswith("After Ping Ring 7"):
1228
  descent_start_dt = dt_ping7_utc()
1229
 
1230
- total_sched_min = float(np.sum(GUIDED_LEG_SCHEDULE_MIN))
1231
- total_window_min = (end_dt - start_dt).total_seconds() / 60.0
1232
- if abs(total_sched_min - total_window_min) > 0.01:
1233
- return None, (
1234
- f"Guided schedule mismatch: schedule totals {total_sched_min:.1f} min but window is {total_window_min:.1f} min."
1235
- ), None
1236
-
1237
  best_s = None
1238
  best_legs = None
1239
  best_h0 = None
1240
  best_deltas = None
 
1241
 
1242
  n = len(GUIDED_LEG_SCHEDULE_MIN)
1243
 
@@ -1245,9 +1349,9 @@ def solve_guided_straight_with_drift(
1245
  while h0 <= GUIDED_HEADING_MAX + 1e-9:
1246
  base_headings = [float(h0)] * n
1247
 
1248
- legs, s, deltas = _coordinate_descent_deltas(
1249
- start_lat, start_lon, method, start_dt, end_dt,
1250
- speed_knots, GUIDED_LEG_SCHEDULE_MIN,
1251
  alt_ft, vs_fpm, descent_start_dt,
1252
  weight_bfo, weight_bto,
1253
  float(drift_weight),
@@ -1257,16 +1361,25 @@ def solve_guided_straight_with_drift(
1257
  DEFAULT_DRIFT_ITERS
1258
  )
1259
 
1260
- if s is not None and (best_s is None or s["combined"] < best_s["combined"]):
1261
  best_s = s
1262
- best_legs = legs
1263
  best_h0 = float(h0)
1264
  best_deltas = deltas
1265
 
1266
- h0 += 1.0 # coarse sweep when drift is enabled
 
 
 
 
 
 
 
 
 
1267
 
1268
  if best_s is None or best_legs is None:
1269
- return None, f"Guided drift solve: not enough ping points between {GUIDED_START_LABEL} and {GUIDED_END_LABEL} to score.", None
1270
 
1271
  rbto = best_s["rmse_bto"]
1272
  rbto_txt = f"{rbto:.1f} µs" if rbto is not None else "n/a"
@@ -1277,16 +1390,29 @@ def solve_guided_straight_with_drift(
1277
  if best_deltas is not None:
1278
  drift_str = ", ".join([f"{d:+.1f}°" for d in best_deltas])
1279
 
 
 
1280
  report = "\n".join([
1281
- "**Guided mode: Straight + small per-leg heading drift (penalized)**",
1282
- f"- Window: **{GUIDED_START_LABEL}Z → 00:20:00Z**",
1283
- f"- Start: lat **{float(start_lat):.2f}**, lon **{float(start_lon):.2f}**",
 
 
 
 
 
 
 
 
 
 
1284
  f"- Legs: **{sched_txt}**",
1285
- f"- Speed: **{float(speed_knots):.1f} kt**",
1286
  f"- Nominal heading H₀: **{best_h0:.1f}°** (searched {GUIDED_HEADING_MIN:.0f}–{GUIDED_HEADING_MAX:.0f}°, step 1.0°)",
1287
  f"- Drift bounds: **±{float(drift_max_deg):.1f}°** | Drift penalty weight: **{float(drift_weight):.2f}**",
1288
  f"- Drift RMS: **{rms_drift:.2f}°**",
1289
  f"- Drift per leg (Δ): {drift_str}" if drift_str else "",
 
 
1290
  f"- Weights: BFO **{float(weight_bfo):.2f}**, BTO **{float(weight_bto):.2f}**",
1291
  f"- BFO points: **{best_s['n_bfo']}** | RMSE_BFO: **{best_s['rmse_bfo']:.2f} Hz**",
1292
  f"- BTO points: **{best_s['n_bto']}** | RMSE_BTO: **{rbto_txt}**",
@@ -1335,23 +1461,20 @@ def run_guided(
1335
  weight_bfo, weight_bto,
1336
  drift_max_deg, drift_weight
1337
  ):
 
1338
  spd_knots = speed_from_profile(speed_profile, alt_ft, custom_mach, custom_knots)
1339
 
1340
  if (guided_scenario or "").startswith("Straight + drift"):
1341
  legs, report_md, start_dt = solve_guided_straight_with_drift(
1342
- start_lat, start_lon, method, spd_knots, alt_ft, vs_fpm,
1343
  descent_start_choice, weight_bfo, weight_bto,
1344
  drift_max_deg, drift_weight
1345
  )
1346
- sim_start_lat = start_lat
1347
- sim_start_lon = start_lon
1348
  else:
1349
  legs, report_md, start_dt = solve_guided_straight(
1350
- start_lat, start_lon, method, spd_knots, alt_ft, vs_fpm,
1351
  descent_start_choice, weight_bfo, weight_bto
1352
  )
1353
- sim_start_lat = start_lat
1354
- sim_start_lon = start_lon
1355
 
1356
  if legs is None:
1357
  fig = go.Figure()
@@ -1365,9 +1488,11 @@ def run_guided(
1365
  ], value=[])
1366
  return gr.update(), f"### ❌ {report_md}", "", "", fig, "", alt_fig, empty_table
1367
 
 
1368
  html_map, stats_md, bfo_fig, bfo_md, alt_fig, headers, rows = _run_common(
1369
- sim_start_lat, sim_start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice
1370
  )
 
1371
  return gr.update(value=legs_to_rows(legs)), report_md, html_map, stats_md, bfo_fig, bfo_md, alt_fig, gr.update(headers=headers, value=rows)
1372
 
1373
 
@@ -1389,14 +1514,22 @@ with gr.Blocks() as demo:
1389
  "Make sure `Search areas.kmz` is uploaded and `SEARCH_AREAS_KMZ` matches exactly."
1390
  )
1391
 
 
 
 
 
 
 
 
 
1392
  with gr.Row():
1393
- start_lat = gr.Slider(-10, 15, value=6.76, label="Start Latitude (used for scenarios A/B)")
1394
- start_lon = gr.Slider(70, 115, value=95.69, label="Start Longitude (used for scenarios A/B)")
1395
  method = gr.Dropdown(["VINCENTY", "RHUMB"], value="VINCENTY", label="Method")
1396
 
1397
  gr.Markdown("### BFO/BTO Settings")
1398
  with gr.Row():
1399
- start_time_str = gr.Textbox(value=f"{GUIDED_START_LABEL}Z", label="Simulation start time (UTC) — Manual mode")
1400
  alt_ft = gr.Number(value=35000, label="Altitude at start (ft)")
1401
  vs_fpm = gr.Number(value=0, label="Vertical speed (ft/min) (negative = descent)")
1402
  descent_start_choice = gr.Dropdown(
@@ -1418,7 +1551,7 @@ with gr.Blocks() as demo:
1418
  speed_profile = gr.Dropdown(
1419
  ["MRC Mach 0.82", "LRC Mach 0.84", "ECON Mach 0.80", "Custom Mach", "Custom knots"],
1420
  value="MRC Mach 0.82",
1421
- label="Speed profile",
1422
  )
1423
  custom_mach = gr.Number(value=0.82, label="Custom Mach (if selected)")
1424
  custom_knots = gr.Number(value=470, label="Custom knots (if selected)")
 
1
  # app.py
2
  # MH370 Flight Simulator — Leaflet map (Folium) + Ping Rings (KML)
 
 
 
 
 
 
 
 
 
 
 
3
  #
4
+ # Guided scenarios (ACTIVE):
5
+ # A) Straight (single heading) ✅
6
+ # A2) Straight + drift (per-leg ±deg, penalized) ✅
 
7
  #
8
+ # Changes implemented (your request):
9
+ # 1) Last radar spot is FIXED:
10
+ # 18:22:00Z @ 6.578N, 96.341E (cannot be changed)
11
+ # 2) Turn completion is FIXED:
12
+ # 18:40:00Z (final major turn completed by this time)
13
+ # 3) Guided mode now automatically computes a realistic pre-turn path (18:22→18:40)
14
+ # and plots it on the map before the southern leg.
15
+ # 4) Speed during the pre-turn is constrained to the user-selected speed.
16
+ # 5) Validation table continues to output waypoints at schedule timestamps.
 
17
 
18
  import os
19
  import re
 
67
 
68
  # EXPLICIT WEIGHTS
69
  DEFAULT_WEIGHT_BFO = 1.0
70
+ DEFAULT_WEIGHT_BTO = 2.0 # emphasize BTO by default
71
 
72
  # satLoc indices (confirmed by you)
73
  SATLOC_IDX_MEAS_BFO = 4
74
  SATLOC_IDX_MEAS_BTO = 5
75
 
76
+ # ===== Fixed Radar + Turn Completion =====
77
+ RADAR_FIX_LAT = 6.578
78
+ RADAR_FIX_LON = 96.341
79
+ RADAR_FIX_TIME_UTC = datetime(2014, 3, 7, 18, 22, 0, tzinfo=timezone.utc)
80
+
81
+ TURN_COMPLETE_TIME_UTC = datetime(2014, 3, 7, 18, 40, 0, tzinfo=timezone.utc)
82
+ TURN_WINDOW_MIN = (TURN_COMPLETE_TIME_UTC - RADAR_FIX_TIME_UTC).total_seconds() / 60.0
83
 
84
+ # Initial heading from radar panel (TRK 296°T)
85
+ RADAR_INITIAL_TRACK_DEG = 296.0
 
86
 
87
+ # Turn modeling parameters (realistic constant-bank turn)
88
+ TURN_BANK_MIN_DEG = 15.0
89
+ TURN_BANK_MAX_DEG = 30.0
90
+ TURN_BANK_DEFAULT_DEG = 25.0
91
+ TURN_STEP_SEC = 30.0 # discretize turn into small straight segments for map + scoring
92
 
93
+ # ===== Guided heading search (southern-ish) =====
94
+ GUIDED_HEADING_MIN = 150.0
95
+ GUIDED_HEADING_MAX = 210.0
96
  STRAIGHT_STEP_DEG = 0.5
97
 
98
+ # ===== Guided schedule for southern leg =====
99
+ # This schedule applies AFTER 18:40, but total sim starts at 18:22 including the turn window.
100
+ GUIDED_LEG_SCHEDULE_MIN = [12.0, 60.0, 60.0, 60.0, 60.0, 90.0, 10.0] # 352 min
101
+
102
+
103
  # ================= HEADING DRIFT =================
104
+ DEFAULT_DRIFT_MAX_DEG = 2.0
105
+ DRIFT_STEP_DEG = 0.5
106
+ DEFAULT_WEIGHT_DRIFT = 0.30
107
+ DEFAULT_DRIFT_ITERS = 2
108
+ DRIFT_SCALE_DEG = 1.0
109
+
110
 
111
  # ================= SEARCH AREA COLORS =================
112
  SEARCH_AREA_COLORS = {
113
  "Ocean Infinity": "#ff3b3b", # red
114
  "Australia": "#1f77b4", # blue
115
+ "Malays": "#2ca02c", # green (Malaysia/Malaysian)
116
  "China": "#ff7f0e", # orange
117
  "Seabed Constructor": "#9467bd", # purple
118
  "Fugro": "#8c564b", # brown
119
  }
120
+ DEFAULT_SEARCH_COLOR = "#00ffff"
121
 
122
 
123
  # ================= PATH RESOLUTION =================
124
  def resolve_local_or_mnt(path_like: str) -> str:
 
 
 
 
 
 
 
 
125
  p = (path_like or "").strip()
126
  if not p:
127
  return p
 
128
  if os.path.exists(p):
129
  return p
 
130
  here = os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals() else os.getcwd()
131
  p2 = os.path.join(here, p)
132
  if os.path.exists(p2):
133
  return p2
 
134
  base = os.path.basename(p)
135
  p3 = os.path.join("/mnt/data", base)
136
  if os.path.exists(p3):
137
  return p3
 
138
  p4 = os.path.join("/mnt/data", p)
139
  if os.path.exists(p4):
140
  return p4
 
141
  return p
142
 
143
 
 
144
  def color_for_search_area(name: str) -> str:
145
  lname = (name or "").lower()
146
  for key, color in SEARCH_AREA_COLORS.items():
 
152
  # ================= KML =================
153
  def load_ping_rings_from_kml(path: str):
154
  path = resolve_local_or_mnt(path)
 
155
  if not os.path.exists(path):
156
  raise FileNotFoundError(
157
  f"KML file not found: '{path}'. Put it in the Space root (same folder as app.py)."
 
227
 
228
 
229
  def load_search_areas_from_kmz(kmz_path: str):
 
 
 
 
 
 
 
230
  kmz_path = resolve_local_or_mnt(kmz_path)
 
231
  if not os.path.exists(kmz_path):
232
  raise FileNotFoundError(
233
  f"KMZ file not found: '{kmz_path}'. Put it next to app.py (Space root) or ensure it's mounted in /mnt/data."
 
241
  break
242
  if not kml_name:
243
  raise ValueError("KMZ contains no .kml file.")
 
244
  root = ET.fromstring(zf.read(kml_name))
245
 
246
  ns = _kml_ns_from_root(root)
 
250
  name_el = pm.find("kml:name", ns)
251
  pm_name = (name_el.text.strip() if (name_el is not None and name_el.text) else "Search area")
252
 
253
+ points, lines, polys = [], [], []
 
 
254
 
255
  for pt in pm.findall(".//kml:Point", ns):
256
  coords = pt.find(".//kml:coordinates", ns)
 
345
  return gr.update(value=[row[:] for row in DEFAULT_LEGS])
346
 
347
 
348
+ # ================= GEO HELPERS =================
349
+ def _wrap_heading_deg(h: float) -> float:
350
+ return float(h) % 360.0
351
+
352
+
353
+ def _shortest_signed_delta_deg(h_from: float, h_to: float) -> float:
354
+ # result in [-180, +180]
355
+ return ((float(h_to) - float(h_from) + 540.0) % 360.0) - 180.0
356
+
357
+
358
+ def _turn_rate_deg_per_sec(speed_knots: float, bank_deg: float) -> float:
359
+ # omega = g*tan(bank)/V
360
+ g = 9.80665
361
+ V = max(10.0, float(speed_knots) * 0.514444) # m/s
362
+ bank = math.radians(max(1e-3, float(bank_deg)))
363
+ omega = g * math.tan(bank) / V # rad/s
364
+ return float(omega * 180.0 / math.pi)
365
+
366
+
367
+ def build_turn_legs(
368
+ speed_knots: float,
369
+ method: str,
370
+ heading_final_deg: float,
371
+ bank_pref_deg: float = TURN_BANK_DEFAULT_DEG,
372
+ step_sec: float = TURN_STEP_SEC
373
+ ):
374
+ """
375
+ Build legs from radar fix (18:22) to turn completion (18:40).
376
+
377
+ Model:
378
+ - straight segment on RADAR_INITIAL_TRACK_DEG
379
+ - then constant-bank turn to heading_final_deg
380
+ - ends exactly at 18:40
381
+
382
+ Returns:
383
+ legs_turn, info_dict
384
+ """
385
+ total_sec = (TURN_COMPLETE_TIME_UTC - RADAR_FIX_TIME_UTC).total_seconds()
386
+ if total_sec <= 0:
387
+ return [], {"error": "Invalid turn window."}
388
+
389
+ h0 = float(RADAR_INITIAL_TRACK_DEG)
390
+ h1 = _wrap_heading_deg(float(heading_final_deg))
391
+ delta = _shortest_signed_delta_deg(h0, h1)
392
+ sign = 1.0 if delta >= 0 else -1.0
393
+ delta_abs = abs(delta)
394
+
395
+ # choose bank angle so turn duration fits window
396
+ bank_candidates = [float(bank_pref_deg)] + list(np.linspace(TURN_BANK_MIN_DEG, TURN_BANK_MAX_DEG, 9))
397
+ best_bank = None
398
+ best_turn_sec = None
399
+
400
+ for b in bank_candidates:
401
+ rate = _turn_rate_deg_per_sec(speed_knots, b)
402
+ if rate <= 1e-6:
403
+ continue
404
+ t_turn = delta_abs / rate
405
+ if t_turn <= total_sec + 1e-6:
406
+ # prefer bank close to preferred
407
+ if best_bank is None:
408
+ best_bank, best_turn_sec = b, t_turn
409
+ else:
410
+ if abs(b - bank_pref_deg) < abs(best_bank - bank_pref_deg):
411
+ best_bank, best_turn_sec = b, t_turn
412
+
413
+ # fallback: max bank
414
+ if best_bank is None:
415
+ best_bank = TURN_BANK_MAX_DEG
416
+ best_turn_sec = delta_abs / max(1e-6, _turn_rate_deg_per_sec(speed_knots, best_bank))
417
+ best_turn_sec = min(best_turn_sec, total_sec)
418
+
419
+ turn_sec = float(best_turn_sec)
420
+ straight_sec = max(0.0, float(total_sec) - turn_sec)
421
+
422
+ legs = []
423
+
424
+ # straight leg (if any)
425
+ if straight_sec > 1.0:
426
+ legs.append((h0, float(speed_knots), straight_sec / 60.0))
427
+
428
+ # turn legs discretized
429
+ if turn_sec > 1.0 and delta_abs > 1e-6:
430
+ step_sec = max(5.0, float(step_sec))
431
+ n = int(math.ceil(turn_sec / step_sec))
432
+ if n < 1:
433
+ n = 1
434
+ # actual step sizes: equal steps except last adjusted
435
+ remaining = turn_sec
436
+ for i in range(n):
437
+ this = min(step_sec, remaining)
438
+ remaining -= this
439
+
440
+ # heading at midpoint of segment:
441
+ frac_mid = ( (i + 0.5) / n )
442
+ hdg_mid = _wrap_heading_deg(h0 + sign * delta_abs * frac_mid)
443
+
444
+ legs.append((hdg_mid, float(speed_knots), this / 60.0))
445
+
446
+ info = {
447
+ "h0": h0,
448
+ "h1": h1,
449
+ "delta_deg": delta,
450
+ "bank_deg": float(best_bank),
451
+ "straight_min": straight_sec / 60.0,
452
+ "turn_min": turn_sec / 60.0,
453
+ "total_min": total_sec / 60.0,
454
+ }
455
+ return legs, info
456
+
457
+
458
  # ================= SIM CORE + STATS =================
459
  def simulate_path(start_lat, start_lon, legs, method):
460
  lat, lon = float(start_lat), float(start_lon)
 
463
  total_time_min = 0.0
464
 
465
  for heading, speed_knots, minutes in legs:
466
+ dist_m = float(speed_knots) * 1852.0 * (float(minutes) / 60.0)
467
  if method == "VINCENTY":
468
+ (lat, lon), _ = fp.vincenty_direct((lat, lon), float(heading), dist_m)
469
  else:
470
+ lat, lon = fp.rhumb_direct((lat, lon), float(heading), dist_m)
471
 
472
  pts.append((lat, lon))
473
  seg_dists_m.append(dist_m)
 
542
  control_scale=True,
543
  )
544
 
545
+ # Basemaps (no LayerControl requested)
546
  folium.TileLayer("OpenStreetMap", name="OpenStreetMap", overlay=False, control=True).add_to(m)
547
  folium.TileLayer(
548
  tiles="https://server.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}",
 
746
  return dt.astimezone(timezone.utc)
747
 
748
 
 
 
 
 
 
 
 
 
749
  def dt_ping6_utc() -> datetime:
750
  return fp._time_label_to_dt("22:40:00", fp.SAT_EPOCH_UTC)
751
 
 
791
  return fig
792
 
793
 
794
+ # ================= PREDICT STATE =================
795
  def _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg_deg, spd_knots, t_inside_leg_sec):
796
  dist_m = float(spd_knots) * 1852.0 * (float(t_inside_leg_sec) / 3600.0)
797
  if method == "VINCENTY":
 
832
  t_sec = (dt_ping - start_dt).total_seconds()
833
  if t_sec < 0 or t_sec > total_end:
834
  continue
835
+
836
  i = int(np.searchsorted(boundaries, t_sec, side="right") - 1)
837
  i = max(0, min(i, len(legs) - 1))
838
  t_inside = t_sec - boundaries[i]
 
930
  legs = []
931
  if isinstance(headings, (int, float)):
932
  headings = [float(headings)] * len(schedule_minutes)
 
933
  for hdg, m in zip(headings, schedule_minutes):
934
  m = float(m)
935
  if m <= 0:
 
977
 
978
  leg_start_lat, leg_start_lon = leg_start_positions[i]
979
  hdg, spd, _ = legs[i]
 
980
  lat, lon = _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg, spd, t_inside)
981
 
982
  alt_now_ft = altitude_ft_at_dt(dt_ping, start_dt, float(alt_ft), float(vs_fpm), descent_start_dt)
983
  alt_m = alt_now_ft * 0.3048
984
+
985
  vs_mps = float(vs_fpm) * 0.00508
986
  if descent_start_dt is not None and dt_ping < descent_start_dt:
987
  vs_mps = 0.0
 
1048
  return s
1049
 
1050
 
 
 
 
 
1051
  def _apply_base_plus_deltas(base_headings, deltas_deg):
1052
+ return [_wrap_heading_deg(float(b) + float(d)) for b, d in zip(base_headings, deltas_deg)]
 
 
 
1053
 
1054
 
1055
  def _coordinate_descent_deltas(
 
1069
 
1070
  best_s = None
1071
  best_d = deltas[:]
1072
+ best_legs = None
1073
 
1074
  for _it in range(max(1, int(iters))):
1075
  improved = False
1076
  for j in range(n):
1077
  best_local_s = None
1078
  best_local_dj = deltas[j]
1079
+ best_local_legs = None
1080
+
1081
  for dv in cand:
1082
  trial = deltas[:]
1083
  trial[j] = float(dv)
1084
  trial_headings = _apply_base_plus_deltas(base_headings, trial)
1085
+
1086
+ # southern legs
1087
+ southern_legs = make_legs_from_schedule(trial_headings, float(speed_knots), schedule_minutes)
1088
+
1089
+ # pre-turn legs built to align with the FIRST southern heading at 18:40
1090
+ preturn_legs, _info = build_turn_legs(
1091
+ speed_knots=float(speed_knots),
1092
+ method=method,
1093
+ heading_final_deg=float(trial_headings[0]),
1094
+ )
1095
+
1096
+ full_legs = preturn_legs + southern_legs
1097
 
1098
  s = score_legs_bfo_bto_with_drift(
1099
+ start_lat, start_lon, method, start_dt, end_dt, full_legs,
1100
  alt_ft, vs_fpm, descent_start_dt,
1101
  weight_bfo, weight_bto,
1102
  drift_weight, trial
1103
  )
1104
  if s is None:
1105
  continue
1106
+
1107
  if best_local_s is None or s["combined"] < best_local_s["combined"]:
1108
  best_local_s = s
1109
  best_local_dj = float(dv)
1110
+ best_local_legs = full_legs
1111
 
1112
  deltas[j] = best_local_dj
1113
 
1114
  if best_local_s is not None and (best_s is None or best_local_s["combined"] < best_s["combined"]):
1115
  best_s = best_local_s
1116
  best_d = deltas[:]
1117
+ best_legs = best_local_legs
1118
  improved = True
1119
 
1120
  if not improved:
1121
  break
1122
 
1123
+ return best_legs, best_s, best_d
 
 
 
 
 
 
 
 
1124
 
1125
 
1126
  # ================= VALIDATION TABLE =================
 
1163
  elif (descent_start_choice or "").startswith("After Ping Ring 7"):
1164
  descent_start_dt = dt_ping7_utc()
1165
 
1166
+ # Schedule timestamps: start + cumulative leg minutes
1167
  times = [start_dt]
1168
  cum = 0.0
1169
  for (_h, _s, m) in legs:
1170
  cum += float(m)
1171
  times.append(start_dt + timedelta(minutes=cum))
1172
 
1173
+ # boundaries for segment lookup
1174
  leg_secs = [float(m) * 60.0 for _, _, m in legs]
1175
  boundaries = np.cumsum([0.0] + leg_secs)
1176
 
1177
+ # precompute leg start positions
1178
  cur_lat = float(start_lat)
1179
  cur_lon = float(start_lon)
1180
  leg_start_positions = [(cur_lat, cur_lon)]
 
1211
 
1212
  bfo_meas, bto_meas = _lookup_measured_at_dt(dt_k)
1213
 
1214
+ # errors (calc - meas)
1215
  bfo_err = None if bfo_meas is None else (float(bfo_calc) - float(bfo_meas))
1216
  bto_err = None if bto_meas is None else (float(bto_calc) - float(bto_meas))
1217
 
 
1238
 
1239
 
1240
  # ================= GUIDED SOLVERS =================
1241
+ def _guided_start_dt():
1242
+ return RADAR_FIX_TIME_UTC
1243
+
1244
+
1245
+ def _guided_end_dt():
1246
+ total_min = TURN_WINDOW_MIN + float(np.sum(GUIDED_LEG_SCHEDULE_MIN))
1247
+ return RADAR_FIX_TIME_UTC + timedelta(minutes=total_min)
1248
+
1249
+
1250
  def solve_guided_straight(
1251
+ method, speed_knots, alt_ft, vs_fpm,
1252
  descent_start_choice, weight_bfo, weight_bto
1253
  ):
1254
+ start_dt = _guided_start_dt()
1255
+ end_dt = _guided_end_dt()
1256
 
1257
  descent_start_dt = None
1258
  if (descent_start_choice or "").startswith("After Ping Ring 6"):
 
1260
  elif (descent_start_choice or "").startswith("After Ping Ring 7"):
1261
  descent_start_dt = dt_ping7_utc()
1262
 
 
 
 
 
 
 
 
1263
  best = None
1264
  best_h = None
1265
+ best_full_legs = None
1266
+ best_turn_info = None
1267
 
1268
  h = GUIDED_HEADING_MIN
1269
  while h <= GUIDED_HEADING_MAX + 1e-9:
1270
+ southern_legs = make_legs_from_schedule(h, float(speed_knots), GUIDED_LEG_SCHEDULE_MIN)
1271
+
1272
+ preturn_legs, turn_info = build_turn_legs(
1273
+ speed_knots=float(speed_knots),
1274
+ method=method,
1275
+ heading_final_deg=float(h),
1276
+ )
1277
+ full_legs = preturn_legs + southern_legs
1278
+
1279
  s = score_legs_bfo_bto(
1280
+ RADAR_FIX_LAT, RADAR_FIX_LON, method, start_dt, end_dt, full_legs,
1281
  alt_ft, vs_fpm, descent_start_dt,
1282
  weight_bfo, weight_bto
1283
  )
1284
  if s is not None and (best is None or s["combined"] < best["combined"]):
1285
  best = s
1286
  best_h = float(h)
1287
+ best_full_legs = full_legs
1288
+ best_turn_info = turn_info
1289
 
1290
+ h += STRAIGHT_STEP_DEG
 
1291
 
1292
+ if best is None or best_full_legs is None:
1293
+ return None, f"Guided solve: not enough ping points to score in window {start_dt.strftime('%H:%MZ')} → {end_dt.strftime('%H:%MZ')}.", None
1294
 
1295
  rbto = best["rmse_bto"]
1296
  rbto_txt = f"{rbto:.1f} µs" if rbto is not None else "n/a"
1297
  sched_txt = ", ".join([f"{m:.0f}" for m in GUIDED_LEG_SCHEDULE_MIN]) + " min"
1298
 
1299
+ tinfo = best_turn_info or {}
1300
  report = "\n".join([
1301
+ "**Guided mode: Straight (single heading) + computed turn (18:22→18:40)**",
1302
+ f"- Radar fix (fixed): **18:22:00Z** @ lat **{RADAR_FIX_LAT:.3f}**, lon **{RADAR_FIX_LON:.3f}**",
1303
+ f"- Turn completion (fixed): **18:40:00Z**",
1304
+ f"- Initial track (fixed): **{RADAR_INITIAL_TRACK_DEG:.1f}°**",
1305
+ f"- Speed (constrained): **{float(speed_knots):.1f} kt**",
1306
+ f"- Best southern heading: **{best_h:.1f}°** (searched {GUIDED_HEADING_MIN:.0f}–{GUIDED_HEADING_MAX:.0f}°, step {STRAIGHT_STEP_DEG}°)",
1307
+ "",
1308
+ "**Computed turn model**",
1309
+ f"- Bank angle: **{tinfo.get('bank_deg', TURN_BANK_DEFAULT_DEG):.1f}°**",
1310
+ f"- Heading change: **{tinfo.get('delta_deg', 0.0):+.1f}°**",
1311
+ f"- Straight-before-turn: **{tinfo.get('straight_min', 0.0):.2f} min**",
1312
+ f"- Turn duration: **{tinfo.get('turn_min', 0.0):.2f} min**",
1313
+ "",
1314
+ "**Southern schedule**",
1315
  f"- Legs: **{sched_txt}**",
1316
+ "",
1317
+ "**Fit**",
1318
  f"- Weights: BFO **{float(weight_bfo):.2f}**, BTO **{float(weight_bto):.2f}**",
1319
  f"- BFO points: **{best['n_bfo']}** | RMSE_BFO: **{best['rmse_bfo']:.2f} Hz**",
1320
  f"- BTO points: **{best['n_bto']}** | RMSE_BTO: **{rbto_txt}**",
1321
  ])
1322
+
1323
+ return best_full_legs, report, start_dt
1324
 
1325
 
1326
  def solve_guided_straight_with_drift(
1327
+ method, speed_knots, alt_ft, vs_fpm,
1328
  descent_start_choice, weight_bfo, weight_bto,
1329
  drift_max_deg, drift_weight
1330
  ):
1331
+ start_dt = _guided_start_dt()
1332
+ end_dt = _guided_end_dt()
1333
 
1334
  descent_start_dt = None
1335
  if (descent_start_choice or "").startswith("After Ping Ring 6"):
 
1337
  elif (descent_start_choice or "").startswith("After Ping Ring 7"):
1338
  descent_start_dt = dt_ping7_utc()
1339
 
 
 
 
 
 
 
 
1340
  best_s = None
1341
  best_legs = None
1342
  best_h0 = None
1343
  best_deltas = None
1344
+ best_turn_info = None
1345
 
1346
  n = len(GUIDED_LEG_SCHEDULE_MIN)
1347
 
 
1349
  while h0 <= GUIDED_HEADING_MAX + 1e-9:
1350
  base_headings = [float(h0)] * n
1351
 
1352
+ full_legs, s, deltas = _coordinate_descent_deltas(
1353
+ RADAR_FIX_LAT, RADAR_FIX_LON, method, start_dt, end_dt,
1354
+ float(speed_knots), GUIDED_LEG_SCHEDULE_MIN,
1355
  alt_ft, vs_fpm, descent_start_dt,
1356
  weight_bfo, weight_bto,
1357
  float(drift_weight),
 
1361
  DEFAULT_DRIFT_ITERS
1362
  )
1363
 
1364
+ if s is not None and full_legs is not None and (best_s is None or s["combined"] < best_s["combined"]):
1365
  best_s = s
1366
+ best_legs = full_legs
1367
  best_h0 = float(h0)
1368
  best_deltas = deltas
1369
 
1370
+ # compute turn info based on first southern heading (base + delta[0])
1371
+ trial_headings = _apply_base_plus_deltas(base_headings, deltas)
1372
+ _, turn_info = build_turn_legs(
1373
+ speed_knots=float(speed_knots),
1374
+ method=method,
1375
+ heading_final_deg=float(trial_headings[0]),
1376
+ )
1377
+ best_turn_info = turn_info
1378
+
1379
+ h0 += 1.0
1380
 
1381
  if best_s is None or best_legs is None:
1382
+ return None, f"Guided drift solve: not enough ping points to score in window {start_dt.strftime('%H:%MZ')} → {end_dt.strftime('%H:%MZ')}.", None
1383
 
1384
  rbto = best_s["rmse_bto"]
1385
  rbto_txt = f"{rbto:.1f} µs" if rbto is not None else "n/a"
 
1390
  if best_deltas is not None:
1391
  drift_str = ", ".join([f"{d:+.1f}°" for d in best_deltas])
1392
 
1393
+ tinfo = best_turn_info or {}
1394
+
1395
  report = "\n".join([
1396
+ "**Guided mode: Straight + drift (penalized) + computed turn (18:22→18:40)**",
1397
+ f"- Radar fix (fixed): **18:22:00Z** @ lat **{RADAR_FIX_LAT:.3f}**, lon **{RADAR_FIX_LON:.3f}**",
1398
+ f"- Turn completion (fixed): **18:40:00Z**",
1399
+ f"- Initial track (fixed): **{RADAR_INITIAL_TRACK_DEG:.1f}°**",
1400
+ f"- Speed (constrained): **{float(speed_knots):.1f} kt**",
1401
+ "",
1402
+ "**Computed turn model (uses first drifted heading at 18:40)**",
1403
+ f"- Bank angle: **{tinfo.get('bank_deg', TURN_BANK_DEFAULT_DEG):.1f}°**",
1404
+ f"- Heading change: **{tinfo.get('delta_deg', 0.0):+.1f}°**",
1405
+ f"- Straight-before-turn: **{tinfo.get('straight_min', 0.0):.2f} min**",
1406
+ f"- Turn duration: **{tinfo.get('turn_min', 0.0):.2f} min**",
1407
+ "",
1408
+ "**Southern schedule**",
1409
  f"- Legs: **{sched_txt}**",
 
1410
  f"- Nominal heading H₀: **{best_h0:.1f}°** (searched {GUIDED_HEADING_MIN:.0f}–{GUIDED_HEADING_MAX:.0f}°, step 1.0°)",
1411
  f"- Drift bounds: **±{float(drift_max_deg):.1f}°** | Drift penalty weight: **{float(drift_weight):.2f}**",
1412
  f"- Drift RMS: **{rms_drift:.2f}°**",
1413
  f"- Drift per leg (Δ): {drift_str}" if drift_str else "",
1414
+ "",
1415
+ "**Fit**",
1416
  f"- Weights: BFO **{float(weight_bfo):.2f}**, BTO **{float(weight_bto):.2f}**",
1417
  f"- BFO points: **{best_s['n_bfo']}** | RMSE_BFO: **{best_s['rmse_bfo']:.2f} Hz**",
1418
  f"- BTO points: **{best_s['n_bto']}** | RMSE_BTO: **{rbto_txt}**",
 
1461
  weight_bfo, weight_bto,
1462
  drift_max_deg, drift_weight
1463
  ):
1464
+ # Speed constrained to user-selected speed
1465
  spd_knots = speed_from_profile(speed_profile, alt_ft, custom_mach, custom_knots)
1466
 
1467
  if (guided_scenario or "").startswith("Straight + drift"):
1468
  legs, report_md, start_dt = solve_guided_straight_with_drift(
1469
+ method, spd_knots, alt_ft, vs_fpm,
1470
  descent_start_choice, weight_bfo, weight_bto,
1471
  drift_max_deg, drift_weight
1472
  )
 
 
1473
  else:
1474
  legs, report_md, start_dt = solve_guided_straight(
1475
+ method, spd_knots, alt_ft, vs_fpm,
1476
  descent_start_choice, weight_bfo, weight_bto
1477
  )
 
 
1478
 
1479
  if legs is None:
1480
  fig = go.Figure()
 
1488
  ], value=[])
1489
  return gr.update(), f"### ❌ {report_md}", "", "", fig, "", alt_fig, empty_table
1490
 
1491
+ # Guided ALWAYS starts at fixed radar spot
1492
  html_map, stats_md, bfo_fig, bfo_md, alt_fig, headers, rows = _run_common(
1493
+ RADAR_FIX_LAT, RADAR_FIX_LON, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice
1494
  )
1495
+
1496
  return gr.update(value=legs_to_rows(legs)), report_md, html_map, stats_md, bfo_fig, bfo_md, alt_fig, gr.update(headers=headers, value=rows)
1497
 
1498
 
 
1514
  "Make sure `Search areas.kmz` is uploaded and `SEARCH_AREAS_KMZ` matches exactly."
1515
  )
1516
 
1517
+ gr.Markdown(
1518
+ "### Fixed Radar + Turn Completion\n"
1519
+ f"- **Last radar spot (fixed):** 18:22:00Z @ **{RADAR_FIX_LAT:.3f}N, {RADAR_FIX_LON:.3f}E**\n"
1520
+ f"- **Turn completion (fixed):** 18:40:00Z\n"
1521
+ f"- **Initial track (fixed):** {RADAR_INITIAL_TRACK_DEG:.1f}°\n"
1522
+ f"- Guided scenarios compute a realistic turn between 18:22→18:40 and then the southern leg."
1523
+ )
1524
+
1525
  with gr.Row():
1526
+ start_lat = gr.Slider(-10, 15, value=6.76, label="Start Latitude (Manual only)")
1527
+ start_lon = gr.Slider(70, 115, value=95.69, label="Start Longitude (Manual only)")
1528
  method = gr.Dropdown(["VINCENTY", "RHUMB"], value="VINCENTY", label="Method")
1529
 
1530
  gr.Markdown("### BFO/BTO Settings")
1531
  with gr.Row():
1532
+ start_time_str = gr.Textbox(value="18:22:00Z", label="Simulation start time (UTC) — Manual mode")
1533
  alt_ft = gr.Number(value=35000, label="Altitude at start (ft)")
1534
  vs_fpm = gr.Number(value=0, label="Vertical speed (ft/min) (negative = descent)")
1535
  descent_start_choice = gr.Dropdown(
 
1551
  speed_profile = gr.Dropdown(
1552
  ["MRC Mach 0.82", "LRC Mach 0.84", "ECON Mach 0.80", "Custom Mach", "Custom knots"],
1553
  value="MRC Mach 0.82",
1554
+ label="Speed profile (constrains speed during turn + southern leg)",
1555
  )
1556
  custom_mach = gr.Number(value=0.82, label="Custom Mach (if selected)")
1557
  custom_knots = gr.Number(value=470, label="Custom knots (if selected)")