Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,32 +1,19 @@
|
|
| 1 |
# app.py
|
| 2 |
# MH370 Flight Simulator — Leaflet map (Folium) + Ping Rings (KML)
|
| 3 |
-
# Features:
|
| 4 |
-
# (6) Multi-leg editor
|
| 5 |
-
# (7) Add / Reset legs
|
| 6 |
-
# (8) Stats panel
|
| 7 |
-
# (13) Permanent labeled waypoints
|
| 8 |
-
# (14) BFO analysis chart + MAE/RMSE
|
| 9 |
-
# Guided mode scenarios:
|
| 10 |
-
# A) Straight (single heading+speed) with FIXED schedule
|
| 11 |
-
# A2) Straight (nominal heading) + small per-leg drift (±deg) with penalty
|
| 12 |
-
# (REMOVED) Turn after Ping Ring 6
|
| 13 |
-
# (REMOVED) Radar fix → turn completion @18:28Z → southern solve
|
| 14 |
#
|
| 15 |
-
#
|
| 16 |
-
#
|
| 17 |
-
#
|
| 18 |
-
# NEW: Heading-drift penalty (keeps drift small + defensible)
|
| 19 |
#
|
| 20 |
-
#
|
| 21 |
-
# 1)
|
| 22 |
-
#
|
| 23 |
-
#
|
| 24 |
-
#
|
| 25 |
-
#
|
| 26 |
-
#
|
| 27 |
-
#
|
| 28 |
-
#
|
| 29 |
-
# + Remove the radar-fix UI controls
|
| 30 |
|
| 31 |
import os
|
| 32 |
import re
|
|
@@ -80,80 +67,80 @@ BTO_SCALE_US = 50.0
|
|
| 80 |
|
| 81 |
# EXPLICIT WEIGHTS
|
| 82 |
DEFAULT_WEIGHT_BFO = 1.0
|
| 83 |
-
DEFAULT_WEIGHT_BTO = 2.0 # emphasize BTO by default
|
| 84 |
|
| 85 |
# satLoc indices (confirmed by you)
|
| 86 |
SATLOC_IDX_MEAS_BFO = 4
|
| 87 |
SATLOC_IDX_MEAS_BTO = 5
|
| 88 |
|
| 89 |
-
#
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
-
#
|
| 94 |
-
|
| 95 |
-
GUIDED_END_LABEL = "24:20:00" # 00:20:00Z represented as 24:20:00
|
| 96 |
|
| 97 |
-
#
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
-
# Guided
|
|
|
|
|
|
|
| 101 |
STRAIGHT_STEP_DEG = 0.5
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
# ================= HEADING DRIFT =================
|
| 104 |
-
DEFAULT_DRIFT_MAX_DEG = 2.0
|
| 105 |
-
DRIFT_STEP_DEG = 0.5
|
| 106 |
-
DEFAULT_WEIGHT_DRIFT = 0.30
|
| 107 |
-
DEFAULT_DRIFT_ITERS = 2
|
| 108 |
-
DRIFT_SCALE_DEG = 1.0
|
|
|
|
| 109 |
|
| 110 |
# ================= SEARCH AREA COLORS =================
|
| 111 |
SEARCH_AREA_COLORS = {
|
| 112 |
"Ocean Infinity": "#ff3b3b", # red
|
| 113 |
"Australia": "#1f77b4", # blue
|
| 114 |
-
"Malays": "#2ca02c", # green (
|
| 115 |
"China": "#ff7f0e", # orange
|
| 116 |
"Seabed Constructor": "#9467bd", # purple
|
| 117 |
"Fugro": "#8c564b", # brown
|
| 118 |
}
|
| 119 |
-
DEFAULT_SEARCH_COLOR = "#00ffff"
|
| 120 |
|
| 121 |
|
| 122 |
# ================= PATH RESOLUTION =================
|
| 123 |
def resolve_local_or_mnt(path_like: str) -> str:
|
| 124 |
-
"""
|
| 125 |
-
Try:
|
| 126 |
-
1) as given
|
| 127 |
-
2) same dir as app.py
|
| 128 |
-
3) /mnt/data/<filename> (common upload mount)
|
| 129 |
-
4) /mnt/data/<path_like> (if they passed a relative path with dirs)
|
| 130 |
-
Returns best guess path (may still not exist).
|
| 131 |
-
"""
|
| 132 |
p = (path_like or "").strip()
|
| 133 |
if not p:
|
| 134 |
return p
|
| 135 |
-
|
| 136 |
if os.path.exists(p):
|
| 137 |
return p
|
| 138 |
-
|
| 139 |
here = os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals() else os.getcwd()
|
| 140 |
p2 = os.path.join(here, p)
|
| 141 |
if os.path.exists(p2):
|
| 142 |
return p2
|
| 143 |
-
|
| 144 |
base = os.path.basename(p)
|
| 145 |
p3 = os.path.join("/mnt/data", base)
|
| 146 |
if os.path.exists(p3):
|
| 147 |
return p3
|
| 148 |
-
|
| 149 |
p4 = os.path.join("/mnt/data", p)
|
| 150 |
if os.path.exists(p4):
|
| 151 |
return p4
|
| 152 |
-
|
| 153 |
return p
|
| 154 |
|
| 155 |
|
| 156 |
-
# ================= SEARCH AREA COLOR PICKER =================
|
| 157 |
def color_for_search_area(name: str) -> str:
|
| 158 |
lname = (name or "").lower()
|
| 159 |
for key, color in SEARCH_AREA_COLORS.items():
|
|
@@ -165,7 +152,6 @@ def color_for_search_area(name: str) -> str:
|
|
| 165 |
# ================= KML =================
|
| 166 |
def load_ping_rings_from_kml(path: str):
|
| 167 |
path = resolve_local_or_mnt(path)
|
| 168 |
-
|
| 169 |
if not os.path.exists(path):
|
| 170 |
raise FileNotFoundError(
|
| 171 |
f"KML file not found: '{path}'. Put it in the Space root (same folder as app.py)."
|
|
@@ -241,15 +227,7 @@ def _parse_kml_coordinates_text(txt: str):
|
|
| 241 |
|
| 242 |
|
| 243 |
def load_search_areas_from_kmz(kmz_path: str):
|
| 244 |
-
"""
|
| 245 |
-
Load features from a KMZ (KML inside zip). Supports:
|
| 246 |
-
- Placemark -> Point
|
| 247 |
-
- Placemark -> LineString
|
| 248 |
-
- Placemark -> Polygon
|
| 249 |
-
- MultiGeometry versions of the above
|
| 250 |
-
"""
|
| 251 |
kmz_path = resolve_local_or_mnt(kmz_path)
|
| 252 |
-
|
| 253 |
if not os.path.exists(kmz_path):
|
| 254 |
raise FileNotFoundError(
|
| 255 |
f"KMZ file not found: '{kmz_path}'. Put it next to app.py (Space root) or ensure it's mounted in /mnt/data."
|
|
@@ -263,7 +241,6 @@ def load_search_areas_from_kmz(kmz_path: str):
|
|
| 263 |
break
|
| 264 |
if not kml_name:
|
| 265 |
raise ValueError("KMZ contains no .kml file.")
|
| 266 |
-
|
| 267 |
root = ET.fromstring(zf.read(kml_name))
|
| 268 |
|
| 269 |
ns = _kml_ns_from_root(root)
|
|
@@ -273,9 +250,7 @@ def load_search_areas_from_kmz(kmz_path: str):
|
|
| 273 |
name_el = pm.find("kml:name", ns)
|
| 274 |
pm_name = (name_el.text.strip() if (name_el is not None and name_el.text) else "Search area")
|
| 275 |
|
| 276 |
-
points = []
|
| 277 |
-
lines = []
|
| 278 |
-
polys = []
|
| 279 |
|
| 280 |
for pt in pm.findall(".//kml:Point", ns):
|
| 281 |
coords = pt.find(".//kml:coordinates", ns)
|
|
@@ -370,6 +345,116 @@ def reset_legs():
|
|
| 370 |
return gr.update(value=[row[:] for row in DEFAULT_LEGS])
|
| 371 |
|
| 372 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
# ================= SIM CORE + STATS =================
|
| 374 |
def simulate_path(start_lat, start_lon, legs, method):
|
| 375 |
lat, lon = float(start_lat), float(start_lon)
|
|
@@ -378,11 +463,11 @@ def simulate_path(start_lat, start_lon, legs, method):
|
|
| 378 |
total_time_min = 0.0
|
| 379 |
|
| 380 |
for heading, speed_knots, minutes in legs:
|
| 381 |
-
dist_m = speed_knots * 1852.0 * (minutes / 60.0)
|
| 382 |
if method == "VINCENTY":
|
| 383 |
-
(lat, lon), _ = fp.vincenty_direct((lat, lon), heading, dist_m)
|
| 384 |
else:
|
| 385 |
-
lat, lon = fp.rhumb_direct((lat, lon), heading, dist_m)
|
| 386 |
|
| 387 |
pts.append((lat, lon))
|
| 388 |
seg_dists_m.append(dist_m)
|
|
@@ -457,6 +542,7 @@ def build_leaflet_map(path_pts, legs, seg_dists_m):
|
|
| 457 |
control_scale=True,
|
| 458 |
)
|
| 459 |
|
|
|
|
| 460 |
folium.TileLayer("OpenStreetMap", name="OpenStreetMap", overlay=False, control=True).add_to(m)
|
| 461 |
folium.TileLayer(
|
| 462 |
tiles="https://server.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}",
|
|
@@ -660,14 +746,6 @@ def _parse_start_time(s: str) -> datetime:
|
|
| 660 |
return dt.astimezone(timezone.utc)
|
| 661 |
|
| 662 |
|
| 663 |
-
def dt_guided_start_utc() -> datetime:
|
| 664 |
-
return fp._time_label_to_dt(GUIDED_START_LABEL, fp.SAT_EPOCH_UTC)
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
def dt_guided_end_utc() -> datetime:
|
| 668 |
-
return fp._time_label_to_dt(GUIDED_END_LABEL, fp.SAT_EPOCH_UTC)
|
| 669 |
-
|
| 670 |
-
|
| 671 |
def dt_ping6_utc() -> datetime:
|
| 672 |
return fp._time_label_to_dt("22:40:00", fp.SAT_EPOCH_UTC)
|
| 673 |
|
|
@@ -713,7 +791,7 @@ def build_altitude_plot(start_dt: datetime, total_time_min: float, alt0_ft: floa
|
|
| 713 |
return fig
|
| 714 |
|
| 715 |
|
| 716 |
-
# =================
|
| 717 |
def _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg_deg, spd_knots, t_inside_leg_sec):
|
| 718 |
dist_m = float(spd_knots) * 1852.0 * (float(t_inside_leg_sec) / 3600.0)
|
| 719 |
if method == "VINCENTY":
|
|
@@ -754,6 +832,7 @@ def bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_time_str, a
|
|
| 754 |
t_sec = (dt_ping - start_dt).total_seconds()
|
| 755 |
if t_sec < 0 or t_sec > total_end:
|
| 756 |
continue
|
|
|
|
| 757 |
i = int(np.searchsorted(boundaries, t_sec, side="right") - 1)
|
| 758 |
i = max(0, min(i, len(legs) - 1))
|
| 759 |
t_inside = t_sec - boundaries[i]
|
|
@@ -851,7 +930,6 @@ def make_legs_from_schedule(headings, speed_knots, schedule_minutes):
|
|
| 851 |
legs = []
|
| 852 |
if isinstance(headings, (int, float)):
|
| 853 |
headings = [float(headings)] * len(schedule_minutes)
|
| 854 |
-
|
| 855 |
for hdg, m in zip(headings, schedule_minutes):
|
| 856 |
m = float(m)
|
| 857 |
if m <= 0:
|
|
@@ -899,11 +977,11 @@ def score_legs_bfo_bto(
|
|
| 899 |
|
| 900 |
leg_start_lat, leg_start_lon = leg_start_positions[i]
|
| 901 |
hdg, spd, _ = legs[i]
|
| 902 |
-
|
| 903 |
lat, lon = _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg, spd, t_inside)
|
| 904 |
|
| 905 |
alt_now_ft = altitude_ft_at_dt(dt_ping, start_dt, float(alt_ft), float(vs_fpm), descent_start_dt)
|
| 906 |
alt_m = alt_now_ft * 0.3048
|
|
|
|
| 907 |
vs_mps = float(vs_fpm) * 0.00508
|
| 908 |
if descent_start_dt is not None and dt_ping < descent_start_dt:
|
| 909 |
vs_mps = 0.0
|
|
@@ -970,15 +1048,8 @@ def score_legs_bfo_bto_with_drift(
|
|
| 970 |
return s
|
| 971 |
|
| 972 |
|
| 973 |
-
def _wrap_heading_deg(h: float) -> float:
|
| 974 |
-
return float(h) % 360.0
|
| 975 |
-
|
| 976 |
-
|
| 977 |
def _apply_base_plus_deltas(base_headings, deltas_deg):
|
| 978 |
-
|
| 979 |
-
for b, d in zip(base_headings, deltas_deg):
|
| 980 |
-
headings.append(_wrap_heading_deg(float(b) + float(d)))
|
| 981 |
-
return headings
|
| 982 |
|
| 983 |
|
| 984 |
def _coordinate_descent_deltas(
|
|
@@ -998,49 +1069,58 @@ def _coordinate_descent_deltas(
|
|
| 998 |
|
| 999 |
best_s = None
|
| 1000 |
best_d = deltas[:]
|
|
|
|
| 1001 |
|
| 1002 |
for _it in range(max(1, int(iters))):
|
| 1003 |
improved = False
|
| 1004 |
for j in range(n):
|
| 1005 |
best_local_s = None
|
| 1006 |
best_local_dj = deltas[j]
|
|
|
|
|
|
|
| 1007 |
for dv in cand:
|
| 1008 |
trial = deltas[:]
|
| 1009 |
trial[j] = float(dv)
|
| 1010 |
trial_headings = _apply_base_plus_deltas(base_headings, trial)
|
| 1011 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1012 |
|
| 1013 |
s = score_legs_bfo_bto_with_drift(
|
| 1014 |
-
start_lat, start_lon, method, start_dt, end_dt,
|
| 1015 |
alt_ft, vs_fpm, descent_start_dt,
|
| 1016 |
weight_bfo, weight_bto,
|
| 1017 |
drift_weight, trial
|
| 1018 |
)
|
| 1019 |
if s is None:
|
| 1020 |
continue
|
|
|
|
| 1021 |
if best_local_s is None or s["combined"] < best_local_s["combined"]:
|
| 1022 |
best_local_s = s
|
| 1023 |
best_local_dj = float(dv)
|
|
|
|
| 1024 |
|
| 1025 |
deltas[j] = best_local_dj
|
| 1026 |
|
| 1027 |
if best_local_s is not None and (best_s is None or best_local_s["combined"] < best_s["combined"]):
|
| 1028 |
best_s = best_local_s
|
| 1029 |
best_d = deltas[:]
|
|
|
|
| 1030 |
improved = True
|
| 1031 |
|
| 1032 |
if not improved:
|
| 1033 |
break
|
| 1034 |
|
| 1035 |
-
|
| 1036 |
-
final_legs = make_legs_from_schedule(final_headings, float(speed_knots), schedule_minutes)
|
| 1037 |
-
final_s = score_legs_bfo_bto_with_drift(
|
| 1038 |
-
start_lat, start_lon, method, start_dt, end_dt, final_legs,
|
| 1039 |
-
alt_ft, vs_fpm, descent_start_dt,
|
| 1040 |
-
weight_bfo, weight_bto,
|
| 1041 |
-
drift_weight, best_d
|
| 1042 |
-
)
|
| 1043 |
-
return final_legs, final_s, best_d
|
| 1044 |
|
| 1045 |
|
| 1046 |
# ================= VALIDATION TABLE =================
|
|
@@ -1083,15 +1163,18 @@ def build_validation_table(
|
|
| 1083 |
elif (descent_start_choice or "").startswith("After Ping Ring 7"):
|
| 1084 |
descent_start_dt = dt_ping7_utc()
|
| 1085 |
|
|
|
|
| 1086 |
times = [start_dt]
|
| 1087 |
cum = 0.0
|
| 1088 |
for (_h, _s, m) in legs:
|
| 1089 |
cum += float(m)
|
| 1090 |
times.append(start_dt + timedelta(minutes=cum))
|
| 1091 |
|
|
|
|
| 1092 |
leg_secs = [float(m) * 60.0 for _, _, m in legs]
|
| 1093 |
boundaries = np.cumsum([0.0] + leg_secs)
|
| 1094 |
|
|
|
|
| 1095 |
cur_lat = float(start_lat)
|
| 1096 |
cur_lon = float(start_lon)
|
| 1097 |
leg_start_positions = [(cur_lat, cur_lon)]
|
|
@@ -1128,6 +1211,7 @@ def build_validation_table(
|
|
| 1128 |
|
| 1129 |
bfo_meas, bto_meas = _lookup_measured_at_dt(dt_k)
|
| 1130 |
|
|
|
|
| 1131 |
bfo_err = None if bfo_meas is None else (float(bfo_calc) - float(bfo_meas))
|
| 1132 |
bto_err = None if bto_meas is None else (float(bto_calc) - float(bto_meas))
|
| 1133 |
|
|
@@ -1154,12 +1238,21 @@ def build_validation_table(
|
|
| 1154 |
|
| 1155 |
|
| 1156 |
# ================= GUIDED SOLVERS =================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1157 |
def solve_guided_straight(
|
| 1158 |
-
|
| 1159 |
descent_start_choice, weight_bfo, weight_bto
|
| 1160 |
):
|
| 1161 |
-
start_dt =
|
| 1162 |
-
end_dt =
|
| 1163 |
|
| 1164 |
descent_start_dt = None
|
| 1165 |
if (descent_start_choice or "").startswith("After Ping Ring 6"):
|
|
@@ -1167,59 +1260,76 @@ def solve_guided_straight(
|
|
| 1167 |
elif (descent_start_choice or "").startswith("After Ping Ring 7"):
|
| 1168 |
descent_start_dt = dt_ping7_utc()
|
| 1169 |
|
| 1170 |
-
total_sched_min = float(np.sum(GUIDED_LEG_SCHEDULE_MIN))
|
| 1171 |
-
total_window_min = (end_dt - start_dt).total_seconds() / 60.0
|
| 1172 |
-
if abs(total_sched_min - total_window_min) > 0.01:
|
| 1173 |
-
return None, (
|
| 1174 |
-
f"Guided schedule mismatch: schedule totals {total_sched_min:.1f} min but window is {total_window_min:.1f} min."
|
| 1175 |
-
), None
|
| 1176 |
-
|
| 1177 |
best = None
|
| 1178 |
best_h = None
|
|
|
|
|
|
|
| 1179 |
|
| 1180 |
h = GUIDED_HEADING_MIN
|
| 1181 |
while h <= GUIDED_HEADING_MAX + 1e-9:
|
| 1182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1183 |
s = score_legs_bfo_bto(
|
| 1184 |
-
|
| 1185 |
alt_ft, vs_fpm, descent_start_dt,
|
| 1186 |
weight_bfo, weight_bto
|
| 1187 |
)
|
| 1188 |
if s is not None and (best is None or s["combined"] < best["combined"]):
|
| 1189 |
best = s
|
| 1190 |
best_h = float(h)
|
| 1191 |
-
|
|
|
|
| 1192 |
|
| 1193 |
-
|
| 1194 |
-
return None, f"Guided solve: not enough ping points between {GUIDED_START_LABEL} and {GUIDED_END_LABEL} to score.", None
|
| 1195 |
|
| 1196 |
-
|
|
|
|
| 1197 |
|
| 1198 |
rbto = best["rmse_bto"]
|
| 1199 |
rbto_txt = f"{rbto:.1f} µs" if rbto is not None else "n/a"
|
| 1200 |
sched_txt = ", ".join([f"{m:.0f}" for m in GUIDED_LEG_SCHEDULE_MIN]) + " min"
|
| 1201 |
|
|
|
|
| 1202 |
report = "\n".join([
|
| 1203 |
-
"**Guided mode: Straight (single heading)**",
|
| 1204 |
-
f"-
|
| 1205 |
-
f"-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1206 |
f"- Legs: **{sched_txt}**",
|
| 1207 |
-
|
| 1208 |
-
|
| 1209 |
f"- Weights: BFO **{float(weight_bfo):.2f}**, BTO **{float(weight_bto):.2f}**",
|
| 1210 |
f"- BFO points: **{best['n_bfo']}** | RMSE_BFO: **{best['rmse_bfo']:.2f} Hz**",
|
| 1211 |
f"- BTO points: **{best['n_bto']}** | RMSE_BTO: **{rbto_txt}**",
|
| 1212 |
])
|
| 1213 |
-
|
|
|
|
| 1214 |
|
| 1215 |
|
| 1216 |
def solve_guided_straight_with_drift(
|
| 1217 |
-
|
| 1218 |
descent_start_choice, weight_bfo, weight_bto,
|
| 1219 |
drift_max_deg, drift_weight
|
| 1220 |
):
|
| 1221 |
-
start_dt =
|
| 1222 |
-
end_dt =
|
| 1223 |
|
| 1224 |
descent_start_dt = None
|
| 1225 |
if (descent_start_choice or "").startswith("After Ping Ring 6"):
|
|
@@ -1227,17 +1337,11 @@ def solve_guided_straight_with_drift(
|
|
| 1227 |
elif (descent_start_choice or "").startswith("After Ping Ring 7"):
|
| 1228 |
descent_start_dt = dt_ping7_utc()
|
| 1229 |
|
| 1230 |
-
total_sched_min = float(np.sum(GUIDED_LEG_SCHEDULE_MIN))
|
| 1231 |
-
total_window_min = (end_dt - start_dt).total_seconds() / 60.0
|
| 1232 |
-
if abs(total_sched_min - total_window_min) > 0.01:
|
| 1233 |
-
return None, (
|
| 1234 |
-
f"Guided schedule mismatch: schedule totals {total_sched_min:.1f} min but window is {total_window_min:.1f} min."
|
| 1235 |
-
), None
|
| 1236 |
-
|
| 1237 |
best_s = None
|
| 1238 |
best_legs = None
|
| 1239 |
best_h0 = None
|
| 1240 |
best_deltas = None
|
|
|
|
| 1241 |
|
| 1242 |
n = len(GUIDED_LEG_SCHEDULE_MIN)
|
| 1243 |
|
|
@@ -1245,9 +1349,9 @@ def solve_guided_straight_with_drift(
|
|
| 1245 |
while h0 <= GUIDED_HEADING_MAX + 1e-9:
|
| 1246 |
base_headings = [float(h0)] * n
|
| 1247 |
|
| 1248 |
-
|
| 1249 |
-
|
| 1250 |
-
speed_knots, GUIDED_LEG_SCHEDULE_MIN,
|
| 1251 |
alt_ft, vs_fpm, descent_start_dt,
|
| 1252 |
weight_bfo, weight_bto,
|
| 1253 |
float(drift_weight),
|
|
@@ -1257,16 +1361,25 @@ def solve_guided_straight_with_drift(
|
|
| 1257 |
DEFAULT_DRIFT_ITERS
|
| 1258 |
)
|
| 1259 |
|
| 1260 |
-
if s is not None and (best_s is None or s["combined"] < best_s["combined"]):
|
| 1261 |
best_s = s
|
| 1262 |
-
best_legs =
|
| 1263 |
best_h0 = float(h0)
|
| 1264 |
best_deltas = deltas
|
| 1265 |
|
| 1266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1267 |
|
| 1268 |
if best_s is None or best_legs is None:
|
| 1269 |
-
return None, f"Guided drift solve: not enough ping points
|
| 1270 |
|
| 1271 |
rbto = best_s["rmse_bto"]
|
| 1272 |
rbto_txt = f"{rbto:.1f} µs" if rbto is not None else "n/a"
|
|
@@ -1277,16 +1390,29 @@ def solve_guided_straight_with_drift(
|
|
| 1277 |
if best_deltas is not None:
|
| 1278 |
drift_str = ", ".join([f"{d:+.1f}°" for d in best_deltas])
|
| 1279 |
|
|
|
|
|
|
|
| 1280 |
report = "\n".join([
|
| 1281 |
-
"**Guided mode: Straight +
|
| 1282 |
-
f"-
|
| 1283 |
-
f"-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1284 |
f"- Legs: **{sched_txt}**",
|
| 1285 |
-
f"- Speed: **{float(speed_knots):.1f} kt**",
|
| 1286 |
f"- Nominal heading H₀: **{best_h0:.1f}°** (searched {GUIDED_HEADING_MIN:.0f}–{GUIDED_HEADING_MAX:.0f}°, step 1.0°)",
|
| 1287 |
f"- Drift bounds: **±{float(drift_max_deg):.1f}°** | Drift penalty weight: **{float(drift_weight):.2f}**",
|
| 1288 |
f"- Drift RMS: **{rms_drift:.2f}°**",
|
| 1289 |
f"- Drift per leg (Δ): {drift_str}" if drift_str else "",
|
|
|
|
|
|
|
| 1290 |
f"- Weights: BFO **{float(weight_bfo):.2f}**, BTO **{float(weight_bto):.2f}**",
|
| 1291 |
f"- BFO points: **{best_s['n_bfo']}** | RMSE_BFO: **{best_s['rmse_bfo']:.2f} Hz**",
|
| 1292 |
f"- BTO points: **{best_s['n_bto']}** | RMSE_BTO: **{rbto_txt}**",
|
|
@@ -1335,23 +1461,20 @@ def run_guided(
|
|
| 1335 |
weight_bfo, weight_bto,
|
| 1336 |
drift_max_deg, drift_weight
|
| 1337 |
):
|
|
|
|
| 1338 |
spd_knots = speed_from_profile(speed_profile, alt_ft, custom_mach, custom_knots)
|
| 1339 |
|
| 1340 |
if (guided_scenario or "").startswith("Straight + drift"):
|
| 1341 |
legs, report_md, start_dt = solve_guided_straight_with_drift(
|
| 1342 |
-
|
| 1343 |
descent_start_choice, weight_bfo, weight_bto,
|
| 1344 |
drift_max_deg, drift_weight
|
| 1345 |
)
|
| 1346 |
-
sim_start_lat = start_lat
|
| 1347 |
-
sim_start_lon = start_lon
|
| 1348 |
else:
|
| 1349 |
legs, report_md, start_dt = solve_guided_straight(
|
| 1350 |
-
|
| 1351 |
descent_start_choice, weight_bfo, weight_bto
|
| 1352 |
)
|
| 1353 |
-
sim_start_lat = start_lat
|
| 1354 |
-
sim_start_lon = start_lon
|
| 1355 |
|
| 1356 |
if legs is None:
|
| 1357 |
fig = go.Figure()
|
|
@@ -1365,9 +1488,11 @@ def run_guided(
|
|
| 1365 |
], value=[])
|
| 1366 |
return gr.update(), f"### ❌ {report_md}", "", "", fig, "", alt_fig, empty_table
|
| 1367 |
|
|
|
|
| 1368 |
html_map, stats_md, bfo_fig, bfo_md, alt_fig, headers, rows = _run_common(
|
| 1369 |
-
|
| 1370 |
)
|
|
|
|
| 1371 |
return gr.update(value=legs_to_rows(legs)), report_md, html_map, stats_md, bfo_fig, bfo_md, alt_fig, gr.update(headers=headers, value=rows)
|
| 1372 |
|
| 1373 |
|
|
@@ -1389,14 +1514,22 @@ with gr.Blocks() as demo:
|
|
| 1389 |
"Make sure `Search areas.kmz` is uploaded and `SEARCH_AREAS_KMZ` matches exactly."
|
| 1390 |
)
|
| 1391 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1392 |
with gr.Row():
|
| 1393 |
-
start_lat = gr.Slider(-10, 15, value=6.76, label="Start Latitude (
|
| 1394 |
-
start_lon = gr.Slider(70, 115, value=95.69, label="Start Longitude (
|
| 1395 |
method = gr.Dropdown(["VINCENTY", "RHUMB"], value="VINCENTY", label="Method")
|
| 1396 |
|
| 1397 |
gr.Markdown("### BFO/BTO Settings")
|
| 1398 |
with gr.Row():
|
| 1399 |
-
start_time_str = gr.Textbox(value=
|
| 1400 |
alt_ft = gr.Number(value=35000, label="Altitude at start (ft)")
|
| 1401 |
vs_fpm = gr.Number(value=0, label="Vertical speed (ft/min) (negative = descent)")
|
| 1402 |
descent_start_choice = gr.Dropdown(
|
|
@@ -1418,7 +1551,7 @@ with gr.Blocks() as demo:
|
|
| 1418 |
speed_profile = gr.Dropdown(
|
| 1419 |
["MRC Mach 0.82", "LRC Mach 0.84", "ECON Mach 0.80", "Custom Mach", "Custom knots"],
|
| 1420 |
value="MRC Mach 0.82",
|
| 1421 |
-
label="Speed profile",
|
| 1422 |
)
|
| 1423 |
custom_mach = gr.Number(value=0.82, label="Custom Mach (if selected)")
|
| 1424 |
custom_knots = gr.Number(value=470, label="Custom knots (if selected)")
|
|
|
|
| 1 |
# app.py
|
| 2 |
# MH370 Flight Simulator — Leaflet map (Folium) + Ping Rings (KML)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
#
|
| 4 |
+
# Guided scenarios (ACTIVE):
|
| 5 |
+
# A) Straight (single heading) ✅
|
| 6 |
+
# A2) Straight + drift (per-leg ±deg, penalized) ✅
|
|
|
|
| 7 |
#
|
| 8 |
+
# Changes implemented (your request):
|
| 9 |
+
# 1) Last radar spot is FIXED:
|
| 10 |
+
# 18:22:00Z @ 6.578N, 96.341E (cannot be changed)
|
| 11 |
+
# 2) Turn completion is FIXED:
|
| 12 |
+
# 18:40:00Z (final major turn completed by this time)
|
| 13 |
+
# 3) Guided mode now automatically computes a realistic pre-turn path (18:22→18:40)
|
| 14 |
+
# and plots it on the map before the southern leg.
|
| 15 |
+
# 4) Speed during the pre-turn is constrained to the user-selected speed.
|
| 16 |
+
# 5) Validation table continues to output waypoints at schedule timestamps.
|
|
|
|
| 17 |
|
| 18 |
import os
|
| 19 |
import re
|
|
|
|
| 67 |
|
| 68 |
# EXPLICIT WEIGHTS
|
| 69 |
DEFAULT_WEIGHT_BFO = 1.0
|
| 70 |
+
DEFAULT_WEIGHT_BTO = 2.0 # emphasize BTO by default
|
| 71 |
|
| 72 |
# satLoc indices (confirmed by you)
|
| 73 |
SATLOC_IDX_MEAS_BFO = 4
|
| 74 |
SATLOC_IDX_MEAS_BTO = 5
|
| 75 |
|
| 76 |
+
# ===== Fixed Radar + Turn Completion =====
|
| 77 |
+
RADAR_FIX_LAT = 6.578
|
| 78 |
+
RADAR_FIX_LON = 96.341
|
| 79 |
+
RADAR_FIX_TIME_UTC = datetime(2014, 3, 7, 18, 22, 0, tzinfo=timezone.utc)
|
| 80 |
+
|
| 81 |
+
TURN_COMPLETE_TIME_UTC = datetime(2014, 3, 7, 18, 40, 0, tzinfo=timezone.utc)
|
| 82 |
+
TURN_WINDOW_MIN = (TURN_COMPLETE_TIME_UTC - RADAR_FIX_TIME_UTC).total_seconds() / 60.0
|
| 83 |
|
| 84 |
+
# Initial heading from radar panel (TRK 296°T)
|
| 85 |
+
RADAR_INITIAL_TRACK_DEG = 296.0
|
|
|
|
| 86 |
|
| 87 |
+
# Turn modeling parameters (realistic constant-bank turn)
|
| 88 |
+
TURN_BANK_MIN_DEG = 15.0
|
| 89 |
+
TURN_BANK_MAX_DEG = 30.0
|
| 90 |
+
TURN_BANK_DEFAULT_DEG = 25.0
|
| 91 |
+
TURN_STEP_SEC = 30.0 # discretize turn into small straight segments for map + scoring
|
| 92 |
|
| 93 |
+
# ===== Guided heading search (southern-ish) =====
|
| 94 |
+
GUIDED_HEADING_MIN = 150.0
|
| 95 |
+
GUIDED_HEADING_MAX = 210.0
|
| 96 |
STRAIGHT_STEP_DEG = 0.5
|
| 97 |
|
| 98 |
+
# ===== Guided schedule for southern leg =====
|
| 99 |
+
# This schedule applies AFTER 18:40, but total sim starts at 18:22 including the turn window.
|
| 100 |
+
GUIDED_LEG_SCHEDULE_MIN = [12.0, 60.0, 60.0, 60.0, 60.0, 90.0, 10.0] # 352 min
|
| 101 |
+
|
| 102 |
+
|
| 103 |
# ================= HEADING DRIFT =================
|
| 104 |
+
DEFAULT_DRIFT_MAX_DEG = 2.0
|
| 105 |
+
DRIFT_STEP_DEG = 0.5
|
| 106 |
+
DEFAULT_WEIGHT_DRIFT = 0.30
|
| 107 |
+
DEFAULT_DRIFT_ITERS = 2
|
| 108 |
+
DRIFT_SCALE_DEG = 1.0
|
| 109 |
+
|
| 110 |
|
| 111 |
# ================= SEARCH AREA COLORS =================
|
| 112 |
SEARCH_AREA_COLORS = {
|
| 113 |
"Ocean Infinity": "#ff3b3b", # red
|
| 114 |
"Australia": "#1f77b4", # blue
|
| 115 |
+
"Malays": "#2ca02c", # green (Malaysia/Malaysian)
|
| 116 |
"China": "#ff7f0e", # orange
|
| 117 |
"Seabed Constructor": "#9467bd", # purple
|
| 118 |
"Fugro": "#8c564b", # brown
|
| 119 |
}
|
| 120 |
+
DEFAULT_SEARCH_COLOR = "#00ffff"
|
| 121 |
|
| 122 |
|
| 123 |
# ================= PATH RESOLUTION =================
|
| 124 |
def resolve_local_or_mnt(path_like: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
p = (path_like or "").strip()
|
| 126 |
if not p:
|
| 127 |
return p
|
|
|
|
| 128 |
if os.path.exists(p):
|
| 129 |
return p
|
|
|
|
| 130 |
here = os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals() else os.getcwd()
|
| 131 |
p2 = os.path.join(here, p)
|
| 132 |
if os.path.exists(p2):
|
| 133 |
return p2
|
|
|
|
| 134 |
base = os.path.basename(p)
|
| 135 |
p3 = os.path.join("/mnt/data", base)
|
| 136 |
if os.path.exists(p3):
|
| 137 |
return p3
|
|
|
|
| 138 |
p4 = os.path.join("/mnt/data", p)
|
| 139 |
if os.path.exists(p4):
|
| 140 |
return p4
|
|
|
|
| 141 |
return p
|
| 142 |
|
| 143 |
|
|
|
|
| 144 |
def color_for_search_area(name: str) -> str:
|
| 145 |
lname = (name or "").lower()
|
| 146 |
for key, color in SEARCH_AREA_COLORS.items():
|
|
|
|
| 152 |
# ================= KML =================
|
| 153 |
def load_ping_rings_from_kml(path: str):
|
| 154 |
path = resolve_local_or_mnt(path)
|
|
|
|
| 155 |
if not os.path.exists(path):
|
| 156 |
raise FileNotFoundError(
|
| 157 |
f"KML file not found: '{path}'. Put it in the Space root (same folder as app.py)."
|
|
|
|
| 227 |
|
| 228 |
|
| 229 |
def load_search_areas_from_kmz(kmz_path: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
kmz_path = resolve_local_or_mnt(kmz_path)
|
|
|
|
| 231 |
if not os.path.exists(kmz_path):
|
| 232 |
raise FileNotFoundError(
|
| 233 |
f"KMZ file not found: '{kmz_path}'. Put it next to app.py (Space root) or ensure it's mounted in /mnt/data."
|
|
|
|
| 241 |
break
|
| 242 |
if not kml_name:
|
| 243 |
raise ValueError("KMZ contains no .kml file.")
|
|
|
|
| 244 |
root = ET.fromstring(zf.read(kml_name))
|
| 245 |
|
| 246 |
ns = _kml_ns_from_root(root)
|
|
|
|
| 250 |
name_el = pm.find("kml:name", ns)
|
| 251 |
pm_name = (name_el.text.strip() if (name_el is not None and name_el.text) else "Search area")
|
| 252 |
|
| 253 |
+
points, lines, polys = [], [], []
|
|
|
|
|
|
|
| 254 |
|
| 255 |
for pt in pm.findall(".//kml:Point", ns):
|
| 256 |
coords = pt.find(".//kml:coordinates", ns)
|
|
|
|
| 345 |
return gr.update(value=[row[:] for row in DEFAULT_LEGS])
|
| 346 |
|
| 347 |
|
| 348 |
+
# ================= GEO HELPERS =================
|
| 349 |
+
def _wrap_heading_deg(h: float) -> float:
|
| 350 |
+
return float(h) % 360.0
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def _shortest_signed_delta_deg(h_from: float, h_to: float) -> float:
|
| 354 |
+
# result in [-180, +180]
|
| 355 |
+
return ((float(h_to) - float(h_from) + 540.0) % 360.0) - 180.0
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def _turn_rate_deg_per_sec(speed_knots: float, bank_deg: float) -> float:
|
| 359 |
+
# omega = g*tan(bank)/V
|
| 360 |
+
g = 9.80665
|
| 361 |
+
V = max(10.0, float(speed_knots) * 0.514444) # m/s
|
| 362 |
+
bank = math.radians(max(1e-3, float(bank_deg)))
|
| 363 |
+
omega = g * math.tan(bank) / V # rad/s
|
| 364 |
+
return float(omega * 180.0 / math.pi)
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def build_turn_legs(
|
| 368 |
+
speed_knots: float,
|
| 369 |
+
method: str,
|
| 370 |
+
heading_final_deg: float,
|
| 371 |
+
bank_pref_deg: float = TURN_BANK_DEFAULT_DEG,
|
| 372 |
+
step_sec: float = TURN_STEP_SEC
|
| 373 |
+
):
|
| 374 |
+
"""
|
| 375 |
+
Build legs from radar fix (18:22) to turn completion (18:40).
|
| 376 |
+
|
| 377 |
+
Model:
|
| 378 |
+
- straight segment on RADAR_INITIAL_TRACK_DEG
|
| 379 |
+
- then constant-bank turn to heading_final_deg
|
| 380 |
+
- ends exactly at 18:40
|
| 381 |
+
|
| 382 |
+
Returns:
|
| 383 |
+
legs_turn, info_dict
|
| 384 |
+
"""
|
| 385 |
+
total_sec = (TURN_COMPLETE_TIME_UTC - RADAR_FIX_TIME_UTC).total_seconds()
|
| 386 |
+
if total_sec <= 0:
|
| 387 |
+
return [], {"error": "Invalid turn window."}
|
| 388 |
+
|
| 389 |
+
h0 = float(RADAR_INITIAL_TRACK_DEG)
|
| 390 |
+
h1 = _wrap_heading_deg(float(heading_final_deg))
|
| 391 |
+
delta = _shortest_signed_delta_deg(h0, h1)
|
| 392 |
+
sign = 1.0 if delta >= 0 else -1.0
|
| 393 |
+
delta_abs = abs(delta)
|
| 394 |
+
|
| 395 |
+
# choose bank angle so turn duration fits window
|
| 396 |
+
bank_candidates = [float(bank_pref_deg)] + list(np.linspace(TURN_BANK_MIN_DEG, TURN_BANK_MAX_DEG, 9))
|
| 397 |
+
best_bank = None
|
| 398 |
+
best_turn_sec = None
|
| 399 |
+
|
| 400 |
+
for b in bank_candidates:
|
| 401 |
+
rate = _turn_rate_deg_per_sec(speed_knots, b)
|
| 402 |
+
if rate <= 1e-6:
|
| 403 |
+
continue
|
| 404 |
+
t_turn = delta_abs / rate
|
| 405 |
+
if t_turn <= total_sec + 1e-6:
|
| 406 |
+
# prefer bank close to preferred
|
| 407 |
+
if best_bank is None:
|
| 408 |
+
best_bank, best_turn_sec = b, t_turn
|
| 409 |
+
else:
|
| 410 |
+
if abs(b - bank_pref_deg) < abs(best_bank - bank_pref_deg):
|
| 411 |
+
best_bank, best_turn_sec = b, t_turn
|
| 412 |
+
|
| 413 |
+
# fallback: max bank
|
| 414 |
+
if best_bank is None:
|
| 415 |
+
best_bank = TURN_BANK_MAX_DEG
|
| 416 |
+
best_turn_sec = delta_abs / max(1e-6, _turn_rate_deg_per_sec(speed_knots, best_bank))
|
| 417 |
+
best_turn_sec = min(best_turn_sec, total_sec)
|
| 418 |
+
|
| 419 |
+
turn_sec = float(best_turn_sec)
|
| 420 |
+
straight_sec = max(0.0, float(total_sec) - turn_sec)
|
| 421 |
+
|
| 422 |
+
legs = []
|
| 423 |
+
|
| 424 |
+
# straight leg (if any)
|
| 425 |
+
if straight_sec > 1.0:
|
| 426 |
+
legs.append((h0, float(speed_knots), straight_sec / 60.0))
|
| 427 |
+
|
| 428 |
+
# turn legs discretized
|
| 429 |
+
if turn_sec > 1.0 and delta_abs > 1e-6:
|
| 430 |
+
step_sec = max(5.0, float(step_sec))
|
| 431 |
+
n = int(math.ceil(turn_sec / step_sec))
|
| 432 |
+
if n < 1:
|
| 433 |
+
n = 1
|
| 434 |
+
# actual step sizes: equal steps except last adjusted
|
| 435 |
+
remaining = turn_sec
|
| 436 |
+
for i in range(n):
|
| 437 |
+
this = min(step_sec, remaining)
|
| 438 |
+
remaining -= this
|
| 439 |
+
|
| 440 |
+
# heading at midpoint of segment:
|
| 441 |
+
frac_mid = ( (i + 0.5) / n )
|
| 442 |
+
hdg_mid = _wrap_heading_deg(h0 + sign * delta_abs * frac_mid)
|
| 443 |
+
|
| 444 |
+
legs.append((hdg_mid, float(speed_knots), this / 60.0))
|
| 445 |
+
|
| 446 |
+
info = {
|
| 447 |
+
"h0": h0,
|
| 448 |
+
"h1": h1,
|
| 449 |
+
"delta_deg": delta,
|
| 450 |
+
"bank_deg": float(best_bank),
|
| 451 |
+
"straight_min": straight_sec / 60.0,
|
| 452 |
+
"turn_min": turn_sec / 60.0,
|
| 453 |
+
"total_min": total_sec / 60.0,
|
| 454 |
+
}
|
| 455 |
+
return legs, info
|
| 456 |
+
|
| 457 |
+
|
| 458 |
# ================= SIM CORE + STATS =================
|
| 459 |
def simulate_path(start_lat, start_lon, legs, method):
|
| 460 |
lat, lon = float(start_lat), float(start_lon)
|
|
|
|
| 463 |
total_time_min = 0.0
|
| 464 |
|
| 465 |
for heading, speed_knots, minutes in legs:
|
| 466 |
+
dist_m = float(speed_knots) * 1852.0 * (float(minutes) / 60.0)
|
| 467 |
if method == "VINCENTY":
|
| 468 |
+
(lat, lon), _ = fp.vincenty_direct((lat, lon), float(heading), dist_m)
|
| 469 |
else:
|
| 470 |
+
lat, lon = fp.rhumb_direct((lat, lon), float(heading), dist_m)
|
| 471 |
|
| 472 |
pts.append((lat, lon))
|
| 473 |
seg_dists_m.append(dist_m)
|
|
|
|
| 542 |
control_scale=True,
|
| 543 |
)
|
| 544 |
|
| 545 |
+
# Basemaps (no LayerControl requested)
|
| 546 |
folium.TileLayer("OpenStreetMap", name="OpenStreetMap", overlay=False, control=True).add_to(m)
|
| 547 |
folium.TileLayer(
|
| 548 |
tiles="https://server.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}",
|
|
|
|
| 746 |
return dt.astimezone(timezone.utc)
|
| 747 |
|
| 748 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 749 |
def dt_ping6_utc() -> datetime:
|
| 750 |
return fp._time_label_to_dt("22:40:00", fp.SAT_EPOCH_UTC)
|
| 751 |
|
|
|
|
| 791 |
return fig
|
| 792 |
|
| 793 |
|
| 794 |
+
# ================= PREDICT STATE =================
|
| 795 |
def _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg_deg, spd_knots, t_inside_leg_sec):
|
| 796 |
dist_m = float(spd_knots) * 1852.0 * (float(t_inside_leg_sec) / 3600.0)
|
| 797 |
if method == "VINCENTY":
|
|
|
|
| 832 |
t_sec = (dt_ping - start_dt).total_seconds()
|
| 833 |
if t_sec < 0 or t_sec > total_end:
|
| 834 |
continue
|
| 835 |
+
|
| 836 |
i = int(np.searchsorted(boundaries, t_sec, side="right") - 1)
|
| 837 |
i = max(0, min(i, len(legs) - 1))
|
| 838 |
t_inside = t_sec - boundaries[i]
|
|
|
|
| 930 |
legs = []
|
| 931 |
if isinstance(headings, (int, float)):
|
| 932 |
headings = [float(headings)] * len(schedule_minutes)
|
|
|
|
| 933 |
for hdg, m in zip(headings, schedule_minutes):
|
| 934 |
m = float(m)
|
| 935 |
if m <= 0:
|
|
|
|
| 977 |
|
| 978 |
leg_start_lat, leg_start_lon = leg_start_positions[i]
|
| 979 |
hdg, spd, _ = legs[i]
|
|
|
|
| 980 |
lat, lon = _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg, spd, t_inside)
|
| 981 |
|
| 982 |
alt_now_ft = altitude_ft_at_dt(dt_ping, start_dt, float(alt_ft), float(vs_fpm), descent_start_dt)
|
| 983 |
alt_m = alt_now_ft * 0.3048
|
| 984 |
+
|
| 985 |
vs_mps = float(vs_fpm) * 0.00508
|
| 986 |
if descent_start_dt is not None and dt_ping < descent_start_dt:
|
| 987 |
vs_mps = 0.0
|
|
|
|
| 1048 |
return s
|
| 1049 |
|
| 1050 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1051 |
def _apply_base_plus_deltas(base_headings, deltas_deg):
|
| 1052 |
+
return [_wrap_heading_deg(float(b) + float(d)) for b, d in zip(base_headings, deltas_deg)]
|
|
|
|
|
|
|
|
|
|
| 1053 |
|
| 1054 |
|
| 1055 |
def _coordinate_descent_deltas(
|
|
|
|
| 1069 |
|
| 1070 |
best_s = None
|
| 1071 |
best_d = deltas[:]
|
| 1072 |
+
best_legs = None
|
| 1073 |
|
| 1074 |
for _it in range(max(1, int(iters))):
|
| 1075 |
improved = False
|
| 1076 |
for j in range(n):
|
| 1077 |
best_local_s = None
|
| 1078 |
best_local_dj = deltas[j]
|
| 1079 |
+
best_local_legs = None
|
| 1080 |
+
|
| 1081 |
for dv in cand:
|
| 1082 |
trial = deltas[:]
|
| 1083 |
trial[j] = float(dv)
|
| 1084 |
trial_headings = _apply_base_plus_deltas(base_headings, trial)
|
| 1085 |
+
|
| 1086 |
+
# southern legs
|
| 1087 |
+
southern_legs = make_legs_from_schedule(trial_headings, float(speed_knots), schedule_minutes)
|
| 1088 |
+
|
| 1089 |
+
# pre-turn legs built to align with the FIRST southern heading at 18:40
|
| 1090 |
+
preturn_legs, _info = build_turn_legs(
|
| 1091 |
+
speed_knots=float(speed_knots),
|
| 1092 |
+
method=method,
|
| 1093 |
+
heading_final_deg=float(trial_headings[0]),
|
| 1094 |
+
)
|
| 1095 |
+
|
| 1096 |
+
full_legs = preturn_legs + southern_legs
|
| 1097 |
|
| 1098 |
s = score_legs_bfo_bto_with_drift(
|
| 1099 |
+
start_lat, start_lon, method, start_dt, end_dt, full_legs,
|
| 1100 |
alt_ft, vs_fpm, descent_start_dt,
|
| 1101 |
weight_bfo, weight_bto,
|
| 1102 |
drift_weight, trial
|
| 1103 |
)
|
| 1104 |
if s is None:
|
| 1105 |
continue
|
| 1106 |
+
|
| 1107 |
if best_local_s is None or s["combined"] < best_local_s["combined"]:
|
| 1108 |
best_local_s = s
|
| 1109 |
best_local_dj = float(dv)
|
| 1110 |
+
best_local_legs = full_legs
|
| 1111 |
|
| 1112 |
deltas[j] = best_local_dj
|
| 1113 |
|
| 1114 |
if best_local_s is not None and (best_s is None or best_local_s["combined"] < best_s["combined"]):
|
| 1115 |
best_s = best_local_s
|
| 1116 |
best_d = deltas[:]
|
| 1117 |
+
best_legs = best_local_legs
|
| 1118 |
improved = True
|
| 1119 |
|
| 1120 |
if not improved:
|
| 1121 |
break
|
| 1122 |
|
| 1123 |
+
return best_legs, best_s, best_d
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1124 |
|
| 1125 |
|
| 1126 |
# ================= VALIDATION TABLE =================
|
|
|
|
| 1163 |
elif (descent_start_choice or "").startswith("After Ping Ring 7"):
|
| 1164 |
descent_start_dt = dt_ping7_utc()
|
| 1165 |
|
| 1166 |
+
# Schedule timestamps: start + cumulative leg minutes
|
| 1167 |
times = [start_dt]
|
| 1168 |
cum = 0.0
|
| 1169 |
for (_h, _s, m) in legs:
|
| 1170 |
cum += float(m)
|
| 1171 |
times.append(start_dt + timedelta(minutes=cum))
|
| 1172 |
|
| 1173 |
+
# boundaries for segment lookup
|
| 1174 |
leg_secs = [float(m) * 60.0 for _, _, m in legs]
|
| 1175 |
boundaries = np.cumsum([0.0] + leg_secs)
|
| 1176 |
|
| 1177 |
+
# precompute leg start positions
|
| 1178 |
cur_lat = float(start_lat)
|
| 1179 |
cur_lon = float(start_lon)
|
| 1180 |
leg_start_positions = [(cur_lat, cur_lon)]
|
|
|
|
| 1211 |
|
| 1212 |
bfo_meas, bto_meas = _lookup_measured_at_dt(dt_k)
|
| 1213 |
|
| 1214 |
+
# errors (calc - meas)
|
| 1215 |
bfo_err = None if bfo_meas is None else (float(bfo_calc) - float(bfo_meas))
|
| 1216 |
bto_err = None if bto_meas is None else (float(bto_calc) - float(bto_meas))
|
| 1217 |
|
|
|
|
| 1238 |
|
| 1239 |
|
| 1240 |
# ================= GUIDED SOLVERS =================
|
| 1241 |
+
def _guided_start_dt():
|
| 1242 |
+
return RADAR_FIX_TIME_UTC
|
| 1243 |
+
|
| 1244 |
+
|
| 1245 |
+
def _guided_end_dt():
|
| 1246 |
+
total_min = TURN_WINDOW_MIN + float(np.sum(GUIDED_LEG_SCHEDULE_MIN))
|
| 1247 |
+
return RADAR_FIX_TIME_UTC + timedelta(minutes=total_min)
|
| 1248 |
+
|
| 1249 |
+
|
| 1250 |
def solve_guided_straight(
|
| 1251 |
+
method, speed_knots, alt_ft, vs_fpm,
|
| 1252 |
descent_start_choice, weight_bfo, weight_bto
|
| 1253 |
):
|
| 1254 |
+
start_dt = _guided_start_dt()
|
| 1255 |
+
end_dt = _guided_end_dt()
|
| 1256 |
|
| 1257 |
descent_start_dt = None
|
| 1258 |
if (descent_start_choice or "").startswith("After Ping Ring 6"):
|
|
|
|
| 1260 |
elif (descent_start_choice or "").startswith("After Ping Ring 7"):
|
| 1261 |
descent_start_dt = dt_ping7_utc()
|
| 1262 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1263 |
best = None
|
| 1264 |
best_h = None
|
| 1265 |
+
best_full_legs = None
|
| 1266 |
+
best_turn_info = None
|
| 1267 |
|
| 1268 |
h = GUIDED_HEADING_MIN
|
| 1269 |
while h <= GUIDED_HEADING_MAX + 1e-9:
|
| 1270 |
+
southern_legs = make_legs_from_schedule(h, float(speed_knots), GUIDED_LEG_SCHEDULE_MIN)
|
| 1271 |
+
|
| 1272 |
+
preturn_legs, turn_info = build_turn_legs(
|
| 1273 |
+
speed_knots=float(speed_knots),
|
| 1274 |
+
method=method,
|
| 1275 |
+
heading_final_deg=float(h),
|
| 1276 |
+
)
|
| 1277 |
+
full_legs = preturn_legs + southern_legs
|
| 1278 |
+
|
| 1279 |
s = score_legs_bfo_bto(
|
| 1280 |
+
RADAR_FIX_LAT, RADAR_FIX_LON, method, start_dt, end_dt, full_legs,
|
| 1281 |
alt_ft, vs_fpm, descent_start_dt,
|
| 1282 |
weight_bfo, weight_bto
|
| 1283 |
)
|
| 1284 |
if s is not None and (best is None or s["combined"] < best["combined"]):
|
| 1285 |
best = s
|
| 1286 |
best_h = float(h)
|
| 1287 |
+
best_full_legs = full_legs
|
| 1288 |
+
best_turn_info = turn_info
|
| 1289 |
|
| 1290 |
+
h += STRAIGHT_STEP_DEG
|
|
|
|
| 1291 |
|
| 1292 |
+
if best is None or best_full_legs is None:
|
| 1293 |
+
return None, f"Guided solve: not enough ping points to score in window {start_dt.strftime('%H:%MZ')} → {end_dt.strftime('%H:%MZ')}.", None
|
| 1294 |
|
| 1295 |
rbto = best["rmse_bto"]
|
| 1296 |
rbto_txt = f"{rbto:.1f} µs" if rbto is not None else "n/a"
|
| 1297 |
sched_txt = ", ".join([f"{m:.0f}" for m in GUIDED_LEG_SCHEDULE_MIN]) + " min"
|
| 1298 |
|
| 1299 |
+
tinfo = best_turn_info or {}
|
| 1300 |
report = "\n".join([
|
| 1301 |
+
"**Guided mode: Straight (single heading) + computed turn (18:22→18:40)**",
|
| 1302 |
+
f"- Radar fix (fixed): **18:22:00Z** @ lat **{RADAR_FIX_LAT:.3f}**, lon **{RADAR_FIX_LON:.3f}**",
|
| 1303 |
+
f"- Turn completion (fixed): **18:40:00Z**",
|
| 1304 |
+
f"- Initial track (fixed): **{RADAR_INITIAL_TRACK_DEG:.1f}°**",
|
| 1305 |
+
f"- Speed (constrained): **{float(speed_knots):.1f} kt**",
|
| 1306 |
+
f"- Best southern heading: **{best_h:.1f}°** (searched {GUIDED_HEADING_MIN:.0f}–{GUIDED_HEADING_MAX:.0f}°, step {STRAIGHT_STEP_DEG}°)",
|
| 1307 |
+
"",
|
| 1308 |
+
"**Computed turn model**",
|
| 1309 |
+
f"- Bank angle: **{tinfo.get('bank_deg', TURN_BANK_DEFAULT_DEG):.1f}°**",
|
| 1310 |
+
f"- Heading change: **{tinfo.get('delta_deg', 0.0):+.1f}°**",
|
| 1311 |
+
f"- Straight-before-turn: **{tinfo.get('straight_min', 0.0):.2f} min**",
|
| 1312 |
+
f"- Turn duration: **{tinfo.get('turn_min', 0.0):.2f} min**",
|
| 1313 |
+
"",
|
| 1314 |
+
"**Southern schedule**",
|
| 1315 |
f"- Legs: **{sched_txt}**",
|
| 1316 |
+
"",
|
| 1317 |
+
"**Fit**",
|
| 1318 |
f"- Weights: BFO **{float(weight_bfo):.2f}**, BTO **{float(weight_bto):.2f}**",
|
| 1319 |
f"- BFO points: **{best['n_bfo']}** | RMSE_BFO: **{best['rmse_bfo']:.2f} Hz**",
|
| 1320 |
f"- BTO points: **{best['n_bto']}** | RMSE_BTO: **{rbto_txt}**",
|
| 1321 |
])
|
| 1322 |
+
|
| 1323 |
+
return best_full_legs, report, start_dt
|
| 1324 |
|
| 1325 |
|
| 1326 |
def solve_guided_straight_with_drift(
|
| 1327 |
+
method, speed_knots, alt_ft, vs_fpm,
|
| 1328 |
descent_start_choice, weight_bfo, weight_bto,
|
| 1329 |
drift_max_deg, drift_weight
|
| 1330 |
):
|
| 1331 |
+
start_dt = _guided_start_dt()
|
| 1332 |
+
end_dt = _guided_end_dt()
|
| 1333 |
|
| 1334 |
descent_start_dt = None
|
| 1335 |
if (descent_start_choice or "").startswith("After Ping Ring 6"):
|
|
|
|
| 1337 |
elif (descent_start_choice or "").startswith("After Ping Ring 7"):
|
| 1338 |
descent_start_dt = dt_ping7_utc()
|
| 1339 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1340 |
best_s = None
|
| 1341 |
best_legs = None
|
| 1342 |
best_h0 = None
|
| 1343 |
best_deltas = None
|
| 1344 |
+
best_turn_info = None
|
| 1345 |
|
| 1346 |
n = len(GUIDED_LEG_SCHEDULE_MIN)
|
| 1347 |
|
|
|
|
| 1349 |
while h0 <= GUIDED_HEADING_MAX + 1e-9:
|
| 1350 |
base_headings = [float(h0)] * n
|
| 1351 |
|
| 1352 |
+
full_legs, s, deltas = _coordinate_descent_deltas(
|
| 1353 |
+
RADAR_FIX_LAT, RADAR_FIX_LON, method, start_dt, end_dt,
|
| 1354 |
+
float(speed_knots), GUIDED_LEG_SCHEDULE_MIN,
|
| 1355 |
alt_ft, vs_fpm, descent_start_dt,
|
| 1356 |
weight_bfo, weight_bto,
|
| 1357 |
float(drift_weight),
|
|
|
|
| 1361 |
DEFAULT_DRIFT_ITERS
|
| 1362 |
)
|
| 1363 |
|
| 1364 |
+
if s is not None and full_legs is not None and (best_s is None or s["combined"] < best_s["combined"]):
|
| 1365 |
best_s = s
|
| 1366 |
+
best_legs = full_legs
|
| 1367 |
best_h0 = float(h0)
|
| 1368 |
best_deltas = deltas
|
| 1369 |
|
| 1370 |
+
# compute turn info based on first southern heading (base + delta[0])
|
| 1371 |
+
trial_headings = _apply_base_plus_deltas(base_headings, deltas)
|
| 1372 |
+
_, turn_info = build_turn_legs(
|
| 1373 |
+
speed_knots=float(speed_knots),
|
| 1374 |
+
method=method,
|
| 1375 |
+
heading_final_deg=float(trial_headings[0]),
|
| 1376 |
+
)
|
| 1377 |
+
best_turn_info = turn_info
|
| 1378 |
+
|
| 1379 |
+
h0 += 1.0
|
| 1380 |
|
| 1381 |
if best_s is None or best_legs is None:
|
| 1382 |
+
return None, f"Guided drift solve: not enough ping points to score in window {start_dt.strftime('%H:%MZ')} → {end_dt.strftime('%H:%MZ')}.", None
|
| 1383 |
|
| 1384 |
rbto = best_s["rmse_bto"]
|
| 1385 |
rbto_txt = f"{rbto:.1f} µs" if rbto is not None else "n/a"
|
|
|
|
| 1390 |
if best_deltas is not None:
|
| 1391 |
drift_str = ", ".join([f"{d:+.1f}°" for d in best_deltas])
|
| 1392 |
|
| 1393 |
+
tinfo = best_turn_info or {}
|
| 1394 |
+
|
| 1395 |
report = "\n".join([
|
| 1396 |
+
"**Guided mode: Straight + drift (penalized) + computed turn (18:22→18:40)**",
|
| 1397 |
+
f"- Radar fix (fixed): **18:22:00Z** @ lat **{RADAR_FIX_LAT:.3f}**, lon **{RADAR_FIX_LON:.3f}**",
|
| 1398 |
+
f"- Turn completion (fixed): **18:40:00Z**",
|
| 1399 |
+
f"- Initial track (fixed): **{RADAR_INITIAL_TRACK_DEG:.1f}°**",
|
| 1400 |
+
f"- Speed (constrained): **{float(speed_knots):.1f} kt**",
|
| 1401 |
+
"",
|
| 1402 |
+
"**Computed turn model (uses first drifted heading at 18:40)**",
|
| 1403 |
+
f"- Bank angle: **{tinfo.get('bank_deg', TURN_BANK_DEFAULT_DEG):.1f}°**",
|
| 1404 |
+
f"- Heading change: **{tinfo.get('delta_deg', 0.0):+.1f}°**",
|
| 1405 |
+
f"- Straight-before-turn: **{tinfo.get('straight_min', 0.0):.2f} min**",
|
| 1406 |
+
f"- Turn duration: **{tinfo.get('turn_min', 0.0):.2f} min**",
|
| 1407 |
+
"",
|
| 1408 |
+
"**Southern schedule**",
|
| 1409 |
f"- Legs: **{sched_txt}**",
|
|
|
|
| 1410 |
f"- Nominal heading H₀: **{best_h0:.1f}°** (searched {GUIDED_HEADING_MIN:.0f}–{GUIDED_HEADING_MAX:.0f}°, step 1.0°)",
|
| 1411 |
f"- Drift bounds: **±{float(drift_max_deg):.1f}°** | Drift penalty weight: **{float(drift_weight):.2f}**",
|
| 1412 |
f"- Drift RMS: **{rms_drift:.2f}°**",
|
| 1413 |
f"- Drift per leg (Δ): {drift_str}" if drift_str else "",
|
| 1414 |
+
"",
|
| 1415 |
+
"**Fit**",
|
| 1416 |
f"- Weights: BFO **{float(weight_bfo):.2f}**, BTO **{float(weight_bto):.2f}**",
|
| 1417 |
f"- BFO points: **{best_s['n_bfo']}** | RMSE_BFO: **{best_s['rmse_bfo']:.2f} Hz**",
|
| 1418 |
f"- BTO points: **{best_s['n_bto']}** | RMSE_BTO: **{rbto_txt}**",
|
|
|
|
| 1461 |
weight_bfo, weight_bto,
|
| 1462 |
drift_max_deg, drift_weight
|
| 1463 |
):
|
| 1464 |
+
# Speed constrained to user-selected speed
|
| 1465 |
spd_knots = speed_from_profile(speed_profile, alt_ft, custom_mach, custom_knots)
|
| 1466 |
|
| 1467 |
if (guided_scenario or "").startswith("Straight + drift"):
|
| 1468 |
legs, report_md, start_dt = solve_guided_straight_with_drift(
|
| 1469 |
+
method, spd_knots, alt_ft, vs_fpm,
|
| 1470 |
descent_start_choice, weight_bfo, weight_bto,
|
| 1471 |
drift_max_deg, drift_weight
|
| 1472 |
)
|
|
|
|
|
|
|
| 1473 |
else:
|
| 1474 |
legs, report_md, start_dt = solve_guided_straight(
|
| 1475 |
+
method, spd_knots, alt_ft, vs_fpm,
|
| 1476 |
descent_start_choice, weight_bfo, weight_bto
|
| 1477 |
)
|
|
|
|
|
|
|
| 1478 |
|
| 1479 |
if legs is None:
|
| 1480 |
fig = go.Figure()
|
|
|
|
| 1488 |
], value=[])
|
| 1489 |
return gr.update(), f"### ❌ {report_md}", "", "", fig, "", alt_fig, empty_table
|
| 1490 |
|
| 1491 |
+
# Guided ALWAYS starts at fixed radar spot
|
| 1492 |
html_map, stats_md, bfo_fig, bfo_md, alt_fig, headers, rows = _run_common(
|
| 1493 |
+
RADAR_FIX_LAT, RADAR_FIX_LON, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice
|
| 1494 |
)
|
| 1495 |
+
|
| 1496 |
return gr.update(value=legs_to_rows(legs)), report_md, html_map, stats_md, bfo_fig, bfo_md, alt_fig, gr.update(headers=headers, value=rows)
|
| 1497 |
|
| 1498 |
|
|
|
|
| 1514 |
"Make sure `Search areas.kmz` is uploaded and `SEARCH_AREAS_KMZ` matches exactly."
|
| 1515 |
)
|
| 1516 |
|
| 1517 |
+
gr.Markdown(
|
| 1518 |
+
"### Fixed Radar + Turn Completion\n"
|
| 1519 |
+
f"- **Last radar spot (fixed):** 18:22:00Z @ **{RADAR_FIX_LAT:.3f}N, {RADAR_FIX_LON:.3f}E**\n"
|
| 1520 |
+
f"- **Turn completion (fixed):** 18:40:00Z\n"
|
| 1521 |
+
f"- **Initial track (fixed):** {RADAR_INITIAL_TRACK_DEG:.1f}°\n"
|
| 1522 |
+
f"- Guided scenarios compute a realistic turn between 18:22→18:40 and then the southern leg."
|
| 1523 |
+
)
|
| 1524 |
+
|
| 1525 |
with gr.Row():
|
| 1526 |
+
start_lat = gr.Slider(-10, 15, value=6.76, label="Start Latitude (Manual only)")
|
| 1527 |
+
start_lon = gr.Slider(70, 115, value=95.69, label="Start Longitude (Manual only)")
|
| 1528 |
method = gr.Dropdown(["VINCENTY", "RHUMB"], value="VINCENTY", label="Method")
|
| 1529 |
|
| 1530 |
gr.Markdown("### BFO/BTO Settings")
|
| 1531 |
with gr.Row():
|
| 1532 |
+
start_time_str = gr.Textbox(value="18:22:00Z", label="Simulation start time (UTC) — Manual mode")
|
| 1533 |
alt_ft = gr.Number(value=35000, label="Altitude at start (ft)")
|
| 1534 |
vs_fpm = gr.Number(value=0, label="Vertical speed (ft/min) (negative = descent)")
|
| 1535 |
descent_start_choice = gr.Dropdown(
|
|
|
|
| 1551 |
speed_profile = gr.Dropdown(
|
| 1552 |
["MRC Mach 0.82", "LRC Mach 0.84", "ECON Mach 0.80", "Custom Mach", "Custom knots"],
|
| 1553 |
value="MRC Mach 0.82",
|
| 1554 |
+
label="Speed profile (constrains speed during turn + southern leg)",
|
| 1555 |
)
|
| 1556 |
custom_mach = gr.Number(value=0.82, label="Custom Mach (if selected)")
|
| 1557 |
custom_knots = gr.Number(value=470, label="Custom knots (if selected)")
|