Spaces:
Sleeping
Sleeping
Update app.py
Browse files
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 |
-
#
|
| 20 |
-
# 3) Plausible final major turn (18:22→18:40) can adjust BOTH heading and speed
|
| 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 |
-
#
|
| 26 |
-
# - Guided solver
|
| 27 |
-
# -
|
| 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 =
|
| 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 =
|
| 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,
|
| 1311 |
speed_knots=float(spd_turn),
|
| 1312 |
method=method,
|
| 1313 |
heading_final_deg=float(h_south),
|
| 1314 |
)
|
| 1315 |
-
full_legs =
|
|
|
|
|
|
|
| 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 |
-
|
| 1325 |
-
|
|
|
|
| 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 |
-
|
| 1348 |
-
|
|
|
|
| 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,
|
| 1413 |
speed_knots=float(spd_turn),
|
| 1414 |
method=method,
|
| 1415 |
heading_final_deg=float(h_eff),
|
| 1416 |
)
|
| 1417 |
-
full_legs =
|
|
|
|
| 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 |
-
|
| 1432 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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"**
|
| 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 |
-
|
| 1644 |
-
|
| 1645 |
-
|
| 1646 |
-
|
| 1647 |
-
|
| 1648 |
-
|
| 1649 |
-
|
| 1650 |
-
|
| 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():
|