Chris773 commited on
Commit
080a016
·
verified ·
1 Parent(s): 090921a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +125 -132
app.py CHANGED
@@ -15,17 +15,16 @@
15
  # 1) Remove trajectory (red line) AFTER 00:20
16
  # - In guided mode, we CLIP the simulated legs so the polyline ends exactly at 00:20Z.
17
  # 2) Restore BFO plot
 
18
  #
19
- # PREVIOUS UPDATES:
20
- # 3) Plausible final major turn (18:22→18:40) can adjust BOTH heading and speed (turn speed searched)
21
  # 4) Two-stage guided search (COARSE → REFINE)
22
- # 5) Southern trajectory speed is free (solver searches best speed vs BFO+BTO)
23
  #
24
  # NEW (THIS UPDATE):
25
- # 6) Removed the Speed Profile UI section entirely.
26
- # - Guided solver still freely searches southern speed + turn speed to best match BFO+BTO.
27
- # - Internally, we use a reasonable starting guess (Mach 0.82 at current altitude) only to center
28
- # the search window — user no longer needs to choose a speed profile.
29
 
30
  import os
31
  import re
@@ -91,11 +90,13 @@ RADAR_FIX_LON = 96.341
91
  RADAR_FIX_TIME_UTC = datetime(2014, 3, 7, 18, 22, 0, tzinfo=timezone.utc)
92
 
93
  TURN_COMPLETE_TIME_UTC = datetime(2014, 3, 7, 18, 40, 0, tzinfo=timezone.utc)
 
94
 
95
  # Initial track shown in your radar annotation
96
  RADAR_INITIAL_TRACK_DEG = 296.0
97
 
98
  # ===== Guided validation timestamps of interest =====
 
99
  GUIDED_VALIDATION_LABELS = [
100
  "18:22:00",
101
  "18:40:00",
@@ -115,38 +116,40 @@ TURN_BANK_DEFAULT_DEG = 25.0
115
  TURN_STEP_SEC = 30.0
116
 
117
  # Turn-speed search parameters (turn only)
118
- TURN_SPEED_SEARCH_DELTA_KT = 40.0
119
- TURN_SPEED_STEP_KT = 5.0
120
- TURN_SPEED_MIN_ABS_KT = 350.0
121
- TURN_SPEED_MAX_ABS_KT = 550.0
122
 
123
  # Southern-speed search parameters (southern speed is free)
124
- SOUTH_SPEED_SEARCH_DELTA_KT = 80.0
125
  SOUTH_SPEED_MIN_ABS_KT = 330.0
126
  SOUTH_SPEED_MAX_ABS_KT = 530.0
127
  SOUTH_SPEED_STEP_KT = 2.5
128
  COARSE_SOUTH_SPEED_STEP_KT = 10.0
129
- REFINE_SOUTH_SPEED_HALF_WINDOW_KT = 12.5
130
 
131
  # Guided heading search (south-ish)
132
  GUIDED_HEADING_MIN = 150.0
133
  GUIDED_HEADING_MAX = 210.0
134
- STRAIGHT_STEP_DEG = 0.5
135
 
136
  # Southern schedule (kept)
137
  GUIDED_LEG_SCHEDULE_MIN = [12.0, 60.0, 60.0, 60.0, 60.0, 90.0, 10.0]
138
 
139
  # Drift
140
  DEFAULT_DRIFT_MAX_DEG = 2.0
141
- DRIFT_STEP_DEG = 0.5
142
  DEFAULT_WEIGHT_DRIFT = 0.30
143
  DRIFT_SCALE_DEG = 1.0
144
 
145
  # ===== Two-stage search knobs =====
 
146
  COARSE_HEADING_STEP_DEG = 2.0
147
  COARSE_TURN_SPEED_STEP_KT = 10.0
148
  COARSE_DRIFT_STEP_DEG = 1.0
149
 
 
150
  REFINE_HEADING_HALF_WINDOW_DEG = 2.0
151
  REFINE_TURN_SPEED_HALF_WINDOW_KT = 10.0
152
  REFINE_DRIFT_HALF_WINDOW_DEG = 1.0
@@ -449,11 +452,9 @@ def build_turn_legs(
449
 
450
  legs = []
451
 
452
- # initial straight on radar track
453
  if straight_sec > 1.0:
454
  legs.append((h0, float(speed_knots), straight_sec / 60.0))
455
 
456
- # discretized turn segments
457
  if turn_sec > 1.0 and delta_abs > 1e-6:
458
  step_sec = max(5.0, float(step_sec))
459
  n = int(math.ceil(turn_sec / step_sec))
@@ -694,6 +695,72 @@ def clip_legs_to_end_dt(legs, start_dt: datetime, end_dt: datetime):
694
  return out
695
 
696
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
697
  # ================= BFO ANALYSIS (RESTORED) =================
698
  def _ping_points_after(start_dt: datetime):
699
  pts = []
@@ -947,7 +1014,6 @@ def build_leaflet_map(path_pts, legs, method, start_dt):
947
  control=True,
948
  ).add_to(m)
949
 
950
- # Search areas
951
  search_area_pts_for_bounds = []
952
  for area in SEARCH_AREAS:
953
  nm = area.get("name", "Search area")
@@ -993,7 +1059,6 @@ def build_leaflet_map(path_pts, legs, method, start_dt):
993
  tooltip=nm,
994
  ).add_to(m)
995
 
996
- # Ping rings — permanent
997
  for ring_name in sorted(PING_RINGS.keys()):
998
  ring_pts = PING_RINGS.get(ring_name)
999
  if not ring_pts:
@@ -1002,11 +1067,9 @@ def build_leaflet_map(path_pts, legs, method, start_dt):
1002
  tooltip = f"{ring_name} ({ring_time})" if ring_time else ring_name
1003
  folium.PolyLine(ring_pts, weight=2, opacity=0.6, color="yellow", tooltip=tooltip).add_to(m)
1004
 
1005
- # Flight path (polyline)
1006
  folium.PolyLine(path_pts, color="white", weight=8, opacity=0.3).add_to(m)
1007
  folium.PolyLine(path_pts, color="red", weight=4, opacity=1.0, tooltip="Flight path").add_to(m)
1008
 
1009
- # Start marker
1010
  folium.CircleMarker(
1011
  location=path_pts[0],
1012
  radius=7,
@@ -1019,7 +1082,6 @@ def build_leaflet_map(path_pts, legs, method, start_dt):
1019
  ),
1020
  ).add_to(m)
1021
 
1022
- # Manual mode can show an "End" marker. Guided mode: no extra end marker.
1023
  if not is_guided:
1024
  folium.CircleMarker(
1025
  location=path_pts[-1],
@@ -1033,7 +1095,6 @@ def build_leaflet_map(path_pts, legs, method, start_dt):
1033
  ),
1034
  ).add_to(m)
1035
 
1036
- # Validation markers (authoritative)
1037
  marker_times = _map_marker_times(start_dt, legs)
1038
  marker_pts_for_bounds = []
1039
 
@@ -1088,7 +1149,6 @@ def build_leaflet_map(path_pts, legs, method, start_dt):
1088
  popup=folium.Popup("<br>".join(popup_lines), max_width=360),
1089
  ).add_to(m)
1090
 
1091
- # Permanent labels
1092
  for name, lat, lon in WAYPOINTS:
1093
  add_permanent_label(m, name, float(lat), float(lon))
1094
 
@@ -1119,90 +1179,6 @@ def build_leaflet_map(path_pts, legs, method, start_dt):
1119
  """
1120
 
1121
 
1122
- # ================= Speed helpers (internal) =================
1123
- def isa_speed_of_sound_mps(alt_m: float) -> float:
1124
- T0 = 288.15
1125
- L = -0.0065
1126
- h_trop = 11000.0
1127
- if alt_m <= h_trop:
1128
- T = T0 + L * alt_m
1129
- else:
1130
- T = 216.65
1131
- gamma = 1.4
1132
- R = 287.05
1133
- return math.sqrt(gamma * R * T)
1134
-
1135
-
1136
- def mach_to_tas_knots(mach: float, alt_ft: float) -> float:
1137
- alt_m = float(alt_ft) * 0.3048
1138
- a = isa_speed_of_sound_mps(alt_m)
1139
- tas_mps = float(mach) * a
1140
- return tas_mps / 0.514444
1141
-
1142
-
1143
- def default_speed_guess_knots(alt_ft: float) -> float:
1144
- # Only used to center the search window; solver still picks the best speed.
1145
- return mach_to_tas_knots(0.82, alt_ft)
1146
-
1147
-
1148
- def _turn_speed_candidates(south_speed_knots: float, step_kt: float):
1149
- base = float(south_speed_knots)
1150
- lo = max(TURN_SPEED_MIN_ABS_KT, base - float(TURN_SPEED_SEARCH_DELTA_KT))
1151
- hi = min(TURN_SPEED_MAX_ABS_KT, base + float(TURN_SPEED_SEARCH_DELTA_KT))
1152
- step = max(1.0, float(step_kt))
1153
- if hi < lo:
1154
- lo, hi = hi, lo
1155
- cand = list(np.arange(lo, hi + 1e-9, step))
1156
- if all(abs(c - base) > 1e-9 for c in cand):
1157
- cand.append(base)
1158
- return sorted({round(float(c), 6) for c in cand})
1159
-
1160
-
1161
- def _south_speed_candidates(base_guess_knots: float, step_kt: float):
1162
- base = float(base_guess_knots)
1163
- lo = max(SOUTH_SPEED_MIN_ABS_KT, base - float(SOUTH_SPEED_SEARCH_DELTA_KT))
1164
- hi = min(SOUTH_SPEED_MAX_ABS_KT, base + float(SOUTH_SPEED_SEARCH_DELTA_KT))
1165
- step = max(1.0, float(step_kt))
1166
- if hi < lo:
1167
- lo, hi = hi, lo
1168
- cand = list(np.arange(lo, hi + 1e-9, step))
1169
- if all(abs(c - base) > 1e-9 for c in cand):
1170
- cand.append(base)
1171
- return sorted({round(float(c), 6) for c in cand})
1172
-
1173
-
1174
- def _clamp(x, lo, hi):
1175
- return max(lo, min(hi, x))
1176
-
1177
-
1178
- def _frange(lo: float, hi: float, step: float):
1179
- step = float(step)
1180
- if step <= 0:
1181
- return []
1182
- n = int(math.floor((hi - lo) / step + 1e-9)) + 1
1183
- out = [lo + i * step for i in range(max(0, n))]
1184
- return [x for x in out if x <= hi + 1e-9]
1185
-
1186
-
1187
- def _refine_range(center: float, half_window: float, step: float, lo: float, hi: float):
1188
- lo2 = _clamp(center - half_window, lo, hi)
1189
- hi2 = _clamp(center + half_window, lo, hi)
1190
- return _frange(lo2, hi2, step)
1191
-
1192
-
1193
- # ================= Guided Legs Creation =================
1194
- def make_legs_from_schedule(headings, speed_knots, schedule_minutes):
1195
- legs = []
1196
- if isinstance(headings, (int, float)):
1197
- headings = [float(headings)] * len(schedule_minutes)
1198
- for hdg, m in zip(headings, schedule_minutes):
1199
- m = float(m)
1200
- if m <= 0:
1201
- continue
1202
- legs.append((float(hdg), float(speed_knots), float(m)))
1203
- return legs
1204
-
1205
-
1206
  # ================= Scoring helper (used in guided solvers) =================
1207
  def score_legs_bfo_bto(
1208
  start_lat, start_lon, method, start_dt, end_dt, legs,
@@ -1282,6 +1258,19 @@ def score_legs_bfo_bto(
1282
  }
1283
 
1284
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1285
  # ================= Guided Solvers =================
1286
  def _guided_start_dt():
1287
  return RADAR_FIX_TIME_UTC
@@ -1307,12 +1296,14 @@ def solve_guided_straight(
1307
 
1308
  def try_candidate(h_south: float, spd_south: float, spd_turn: float):
1309
  southern_legs = make_legs_from_schedule(h_south, float(spd_south), GUIDED_LEG_SCHEDULE_MIN)
1310
- preturn_legs, _ = build_turn_legs(
1311
  speed_knots=float(spd_turn),
1312
  method=method,
1313
  heading_final_deg=float(h_south),
1314
  )
1315
- full_legs = clip_legs_to_end_dt(preturn_legs + southern_legs, start_dt, end_dt)
 
 
1316
  s = score_legs_bfo_bto(
1317
  RADAR_FIX_LAT, RADAR_FIX_LON, method, start_dt, end_dt, full_legs,
1318
  alt_ft, vs_fpm, descent_start_dt,
@@ -1321,8 +1312,9 @@ def solve_guided_straight(
1321
  )
1322
  return s, full_legs
1323
 
1324
- # -------- Stage 1: COARSE search --------
1325
- best_h = best_south_spd = best_turn_spd = None
 
1326
  best_full_legs = None
1327
  best_score = None
1328
 
@@ -1344,8 +1336,9 @@ def solve_guided_straight(
1344
  if best_full_legs is None:
1345
  return None, "Guided solve: not enough ping points to score.", None
1346
 
1347
- # -------- Stage 2: REFINE around best --------
1348
- refine_headings = _refine_range(best_h, REFINE_HEADING_HALF_WINDOW_DEG, STRAIGHT_STEP_DEG, GUIDED_HEADING_MIN, GUIDED_HEADING_MAX)
 
1349
 
1350
  refine_south_speeds = _refine_range(
1351
  best_south_spd, REFINE_SOUTH_SPEED_HALF_WINDOW_KT, SOUTH_SPEED_STEP_KT,
@@ -1409,12 +1402,13 @@ def solve_guided_straight_with_drift(
1409
  h_eff = _wrap_heading_deg(float(h0_nominal) + float(drift_deg))
1410
 
1411
  southern_legs = make_legs_from_schedule(h_eff, float(spd_south), GUIDED_LEG_SCHEDULE_MIN)
1412
- preturn_legs, _ = build_turn_legs(
1413
  speed_knots=float(spd_turn),
1414
  method=method,
1415
  heading_final_deg=float(h_eff),
1416
  )
1417
- full_legs = clip_legs_to_end_dt(preturn_legs + southern_legs, start_dt, end_dt)
 
1418
 
1419
  s = score_legs_bfo_bto(
1420
  RADAR_FIX_LAT, RADAR_FIX_LON, method, start_dt, end_dt, full_legs,
@@ -1428,8 +1422,10 @@ def solve_guided_straight_with_drift(
1428
  combined_plus = float(s["combined"]) + float(drift_weight) * (abs(float(drift_deg)) / DRIFT_SCALE_DEG)
1429
  return s, combined_plus, full_legs
1430
 
1431
- # -------- Stage 1: COARSE search --------
1432
- best_h0 = best_d = best_south_spd = best_turn_spd = None
 
 
1433
  best_full_legs = None
1434
  best_score = None
1435
  best_combined_plus = None
@@ -1466,9 +1462,7 @@ def solve_guided_straight_with_drift(
1466
  if best_full_legs is None:
1467
  return None, "Guided drift solve: not enough ping points to score.", None
1468
 
1469
- # -------- Stage 2: REFINE around best --------
1470
  refine_headings = _refine_range(best_h0, REFINE_HEADING_HALF_WINDOW_DEG, 1.0, GUIDED_HEADING_MIN, GUIDED_HEADING_MAX)
1471
-
1472
  d_lo = max(-drift_max_deg, best_d - REFINE_DRIFT_HALF_WINDOW_DEG)
1473
  d_hi = min(+drift_max_deg, best_d + REFINE_DRIFT_HALF_WINDOW_DEG)
1474
  refine_drifts = _frange(d_lo, d_hi, DRIFT_STEP_DEG) or [best_d]
@@ -1559,7 +1553,8 @@ def run_guided(
1559
  start_lat, start_lon, method, alt_ft, vs_fpm, descent_start_choice,
1560
  guided_scenario, weight_bfo, weight_bto, drift_max_deg, drift_weight
1561
  ):
1562
- south_speed_guess = default_speed_guess_knots(alt_ft)
 
1563
 
1564
  if (guided_scenario or "").startswith("Straight + drift"):
1565
  legs, report_md, start_dt = solve_guided_straight_with_drift(
@@ -1618,8 +1613,7 @@ with gr.Blocks() as demo:
1618
  f"- **Turn completion (fixed):** 18:40:00Z\n"
1619
  f"- **Initial track (fixed):** {RADAR_INITIAL_TRACK_DEG:.1f}°\n"
1620
  f"- **Final resting point:** **00:20:00Z** (trajectory clipped exactly at this time)\n\n"
1621
- f"**Turn behavior:** 18:22→18:40 turn is solved with **adjustable turn speed**.\n"
1622
- f"**Southern behavior:** after 18:40, **southern speed is solved (free)** to best match BFO+BTO.\n"
1623
  f"**Performance:** guided solver uses **two-stage search (coarse → refine)**."
1624
  )
1625
 
@@ -1640,15 +1634,14 @@ with gr.Blocks() as demo:
1640
  )
1641
 
1642
  gr.Markdown("### Guided mode (Recommended)")
1643
- with gr.Row():
1644
- guided_scenario = gr.Dropdown(
1645
- [
1646
- "Straight (single heading)",
1647
- "Straight + drift (per-leg ±deg, penalized)",
1648
- ],
1649
- value="Straight (single heading)",
1650
- label="Scenario",
1651
- )
1652
 
1653
  gr.Markdown("### Heading drift settings (for “Straight + drift” scenario)")
1654
  with gr.Row():
 
15
  # 1) Remove trajectory (red line) AFTER 00:20
16
  # - In guided mode, we CLIP the simulated legs so the polyline ends exactly at 00:20Z.
17
  # 2) Restore BFO plot
18
+ # - Full BFO analysis computation + chart + MAE/RMSE markdown.
19
  #
20
+ # TURN MODEL:
21
+ # 3) Plausible final major turn (18:22→18:40) can adjust BOTH heading and speed
22
  # 4) Two-stage guided search (COARSE → REFINE)
 
23
  #
24
  # NEW (THIS UPDATE):
25
+ # 5) Removed "Speed profile / Custom Mach / Custom knots" UI section entirely.
26
+ # - Guided solver freely chooses southern speed (and turn speed) to best match measured BFO+BTO.
27
+ # - A hidden internal speed guess (Mach 0.82 at current altitude) is used only to center the search window.
 
28
 
29
  import os
30
  import re
 
90
  RADAR_FIX_TIME_UTC = datetime(2014, 3, 7, 18, 22, 0, tzinfo=timezone.utc)
91
 
92
  TURN_COMPLETE_TIME_UTC = datetime(2014, 3, 7, 18, 40, 0, tzinfo=timezone.utc)
93
+ TURN_WINDOW_MIN = (TURN_COMPLETE_TIME_UTC - RADAR_FIX_TIME_UTC).total_seconds() / 60.0
94
 
95
  # Initial track shown in your radar annotation
96
  RADAR_INITIAL_TRACK_DEG = 296.0
97
 
98
  # ===== Guided validation timestamps of interest =====
99
+ # (00:10 and 00:20 are represented as 24:10 and 24:20 in your SAT epoch helper)
100
  GUIDED_VALIDATION_LABELS = [
101
  "18:22:00",
102
  "18:40:00",
 
116
  TURN_STEP_SEC = 30.0
117
 
118
  # Turn-speed search parameters (turn only)
119
+ TURN_SPEED_SEARCH_DELTA_KT = 40.0 # search ± around the candidate southern speed
120
+ TURN_SPEED_STEP_KT = 5.0 # refined candidate spacing
121
+ TURN_SPEED_MIN_ABS_KT = 350.0 # hard min plausible
122
+ TURN_SPEED_MAX_ABS_KT = 550.0 # hard max plausible
123
 
124
  # Southern-speed search parameters (southern speed is free)
125
+ SOUTH_SPEED_SEARCH_DELTA_KT = 120.0
126
  SOUTH_SPEED_MIN_ABS_KT = 330.0
127
  SOUTH_SPEED_MAX_ABS_KT = 530.0
128
  SOUTH_SPEED_STEP_KT = 2.5
129
  COARSE_SOUTH_SPEED_STEP_KT = 10.0
130
+ REFINE_SOUTH_SPEED_HALF_WINDOW_KT = 15.0
131
 
132
  # Guided heading search (south-ish)
133
  GUIDED_HEADING_MIN = 150.0
134
  GUIDED_HEADING_MAX = 210.0
135
+ STRAIGHT_STEP_DEG = 0.5 # refined step
136
 
137
  # Southern schedule (kept)
138
  GUIDED_LEG_SCHEDULE_MIN = [12.0, 60.0, 60.0, 60.0, 60.0, 90.0, 10.0]
139
 
140
  # Drift
141
  DEFAULT_DRIFT_MAX_DEG = 2.0
142
+ DRIFT_STEP_DEG = 0.5 # refined step
143
  DEFAULT_WEIGHT_DRIFT = 0.30
144
  DRIFT_SCALE_DEG = 1.0
145
 
146
  # ===== Two-stage search knobs =====
147
+ # Stage 1 (coarse)
148
  COARSE_HEADING_STEP_DEG = 2.0
149
  COARSE_TURN_SPEED_STEP_KT = 10.0
150
  COARSE_DRIFT_STEP_DEG = 1.0
151
 
152
+ # Stage 2 (refine around best)
153
  REFINE_HEADING_HALF_WINDOW_DEG = 2.0
154
  REFINE_TURN_SPEED_HALF_WINDOW_KT = 10.0
155
  REFINE_DRIFT_HALF_WINDOW_DEG = 1.0
 
452
 
453
  legs = []
454
 
 
455
  if straight_sec > 1.0:
456
  legs.append((h0, float(speed_knots), straight_sec / 60.0))
457
 
 
458
  if turn_sec > 1.0 and delta_abs > 1e-6:
459
  step_sec = max(5.0, float(step_sec))
460
  n = int(math.ceil(turn_sec / step_sec))
 
695
  return out
696
 
697
 
698
+ # ================= INTERNAL SPEED HELPERS (no UI) =================
699
+ def isa_speed_of_sound_mps(alt_m: float) -> float:
700
+ T0 = 288.15
701
+ L = -0.0065
702
+ h_trop = 11000.0
703
+ if alt_m <= h_trop:
704
+ T = T0 + L * alt_m
705
+ else:
706
+ T = 216.65
707
+ gamma = 1.4
708
+ R = 287.05
709
+ return math.sqrt(gamma * R * T)
710
+
711
+
712
+ def mach_to_tas_knots(mach: float, alt_ft: float) -> float:
713
+ alt_m = float(alt_ft) * 0.3048
714
+ a = isa_speed_of_sound_mps(alt_m)
715
+ tas_mps = float(mach) * a
716
+ return tas_mps / 0.514444
717
+
718
+
719
+ def _turn_speed_candidates(south_speed_knots: float, step_kt: float):
720
+ base = float(south_speed_knots)
721
+ lo = max(TURN_SPEED_MIN_ABS_KT, base - float(TURN_SPEED_SEARCH_DELTA_KT))
722
+ hi = min(TURN_SPEED_MAX_ABS_KT, base + float(TURN_SPEED_SEARCH_DELTA_KT))
723
+ step = max(1.0, float(step_kt))
724
+ if hi < lo:
725
+ lo, hi = hi, lo
726
+ cand = list(np.arange(lo, hi + 1e-9, step))
727
+ if all(abs(c - base) > 1e-9 for c in cand):
728
+ cand.append(base)
729
+ return sorted({round(float(c), 6) for c in cand})
730
+
731
+
732
+ def _south_speed_candidates(base_guess_knots: float, step_kt: float):
733
+ base = float(base_guess_knots)
734
+ lo = max(SOUTH_SPEED_MIN_ABS_KT, base - float(SOUTH_SPEED_SEARCH_DELTA_KT))
735
+ hi = min(SOUTH_SPEED_MAX_ABS_KT, base + float(SOUTH_SPEED_SEARCH_DELTA_KT))
736
+ step = max(1.0, float(step_kt))
737
+ if hi < lo:
738
+ lo, hi = hi, lo
739
+ cand = list(np.arange(lo, hi + 1e-9, step))
740
+ if all(abs(c - base) > 1e-9 for c in cand):
741
+ cand.append(base)
742
+ return sorted({round(float(c), 6) for c in cand})
743
+
744
+
745
+ def _clamp(x, lo, hi):
746
+ return max(lo, min(hi, x))
747
+
748
+
749
+ def _frange(lo: float, hi: float, step: float):
750
+ step = float(step)
751
+ if step <= 0:
752
+ return []
753
+ n = int(math.floor((hi - lo) / step + 1e-9)) + 1
754
+ out = [lo + i * step for i in range(max(0, n))]
755
+ return [x for x in out if x <= hi + 1e-9]
756
+
757
+
758
+ def _refine_range(center: float, half_window: float, step: float, lo: float, hi: float):
759
+ lo2 = _clamp(center - half_window, lo, hi)
760
+ hi2 = _clamp(center + half_window, lo, hi)
761
+ return _frange(lo2, hi2, step)
762
+
763
+
764
  # ================= BFO ANALYSIS (RESTORED) =================
765
  def _ping_points_after(start_dt: datetime):
766
  pts = []
 
1014
  control=True,
1015
  ).add_to(m)
1016
 
 
1017
  search_area_pts_for_bounds = []
1018
  for area in SEARCH_AREAS:
1019
  nm = area.get("name", "Search area")
 
1059
  tooltip=nm,
1060
  ).add_to(m)
1061
 
 
1062
  for ring_name in sorted(PING_RINGS.keys()):
1063
  ring_pts = PING_RINGS.get(ring_name)
1064
  if not ring_pts:
 
1067
  tooltip = f"{ring_name} ({ring_time})" if ring_time else ring_name
1068
  folium.PolyLine(ring_pts, weight=2, opacity=0.6, color="yellow", tooltip=tooltip).add_to(m)
1069
 
 
1070
  folium.PolyLine(path_pts, color="white", weight=8, opacity=0.3).add_to(m)
1071
  folium.PolyLine(path_pts, color="red", weight=4, opacity=1.0, tooltip="Flight path").add_to(m)
1072
 
 
1073
  folium.CircleMarker(
1074
  location=path_pts[0],
1075
  radius=7,
 
1082
  ),
1083
  ).add_to(m)
1084
 
 
1085
  if not is_guided:
1086
  folium.CircleMarker(
1087
  location=path_pts[-1],
 
1095
  ),
1096
  ).add_to(m)
1097
 
 
1098
  marker_times = _map_marker_times(start_dt, legs)
1099
  marker_pts_for_bounds = []
1100
 
 
1149
  popup=folium.Popup("<br>".join(popup_lines), max_width=360),
1150
  ).add_to(m)
1151
 
 
1152
  for name, lat, lon in WAYPOINTS:
1153
  add_permanent_label(m, name, float(lat), float(lon))
1154
 
 
1179
  """
1180
 
1181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1182
  # ================= Scoring helper (used in guided solvers) =================
1183
  def score_legs_bfo_bto(
1184
  start_lat, start_lon, method, start_dt, end_dt, legs,
 
1258
  }
1259
 
1260
 
1261
+ # ================= Guided Legs Creation =================
1262
+ def make_legs_from_schedule(headings, speed_knots, schedule_minutes):
1263
+ legs = []
1264
+ if isinstance(headings, (int, float)):
1265
+ headings = [float(headings)] * len(schedule_minutes)
1266
+ for hdg, m in zip(headings, schedule_minutes):
1267
+ m = float(m)
1268
+ if m <= 0:
1269
+ continue
1270
+ legs.append((float(hdg), float(speed_knots), float(m)))
1271
+ return legs
1272
+
1273
+
1274
  # ================= Guided Solvers =================
1275
  def _guided_start_dt():
1276
  return RADAR_FIX_TIME_UTC
 
1296
 
1297
  def try_candidate(h_south: float, spd_south: float, spd_turn: float):
1298
  southern_legs = make_legs_from_schedule(h_south, float(spd_south), GUIDED_LEG_SCHEDULE_MIN)
1299
+ preturn_legs, _turn_info = build_turn_legs(
1300
  speed_knots=float(spd_turn),
1301
  method=method,
1302
  heading_final_deg=float(h_south),
1303
  )
1304
+ full_legs = preturn_legs + southern_legs
1305
+ full_legs = clip_legs_to_end_dt(full_legs, start_dt, end_dt)
1306
+
1307
  s = score_legs_bfo_bto(
1308
  RADAR_FIX_LAT, RADAR_FIX_LON, method, start_dt, end_dt, full_legs,
1309
  alt_ft, vs_fpm, descent_start_dt,
 
1312
  )
1313
  return s, full_legs
1314
 
1315
+ best_h = None
1316
+ best_south_spd = None
1317
+ best_turn_spd = None
1318
  best_full_legs = None
1319
  best_score = None
1320
 
 
1336
  if best_full_legs is None:
1337
  return None, "Guided solve: not enough ping points to score.", None
1338
 
1339
+ refine_headings = _refine_range(
1340
+ best_h, REFINE_HEADING_HALF_WINDOW_DEG, STRAIGHT_STEP_DEG, GUIDED_HEADING_MIN, GUIDED_HEADING_MAX
1341
+ )
1342
 
1343
  refine_south_speeds = _refine_range(
1344
  best_south_spd, REFINE_SOUTH_SPEED_HALF_WINDOW_KT, SOUTH_SPEED_STEP_KT,
 
1402
  h_eff = _wrap_heading_deg(float(h0_nominal) + float(drift_deg))
1403
 
1404
  southern_legs = make_legs_from_schedule(h_eff, float(spd_south), GUIDED_LEG_SCHEDULE_MIN)
1405
+ preturn_legs, _turn_info = build_turn_legs(
1406
  speed_knots=float(spd_turn),
1407
  method=method,
1408
  heading_final_deg=float(h_eff),
1409
  )
1410
+ full_legs = preturn_legs + southern_legs
1411
+ full_legs = clip_legs_to_end_dt(full_legs, start_dt, end_dt)
1412
 
1413
  s = score_legs_bfo_bto(
1414
  RADAR_FIX_LAT, RADAR_FIX_LON, method, start_dt, end_dt, full_legs,
 
1422
  combined_plus = float(s["combined"]) + float(drift_weight) * (abs(float(drift_deg)) / DRIFT_SCALE_DEG)
1423
  return s, combined_plus, full_legs
1424
 
1425
+ best_h0 = None
1426
+ best_d = None
1427
+ best_south_spd = None
1428
+ best_turn_spd = None
1429
  best_full_legs = None
1430
  best_score = None
1431
  best_combined_plus = None
 
1462
  if best_full_legs is None:
1463
  return None, "Guided drift solve: not enough ping points to score.", None
1464
 
 
1465
  refine_headings = _refine_range(best_h0, REFINE_HEADING_HALF_WINDOW_DEG, 1.0, GUIDED_HEADING_MIN, GUIDED_HEADING_MAX)
 
1466
  d_lo = max(-drift_max_deg, best_d - REFINE_DRIFT_HALF_WINDOW_DEG)
1467
  d_hi = min(+drift_max_deg, best_d + REFINE_DRIFT_HALF_WINDOW_DEG)
1468
  refine_drifts = _frange(d_lo, d_hi, DRIFT_STEP_DEG) or [best_d]
 
1553
  start_lat, start_lon, method, alt_ft, vs_fpm, descent_start_choice,
1554
  guided_scenario, weight_bfo, weight_bto, drift_max_deg, drift_weight
1555
  ):
1556
+ # Hidden internal guess only to center search window (solver freely chooses speed)
1557
+ south_speed_guess = mach_to_tas_knots(0.82, float(alt_ft))
1558
 
1559
  if (guided_scenario or "").startswith("Straight + drift"):
1560
  legs, report_md, start_dt = solve_guided_straight_with_drift(
 
1613
  f"- **Turn completion (fixed):** 18:40:00Z\n"
1614
  f"- **Initial track (fixed):** {RADAR_INITIAL_TRACK_DEG:.1f}°\n"
1615
  f"- **Final resting point:** **00:20:00Z** (trajectory clipped exactly at this time)\n\n"
1616
+ f"**Speed behavior:** after 18:40, **southern speed is solved (free)** to best match measured BFO+BTO.\n"
 
1617
  f"**Performance:** guided solver uses **two-stage search (coarse → refine)**."
1618
  )
1619
 
 
1634
  )
1635
 
1636
  gr.Markdown("### Guided mode (Recommended)")
1637
+ guided_scenario = gr.Dropdown(
1638
+ [
1639
+ "Straight (single heading)",
1640
+ "Straight + drift (per-leg ±deg, penalized)",
1641
+ ],
1642
+ value="Straight (single heading)",
1643
+ label="Scenario",
1644
+ )
 
1645
 
1646
  gr.Markdown("### Heading drift settings (for “Straight + drift” scenario)")
1647
  with gr.Row():