Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -38,8 +38,6 @@ import flight_plotter_v32 as fp
|
|
| 38 |
# ================= CONFIG =================
|
| 39 |
KML_FILE = "MH370 - 13 nov (3).kml"
|
| 40 |
SEARCH_AREAS_KMZ = "Search areas.kmz"
|
| 41 |
-
|
| 42 |
-
# NEW: max range ring KML
|
| 43 |
MAX_RANGE_KML = "MH370_max_range_6166km_KUL_red.kml"
|
| 44 |
|
| 45 |
DEFAULT_LEGS = [
|
|
@@ -58,12 +56,10 @@ WAYPOINTS = [
|
|
| 58 |
("MEKAR", 6.5, 96.48333333),
|
| 59 |
]
|
| 60 |
|
| 61 |
-
# NEW: Reference marker requested
|
| 62 |
CAPT_BYRON_BAILEY_NAME = "Captain Byron Bailey"
|
| 63 |
CAPT_BYRON_BAILEY_LAT = -(39 + 10 / 60) # 39°10' S
|
| 64 |
CAPT_BYRON_BAILEY_LON = (88 + 18 / 60) # 88°18' E
|
| 65 |
|
| 66 |
-
# NEW: measured BFO at 00:20Z
|
| 67 |
MEASURED_BFO_0020_HZ = 182.0
|
| 68 |
|
| 69 |
RING_TIMES = {
|
|
@@ -76,20 +72,15 @@ RING_TIMES = {
|
|
| 76 |
"Ping Ring 7": "24:10:00Z",
|
| 77 |
}
|
| 78 |
|
| 79 |
-
# satLoc indices
|
| 80 |
SATLOC_IDX_MEAS_BFO = 4
|
| 81 |
SATLOC_IDX_MEAS_BTO = 5
|
| 82 |
|
| 83 |
-
# ===== Fixed Radar + Turn Completion =====
|
| 84 |
RADAR_FIX_LAT = 6.578
|
| 85 |
RADAR_FIX_LON = 96.341
|
| 86 |
RADAR_FIX_TIME_UTC = datetime(2014, 3, 7, 18, 22, 0, tzinfo=timezone.utc)
|
| 87 |
-
|
| 88 |
TURN_COMPLETE_TIME_UTC = datetime(2014, 3, 7, 18, 40, 0, tzinfo=timezone.utc)
|
| 89 |
-
|
| 90 |
RADAR_INITIAL_TRACK_DEG = 296.0
|
| 91 |
|
| 92 |
-
# ===== Guided validation timestamps of interest =====
|
| 93 |
GUIDED_VALIDATION_LABELS = [
|
| 94 |
"18:22:00",
|
| 95 |
"18:40:00",
|
|
@@ -97,42 +88,34 @@ GUIDED_VALIDATION_LABELS = [
|
|
| 97 |
"20:40:00",
|
| 98 |
"21:40:00",
|
| 99 |
"22:40:00",
|
| 100 |
-
"24:10:00",
|
| 101 |
-
"24:20:00",
|
| 102 |
]
|
| 103 |
-
GUIDED_FINAL_LABEL = "24:20:00"
|
| 104 |
|
| 105 |
-
# Turn modeling parameters
|
| 106 |
TURN_BANK_MIN_DEG = 15.0
|
| 107 |
TURN_BANK_MAX_DEG = 30.0
|
| 108 |
TURN_BANK_DEFAULT_DEG = 25.0
|
| 109 |
TURN_STEP_SEC = 30.0
|
| 110 |
|
| 111 |
-
# ===== Speed bounds per your request =====
|
| 112 |
TURN_SPEED_MIN_KT = 470.0
|
| 113 |
TURN_SPEED_MAX_KT = 520.0
|
| 114 |
-
|
| 115 |
SOUTH_SPEED_MIN_KT = 430.0
|
| 116 |
SOUTH_SPEED_MAX_KT = 510.0
|
| 117 |
|
| 118 |
-
|
| 119 |
-
CONST_SOUTH_SPEED_KT = 443.0 # changed from 447 -> 443
|
| 120 |
|
| 121 |
-
# ===== Straight heading search =====
|
| 122 |
GUIDED_HEADING_MIN = 175
|
| 123 |
GUIDED_HEADING_MAX = 190
|
| 124 |
COARSE_HEADING_STEP_DEG = 2.0
|
| 125 |
REFINE_HEADING_HALF_WINDOW_DEG = 2.0
|
| 126 |
REFINE_HEADING_STEP_DEG = 0.5
|
| 127 |
|
| 128 |
-
# Southern ping-interval schedule (18:40→00:20 = 340 min)
|
| 129 |
GUIDED_SOUTH_SEG_MIN = [60.0, 60.0, 60.0, 60.0, 90.0, 10.0]
|
| 130 |
GUIDED_SOUTH_SEG_LABELS_END = ["19:40:00", "20:40:00", "21:40:00", "22:40:00", "24:10:00", "24:20:00"]
|
| 131 |
|
| 132 |
-
# HARD BTO labels list (AUTO-FILTERED to those with measured BTO)
|
| 133 |
HARD_BTO_LABELS = ["18:40:00", "19:40:00", "20:40:00", "21:40:00", "22:40:00", "24:10:00", "24:20:00"]
|
| 134 |
|
| 135 |
-
# ===== Performance knobs: Top-K polish (Straight mode only) =====
|
| 136 |
TOPK_POLISH = 5
|
| 137 |
ENABLE_POLISH_STRAIGHT = True
|
| 138 |
POLISH_PASSES = 3
|
|
@@ -140,21 +123,18 @@ POLISH_WINDOW_KT = 6.0
|
|
| 140 |
POLISH_STEP_KT = 2.0
|
| 141 |
POLISH_OBJ_EPS = 1e-3
|
| 142 |
|
| 143 |
-
# ===== Variable heading & speed solver knobs (FAST) =====
|
| 144 |
VAR_HEADING_MIN = 170.0
|
| 145 |
VAR_HEADING_MAX = 200.0
|
| 146 |
-
VAR_TURN_SPEED_STEP = 10.0
|
| 147 |
-
VAR_FIRST_HEADING_STEP = 4.0
|
| 148 |
-
VAR_HEADING_DELTA_SET = [-6, -3, 0, 3, 6]
|
| 149 |
-
VAR_BEAM_WIDTH = 12
|
| 150 |
-
VAR_SMOOTH_PENALTY_US_PER_DEG = 1.5
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
# Search area colors (optional)
|
| 158 |
SEARCH_AREA_COLORS = {
|
| 159 |
"Ocean Infinity": "#ff3b3b",
|
| 160 |
"Australia": "#1f77b4",
|
|
@@ -200,9 +180,7 @@ def color_for_search_area(name: str) -> str:
|
|
| 200 |
def load_ping_rings_from_kml(path: str):
|
| 201 |
path = resolve_local_or_mnt(path)
|
| 202 |
if not os.path.exists(path):
|
| 203 |
-
raise FileNotFoundError(
|
| 204 |
-
f"KML file not found: '{path}'. Put it in the Space root (same folder as app.py)."
|
| 205 |
-
)
|
| 206 |
|
| 207 |
tree = ET.parse(path)
|
| 208 |
root = tree.getroot()
|
|
@@ -250,13 +228,11 @@ else:
|
|
| 250 |
KML_LOAD_ERROR = ""
|
| 251 |
|
| 252 |
|
| 253 |
-
# ================= KML (
|
| 254 |
def load_generic_kml_features(path: str):
|
| 255 |
path = resolve_local_or_mnt(path)
|
| 256 |
if not os.path.exists(path):
|
| 257 |
-
raise FileNotFoundError(
|
| 258 |
-
f"KML file not found: '{path}'. Put it in the Space root (same folder as app.py)."
|
| 259 |
-
)
|
| 260 |
|
| 261 |
tree = ET.parse(path)
|
| 262 |
root = tree.getroot()
|
|
@@ -270,7 +246,6 @@ def load_generic_kml_features(path: str):
|
|
| 270 |
name_el = pm.find("kml:name", ns)
|
| 271 |
pm_name = name_el.text.strip() if (name_el is not None and name_el.text) else "KML feature"
|
| 272 |
|
| 273 |
-
# Points
|
| 274 |
for pt in pm.findall(".//kml:Point", ns):
|
| 275 |
coords_el = pt.find(".//kml:coordinates", ns)
|
| 276 |
if coords_el is None or coords_el.text is None:
|
|
@@ -279,14 +254,8 @@ def load_generic_kml_features(path: str):
|
|
| 279 |
if len(coords) >= 2:
|
| 280 |
lon = float(coords[0])
|
| 281 |
lat = float(coords[1])
|
| 282 |
-
features.append({
|
| 283 |
-
|
| 284 |
-
"name": pm_name,
|
| 285 |
-
"lat": lat,
|
| 286 |
-
"lon": lon,
|
| 287 |
-
})
|
| 288 |
-
|
| 289 |
-
# Lines
|
| 290 |
for ls in pm.findall(".//kml:LineString", ns):
|
| 291 |
coords_el = ls.find(".//kml:coordinates", ns)
|
| 292 |
if coords_el is None or coords_el.text is None:
|
|
@@ -302,11 +271,7 @@ def load_generic_kml_features(path: str):
|
|
| 302 |
lat = float(parts[1])
|
| 303 |
pts.append((lat, lon))
|
| 304 |
if pts:
|
| 305 |
-
features.append({
|
| 306 |
-
"type": "line",
|
| 307 |
-
"name": pm_name,
|
| 308 |
-
"points": pts,
|
| 309 |
-
})
|
| 310 |
|
| 311 |
return features
|
| 312 |
|
|
@@ -346,9 +311,7 @@ def _parse_kml_coordinates_text(txt: str):
|
|
| 346 |
def load_search_areas_from_kmz(kmz_path: str):
|
| 347 |
kmz_path = resolve_local_or_mnt(kmz_path)
|
| 348 |
if not os.path.exists(kmz_path):
|
| 349 |
-
raise FileNotFoundError(
|
| 350 |
-
f"KMZ file not found: '{kmz_path}'. Put it next to app.py or ensure it's in /mnt/data."
|
| 351 |
-
)
|
| 352 |
|
| 353 |
with zipfile.ZipFile(kmz_path, "r") as zf:
|
| 354 |
kml_name = None
|
|
@@ -479,13 +442,8 @@ def _turn_rate_deg_per_sec(speed_knots: float, bank_deg: float) -> float:
|
|
| 479 |
return float(omega * 180.0 / math.pi)
|
| 480 |
|
| 481 |
|
| 482 |
-
def build_turn_legs(
|
| 483 |
-
|
| 484 |
-
method: str,
|
| 485 |
-
heading_final_deg: float,
|
| 486 |
-
bank_pref_deg: float = TURN_BANK_DEFAULT_DEG,
|
| 487 |
-
step_sec: float = TURN_STEP_SEC
|
| 488 |
-
):
|
| 489 |
total_sec = (TURN_COMPLETE_TIME_UTC - RADAR_FIX_TIME_UTC).total_seconds()
|
| 490 |
if total_sec <= 0:
|
| 491 |
return [], {"error": "Invalid turn window."}
|
|
@@ -578,19 +536,11 @@ def _dt_from_label(label_hms: str) -> datetime:
|
|
| 578 |
return fp._time_label_to_dt(s, fp.SAT_EPOCH_UTC)
|
| 579 |
|
| 580 |
|
| 581 |
-
def dt_ping6_utc() -> datetime:
|
| 582 |
-
return fp._time_label_to_dt("22:40:00", fp.SAT_EPOCH_UTC)
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
def dt_ping7_utc() -> datetime:
|
| 586 |
-
return fp._time_label_to_dt("24:10:00", fp.SAT_EPOCH_UTC)
|
| 587 |
-
|
| 588 |
-
|
| 589 |
def dt_0020_utc() -> datetime:
|
| 590 |
return fp._time_label_to_dt("24:20:00", fp.SAT_EPOCH_UTC)
|
| 591 |
|
| 592 |
|
| 593 |
-
# ================= PATCH satLoc
|
| 594 |
def _patch_measured_bfo_0020():
|
| 595 |
target_labels = {"24:20:00", "00:20:00"}
|
| 596 |
patched = False
|
|
@@ -616,13 +566,12 @@ _ = _patch_measured_bfo_0020()
|
|
| 616 |
|
| 617 |
# ================= ALTITUDE HELPERS =================
|
| 618 |
def altitude_ft_at_dt(dt_ping: datetime, start_dt: datetime, alt0_ft: float,
|
| 619 |
-
vs_fpm: float, descent_start_dt
|
| 620 |
alt0_ft = float(alt0_ft)
|
| 621 |
vs_fpm = float(vs_fpm)
|
| 622 |
|
| 623 |
if descent_start_dt is None or vs_fpm == 0.0:
|
| 624 |
return max(0.0, alt0_ft)
|
| 625 |
-
|
| 626 |
if dt_ping < descent_start_dt:
|
| 627 |
return max(0.0, alt0_ft)
|
| 628 |
|
|
@@ -632,13 +581,12 @@ def altitude_ft_at_dt(dt_ping: datetime, start_dt: datetime, alt0_ft: float,
|
|
| 632 |
|
| 633 |
|
| 634 |
def build_altitude_plot(start_dt: datetime, total_time_min: float, alt0_ft: float,
|
| 635 |
-
vs_fpm: float, descent_start_dt
|
| 636 |
t = np.linspace(0.0, max(1.0, float(total_time_min)), 120)
|
| 637 |
alts = []
|
| 638 |
for mins in t:
|
| 639 |
dt = start_dt + timedelta(minutes=float(mins))
|
| 640 |
-
|
| 641 |
-
alts.append(alt)
|
| 642 |
|
| 643 |
fig = go.Figure()
|
| 644 |
fig.add_trace(go.Scatter(x=t, y=alts, mode="lines", name="Altitude (ft)"))
|
|
@@ -653,7 +601,7 @@ def build_altitude_plot(start_dt: datetime, total_time_min: float, alt0_ft: floa
|
|
| 653 |
|
| 654 |
|
| 655 |
# ================= SPEED PROFILE PLOT =================
|
| 656 |
-
def build_speed_profile_plot(start_dt: datetime, legs
|
| 657 |
xs = [0.0]
|
| 658 |
ys = []
|
| 659 |
cum_min = 0.0
|
|
@@ -672,13 +620,9 @@ def build_speed_profile_plot(start_dt: datetime, legs: list[tuple[float, float,
|
|
| 672 |
xs.append(cum_min)
|
| 673 |
ys.append(spd)
|
| 674 |
|
| 675 |
-
if not ys:
|
| 676 |
-
fig = go.Figure()
|
| 677 |
-
fig.update_layout(title="Speed Profile", xaxis_title="Minutes since start", yaxis_title="Speed (kt)", height=320)
|
| 678 |
-
return fig
|
| 679 |
-
|
| 680 |
fig = go.Figure()
|
| 681 |
-
|
|
|
|
| 682 |
fig.update_layout(
|
| 683 |
title="Speed Profile",
|
| 684 |
xaxis_title="Minutes since start",
|
|
@@ -714,10 +658,8 @@ def compute_stats(path_pts, seg_dists_m, total_time_min, legs):
|
|
| 714 |
total_dist_m = float(np.sum(seg_dists_m)) if seg_dists_m else 0.0
|
| 715 |
total_dist_km = total_dist_m / 1000.0
|
| 716 |
total_dist_nm = total_dist_m / 1852.0
|
| 717 |
-
|
| 718 |
total_time_hr = total_time_min / 60.0
|
| 719 |
avg_gs_knots = (total_dist_nm / total_time_hr) if total_time_hr > 0 else 0.0
|
| 720 |
-
|
| 721 |
end_lat, end_lon = path_pts[-1]
|
| 722 |
return "\n\n".join([
|
| 723 |
f"**Legs:** {len(legs)}",
|
|
@@ -793,8 +735,7 @@ def _state_at_dt(start_lat, start_lon, legs, method, start_dt, dt_k):
|
|
| 793 |
def _map_marker_times(start_dt, legs):
|
| 794 |
if start_dt == RADAR_FIX_TIME_UTC:
|
| 795 |
times = [_dt_from_label(x) for x in GUIDED_VALIDATION_LABELS]
|
| 796 |
-
|
| 797 |
-
return times
|
| 798 |
|
| 799 |
times = [start_dt]
|
| 800 |
cum = 0.0
|
|
@@ -804,7 +745,7 @@ def _map_marker_times(start_dt, legs):
|
|
| 804 |
return times
|
| 805 |
|
| 806 |
|
| 807 |
-
# ================= CLIP TRAJECTORY
|
| 808 |
def clip_legs_to_end_dt(legs, start_dt: datetime, end_dt: datetime):
|
| 809 |
total_target_sec = (end_dt - start_dt).total_seconds()
|
| 810 |
if total_target_sec <= 0:
|
|
@@ -849,7 +790,7 @@ def _ping_points_after(start_dt: datetime):
|
|
| 849 |
return pts
|
| 850 |
|
| 851 |
|
| 852 |
-
def _descent_start_dt_from_choice(descent_start_choice: str)
|
| 853 |
ch = (descent_start_choice or "").strip()
|
| 854 |
if ch.startswith("At 00:20Z only"):
|
| 855 |
return dt_0020_utc()
|
|
@@ -860,13 +801,11 @@ def _descent_start_dt_from_choice(descent_start_choice: str) -> datetime | None:
|
|
| 860 |
|
| 861 |
def bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice):
|
| 862 |
pts = _ping_points_after(start_dt)
|
| 863 |
-
|
| 864 |
descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
|
| 865 |
|
| 866 |
leg_secs = [float(m) * 60.0 for _, _, m in legs]
|
| 867 |
boundaries = np.cumsum([0.0] + leg_secs)
|
| 868 |
total_end = float(boundaries[-1])
|
| 869 |
-
|
| 870 |
leg_start_positions = _precompute_leg_starts(start_lat, start_lon, legs, method)
|
| 871 |
|
| 872 |
x_labels, measured, calculated = [], [], []
|
|
@@ -889,7 +828,6 @@ def bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_dt, alt_ft,
|
|
| 889 |
|
| 890 |
leg_start_lat, leg_start_lon = leg_start_positions[i]
|
| 891 |
hdg, spd, _m = legs[i]
|
| 892 |
-
|
| 893 |
lat, lon = _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg, spd, t_inside)
|
| 894 |
|
| 895 |
alt_now_ft = altitude_ft_at_dt(dt_ping, start_dt, float(alt_ft), float(vs_fpm), descent_start_dt)
|
|
@@ -907,23 +845,20 @@ def bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_dt, alt_ft,
|
|
| 907 |
calculated.append(float(pred_bfo))
|
| 908 |
|
| 909 |
fig = go.Figure()
|
| 910 |
-
if
|
| 911 |
-
fig.
|
| 912 |
-
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
line=dict(width=3),
|
| 925 |
-
marker=dict(size=10, symbol="diamond")
|
| 926 |
-
))
|
| 927 |
fig.update_layout(
|
| 928 |
title="BFO Analysis",
|
| 929 |
xaxis_title="Time",
|
|
@@ -932,7 +867,6 @@ def bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_dt, alt_ft,
|
|
| 932 |
margin=dict(l=60, r=160, t=80, b=60),
|
| 933 |
height=520,
|
| 934 |
)
|
| 935 |
-
|
| 936 |
return fig, ""
|
| 937 |
|
| 938 |
|
|
@@ -945,12 +879,8 @@ def _lookup_measured_at_dt(dt_ping: datetime):
|
|
| 945 |
return None, None
|
| 946 |
|
| 947 |
|
| 948 |
-
def build_validation_table(
|
| 949 |
-
start_lat, start_lon, method, start_dt, legs,
|
| 950 |
-
alt_ft, vs_fpm, descent_start_choice
|
| 951 |
-
):
|
| 952 |
descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
|
| 953 |
-
|
| 954 |
is_guided = (start_dt == RADAR_FIX_TIME_UTC)
|
| 955 |
|
| 956 |
if is_guided:
|
|
@@ -1035,10 +965,7 @@ def _all_bounds(points_list):
|
|
| 1035 |
|
| 1036 |
def add_permanent_label(m, name, lat, lon):
|
| 1037 |
tip = f"{html.escape(name)}<br>Lat: {float(lat):.5f}<br>Lon: {float(lon):.5f}"
|
| 1038 |
-
pop = folium.Popup(
|
| 1039 |
-
f"<b>{html.escape(name)}</b><br>Lat: {float(lat):.5f}<br>Lon: {float(lon):.5f}",
|
| 1040 |
-
max_width=260,
|
| 1041 |
-
)
|
| 1042 |
|
| 1043 |
folium.CircleMarker(
|
| 1044 |
location=(lat, lon),
|
|
@@ -1068,10 +995,7 @@ def add_permanent_label(m, name, lat, lon):
|
|
| 1068 |
|
| 1069 |
def add_named_marker(m, name: str, lat: float, lon: float):
|
| 1070 |
tip = f"{html.escape(name)}<br>Lat: {float(lat):.5f}<br>Lon: {float(lon):.5f}"
|
| 1071 |
-
pop = folium.Popup(
|
| 1072 |
-
f"<b>{html.escape(name)}</b><br>Lat: {float(lat):.5f}<br>Lon: {float(lon):.5f}",
|
| 1073 |
-
max_width=260,
|
| 1074 |
-
)
|
| 1075 |
folium.CircleMarker(
|
| 1076 |
location=(float(lat), float(lon)),
|
| 1077 |
radius=6,
|
|
@@ -1087,7 +1011,6 @@ def add_named_marker(m, name: str, lat: float, lon: float):
|
|
| 1087 |
|
| 1088 |
def add_max_range_features(m, max_range_features):
|
| 1089 |
bounds_pts = []
|
| 1090 |
-
|
| 1091 |
for feat in max_range_features:
|
| 1092 |
ftype = feat.get("type")
|
| 1093 |
name = feat.get("name", "Maximum range")
|
|
@@ -1142,7 +1065,6 @@ def build_leaflet_map(path_pts, legs, method, start_dt, show_max_range_ring=Fals
|
|
| 1142 |
control=True,
|
| 1143 |
).add_to(m)
|
| 1144 |
|
| 1145 |
-
# NEW: mouse cursor lat/lon display
|
| 1146 |
MousePosition(
|
| 1147 |
position="topright",
|
| 1148 |
separator=" | ",
|
|
@@ -1151,7 +1073,6 @@ def build_leaflet_map(path_pts, legs, method, start_dt, show_max_range_ring=Fals
|
|
| 1151 |
lng_formatter="function(num) {return L.Util.formatNum(num, 5);}",
|
| 1152 |
).add_to(m)
|
| 1153 |
|
| 1154 |
-
# Search areas
|
| 1155 |
search_area_pts_for_bounds = []
|
| 1156 |
for area in SEARCH_AREAS:
|
| 1157 |
nm = area.get("name", "Search area")
|
|
@@ -1174,13 +1095,7 @@ def build_leaflet_map(path_pts, legs, method, start_dt, show_max_range_ring=Fals
|
|
| 1174 |
if not line:
|
| 1175 |
continue
|
| 1176 |
search_area_pts_for_bounds.extend(line)
|
| 1177 |
-
folium.PolyLine(
|
| 1178 |
-
locations=line,
|
| 1179 |
-
color=col,
|
| 1180 |
-
weight=3,
|
| 1181 |
-
opacity=0.9,
|
| 1182 |
-
tooltip=nm,
|
| 1183 |
-
).add_to(m)
|
| 1184 |
|
| 1185 |
for poly in area.get("polygons", []) or []:
|
| 1186 |
if not poly:
|
|
@@ -1197,7 +1112,6 @@ def build_leaflet_map(path_pts, legs, method, start_dt, show_max_range_ring=Fals
|
|
| 1197 |
tooltip=nm,
|
| 1198 |
).add_to(m)
|
| 1199 |
|
| 1200 |
-
# Ping rings — permanent
|
| 1201 |
for ring_name in sorted(PING_RINGS.keys()):
|
| 1202 |
ring_pts = PING_RINGS.get(ring_name)
|
| 1203 |
if not ring_pts:
|
|
@@ -1206,12 +1120,10 @@ def build_leaflet_map(path_pts, legs, method, start_dt, show_max_range_ring=Fals
|
|
| 1206 |
tooltip = f"{ring_name} ({ring_time})" if ring_time else ring_name
|
| 1207 |
folium.PolyLine(ring_pts, weight=2, opacity=0.6, color="yellow", tooltip=tooltip).add_to(m)
|
| 1208 |
|
| 1209 |
-
# NEW: toggleable maximum range ring
|
| 1210 |
max_range_pts_for_bounds = []
|
| 1211 |
if show_max_range_ring:
|
| 1212 |
max_range_pts_for_bounds = add_max_range_features(m, MAX_RANGE_FEATURES)
|
| 1213 |
|
| 1214 |
-
# Flight path (polyline)
|
| 1215 |
folium.PolyLine(path_pts, color="white", weight=8, opacity=0.3).add_to(m)
|
| 1216 |
folium.PolyLine(path_pts, color="red", weight=4, opacity=1.0, tooltip="Flight path").add_to(m)
|
| 1217 |
|
|
@@ -1294,11 +1206,9 @@ def build_leaflet_map(path_pts, legs, method, start_dt, show_max_range_ring=Fals
|
|
| 1294 |
popup=folium.Popup("<br>".join(popup_lines), max_width=360),
|
| 1295 |
).add_to(m)
|
| 1296 |
|
| 1297 |
-
# Waypoints
|
| 1298 |
for name, lat, lon in WAYPOINTS:
|
| 1299 |
-
add_permanent_label(m, name, float(lat), float(lon))
|
| 1300 |
|
| 1301 |
-
# Requested marker: Captain Byron Bailey
|
| 1302 |
add_named_marker(m, CAPT_BYRON_BAILEY_NAME, CAPT_BYRON_BAILEY_LAT, CAPT_BYRON_BAILEY_LON)
|
| 1303 |
|
| 1304 |
waypoint_pts = [(float(lat), float(lon)) for _, lat, lon in WAYPOINTS] + [(CAPT_BYRON_BAILEY_LAT, CAPT_BYRON_BAILEY_LON)]
|
|
@@ -1376,7 +1286,8 @@ def _simulate_one_leg_end(method: str, lat0: float, lon0: float, hdg: float, spd
|
|
| 1376 |
return float(lat1), float(lon1)
|
| 1377 |
|
| 1378 |
|
| 1379 |
-
def _bto_calc(method: str, lat: float, lon: float, dt_ping: datetime, alt_ft: float,
|
|
|
|
| 1380 |
alt_now_ft = altitude_ft_at_dt(dt_ping, start_dt, float(alt_ft), float(vs_fpm), descent_start_dt)
|
| 1381 |
alt_m = alt_now_ft * 0.3048
|
| 1382 |
return float(fp.calculateBTO_at_time(float(lat), float(lon), alt_m, dt_ping))
|
|
@@ -1402,20 +1313,9 @@ def _golden_min_abs(fn, lo: float, hi: float, iters: int = 24):
|
|
| 1402 |
return float(x_best), abs(float(fn(x_best)))
|
| 1403 |
|
| 1404 |
|
| 1405 |
-
def _fit_segment_speed(
|
| 1406 |
-
|
| 1407 |
-
|
| 1408 |
-
lon0: float,
|
| 1409 |
-
hdg: float,
|
| 1410 |
-
minutes: float,
|
| 1411 |
-
dt_end: datetime,
|
| 1412 |
-
alt_ft: float,
|
| 1413 |
-
vs_fpm: float,
|
| 1414 |
-
start_dt: datetime,
|
| 1415 |
-
descent_start_dt: datetime | None,
|
| 1416 |
-
vmin: float,
|
| 1417 |
-
vmax: float,
|
| 1418 |
-
):
|
| 1419 |
meas = _lookup_measured_bto(dt_end)
|
| 1420 |
if meas is None:
|
| 1421 |
v = 0.5 * (vmin + vmax)
|
|
@@ -1452,15 +1352,8 @@ def _fit_segment_speed(
|
|
| 1452 |
return float(v_best), float(err(v_best)), (lat1, lon1)
|
| 1453 |
|
| 1454 |
|
| 1455 |
-
def _compute_bto_errors_at_enforced(
|
| 1456 |
-
|
| 1457 |
-
start_dt: datetime,
|
| 1458 |
-
alt_ft: float,
|
| 1459 |
-
vs_fpm: float,
|
| 1460 |
-
descent_start_dt: datetime | None,
|
| 1461 |
-
full_legs: list[tuple[float, float, float]],
|
| 1462 |
-
enforced_dts: list[datetime],
|
| 1463 |
-
):
|
| 1464 |
errs = []
|
| 1465 |
for dt_k in enforced_dts:
|
| 1466 |
meas = _lookup_measured_bto(dt_k)
|
|
@@ -1474,7 +1367,7 @@ def _compute_bto_errors_at_enforced(
|
|
| 1474 |
return errs
|
| 1475 |
|
| 1476 |
|
| 1477 |
-
def _build_south_legs_piecewise_constant_heading(h_south: float, speeds
|
| 1478 |
legs = []
|
| 1479 |
for v, m in zip(speeds, minutes):
|
| 1480 |
if float(m) <= 0:
|
|
@@ -1483,7 +1376,7 @@ def _build_south_legs_piecewise_constant_heading(h_south: float, speeds: list[fl
|
|
| 1483 |
return legs
|
| 1484 |
|
| 1485 |
|
| 1486 |
-
def _build_south_legs_variable(headings
|
| 1487 |
legs = []
|
| 1488 |
for h, v, m in zip(headings, speeds, minutes):
|
| 1489 |
if float(m) <= 0:
|
|
@@ -1492,28 +1385,17 @@ def _build_south_legs_variable(headings: list[float], speeds: list[float], minut
|
|
| 1492 |
return legs
|
| 1493 |
|
| 1494 |
|
| 1495 |
-
# ================= Top-K polish
|
| 1496 |
-
def _polish_south_speeds_topk(
|
| 1497 |
-
|
| 1498 |
-
|
| 1499 |
-
|
| 1500 |
-
alt_ft: float,
|
| 1501 |
-
vs_fpm: float,
|
| 1502 |
-
descent_start_dt: datetime | None,
|
| 1503 |
-
h_south: float,
|
| 1504 |
-
turn_legs: list[tuple[float, float, float]],
|
| 1505 |
-
enforced_dts: list[datetime],
|
| 1506 |
-
init_speeds: list[float],
|
| 1507 |
-
passes: int = POLISH_PASSES,
|
| 1508 |
-
window_kt: float = POLISH_WINDOW_KT,
|
| 1509 |
-
step_kt: float = POLISH_STEP_KT,
|
| 1510 |
-
):
|
| 1511 |
speeds = [float(v) for v in init_speeds]
|
| 1512 |
|
| 1513 |
pts_turn, _, _ = simulate_path(RADAR_FIX_LAT, RADAR_FIX_LON, turn_legs, method)
|
| 1514 |
turn_end_lat, turn_end_lon = float(pts_turn[-1][0]), float(pts_turn[-1][1])
|
| 1515 |
|
| 1516 |
-
def build_full(speeds_now
|
| 1517 |
lat_cur, lon_cur = turn_end_lat, turn_end_lon
|
| 1518 |
seg_errs = []
|
| 1519 |
for mins, end_lbl, vseg in zip(GUIDED_SOUTH_SEG_MIN, GUIDED_SOUTH_SEG_LABELS_END, speeds_now):
|
|
@@ -1589,7 +1471,6 @@ def _polish_south_speeds_topk(
|
|
| 1589 |
def solve_guided_straight(method, alt_ft, vs_fpm, descent_start_choice):
|
| 1590 |
start_dt = _guided_start_dt()
|
| 1591 |
end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
|
| 1592 |
-
|
| 1593 |
descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
|
| 1594 |
|
| 1595 |
pts_all = _ping_points_after(start_dt)
|
|
@@ -1607,12 +1488,7 @@ def solve_guided_straight(method, alt_ft, vs_fpm, descent_start_choice):
|
|
| 1607 |
h_south = float(h_south)
|
| 1608 |
v_turn = float(v_turn)
|
| 1609 |
|
| 1610 |
-
turn_legs, _turn_info = build_turn_legs(
|
| 1611 |
-
speed_knots=float(v_turn),
|
| 1612 |
-
method=method,
|
| 1613 |
-
heading_final_deg=float(h_south),
|
| 1614 |
-
)
|
| 1615 |
-
|
| 1616 |
pts_turn, _, _ = simulate_path(RADAR_FIX_LAT, RADAR_FIX_LON, turn_legs, method)
|
| 1617 |
lat_cur, lon_cur = float(pts_turn[-1][0]), float(pts_turn[-1][1])
|
| 1618 |
|
|
@@ -1764,11 +1640,10 @@ def solve_guided_straight(method, alt_ft, vs_fpm, descent_start_choice):
|
|
| 1764 |
return best_final_legs, "", start_dt
|
| 1765 |
|
| 1766 |
|
| 1767 |
-
# ================= Guided Solver: Variable heading & speed
|
| 1768 |
def _solve_guided_variable_core(method, alt_ft, vs_fpm, descent_start_choice, se_bias_after_2240: bool):
|
| 1769 |
start_dt = _guided_start_dt()
|
| 1770 |
end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
|
| 1771 |
-
|
| 1772 |
descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
|
| 1773 |
|
| 1774 |
enforced_labels = _available_hard_bto_labels()
|
|
@@ -1782,7 +1657,7 @@ def _solve_guided_variable_core(method, alt_ft, vs_fpm, descent_start_choice, se
|
|
| 1782 |
best_global = None
|
| 1783 |
best_global_legs = None
|
| 1784 |
|
| 1785 |
-
def score_from_errors(errs
|
| 1786 |
if not errs:
|
| 1787 |
return None
|
| 1788 |
abs_arr = np.abs(np.array(errs, dtype=float))
|
|
@@ -1790,11 +1665,7 @@ def _solve_guided_variable_core(method, alt_ft, vs_fpm, descent_start_choice, se
|
|
| 1790 |
|
| 1791 |
for v_turn in turn_speeds:
|
| 1792 |
for h1 in first_headings:
|
| 1793 |
-
turn_legs, _turn_info = build_turn_legs(
|
| 1794 |
-
speed_knots=float(v_turn),
|
| 1795 |
-
method=method,
|
| 1796 |
-
heading_final_deg=float(h1),
|
| 1797 |
-
)
|
| 1798 |
pts_turn, _, _ = simulate_path(RADAR_FIX_LAT, RADAR_FIX_LON, turn_legs, method)
|
| 1799 |
lat0, lon0 = float(pts_turn[-1][0]), float(pts_turn[-1][1])
|
| 1800 |
|
|
@@ -1915,11 +1786,10 @@ def solve_guided_variable_se(method, alt_ft, vs_fpm, descent_start_choice):
|
|
| 1915 |
return _solve_guided_variable_core(method, alt_ft, vs_fpm, descent_start_choice, se_bias_after_2240=True)
|
| 1916 |
|
| 1917 |
|
| 1918 |
-
# ================= Guided Solver: Variable heading, CONSTANT southern speed
|
| 1919 |
def solve_guided_constant_speed(method, alt_ft, vs_fpm, descent_start_choice):
|
| 1920 |
start_dt = _guided_start_dt()
|
| 1921 |
end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
|
| 1922 |
-
|
| 1923 |
descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
|
| 1924 |
|
| 1925 |
enforced_labels = _available_hard_bto_labels()
|
|
@@ -1933,7 +1803,7 @@ def solve_guided_constant_speed(method, alt_ft, vs_fpm, descent_start_choice):
|
|
| 1933 |
best_global = None
|
| 1934 |
best_global_legs = None
|
| 1935 |
|
| 1936 |
-
def score_from_errors(errs
|
| 1937 |
if not errs:
|
| 1938 |
return None
|
| 1939 |
abs_arr = np.abs(np.array(errs, dtype=float))
|
|
@@ -1943,11 +1813,7 @@ def solve_guided_constant_speed(method, alt_ft, vs_fpm, descent_start_choice):
|
|
| 1943 |
|
| 1944 |
for v_turn in turn_speeds:
|
| 1945 |
for h1 in first_headings:
|
| 1946 |
-
turn_legs, _turn_info = build_turn_legs(
|
| 1947 |
-
speed_knots=float(v_turn),
|
| 1948 |
-
method=method,
|
| 1949 |
-
heading_final_deg=float(h1),
|
| 1950 |
-
)
|
| 1951 |
pts_turn, _, _ = simulate_path(RADAR_FIX_LAT, RADAR_FIX_LON, turn_legs, method)
|
| 1952 |
lat0, lon0 = float(pts_turn[-1][0]), float(pts_turn[-1][1])
|
| 1953 |
|
|
@@ -1970,7 +1836,6 @@ def solve_guided_constant_speed(method, alt_ft, vs_fpm, descent_start_choice):
|
|
| 1970 |
|
| 1971 |
for st in beam:
|
| 1972 |
h_prev = float(st["h_prev"])
|
| 1973 |
-
|
| 1974 |
cand_h = []
|
| 1975 |
for d in VAR_HEADING_DELTA_SET:
|
| 1976 |
h_try = _clamp(h_prev + float(d), VAR_HEADING_MIN, VAR_HEADING_MAX)
|
|
@@ -2047,19 +1912,13 @@ def _run_common(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, de
|
|
| 2047 |
html_map = build_leaflet_map(path_pts, legs, method, start_dt, show_max_range_ring=show_max_range_ring)
|
| 2048 |
stats_md = compute_stats(path_pts, seg_dists_m, total_time_min, legs)
|
| 2049 |
|
| 2050 |
-
bfo_fig, _bfo_md = bfo_analysis_with_legs(
|
| 2051 |
-
start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice
|
| 2052 |
-
)
|
| 2053 |
|
| 2054 |
descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
|
| 2055 |
alt_fig = build_altitude_plot(start_dt, total_time_min, alt_ft, vs_fpm, descent_start_dt)
|
| 2056 |
-
|
| 2057 |
speed_fig = build_speed_profile_plot(start_dt, legs)
|
| 2058 |
|
| 2059 |
-
headers, rows = build_validation_table(
|
| 2060 |
-
start_lat, start_lon, method, start_dt, legs, alt_ft, vs_fpm, descent_start_choice
|
| 2061 |
-
)
|
| 2062 |
-
|
| 2063 |
return html_map, stats_md, bfo_fig, alt_fig, speed_fig, headers, rows
|
| 2064 |
|
| 2065 |
|
|
@@ -2193,7 +2052,7 @@ with gr.Blocks() as demo:
|
|
| 2193 |
value=[],
|
| 2194 |
interactive=False,
|
| 2195 |
row_count=(40, "dynamic"),
|
| 2196 |
-
|
| 2197 |
wrap=True,
|
| 2198 |
label="Validation"
|
| 2199 |
)
|
|
|
|
| 38 |
# ================= CONFIG =================
|
| 39 |
KML_FILE = "MH370 - 13 nov (3).kml"
|
| 40 |
SEARCH_AREAS_KMZ = "Search areas.kmz"
|
|
|
|
|
|
|
| 41 |
MAX_RANGE_KML = "MH370_max_range_6166km_KUL_red.kml"
|
| 42 |
|
| 43 |
DEFAULT_LEGS = [
|
|
|
|
| 56 |
("MEKAR", 6.5, 96.48333333),
|
| 57 |
]
|
| 58 |
|
|
|
|
| 59 |
CAPT_BYRON_BAILEY_NAME = "Captain Byron Bailey"
|
| 60 |
CAPT_BYRON_BAILEY_LAT = -(39 + 10 / 60) # 39°10' S
|
| 61 |
CAPT_BYRON_BAILEY_LON = (88 + 18 / 60) # 88°18' E
|
| 62 |
|
|
|
|
| 63 |
MEASURED_BFO_0020_HZ = 182.0
|
| 64 |
|
| 65 |
RING_TIMES = {
|
|
|
|
| 72 |
"Ping Ring 7": "24:10:00Z",
|
| 73 |
}
|
| 74 |
|
|
|
|
| 75 |
SATLOC_IDX_MEAS_BFO = 4
|
| 76 |
SATLOC_IDX_MEAS_BTO = 5
|
| 77 |
|
|
|
|
| 78 |
RADAR_FIX_LAT = 6.578
|
| 79 |
RADAR_FIX_LON = 96.341
|
| 80 |
RADAR_FIX_TIME_UTC = datetime(2014, 3, 7, 18, 22, 0, tzinfo=timezone.utc)
|
|
|
|
| 81 |
TURN_COMPLETE_TIME_UTC = datetime(2014, 3, 7, 18, 40, 0, tzinfo=timezone.utc)
|
|
|
|
| 82 |
RADAR_INITIAL_TRACK_DEG = 296.0
|
| 83 |
|
|
|
|
| 84 |
GUIDED_VALIDATION_LABELS = [
|
| 85 |
"18:22:00",
|
| 86 |
"18:40:00",
|
|
|
|
| 88 |
"20:40:00",
|
| 89 |
"21:40:00",
|
| 90 |
"22:40:00",
|
| 91 |
+
"24:10:00",
|
| 92 |
+
"24:20:00",
|
| 93 |
]
|
| 94 |
+
GUIDED_FINAL_LABEL = "24:20:00"
|
| 95 |
|
|
|
|
| 96 |
TURN_BANK_MIN_DEG = 15.0
|
| 97 |
TURN_BANK_MAX_DEG = 30.0
|
| 98 |
TURN_BANK_DEFAULT_DEG = 25.0
|
| 99 |
TURN_STEP_SEC = 30.0
|
| 100 |
|
|
|
|
| 101 |
TURN_SPEED_MIN_KT = 470.0
|
| 102 |
TURN_SPEED_MAX_KT = 520.0
|
|
|
|
| 103 |
SOUTH_SPEED_MIN_KT = 430.0
|
| 104 |
SOUTH_SPEED_MAX_KT = 510.0
|
| 105 |
|
| 106 |
+
CONST_SOUTH_SPEED_KT = 443.0
|
|
|
|
| 107 |
|
|
|
|
| 108 |
GUIDED_HEADING_MIN = 175
|
| 109 |
GUIDED_HEADING_MAX = 190
|
| 110 |
COARSE_HEADING_STEP_DEG = 2.0
|
| 111 |
REFINE_HEADING_HALF_WINDOW_DEG = 2.0
|
| 112 |
REFINE_HEADING_STEP_DEG = 0.5
|
| 113 |
|
|
|
|
| 114 |
GUIDED_SOUTH_SEG_MIN = [60.0, 60.0, 60.0, 60.0, 90.0, 10.0]
|
| 115 |
GUIDED_SOUTH_SEG_LABELS_END = ["19:40:00", "20:40:00", "21:40:00", "22:40:00", "24:10:00", "24:20:00"]
|
| 116 |
|
|
|
|
| 117 |
HARD_BTO_LABELS = ["18:40:00", "19:40:00", "20:40:00", "21:40:00", "22:40:00", "24:10:00", "24:20:00"]
|
| 118 |
|
|
|
|
| 119 |
TOPK_POLISH = 5
|
| 120 |
ENABLE_POLISH_STRAIGHT = True
|
| 121 |
POLISH_PASSES = 3
|
|
|
|
| 123 |
POLISH_STEP_KT = 2.0
|
| 124 |
POLISH_OBJ_EPS = 1e-3
|
| 125 |
|
|
|
|
| 126 |
VAR_HEADING_MIN = 170.0
|
| 127 |
VAR_HEADING_MAX = 200.0
|
| 128 |
+
VAR_TURN_SPEED_STEP = 10.0
|
| 129 |
+
VAR_FIRST_HEADING_STEP = 4.0
|
| 130 |
+
VAR_HEADING_DELTA_SET = [-6, -3, 0, 3, 6]
|
| 131 |
+
VAR_BEAM_WIDTH = 12
|
| 132 |
+
VAR_SMOOTH_PENALTY_US_PER_DEG = 1.5
|
| 133 |
+
|
| 134 |
+
SE_EAST_DIVERGE_START_SEG_INDEX = 4
|
| 135 |
+
SE_VAR_EAST_DELTA_SET = [-8, -4, -2, 0, 2]
|
| 136 |
+
SE_EAST_BIAS_PENALTY_US_PER_DEG = 0.8
|
| 137 |
+
|
|
|
|
|
|
|
| 138 |
SEARCH_AREA_COLORS = {
|
| 139 |
"Ocean Infinity": "#ff3b3b",
|
| 140 |
"Australia": "#1f77b4",
|
|
|
|
| 180 |
def load_ping_rings_from_kml(path: str):
|
| 181 |
path = resolve_local_or_mnt(path)
|
| 182 |
if not os.path.exists(path):
|
| 183 |
+
raise FileNotFoundError(f"KML file not found: '{path}'.")
|
|
|
|
|
|
|
| 184 |
|
| 185 |
tree = ET.parse(path)
|
| 186 |
root = tree.getroot()
|
|
|
|
| 228 |
KML_LOAD_ERROR = ""
|
| 229 |
|
| 230 |
|
| 231 |
+
# ================= KML (Generic features / max range ring) =================
|
| 232 |
def load_generic_kml_features(path: str):
|
| 233 |
path = resolve_local_or_mnt(path)
|
| 234 |
if not os.path.exists(path):
|
| 235 |
+
raise FileNotFoundError(f"KML file not found: '{path}'.")
|
|
|
|
|
|
|
| 236 |
|
| 237 |
tree = ET.parse(path)
|
| 238 |
root = tree.getroot()
|
|
|
|
| 246 |
name_el = pm.find("kml:name", ns)
|
| 247 |
pm_name = name_el.text.strip() if (name_el is not None and name_el.text) else "KML feature"
|
| 248 |
|
|
|
|
| 249 |
for pt in pm.findall(".//kml:Point", ns):
|
| 250 |
coords_el = pt.find(".//kml:coordinates", ns)
|
| 251 |
if coords_el is None or coords_el.text is None:
|
|
|
|
| 254 |
if len(coords) >= 2:
|
| 255 |
lon = float(coords[0])
|
| 256 |
lat = float(coords[1])
|
| 257 |
+
features.append({"type": "point", "name": pm_name, "lat": lat, "lon": lon})
|
| 258 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
for ls in pm.findall(".//kml:LineString", ns):
|
| 260 |
coords_el = ls.find(".//kml:coordinates", ns)
|
| 261 |
if coords_el is None or coords_el.text is None:
|
|
|
|
| 271 |
lat = float(parts[1])
|
| 272 |
pts.append((lat, lon))
|
| 273 |
if pts:
|
| 274 |
+
features.append({"type": "line", "name": pm_name, "points": pts})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
|
| 276 |
return features
|
| 277 |
|
|
|
|
| 311 |
def load_search_areas_from_kmz(kmz_path: str):
|
| 312 |
kmz_path = resolve_local_or_mnt(kmz_path)
|
| 313 |
if not os.path.exists(kmz_path):
|
| 314 |
+
raise FileNotFoundError(f"KMZ file not found: '{kmz_path}'.")
|
|
|
|
|
|
|
| 315 |
|
| 316 |
with zipfile.ZipFile(kmz_path, "r") as zf:
|
| 317 |
kml_name = None
|
|
|
|
| 442 |
return float(omega * 180.0 / math.pi)
|
| 443 |
|
| 444 |
|
| 445 |
+
def build_turn_legs(speed_knots: float, method: str, heading_final_deg: float,
|
| 446 |
+
bank_pref_deg: float = TURN_BANK_DEFAULT_DEG, step_sec: float = TURN_STEP_SEC):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
total_sec = (TURN_COMPLETE_TIME_UTC - RADAR_FIX_TIME_UTC).total_seconds()
|
| 448 |
if total_sec <= 0:
|
| 449 |
return [], {"error": "Invalid turn window."}
|
|
|
|
| 536 |
return fp._time_label_to_dt(s, fp.SAT_EPOCH_UTC)
|
| 537 |
|
| 538 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
def dt_0020_utc() -> datetime:
|
| 540 |
return fp._time_label_to_dt("24:20:00", fp.SAT_EPOCH_UTC)
|
| 541 |
|
| 542 |
|
| 543 |
+
# ================= PATCH satLoc =================
|
| 544 |
def _patch_measured_bfo_0020():
|
| 545 |
target_labels = {"24:20:00", "00:20:00"}
|
| 546 |
patched = False
|
|
|
|
| 566 |
|
| 567 |
# ================= ALTITUDE HELPERS =================
|
| 568 |
def altitude_ft_at_dt(dt_ping: datetime, start_dt: datetime, alt0_ft: float,
|
| 569 |
+
vs_fpm: float, descent_start_dt):
|
| 570 |
alt0_ft = float(alt0_ft)
|
| 571 |
vs_fpm = float(vs_fpm)
|
| 572 |
|
| 573 |
if descent_start_dt is None or vs_fpm == 0.0:
|
| 574 |
return max(0.0, alt0_ft)
|
|
|
|
| 575 |
if dt_ping < descent_start_dt:
|
| 576 |
return max(0.0, alt0_ft)
|
| 577 |
|
|
|
|
| 581 |
|
| 582 |
|
| 583 |
def build_altitude_plot(start_dt: datetime, total_time_min: float, alt0_ft: float,
|
| 584 |
+
vs_fpm: float, descent_start_dt):
|
| 585 |
t = np.linspace(0.0, max(1.0, float(total_time_min)), 120)
|
| 586 |
alts = []
|
| 587 |
for mins in t:
|
| 588 |
dt = start_dt + timedelta(minutes=float(mins))
|
| 589 |
+
alts.append(altitude_ft_at_dt(dt, start_dt, float(alt0_ft), float(vs_fpm), descent_start_dt))
|
|
|
|
| 590 |
|
| 591 |
fig = go.Figure()
|
| 592 |
fig.add_trace(go.Scatter(x=t, y=alts, mode="lines", name="Altitude (ft)"))
|
|
|
|
| 601 |
|
| 602 |
|
| 603 |
# ================= SPEED PROFILE PLOT =================
|
| 604 |
+
def build_speed_profile_plot(start_dt: datetime, legs):
|
| 605 |
xs = [0.0]
|
| 606 |
ys = []
|
| 607 |
cum_min = 0.0
|
|
|
|
| 620 |
xs.append(cum_min)
|
| 621 |
ys.append(spd)
|
| 622 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 623 |
fig = go.Figure()
|
| 624 |
+
if ys:
|
| 625 |
+
fig.add_trace(go.Scatter(x=xs[1:], y=ys, mode="lines", name="Speed (kt)"))
|
| 626 |
fig.update_layout(
|
| 627 |
title="Speed Profile",
|
| 628 |
xaxis_title="Minutes since start",
|
|
|
|
| 658 |
total_dist_m = float(np.sum(seg_dists_m)) if seg_dists_m else 0.0
|
| 659 |
total_dist_km = total_dist_m / 1000.0
|
| 660 |
total_dist_nm = total_dist_m / 1852.0
|
|
|
|
| 661 |
total_time_hr = total_time_min / 60.0
|
| 662 |
avg_gs_knots = (total_dist_nm / total_time_hr) if total_time_hr > 0 else 0.0
|
|
|
|
| 663 |
end_lat, end_lon = path_pts[-1]
|
| 664 |
return "\n\n".join([
|
| 665 |
f"**Legs:** {len(legs)}",
|
|
|
|
| 735 |
def _map_marker_times(start_dt, legs):
|
| 736 |
if start_dt == RADAR_FIX_TIME_UTC:
|
| 737 |
times = [_dt_from_label(x) for x in GUIDED_VALIDATION_LABELS]
|
| 738 |
+
return sorted(list({t: t for t in times}.values()))
|
|
|
|
| 739 |
|
| 740 |
times = [start_dt]
|
| 741 |
cum = 0.0
|
|
|
|
| 745 |
return times
|
| 746 |
|
| 747 |
|
| 748 |
+
# ================= CLIP TRAJECTORY =================
|
| 749 |
def clip_legs_to_end_dt(legs, start_dt: datetime, end_dt: datetime):
|
| 750 |
total_target_sec = (end_dt - start_dt).total_seconds()
|
| 751 |
if total_target_sec <= 0:
|
|
|
|
| 790 |
return pts
|
| 791 |
|
| 792 |
|
| 793 |
+
def _descent_start_dt_from_choice(descent_start_choice: str):
|
| 794 |
ch = (descent_start_choice or "").strip()
|
| 795 |
if ch.startswith("At 00:20Z only"):
|
| 796 |
return dt_0020_utc()
|
|
|
|
| 801 |
|
| 802 |
def bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice):
|
| 803 |
pts = _ping_points_after(start_dt)
|
|
|
|
| 804 |
descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
|
| 805 |
|
| 806 |
leg_secs = [float(m) * 60.0 for _, _, m in legs]
|
| 807 |
boundaries = np.cumsum([0.0] + leg_secs)
|
| 808 |
total_end = float(boundaries[-1])
|
|
|
|
| 809 |
leg_start_positions = _precompute_leg_starts(start_lat, start_lon, legs, method)
|
| 810 |
|
| 811 |
x_labels, measured, calculated = [], [], []
|
|
|
|
| 828 |
|
| 829 |
leg_start_lat, leg_start_lon = leg_start_positions[i]
|
| 830 |
hdg, spd, _m = legs[i]
|
|
|
|
| 831 |
lat, lon = _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg, spd, t_inside)
|
| 832 |
|
| 833 |
alt_now_ft = altitude_ft_at_dt(dt_ping, start_dt, float(alt_ft), float(vs_fpm), descent_start_dt)
|
|
|
|
| 845 |
calculated.append(float(pred_bfo))
|
| 846 |
|
| 847 |
fig = go.Figure()
|
| 848 |
+
if x_labels:
|
| 849 |
+
fig.add_trace(go.Scatter(
|
| 850 |
+
x=x_labels, y=measured,
|
| 851 |
+
mode="markers",
|
| 852 |
+
name="Measured BFO",
|
| 853 |
+
marker=dict(size=11, symbol="circle")
|
| 854 |
+
))
|
| 855 |
+
fig.add_trace(go.Scatter(
|
| 856 |
+
x=x_labels, y=calculated,
|
| 857 |
+
mode="lines+markers",
|
| 858 |
+
name="Calculated BFO",
|
| 859 |
+
line=dict(width=3),
|
| 860 |
+
marker=dict(size=10, symbol="diamond")
|
| 861 |
+
))
|
|
|
|
|
|
|
|
|
|
| 862 |
fig.update_layout(
|
| 863 |
title="BFO Analysis",
|
| 864 |
xaxis_title="Time",
|
|
|
|
| 867 |
margin=dict(l=60, r=160, t=80, b=60),
|
| 868 |
height=520,
|
| 869 |
)
|
|
|
|
| 870 |
return fig, ""
|
| 871 |
|
| 872 |
|
|
|
|
| 879 |
return None, None
|
| 880 |
|
| 881 |
|
| 882 |
+
def build_validation_table(start_lat, start_lon, method, start_dt, legs, alt_ft, vs_fpm, descent_start_choice):
|
|
|
|
|
|
|
|
|
|
| 883 |
descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
|
|
|
|
| 884 |
is_guided = (start_dt == RADAR_FIX_TIME_UTC)
|
| 885 |
|
| 886 |
if is_guided:
|
|
|
|
| 965 |
|
| 966 |
def add_permanent_label(m, name, lat, lon):
|
| 967 |
tip = f"{html.escape(name)}<br>Lat: {float(lat):.5f}<br>Lon: {float(lon):.5f}"
|
| 968 |
+
pop = folium.Popup(f"<b>{html.escape(name)}</b><br>Lat: {float(lat):.5f}<br>Lon: {float(lon):.5f}", max_width=260)
|
|
|
|
|
|
|
|
|
|
| 969 |
|
| 970 |
folium.CircleMarker(
|
| 971 |
location=(lat, lon),
|
|
|
|
| 995 |
|
| 996 |
def add_named_marker(m, name: str, lat: float, lon: float):
|
| 997 |
tip = f"{html.escape(name)}<br>Lat: {float(lat):.5f}<br>Lon: {float(lon):.5f}"
|
| 998 |
+
pop = folium.Popup(f"<b>{html.escape(name)}</b><br>Lat: {float(lat):.5f}<br>Lon: {float(lon):.5f}", max_width=260)
|
|
|
|
|
|
|
|
|
|
| 999 |
folium.CircleMarker(
|
| 1000 |
location=(float(lat), float(lon)),
|
| 1001 |
radius=6,
|
|
|
|
| 1011 |
|
| 1012 |
def add_max_range_features(m, max_range_features):
|
| 1013 |
bounds_pts = []
|
|
|
|
| 1014 |
for feat in max_range_features:
|
| 1015 |
ftype = feat.get("type")
|
| 1016 |
name = feat.get("name", "Maximum range")
|
|
|
|
| 1065 |
control=True,
|
| 1066 |
).add_to(m)
|
| 1067 |
|
|
|
|
| 1068 |
MousePosition(
|
| 1069 |
position="topright",
|
| 1070 |
separator=" | ",
|
|
|
|
| 1073 |
lng_formatter="function(num) {return L.Util.formatNum(num, 5);}",
|
| 1074 |
).add_to(m)
|
| 1075 |
|
|
|
|
| 1076 |
search_area_pts_for_bounds = []
|
| 1077 |
for area in SEARCH_AREAS:
|
| 1078 |
nm = area.get("name", "Search area")
|
|
|
|
| 1095 |
if not line:
|
| 1096 |
continue
|
| 1097 |
search_area_pts_for_bounds.extend(line)
|
| 1098 |
+
folium.PolyLine(locations=line, color=col, weight=3, opacity=0.9, tooltip=nm).add_to(m)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1099 |
|
| 1100 |
for poly in area.get("polygons", []) or []:
|
| 1101 |
if not poly:
|
|
|
|
| 1112 |
tooltip=nm,
|
| 1113 |
).add_to(m)
|
| 1114 |
|
|
|
|
| 1115 |
for ring_name in sorted(PING_RINGS.keys()):
|
| 1116 |
ring_pts = PING_RINGS.get(ring_name)
|
| 1117 |
if not ring_pts:
|
|
|
|
| 1120 |
tooltip = f"{ring_name} ({ring_time})" if ring_time else ring_name
|
| 1121 |
folium.PolyLine(ring_pts, weight=2, opacity=0.6, color="yellow", tooltip=tooltip).add_to(m)
|
| 1122 |
|
|
|
|
| 1123 |
max_range_pts_for_bounds = []
|
| 1124 |
if show_max_range_ring:
|
| 1125 |
max_range_pts_for_bounds = add_max_range_features(m, MAX_RANGE_FEATURES)
|
| 1126 |
|
|
|
|
| 1127 |
folium.PolyLine(path_pts, color="white", weight=8, opacity=0.3).add_to(m)
|
| 1128 |
folium.PolyLine(path_pts, color="red", weight=4, opacity=1.0, tooltip="Flight path").add_to(m)
|
| 1129 |
|
|
|
|
| 1206 |
popup=folium.Popup("<br>".join(popup_lines), max_width=360),
|
| 1207 |
).add_to(m)
|
| 1208 |
|
|
|
|
| 1209 |
for name, lat, lon in WAYPOINTS:
|
| 1210 |
+
add_permanent_label(m, float(name) if False else name, float(lat), float(lon))
|
| 1211 |
|
|
|
|
| 1212 |
add_named_marker(m, CAPT_BYRON_BAILEY_NAME, CAPT_BYRON_BAILEY_LAT, CAPT_BYRON_BAILEY_LON)
|
| 1213 |
|
| 1214 |
waypoint_pts = [(float(lat), float(lon)) for _, lat, lon in WAYPOINTS] + [(CAPT_BYRON_BAILEY_LAT, CAPT_BYRON_BAILEY_LON)]
|
|
|
|
| 1286 |
return float(lat1), float(lon1)
|
| 1287 |
|
| 1288 |
|
| 1289 |
+
def _bto_calc(method: str, lat: float, lon: float, dt_ping: datetime, alt_ft: float,
|
| 1290 |
+
vs_fpm: float, start_dt: datetime, descent_start_dt):
|
| 1291 |
alt_now_ft = altitude_ft_at_dt(dt_ping, start_dt, float(alt_ft), float(vs_fpm), descent_start_dt)
|
| 1292 |
alt_m = alt_now_ft * 0.3048
|
| 1293 |
return float(fp.calculateBTO_at_time(float(lat), float(lon), alt_m, dt_ping))
|
|
|
|
| 1313 |
return float(x_best), abs(float(fn(x_best)))
|
| 1314 |
|
| 1315 |
|
| 1316 |
+
def _fit_segment_speed(method: str, lat0: float, lon0: float, hdg: float, minutes: float,
|
| 1317 |
+
dt_end: datetime, alt_ft: float, vs_fpm: float, start_dt: datetime,
|
| 1318 |
+
descent_start_dt, vmin: float, vmax: float):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1319 |
meas = _lookup_measured_bto(dt_end)
|
| 1320 |
if meas is None:
|
| 1321 |
v = 0.5 * (vmin + vmax)
|
|
|
|
| 1352 |
return float(v_best), float(err(v_best)), (lat1, lon1)
|
| 1353 |
|
| 1354 |
|
| 1355 |
+
def _compute_bto_errors_at_enforced(method: str, start_dt: datetime, alt_ft: float, vs_fpm: float,
|
| 1356 |
+
descent_start_dt, full_legs, enforced_dts):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1357 |
errs = []
|
| 1358 |
for dt_k in enforced_dts:
|
| 1359 |
meas = _lookup_measured_bto(dt_k)
|
|
|
|
| 1367 |
return errs
|
| 1368 |
|
| 1369 |
|
| 1370 |
+
def _build_south_legs_piecewise_constant_heading(h_south: float, speeds, minutes):
|
| 1371 |
legs = []
|
| 1372 |
for v, m in zip(speeds, minutes):
|
| 1373 |
if float(m) <= 0:
|
|
|
|
| 1376 |
return legs
|
| 1377 |
|
| 1378 |
|
| 1379 |
+
def _build_south_legs_variable(headings, speeds, minutes):
|
| 1380 |
legs = []
|
| 1381 |
for h, v, m in zip(headings, speeds, minutes):
|
| 1382 |
if float(m) <= 0:
|
|
|
|
| 1385 |
return legs
|
| 1386 |
|
| 1387 |
|
| 1388 |
+
# ================= Top-K polish =================
|
| 1389 |
+
def _polish_south_speeds_topk(method: str, start_dt: datetime, end_dt: datetime, alt_ft: float,
|
| 1390 |
+
vs_fpm: float, descent_start_dt, h_south: float, turn_legs,
|
| 1391 |
+
enforced_dts, init_speeds, passes: int = POLISH_PASSES,
|
| 1392 |
+
window_kt: float = POLISH_WINDOW_KT, step_kt: float = POLISH_STEP_KT):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1393 |
speeds = [float(v) for v in init_speeds]
|
| 1394 |
|
| 1395 |
pts_turn, _, _ = simulate_path(RADAR_FIX_LAT, RADAR_FIX_LON, turn_legs, method)
|
| 1396 |
turn_end_lat, turn_end_lon = float(pts_turn[-1][0]), float(pts_turn[-1][1])
|
| 1397 |
|
| 1398 |
+
def build_full(speeds_now):
|
| 1399 |
lat_cur, lon_cur = turn_end_lat, turn_end_lon
|
| 1400 |
seg_errs = []
|
| 1401 |
for mins, end_lbl, vseg in zip(GUIDED_SOUTH_SEG_MIN, GUIDED_SOUTH_SEG_LABELS_END, speeds_now):
|
|
|
|
| 1471 |
def solve_guided_straight(method, alt_ft, vs_fpm, descent_start_choice):
|
| 1472 |
start_dt = _guided_start_dt()
|
| 1473 |
end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
|
|
|
|
| 1474 |
descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
|
| 1475 |
|
| 1476 |
pts_all = _ping_points_after(start_dt)
|
|
|
|
| 1488 |
h_south = float(h_south)
|
| 1489 |
v_turn = float(v_turn)
|
| 1490 |
|
| 1491 |
+
turn_legs, _turn_info = build_turn_legs(speed_knots=float(v_turn), method=method, heading_final_deg=float(h_south))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1492 |
pts_turn, _, _ = simulate_path(RADAR_FIX_LAT, RADAR_FIX_LON, turn_legs, method)
|
| 1493 |
lat_cur, lon_cur = float(pts_turn[-1][0]), float(pts_turn[-1][1])
|
| 1494 |
|
|
|
|
| 1640 |
return best_final_legs, "", start_dt
|
| 1641 |
|
| 1642 |
|
| 1643 |
+
# ================= Guided Solver: Variable heading & speed =================
|
| 1644 |
def _solve_guided_variable_core(method, alt_ft, vs_fpm, descent_start_choice, se_bias_after_2240: bool):
|
| 1645 |
start_dt = _guided_start_dt()
|
| 1646 |
end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
|
|
|
|
| 1647 |
descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
|
| 1648 |
|
| 1649 |
enforced_labels = _available_hard_bto_labels()
|
|
|
|
| 1657 |
best_global = None
|
| 1658 |
best_global_legs = None
|
| 1659 |
|
| 1660 |
+
def score_from_errors(errs):
|
| 1661 |
if not errs:
|
| 1662 |
return None
|
| 1663 |
abs_arr = np.abs(np.array(errs, dtype=float))
|
|
|
|
| 1665 |
|
| 1666 |
for v_turn in turn_speeds:
|
| 1667 |
for h1 in first_headings:
|
| 1668 |
+
turn_legs, _turn_info = build_turn_legs(speed_knots=float(v_turn), method=method, heading_final_deg=float(h1))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1669 |
pts_turn, _, _ = simulate_path(RADAR_FIX_LAT, RADAR_FIX_LON, turn_legs, method)
|
| 1670 |
lat0, lon0 = float(pts_turn[-1][0]), float(pts_turn[-1][1])
|
| 1671 |
|
|
|
|
| 1786 |
return _solve_guided_variable_core(method, alt_ft, vs_fpm, descent_start_choice, se_bias_after_2240=True)
|
| 1787 |
|
| 1788 |
|
| 1789 |
+
# ================= Guided Solver: Variable heading, CONSTANT southern speed =================
|
| 1790 |
def solve_guided_constant_speed(method, alt_ft, vs_fpm, descent_start_choice):
|
| 1791 |
start_dt = _guided_start_dt()
|
| 1792 |
end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
|
|
|
|
| 1793 |
descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
|
| 1794 |
|
| 1795 |
enforced_labels = _available_hard_bto_labels()
|
|
|
|
| 1803 |
best_global = None
|
| 1804 |
best_global_legs = None
|
| 1805 |
|
| 1806 |
+
def score_from_errors(errs):
|
| 1807 |
if not errs:
|
| 1808 |
return None
|
| 1809 |
abs_arr = np.abs(np.array(errs, dtype=float))
|
|
|
|
| 1813 |
|
| 1814 |
for v_turn in turn_speeds:
|
| 1815 |
for h1 in first_headings:
|
| 1816 |
+
turn_legs, _turn_info = build_turn_legs(speed_knots=float(v_turn), method=method, heading_final_deg=float(h1))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1817 |
pts_turn, _, _ = simulate_path(RADAR_FIX_LAT, RADAR_FIX_LON, turn_legs, method)
|
| 1818 |
lat0, lon0 = float(pts_turn[-1][0]), float(pts_turn[-1][1])
|
| 1819 |
|
|
|
|
| 1836 |
|
| 1837 |
for st in beam:
|
| 1838 |
h_prev = float(st["h_prev"])
|
|
|
|
| 1839 |
cand_h = []
|
| 1840 |
for d in VAR_HEADING_DELTA_SET:
|
| 1841 |
h_try = _clamp(h_prev + float(d), VAR_HEADING_MIN, VAR_HEADING_MAX)
|
|
|
|
| 1912 |
html_map = build_leaflet_map(path_pts, legs, method, start_dt, show_max_range_ring=show_max_range_ring)
|
| 1913 |
stats_md = compute_stats(path_pts, seg_dists_m, total_time_min, legs)
|
| 1914 |
|
| 1915 |
+
bfo_fig, _bfo_md = bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice)
|
|
|
|
|
|
|
| 1916 |
|
| 1917 |
descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
|
| 1918 |
alt_fig = build_altitude_plot(start_dt, total_time_min, alt_ft, vs_fpm, descent_start_dt)
|
|
|
|
| 1919 |
speed_fig = build_speed_profile_plot(start_dt, legs)
|
| 1920 |
|
| 1921 |
+
headers, rows = build_validation_table(start_lat, start_lon, method, start_dt, legs, alt_ft, vs_fpm, descent_start_choice)
|
|
|
|
|
|
|
|
|
|
| 1922 |
return html_map, stats_md, bfo_fig, alt_fig, speed_fig, headers, rows
|
| 1923 |
|
| 1924 |
|
|
|
|
| 2052 |
value=[],
|
| 2053 |
interactive=False,
|
| 2054 |
row_count=(40, "dynamic"),
|
| 2055 |
+
column_count=(11, "fixed"),
|
| 2056 |
wrap=True,
|
| 2057 |
label="Validation"
|
| 2058 |
)
|