Chris773 commited on
Commit
860ed01
·
verified ·
1 Parent(s): af84f42

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +276 -167
app.py CHANGED
@@ -17,12 +17,18 @@
17
  # 2) Restore BFO plot (it disappeared previously)
18
  # - Re-added full BFO analysis computation + chart + MAE/RMSE markdown.
19
  #
20
- # NEW (THIS UPDATE):
21
  # 3) Plausible final major turn (18:22→18:40) can adjust BOTH heading and speed
22
  # - Turn speed is no longer fixed to the southern cruise speed.
23
  # - The solver searches a plausible TURN speed range and chooses the best-scoring solution.
24
  # - After 18:40, the southern trajectory continues “as per usual” using the selected speed profile.
25
  # - Applies to BOTH scenarios: Straight and Straight + drift.
 
 
 
 
 
 
26
 
27
  import os
28
  import re
@@ -113,27 +119,38 @@ TURN_BANK_MAX_DEG = 30.0
113
  TURN_BANK_DEFAULT_DEG = 25.0
114
  TURN_STEP_SEC = 30.0
115
 
116
- # ===== NEW: Turn-speed search parameters (turn only; southern uses speed profile) =====
117
  TURN_SPEED_SEARCH_DELTA_KT = 40.0 # search ± around the southern cruise speed
118
- TURN_SPEED_STEP_KT = 5.0 # candidate spacing
119
  TURN_SPEED_MIN_ABS_KT = 350.0 # hard min plausible
120
  TURN_SPEED_MAX_ABS_KT = 550.0 # hard max plausible
121
 
122
  # Guided heading search (south-ish)
123
  GUIDED_HEADING_MIN = 150.0
124
  GUIDED_HEADING_MAX = 210.0
125
- STRAIGHT_STEP_DEG = 0.5
126
 
127
  # Southern schedule (kept)
128
  GUIDED_LEG_SCHEDULE_MIN = [12.0, 60.0, 60.0, 60.0, 60.0, 90.0, 10.0]
129
 
130
  # Drift
131
  DEFAULT_DRIFT_MAX_DEG = 2.0
132
- DRIFT_STEP_DEG = 0.5
133
  DEFAULT_WEIGHT_DRIFT = 0.30
134
  DEFAULT_DRIFT_ITERS = 2
135
  DRIFT_SCALE_DEG = 1.0
136
 
 
 
 
 
 
 
 
 
 
 
 
137
  # Search area colors (optional)
138
  SEARCH_AREA_COLORS = {
139
  "Ocean Infinity": "#ff3b3b",
@@ -1151,7 +1168,7 @@ def speed_from_profile(profile: str, alt_ft: float, custom_mach: float, custom_k
1151
  return mach_to_tas_knots(0.82, alt_ft)
1152
 
1153
 
1154
- def _turn_speed_candidates(south_speed_knots: float):
1155
  """
1156
  Plausible turn-speed candidates around the southern cruise speed.
1157
  Hard-clipped to [TURN_SPEED_MIN_ABS_KT, TURN_SPEED_MAX_ABS_KT].
@@ -1159,7 +1176,7 @@ def _turn_speed_candidates(south_speed_knots: float):
1159
  base = float(south_speed_knots)
1160
  lo = max(TURN_SPEED_MIN_ABS_KT, base - float(TURN_SPEED_SEARCH_DELTA_KT))
1161
  hi = min(TURN_SPEED_MAX_ABS_KT, base + float(TURN_SPEED_SEARCH_DELTA_KT))
1162
- step = max(1.0, float(TURN_SPEED_STEP_KT))
1163
  if hi < lo:
1164
  lo, hi = hi, lo
1165
  cand = list(np.arange(lo, hi + 1e-9, step))
@@ -1170,6 +1187,28 @@ def _turn_speed_candidates(south_speed_knots: float):
1170
  return cand
1171
 
1172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1173
  # ================= Guided Legs Creation =================
1174
  def make_legs_from_schedule(headings, speed_knots, schedule_minutes):
1175
  legs = []
@@ -1183,6 +1222,86 @@ def make_legs_from_schedule(headings, speed_knots, schedule_minutes):
1183
  return legs
1184
 
1185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1186
  # ================= Guided Solvers =================
1187
  def _guided_start_dt():
1188
  return RADAR_FIX_TIME_UTC
@@ -1196,11 +1315,6 @@ def solve_guided_straight(
1196
  start_dt = _guided_start_dt()
1197
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1198
 
1199
- best_h = None
1200
- best_turn_spd = None
1201
- best_full_legs = None
1202
- best_score = None
1203
-
1204
  # descent start
1205
  descent_start_dt = None
1206
  if (descent_start_choice or "").startswith("After Ping Ring 6"):
@@ -1208,45 +1322,70 @@ def solve_guided_straight(
1208
  elif (descent_start_choice or "").startswith("After Ping Ring 7"):
1209
  descent_start_dt = dt_ping7_utc()
1210
 
1211
- # turn-speed candidates (turn only). Southern uses south_speed_knots.
1212
- turn_speed_candidates = _turn_speed_candidates(float(south_speed_knots))
1213
-
1214
- h = GUIDED_HEADING_MIN
1215
- while h <= GUIDED_HEADING_MAX + 1e-9:
1216
- # Southern cruise continues as usual (fixed by speed profile)
1217
- southern_legs = make_legs_from_schedule(h, float(south_speed_knots), GUIDED_LEG_SCHEDULE_MIN)
1218
-
1219
- # Search for plausible turn speed that best fits BFO/BTO
1220
- for spd_turn in turn_speed_candidates:
1221
- preturn_legs, _turn_info = build_turn_legs(
1222
- speed_knots=float(spd_turn),
1223
- method=method,
1224
- heading_final_deg=float(h),
1225
- )
1226
- full_legs = preturn_legs + southern_legs
1227
-
1228
- # clip to 00:20 so scoring window and plotting window are consistent
1229
- full_legs = clip_legs_to_end_dt(full_legs, start_dt, end_dt)
1230
-
1231
- # score using BFO/BTO inside start_dt→end_dt duration only
1232
- s = score_legs_bfo_bto(
1233
- RADAR_FIX_LAT, RADAR_FIX_LON, method, start_dt, end_dt, full_legs,
1234
- alt_ft, vs_fpm, descent_start_dt,
1235
- weight_bfo, weight_bto
1236
- )
 
 
 
 
 
 
 
 
 
1237
  if s is not None and (best_score is None or s["combined"] < best_score["combined"]):
1238
  best_score = s
1239
  best_h = float(h)
1240
  best_turn_spd = float(spd_turn)
1241
- best_full_legs = full_legs
1242
-
1243
- h += STRAIGHT_STEP_DEG
1244
 
1245
  if best_full_legs is None:
1246
  return None, "Guided solve: not enough ping points to score.", None
1247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1248
  report = "\n".join([
1249
  "**Guided mode: Straight + computed turn (18:22→18:40), clipped to 00:20**",
 
1250
  f"- Southern speed (speed profile): **{float(south_speed_knots):.1f} kt**",
1251
  f"- Best turn speed (18:22→18:40): **{float(best_turn_spd):.1f} kt**",
1252
  f"- Best southern heading: **{best_h:.1f}°**",
@@ -1263,15 +1402,9 @@ def solve_guided_straight_with_drift(
1263
  descent_start_choice, weight_bfo, weight_bto,
1264
  drift_max_deg, drift_weight
1265
  ):
1266
- # lightweight drift version: use nominal heading search, but add a small constant drift
1267
  start_dt = _guided_start_dt()
1268
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1269
 
1270
- best_h0 = None
1271
- best_turn_spd = None
1272
- best_full_legs = None
1273
- best_score = None
1274
-
1275
  # descent start
1276
  descent_start_dt = None
1277
  if (descent_start_choice or "").startswith("After Ping Ring 6"):
@@ -1279,64 +1412,113 @@ def solve_guided_straight_with_drift(
1279
  elif (descent_start_choice or "").startswith("After Ping Ring 7"):
1280
  descent_start_dt = dt_ping7_utc()
1281
 
1282
- # try drift candidates within ±drift_max_deg
1283
- drift_candidates = list(np.arange(-float(drift_max_deg), float(drift_max_deg) + 1e-9, DRIFT_STEP_DEG))
1284
- if not drift_candidates:
1285
- drift_candidates = [0.0]
1286
-
1287
- # turn-speed candidates (turn only). Southern uses south_speed_knots.
1288
- turn_speed_candidates = _turn_speed_candidates(float(south_speed_knots))
1289
-
1290
- h0 = GUIDED_HEADING_MIN
1291
- while h0 <= GUIDED_HEADING_MAX + 1e-9:
1292
- for d in drift_candidates:
1293
- h_eff = _wrap_heading_deg(float(h0) + float(d))
1294
-
1295
- # Southern cruise continues as usual (fixed by speed profile)
1296
- southern_legs = make_legs_from_schedule(h_eff, float(south_speed_knots), GUIDED_LEG_SCHEDULE_MIN)
1297
-
1298
- # Search turn speed candidates for best fit
1299
- for spd_turn in turn_speed_candidates:
1300
- preturn_legs, _turn_info = build_turn_legs(
1301
- speed_knots=float(spd_turn),
1302
- method=method,
1303
- heading_final_deg=float(h_eff),
1304
- )
1305
- full_legs = preturn_legs + southern_legs
1306
-
1307
- # clip to 00:20
1308
- full_legs = clip_legs_to_end_dt(full_legs, start_dt, end_dt)
1309
-
1310
- s = score_legs_bfo_bto(
1311
- RADAR_FIX_LAT, RADAR_FIX_LON, method, start_dt, end_dt, full_legs,
1312
- alt_ft, vs_fpm, descent_start_dt,
1313
- weight_bfo, weight_bto
1314
- )
1315
- if s is None:
1316
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1317
 
1318
- # add drift penalty (RMS here is just |d| for constant drift)
1319
- combined = float(s["combined"]) + float(drift_weight) * (abs(float(d)) / DRIFT_SCALE_DEG)
1320
 
1321
- if best_score is None or combined < best_score["combined_plus"]:
 
 
 
 
 
 
1322
  best_score = dict(s)
1323
- best_score["combined_plus"] = combined
1324
- best_score["drift_used"] = float(d)
1325
  best_h0 = float(h0)
 
1326
  best_turn_spd = float(spd_turn)
1327
- best_full_legs = full_legs
1328
-
1329
- h0 += 1.0
1330
 
1331
  if best_full_legs is None:
1332
  return None, "Guided drift solve: not enough ping points to score.", None
1333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1334
  report = "\n".join([
1335
  "**Guided mode: Straight + drift + computed turn (18:22→18:40), clipped to 00:20**",
 
1336
  f"- Southern speed (speed profile): **{float(south_speed_knots):.1f} kt**",
1337
  f"- Best turn speed (18:22→18:40): **{float(best_turn_spd):.1f} kt**",
1338
  f"- Best nominal heading: **{best_h0:.1f}°**",
1339
- f"- Drift used: **{best_score.get('drift_used', 0.0):+.1f}°** | Drift penalty weight: **{float(drift_weight):.2f}**",
1340
  f"- BFO points: **{best_score['n_bfo']}** | RMSE_BFO: **{best_score['rmse_bfo']:.2f} Hz**",
1341
  f"- BTO points: **{best_score['n_bto']}** | RMSE_BTO: **{best_score['rmse_bto']:.1f} µs**" if best_score.get("rmse_bto") is not None else
1342
  f"- BTO points: **{best_score['n_bto']}** | RMSE_BTO: n/a",
@@ -1345,80 +1527,6 @@ def solve_guided_straight_with_drift(
1345
  return best_full_legs, report, start_dt
1346
 
1347
 
1348
- # ================= Scoring helper (used in guided solvers) =================
1349
- def score_legs_bfo_bto(
1350
- start_lat, start_lon, method, start_dt, end_dt, legs,
1351
- alt_ft, vs_fpm, descent_start_dt,
1352
- weight_bfo: float, weight_bto: float
1353
- ):
1354
- pts = _ping_points_after(start_dt)
1355
- pts = [(lbl, dt, bfo, bto) for (lbl, dt, bfo, bto) in pts if start_dt <= dt <= end_dt]
1356
- if len(pts) < 2:
1357
- return None
1358
-
1359
- leg_secs = [float(m) * 60.0 for _, _, m in legs]
1360
- boundaries = np.cumsum([0.0] + leg_secs)
1361
- total_end = float(boundaries[-1])
1362
-
1363
- leg_start_positions = _precompute_leg_starts(start_lat, start_lon, legs, method)
1364
-
1365
- bfo_preds, bfo_meas = [], []
1366
- bto_preds, bto_meas = [], []
1367
-
1368
- for _lbl, dt_ping, meas_bfo, meas_bto in pts:
1369
- t_sec = (dt_ping - start_dt).total_seconds()
1370
- if t_sec < 0 or t_sec > total_end:
1371
- continue
1372
-
1373
- i = int(np.searchsorted(boundaries, t_sec, side="right") - 1)
1374
- i = max(0, min(i, len(legs) - 1))
1375
- t_inside = float(t_sec) - float(boundaries[i])
1376
-
1377
- leg_start_lat, leg_start_lon = leg_start_positions[i]
1378
- hdg, spd, _ = legs[i]
1379
- lat, lon = _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg, spd, t_inside)
1380
-
1381
- alt_now_ft = altitude_ft_at_dt(dt_ping, start_dt, float(alt_ft), float(vs_fpm), descent_start_dt)
1382
- alt_m = alt_now_ft * 0.3048
1383
-
1384
- vs_mps = float(vs_fpm) * 0.00508
1385
- if descent_start_dt is not None and dt_ping < descent_start_dt:
1386
- vs_mps = 0.0
1387
-
1388
- spd_mps = float(spd) * 1852.0 / 3600.0
1389
- pred_bfo = float(fp.calculateBFO_at_time(spd_mps, float(hdg), float(lat), float(lon), alt_m, vs_mps, dt_ping))
1390
- bfo_preds.append(pred_bfo)
1391
- bfo_meas.append(float(meas_bfo))
1392
-
1393
- if meas_bto is not None:
1394
- pred_bto = float(fp.calculateBTO_at_time(float(lat), float(lon), alt_m, dt_ping))
1395
- bto_preds.append(pred_bto)
1396
- bto_meas.append(float(meas_bto))
1397
-
1398
- if len(bfo_meas) < 2:
1399
- return None
1400
-
1401
- e_bfo = np.array(bfo_preds) - np.array(bfo_meas)
1402
- rmse_bfo = float(np.sqrt(np.mean(e_bfo * e_bfo)))
1403
-
1404
- rmse_bto = None
1405
- if len(bto_meas) >= 2:
1406
- e_bto = np.array(bto_preds) - np.array(bto_meas)
1407
- rmse_bto = float(np.sqrt(np.mean(e_bto * e_bto)))
1408
-
1409
- combined = float(weight_bfo) * (rmse_bfo / BFO_SCALE_HZ)
1410
- if rmse_bto is not None:
1411
- combined += float(weight_bto) * (rmse_bto / BTO_SCALE_US)
1412
-
1413
- return {
1414
- "n_bfo": len(bfo_meas),
1415
- "n_bto": len(bto_meas),
1416
- "rmse_bfo": rmse_bfo,
1417
- "rmse_bto": rmse_bto,
1418
- "combined": combined,
1419
- }
1420
-
1421
-
1422
  # ================= RUNNERS =================
1423
  def _run_common(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice):
1424
  # In guided mode, ensure the polyline ends at 00:20 by clipping legs to end_dt first.
@@ -1466,7 +1574,7 @@ def run_guided(
1466
  weight_bfo, weight_bto,
1467
  drift_max_deg, drift_weight
1468
  ):
1469
- # This is the SOUTHERN cruise speed (post-18:40). Turn speed is now searched independently.
1470
  south_spd_knots = speed_from_profile(speed_profile, alt_ft, custom_mach, custom_knots)
1471
 
1472
  if (guided_scenario or "").startswith("Straight + drift"):
@@ -1527,7 +1635,8 @@ with gr.Blocks() as demo:
1527
  f"- **Turn completion (fixed):** 18:40:00Z\n"
1528
  f"- **Initial track (fixed):** {RADAR_INITIAL_TRACK_DEG:.1f}°\n"
1529
  f"- **Final resting point:** **00:20:00Z** (trajectory clipped exactly at this time)\n\n"
1530
- f"**Turn behavior:** 18:22→18:40 turn is now solved with **adjustable turn speed** (southern speed still follows the selected speed profile)."
 
1531
  )
1532
 
1533
  with gr.Row():
 
17
  # 2) Restore BFO plot (it disappeared previously)
18
  # - Re-added full BFO analysis computation + chart + MAE/RMSE markdown.
19
  #
20
+ # NEW (PREVIOUS UPDATE):
21
  # 3) Plausible final major turn (18:22→18:40) can adjust BOTH heading and speed
22
  # - Turn speed is no longer fixed to the southern cruise speed.
23
  # - The solver searches a plausible TURN speed range and chooses the best-scoring solution.
24
  # - After 18:40, the southern trajectory continues “as per usual” using the selected speed profile.
25
  # - Applies to BOTH scenarios: Straight and Straight + drift.
26
+ #
27
+ # NEW (THIS UPDATE):
28
+ # 4) Two-stage guided search (COARSE → REFINE) to make generation much faster
29
+ # - Stage 1: coarse grid search to find the region of the best solution
30
+ # - Stage 2: refined grid around the best candidate only
31
+ # - Applies to BOTH scenarios: Straight and Straight + drift
32
 
33
  import os
34
  import re
 
119
  TURN_BANK_DEFAULT_DEG = 25.0
120
  TURN_STEP_SEC = 30.0
121
 
122
+ # Turn-speed search parameters (turn only; southern uses speed profile)
123
  TURN_SPEED_SEARCH_DELTA_KT = 40.0 # search ± around the southern cruise speed
124
+ TURN_SPEED_STEP_KT = 5.0 # refined candidate spacing
125
  TURN_SPEED_MIN_ABS_KT = 350.0 # hard min plausible
126
  TURN_SPEED_MAX_ABS_KT = 550.0 # hard max plausible
127
 
128
  # Guided heading search (south-ish)
129
  GUIDED_HEADING_MIN = 150.0
130
  GUIDED_HEADING_MAX = 210.0
131
+ STRAIGHT_STEP_DEG = 0.5 # refined step
132
 
133
  # Southern schedule (kept)
134
  GUIDED_LEG_SCHEDULE_MIN = [12.0, 60.0, 60.0, 60.0, 60.0, 90.0, 10.0]
135
 
136
  # Drift
137
  DEFAULT_DRIFT_MAX_DEG = 2.0
138
+ DRIFT_STEP_DEG = 0.5 # refined step
139
  DEFAULT_WEIGHT_DRIFT = 0.30
140
  DEFAULT_DRIFT_ITERS = 2
141
  DRIFT_SCALE_DEG = 1.0
142
 
143
+ # ===== NEW: Two-stage search knobs =====
144
+ # Stage 1 (coarse)
145
+ COARSE_HEADING_STEP_DEG = 2.0
146
+ COARSE_TURN_SPEED_STEP_KT = 10.0
147
+ COARSE_DRIFT_STEP_DEG = 1.0
148
+
149
+ # Stage 2 (refine around best)
150
+ REFINE_HEADING_HALF_WINDOW_DEG = 2.0 # +/- around best
151
+ REFINE_TURN_SPEED_HALF_WINDOW_KT = 10.0
152
+ REFINE_DRIFT_HALF_WINDOW_DEG = 1.0
153
+
154
  # Search area colors (optional)
155
  SEARCH_AREA_COLORS = {
156
  "Ocean Infinity": "#ff3b3b",
 
1168
  return mach_to_tas_knots(0.82, alt_ft)
1169
 
1170
 
1171
+ def _turn_speed_candidates(south_speed_knots: float, step_kt: float):
1172
  """
1173
  Plausible turn-speed candidates around the southern cruise speed.
1174
  Hard-clipped to [TURN_SPEED_MIN_ABS_KT, TURN_SPEED_MAX_ABS_KT].
 
1176
  base = float(south_speed_knots)
1177
  lo = max(TURN_SPEED_MIN_ABS_KT, base - float(TURN_SPEED_SEARCH_DELTA_KT))
1178
  hi = min(TURN_SPEED_MAX_ABS_KT, base + float(TURN_SPEED_SEARCH_DELTA_KT))
1179
+ step = max(1.0, float(step_kt))
1180
  if hi < lo:
1181
  lo, hi = hi, lo
1182
  cand = list(np.arange(lo, hi + 1e-9, step))
 
1187
  return cand
1188
 
1189
 
1190
+ def _clamp(x, lo, hi):
1191
+ return max(lo, min(hi, x))
1192
+
1193
+
1194
+ def _frange(lo: float, hi: float, step: float):
1195
+ # inclusive hi, stable float stepping
1196
+ step = float(step)
1197
+ if step <= 0:
1198
+ return []
1199
+ n = int(math.floor((hi - lo) / step + 1e-9)) + 1
1200
+ out = [lo + i * step for i in range(max(0, n))]
1201
+ # trim due to floating errors
1202
+ out = [x for x in out if x <= hi + 1e-9]
1203
+ return out
1204
+
1205
+
1206
+ def _refine_range(center: float, half_window: float, step: float, lo: float, hi: float):
1207
+ lo2 = _clamp(center - half_window, lo, hi)
1208
+ hi2 = _clamp(center + half_window, lo, hi)
1209
+ return _frange(lo2, hi2, step)
1210
+
1211
+
1212
  # ================= Guided Legs Creation =================
1213
  def make_legs_from_schedule(headings, speed_knots, schedule_minutes):
1214
  legs = []
 
1222
  return legs
1223
 
1224
 
1225
+ # ================= Scoring helper (used in guided solvers) =================
1226
+ def score_legs_bfo_bto(
1227
+ start_lat, start_lon, method, start_dt, end_dt, legs,
1228
+ alt_ft, vs_fpm, descent_start_dt,
1229
+ weight_bfo: float, weight_bto: float,
1230
+ pts_window=None
1231
+ ):
1232
+ # pts_window: optional precomputed list [(lbl, dt, meas_bfo, meas_bto), ...] already clipped to [start_dt, end_dt]
1233
+ if pts_window is None:
1234
+ pts = _ping_points_after(start_dt)
1235
+ pts = [(lbl, dt, bfo, bto) for (lbl, dt, bfo, bto) in pts if start_dt <= dt <= end_dt]
1236
+ else:
1237
+ pts = pts_window
1238
+
1239
+ if len(pts) < 2:
1240
+ return None
1241
+
1242
+ leg_secs = [float(m) * 60.0 for _, _, m in legs]
1243
+ boundaries = np.cumsum([0.0] + leg_secs)
1244
+ total_end = float(boundaries[-1])
1245
+
1246
+ leg_start_positions = _precompute_leg_starts(start_lat, start_lon, legs, method)
1247
+
1248
+ bfo_preds, bfo_meas = [], []
1249
+ bto_preds, bto_meas = [], []
1250
+
1251
+ for _lbl, dt_ping, meas_bfo, meas_bto in pts:
1252
+ t_sec = (dt_ping - start_dt).total_seconds()
1253
+ if t_sec < 0 or t_sec > total_end:
1254
+ continue
1255
+
1256
+ i = int(np.searchsorted(boundaries, t_sec, side="right") - 1)
1257
+ i = max(0, min(i, len(legs) - 1))
1258
+ t_inside = float(t_sec) - float(boundaries[i])
1259
+
1260
+ leg_start_lat, leg_start_lon = leg_start_positions[i]
1261
+ hdg, spd, _ = legs[i]
1262
+ lat, lon = _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg, spd, t_inside)
1263
+
1264
+ alt_now_ft = altitude_ft_at_dt(dt_ping, start_dt, float(alt_ft), float(vs_fpm), descent_start_dt)
1265
+ alt_m = alt_now_ft * 0.3048
1266
+
1267
+ vs_mps = float(vs_fpm) * 0.00508
1268
+ if descent_start_dt is not None and dt_ping < descent_start_dt:
1269
+ vs_mps = 0.0
1270
+
1271
+ spd_mps = float(spd) * 1852.0 / 3600.0
1272
+ pred_bfo = float(fp.calculateBFO_at_time(spd_mps, float(hdg), float(lat), float(lon), alt_m, vs_mps, dt_ping))
1273
+ bfo_preds.append(pred_bfo)
1274
+ bfo_meas.append(float(meas_bfo))
1275
+
1276
+ if meas_bto is not None:
1277
+ pred_bto = float(fp.calculateBTO_at_time(float(lat), float(lon), alt_m, dt_ping))
1278
+ bto_preds.append(pred_bto)
1279
+ bto_meas.append(float(meas_bto))
1280
+
1281
+ if len(bfo_meas) < 2:
1282
+ return None
1283
+
1284
+ e_bfo = np.array(bfo_preds) - np.array(bfo_meas)
1285
+ rmse_bfo = float(np.sqrt(np.mean(e_bfo * e_bfo)))
1286
+
1287
+ rmse_bto = None
1288
+ if len(bto_meas) >= 2:
1289
+ e_bto = np.array(bto_preds) - np.array(bto_meas)
1290
+ rmse_bto = float(np.sqrt(np.mean(e_bto * e_bto)))
1291
+
1292
+ combined = float(weight_bfo) * (rmse_bfo / BFO_SCALE_HZ)
1293
+ if rmse_bto is not None:
1294
+ combined += float(weight_bto) * (rmse_bto / BTO_SCALE_US)
1295
+
1296
+ return {
1297
+ "n_bfo": len(bfo_meas),
1298
+ "n_bto": len(bto_meas),
1299
+ "rmse_bfo": rmse_bfo,
1300
+ "rmse_bto": rmse_bto,
1301
+ "combined": combined,
1302
+ }
1303
+
1304
+
1305
  # ================= Guided Solvers =================
1306
  def _guided_start_dt():
1307
  return RADAR_FIX_TIME_UTC
 
1315
  start_dt = _guided_start_dt()
1316
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1317
 
 
 
 
 
 
1318
  # descent start
1319
  descent_start_dt = None
1320
  if (descent_start_choice or "").startswith("After Ping Ring 6"):
 
1322
  elif (descent_start_choice or "").startswith("After Ping Ring 7"):
1323
  descent_start_dt = dt_ping7_utc()
1324
 
1325
+ # Precompute ping points once (windowed)
1326
+ pts_all = _ping_points_after(start_dt)
1327
+ pts_window = [(lbl, dt, bfo, bto) for (lbl, dt, bfo, bto) in pts_all if start_dt <= dt <= end_dt]
1328
+ if len(pts_window) < 2:
1329
+ return None, "Guided solve: not enough ping points to score.", None
1330
+
1331
+ def try_candidate(h_south: float, spd_turn: float):
1332
+ southern_legs = make_legs_from_schedule(h_south, float(south_speed_knots), GUIDED_LEG_SCHEDULE_MIN)
1333
+ preturn_legs, _turn_info = build_turn_legs(
1334
+ speed_knots=float(spd_turn),
1335
+ method=method,
1336
+ heading_final_deg=float(h_south),
1337
+ )
1338
+ full_legs = preturn_legs + southern_legs
1339
+ full_legs = clip_legs_to_end_dt(full_legs, start_dt, end_dt)
1340
+ s = score_legs_bfo_bto(
1341
+ RADAR_FIX_LAT, RADAR_FIX_LON, method, start_dt, end_dt, full_legs,
1342
+ alt_ft, vs_fpm, descent_start_dt,
1343
+ weight_bfo, weight_bto,
1344
+ pts_window=pts_window
1345
+ )
1346
+ return s, full_legs
1347
+
1348
+ # -------- Stage 1: COARSE search --------
1349
+ best_h = None
1350
+ best_turn_spd = None
1351
+ best_full_legs = None
1352
+ best_score = None
1353
+
1354
+ coarse_headings = _frange(GUIDED_HEADING_MIN, GUIDED_HEADING_MAX, COARSE_HEADING_STEP_DEG)
1355
+ coarse_turn_speeds = _turn_speed_candidates(float(south_speed_knots), COARSE_TURN_SPEED_STEP_KT)
1356
+
1357
+ for h in coarse_headings:
1358
+ for spd_turn in coarse_turn_speeds:
1359
+ s, legs = try_candidate(h, spd_turn)
1360
  if s is not None and (best_score is None or s["combined"] < best_score["combined"]):
1361
  best_score = s
1362
  best_h = float(h)
1363
  best_turn_spd = float(spd_turn)
1364
+ best_full_legs = legs
 
 
1365
 
1366
  if best_full_legs is None:
1367
  return None, "Guided solve: not enough ping points to score.", None
1368
 
1369
+ # -------- Stage 2: REFINE around best --------
1370
+ refine_headings = _refine_range(best_h, REFINE_HEADING_HALF_WINDOW_DEG, STRAIGHT_STEP_DEG, GUIDED_HEADING_MIN, GUIDED_HEADING_MAX)
1371
+ refine_turn_speeds = _refine_range(
1372
+ best_turn_spd, REFINE_TURN_SPEED_HALF_WINDOW_KT, TURN_SPEED_STEP_KT,
1373
+ max(TURN_SPEED_MIN_ABS_KT, float(south_speed_knots) - TURN_SPEED_SEARCH_DELTA_KT),
1374
+ min(TURN_SPEED_MAX_ABS_KT, float(south_speed_knots) + TURN_SPEED_SEARCH_DELTA_KT),
1375
+ )
1376
+
1377
+ for h in refine_headings:
1378
+ for spd_turn in refine_turn_speeds:
1379
+ s, legs = try_candidate(h, spd_turn)
1380
+ if s is not None and (best_score is None or s["combined"] < best_score["combined"]):
1381
+ best_score = s
1382
+ best_h = float(h)
1383
+ best_turn_spd = float(spd_turn)
1384
+ best_full_legs = legs
1385
+
1386
  report = "\n".join([
1387
  "**Guided mode: Straight + computed turn (18:22→18:40), clipped to 00:20**",
1388
+ f"- Search mode: **two-stage (coarse → refine)**",
1389
  f"- Southern speed (speed profile): **{float(south_speed_knots):.1f} kt**",
1390
  f"- Best turn speed (18:22→18:40): **{float(best_turn_spd):.1f} kt**",
1391
  f"- Best southern heading: **{best_h:.1f}°**",
 
1402
  descent_start_choice, weight_bfo, weight_bto,
1403
  drift_max_deg, drift_weight
1404
  ):
 
1405
  start_dt = _guided_start_dt()
1406
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1407
 
 
 
 
 
 
1408
  # descent start
1409
  descent_start_dt = None
1410
  if (descent_start_choice or "").startswith("After Ping Ring 6"):
 
1412
  elif (descent_start_choice or "").startswith("After Ping Ring 7"):
1413
  descent_start_dt = dt_ping7_utc()
1414
 
1415
+ # Precompute ping points once (windowed)
1416
+ pts_all = _ping_points_after(start_dt)
1417
+ pts_window = [(lbl, dt, bfo, bto) for (lbl, dt, bfo, bto) in pts_all if start_dt <= dt <= end_dt]
1418
+ if len(pts_window) < 2:
1419
+ return None, "Guided drift solve: not enough ping points to score.", None
1420
+
1421
+ drift_max_deg = float(drift_max_deg)
1422
+
1423
+ def try_candidate(h0_nominal: float, drift_deg: float, spd_turn: float):
1424
+ h_eff = _wrap_heading_deg(float(h0_nominal) + float(drift_deg))
1425
+
1426
+ southern_legs = make_legs_from_schedule(h_eff, float(south_speed_knots), GUIDED_LEG_SCHEDULE_MIN)
1427
+ preturn_legs, _turn_info = build_turn_legs(
1428
+ speed_knots=float(spd_turn),
1429
+ method=method,
1430
+ heading_final_deg=float(h_eff),
1431
+ )
1432
+ full_legs = preturn_legs + southern_legs
1433
+ full_legs = clip_legs_to_end_dt(full_legs, start_dt, end_dt)
1434
+
1435
+ s = score_legs_bfo_bto(
1436
+ RADAR_FIX_LAT, RADAR_FIX_LON, method, start_dt, end_dt, full_legs,
1437
+ alt_ft, vs_fpm, descent_start_dt,
1438
+ weight_bfo, weight_bto,
1439
+ pts_window=pts_window
1440
+ )
1441
+ if s is None:
1442
+ return None, None, None
1443
+
1444
+ combined_plus = float(s["combined"]) + float(drift_weight) * (abs(float(drift_deg)) / DRIFT_SCALE_DEG)
1445
+ return s, combined_plus, full_legs
1446
+
1447
+ # -------- Stage 1: COARSE search --------
1448
+ best_h0 = None
1449
+ best_d = None
1450
+ best_turn_spd = None
1451
+ best_full_legs = None
1452
+ best_score = None
1453
+ best_combined_plus = None
1454
+
1455
+ coarse_headings = _frange(GUIDED_HEADING_MIN, GUIDED_HEADING_MAX, COARSE_HEADING_STEP_DEG)
1456
+
1457
+ # Drift candidates (coarse)
1458
+ if drift_max_deg <= 1e-9:
1459
+ coarse_drifts = [0.0]
1460
+ else:
1461
+ coarse_drifts = list(np.arange(-drift_max_deg, drift_max_deg + 1e-9, COARSE_DRIFT_STEP_DEG))
1462
+ if 0.0 not in coarse_drifts:
1463
+ coarse_drifts.append(0.0)
1464
+ coarse_drifts = sorted({round(float(x), 6) for x in coarse_drifts})
1465
 
1466
+ coarse_turn_speeds = _turn_speed_candidates(float(south_speed_knots), COARSE_TURN_SPEED_STEP_KT)
 
1467
 
1468
+ for h0 in coarse_headings:
1469
+ for d in coarse_drifts:
1470
+ for spd_turn in coarse_turn_speeds:
1471
+ s, combined_plus, legs = try_candidate(h0, d, spd_turn)
1472
+ if s is None:
1473
+ continue
1474
+ if best_combined_plus is None or float(combined_plus) < float(best_combined_plus):
1475
  best_score = dict(s)
1476
+ best_combined_plus = float(combined_plus)
 
1477
  best_h0 = float(h0)
1478
+ best_d = float(d)
1479
  best_turn_spd = float(spd_turn)
1480
+ best_full_legs = legs
 
 
1481
 
1482
  if best_full_legs is None:
1483
  return None, "Guided drift solve: not enough ping points to score.", None
1484
 
1485
+ # -------- Stage 2: REFINE around best --------
1486
+ refine_headings = _refine_range(best_h0, REFINE_HEADING_HALF_WINDOW_DEG, 1.0, GUIDED_HEADING_MIN, GUIDED_HEADING_MAX)
1487
+
1488
+ # Refine drift around best_d with DRIFT_STEP_DEG (0.5)
1489
+ d_lo = max(-drift_max_deg, best_d - REFINE_DRIFT_HALF_WINDOW_DEG)
1490
+ d_hi = min(+drift_max_deg, best_d + REFINE_DRIFT_HALF_WINDOW_DEG)
1491
+ refine_drifts = _frange(d_lo, d_hi, DRIFT_STEP_DEG)
1492
+ if not refine_drifts:
1493
+ refine_drifts = [best_d]
1494
+
1495
+ refine_turn_speeds = _refine_range(
1496
+ best_turn_spd, REFINE_TURN_SPEED_HALF_WINDOW_KT, TURN_SPEED_STEP_KT,
1497
+ max(TURN_SPEED_MIN_ABS_KT, float(south_speed_knots) - TURN_SPEED_SEARCH_DELTA_KT),
1498
+ min(TURN_SPEED_MAX_ABS_KT, float(south_speed_knots) + TURN_SPEED_SEARCH_DELTA_KT),
1499
+ )
1500
+
1501
+ for h0 in refine_headings:
1502
+ for d in refine_drifts:
1503
+ for spd_turn in refine_turn_speeds:
1504
+ s, combined_plus, legs = try_candidate(h0, d, spd_turn)
1505
+ if s is None:
1506
+ continue
1507
+ if best_combined_plus is None or float(combined_plus) < float(best_combined_plus):
1508
+ best_score = dict(s)
1509
+ best_combined_plus = float(combined_plus)
1510
+ best_h0 = float(h0)
1511
+ best_d = float(d)
1512
+ best_turn_spd = float(spd_turn)
1513
+ best_full_legs = legs
1514
+
1515
  report = "\n".join([
1516
  "**Guided mode: Straight + drift + computed turn (18:22→18:40), clipped to 00:20**",
1517
+ f"- Search mode: **two-stage (coarse → refine)**",
1518
  f"- Southern speed (speed profile): **{float(south_speed_knots):.1f} kt**",
1519
  f"- Best turn speed (18:22→18:40): **{float(best_turn_spd):.1f} kt**",
1520
  f"- Best nominal heading: **{best_h0:.1f}°**",
1521
+ f"- Drift used: **{best_d:+.1f}°** | Drift penalty weight: **{float(drift_weight):.2f}**",
1522
  f"- BFO points: **{best_score['n_bfo']}** | RMSE_BFO: **{best_score['rmse_bfo']:.2f} Hz**",
1523
  f"- BTO points: **{best_score['n_bto']}** | RMSE_BTO: **{best_score['rmse_bto']:.1f} µs**" if best_score.get("rmse_bto") is not None else
1524
  f"- BTO points: **{best_score['n_bto']}** | RMSE_BTO: n/a",
 
1527
  return best_full_legs, report, start_dt
1528
 
1529
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1530
  # ================= RUNNERS =================
1531
  def _run_common(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice):
1532
  # In guided mode, ensure the polyline ends at 00:20 by clipping legs to end_dt first.
 
1574
  weight_bfo, weight_bto,
1575
  drift_max_deg, drift_weight
1576
  ):
1577
+ # This is the SOUTHERN cruise speed (post-18:40). Turn speed is searched independently.
1578
  south_spd_knots = speed_from_profile(speed_profile, alt_ft, custom_mach, custom_knots)
1579
 
1580
  if (guided_scenario or "").startswith("Straight + drift"):
 
1635
  f"- **Turn completion (fixed):** 18:40:00Z\n"
1636
  f"- **Initial track (fixed):** {RADAR_INITIAL_TRACK_DEG:.1f}°\n"
1637
  f"- **Final resting point:** **00:20:00Z** (trajectory clipped exactly at this time)\n\n"
1638
+ f"**Turn behavior:** 18:22→18:40 turn is solved with **adjustable turn speed**.\n"
1639
+ f"**Performance:** guided solver uses **two-stage search (coarse → refine)** to run faster."
1640
  )
1641
 
1642
  with gr.Row():