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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +140 -93
app.py CHANGED
@@ -17,18 +17,22 @@
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,12 +123,20 @@ TURN_BANK_MAX_DEG = 30.0
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
@@ -140,7 +152,7 @@ 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
@@ -1132,7 +1144,7 @@ def build_leaflet_map(path_pts, legs, method, start_dt):
1132
  """
1133
 
1134
 
1135
- # ================= Guided Speed Profile =================
1136
  def isa_speed_of_sound_mps(alt_m: float) -> float:
1137
  T0 = 288.15
1138
  L = -0.0065
@@ -1170,7 +1182,7 @@ def speed_from_profile(profile: str, alt_ft: float, custom_mach: float, custom_k
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].
1175
  """
1176
  base = float(south_speed_knots)
@@ -1180,7 +1192,24 @@ def _turn_speed_candidates(south_speed_knots: float, step_kt: float):
1180
  if hi < lo:
1181
  lo, hi = hi, lo
1182
  cand = list(np.arange(lo, hi + 1e-9, step))
1183
- # Ensure base is included
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1184
  if all(abs(c - base) > 1e-9 for c in cand):
1185
  cand.append(base)
1186
  cand = sorted({round(float(c), 6) for c in cand})
@@ -1192,13 +1221,11 @@ def _clamp(x, lo, hi):
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
 
@@ -1229,7 +1256,6 @@ def score_legs_bfo_bto(
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]
@@ -1308,28 +1334,25 @@ def _guided_start_dt():
1308
 
1309
 
1310
  def solve_guided_straight(
1311
- method, south_speed_knots, alt_ft, vs_fpm,
1312
  descent_start_choice, weight_bfo, weight_bto
1313
  ):
1314
- # We clip everything to final 00:20Z; end_dt is fixed by label.
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"):
1321
  descent_start_dt = dt_ping6_utc()
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,
@@ -1337,6 +1360,7 @@ def solve_guided_straight(
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,
@@ -1345,48 +1369,67 @@ def solve_guided_straight(
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}°**",
1392
  f"- BFO points: **{best_score['n_bfo']}** | RMSE_BFO: **{best_score['rmse_bfo']:.2f} Hz**",
@@ -1398,21 +1441,19 @@ def solve_guided_straight(
1398
 
1399
 
1400
  def solve_guided_straight_with_drift(
1401
- method, south_speed_knots, alt_ft, vs_fpm,
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"):
1411
  descent_start_dt = dt_ping6_utc()
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:
@@ -1420,10 +1461,10 @@ def solve_guided_straight_with_drift(
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,
@@ -1447,6 +1488,7 @@ def solve_guided_straight_with_drift(
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
@@ -1454,7 +1496,6 @@ def solve_guided_straight_with_drift(
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:
@@ -1463,21 +1504,24 @@ def solve_guided_straight_with_drift(
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
@@ -1485,37 +1529,42 @@ def solve_guided_straight_with_drift(
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}**",
@@ -1529,7 +1578,6 @@ def solve_guided_straight_with_drift(
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.
1533
  if start_dt == RADAR_FIX_TIME_UTC:
1534
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1535
  legs = clip_legs_to_end_dt(legs, start_dt, end_dt)
@@ -1539,7 +1587,6 @@ def _run_common(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, de
1539
  html_map = build_leaflet_map(path_pts, legs, method, start_dt)
1540
  stats_md = compute_stats(path_pts, seg_dists_m, total_time_min, legs)
1541
 
1542
- # BFO plot (restored)
1543
  bfo_fig, bfo_md = bfo_analysis_with_legs(
1544
  start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice
1545
  )
@@ -1574,19 +1621,19 @@ def run_guided(
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"):
1581
  legs, report_md, start_dt = solve_guided_straight_with_drift(
1582
- method, south_spd_knots,
1583
  alt_ft, vs_fpm,
1584
  descent_start_choice, weight_bfo, weight_bto,
1585
  drift_max_deg, drift_weight
1586
  )
1587
  else:
1588
  legs, report_md, start_dt = solve_guided_straight(
1589
- method, south_spd_knots,
1590
  alt_ft, vs_fpm,
1591
  descent_start_choice, weight_bfo, weight_bto
1592
  )
@@ -1603,7 +1650,6 @@ def run_guided(
1603
  ], value=[])
1604
  return gr.update(), f"### ❌ {report_md}", "", "", fig, "", alt_fig, empty_table
1605
 
1606
- # Guided always starts at radar fix
1607
  html_map, stats_md, bfo_fig, bfo_md, alt_fig, headers, rows = _run_common(
1608
  RADAR_FIX_LAT, RADAR_FIX_LON, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice
1609
  )
@@ -1636,7 +1682,8 @@ with gr.Blocks() as demo:
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():
@@ -1668,7 +1715,7 @@ with gr.Blocks() as demo:
1668
  speed_profile = gr.Dropdown(
1669
  ["MRC Mach 0.82", "LRC Mach 0.84", "ECON Mach 0.80", "Custom Mach", "Custom knots"],
1670
  value="MRC Mach 0.82",
1671
- label="Speed profile (sets SOUTHERN speed after 18:40)",
1672
  )
1673
  custom_mach = gr.Number(value=0.82, label="Custom Mach (if selected)")
1674
  custom_knots = gr.Number(value=470, label="Custom knots (if selected)")
 
17
  # 2) Restore BFO plot (it disappeared previously)
18
  # - Re-added full BFO analysis computation + chart + MAE/RMSE markdown.
19
  #
20
+ # 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
  #
25
+ # PREVIOUS UPDATE:
26
  # 4) Two-stage guided search (COARSE → REFINE) to make generation much faster
27
+ #
28
+ # NEW (THIS UPDATE):
29
+ # 5) Southern trajectory speed is NO LONGER locked to a fixed speed profile.
30
+ # - The guided solver now searches BOTH:
31
+ # - southern heading
32
+ # - southern speed (free variable)
33
+ # - turn speed (18:22→18:40)
34
+ # to best match BFO + BTO (with your weights).
35
+ # - “Speed profile” in UI becomes an INITIAL GUESS (center of the search window).
36
 
37
  import os
38
  import re
 
123
  TURN_BANK_DEFAULT_DEG = 25.0
124
  TURN_STEP_SEC = 30.0
125
 
126
+ # Turn-speed search parameters (turn only)
127
+ TURN_SPEED_SEARCH_DELTA_KT = 40.0 # search ± around the candidate southern speed
128
  TURN_SPEED_STEP_KT = 5.0 # refined candidate spacing
129
  TURN_SPEED_MIN_ABS_KT = 350.0 # hard min plausible
130
  TURN_SPEED_MAX_ABS_KT = 550.0 # hard max plausible
131
 
132
+ # Southern-speed search parameters (NEW: southern speed is free)
133
+ SOUTH_SPEED_SEARCH_DELTA_KT = 80.0 # search ± around the UI "speed profile" initial guess
134
+ SOUTH_SPEED_MIN_ABS_KT = 330.0 # hard min plausible
135
+ SOUTH_SPEED_MAX_ABS_KT = 530.0 # hard max plausible
136
+ SOUTH_SPEED_STEP_KT = 2.5 # refined step
137
+ COARSE_SOUTH_SPEED_STEP_KT = 10.0 # coarse step
138
+ REFINE_SOUTH_SPEED_HALF_WINDOW_KT = 12.5 # refine window around best
139
+
140
  # Guided heading search (south-ish)
141
  GUIDED_HEADING_MIN = 150.0
142
  GUIDED_HEADING_MAX = 210.0
 
152
  DEFAULT_DRIFT_ITERS = 2
153
  DRIFT_SCALE_DEG = 1.0
154
 
155
+ # ===== Two-stage search knobs =====
156
  # Stage 1 (coarse)
157
  COARSE_HEADING_STEP_DEG = 2.0
158
  COARSE_TURN_SPEED_STEP_KT = 10.0
 
1144
  """
1145
 
1146
 
1147
+ # ================= Guided Speed Profile (UI initial guess) =================
1148
  def isa_speed_of_sound_mps(alt_m: float) -> float:
1149
  T0 = 288.15
1150
  L = -0.0065
 
1182
 
1183
  def _turn_speed_candidates(south_speed_knots: float, step_kt: float):
1184
  """
1185
+ Plausible turn-speed candidates around the candidate southern speed.
1186
  Hard-clipped to [TURN_SPEED_MIN_ABS_KT, TURN_SPEED_MAX_ABS_KT].
1187
  """
1188
  base = float(south_speed_knots)
 
1192
  if hi < lo:
1193
  lo, hi = hi, lo
1194
  cand = list(np.arange(lo, hi + 1e-9, step))
1195
+ if all(abs(c - base) > 1e-9 for c in cand):
1196
+ cand.append(base)
1197
+ cand = sorted({round(float(c), 6) for c in cand})
1198
+ return cand
1199
+
1200
+
1201
+ def _south_speed_candidates(base_guess_knots: float, step_kt: float):
1202
+ """
1203
+ Southern speed candidates around the UI profile-derived guess.
1204
+ Hard-clipped to [SOUTH_SPEED_MIN_ABS_KT, SOUTH_SPEED_MAX_ABS_KT].
1205
+ """
1206
+ base = float(base_guess_knots)
1207
+ lo = max(SOUTH_SPEED_MIN_ABS_KT, base - float(SOUTH_SPEED_SEARCH_DELTA_KT))
1208
+ hi = min(SOUTH_SPEED_MAX_ABS_KT, base + float(SOUTH_SPEED_SEARCH_DELTA_KT))
1209
+ step = max(1.0, float(step_kt))
1210
+ if hi < lo:
1211
+ lo, hi = hi, lo
1212
+ cand = list(np.arange(lo, hi + 1e-9, step))
1213
  if all(abs(c - base) > 1e-9 for c in cand):
1214
  cand.append(base)
1215
  cand = sorted({round(float(c), 6) for c in cand})
 
1221
 
1222
 
1223
  def _frange(lo: float, hi: float, step: float):
 
1224
  step = float(step)
1225
  if step <= 0:
1226
  return []
1227
  n = int(math.floor((hi - lo) / step + 1e-9)) + 1
1228
  out = [lo + i * step for i in range(max(0, n))]
 
1229
  out = [x for x in out if x <= hi + 1e-9]
1230
  return out
1231
 
 
1256
  weight_bfo: float, weight_bto: float,
1257
  pts_window=None
1258
  ):
 
1259
  if pts_window is None:
1260
  pts = _ping_points_after(start_dt)
1261
  pts = [(lbl, dt, bfo, bto) for (lbl, dt, bfo, bto) in pts if start_dt <= dt <= end_dt]
 
1334
 
1335
 
1336
  def solve_guided_straight(
1337
+ method, south_speed_guess_knots, alt_ft, vs_fpm,
1338
  descent_start_choice, weight_bfo, weight_bto
1339
  ):
 
1340
  start_dt = _guided_start_dt()
1341
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1342
 
 
1343
  descent_start_dt = None
1344
  if (descent_start_choice or "").startswith("After Ping Ring 6"):
1345
  descent_start_dt = dt_ping6_utc()
1346
  elif (descent_start_choice or "").startswith("After Ping Ring 7"):
1347
  descent_start_dt = dt_ping7_utc()
1348
 
 
1349
  pts_all = _ping_points_after(start_dt)
1350
  pts_window = [(lbl, dt, bfo, bto) for (lbl, dt, bfo, bto) in pts_all if start_dt <= dt <= end_dt]
1351
  if len(pts_window) < 2:
1352
  return None, "Guided solve: not enough ping points to score.", None
1353
 
1354
+ def try_candidate(h_south: float, spd_south: float, spd_turn: float):
1355
+ southern_legs = make_legs_from_schedule(h_south, float(spd_south), GUIDED_LEG_SCHEDULE_MIN)
1356
  preturn_legs, _turn_info = build_turn_legs(
1357
  speed_knots=float(spd_turn),
1358
  method=method,
 
1360
  )
1361
  full_legs = preturn_legs + southern_legs
1362
  full_legs = clip_legs_to_end_dt(full_legs, start_dt, end_dt)
1363
+
1364
  s = score_legs_bfo_bto(
1365
  RADAR_FIX_LAT, RADAR_FIX_LON, method, start_dt, end_dt, full_legs,
1366
  alt_ft, vs_fpm, descent_start_dt,
 
1369
  )
1370
  return s, full_legs
1371
 
1372
+ # -------- Stage 1: COARSE search (heading + south speed + turn speed) --------
1373
  best_h = None
1374
+ best_south_spd = None
1375
  best_turn_spd = None
1376
  best_full_legs = None
1377
  best_score = None
1378
 
1379
  coarse_headings = _frange(GUIDED_HEADING_MIN, GUIDED_HEADING_MAX, COARSE_HEADING_STEP_DEG)
1380
+ coarse_south_speeds = _south_speed_candidates(float(south_speed_guess_knots), COARSE_SOUTH_SPEED_STEP_KT)
1381
 
1382
  for h in coarse_headings:
1383
+ for spd_south in coarse_south_speeds:
1384
+ coarse_turn_speeds = _turn_speed_candidates(float(spd_south), COARSE_TURN_SPEED_STEP_KT)
1385
+ for spd_turn in coarse_turn_speeds:
1386
+ s, legs = try_candidate(h, spd_south, spd_turn)
1387
+ if s is not None and (best_score is None or s["combined"] < best_score["combined"]):
1388
+ best_score = s
1389
+ best_h = float(h)
1390
+ best_south_spd = float(spd_south)
1391
+ best_turn_spd = float(spd_turn)
1392
+ best_full_legs = legs
1393
 
1394
  if best_full_legs is None:
1395
  return None, "Guided solve: not enough ping points to score.", None
1396
 
1397
  # -------- Stage 2: REFINE around best --------
1398
+ refine_headings = _refine_range(
1399
+ best_h, REFINE_HEADING_HALF_WINDOW_DEG, STRAIGHT_STEP_DEG, GUIDED_HEADING_MIN, GUIDED_HEADING_MAX
1400
+ )
1401
+
1402
+ refine_south_speeds = _refine_range(
1403
+ best_south_spd, REFINE_SOUTH_SPEED_HALF_WINDOW_KT, SOUTH_SPEED_STEP_KT,
1404
+ max(SOUTH_SPEED_MIN_ABS_KT, float(south_speed_guess_knots) - SOUTH_SPEED_SEARCH_DELTA_KT),
1405
+ min(SOUTH_SPEED_MAX_ABS_KT, float(south_speed_guess_knots) + SOUTH_SPEED_SEARCH_DELTA_KT),
1406
  )
1407
+ if not refine_south_speeds:
1408
+ refine_south_speeds = [best_south_spd]
1409
 
1410
  for h in refine_headings:
1411
+ for spd_south in refine_south_speeds:
1412
+ refine_turn_speeds = _refine_range(
1413
+ best_turn_spd, REFINE_TURN_SPEED_HALF_WINDOW_KT, TURN_SPEED_STEP_KT,
1414
+ max(TURN_SPEED_MIN_ABS_KT, float(spd_south) - TURN_SPEED_SEARCH_DELTA_KT),
1415
+ min(TURN_SPEED_MAX_ABS_KT, float(spd_south) + TURN_SPEED_SEARCH_DELTA_KT),
1416
+ )
1417
+ if not refine_turn_speeds:
1418
+ refine_turn_speeds = [best_turn_spd]
1419
+
1420
+ for spd_turn in refine_turn_speeds:
1421
+ s, legs = try_candidate(h, spd_south, spd_turn)
1422
+ if s is not None and (best_score is None or s["combined"] < best_score["combined"]):
1423
+ best_score = s
1424
+ best_h = float(h)
1425
+ best_south_spd = float(spd_south)
1426
+ best_turn_spd = float(spd_turn)
1427
+ best_full_legs = legs
1428
 
1429
  report = "\n".join([
1430
  "**Guided mode: Straight + computed turn (18:22→18:40), clipped to 00:20**",
1431
  f"- Search mode: **two-stage (coarse → refine)**",
1432
+ f"- Southern speed (solved): **{float(best_south_spd):.1f} kt** (UI guess was ~{float(south_speed_guess_knots):.1f} kt)",
1433
  f"- Best turn speed (18:22→18:40): **{float(best_turn_spd):.1f} kt**",
1434
  f"- Best southern heading: **{best_h:.1f}°**",
1435
  f"- BFO points: **{best_score['n_bfo']}** | RMSE_BFO: **{best_score['rmse_bfo']:.2f} Hz**",
 
1441
 
1442
 
1443
  def solve_guided_straight_with_drift(
1444
+ method, south_speed_guess_knots, alt_ft, vs_fpm,
1445
  descent_start_choice, weight_bfo, weight_bto,
1446
  drift_max_deg, drift_weight
1447
  ):
1448
  start_dt = _guided_start_dt()
1449
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1450
 
 
1451
  descent_start_dt = None
1452
  if (descent_start_choice or "").startswith("After Ping Ring 6"):
1453
  descent_start_dt = dt_ping6_utc()
1454
  elif (descent_start_choice or "").startswith("After Ping Ring 7"):
1455
  descent_start_dt = dt_ping7_utc()
1456
 
 
1457
  pts_all = _ping_points_after(start_dt)
1458
  pts_window = [(lbl, dt, bfo, bto) for (lbl, dt, bfo, bto) in pts_all if start_dt <= dt <= end_dt]
1459
  if len(pts_window) < 2:
 
1461
 
1462
  drift_max_deg = float(drift_max_deg)
1463
 
1464
+ def try_candidate(h0_nominal: float, drift_deg: float, spd_south: float, spd_turn: float):
1465
  h_eff = _wrap_heading_deg(float(h0_nominal) + float(drift_deg))
1466
 
1467
+ southern_legs = make_legs_from_schedule(h_eff, float(spd_south), GUIDED_LEG_SCHEDULE_MIN)
1468
  preturn_legs, _turn_info = build_turn_legs(
1469
  speed_knots=float(spd_turn),
1470
  method=method,
 
1488
  # -------- Stage 1: COARSE search --------
1489
  best_h0 = None
1490
  best_d = None
1491
+ best_south_spd = None
1492
  best_turn_spd = None
1493
  best_full_legs = None
1494
  best_score = None
 
1496
 
1497
  coarse_headings = _frange(GUIDED_HEADING_MIN, GUIDED_HEADING_MAX, COARSE_HEADING_STEP_DEG)
1498
 
 
1499
  if drift_max_deg <= 1e-9:
1500
  coarse_drifts = [0.0]
1501
  else:
 
1504
  coarse_drifts.append(0.0)
1505
  coarse_drifts = sorted({round(float(x), 6) for x in coarse_drifts})
1506
 
1507
+ coarse_south_speeds = _south_speed_candidates(float(south_speed_guess_knots), COARSE_SOUTH_SPEED_STEP_KT)
1508
 
1509
  for h0 in coarse_headings:
1510
  for d in coarse_drifts:
1511
+ for spd_south in coarse_south_speeds:
1512
+ coarse_turn_speeds = _turn_speed_candidates(float(spd_south), COARSE_TURN_SPEED_STEP_KT)
1513
+ for spd_turn in coarse_turn_speeds:
1514
+ s, combined_plus, legs = try_candidate(h0, d, spd_south, spd_turn)
1515
+ if s is None:
1516
+ continue
1517
+ if best_combined_plus is None or float(combined_plus) < float(best_combined_plus):
1518
+ best_score = dict(s)
1519
+ best_combined_plus = float(combined_plus)
1520
+ best_h0 = float(h0)
1521
+ best_d = float(d)
1522
+ best_south_spd = float(spd_south)
1523
+ best_turn_spd = float(spd_turn)
1524
+ best_full_legs = legs
1525
 
1526
  if best_full_legs is None:
1527
  return None, "Guided drift solve: not enough ping points to score.", None
 
1529
  # -------- Stage 2: REFINE around best --------
1530
  refine_headings = _refine_range(best_h0, REFINE_HEADING_HALF_WINDOW_DEG, 1.0, GUIDED_HEADING_MIN, GUIDED_HEADING_MAX)
1531
 
 
1532
  d_lo = max(-drift_max_deg, best_d - REFINE_DRIFT_HALF_WINDOW_DEG)
1533
  d_hi = min(+drift_max_deg, best_d + REFINE_DRIFT_HALF_WINDOW_DEG)
1534
+ refine_drifts = _frange(d_lo, d_hi, DRIFT_STEP_DEG) or [best_d]
1535
+
1536
+ refine_south_speeds = _refine_range(
1537
+ best_south_spd, REFINE_SOUTH_SPEED_HALF_WINDOW_KT, SOUTH_SPEED_STEP_KT,
1538
+ max(SOUTH_SPEED_MIN_ABS_KT, float(south_speed_guess_knots) - SOUTH_SPEED_SEARCH_DELTA_KT),
1539
+ min(SOUTH_SPEED_MAX_ABS_KT, float(south_speed_guess_knots) + SOUTH_SPEED_SEARCH_DELTA_KT),
1540
+ ) or [best_south_spd]
 
 
1541
 
1542
  for h0 in refine_headings:
1543
  for d in refine_drifts:
1544
+ for spd_south in refine_south_speeds:
1545
+ refine_turn_speeds = _refine_range(
1546
+ best_turn_spd, REFINE_TURN_SPEED_HALF_WINDOW_KT, TURN_SPEED_STEP_KT,
1547
+ max(TURN_SPEED_MIN_ABS_KT, float(spd_south) - TURN_SPEED_SEARCH_DELTA_KT),
1548
+ min(TURN_SPEED_MAX_ABS_KT, float(spd_south) + TURN_SPEED_SEARCH_DELTA_KT),
1549
+ ) or [best_turn_spd]
1550
+
1551
+ for spd_turn in refine_turn_speeds:
1552
+ s, combined_plus, legs = try_candidate(h0, d, spd_south, spd_turn)
1553
+ if s is None:
1554
+ continue
1555
+ if best_combined_plus is None or float(combined_plus) < float(best_combined_plus):
1556
+ best_score = dict(s)
1557
+ best_combined_plus = float(combined_plus)
1558
+ best_h0 = float(h0)
1559
+ best_d = float(d)
1560
+ best_south_spd = float(spd_south)
1561
+ best_turn_spd = float(spd_turn)
1562
+ best_full_legs = legs
1563
 
1564
  report = "\n".join([
1565
  "**Guided mode: Straight + drift + computed turn (18:22→18:40), clipped to 00:20**",
1566
  f"- Search mode: **two-stage (coarse → refine)**",
1567
+ f"- Southern speed (solved): **{float(best_south_spd):.1f} kt** (UI guess was ~{float(south_speed_guess_knots):.1f} kt)",
1568
  f"- Best turn speed (18:22→18:40): **{float(best_turn_spd):.1f} kt**",
1569
  f"- Best nominal heading: **{best_h0:.1f}°**",
1570
  f"- Drift used: **{best_d:+.1f}°** | Drift penalty weight: **{float(drift_weight):.2f}**",
 
1578
 
1579
  # ================= RUNNERS =================
1580
  def _run_common(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice):
 
1581
  if start_dt == RADAR_FIX_TIME_UTC:
1582
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1583
  legs = clip_legs_to_end_dt(legs, start_dt, end_dt)
 
1587
  html_map = build_leaflet_map(path_pts, legs, method, start_dt)
1588
  stats_md = compute_stats(path_pts, seg_dists_m, total_time_min, legs)
1589
 
 
1590
  bfo_fig, bfo_md = bfo_analysis_with_legs(
1591
  start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice
1592
  )
 
1621
  weight_bfo, weight_bto,
1622
  drift_max_deg, drift_weight
1623
  ):
1624
+ # UI speed profile now acts as an INITIAL GUESS (center of southern speed search window)
1625
+ south_speed_guess = speed_from_profile(speed_profile, alt_ft, custom_mach, custom_knots)
1626
 
1627
  if (guided_scenario or "").startswith("Straight + drift"):
1628
  legs, report_md, start_dt = solve_guided_straight_with_drift(
1629
+ method, south_speed_guess,
1630
  alt_ft, vs_fpm,
1631
  descent_start_choice, weight_bfo, weight_bto,
1632
  drift_max_deg, drift_weight
1633
  )
1634
  else:
1635
  legs, report_md, start_dt = solve_guided_straight(
1636
+ method, south_speed_guess,
1637
  alt_ft, vs_fpm,
1638
  descent_start_choice, weight_bfo, weight_bto
1639
  )
 
1650
  ], value=[])
1651
  return gr.update(), f"### ❌ {report_md}", "", "", fig, "", alt_fig, empty_table
1652
 
 
1653
  html_map, stats_md, bfo_fig, bfo_md, alt_fig, headers, rows = _run_common(
1654
  RADAR_FIX_LAT, RADAR_FIX_LON, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice
1655
  )
 
1682
  f"- **Initial track (fixed):** {RADAR_INITIAL_TRACK_DEG:.1f}°\n"
1683
  f"- **Final resting point:** **00:20:00Z** (trajectory clipped exactly at this time)\n\n"
1684
  f"**Turn behavior:** 18:22→18:40 turn is solved with **adjustable turn speed**.\n"
1685
+ f"**Southern behavior:** after 18:40, **southern speed is solved (free)** to best match BFO+BTO.\n"
1686
+ f"**Performance:** guided solver uses **two-stage search (coarse → refine)**."
1687
  )
1688
 
1689
  with gr.Row():
 
1715
  speed_profile = gr.Dropdown(
1716
  ["MRC Mach 0.82", "LRC Mach 0.84", "ECON Mach 0.80", "Custom Mach", "Custom knots"],
1717
  value="MRC Mach 0.82",
1718
+ label="Speed profile (INITIAL GUESS only — solver will choose best southern speed)",
1719
  )
1720
  custom_mach = gr.Number(value=0.82, label="Custom Mach (if selected)")
1721
  custom_knots = gr.Number(value=470, label="Custom knots (if selected)")