Chris773 commited on
Commit
e240ccf
·
verified ·
1 Parent(s): a62d73c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +77 -218
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", # 00:10Z
101
- "24:20:00", # 00:20Z
102
  ]
103
- GUIDED_FINAL_LABEL = "24:20:00" # 00:20Z FINAL RESTING POINT
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
- # ===== NEW: Constant southern speed mode =====
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 # coarse turn speed scan
147
- VAR_FIRST_HEADING_STEP = 4.0 # coarse first south heading grid
148
- VAR_HEADING_DELTA_SET = [-6, -3, 0, 3, 6] # candidate changes per segment (deg)
149
- VAR_BEAM_WIDTH = 12 # keep top-N partial paths each segment
150
- VAR_SMOOTH_PENALTY_US_PER_DEG = 1.5 # small penalty to avoid wiggles (still BTO-first)
151
-
152
- # ===== SE-bias-after-22:40 (new mode) =====
153
- SE_EAST_DIVERGE_START_SEG_INDEX = 4 # seg 4 starts after 22:40
154
- SE_VAR_EAST_DELTA_SET = [-8, -4, -2, 0, 2] # prefer small decreases (eastward) after 22:40
155
- SE_EAST_BIAS_PENALTY_US_PER_DEG = 0.8 # small; BTO still dominates
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 (Maximum Range Ring) =================
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
- "type": "point",
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
- speed_knots: float,
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: ensure measured BFO at 00:20 is 182 Hz =================
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: datetime | None):
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: datetime | None):
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
- alt = altitude_ft_at_dt(dt, start_dt, float(alt0_ft), float(vs_fpm), descent_start_dt)
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: list[tuple[float, float, float]]):
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
- fig.add_trace(go.Scatter(x=xs[1:], y=ys, mode="lines", name="Speed (kt)"))
 
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
- times = sorted(list({t: t for t in times}.values()))
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 TO 00:20 (GUIDED) =================
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) -> datetime | None:
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 not x_labels:
911
- fig.update_layout(title="BFO Analysis", xaxis_title="Time", yaxis_title="Hz", height=520)
912
- return fig, ""
913
-
914
- fig.add_trace(go.Scatter(
915
- x=x_labels, y=measured,
916
- mode="markers",
917
- name="Measured BFO",
918
- marker=dict(size=11, symbol="circle")
919
- ))
920
- fig.add_trace(go.Scatter(
921
- x=x_labels, y=calculated,
922
- mode="lines+markers",
923
- name="Calculated BFO",
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, vs_fpm: float, start_dt: datetime, descent_start_dt: datetime | None):
 
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
- method: str,
1407
- lat0: float,
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
- method: str,
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: list[float], minutes: list[float]):
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: list[float], speeds: list[float], minutes: list[float]):
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 (Straight mode only) =================
1496
- def _polish_south_speeds_topk(
1497
- method: str,
1498
- start_dt: datetime,
1499
- end_dt: datetime,
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: list[float]):
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 (FAST beam search) =================
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: list[float]):
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 (443 kt) =================
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: list[float]):
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
- col_count=(11, "fixed"),
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
  )