Spaces:
Sleeping
Sleeping
Download app.py from WhereIsMH370/MH370Sim: direct link, hf CLI and curl.
- Browser
- Download file 75.4 kB
-
https://huggingface.co/spaces/WhereIsMH370/MH370Sim/resolve/main/app.py
- Command line
-
hf download hf://spaces/WhereIsMH370/MH370Sim/app.py
-
curl -L -o app.py https://huggingface.co/spaces/WhereIsMH370/MH370Sim/resolve/main/app.py
75.4 kB
| # 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)}<br>Lat: {float(lat):.5f}<br>Lon: {float(lon):.5f}" | |
| pop = folium.Popup(f"<b>{html.escape(name)}</b><br>Lat: {float(lat):.5f}<br>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""" | |
| <div style=" | |
| font-size: 13px; | |
| font-weight: 700; | |
| color: black; | |
| text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff; | |
| white-space: nowrap; | |
| transform: translate(8px, -12px); | |
| pointer-events: none; | |
| ">{html.escape(name)}</div> | |
| """ | |
| folium.Marker(location=(lat, lon), icon=folium.DivIcon(html=label_html)).add_to(m) | |
| def add_named_marker(m, name: str, lat: float, lon: float): | |
| tip = f"{html.escape(name)}<br>Lat: {float(lat):.5f}<br>Lon: {float(lon):.5f}" | |
| pop = folium.Popup(f"<b>{html.escape(name)}</b><br>Lat: {float(lat):.5f}<br>Lon: {float(lon):.5f}", max_width=260) | |
| folium.CircleMarker( | |
| location=(float(lat), float(lon)), | |
| radius=6, | |
| color="white", | |
| weight=2, | |
| fill=True, | |
| fill_color="cyan", | |
| fill_opacity=0.9, | |
| tooltip=folium.Tooltip(tip, sticky=True), | |
| popup=pop, | |
| ).add_to(m) | |
| def add_max_range_features(m, max_range_features): | |
| bounds_pts = [] | |
| for feat in max_range_features: | |
| ftype = feat.get("type") | |
| name = feat.get("name", "Maximum range") | |
| if ftype in ("line", "polygon"): | |
| pts = feat.get("points", []) or [] | |
| if not pts: | |
| continue | |
| bounds_pts.extend(pts) | |
| folium.PolyLine( | |
| locations=pts, | |
| color="red", | |
| weight=3, | |
| opacity=0.95, | |
| tooltip=name, | |
| popup=folium.Popup(f"<b>{html.escape(name)}</b>", max_width=260), | |
| ).add_to(m) | |
| elif ftype == "point": | |
| lat = float(feat["lat"]) | |
| lon = float(feat["lon"]) | |
| bounds_pts.append((lat, lon)) | |
| add_named_marker(m, name, lat, lon) | |
| return bounds_pts | |
| def build_leaflet_map(path_pts, legs, method, start_dt, show_max_range_ring=False): | |
| start_lat, start_lon = path_pts[0] | |
| is_guided = (start_dt == RADAR_FIX_TIME_UTC) | |
| m = folium.Map( | |
| location=[start_lat, start_lon], | |
| zoom_start=4, | |
| tiles=None, | |
| control_scale=True, | |
| ) | |
| folium.TileLayer("OpenStreetMap", name="OpenStreetMap", overlay=False, control=True).add_to(m) | |
| folium.TileLayer( | |
| tiles="https://server.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}", | |
| attr="Esri", | |
| name="Esri Ocean", | |
| overlay=False, | |
| control=True, | |
| ).add_to(m) | |
| folium.TileLayer( | |
| tiles="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", | |
| attr="Esri", | |
| name="Esri Satellite", | |
| overlay=False, | |
| control=True, | |
| ).add_to(m) | |
| MousePosition( | |
| position="topright", | |
| separator=" | ", | |
| prefix="Lat/Lon:", | |
| lat_formatter="function(num) {return L.Util.formatNum(num, 5);}", | |
| lng_formatter="function(num) {return L.Util.formatNum(num, 5);}", | |
| ).add_to(m) | |
| search_area_pts_for_bounds = [] | |
| for area in SEARCH_AREAS: | |
| nm = area.get("name", "Search area") | |
| col = color_for_search_area(nm) | |
| for (la, lo) in area.get("points", []) or []: | |
| search_area_pts_for_bounds.append((la, lo)) | |
| folium.CircleMarker( | |
| location=(la, lo), | |
| radius=6, | |
| color=col, | |
| weight=2, | |
| fill=True, | |
| fill_color=col, | |
| fill_opacity=0.85, | |
| tooltip=nm, | |
| ).add_to(m) | |
| for line in area.get("lines", []) or []: | |
| if not line: | |
| continue | |
| search_area_pts_for_bounds.extend(line) | |
| folium.PolyLine(locations=line, color=col, weight=3, opacity=0.9, tooltip=nm).add_to(m) | |
| for poly in area.get("polygons", []) or []: | |
| if not poly: | |
| continue | |
| search_area_pts_for_bounds.extend(poly) | |
| folium.Polygon( | |
| locations=poly, | |
| color=col, | |
| weight=2, | |
| opacity=0.9, | |
| fill=True, | |
| fill_color=col, | |
| fill_opacity=0.18, | |
| tooltip=nm, | |
| ).add_to(m) | |
| for ring_name in sorted(PING_RINGS.keys()): | |
| ring_pts = PING_RINGS.get(ring_name) | |
| if not ring_pts: | |
| continue | |
| ring_time = RING_TIMES.get(ring_name) | |
| tooltip = f"{ring_name} ({ring_time})" if ring_time else ring_name | |
| folium.PolyLine(ring_pts, weight=2, opacity=0.6, color="yellow", tooltip=tooltip).add_to(m) | |
| max_range_pts_for_bounds = [] | |
| if show_max_range_ring: | |
| max_range_pts_for_bounds = add_max_range_features(m, MAX_RANGE_FEATURES) | |
| folium.PolyLine(path_pts, color="white", weight=8, opacity=0.3).add_to(m) | |
| folium.PolyLine(path_pts, color="red", weight=4, opacity=1.0, tooltip="Flight path").add_to(m) | |
| folium.CircleMarker( | |
| location=path_pts[0], | |
| radius=7, | |
| fill=True, | |
| color="green", | |
| fill_opacity=1.0, | |
| popup=folium.Popup( | |
| f"<b>Start</b><br>Lat: {path_pts[0][0]:.5f}<br>Lon: {path_pts[0][1]:.5f}", | |
| max_width=320, | |
| ), | |
| ).add_to(m) | |
| if not is_guided: | |
| folium.CircleMarker( | |
| location=path_pts[-1], | |
| radius=7, | |
| fill=True, | |
| color="purple", | |
| fill_opacity=1.0, | |
| popup=folium.Popup( | |
| f"<b>End</b><br>Lat: {path_pts[-1][0]:.5f}<br>Lon: {path_pts[-1][1]:.5f}", | |
| max_width=320, | |
| ), | |
| ).add_to(m) | |
| marker_times = _map_marker_times(start_dt, legs) | |
| marker_pts_for_bounds = [] | |
| for dt_k in marker_times: | |
| st = _state_at_dt(path_pts[0][0], path_pts[0][1], legs, method, start_dt, dt_k) | |
| if st is None: | |
| continue | |
| lat = st["lat"] | |
| lon = st["lon"] | |
| marker_pts_for_bounds.append((lat, lon)) | |
| tlabel = _to_label_utc(dt_k) | |
| elapsed_min = st["elapsed_min"] | |
| hdg = st["heading_deg"] | |
| spd = st["speed_kt"] | |
| dist_km = st["dist_km"] | |
| is_final = (start_dt == RADAR_FIX_TIME_UTC) and (dt_k == _dt_from_label(GUIDED_FINAL_LABEL)) | |
| popup_lines = [ | |
| f"<b>{'FINAL RESTING POINT' if is_final else 'Validation point'}</b>", | |
| f"UTC time: {tlabel}", | |
| f"Lat: {lat:.5f}", | |
| f"Lon: {lon:.5f}", | |
| f"Time elapsed: {elapsed_min:.1f} min", | |
| f"Heading: {hdg:.1f}°", | |
| f"Speed: {spd:.1f} kt", | |
| f"Distance flown: {dist_km:.2f} km", | |
| ] | |
| if is_final: | |
| folium.CircleMarker( | |
| location=(lat, lon), | |
| radius=9, | |
| fill=True, | |
| color="magenta", | |
| fill_color="magenta", | |
| fill_opacity=0.95, | |
| tooltip=f"{tlabel} (FINAL)", | |
| popup=folium.Popup("<br>".join(popup_lines), max_width=360), | |
| ).add_to(m) | |
| else: | |
| folium.CircleMarker( | |
| location=(lat, lon), | |
| radius=6, | |
| fill=True, | |
| color="white", | |
| fill_color="white", | |
| fill_opacity=1.0, | |
| tooltip=tlabel, | |
| popup=folium.Popup("<br>".join(popup_lines), max_width=360), | |
| ).add_to(m) | |
| for name, lat, lon in WAYPOINTS: | |
| add_permanent_label(m, float(name) if False else name, float(lat), float(lon)) | |
| add_named_marker(m, CAPT_SIMON_HARDY_NAME, CAPT_SIMON_HARDY_LAT, CAPT_SIMON_HARDY_LON) | |
| waypoint_pts = [(float(lat), float(lon)) for _, lat, lon in WAYPOINTS] + [(CAPT_SIMON_HARDY_LAT, CAPT_SIMON_HARDY_LON)] | |
| all_ring_pts = [] | |
| for k in PING_RINGS: | |
| all_ring_pts.extend(PING_RINGS.get(k, [])) | |
| bounds = _all_bounds( | |
| [path_pts] | |
| + [waypoint_pts] | |
| + [all_ring_pts] | |
| + ([search_area_pts_for_bounds] if search_area_pts_for_bounds else []) | |
| + ([marker_pts_for_bounds] if marker_pts_for_bounds else []) | |
| + ([max_range_pts_for_bounds] if max_range_pts_for_bounds else []) | |
| ) | |
| if bounds: | |
| m.fit_bounds(bounds, padding=(20, 20)) | |
| map_html = m.get_root().render() | |
| escaped = html.escape(map_html, quote=True) | |
| return f""" | |
| <iframe | |
| srcdoc="{escaped}" | |
| style="width: 100%; height: 650px; border: 0; border-radius: 12px;" | |
| loading="lazy" | |
| referrerpolicy="no-referrer" | |
| ></iframe> | |
| """ | |
| # ================= Guided helpers ================= | |
| def _clamp(x, lo, hi): | |
| return max(lo, min(hi, x)) | |
| def _frange(lo: float, hi: float, step: float): | |
| step = float(step) | |
| if step <= 0: | |
| return [] | |
| n = int(math.floor((hi - lo) / step + 1e-9)) + 1 | |
| out = [lo + i * step for i in range(max(0, n))] | |
| return [x for x in out if x <= hi + 1e-9] | |
| def _refine_range(center: float, half_window: float, step: float, lo: float, hi: float): | |
| lo2 = _clamp(center - half_window, lo, hi) | |
| hi2 = _clamp(center + half_window, lo, hi) | |
| return _frange(lo2, hi2, step) | |
| def _guided_start_dt(): | |
| return RADAR_FIX_TIME_UTC | |
| def _lookup_measured_bto(dt_ping: datetime): | |
| _bfo, bto = _lookup_measured_at_dt(dt_ping) | |
| return None if bto is None else float(bto) | |
| def _available_hard_bto_labels(): | |
| out = [] | |
| for lbl in HARD_BTO_LABELS: | |
| dt = _dt_from_label(lbl) | |
| if _lookup_measured_bto(dt) is not None: | |
| out.append(lbl) | |
| return out | |
| def _simulate_one_leg_end(method: str, lat0: float, lon0: float, hdg: float, spd_kt: float, minutes: float): | |
| dist_m = float(spd_kt) * 1852.0 * (float(minutes) / 60.0) | |
| if method == "VINCENTY": | |
| (lat1, lon1), _ = fp.vincenty_direct((float(lat0), float(lon0)), float(hdg), dist_m) | |
| else: | |
| lat1, lon1 = fp.rhumb_direct((float(lat0), float(lon0)), float(hdg), dist_m) | |
| return float(lat1), float(lon1) | |
| def _bto_calc(method: str, lat: float, lon: float, dt_ping: datetime, alt_ft: float, | |
| vs_fpm: float, start_dt: datetime, descent_start_dt): | |
| 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 | |
| return float(fp.calculateBTO_at_time(float(lat), float(lon), alt_m, dt_ping)) | |
| def _golden_min_abs(fn, lo: float, hi: float, iters: int = 24): | |
| grc = (math.sqrt(5.0) - 1.0) / 2.0 | |
| a, b = float(lo), float(hi) | |
| c = b - grc * (b - a) | |
| d = a + grc * (b - a) | |
| fc = abs(float(fn(c))) | |
| fd = abs(float(fn(d))) | |
| for _ in range(int(iters)): | |
| if fc <= fd: | |
| b, d, fd = d, c, fc | |
| c = b - grc * (b - a) | |
| fc = abs(float(fn(c))) | |
| else: | |
| a, c, fc = c, d, fd | |
| d = a + grc * (b - a) | |
| fd = abs(float(fn(d))) | |
| x_best = (a + b) / 2.0 | |
| return float(x_best), abs(float(fn(x_best))) | |
| def _fit_segment_speed(method: str, lat0: float, lon0: float, hdg: float, minutes: float, | |
| dt_end: datetime, alt_ft: float, vs_fpm: float, start_dt: datetime, | |
| descent_start_dt, vmin: float, vmax: float): | |
| meas = _lookup_measured_bto(dt_end) | |
| if meas is None: | |
| v = 0.5 * (vmin + vmax) | |
| lat1, lon1 = _simulate_one_leg_end(method, lat0, lon0, hdg, v, minutes) | |
| return float(v), float("nan"), (lat1, lon1) | |
| def err(v): | |
| lat1, lon1 = _simulate_one_leg_end(method, lat0, lon0, hdg, v, minutes) | |
| bto = _bto_calc(method, lat1, lon1, dt_end, alt_ft, vs_fpm, start_dt, descent_start_dt) | |
| return float(bto - meas) | |
| e_lo = float(err(vmin)) | |
| e_hi = float(err(vmax)) | |
| if (e_lo < 0.0 and e_hi > 0.0) or (e_lo > 0.0 and e_hi < 0.0): | |
| a, b = float(vmin), float(vmax) | |
| fa, fb = e_lo, e_hi | |
| for _ in range(26): | |
| m = 0.5 * (a + b) | |
| fm = float(err(m)) | |
| if abs(fm) < 0.2: | |
| a = b = m | |
| break | |
| if (fa < 0 and fm > 0) or (fa > 0 and fm < 0): | |
| b, fb = m, fm | |
| else: | |
| a, fa = m, fm | |
| v_best = 0.5 * (a + b) | |
| lat1, lon1 = _simulate_one_leg_end(method, lat0, lon0, hdg, v_best, minutes) | |
| return float(v_best), float(err(v_best)), (lat1, lon1) | |
| v_best, _ = _golden_min_abs(err, vmin, vmax, iters=18) | |
| lat1, lon1 = _simulate_one_leg_end(method, lat0, lon0, hdg, v_best, minutes) | |
| return float(v_best), float(err(v_best)), (lat1, lon1) | |
| def _compute_bto_errors_at_enforced(method: str, start_dt: datetime, alt_ft: float, vs_fpm: float, | |
| descent_start_dt, full_legs, enforced_dts): | |
| errs = [] | |
| for dt_k in enforced_dts: | |
| meas = _lookup_measured_bto(dt_k) | |
| if meas is None: | |
| continue | |
| st = _state_at_dt(RADAR_FIX_LAT, RADAR_FIX_LON, full_legs, method, start_dt, dt_k) | |
| if st is None: | |
| continue | |
| bto_calc = _bto_calc(method, st["lat"], st["lon"], dt_k, alt_ft, vs_fpm, start_dt, descent_start_dt) | |
| errs.append(float(bto_calc - meas)) | |
| return errs | |
| def _build_south_legs_piecewise_constant_heading(h_south: float, speeds, minutes): | |
| legs = [] | |
| for v, m in zip(speeds, minutes): | |
| if float(m) <= 0: | |
| continue | |
| legs.append((float(h_south), float(v), float(m))) | |
| return legs | |
| def _build_south_legs_variable(headings, speeds, minutes): | |
| legs = [] | |
| for h, v, m in zip(headings, speeds, minutes): | |
| if float(m) <= 0: | |
| continue | |
| legs.append((float(h), float(v), float(m))) | |
| return legs | |
| # ================= Top-K polish ================= | |
| def _polish_south_speeds_topk(method: str, start_dt: datetime, end_dt: datetime, alt_ft: float, | |
| vs_fpm: float, descent_start_dt, h_south: float, turn_legs, | |
| enforced_dts, init_speeds, passes: int = POLISH_PASSES, | |
| window_kt: float = POLISH_WINDOW_KT, step_kt: float = POLISH_STEP_KT): | |
| speeds = [float(v) for v in init_speeds] | |
| pts_turn, _, _ = simulate_path(RADAR_FIX_LAT, RADAR_FIX_LON, turn_legs, method) | |
| turn_end_lat, turn_end_lon = float(pts_turn[-1][0]), float(pts_turn[-1][1]) | |
| def build_full(speeds_now): | |
| lat_cur, lon_cur = turn_end_lat, turn_end_lon | |
| seg_errs = [] | |
| for mins, end_lbl, vseg in zip(GUIDED_SOUTH_SEG_MIN, GUIDED_SOUTH_SEG_LABELS_END, speeds_now): | |
| dt_end = _dt_from_label(end_lbl) | |
| lat_cur, lon_cur = _simulate_one_leg_end(method, lat_cur, lon_cur, h_south, vseg, mins) | |
| meas = _lookup_measured_bto(dt_end) | |
| if meas is None: | |
| seg_errs.append(float("nan")) | |
| else: | |
| bto_here = _bto_calc(method, lat_cur, lon_cur, dt_end, alt_ft, vs_fpm, start_dt, descent_start_dt) | |
| seg_errs.append(float(bto_here - meas)) | |
| south_legs = _build_south_legs_piecewise_constant_heading(h_south, speeds_now, GUIDED_SOUTH_SEG_MIN) | |
| full_legs = clip_legs_to_end_dt(turn_legs + south_legs, start_dt, end_dt) | |
| return full_legs, seg_errs | |
| def objective(full_legs): | |
| bto_errs = _compute_bto_errors_at_enforced(method, start_dt, alt_ft, vs_fpm, descent_start_dt, full_legs, enforced_dts) | |
| if not bto_errs: | |
| return 1e9 | |
| abs_arr = np.abs(np.array(bto_errs, dtype=float)) | |
| max_abs = float(np.max(abs_arr)) | |
| sum_abs = float(np.sum(abs_arr)) | |
| return float(max_abs + POLISH_OBJ_EPS * sum_abs) | |
| full_legs, seg_errs = build_full(speeds) | |
| best_obj = objective(full_legs) | |
| best_speeds = speeds[:] | |
| best_full = full_legs | |
| best_seg_errs = seg_errs | |
| for _p in range(int(passes)): | |
| improved_any = False | |
| for i in range(len(best_speeds)): | |
| v0 = float(best_speeds[i]) | |
| v_lo = _clamp(v0 - float(window_kt), SOUTH_SPEED_MIN_KT, SOUTH_SPEED_MAX_KT) | |
| v_hi = _clamp(v0 + float(window_kt), SOUTH_SPEED_MIN_KT, SOUTH_SPEED_MAX_KT) | |
| cand = list(np.arange(v_lo, v_hi + 1e-9, float(step_kt))) | |
| if not cand: | |
| continue | |
| local_best_obj = best_obj | |
| local_best_speeds = best_speeds[:] | |
| local_best_full = best_full | |
| local_best_seg_errs = best_seg_errs | |
| for v_try in cand: | |
| sp = best_speeds[:] | |
| sp[i] = float(v_try) | |
| full_try, seg_try = build_full(sp) | |
| obj_try = objective(full_try) | |
| if obj_try < local_best_obj - 1e-9: | |
| local_best_obj = obj_try | |
| local_best_speeds = sp | |
| local_best_full = full_try | |
| local_best_seg_errs = seg_try | |
| if local_best_obj < best_obj - 1e-9: | |
| best_obj = local_best_obj | |
| best_speeds = local_best_speeds | |
| best_full = local_best_full | |
| best_seg_errs = local_best_seg_errs | |
| improved_any = True | |
| if not improved_any: | |
| break | |
| return best_speeds, best_full, best_seg_errs | |
| # ================= Guided Solver: Straight heading ================= | |
| def solve_guided_straight(method, alt_ft, vs_fpm, descent_start_choice): | |
| start_dt = _guided_start_dt() | |
| end_dt = _dt_from_label(GUIDED_FINAL_LABEL) | |
| descent_start_dt = _descent_start_dt_from_choice(descent_start_choice) | |
| pts_all = _ping_points_after(start_dt) | |
| pts_window = [(lbl, dt, bfo, bto) for (lbl, dt, bfo, bto) in pts_all if start_dt <= dt <= end_dt] | |
| if len(pts_window) < 2: | |
| return None, "Guided solve: not enough ping points to score.", None | |
| enforced_labels = _available_hard_bto_labels() | |
| if not enforced_labels: | |
| return None, "Guided solve: no measured BTO available at any enforced ring times.", None | |
| enforced_dts = [_dt_from_label(lbl) for lbl in enforced_labels] | |
| def _evaluate(h_south: float, v_turn: float, do_polish: bool): | |
| h_south = float(h_south) | |
| v_turn = float(v_turn) | |
| turn_legs, _turn_info = build_turn_legs(speed_knots=float(v_turn), method=method, heading_final_deg=float(h_south)) | |
| pts_turn, _, _ = simulate_path(RADAR_FIX_LAT, RADAR_FIX_LON, turn_legs, method) | |
| lat_cur, lon_cur = float(pts_turn[-1][0]), float(pts_turn[-1][1]) | |
| south_speeds = [] | |
| south_errs = [] | |
| for mins, end_lbl in zip(GUIDED_SOUTH_SEG_MIN, GUIDED_SOUTH_SEG_LABELS_END): | |
| dt_end = _dt_from_label(end_lbl) | |
| v_seg, err_seg, (lat1, lon1) = _fit_segment_speed( | |
| method=method, | |
| lat0=lat_cur, | |
| lon0=lon_cur, | |
| hdg=float(h_south), | |
| minutes=float(mins), | |
| dt_end=dt_end, | |
| alt_ft=alt_ft, | |
| vs_fpm=vs_fpm, | |
| start_dt=start_dt, | |
| descent_start_dt=descent_start_dt, | |
| vmin=SOUTH_SPEED_MIN_KT, | |
| vmax=SOUTH_SPEED_MAX_KT, | |
| ) | |
| south_speeds.append(float(v_seg)) | |
| south_errs.append(float(err_seg)) | |
| lat_cur, lon_cur = float(lat1), float(lon1) | |
| south_legs = _build_south_legs_piecewise_constant_heading(float(h_south), south_speeds, GUIDED_SOUTH_SEG_MIN) | |
| full_legs = clip_legs_to_end_dt(turn_legs + south_legs, start_dt, end_dt) | |
| if do_polish and ENABLE_POLISH_STRAIGHT: | |
| south_speeds, full_legs, south_errs = _polish_south_speeds_topk( | |
| method=method, | |
| start_dt=start_dt, | |
| end_dt=end_dt, | |
| alt_ft=alt_ft, | |
| vs_fpm=vs_fpm, | |
| descent_start_dt=descent_start_dt, | |
| h_south=float(h_south), | |
| turn_legs=turn_legs, | |
| enforced_dts=enforced_dts, | |
| init_speeds=south_speeds, | |
| ) | |
| bto_errs = _compute_bto_errors_at_enforced(method, start_dt, alt_ft, vs_fpm, descent_start_dt, full_legs, enforced_dts) | |
| if not bto_errs: | |
| return None | |
| abs_arr = np.abs(np.array(bto_errs, dtype=float)) | |
| max_abs = float(np.max(abs_arr)) | |
| sum_abs = float(np.sum(abs_arr)) | |
| rmse = float(np.sqrt(np.mean(np.array(bto_errs, dtype=float) ** 2))) | |
| details = { | |
| "h_south": float(h_south), | |
| "v_turn": float(v_turn), | |
| "south_speeds": [float(v) for v in south_speeds], | |
| "south_errs_us": [float(e) for e in south_errs], | |
| "enforced_labels": enforced_labels, | |
| "max_abs_err_us": max_abs, | |
| "sum_abs_err_us": sum_abs, | |
| "rmse_bto_us": rmse, | |
| } | |
| return (max_abs, sum_abs, rmse), full_legs, details | |
| def best_for_heading(h_south: float): | |
| coarse_turns = list(np.arange(TURN_SPEED_MIN_KT, TURN_SPEED_MAX_KT + 1e-9, 5.0)) | |
| best_key = None | |
| best_legs = None | |
| best_det = None | |
| for v in coarse_turns: | |
| out = _evaluate(h_south, v, do_polish=False) | |
| if out is None: | |
| continue | |
| key, legs, det = out | |
| if best_key is None or key < best_key: | |
| best_key, best_legs, best_det = key, legs, det | |
| if best_key is None: | |
| return None | |
| v0 = float(best_det["v_turn"]) | |
| v_lo = _clamp(v0 - 7.5, TURN_SPEED_MIN_KT, TURN_SPEED_MAX_KT) | |
| v_hi = _clamp(v0 + 7.5, TURN_SPEED_MIN_KT, TURN_SPEED_MAX_KT) | |
| def obj(v): | |
| out2 = _evaluate(h_south, v, do_polish=False) | |
| if out2 is None: | |
| return 1e9 | |
| (max_abs, sum_abs, _rmse), _legs, _det = out2 | |
| return float(max_abs) + POLISH_OBJ_EPS * float(sum_abs) | |
| v_best, _ = _golden_min_abs(obj, v_lo, v_hi, iters=16) | |
| out3 = _evaluate(h_south, v_best, do_polish=False) | |
| if out3 is None: | |
| return best_key, best_legs, best_det | |
| return out3 | |
| coarse_headings = _frange(GUIDED_HEADING_MIN, GUIDED_HEADING_MAX, COARSE_HEADING_STEP_DEG) | |
| candidates = [] | |
| best = None | |
| best_legs = None | |
| best_det = None | |
| for h in coarse_headings: | |
| out = best_for_heading(h) | |
| if out is None: | |
| continue | |
| key, legs, det = out | |
| candidates.append((key, float(det["h_south"]), float(det["v_turn"]))) | |
| if best is None or key < best: | |
| best, best_legs, best_det = key, legs, det | |
| if best_legs is None: | |
| return None, "Guided solve: no solution found within bounds.", None | |
| h0 = float(best_det["h_south"]) | |
| refine_headings = _refine_range(h0, REFINE_HEADING_HALF_WINDOW_DEG, REFINE_HEADING_STEP_DEG, GUIDED_HEADING_MIN, GUIDED_HEADING_MAX) | |
| for h in refine_headings: | |
| out = best_for_heading(h) | |
| if out is None: | |
| continue | |
| key, legs, det = out | |
| candidates.append((key, float(det["h_south"]), float(det["v_turn"]))) | |
| if best is None or key < best: | |
| best, best_legs, best_det = key, legs, det | |
| candidates_sorted = sorted(candidates, key=lambda x: x[0]) | |
| uniq = [] | |
| seen = set() | |
| for key, hh, vv in candidates_sorted: | |
| k2 = (round(hh, 4), round(vv, 4)) | |
| if k2 in seen: | |
| continue | |
| seen.add(k2) | |
| uniq.append((key, hh, vv)) | |
| if len(uniq) >= max(1, int(TOPK_POLISH)): | |
| break | |
| best_final_key, best_final_legs, best_final_det = best, best_legs, best_det | |
| if ENABLE_POLISH_STRAIGHT and uniq: | |
| for _key, hh, vv in uniq: | |
| outp = _evaluate(hh, vv, do_polish=True) | |
| if outp is None: | |
| continue | |
| keyp, legsp, detp = outp | |
| if best_final_key is None or keyp < best_final_key: | |
| best_final_key, best_final_legs, best_final_det = keyp, legsp, detp | |
| return best_final_legs, "", start_dt | |
| # ================= Guided Solver: Variable heading & speed ================= | |
| def _solve_guided_variable_core(method, alt_ft, vs_fpm, descent_start_choice, se_bias_after_2240: bool): | |
| start_dt = _guided_start_dt() | |
| end_dt = _dt_from_label(GUIDED_FINAL_LABEL) | |
| descent_start_dt = _descent_start_dt_from_choice(descent_start_choice) | |
| enforced_labels = _available_hard_bto_labels() | |
| if not enforced_labels: | |
| return None, "Guided solve: no measured BTO available at any enforced ring times.", None | |
| enforced_dts = [_dt_from_label(lbl) for lbl in enforced_labels] | |
| turn_speeds = list(np.arange(TURN_SPEED_MIN_KT, TURN_SPEED_MAX_KT + 1e-9, VAR_TURN_SPEED_STEP)) | |
| first_headings = _frange(VAR_HEADING_MIN, VAR_HEADING_MAX, VAR_FIRST_HEADING_STEP) | |
| best_global = None | |
| best_global_legs = None | |
| def score_from_errors(errs): | |
| if not errs: | |
| return None | |
| abs_arr = np.abs(np.array(errs, dtype=float)) | |
| return float(np.max(abs_arr)), float(np.sum(abs_arr)), float(np.sqrt(np.mean(abs_arr * abs_arr))) | |
| for v_turn in turn_speeds: | |
| for h1 in first_headings: | |
| turn_legs, _turn_info = build_turn_legs(speed_knots=float(v_turn), method=method, heading_final_deg=float(h1)) | |
| pts_turn, _, _ = simulate_path(RADAR_FIX_LAT, RADAR_FIX_LON, turn_legs, method) | |
| lat0, lon0 = float(pts_turn[-1][0]), float(pts_turn[-1][1]) | |
| beam = [{ | |
| "lat": lat0, | |
| "lon": lon0, | |
| "headings": [], | |
| "speeds": [], | |
| "h_prev": float(h1), | |
| "v_turn": float(v_turn), | |
| "turn_legs": turn_legs, | |
| "max_abs": 0.0, | |
| "sum_abs": 0.0, | |
| "smooth_sum": 0.0, | |
| "east_bias_sum": 0.0, | |
| }] | |
| seg_i = -1 | |
| for mins, end_lbl in zip(GUIDED_SOUTH_SEG_MIN, GUIDED_SOUTH_SEG_LABELS_END): | |
| seg_i += 1 | |
| dt_end = _dt_from_label(end_lbl) | |
| meas = _lookup_measured_bto(dt_end) | |
| next_beam = [] | |
| use_se_bias = bool(se_bias_after_2240 and seg_i >= SE_EAST_DIVERGE_START_SEG_INDEX) | |
| delta_set = SE_VAR_EAST_DELTA_SET if use_se_bias else VAR_HEADING_DELTA_SET | |
| for st in beam: | |
| h_prev = float(st["h_prev"]) | |
| cand_h = [] | |
| for d in delta_set: | |
| h_try = h_prev + float(d) | |
| h_try = _clamp(h_try, VAR_HEADING_MIN, VAR_HEADING_MAX) | |
| cand_h.append(h_try) | |
| cand_h = sorted(list({round(x, 6): x for x in cand_h}.values())) | |
| for h_try in cand_h: | |
| v_seg, err_seg, (lat1, lon1) = _fit_segment_speed( | |
| method=method, | |
| lat0=float(st["lat"]), | |
| lon0=float(st["lon"]), | |
| hdg=float(h_try), | |
| minutes=float(mins), | |
| dt_end=dt_end, | |
| alt_ft=alt_ft, | |
| vs_fpm=vs_fpm, | |
| start_dt=start_dt, | |
| descent_start_dt=descent_start_dt, | |
| vmin=SOUTH_SPEED_MIN_KT, | |
| vmax=SOUTH_SPEED_MAX_KT, | |
| ) | |
| abs_err = 0.0 if meas is None or (err_seg != err_seg) else abs(float(err_seg)) | |
| smooth_pen = abs(float(h_try) - float(h_prev)) * VAR_SMOOTH_PENALTY_US_PER_DEG | |
| east_bias_pen = 0.0 | |
| if use_se_bias: | |
| dh = float(h_try) - float(h_prev) | |
| if dh > 0.0: | |
| east_bias_pen = dh * float(SE_EAST_BIAS_PENALTY_US_PER_DEG) | |
| max_abs = max(float(st["max_abs"]), float(abs_err)) | |
| sum_abs = float(st["sum_abs"]) + float(abs_err) | |
| smooth_sum = float(st["smooth_sum"]) + float(smooth_pen) | |
| east_bias_sum = float(st.get("east_bias_sum", 0.0)) + float(east_bias_pen) | |
| next_beam.append({ | |
| "lat": float(lat1), | |
| "lon": float(lon1), | |
| "headings": st["headings"] + [float(h_try)], | |
| "speeds": st["speeds"] + [float(v_seg)], | |
| "h_prev": float(h_try), | |
| "v_turn": float(st["v_turn"]), | |
| "turn_legs": st["turn_legs"], | |
| "max_abs": max_abs, | |
| "sum_abs": sum_abs, | |
| "smooth_sum": smooth_sum, | |
| "east_bias_sum": east_bias_sum, | |
| }) | |
| if not next_beam: | |
| beam = [] | |
| break | |
| next_beam.sort(key=lambda x: (x["max_abs"], x["sum_abs"], x.get("east_bias_sum", 0.0), x["smooth_sum"])) | |
| beam = next_beam[:max(1, int(VAR_BEAM_WIDTH))] | |
| if not beam: | |
| continue | |
| for st in beam: | |
| south_legs = _build_south_legs_variable(st["headings"], st["speeds"], GUIDED_SOUTH_SEG_MIN) | |
| full_legs = clip_legs_to_end_dt(st["turn_legs"] + south_legs, start_dt, end_dt) | |
| bto_errs = _compute_bto_errors_at_enforced(method, start_dt, alt_ft, vs_fpm, descent_start_dt, full_legs, enforced_dts) | |
| if not bto_errs: | |
| continue | |
| max_abs, sum_abs, rmse = score_from_errors(bto_errs) | |
| key = (max_abs, sum_abs, float(st.get("east_bias_sum", 0.0)), float(st["smooth_sum"]), rmse) | |
| if best_global is None or key < best_global: | |
| best_global = key | |
| best_global_legs = full_legs | |
| if best_global_legs is None: | |
| return None, "Guided solve: Variable mode found no solution within bounds.", None | |
| return best_global_legs, "", start_dt | |
| def solve_guided_variable(method, alt_ft, vs_fpm, descent_start_choice): | |
| return _solve_guided_variable_core(method, alt_ft, vs_fpm, descent_start_choice, se_bias_after_2240=False) | |
| def solve_guided_variable_se(method, alt_ft, vs_fpm, descent_start_choice): | |
| return _solve_guided_variable_core(method, alt_ft, vs_fpm, descent_start_choice, se_bias_after_2240=True) | |
| # ================= Guided Solver: Variable heading, CONSTANT southern speed ================= | |
| def solve_guided_constant_speed(method, alt_ft, vs_fpm, descent_start_choice): | |
| start_dt = _guided_start_dt() | |
| end_dt = _dt_from_label(GUIDED_FINAL_LABEL) | |
| descent_start_dt = _descent_start_dt_from_choice(descent_start_choice) | |
| enforced_labels = _available_hard_bto_labels() | |
| if not enforced_labels: | |
| return None, "Guided solve: no measured BTO available at any enforced ring times.", None | |
| enforced_dts = [_dt_from_label(lbl) for lbl in enforced_labels] | |
| turn_speeds = list(np.arange(TURN_SPEED_MIN_KT, TURN_SPEED_MAX_KT + 1e-9, VAR_TURN_SPEED_STEP)) | |
| first_headings = _frange(VAR_HEADING_MIN, VAR_HEADING_MAX, VAR_FIRST_HEADING_STEP) | |
| best_global = None | |
| best_global_legs = None | |
| def score_from_errors(errs): | |
| if not errs: | |
| return None | |
| abs_arr = np.abs(np.array(errs, dtype=float)) | |
| return float(np.max(abs_arr)), float(np.sum(abs_arr)), float(np.sqrt(np.mean(abs_arr * abs_arr))) | |
| v_const = float(CONST_SOUTH_SPEED_KT) | |
| for v_turn in turn_speeds: | |
| for h1 in first_headings: | |
| turn_legs, _turn_info = build_turn_legs(speed_knots=float(v_turn), method=method, heading_final_deg=float(h1)) | |
| pts_turn, _, _ = simulate_path(RADAR_FIX_LAT, RADAR_FIX_LON, turn_legs, method) | |
| lat0, lon0 = float(pts_turn[-1][0]), float(pts_turn[-1][1]) | |
| beam = [{ | |
| "lat": lat0, | |
| "lon": lon0, | |
| "headings": [], | |
| "h_prev": float(h1), | |
| "v_turn": float(v_turn), | |
| "turn_legs": turn_legs, | |
| "max_abs": 0.0, | |
| "sum_abs": 0.0, | |
| "smooth_sum": 0.0, | |
| }] | |
| for mins, end_lbl in zip(GUIDED_SOUTH_SEG_MIN, GUIDED_SOUTH_SEG_LABELS_END): | |
| dt_end = _dt_from_label(end_lbl) | |
| meas = _lookup_measured_bto(dt_end) | |
| next_beam = [] | |
| for st in beam: | |
| h_prev = float(st["h_prev"]) | |
| cand_h = [] | |
| for d in VAR_HEADING_DELTA_SET: | |
| h_try = _clamp(h_prev + float(d), VAR_HEADING_MIN, VAR_HEADING_MAX) | |
| cand_h.append(h_try) | |
| cand_h = sorted(list({round(x, 6): x for x in cand_h}.values())) | |
| for h_try in cand_h: | |
| lat1, lon1 = _simulate_one_leg_end(method, float(st["lat"]), float(st["lon"]), float(h_try), v_const, float(mins)) | |
| if meas is None: | |
| abs_err = 0.0 | |
| else: | |
| bto_here = _bto_calc(method, lat1, lon1, dt_end, alt_ft, vs_fpm, start_dt, descent_start_dt) | |
| abs_err = abs(float(bto_here - float(meas))) | |
| smooth_pen = abs(float(h_try) - float(h_prev)) * VAR_SMOOTH_PENALTY_US_PER_DEG | |
| max_abs = max(float(st["max_abs"]), float(abs_err)) | |
| sum_abs = float(st["sum_abs"]) + float(abs_err) | |
| smooth_sum = float(st["smooth_sum"]) + float(smooth_pen) | |
| next_beam.append({ | |
| "lat": float(lat1), | |
| "lon": float(lon1), | |
| "headings": st["headings"] + [float(h_try)], | |
| "h_prev": float(h_try), | |
| "v_turn": float(st["v_turn"]), | |
| "turn_legs": st["turn_legs"], | |
| "max_abs": max_abs, | |
| "sum_abs": sum_abs, | |
| "smooth_sum": smooth_sum, | |
| }) | |
| if not next_beam: | |
| beam = [] | |
| break | |
| next_beam.sort(key=lambda x: (x["max_abs"], x["sum_abs"], x["smooth_sum"])) | |
| beam = next_beam[:max(1, int(VAR_BEAM_WIDTH))] | |
| if not beam: | |
| continue | |
| for st in beam: | |
| speeds = [v_const] * len(GUIDED_SOUTH_SEG_MIN) | |
| south_legs = _build_south_legs_variable(st["headings"], speeds, GUIDED_SOUTH_SEG_MIN) | |
| full_legs = clip_legs_to_end_dt(st["turn_legs"] + south_legs, start_dt, end_dt) | |
| bto_errs = _compute_bto_errors_at_enforced(method, start_dt, alt_ft, vs_fpm, descent_start_dt, full_legs, enforced_dts) | |
| if not bto_errs: | |
| continue | |
| max_abs, sum_abs, rmse = score_from_errors(bto_errs) | |
| key = (max_abs, sum_abs, float(st["smooth_sum"]), rmse) | |
| if best_global is None or key < best_global: | |
| best_global = key | |
| best_global_legs = full_legs | |
| if best_global_legs is None: | |
| return None, "Guided solve: Constant-speed mode found no solution within bounds.", None | |
| return best_global_legs, "", start_dt | |
| # ================= RUNNERS ================= | |
| def _run_common(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice, show_max_range_ring): | |
| if start_dt == RADAR_FIX_TIME_UTC: | |
| end_dt = _dt_from_label(GUIDED_FINAL_LABEL) | |
| legs = clip_legs_to_end_dt(legs, start_dt, end_dt) | |
| path_pts, seg_dists_m, total_time_min = simulate_path(start_lat, start_lon, legs, method) | |
| html_map = build_leaflet_map(path_pts, legs, method, start_dt, show_max_range_ring=show_max_range_ring) | |
| stats_md = compute_stats(path_pts, seg_dists_m, total_time_min, legs) | |
| bfo_fig, _bfo_md = bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice) | |
| descent_start_dt = _descent_start_dt_from_choice(descent_start_choice) | |
| alt_fig = build_altitude_plot(start_dt, total_time_min, alt_ft, vs_fpm, descent_start_dt) | |
| speed_fig = build_speed_profile_plot(start_dt, legs) | |
| headers, rows = build_validation_table(start_lat, start_lon, method, start_dt, legs, alt_ft, vs_fpm, descent_start_choice) | |
| return html_map, stats_md, bfo_fig, alt_fig, speed_fig, headers, rows | |
| def run_sim_manual(start_lat, start_lon, legs_df, method, start_time_str, alt_ft, vs_fpm, descent_start_choice, show_max_range_ring): | |
| legs = parse_legs_df(legs_df) | |
| start_dt = _parse_start_time(start_time_str) | |
| html_map, stats_md, bfo_fig, alt_fig, speed_fig, headers, rows = _run_common( | |
| start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice, show_max_range_ring | |
| ) | |
| return gr.update(), "", html_map, stats_md, bfo_fig, alt_fig, speed_fig, gr.update(headers=headers, value=rows) | |
| def run_guided(guided_mode, start_lat, start_lon, method, alt_ft, vs_fpm, descent_start_choice, show_max_range_ring): | |
| if guided_mode == "Straight heading": | |
| legs, _report_md, start_dt = solve_guided_straight(method, alt_ft, vs_fpm, descent_start_choice) | |
| elif guided_mode == "Variable heading & speed": | |
| legs, _report_md, start_dt = solve_guided_variable(method, alt_ft, vs_fpm, descent_start_choice) | |
| elif guided_mode == "Variable heading & speed (SE after 22:40)": | |
| legs, _report_md, start_dt = solve_guided_variable_se(method, alt_ft, vs_fpm, descent_start_choice) | |
| elif guided_mode.startswith("Variable heading (constant"): | |
| legs, _report_md, start_dt = solve_guided_constant_speed(method, alt_ft, vs_fpm, descent_start_choice) | |
| else: | |
| legs, _report_md, start_dt = solve_guided_straight(method, alt_ft, vs_fpm, descent_start_choice) | |
| if legs is None: | |
| fig = go.Figure() | |
| fig.update_layout(title="BFO Analysis", xaxis_title="Time", yaxis_title="Hz", height=520) | |
| alt_fig = go.Figure() | |
| alt_fig.update_layout(title="Altitude vs Time", xaxis_title="Minutes since start", yaxis_title="Altitude (ft)", height=320) | |
| speed_fig = go.Figure() | |
| speed_fig.update_layout(title="Speed Profile", xaxis_title="Minutes since start", yaxis_title="Speed (kt)", height=320) | |
| empty_table = gr.update(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" | |
| ], value=[]) | |
| return gr.update(), "", "", "", fig, alt_fig, speed_fig, empty_table | |
| html_map, stats_md, bfo_fig, alt_fig, speed_fig, headers, rows = _run_common( | |
| RADAR_FIX_LAT, RADAR_FIX_LON, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice, show_max_range_ring | |
| ) | |
| return gr.update(value=legs_to_rows(legs)), "", html_map, stats_md, bfo_fig, alt_fig, speed_fig, gr.update(headers=headers, value=rows) | |
| # ================= UI ================= | |
| with gr.Blocks() as demo: | |
| gr.Markdown("## ✈️ MH370 Flight Simulator — Leaflet + Esri Ocean/Satellite") | |
| if KML_LOAD_ERROR: | |
| gr.Markdown( | |
| "### ⚠️ KML not loaded\n" | |
| f"**Error:** {KML_LOAD_ERROR}\n\n" | |
| "Make sure your KML file is uploaded and `KML_FILE` matches exactly." | |
| ) | |
| if KMZ_LOAD_ERROR: | |
| gr.Markdown( | |
| "### ⚠️ Search areas KMZ not loaded\n" | |
| f"**Error:** {KMZ_LOAD_ERROR}\n\n" | |
| "Make sure `Search areas.kmz` is uploaded and `SEARCH_AREAS_KMZ` matches exactly." | |
| ) | |
| if MAX_RANGE_KML_ERROR: | |
| gr.Markdown( | |
| "### ⚠️ Maximum range KML not loaded\n" | |
| f"**Error:** {MAX_RANGE_KML_ERROR}\n\n" | |
| "Make sure `MH370_max_range_6166km_KUL_red.kml` is uploaded and `MAX_RANGE_KML` matches exactly." | |
| ) | |
| guided_mode = gr.Dropdown( | |
| [ | |
| "Straight heading", | |
| "Variable heading & speed", | |
| "Variable heading & speed (SE after 22:40)", | |
| f"Variable heading (constant {CONST_SOUTH_SPEED_KT:.0f} kt)", | |
| ], | |
| value="Straight heading", | |
| label="Guided scenario", | |
| ) | |
| with gr.Row(): | |
| start_lat = gr.Slider(-10, 15, value=6.76, label="Start Latitude (Manual only)") | |
| start_lon = gr.Slider(70, 115, value=95.69, label="Start Longitude (Manual only)") | |
| method = gr.Dropdown(["VINCENTY", "RHUMB"], value="VINCENTY", label="Method") | |
| gr.Markdown("### Map layers") | |
| show_max_range_ring = gr.Checkbox( | |
| value=False, | |
| label="Show maximum range ring (6166 km from KUL)" | |
| ) | |
| gr.Markdown("### BFO/BTO Settings") | |
| with gr.Row(): | |
| start_time_str = gr.Textbox(value="18:22:00Z", label="Simulation start time (UTC) — Manual mode") | |
| alt_ft = gr.Number(value=35000, label="Altitude at start (ft)") | |
| vs_fpm = gr.Number(value=0, label="Vertical speed (ft/min) (negative = descent)") | |
| descent_start_choice = gr.Dropdown( | |
| ["None (constant VS from start)", "At 00:20Z only"], | |
| value="None (constant VS from start)", | |
| label="When does descent start? (VS applies after this time)", | |
| ) | |
| with gr.Row(): | |
| guided_btn = gr.Button("Generate: Guided scenario") | |
| manual_btn = gr.Button("Simulate: Manual legs") | |
| status_md = gr.Markdown(visible=False) | |
| out_map = gr.HTML() | |
| stats = gr.Markdown() | |
| gr.Markdown("### BFO Analysis") | |
| bfo_plot = gr.Plot() | |
| gr.Markdown("### Altitude vs Time") | |
| alt_plot = gr.Plot() | |
| gr.Markdown("### Speed Profile") | |
| speed_plot = gr.Plot() | |
| gr.Markdown("### Validation table") | |
| validation_df = gr.Dataframe( | |
| 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" | |
| ], | |
| value=[], | |
| interactive=False, | |
| row_count=(40, "dynamic"), | |
| column_count=(11, "fixed"), | |
| wrap=True, | |
| label="Validation" | |
| ) | |
| gr.Markdown("### Multi-leg plan (Manual)") | |
| legs_df = gr.Dataframe( | |
| headers=["heading_deg", "speed_knots", "minutes"], | |
| datatype=["number", "number", "number"], | |
| row_count=(200, "dynamic"), | |
| column_count=(3, "fixed"), | |
| value=[row[:] for row in DEFAULT_LEGS], | |
| label="Legs (manual inputs)", | |
| ) | |
| with gr.Row(): | |
| add_btn = gr.Button("➕ Add leg") | |
| reset_btn = gr.Button("♻️ Reset legs") | |
| guided_btn.click( | |
| run_guided, | |
| inputs=[guided_mode, start_lat, start_lon, method, alt_ft, vs_fpm, descent_start_choice, show_max_range_ring], | |
| outputs=[legs_df, status_md, out_map, stats, bfo_plot, alt_plot, speed_plot, validation_df], | |
| ) | |
| manual_btn.click( | |
| run_sim_manual, | |
| inputs=[start_lat, start_lon, legs_df, method, start_time_str, alt_ft, vs_fpm, descent_start_choice, show_max_range_ring], | |
| outputs=[legs_df, status_md, out_map, stats, bfo_plot, alt_plot, speed_plot, validation_df], | |
| ) | |
| add_btn.click(add_leg, inputs=[legs_df], outputs=[legs_df]) | |
| reset_btn.click(reset_legs, outputs=[legs_df]) | |
| demo.launch() |