Spaces:
Sleeping
Sleeping
Update app.py
Browse files
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 |
-
#
|
| 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 |
-
#
|
| 28 |
# 4) Two-stage guided search (COARSE → REFINE) to make generation much faster
|
| 29 |
-
#
|
| 30 |
-
#
|
| 31 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 123 |
-
TURN_SPEED_SEARCH_DELTA_KT = 40.0 # search ± around the southern
|
| 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 |
-
# =====
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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(
|
| 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 |
-
|
| 1356 |
|
| 1357 |
for h in coarse_headings:
|
| 1358 |
-
for
|
| 1359 |
-
|
| 1360 |
-
|
| 1361 |
-
|
| 1362 |
-
|
| 1363 |
-
|
| 1364 |
-
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 1371 |
-
|
| 1372 |
-
|
| 1373 |
-
|
| 1374 |
-
|
|
|
|
|
|
|
|
|
|
| 1375 |
)
|
|
|
|
|
|
|
| 1376 |
|
| 1377 |
for h in refine_headings:
|
| 1378 |
-
for
|
| 1379 |
-
|
| 1380 |
-
|
| 1381 |
-
|
| 1382 |
-
|
| 1383 |
-
|
| 1384 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 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,
|
| 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(
|
| 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 |
-
|
| 1467 |
|
| 1468 |
for h0 in coarse_headings:
|
| 1469 |
for d in coarse_drifts:
|
| 1470 |
-
for
|
| 1471 |
-
|
| 1472 |
-
|
| 1473 |
-
|
| 1474 |
-
|
| 1475 |
-
|
| 1476 |
-
best_combined_plus
|
| 1477 |
-
|
| 1478 |
-
|
| 1479 |
-
|
| 1480 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1493 |
-
|
| 1494 |
-
|
| 1495 |
-
|
| 1496 |
-
|
| 1497 |
-
|
| 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
|
| 1504 |
-
|
| 1505 |
-
|
| 1506 |
-
|
| 1507 |
-
|
| 1508 |
-
|
| 1509 |
-
|
| 1510 |
-
|
| 1511 |
-
|
| 1512 |
-
|
| 1513 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 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 |
-
#
|
| 1578 |
-
|
| 1579 |
|
| 1580 |
if (guided_scenario or "").startswith("Straight + drift"):
|
| 1581 |
legs, report_md, start_dt = solve_guided_straight_with_drift(
|
| 1582 |
-
method,
|
| 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,
|
| 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"**
|
|
|
|
| 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 (
|
| 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)")
|