# app.py
# MH370 Flight Simulator — Leaflet map (Folium) + Ping Rings (KML)
# Guided scenarios (ACTIVE):
# A) Straight heading
# B) Variable heading & speed (beam search, BTO-first)
# C) Variable heading & speed (SE bias after 22:40) (beam search, BTO-first + tiny east bias only after 22:40)
# D) Variable heading, CONSTANT southern speed (443 kt) (beam search, BTO-first)
# Key behavior:
# - Last radar spot FIXED: 18:22:00Z @ 6.578N, 96.341E
# - Turn completion FIXED: 18:40:00Z
# - Guided mode computes a realistic turn between 18:22→18:40 and then southern path.
# - Guided validation timestamps: 18:22, 18:40, 19:40, 20:40, 21:40, 22:40, 00:10, 00:20
import os
import re
import html
import math
import zipfile
import xml.etree.ElementTree as ET
from datetime import datetime, timezone, timedelta
import gradio as gr
import numpy as np
import folium
from folium.plugins import MousePosition
import plotly.graph_objects as go
import flight_plotter_v32 as fp
# ================= CONFIG =================
KML_FILE = "MH370 - 13 nov (3).kml"
SEARCH_AREAS_KMZ = "Search areas.kmz"
MAX_RANGE_KML = "MH370_max_range_6166km_KUL_red.kml"
DEFAULT_LEGS = [
[300, 500, 10],
[270, 500, 10],
[240, 500, 10],
]
NEW_LEG_DEFAULT = [0, 500, 10]
WAYPOINTS = [
("IGARI", 6.93611111, 103.585),
("VAMPI", 6.18222222, 97.58555556),
("NILAM", 6.75638889, 95.97638889),
("SANOB", 6.58611111, 95.66916667),
("VPG", 5.27963889, 100.26038889),
("MEKAR", 6.5, 96.48333333),
]
CAPT_SIMON_HARDY_NAME = "Captain Simon Hardy"
CAPT_SIMON_HARDY_LAT = -(39 + 10 / 60) # 39°10' S
CAPT_SIMON_HARDY_LON = (88 + 18 / 60) # 88°18' E
MEASURED_BFO_0020_HZ = 182.0
RING_TIMES = {
"Ping Ring 1": "18:25:00Z",
"Ping Ring 2": "18:40:00Z",
"Ping Ring 3": "19:40:00Z",
"Ping Ring 4": "20:40:00Z",
"Ping Ring 5": "21:40:00Z",
"Ping Ring 6": "22:40:00Z",
"Ping Ring 7": "24:10:00Z",
}
SATLOC_IDX_MEAS_BFO = 4
SATLOC_IDX_MEAS_BTO = 5
RADAR_FIX_LAT = 6.578
RADAR_FIX_LON = 96.341
RADAR_FIX_TIME_UTC = datetime(2014, 3, 7, 18, 22, 0, tzinfo=timezone.utc)
TURN_COMPLETE_TIME_UTC = datetime(2014, 3, 7, 18, 40, 0, tzinfo=timezone.utc)
RADAR_INITIAL_TRACK_DEG = 296.0
GUIDED_VALIDATION_LABELS = [
"18:22:00",
"18:40:00",
"19:40:00",
"20:40:00",
"21:40:00",
"22:40:00",
"24:10:00",
"24:20:00",
]
GUIDED_FINAL_LABEL = "24:20:00"
TURN_BANK_MIN_DEG = 15.0
TURN_BANK_MAX_DEG = 30.0
TURN_BANK_DEFAULT_DEG = 25.0
TURN_STEP_SEC = 30.0
TURN_SPEED_MIN_KT = 470.0
TURN_SPEED_MAX_KT = 520.0
SOUTH_SPEED_MIN_KT = 430.0
SOUTH_SPEED_MAX_KT = 510.0
CONST_SOUTH_SPEED_KT = 443.0
GUIDED_HEADING_MIN = 175
GUIDED_HEADING_MAX = 190
COARSE_HEADING_STEP_DEG = 2.0
REFINE_HEADING_HALF_WINDOW_DEG = 2.0
REFINE_HEADING_STEP_DEG = 0.5
GUIDED_SOUTH_SEG_MIN = [60.0, 60.0, 60.0, 60.0, 90.0, 10.0]
GUIDED_SOUTH_SEG_LABELS_END = ["19:40:00", "20:40:00", "21:40:00", "22:40:00", "24:10:00", "24:20:00"]
HARD_BTO_LABELS = ["18:40:00", "19:40:00", "20:40:00", "21:40:00", "22:40:00", "24:10:00", "24:20:00"]
TOPK_POLISH = 5
ENABLE_POLISH_STRAIGHT = True
POLISH_PASSES = 3
POLISH_WINDOW_KT = 6.0
POLISH_STEP_KT = 2.0
POLISH_OBJ_EPS = 1e-3
VAR_HEADING_MIN = 170.0
VAR_HEADING_MAX = 200.0
VAR_TURN_SPEED_STEP = 10.0
VAR_FIRST_HEADING_STEP = 4.0
VAR_HEADING_DELTA_SET = [-6, -3, 0, 3, 6]
VAR_BEAM_WIDTH = 12
VAR_SMOOTH_PENALTY_US_PER_DEG = 1.5
SE_EAST_DIVERGE_START_SEG_INDEX = 4
SE_VAR_EAST_DELTA_SET = [-8, -4, -2, 0, 2]
SE_EAST_BIAS_PENALTY_US_PER_DEG = 0.8
SEARCH_AREA_COLORS = {
"Ocean Infinity": "#ff3b3b",
"Australia": "#1f77b4",
"Malays": "#2ca02c",
"Malaysia": "#2ca02c",
"China": "#ff7f0e",
"Seabed Constructor": "#9467bd",
"Fugro": "#8c564b",
}
DEFAULT_SEARCH_COLOR = "#00ffff"
# ================= PATH RESOLUTION =================
def resolve_local_or_mnt(path_like: str) -> str:
p = (path_like or "").strip()
if not p:
return p
if os.path.exists(p):
return p
here = os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals() else os.getcwd()
p2 = os.path.join(here, p)
if os.path.exists(p2):
return p2
base = os.path.basename(p)
p3 = os.path.join("/mnt/data", base)
if os.path.exists(p3):
return p3
p4 = os.path.join("/mnt/data", p)
if os.path.exists(p4):
return p4
return p
def color_for_search_area(name: str) -> str:
lname = (name or "").lower()
for key, color in SEARCH_AREA_COLORS.items():
if key.lower() in lname:
return color
return DEFAULT_SEARCH_COLOR
# ================= KML (Ping Rings) =================
def load_ping_rings_from_kml(path: str):
path = resolve_local_or_mnt(path)
if not os.path.exists(path):
raise FileNotFoundError(f"KML file not found: '{path}'.")
tree = ET.parse(path)
root = tree.getroot()
m = re.match(r"\{(.*)\}", root.tag)
ns_uri = m.group(1) if m else "http://www.opengis.net/kml/2.2"
ns = {"kml": ns_uri}
rings = {}
for pm in root.findall(".//kml:Placemark", ns):
name_el = pm.find("kml:name", ns)
if name_el is None or name_el.text is None:
continue
name = name_el.text.strip()
if not name or not name.lower().startswith("ping ring"):
continue
coords_el = pm.find(".//kml:LineString/kml:coordinates", ns)
if coords_el is None or coords_el.text is None:
continue
pts = []
for token in re.split(r"\s+", coords_el.text.strip()):
if not token:
continue
parts = token.split(",")
if len(parts) < 2:
continue
lon = float(parts[0])
lat = float(parts[1])
pts.append((lat, lon))
if pts:
rings[name] = pts
return rings
try:
PING_RINGS = load_ping_rings_from_kml(KML_FILE)
except Exception as e:
PING_RINGS = {}
KML_LOAD_ERROR = str(e)
else:
KML_LOAD_ERROR = ""
# ================= KML (Generic features / max range ring) =================
def load_generic_kml_features(path: str):
path = resolve_local_or_mnt(path)
if not os.path.exists(path):
raise FileNotFoundError(f"KML file not found: '{path}'.")
tree = ET.parse(path)
root = tree.getroot()
m = re.match(r"\{(.*)\}", root.tag)
ns_uri = m.group(1) if m else "http://www.opengis.net/kml/2.2"
ns = {"kml": ns_uri}
features = []
for pm in root.findall(".//kml:Placemark", ns):
name_el = pm.find("kml:name", ns)
pm_name = name_el.text.strip() if (name_el is not None and name_el.text) else "KML feature"
for pt in pm.findall(".//kml:Point", ns):
coords_el = pt.find(".//kml:coordinates", ns)
if coords_el is None or coords_el.text is None:
continue
coords = coords_el.text.strip().split(",")
if len(coords) >= 2:
lon = float(coords[0])
lat = float(coords[1])
features.append({
"type": "point",
"name": pm_name,
"lat": lat,
"lon": lon,
})
for ls in pm.findall(".//kml:LineString", ns):
coords_el = ls.find(".//kml:coordinates", ns)
if coords_el is None or coords_el.text is None:
continue
pts = []
for token in re.split(r"\s+", coords_el.text.strip()):
if not token:
continue
parts = token.split(",")
if len(parts) < 2:
continue
lon = float(parts[0])
lat = float(parts[1])
pts.append((lat, lon))
if pts:
features.append({
"type": "line",
"name": pm_name,
"points": pts,
})
for poly in pm.findall(".//kml:Polygon", ns):
outer = poly.find(".//kml:outerBoundaryIs/kml:LinearRing/kml:coordinates", ns)
if outer is None or outer.text is None:
continue
pts = []
for token in re.split(r"\s+", outer.text.strip()):
if not token:
continue
parts = token.split(",")
if len(parts) < 2:
continue
lon = float(parts[0])
lat = float(parts[1])
pts.append((lat, lon))
if pts:
features.append({
"type": "polygon",
"name": pm_name,
"points": pts,
})
return features
try:
MAX_RANGE_FEATURES = load_generic_kml_features(MAX_RANGE_KML)
except Exception as e:
MAX_RANGE_FEATURES = []
MAX_RANGE_KML_ERROR = str(e)
else:
MAX_RANGE_KML_ERROR = ""
# ================= KMZ (Search Areas) =================
def _kml_ns_from_root(root):
m = re.match(r"\{(.*)\}", root.tag)
ns_uri = m.group(1) if m else "http://www.opengis.net/kml/2.2"
return {"kml": ns_uri}
def _parse_kml_coordinates_text(txt: str):
pts = []
if not txt:
return pts
for token in re.split(r"\s+", txt.strip()):
if not token:
continue
parts = token.split(",")
if len(parts) < 2:
continue
lon = float(parts[0])
lat = float(parts[1])
pts.append((lat, lon))
return pts
def load_search_areas_from_kmz(kmz_path: str):
kmz_path = resolve_local_or_mnt(kmz_path)
if not os.path.exists(kmz_path):
raise FileNotFoundError(f"KMZ file not found: '{kmz_path}'.")
with zipfile.ZipFile(kmz_path, "r") as zf:
kml_name = None
for n in zf.namelist():
if n.lower().endswith(".kml"):
kml_name = n
break
if not kml_name:
raise ValueError("KMZ contains no .kml file.")
root = ET.fromstring(zf.read(kml_name))
ns = _kml_ns_from_root(root)
out = []
for pm in root.findall(".//kml:Placemark", ns):
name_el = pm.find("kml:name", ns)
pm_name = (name_el.text.strip() if (name_el is not None and name_el.text) else "Search area")
points, lines, polys = [], [], []
for pt in pm.findall(".//kml:Point", ns):
coords = pt.find(".//kml:coordinates", ns)
if coords is None or coords.text is None:
continue
pts = _parse_kml_coordinates_text(coords.text)
if pts:
points.extend(pts[:1])
for ls in pm.findall(".//kml:LineString", ns):
coords = ls.find(".//kml:coordinates", ns)
if coords is None or coords.text is None:
continue
pts = _parse_kml_coordinates_text(coords.text)
if len(pts) >= 2:
lines.append(pts)
for poly in pm.findall(".//kml:Polygon", ns):
outer = poly.find(".//kml:outerBoundaryIs/kml:LinearRing/kml:coordinates", ns)
if outer is None or outer.text is None:
continue
pts = _parse_kml_coordinates_text(outer.text)
if len(pts) >= 3:
polys.append(pts)
if points or lines or polys:
out.append({"name": pm_name, "points": points, "lines": lines, "polygons": polys})
if not out:
raise ValueError("No drawable features found in KMZ/KML.")
return out
try:
SEARCH_AREAS = load_search_areas_from_kmz(SEARCH_AREAS_KMZ)
except Exception as e:
SEARCH_AREAS = []
KMZ_LOAD_ERROR = str(e)
else:
KMZ_LOAD_ERROR = ""
# ================= LEGS HELPERS =================
def df_to_rows(df):
if df is None:
return []
if hasattr(df, "to_numpy"):
return df.to_numpy().tolist()
return df
def parse_legs_df(df):
rows = df_to_rows(df)
legs = []
for row in rows:
if row is None or len(row) < 3:
continue
h, s, m = row[0], row[1], row[2]
if h is None or s is None or m is None:
continue
if isinstance(h, float) and np.isnan(h):
continue
if isinstance(s, float) and np.isnan(s):
continue
if isinstance(m, float) and np.isnan(m):
continue
legs.append((float(h), float(s), float(m)))
return legs or [(300, 500, 10), (270, 500, 10), (240, 500, 10)]
def legs_to_rows(legs):
return [[float(h), float(s), float(m)] for (h, s, m) in legs]
def add_leg(df):
rows = df_to_rows(df)
cleaned = []
for r in rows:
if r is None or len(r) < 3:
continue
a, b, c = r[0], r[1], r[2]
if (a is None or (isinstance(a, float) and np.isnan(a))) and \
(b is None or (isinstance(b, float) and np.isnan(b))) and \
(c is None or (isinstance(c, float) and np.isnan(c))):
continue
cleaned.append([a, b, c])
cleaned.append(list(NEW_LEG_DEFAULT))
return gr.update(value=cleaned)
def reset_legs():
return gr.update(value=[row[:] for row in DEFAULT_LEGS])
# ================= GEO HELPERS =================
def _wrap_heading_deg(h: float) -> float:
return float(h) % 360.0
def _shortest_signed_delta_deg(h_from: float, h_to: float) -> float:
return ((float(h_to) - float(h_from) + 540.0) % 360.0) - 180.0
def _turn_rate_deg_per_sec(speed_knots: float, bank_deg: float) -> float:
g = 9.80665
V = max(10.0, float(speed_knots) * 0.514444)
bank = math.radians(max(1e-3, float(bank_deg)))
omega = g * math.tan(bank) / V
return float(omega * 180.0 / math.pi)
def build_turn_legs(speed_knots: float, method: str, heading_final_deg: float,
bank_pref_deg: float = TURN_BANK_DEFAULT_DEG, step_sec: float = TURN_STEP_SEC):
total_sec = (TURN_COMPLETE_TIME_UTC - RADAR_FIX_TIME_UTC).total_seconds()
if total_sec <= 0:
return [], {"error": "Invalid turn window."}
h0 = float(RADAR_INITIAL_TRACK_DEG)
h1 = _wrap_heading_deg(float(heading_final_deg))
delta = _shortest_signed_delta_deg(h0, h1)
sign = 1.0 if delta >= 0 else -1.0
delta_abs = abs(delta)
bank_candidates = [float(bank_pref_deg)] + list(np.linspace(TURN_BANK_MIN_DEG, TURN_BANK_MAX_DEG, 9))
best_bank = None
best_turn_sec = None
for b in bank_candidates:
rate = _turn_rate_deg_per_sec(speed_knots, b)
if rate <= 1e-6:
continue
t_turn = delta_abs / rate
if t_turn <= total_sec + 1e-6:
if best_bank is None:
best_bank, best_turn_sec = b, t_turn
else:
if abs(b - bank_pref_deg) < abs(best_bank - bank_pref_deg):
best_bank, best_turn_sec = b, t_turn
if best_bank is None:
best_bank = TURN_BANK_MAX_DEG
best_turn_sec = delta_abs / max(1e-6, _turn_rate_deg_per_sec(speed_knots, best_bank))
best_turn_sec = min(best_turn_sec, total_sec)
turn_sec = float(best_turn_sec)
straight_sec = max(0.0, float(total_sec) - turn_sec)
legs = []
if straight_sec > 1.0:
legs.append((h0, float(speed_knots), straight_sec / 60.0))
if turn_sec > 1.0 and delta_abs > 1e-6:
step_sec = max(5.0, float(step_sec))
n = int(math.ceil(turn_sec / step_sec))
if n < 1:
n = 1
remaining = turn_sec
for i in range(n):
this = min(step_sec, remaining)
remaining -= this
frac_mid = ((i + 0.5) / n)
hdg_mid = _wrap_heading_deg(h0 + sign * delta_abs * frac_mid)
legs.append((hdg_mid, float(speed_knots), this / 60.0))
info = {
"h0": h0,
"h1": h1,
"delta_deg": delta,
"bank_deg": float(best_bank),
"straight_min": straight_sec / 60.0,
"turn_min": turn_sec / 60.0,
"total_min": total_sec / 60.0,
"speed_turn_kt": float(speed_knots),
}
return legs, info
# ================= TIME HELPERS =================
def _parse_start_time(s: str) -> datetime:
s = (s or "").strip()
if not s:
return datetime(2014, 3, 7, 18, 22, 0, tzinfo=timezone.utc)
if re.fullmatch(r"\d{2}:\d{2}:\d{2}Z?", s):
s2 = s.replace("Z", "")
hh, mm, ss = map(int, s2.split(":"))
return datetime(2014, 3, 7, hh, mm, ss, tzinfo=timezone.utc)
s = s.replace("T", " ").replace("Z", "")
dt = datetime.fromisoformat(s)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _to_label_utc(dt: datetime) -> str:
return dt.strftime("%H:%M:%SZ")
def _dt_from_label(label_hms: str) -> datetime:
s = (label_hms or "").strip().replace("Z", "")
return fp._time_label_to_dt(s, fp.SAT_EPOCH_UTC)
def dt_0020_utc() -> datetime:
return fp._time_label_to_dt("24:20:00", fp.SAT_EPOCH_UTC)
# ================= PATCH satLoc =================
def _patch_measured_bfo_0020():
target_labels = {"24:20:00", "00:20:00"}
patched = False
try:
for row in fp.satLoc:
try:
lbl = fp._extract_time_string(row[0])
except Exception:
continue
if lbl in target_labels:
try:
row[SATLOC_IDX_MEAS_BFO] = float(MEASURED_BFO_0020_HZ)
patched = True
except Exception:
pass
except Exception:
return False
return patched
_ = _patch_measured_bfo_0020()
# ================= ALTITUDE HELPERS =================
def altitude_ft_at_dt(dt_ping: datetime, start_dt: datetime, alt0_ft: float,
vs_fpm: float, descent_start_dt):
alt0_ft = float(alt0_ft)
vs_fpm = float(vs_fpm)
if descent_start_dt is None or vs_fpm == 0.0:
return max(0.0, alt0_ft)
if dt_ping < descent_start_dt:
return max(0.0, alt0_ft)
mins = (dt_ping - descent_start_dt).total_seconds() / 60.0
alt = alt0_ft + vs_fpm * mins
return max(0.0, alt)
def build_altitude_plot(start_dt: datetime, total_time_min: float, alt0_ft: float,
vs_fpm: float, descent_start_dt):
t = np.linspace(0.0, max(1.0, float(total_time_min)), 120)
alts = []
for mins in t:
dt = start_dt + timedelta(minutes=float(mins))
alts.append(altitude_ft_at_dt(dt, start_dt, float(alt0_ft), float(vs_fpm), descent_start_dt))
fig = go.Figure()
fig.add_trace(go.Scatter(x=t, y=alts, mode="lines", name="Altitude (ft)"))
fig.update_layout(
title="Altitude vs Time",
xaxis_title="Minutes since start",
yaxis_title="Altitude (ft)",
height=320,
margin=dict(l=60, r=30, t=60, b=50),
)
return fig
# ================= SPEED PROFILE PLOT =================
def build_speed_profile_plot(start_dt: datetime, legs):
xs = [0.0]
ys = []
cum_min = 0.0
for (_hdg, spd, mins) in legs:
mins = float(mins)
spd = float(spd)
if mins <= 0:
continue
if not ys:
ys.append(spd)
else:
ys.append(ys[-1])
xs.append(cum_min)
ys.append(spd)
cum_min += mins
xs.append(cum_min)
ys.append(spd)
fig = go.Figure()
if ys:
fig.add_trace(go.Scatter(x=xs[1:], y=ys, mode="lines", name="Speed (kt)"))
fig.update_layout(
title="Speed Profile",
xaxis_title="Minutes since start",
yaxis_title="Speed (kt)",
height=320,
margin=dict(l=60, r=30, t=60, b=50),
)
return fig
# ================= SIM CORE =================
def simulate_path(start_lat, start_lon, legs, method):
lat, lon = float(start_lat), float(start_lon)
pts = [(lat, lon)]
seg_dists_m = []
total_time_min = 0.0
for heading, speed_knots, minutes in legs:
dist_m = float(speed_knots) * 1852.0 * (float(minutes) / 60.0)
if method == "VINCENTY":
(lat, lon), _ = fp.vincenty_direct((lat, lon), float(heading), dist_m)
else:
lat, lon = fp.rhumb_direct((lat, lon), float(heading), dist_m)
pts.append((lat, lon))
seg_dists_m.append(dist_m)
total_time_min += float(minutes)
return pts, seg_dists_m, total_time_min
def compute_stats(path_pts, seg_dists_m, total_time_min, legs):
total_dist_m = float(np.sum(seg_dists_m)) if seg_dists_m else 0.0
total_dist_km = total_dist_m / 1000.0
total_dist_nm = total_dist_m / 1852.0
total_time_hr = total_time_min / 60.0
avg_gs_knots = (total_dist_nm / total_time_hr) if total_time_hr > 0 else 0.0
end_lat, end_lon = path_pts[-1]
return "\n\n".join([
f"**Legs:** {len(legs)}",
f"**Total time:** {total_time_min:.1f} min ({total_time_hr:.2f} hr)",
f"**Total distance:** {total_dist_nm:.1f} nm ({total_dist_km:.1f} km)",
f"**Average ground speed:** {avg_gs_knots:.1f} kt",
f"**Final position (polyline end):** lat {end_lat:.4f}, lon {end_lon:.4f}",
])
# ================= EXACT STATE HELPERS =================
def _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg_deg, spd_knots, t_inside_leg_sec):
dist_m = float(spd_knots) * 1852.0 * (float(t_inside_leg_sec) / 3600.0)
if method == "VINCENTY":
(lat, lon), _ = fp.vincenty_direct((float(leg_start_lat), float(leg_start_lon)), float(hdg_deg), dist_m)
else:
lat, lon = fp.rhumb_direct((float(leg_start_lat), float(leg_start_lon)), float(hdg_deg), dist_m)
return float(lat), float(lon)
def _precompute_leg_starts(start_lat, start_lon, legs, method):
cur_lat = float(start_lat)
cur_lon = float(start_lon)
leg_start_positions = [(cur_lat, cur_lon)]
for (hdg, spd, minutes) in legs[:-1]:
dist_m = float(spd) * 1852.0 * (float(minutes) / 60.0)
if method == "VINCENTY":
(cur_lat, cur_lon), _ = fp.vincenty_direct((cur_lat, cur_lon), float(hdg), dist_m)
else:
cur_lat, cur_lon = fp.rhumb_direct((cur_lat, cur_lon), float(hdg), dist_m)
leg_start_positions.append((cur_lat, cur_lon))
return leg_start_positions
def _state_at_dt(start_lat, start_lon, legs, method, start_dt, dt_k):
if dt_k < start_dt:
return None
leg_secs = [float(m) * 60.0 for _, _, m in legs]
boundaries = np.cumsum([0.0] + leg_secs)
total_end = float(boundaries[-1])
t_sec = float((dt_k - start_dt).total_seconds())
if t_sec > total_end:
t_sec = total_end
i = int(np.searchsorted(boundaries, t_sec, side="right") - 1)
i = max(0, min(i, len(legs) - 1))
t_inside = t_sec - float(boundaries[i])
leg_start_positions = _precompute_leg_starts(start_lat, start_lon, legs, method)
leg_start_lat, leg_start_lon = leg_start_positions[i]
hdg, spd, _m = legs[i]
lat, lon = _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg, spd, t_inside)
dist_km = 0.0
for j in range(i):
_hj, spdj, mj = legs[j]
dist_km += float(spdj) * 1.852 * (float(mj) / 60.0)
dist_km += float(spd) * 1.852 * (float(t_inside) / 3600.0)
return {
"dt": dt_k,
"elapsed_min": t_sec / 60.0,
"lat": float(lat),
"lon": float(lon),
"speed_kt": float(spd),
"heading_deg": float(hdg),
"dist_km": float(dist_km),
}
def _map_marker_times(start_dt, legs):
if start_dt == RADAR_FIX_TIME_UTC:
times = [_dt_from_label(x) for x in GUIDED_VALIDATION_LABELS]
return sorted(list({t: t for t in times}.values()))
times = [start_dt]
cum = 0.0
for (_h, _s, m) in legs:
cum += float(m)
times.append(start_dt + timedelta(minutes=cum))
return times
# ================= CLIP TRAJECTORY =================
def clip_legs_to_end_dt(legs, start_dt: datetime, end_dt: datetime):
total_target_sec = (end_dt - start_dt).total_seconds()
if total_target_sec <= 0:
return []
out = []
remaining = float(total_target_sec)
for (hdg, spd, minutes) in legs:
sec = float(minutes) * 60.0
if remaining <= 0:
break
if sec <= remaining + 1e-9:
out.append((float(hdg), float(spd), float(minutes)))
remaining -= sec
else:
trunc_min = max(0.0, remaining / 60.0)
if trunc_min > 1e-6:
out.append((float(hdg), float(spd), float(trunc_min)))
remaining = 0.0
break
return out
# ================= BFO ANALYSIS =================
def _ping_points_after(start_dt: datetime):
pts = []
for row in fp.satLoc:
label = fp._extract_time_string(row[0])
meas_bfo = row[SATLOC_IDX_MEAS_BFO]
meas_bto = row[SATLOC_IDX_MEAS_BTO]
if meas_bfo is None:
continue
try:
dt_ping = fp._time_label_to_dt(label, fp.SAT_EPOCH_UTC)
except Exception:
continue
if dt_ping < start_dt:
continue
pts.append((label, dt_ping, float(meas_bfo), None if meas_bto is None else float(meas_bto)))
return pts
def _descent_start_dt_from_choice(descent_start_choice: str):
ch = (descent_start_choice or "").strip()
if ch.startswith("At 00:20Z only"):
return dt_0020_utc()
if ch.startswith("None"):
return None
return None
def bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice):
pts = _ping_points_after(start_dt)
descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
leg_secs = [float(m) * 60.0 for _, _, m in legs]
boundaries = np.cumsum([0.0] + leg_secs)
total_end = float(boundaries[-1])
leg_start_positions = _precompute_leg_starts(start_lat, start_lon, legs, method)
x_labels, measured, calculated = [], [], []
dt_0020 = dt_0020_utc()
have_0020 = any((lbl.replace("Z", "") in {"24:20:00", "00:20:00"}) for (lbl, *_rest) in pts)
if (start_dt <= dt_0020 <= (start_dt + timedelta(seconds=total_end))) and not have_0020:
pts.append(("24:20:00", dt_0020, float(MEASURED_BFO_0020_HZ), None))
pts = sorted(pts, key=lambda x: x[1])
for label, dt_ping, meas_bfo, _meas_bto in pts:
t_sec = (dt_ping - start_dt).total_seconds()
if t_sec < 0 or t_sec > total_end:
continue
i = int(np.searchsorted(boundaries, t_sec, side="right") - 1)
i = max(0, min(i, len(legs) - 1))
t_inside = float(t_sec) - float(boundaries[i])
leg_start_lat, leg_start_lon = leg_start_positions[i]
hdg, spd, _m = legs[i]
lat, lon = _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg, spd, t_inside)
alt_now_ft = altitude_ft_at_dt(dt_ping, start_dt, float(alt_ft), float(vs_fpm), descent_start_dt)
alt_m = alt_now_ft * 0.3048
vs_mps = float(vs_fpm) * 0.00508
if descent_start_dt is not None and dt_ping < descent_start_dt:
vs_mps = 0.0
spd_mps = float(spd) * 1852.0 / 3600.0
pred_bfo = float(fp.calculateBFO_at_time(spd_mps, float(hdg), float(lat), float(lon), alt_m, vs_mps, dt_ping))
x_labels.append(label if label.endswith("Z") else (label + "Z"))
measured.append(float(meas_bfo))
calculated.append(float(pred_bfo))
fig = go.Figure()
if x_labels:
fig.add_trace(go.Scatter(
x=x_labels, y=measured,
mode="markers",
name="Measured BFO",
marker=dict(size=11, symbol="circle")
))
fig.add_trace(go.Scatter(
x=x_labels, y=calculated,
mode="lines+markers",
name="Calculated BFO",
line=dict(width=3),
marker=dict(size=10, symbol="diamond")
))
fig.update_layout(
title="BFO Analysis",
xaxis_title="Time",
yaxis_title="Hz",
legend=dict(x=1.02, y=1.0),
margin=dict(l=60, r=160, t=80, b=60),
height=520,
)
return fig, ""
# ================= VALIDATION TABLE =================
def _lookup_measured_at_dt(dt_ping: datetime):
if hasattr(fp, "nearest_measured_for_time"):
idx = fp.nearest_measured_for_time(dt_ping)[0]
row = fp.satLoc[idx]
return row[SATLOC_IDX_MEAS_BFO], row[SATLOC_IDX_MEAS_BTO]
return None, None
def build_validation_table(start_lat, start_lon, method, start_dt, legs, alt_ft, vs_fpm, descent_start_choice):
descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
is_guided = (start_dt == RADAR_FIX_TIME_UTC)
if is_guided:
times = [_dt_from_label(x) for x in GUIDED_VALIDATION_LABELS]
times = sorted(list({t: t for t in times}.values()))
else:
times = [start_dt]
cum = 0.0
for (_h, _s, m) in legs:
cum += float(m)
times.append(start_dt + timedelta(minutes=cum))
rows = []
for dt_k in times:
st = _state_at_dt(start_lat, start_lon, legs, method, start_dt, dt_k)
if st is None:
continue
lat = st["lat"]
lon = st["lon"]
spd = st["speed_kt"]
hdg = st["heading_deg"]
alt_now_ft = altitude_ft_at_dt(dt_k, start_dt, float(alt_ft), float(vs_fpm), descent_start_dt)
alt_m = alt_now_ft * 0.3048
vs_mps = float(vs_fpm) * 0.00508
if descent_start_dt is not None and dt_k < descent_start_dt:
vs_mps = 0.0
spd_mps = float(spd) * 1852.0 / 3600.0
bfo_calc = float(fp.calculateBFO_at_time(spd_mps, float(hdg), float(lat), float(lon), alt_m, vs_mps, dt_k))
bto_calc = float(fp.calculateBTO_at_time(float(lat), float(lon), alt_m, dt_k))
bfo_meas, bto_meas = _lookup_measured_at_dt(dt_k)
try:
if dt_k == dt_0020_utc():
bfo_meas = float(MEASURED_BFO_0020_HZ)
except Exception:
pass
bfo_err = None if bfo_meas is None else (float(bfo_calc) - float(bfo_meas))
bto_err = None if bto_meas is None else (float(bto_calc) - float(bto_meas))
rows.append([
_to_label_utc(dt_k),
f"{lat:.5f}",
f"{lon:.5f}",
f"{float(spd):.1f}",
f"{float(hdg):.1f}",
f"{bfo_calc:.2f}",
"" if bfo_meas is None else f"{float(bfo_meas):.2f}",
"" if bfo_err is None else f"{float(bfo_err):.2f}",
f"{bto_calc:.1f}",
"" if bto_meas is None else f"{float(bto_meas):.1f}",
"" if bto_err is None else f"{float(bto_err):.1f}",
])
headers = [
"time_utc", "lat", "lon", "speed_kt", "heading_deg",
"bfo_calc_hz", "bfo_meas_hz", "bfo_error_hz",
"bto_calc_us", "bto_meas_us", "bto_error_us",
]
return headers, rows
# ================= MAP HELPERS =================
def _all_bounds(points_list):
lats, lons = [], []
for pts in points_list:
for (la, lo) in pts:
if la is None or lo is None:
continue
lats.append(float(la))
lons.append(float(lo))
if not lats:
return None
return [[min(lats), min(lons)], [max(lats), max(lons)]]
def add_permanent_label(m, name, lat, lon):
tip = f"{html.escape(name)}
Lat: {float(lat):.5f}
Lon: {float(lon):.5f}"
pop = folium.Popup(f"{html.escape(name)}
Lat: {float(lat):.5f}
Lon: {float(lon):.5f}", max_width=260)
folium.CircleMarker(
location=(lat, lon),
radius=4,
color="black",
weight=2,
fill=True,
fill_color="white",
fill_opacity=1.0,
tooltip=folium.Tooltip(tip, sticky=True),
popup=pop,
).add_to(m)
label_html = f"""