Chris773 commited on
Commit
c562133
·
verified ·
1 Parent(s): 65bfe38

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +251 -94
app.py CHANGED
@@ -13,11 +13,15 @@
13
  # - Guided mode computes a realistic turn between 18:22→18:40 and then southern path.
14
  # - Guided validation timestamps: 18:22, 18:40, 19:40, 20:40, 21:40, 22:40, 00:10, 00:20
15
  #
16
- # THIS VERSION (UI CHANGES):
17
- # - Removes the big header/config text and removes guided/BFO scoring text blocks from UI
18
- # - Adds a Speed Profile graph (kt vs minutes)
19
- # - Descent-start dropdown now has: None / At 00:20Z only
20
- # - Adds a map marker: Captain Byron Bailey @ 39°10' S, 88°18' E
 
 
 
 
21
 
22
  import os
23
  import re
@@ -55,10 +59,13 @@ WAYPOINTS = [
55
  ("MEKAR", 6.5, 96.48333333),
56
  ]
57
 
58
- # Custom reference marker requested
59
- CAPT_BYRON_BAILEY_LABEL = "Captain Byron Bailey"
60
- CAPT_BYRON_BAILEY_LAT = -(39.0 + 10.0 / 60.0) # 39°10' S -> -39.1666667
61
- CAPT_BYRON_BAILEY_LON = (88.0 + 18.0 / 60.0) # 88°18' E -> 88.3
 
 
 
62
 
63
  RING_TIMES = {
64
  "Ping Ring 1": "18:25:00Z",
@@ -144,10 +151,7 @@ VAR_BEAM_WIDTH = 12 # keep top-N partial paths each segment
144
  VAR_SMOOTH_PENALTY_US_PER_DEG = 1.5 # small penalty to avoid wiggles (still BTO-first)
145
 
146
  # ===== SE-bias-after-22:40 (new mode) =====
147
- # Only applied to the last two segments (22:40→00:10, 00:10→00:20).
148
- # Penalty is applied ONLY when heading increases (more west-of-south),
149
- # nudging solutions to be slightly more east while keeping BTO primary.
150
- SE_EAST_DIVERGE_START_SEG_INDEX = 4 # segment indices: 0..5 ; seg 3 ends at 22:40, seg 4 starts after 22:40
151
  SE_VAR_EAST_DELTA_SET = [-8, -4, -2, 0, 2] # prefer small decreases (eastward) after 22:40
152
  SE_EAST_BIAS_PENALTY_US_PER_DEG = 0.8 # small; BTO still dominates
153
 
@@ -505,6 +509,42 @@ def _dt_from_label(label_hms: str) -> datetime:
505
  return fp._time_label_to_dt(s, fp.SAT_EPOCH_UTC)
506
 
507
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
508
  # ================= ALTITUDE HELPERS =================
509
  def altitude_ft_at_dt(dt_ping: datetime, start_dt: datetime, alt0_ft: float,
510
  vs_fpm: float, descent_start_dt: datetime | None):
@@ -543,26 +583,35 @@ def build_altitude_plot(start_dt: datetime, total_time_min: float, alt0_ft: floa
543
  return fig
544
 
545
 
 
546
  def build_speed_profile_plot(start_dt: datetime, legs: list[tuple[float, float, float]]):
547
- # Stepwise speed vs elapsed minutes
548
- x = [0.0]
549
- y = [float(legs[0][1]) if legs else 0.0]
550
- t = 0.0
551
- for (_h, spd, mins) in legs:
552
- spd = float(spd)
553
  mins = float(mins)
554
- # start of leg
555
- if x[-1] != t:
556
- x.append(t)
557
- y.append(spd)
558
- # end of leg
559
- t2 = t + mins
560
- x.append(t2)
561
- y.append(spd)
562
- t = t2
 
 
 
 
 
 
 
 
 
563
 
564
  fig = go.Figure()
565
- fig.add_trace(go.Scatter(x=x, y=y, mode="lines", name="Speed (kt)"))
566
  fig.update_layout(
567
  title="Speed Profile",
568
  xaxis_title="Minutes since start",
@@ -733,12 +782,19 @@ def _ping_points_after(start_dt: datetime):
733
  return pts
734
 
735
 
 
 
 
 
 
 
 
 
 
736
  def bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice):
737
  pts = _ping_points_after(start_dt)
738
 
739
- descent_start_dt = None
740
- if (descent_start_choice or "").startswith("At 00:20Z"):
741
- descent_start_dt = _dt_from_label("24:20:00")
742
 
743
  leg_secs = [float(m) * 60.0 for _, _, m in legs]
744
  boundaries = np.cumsum([0.0] + leg_secs)
@@ -748,6 +804,14 @@ def bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_dt, alt_ft,
748
 
749
  x_labels, measured, calculated = [], [], []
750
 
 
 
 
 
 
 
 
 
751
  for label, dt_ping, meas_bfo, _meas_bto in pts:
752
  t_sec = (dt_ping - start_dt).total_seconds()
753
  if t_sec < 0 or t_sec > total_end:
@@ -772,7 +836,7 @@ def bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_dt, alt_ft,
772
  spd_mps = float(spd) * 1852.0 / 3600.0
773
  pred_bfo = float(fp.calculateBFO_at_time(spd_mps, float(hdg), float(lat), float(lon), alt_m, vs_mps, dt_ping))
774
 
775
- x_labels.append(label)
776
  measured.append(float(meas_bfo))
777
  calculated.append(float(pred_bfo))
778
 
@@ -802,6 +866,8 @@ def bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_dt, alt_ft,
802
  margin=dict(l=60, r=160, t=80, b=60),
803
  height=520,
804
  )
 
 
805
  return fig, ""
806
 
807
 
@@ -818,9 +884,7 @@ def build_validation_table(
818
  start_lat, start_lon, method, start_dt, legs,
819
  alt_ft, vs_fpm, descent_start_choice
820
  ):
821
- descent_start_dt = None
822
- if (descent_start_choice or "").startswith("At 00:20Z"):
823
- descent_start_dt = _dt_from_label("24:20:00")
824
 
825
  is_guided = (start_dt == RADAR_FIX_TIME_UTC)
826
 
@@ -859,6 +923,13 @@ def build_validation_table(
859
 
860
  bfo_meas, bto_meas = _lookup_measured_at_dt(dt_k)
861
 
 
 
 
 
 
 
 
862
  bfo_err = None if bfo_meas is None else (float(bfo_calc) - float(bfo_meas))
863
  bto_err = None if bto_meas is None else (float(bto_calc) - float(bto_meas))
864
 
@@ -899,6 +970,13 @@ def _all_bounds(points_list):
899
 
900
 
901
  def add_permanent_label(m, name, lat, lon):
 
 
 
 
 
 
 
902
  folium.CircleMarker(
903
  location=(lat, lon),
904
  radius=4,
@@ -907,7 +985,8 @@ def add_permanent_label(m, name, lat, lon):
907
  fill=True,
908
  fill_color="white",
909
  fill_opacity=1.0,
910
- tooltip=name,
 
911
  ).add_to(m)
912
 
913
  label_html = f"""
@@ -918,25 +997,28 @@ def add_permanent_label(m, name, lat, lon):
918
  text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff;
919
  white-space: nowrap;
920
  transform: translate(8px, -12px);
 
921
  ">{html.escape(name)}</div>
922
  """
923
  folium.Marker(location=(lat, lon), icon=folium.DivIcon(html=label_html)).add_to(m)
924
 
925
 
926
- def add_reference_marker(m, name: str, lat: float, lon: float):
 
 
 
 
 
927
  folium.CircleMarker(
928
  location=(float(lat), float(lon)),
929
- radius=7,
930
- color="cyan",
931
- weight=3,
932
  fill=True,
933
  fill_color="cyan",
934
- fill_opacity=0.85,
935
- tooltip=name,
936
- popup=folium.Popup(
937
- f"<b>{html.escape(name)}</b><br>Lat: {float(lat):.5f}<br>Lon: {float(lon):.5f}",
938
- max_width=320,
939
- ),
940
  ).add_to(m)
941
 
942
 
@@ -1064,9 +1146,24 @@ def build_leaflet_map(path_pts, legs, method, start_dt):
1064
  marker_pts_for_bounds.append((lat, lon))
1065
 
1066
  tlabel = _to_label_utc(dt_k)
 
 
 
 
1067
 
1068
  is_final = (start_dt == RADAR_FIX_TIME_UTC) and (dt_k == _dt_from_label(GUIDED_FINAL_LABEL))
1069
 
 
 
 
 
 
 
 
 
 
 
 
1070
  if is_final:
1071
  folium.CircleMarker(
1072
  location=(lat, lon),
@@ -1076,6 +1173,7 @@ def build_leaflet_map(path_pts, legs, method, start_dt):
1076
  fill_color="magenta",
1077
  fill_opacity=0.95,
1078
  tooltip=f"{tlabel} (FINAL)",
 
1079
  ).add_to(m)
1080
  else:
1081
  folium.CircleMarker(
@@ -1086,26 +1184,25 @@ def build_leaflet_map(path_pts, legs, method, start_dt):
1086
  fill_color="white",
1087
  fill_opacity=1.0,
1088
  tooltip=tlabel,
 
1089
  ).add_to(m)
1090
 
 
1091
  for name, lat, lon in WAYPOINTS:
1092
  add_permanent_label(m, name, float(lat), float(lon))
1093
 
1094
- # Requested reference marker
1095
- add_reference_marker(m, CAPT_BYRON_BAILEY_LABEL, CAPT_BYRON_BAILEY_LAT, CAPT_BYRON_BAILEY_LON)
1096
 
1097
- waypoint_pts = [(float(lat), float(lon)) for _, lat, lon in WAYPOINTS]
1098
  all_ring_pts = []
1099
  for k in PING_RINGS:
1100
  all_ring_pts.extend(PING_RINGS.get(k, []))
1101
 
1102
- ref_pts = [(CAPT_BYRON_BAILEY_LAT, CAPT_BYRON_BAILEY_LON)]
1103
-
1104
  bounds = _all_bounds(
1105
  [path_pts]
1106
  + [waypoint_pts]
1107
  + [all_ring_pts]
1108
- + [ref_pts]
1109
  + ([search_area_pts_for_bounds] if search_area_pts_for_bounds else [])
1110
  + ([marker_pts_for_bounds] if marker_pts_for_bounds else [])
1111
  )
@@ -1385,18 +1482,16 @@ def solve_guided_straight(method, alt_ft, vs_fpm, descent_start_choice):
1385
  start_dt = _guided_start_dt()
1386
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1387
 
1388
- descent_start_dt = None
1389
- if (descent_start_choice or "").startswith("At 00:20Z"):
1390
- descent_start_dt = _dt_from_label("24:20:00")
1391
 
1392
  pts_all = _ping_points_after(start_dt)
1393
  pts_window = [(lbl, dt, bfo, bto) for (lbl, dt, bfo, bto) in pts_all if start_dt <= dt <= end_dt]
1394
  if len(pts_window) < 2:
1395
- return None, "", None
1396
 
1397
  enforced_labels = _available_hard_bto_labels()
1398
  if not enforced_labels:
1399
- return None, "", None
1400
 
1401
  enforced_dts = [_dt_from_label(lbl) for lbl in enforced_labels]
1402
 
@@ -1461,43 +1556,105 @@ def solve_guided_straight(method, alt_ft, vs_fpm, descent_start_choice):
1461
  sum_abs = float(np.sum(abs_arr))
1462
  rmse = float(np.sqrt(np.mean(np.array(bto_errs, dtype=float) ** 2)))
1463
 
1464
- return (max_abs, sum_abs, rmse), full_legs
 
 
 
 
 
 
 
 
 
 
1465
 
1466
  def best_for_heading(h_south: float):
1467
  coarse_turns = list(np.arange(TURN_SPEED_MIN_KT, TURN_SPEED_MAX_KT + 1e-9, 5.0))
1468
  best_key = None
1469
  best_legs = None
 
1470
 
1471
  for v in coarse_turns:
1472
  out = _evaluate(h_south, v, do_polish=False)
1473
  if out is None:
1474
  continue
1475
- key, legs = out
1476
  if best_key is None or key < best_key:
1477
- best_key, best_legs = key, legs
1478
 
1479
  if best_key is None:
1480
  return None
1481
- return best_key, best_legs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1482
 
1483
  coarse_headings = _frange(GUIDED_HEADING_MIN, GUIDED_HEADING_MAX, COARSE_HEADING_STEP_DEG)
 
1484
  best = None
1485
  best_legs = None
 
1486
 
1487
  for h in coarse_headings:
1488
  out = best_for_heading(h)
1489
  if out is None:
1490
  continue
1491
- key, legs = out
 
1492
  if best is None or key < best:
1493
- best, best_legs = key, legs
1494
 
1495
  if best_legs is None:
1496
- return None, "", None
1497
 
1498
- # refine around best heading by re-running coarse again in window
1499
- # (kept minimal since UI no longer shows the report)
1500
- return best_legs, "", start_dt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1501
 
1502
 
1503
  # ================= Guided Solver: Variable heading & speed (FAST beam search) =================
@@ -1505,13 +1662,11 @@ def _solve_guided_variable_core(method, alt_ft, vs_fpm, descent_start_choice, se
1505
  start_dt = _guided_start_dt()
1506
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1507
 
1508
- descent_start_dt = None
1509
- if (descent_start_choice or "").startswith("At 00:20Z"):
1510
- descent_start_dt = _dt_from_label("24:20:00")
1511
 
1512
  enforced_labels = _available_hard_bto_labels()
1513
  if not enforced_labels:
1514
- return None, "", None
1515
  enforced_dts = [_dt_from_label(lbl) for lbl in enforced_labels]
1516
 
1517
  turn_speeds = list(np.arange(TURN_SPEED_MIN_KT, TURN_SPEED_MAX_KT + 1e-9, VAR_TURN_SPEED_STEP))
@@ -1562,9 +1717,11 @@ def _solve_guided_variable_core(method, alt_ft, vs_fpm, descent_start_choice, se
1562
 
1563
  for st in beam:
1564
  h_prev = float(st["h_prev"])
 
1565
  cand_h = []
1566
  for d in delta_set:
1567
- h_try = _clamp(h_prev + float(d), VAR_HEADING_MIN, VAR_HEADING_MAX)
 
1568
  cand_h.append(h_try)
1569
  cand_h = sorted(list({round(x, 6): x for x in cand_h}.values()))
1570
 
@@ -1638,8 +1795,9 @@ def _solve_guided_variable_core(method, alt_ft, vs_fpm, descent_start_choice, se
1638
  best_global_legs = full_legs
1639
 
1640
  if best_global_legs is None:
1641
- return None, "", None
1642
 
 
1643
  return best_global_legs, "", start_dt
1644
 
1645
 
@@ -1656,13 +1814,11 @@ def solve_guided_constant_speed(method, alt_ft, vs_fpm, descent_start_choice):
1656
  start_dt = _guided_start_dt()
1657
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1658
 
1659
- descent_start_dt = None
1660
- if (descent_start_choice or "").startswith("At 00:20Z"):
1661
- descent_start_dt = _dt_from_label("24:20:00")
1662
 
1663
  enforced_labels = _available_hard_bto_labels()
1664
  if not enforced_labels:
1665
- return None, "", None
1666
  enforced_dts = [_dt_from_label(lbl) for lbl in enforced_labels]
1667
 
1668
  turn_speeds = list(np.arange(TURN_SPEED_MIN_KT, TURN_SPEED_MAX_KT + 1e-9, VAR_TURN_SPEED_STEP))
@@ -1701,13 +1857,16 @@ def solve_guided_constant_speed(method, alt_ft, vs_fpm, descent_start_choice):
1701
  "smooth_sum": 0.0,
1702
  }]
1703
 
 
1704
  for mins, end_lbl in zip(GUIDED_SOUTH_SEG_MIN, GUIDED_SOUTH_SEG_LABELS_END):
 
1705
  dt_end = _dt_from_label(end_lbl)
1706
  meas = _lookup_measured_bto(dt_end)
1707
  next_beam = []
1708
 
1709
  for st in beam:
1710
  h_prev = float(st["h_prev"])
 
1711
  cand_h = []
1712
  for d in VAR_HEADING_DELTA_SET:
1713
  h_try = _clamp(h_prev + float(d), VAR_HEADING_MIN, VAR_HEADING_MAX)
@@ -1768,8 +1927,9 @@ def solve_guided_constant_speed(method, alt_ft, vs_fpm, descent_start_choice):
1768
  best_global_legs = full_legs
1769
 
1770
  if best_global_legs is None:
1771
- return None, "", None
1772
 
 
1773
  return best_global_legs, "", start_dt
1774
 
1775
 
@@ -1784,15 +1944,13 @@ def _run_common(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, de
1784
  html_map = build_leaflet_map(path_pts, legs, method, start_dt)
1785
  stats_md = compute_stats(path_pts, seg_dists_m, total_time_min, legs)
1786
 
1787
- bfo_fig, _bfo_md_hidden = bfo_analysis_with_legs(
1788
  start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice
1789
  )
1790
 
1791
- descent_start_dt = None
1792
- if (descent_start_choice or "").startswith("At 00:20Z"):
1793
- descent_start_dt = _dt_from_label("24:20:00")
1794
-
1795
  alt_fig = build_altitude_plot(start_dt, total_time_min, alt_ft, vs_fpm, descent_start_dt)
 
1796
  speed_fig = build_speed_profile_plot(start_dt, legs)
1797
 
1798
  headers, rows = build_validation_table(
@@ -1809,19 +1967,20 @@ def run_sim_manual(start_lat, start_lon, legs_df, method, start_time_str, alt_ft
1809
  html_map, stats_md, bfo_fig, alt_fig, speed_fig, headers, rows = _run_common(
1810
  start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice
1811
  )
1812
- # status removed from UI (kept as hidden component), return blank for it
 
1813
  return gr.update(), "", html_map, stats_md, bfo_fig, alt_fig, speed_fig, gr.update(headers=headers, value=rows)
1814
 
1815
 
1816
  def run_guided(guided_mode, start_lat, start_lon, method, alt_ft, vs_fpm, descent_start_choice):
1817
  if guided_mode == "Straight heading":
1818
- legs, _report_md_hidden, start_dt = solve_guided_straight(method, alt_ft, vs_fpm, descent_start_choice)
1819
  elif guided_mode == "Variable heading & speed":
1820
- legs, _report_md_hidden, start_dt = solve_guided_variable(method, alt_ft, vs_fpm, descent_start_choice)
1821
  elif guided_mode == "Variable heading & speed (SE after 22:40)":
1822
- legs, _report_md_hidden, start_dt = solve_guided_variable_se(method, alt_ft, vs_fpm, descent_start_choice)
1823
  else:
1824
- legs, _report_md_hidden, start_dt = solve_guided_constant_speed(method, alt_ft, vs_fpm, descent_start_choice)
1825
 
1826
  if legs is None:
1827
  fig = go.Figure()
@@ -1841,6 +2000,7 @@ def run_guided(guided_mode, start_lat, start_lon, method, alt_ft, vs_fpm, descen
1841
  RADAR_FIX_LAT, RADAR_FIX_LON, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice
1842
  )
1843
 
 
1844
  return gr.update(value=legs_to_rows(legs)), "", html_map, stats_md, bfo_fig, alt_fig, speed_fig, gr.update(headers=headers, value=rows)
1845
 
1846
 
@@ -1884,10 +2044,7 @@ with gr.Blocks() as demo:
1884
  alt_ft = gr.Number(value=35000, label="Altitude at start (ft)")
1885
  vs_fpm = gr.Number(value=0, label="Vertical speed (ft/min) (negative = descent)")
1886
  descent_start_choice = gr.Dropdown(
1887
- [
1888
- "None (constant VS from start)",
1889
- "At 00:20Z only",
1890
- ],
1891
  value="None (constant VS from start)",
1892
  label="When does descent start? (VS applies after this time)",
1893
  )
@@ -1896,7 +2053,7 @@ with gr.Blocks() as demo:
1896
  guided_btn = gr.Button("Generate: Guided scenario")
1897
  manual_btn = gr.Button("Simulate: Manual legs")
1898
 
1899
- # Hidden status (so the UI no longer shows the long guided report / scoring text)
1900
  status_md = gr.Markdown(visible=False)
1901
 
1902
  out_map = gr.HTML()
 
13
  # - Guided mode computes a realistic turn between 18:22→18:40 and then southern path.
14
  # - Guided validation timestamps: 18:22, 18:40, 19:40, 20:40, 21:40, 22:40, 00:10, 00:20
15
  #
16
+ # THIS VERSION (UI cleanup + fixes):
17
+ # - Removes the big solver/bounds markdown from the UI (status panels hidden/blank)
18
+ # - Adds Speed Profile plot (kt vs time)
19
+ # - Descent option changed:
20
+ # * "None (constant VS from start)"
21
+ # * "At 00:20Z only"
22
+ # - Ensures BFO at 00:20Z is measured at 182 Hz (patch + guaranteed inclusion in BFO plot)
23
+ # - Restores waypoint hover info (tooltip now includes name + coords; popup added)
24
+ # - Adds location marker at 39°10'S, 88°18'E with hover label "Captain Byron Bailey"
25
 
26
  import os
27
  import re
 
59
  ("MEKAR", 6.5, 96.48333333),
60
  ]
61
 
62
+ # NEW: Reference marker requested
63
+ CAPT_BYRON_BAILEY_NAME = "Captain Byron Bailey"
64
+ CAPT_BYRON_BAILEY_LAT = -(39 + 10 / 60) # 39°10' S
65
+ CAPT_BYRON_BAILEY_LON = (88 + 18 / 60) # 88°18' E
66
+
67
+ # NEW: measured BFO at 00:20Z
68
+ MEASURED_BFO_0020_HZ = 182.0
69
 
70
  RING_TIMES = {
71
  "Ping Ring 1": "18:25:00Z",
 
151
  VAR_SMOOTH_PENALTY_US_PER_DEG = 1.5 # small penalty to avoid wiggles (still BTO-first)
152
 
153
  # ===== SE-bias-after-22:40 (new mode) =====
154
+ SE_EAST_DIVERGE_START_SEG_INDEX = 4 # seg 4 starts after 22:40
 
 
 
155
  SE_VAR_EAST_DELTA_SET = [-8, -4, -2, 0, 2] # prefer small decreases (eastward) after 22:40
156
  SE_EAST_BIAS_PENALTY_US_PER_DEG = 0.8 # small; BTO still dominates
157
 
 
509
  return fp._time_label_to_dt(s, fp.SAT_EPOCH_UTC)
510
 
511
 
512
+ def dt_ping6_utc() -> datetime:
513
+ return fp._time_label_to_dt("22:40:00", fp.SAT_EPOCH_UTC)
514
+
515
+
516
+ def dt_ping7_utc() -> datetime:
517
+ return fp._time_label_to_dt("24:10:00", fp.SAT_EPOCH_UTC)
518
+
519
+
520
+ def dt_0020_utc() -> datetime:
521
+ return fp._time_label_to_dt("24:20:00", fp.SAT_EPOCH_UTC)
522
+
523
+
524
+ # ================= PATCH satLoc: ensure measured BFO at 00:20 is 182 Hz =================
525
+ def _patch_measured_bfo_0020():
526
+ target_labels = {"24:20:00", "00:20:00"}
527
+ patched = False
528
+ try:
529
+ for row in fp.satLoc:
530
+ try:
531
+ lbl = fp._extract_time_string(row[0])
532
+ except Exception:
533
+ continue
534
+ if lbl in target_labels:
535
+ try:
536
+ row[SATLOC_IDX_MEAS_BFO] = float(MEASURED_BFO_0020_HZ)
537
+ patched = True
538
+ except Exception:
539
+ pass
540
+ except Exception:
541
+ return False
542
+ return patched
543
+
544
+
545
+ _ = _patch_measured_bfo_0020()
546
+
547
+
548
  # ================= ALTITUDE HELPERS =================
549
  def altitude_ft_at_dt(dt_ping: datetime, start_dt: datetime, alt0_ft: float,
550
  vs_fpm: float, descent_start_dt: datetime | None):
 
583
  return fig
584
 
585
 
586
+ # ================= SPEED PROFILE PLOT =================
587
  def build_speed_profile_plot(start_dt: datetime, legs: list[tuple[float, float, float]]):
588
+ # Step-like plot: speed held constant across each leg
589
+ xs = [0.0]
590
+ ys = []
591
+ cum_min = 0.0
592
+ for (_hdg, spd, mins) in legs:
 
593
  mins = float(mins)
594
+ spd = float(spd)
595
+ if mins <= 0:
596
+ continue
597
+ if not ys:
598
+ ys.append(spd)
599
+ else:
600
+ ys.append(ys[-1])
601
+ # extend step
602
+ xs.append(cum_min)
603
+ ys.append(spd)
604
+ cum_min += mins
605
+ xs.append(cum_min)
606
+ ys.append(spd)
607
+
608
+ if not ys:
609
+ fig = go.Figure()
610
+ fig.update_layout(title="Speed Profile", xaxis_title="Minutes since start", yaxis_title="Speed (kt)", height=320)
611
+ return fig
612
 
613
  fig = go.Figure()
614
+ fig.add_trace(go.Scatter(x=xs[1:], y=ys, mode="lines", name="Speed (kt)"))
615
  fig.update_layout(
616
  title="Speed Profile",
617
  xaxis_title="Minutes since start",
 
782
  return pts
783
 
784
 
785
+ def _descent_start_dt_from_choice(descent_start_choice: str) -> datetime | None:
786
+ ch = (descent_start_choice or "").strip()
787
+ if ch.startswith("At 00:20Z only"):
788
+ return dt_0020_utc()
789
+ if ch.startswith("None"):
790
+ return None
791
+ return None
792
+
793
+
794
  def bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice):
795
  pts = _ping_points_after(start_dt)
796
 
797
+ descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
 
 
798
 
799
  leg_secs = [float(m) * 60.0 for _, _, m in legs]
800
  boundaries = np.cumsum([0.0] + leg_secs)
 
804
 
805
  x_labels, measured, calculated = [], [], []
806
 
807
+ # Ensure 00:20 is included as a measured point in the plot if within window
808
+ dt_0020 = dt_0020_utc()
809
+ have_0020 = any((lbl.replace("Z", "") in {"24:20:00", "00:20:00"}) for (lbl, *_rest) in pts)
810
+ if (start_dt <= dt_0020 <= (start_dt + timedelta(seconds=total_end))) and not have_0020:
811
+ pts.append(("24:20:00", dt_0020, float(MEASURED_BFO_0020_HZ), None))
812
+
813
+ pts = sorted(pts, key=lambda x: x[1])
814
+
815
  for label, dt_ping, meas_bfo, _meas_bto in pts:
816
  t_sec = (dt_ping - start_dt).total_seconds()
817
  if t_sec < 0 or t_sec > total_end:
 
836
  spd_mps = float(spd) * 1852.0 / 3600.0
837
  pred_bfo = float(fp.calculateBFO_at_time(spd_mps, float(hdg), float(lat), float(lon), alt_m, vs_mps, dt_ping))
838
 
839
+ x_labels.append(label if label.endswith("Z") else (label + "Z"))
840
  measured.append(float(meas_bfo))
841
  calculated.append(float(pred_bfo))
842
 
 
866
  margin=dict(l=60, r=160, t=80, b=60),
867
  height=520,
868
  )
869
+
870
+ # UI request: remove "BFO scoring" markdown from UI
871
  return fig, ""
872
 
873
 
 
884
  start_lat, start_lon, method, start_dt, legs,
885
  alt_ft, vs_fpm, descent_start_choice
886
  ):
887
+ descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
 
 
888
 
889
  is_guided = (start_dt == RADAR_FIX_TIME_UTC)
890
 
 
923
 
924
  bfo_meas, bto_meas = _lookup_measured_at_dt(dt_k)
925
 
926
+ # Force measured BFO at 00:20 to 182 Hz
927
+ try:
928
+ if dt_k == dt_0020_utc():
929
+ bfo_meas = float(MEASURED_BFO_0020_HZ)
930
+ except Exception:
931
+ pass
932
+
933
  bfo_err = None if bfo_meas is None else (float(bfo_calc) - float(bfo_meas))
934
  bto_err = None if bto_meas is None else (float(bto_calc) - float(bto_meas))
935
 
 
970
 
971
 
972
  def add_permanent_label(m, name, lat, lon):
973
+ # Tooltip should contain waypoint info again (name + coords)
974
+ tip = f"{html.escape(name)}<br>Lat: {float(lat):.5f}<br>Lon: {float(lon):.5f}"
975
+ pop = folium.Popup(
976
+ f"<b>{html.escape(name)}</b><br>Lat: {float(lat):.5f}<br>Lon: {float(lon):.5f}",
977
+ max_width=260,
978
+ )
979
+
980
  folium.CircleMarker(
981
  location=(lat, lon),
982
  radius=4,
 
985
  fill=True,
986
  fill_color="white",
987
  fill_opacity=1.0,
988
+ tooltip=folium.Tooltip(tip, sticky=True),
989
+ popup=pop,
990
  ).add_to(m)
991
 
992
  label_html = f"""
 
997
  text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff;
998
  white-space: nowrap;
999
  transform: translate(8px, -12px);
1000
+ pointer-events: none;
1001
  ">{html.escape(name)}</div>
1002
  """
1003
  folium.Marker(location=(lat, lon), icon=folium.DivIcon(html=label_html)).add_to(m)
1004
 
1005
 
1006
+ def add_named_marker(m, name: str, lat: float, lon: float):
1007
+ tip = f"{html.escape(name)}<br>Lat: {float(lat):.5f}<br>Lon: {float(lon):.5f}"
1008
+ pop = folium.Popup(
1009
+ f"<b>{html.escape(name)}</b><br>Lat: {float(lat):.5f}<br>Lon: {float(lon):.5f}",
1010
+ max_width=260,
1011
+ )
1012
  folium.CircleMarker(
1013
  location=(float(lat), float(lon)),
1014
+ radius=6,
1015
+ color="white",
1016
+ weight=2,
1017
  fill=True,
1018
  fill_color="cyan",
1019
+ fill_opacity=0.9,
1020
+ tooltip=folium.Tooltip(tip, sticky=True),
1021
+ popup=pop,
 
 
 
1022
  ).add_to(m)
1023
 
1024
 
 
1146
  marker_pts_for_bounds.append((lat, lon))
1147
 
1148
  tlabel = _to_label_utc(dt_k)
1149
+ elapsed_min = st["elapsed_min"]
1150
+ hdg = st["heading_deg"]
1151
+ spd = st["speed_kt"]
1152
+ dist_km = st["dist_km"]
1153
 
1154
  is_final = (start_dt == RADAR_FIX_TIME_UTC) and (dt_k == _dt_from_label(GUIDED_FINAL_LABEL))
1155
 
1156
+ popup_lines = [
1157
+ f"<b>{'FINAL RESTING POINT' if is_final else 'Validation point'}</b>",
1158
+ f"UTC time: {tlabel}",
1159
+ f"Lat: {lat:.5f}",
1160
+ f"Lon: {lon:.5f}",
1161
+ f"Time elapsed: {elapsed_min:.1f} min",
1162
+ f"Heading: {hdg:.1f}°",
1163
+ f"Speed: {spd:.1f} kt",
1164
+ f"Distance flown: {dist_km:.2f} km",
1165
+ ]
1166
+
1167
  if is_final:
1168
  folium.CircleMarker(
1169
  location=(lat, lon),
 
1173
  fill_color="magenta",
1174
  fill_opacity=0.95,
1175
  tooltip=f"{tlabel} (FINAL)",
1176
+ popup=folium.Popup("<br>".join(popup_lines), max_width=360),
1177
  ).add_to(m)
1178
  else:
1179
  folium.CircleMarker(
 
1184
  fill_color="white",
1185
  fill_opacity=1.0,
1186
  tooltip=tlabel,
1187
+ popup=folium.Popup("<br>".join(popup_lines), max_width=360),
1188
  ).add_to(m)
1189
 
1190
+ # Waypoints
1191
  for name, lat, lon in WAYPOINTS:
1192
  add_permanent_label(m, name, float(lat), float(lon))
1193
 
1194
+ # Requested marker: Captain Byron Bailey
1195
+ add_named_marker(m, CAPT_BYRON_BAILEY_NAME, CAPT_BYRON_BAILEY_LAT, CAPT_BYRON_BAILEY_LON)
1196
 
1197
+ waypoint_pts = [(float(lat), float(lon)) for _, lat, lon in WAYPOINTS] + [(CAPT_BYRON_BAILEY_LAT, CAPT_BYRON_BAILEY_LON)]
1198
  all_ring_pts = []
1199
  for k in PING_RINGS:
1200
  all_ring_pts.extend(PING_RINGS.get(k, []))
1201
 
 
 
1202
  bounds = _all_bounds(
1203
  [path_pts]
1204
  + [waypoint_pts]
1205
  + [all_ring_pts]
 
1206
  + ([search_area_pts_for_bounds] if search_area_pts_for_bounds else [])
1207
  + ([marker_pts_for_bounds] if marker_pts_for_bounds else [])
1208
  )
 
1482
  start_dt = _guided_start_dt()
1483
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1484
 
1485
+ descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
 
 
1486
 
1487
  pts_all = _ping_points_after(start_dt)
1488
  pts_window = [(lbl, dt, bfo, bto) for (lbl, dt, bfo, bto) in pts_all if start_dt <= dt <= end_dt]
1489
  if len(pts_window) < 2:
1490
+ return None, "Guided solve: not enough ping points to score.", None
1491
 
1492
  enforced_labels = _available_hard_bto_labels()
1493
  if not enforced_labels:
1494
+ return None, "Guided solve: no measured BTO available at any enforced ring times.", None
1495
 
1496
  enforced_dts = [_dt_from_label(lbl) for lbl in enforced_labels]
1497
 
 
1556
  sum_abs = float(np.sum(abs_arr))
1557
  rmse = float(np.sqrt(np.mean(np.array(bto_errs, dtype=float) ** 2)))
1558
 
1559
+ details = {
1560
+ "h_south": float(h_south),
1561
+ "v_turn": float(v_turn),
1562
+ "south_speeds": [float(v) for v in south_speeds],
1563
+ "south_errs_us": [float(e) for e in south_errs],
1564
+ "enforced_labels": enforced_labels,
1565
+ "max_abs_err_us": max_abs,
1566
+ "sum_abs_err_us": sum_abs,
1567
+ "rmse_bto_us": rmse,
1568
+ }
1569
+ return (max_abs, sum_abs, rmse), full_legs, details
1570
 
1571
  def best_for_heading(h_south: float):
1572
  coarse_turns = list(np.arange(TURN_SPEED_MIN_KT, TURN_SPEED_MAX_KT + 1e-9, 5.0))
1573
  best_key = None
1574
  best_legs = None
1575
+ best_det = None
1576
 
1577
  for v in coarse_turns:
1578
  out = _evaluate(h_south, v, do_polish=False)
1579
  if out is None:
1580
  continue
1581
+ key, legs, det = out
1582
  if best_key is None or key < best_key:
1583
+ best_key, best_legs, best_det = key, legs, det
1584
 
1585
  if best_key is None:
1586
  return None
1587
+
1588
+ v0 = float(best_det["v_turn"])
1589
+ v_lo = _clamp(v0 - 7.5, TURN_SPEED_MIN_KT, TURN_SPEED_MAX_KT)
1590
+ v_hi = _clamp(v0 + 7.5, TURN_SPEED_MIN_KT, TURN_SPEED_MAX_KT)
1591
+
1592
+ def obj(v):
1593
+ out2 = _evaluate(h_south, v, do_polish=False)
1594
+ if out2 is None:
1595
+ return 1e9
1596
+ (max_abs, sum_abs, _rmse), _legs, _det = out2
1597
+ return float(max_abs) + POLISH_OBJ_EPS * float(sum_abs)
1598
+
1599
+ v_best, _ = _golden_min_abs(obj, v_lo, v_hi, iters=16)
1600
+ out3 = _evaluate(h_south, v_best, do_polish=False)
1601
+ if out3 is None:
1602
+ return best_key, best_legs, best_det
1603
+ return out3
1604
 
1605
  coarse_headings = _frange(GUIDED_HEADING_MIN, GUIDED_HEADING_MAX, COARSE_HEADING_STEP_DEG)
1606
+ candidates = []
1607
  best = None
1608
  best_legs = None
1609
+ best_det = None
1610
 
1611
  for h in coarse_headings:
1612
  out = best_for_heading(h)
1613
  if out is None:
1614
  continue
1615
+ key, legs, det = out
1616
+ candidates.append((key, float(det["h_south"]), float(det["v_turn"])))
1617
  if best is None or key < best:
1618
+ best, best_legs, best_det = key, legs, det
1619
 
1620
  if best_legs is None:
1621
+ return None, "Guided solve: no solution found within bounds.", None
1622
 
1623
+ h0 = float(best_det["h_south"])
1624
+ refine_headings = _refine_range(h0, REFINE_HEADING_HALF_WINDOW_DEG, REFINE_HEADING_STEP_DEG, GUIDED_HEADING_MIN, GUIDED_HEADING_MAX)
1625
+ for h in refine_headings:
1626
+ out = best_for_heading(h)
1627
+ if out is None:
1628
+ continue
1629
+ key, legs, det = out
1630
+ candidates.append((key, float(det["h_south"]), float(det["v_turn"])))
1631
+ if best is None or key < best:
1632
+ best, best_legs, best_det = key, legs, det
1633
+
1634
+ candidates_sorted = sorted(candidates, key=lambda x: x[0])
1635
+ uniq = []
1636
+ seen = set()
1637
+ for key, hh, vv in candidates_sorted:
1638
+ k2 = (round(hh, 4), round(vv, 4))
1639
+ if k2 in seen:
1640
+ continue
1641
+ seen.add(k2)
1642
+ uniq.append((key, hh, vv))
1643
+ if len(uniq) >= max(1, int(TOPK_POLISH)):
1644
+ break
1645
+
1646
+ best_final_key, best_final_legs, best_final_det = best, best_legs, best_det
1647
+ if ENABLE_POLISH_STRAIGHT and uniq:
1648
+ for _key, hh, vv in uniq:
1649
+ outp = _evaluate(hh, vv, do_polish=True)
1650
+ if outp is None:
1651
+ continue
1652
+ keyp, legsp, detp = outp
1653
+ if best_final_key is None or keyp < best_final_key:
1654
+ best_final_key, best_final_legs, best_final_det = keyp, legsp, detp
1655
+
1656
+ # UI request: remove solver report text from UI -> return empty report
1657
+ return best_final_legs, "", start_dt
1658
 
1659
 
1660
  # ================= Guided Solver: Variable heading & speed (FAST beam search) =================
 
1662
  start_dt = _guided_start_dt()
1663
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1664
 
1665
+ descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
 
 
1666
 
1667
  enforced_labels = _available_hard_bto_labels()
1668
  if not enforced_labels:
1669
+ return None, "Guided solve: no measured BTO available at any enforced ring times.", None
1670
  enforced_dts = [_dt_from_label(lbl) for lbl in enforced_labels]
1671
 
1672
  turn_speeds = list(np.arange(TURN_SPEED_MIN_KT, TURN_SPEED_MAX_KT + 1e-9, VAR_TURN_SPEED_STEP))
 
1717
 
1718
  for st in beam:
1719
  h_prev = float(st["h_prev"])
1720
+
1721
  cand_h = []
1722
  for d in delta_set:
1723
+ h_try = h_prev + float(d)
1724
+ h_try = _clamp(h_try, VAR_HEADING_MIN, VAR_HEADING_MAX)
1725
  cand_h.append(h_try)
1726
  cand_h = sorted(list({round(x, 6): x for x in cand_h}.values()))
1727
 
 
1795
  best_global_legs = full_legs
1796
 
1797
  if best_global_legs is None:
1798
+ return None, "Guided solve: Variable mode found no solution within bounds.", None
1799
 
1800
+ # UI request: remove solver report text from UI -> return empty report
1801
  return best_global_legs, "", start_dt
1802
 
1803
 
 
1814
  start_dt = _guided_start_dt()
1815
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1816
 
1817
+ descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
 
 
1818
 
1819
  enforced_labels = _available_hard_bto_labels()
1820
  if not enforced_labels:
1821
+ return None, "Guided solve: no measured BTO available at any enforced ring times.", None
1822
  enforced_dts = [_dt_from_label(lbl) for lbl in enforced_labels]
1823
 
1824
  turn_speeds = list(np.arange(TURN_SPEED_MIN_KT, TURN_SPEED_MAX_KT + 1e-9, VAR_TURN_SPEED_STEP))
 
1857
  "smooth_sum": 0.0,
1858
  }]
1859
 
1860
+ seg_i = -1
1861
  for mins, end_lbl in zip(GUIDED_SOUTH_SEG_MIN, GUIDED_SOUTH_SEG_LABELS_END):
1862
+ seg_i += 1
1863
  dt_end = _dt_from_label(end_lbl)
1864
  meas = _lookup_measured_bto(dt_end)
1865
  next_beam = []
1866
 
1867
  for st in beam:
1868
  h_prev = float(st["h_prev"])
1869
+
1870
  cand_h = []
1871
  for d in VAR_HEADING_DELTA_SET:
1872
  h_try = _clamp(h_prev + float(d), VAR_HEADING_MIN, VAR_HEADING_MAX)
 
1927
  best_global_legs = full_legs
1928
 
1929
  if best_global_legs is None:
1930
+ return None, "Guided solve: Constant-speed mode found no solution within bounds.", None
1931
 
1932
+ # UI request: remove solver report text from UI -> return empty report
1933
  return best_global_legs, "", start_dt
1934
 
1935
 
 
1944
  html_map = build_leaflet_map(path_pts, legs, method, start_dt)
1945
  stats_md = compute_stats(path_pts, seg_dists_m, total_time_min, legs)
1946
 
1947
+ bfo_fig, _bfo_md = bfo_analysis_with_legs(
1948
  start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice
1949
  )
1950
 
1951
+ descent_start_dt = _descent_start_dt_from_choice(descent_start_choice)
 
 
 
1952
  alt_fig = build_altitude_plot(start_dt, total_time_min, alt_ft, vs_fpm, descent_start_dt)
1953
+
1954
  speed_fig = build_speed_profile_plot(start_dt, legs)
1955
 
1956
  headers, rows = build_validation_table(
 
1967
  html_map, stats_md, bfo_fig, alt_fig, speed_fig, headers, rows = _run_common(
1968
  start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice
1969
  )
1970
+
1971
+ # UI cleanup: keep status blank; no BFO scoring markdown
1972
  return gr.update(), "", html_map, stats_md, bfo_fig, alt_fig, speed_fig, gr.update(headers=headers, value=rows)
1973
 
1974
 
1975
  def run_guided(guided_mode, start_lat, start_lon, method, alt_ft, vs_fpm, descent_start_choice):
1976
  if guided_mode == "Straight heading":
1977
+ legs, _report_md, start_dt = solve_guided_straight(method, alt_ft, vs_fpm, descent_start_choice)
1978
  elif guided_mode == "Variable heading & speed":
1979
+ legs, _report_md, start_dt = solve_guided_variable(method, alt_ft, vs_fpm, descent_start_choice)
1980
  elif guided_mode == "Variable heading & speed (SE after 22:40)":
1981
+ legs, _report_md, start_dt = solve_guided_variable_se(method, alt_ft, vs_fpm, descent_start_choice)
1982
  else:
1983
+ legs, _report_md, start_dt = solve_guided_constant_speed(method, alt_ft, vs_fpm, descent_start_choice)
1984
 
1985
  if legs is None:
1986
  fig = go.Figure()
 
2000
  RADAR_FIX_LAT, RADAR_FIX_LON, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice
2001
  )
2002
 
2003
+ # UI cleanup: keep status blank
2004
  return gr.update(value=legs_to_rows(legs)), "", html_map, stats_md, bfo_fig, alt_fig, speed_fig, gr.update(headers=headers, value=rows)
2005
 
2006
 
 
2044
  alt_ft = gr.Number(value=35000, label="Altitude at start (ft)")
2045
  vs_fpm = gr.Number(value=0, label="Vertical speed (ft/min) (negative = descent)")
2046
  descent_start_choice = gr.Dropdown(
2047
+ ["None (constant VS from start)", "At 00:20Z only"],
 
 
 
2048
  value="None (constant VS from start)",
2049
  label="When does descent start? (VS applies after this time)",
2050
  )
 
2053
  guided_btn = gr.Button("Generate: Guided scenario")
2054
  manual_btn = gr.Button("Simulate: Manual legs")
2055
 
2056
+ # UI cleanup: keep status hidden
2057
  status_md = gr.Markdown(visible=False)
2058
 
2059
  out_map = gr.HTML()