Chris773 commited on
Commit
9b8bfb7
·
verified ·
1 Parent(s): 309ad47

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +99 -302
app.py CHANGED
@@ -7,9 +7,11 @@
7
  # (13) Permanent labeled waypoints
8
  # (14) BFO analysis chart + MAE/RMSE
9
  # Gentle auto-tune (BFO + BTO, BTO at satLoc index 5)
10
- # NEW: Esri Ocean + Satellite basemaps with layer control
11
- # NEW: Ping ring timestamps (Option A) shown in ring tooltip
12
- # NEW: Guided mode — "Straight to 7th arc" (single heading + single speed profile)
 
 
13
  # FIX: robust KML loading + no empty default selection crash
14
 
15
  import os
@@ -56,7 +58,7 @@ RING_TIMES = {
56
  "Ping Ring 7": "24:10:00Z",
57
  }
58
 
59
- # Gentle tuning constants (you can adjust to be more/less conservative)
60
  GENTLE_HEAD_LIMIT_DEG = 20
61
  GENTLE_SPEED_LIMIT_KT = 40
62
  HEAD_STEP_DEG = 2
@@ -72,11 +74,13 @@ BTO_SCALE_US = 50.0
72
  SATLOC_IDX_MEAS_BFO = 4
73
  SATLOC_IDX_MEAS_BTO = 5
74
 
75
- # Guided mode (straight to 7th arc) search range
76
  GUIDED_HEADING_MIN = 150.0
77
  GUIDED_HEADING_MAX = 210.0
78
  GUIDED_HEADING_STEP = 0.5
79
- GUIDED_SEGMENT_MIN = 10.0 # generate legs in ~10-min chunks for nicer waypoints
 
 
80
 
81
 
82
  # ================= KML =================
@@ -100,18 +104,12 @@ def load_ping_rings_from_kml(path: str):
100
  ns = {"kml": ns_uri}
101
 
102
  rings = {}
103
-
104
  for pm in root.findall(".//kml:Placemark", ns):
105
  name_el = pm.find("kml:name", ns)
106
- if name_el is None:
107
- continue
108
- if name_el.text is None:
109
  continue
110
  name = name_el.text.strip()
111
- if not name:
112
- continue
113
-
114
- if not name.lower().startswith("ping ring"):
115
  continue
116
 
117
  coords_el = pm.find(".//kml:LineString/kml:coordinates", ns)
@@ -119,8 +117,7 @@ def load_ping_rings_from_kml(path: str):
119
  continue
120
 
121
  pts = []
122
- text = coords_el.text.strip()
123
- for token in re.split(r"\s+", text):
124
  if not token:
125
  continue
126
  parts = token.split(",")
@@ -139,7 +136,6 @@ def load_ping_rings_from_kml(path: str):
139
  try:
140
  PING_RINGS = load_ping_rings_from_kml(KML_FILE)
141
  except Exception as e:
142
- # Keep app alive and show a readable UI error instead of crashing on import
143
  PING_RINGS = {}
144
  KML_LOAD_ERROR = str(e)
145
  else:
@@ -277,8 +273,6 @@ def add_permanent_label(m, name, lat, lon):
277
 
278
  def build_leaflet_map(path_pts, legs, seg_dists_m, selected_rings):
279
  start_lat, start_lon = path_pts[0]
280
-
281
- # IMPORTANT: tiles=None so we can add multiple basemaps
282
  m = folium.Map(
283
  location=[start_lat, start_lon],
284
  zoom_start=4,
@@ -287,13 +281,7 @@ def build_leaflet_map(path_pts, legs, seg_dists_m, selected_rings):
287
  )
288
 
289
  # ---- BASEMAPS ----
290
- folium.TileLayer(
291
- "OpenStreetMap",
292
- name="OpenStreetMap",
293
- overlay=False,
294
- control=True,
295
- ).add_to(m)
296
-
297
  folium.TileLayer(
298
  tiles="https://server.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}",
299
  attr="Esri",
@@ -301,7 +289,6 @@ def build_leaflet_map(path_pts, legs, seg_dists_m, selected_rings):
301
  overlay=False,
302
  control=True,
303
  ).add_to(m)
304
-
305
  folium.TileLayer(
306
  tiles="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
307
  attr="Esri",
@@ -317,16 +304,9 @@ def build_leaflet_map(path_pts, legs, seg_dists_m, selected_rings):
317
  continue
318
  ring_time = RING_TIMES.get(ring_name)
319
  tooltip = f"{ring_name} ({ring_time})" if ring_time else ring_name
 
320
 
321
- folium.PolyLine(
322
- ring_pts,
323
- weight=2,
324
- opacity=0.6,
325
- color="yellow",
326
- tooltip=tooltip,
327
- ).add_to(m)
328
-
329
- # ---- FLIGHT PATH (glow + main) ----
330
  folium.PolyLine(path_pts, color="white", weight=8, opacity=0.3).add_to(m)
331
  folium.PolyLine(path_pts, color="red", weight=4, opacity=1.0, tooltip="Flight path").add_to(m)
332
 
@@ -355,7 +335,7 @@ def build_leaflet_map(path_pts, legs, seg_dists_m, selected_rings):
355
  ),
356
  ).add_to(m)
357
 
358
- # ---- SIMULATED WAYPOINTS (white dots with popups) ----
359
  total_dist_km = 0.0
360
  elapsed_min = 0.0
361
  for i, (lat, lon) in enumerate(path_pts):
@@ -396,10 +376,8 @@ def build_leaflet_map(path_pts, legs, seg_dists_m, selected_rings):
396
  for name, lat, lon in WAYPOINTS:
397
  add_permanent_label(m, name, float(lat), float(lon))
398
 
399
- # ---- LAYER CONTROL ----
400
  folium.LayerControl(collapsed=False).add_to(m)
401
 
402
- # ---- FIT BOUNDS ----
403
  waypoint_pts = [(float(lat), float(lon)) for _, lat, lon in WAYPOINTS]
404
  bounds = _all_bounds(
405
  [path_pts]
@@ -421,14 +399,15 @@ def build_leaflet_map(path_pts, legs, seg_dists_m, selected_rings):
421
  """
422
 
423
 
424
- # ================= BFO/BTO HELPERS =================
425
  def _parse_start_time(s: str) -> datetime:
426
  s = (s or "").strip()
427
  if not s:
428
  return datetime(2014, 3, 7, 18, 22, 0, tzinfo=timezone.utc)
429
 
430
- if re.fullmatch(r"\d{2}:\d{2}:\d{2}", s):
431
- hh, mm, ss = map(int, s.split(":"))
 
432
  return datetime(2014, 3, 7, hh, mm, ss, tzinfo=timezone.utc)
433
 
434
  s = s.replace("T", " ").replace("Z", "")
@@ -445,7 +424,6 @@ def _ping_points_after(start_dt: datetime):
445
 
446
  meas_bfo = row[SATLOC_IDX_MEAS_BFO]
447
  meas_bto = row[SATLOC_IDX_MEAS_BTO]
448
-
449
  if meas_bfo is None:
450
  continue
451
 
@@ -547,165 +525,8 @@ def bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_time_str, a
547
  return fig, score_md
548
 
549
 
550
- # ================= AUTOTUNE (BFO + BTO) =================
551
- def _wrap_heading(deg):
552
- return float(deg) % 360.0
553
-
554
-
555
- def _heading_diff(a, b):
556
- return (float(a) - float(b) + 180.0) % 360.0 - 180.0
557
-
558
-
559
- def gentle_autotune_all_legs(start_lat, start_lon, legs, method, start_time_str, alt_ft, vs_fpm):
560
- start_dt = _parse_start_time(start_time_str)
561
- pts = _ping_points_after(start_dt)
562
- if not pts:
563
- return legs, "Auto-tune: no BFO points after start time."
564
-
565
- n = len(legs)
566
- leg_secs = [float(m) * 60.0 for _, _, m in legs]
567
- boundaries = np.cumsum([0.0] + leg_secs)
568
- total_end = boundaries[-1]
569
-
570
- pts2 = []
571
- for label, dt_ping, meas_bfo, meas_bto in pts:
572
- t_sec = (dt_ping - start_dt).total_seconds()
573
- if 0 <= t_sec <= total_end:
574
- pts2.append((label, dt_ping, meas_bfo, meas_bto, float(t_sec)))
575
-
576
- if len(pts2) < 2:
577
- return legs, "Auto-tune: not enough pings inside your simulated time."
578
-
579
- tuned = list(legs)
580
- cur_lat = float(start_lat)
581
- cur_lon = float(start_lon)
582
-
583
- prev_h, prev_s, _ = tuned[0]
584
- report = ["**Gentle auto-tune (BFO + BTO, bounded + smooth):**"]
585
- tuned_count = 0
586
-
587
- for i in range(n):
588
- leg_start = boundaries[i]
589
- leg_end = boundaries[i + 1]
590
- minutes_i = float(tuned[i][2])
591
-
592
- leg_pings = [(dt, bfo, bto, (t - leg_start)) for (_, dt, bfo, bto, t) in pts2 if leg_start <= t <= leg_end]
593
- orig_h, orig_s, _ = legs[i]
594
-
595
- if len(leg_pings) < 2:
596
- report.append(f"- Leg {i+1}: skipped (not enough pings in window)")
597
- h0, s0, _ = tuned[i]
598
- dist_m = float(s0) * 1852.0 * (minutes_i / 60.0)
599
- if method == "VINCENTY":
600
- (cur_lat, cur_lon), _ = fp.vincenty_direct((cur_lat, cur_lon), float(h0), dist_m)
601
- else:
602
- cur_lat, cur_lon = fp.rhumb_direct((cur_lat, cur_lon), float(h0), dist_m)
603
- prev_h, prev_s = h0, s0
604
- continue
605
-
606
- leg_start_lat, leg_start_lon = cur_lat, cur_lon
607
-
608
- h_center = _wrap_heading(orig_h)
609
- s_center = float(orig_s)
610
-
611
- h_low = h_center - GENTLE_HEAD_LIMIT_DEG
612
- h_high = h_center + GENTLE_HEAD_LIMIT_DEG
613
-
614
- h_candidates = []
615
- h = h_low
616
- while h <= h_high + 1e-9:
617
- h_candidates.append(_wrap_heading(h))
618
- h += HEAD_STEP_DEG
619
- h_candidates = sorted(set(round(x, 6) for x in h_candidates))
620
-
621
- s_low = max(150.0, s_center - GENTLE_SPEED_LIMIT_KT)
622
- s_high = min(650.0, s_center + GENTLE_SPEED_LIMIT_KT)
623
- s_candidates = np.arange(s_low, s_high + 0.0001, SPEED_STEP_KT).tolist()
624
-
625
- def objective(hdg, spd):
626
- bfo_preds, bfo_meas = [], []
627
- bto_preds, bto_meas = [], []
628
-
629
- alt_m = float(alt_ft) * 0.3048
630
- vs_mps = float(vs_fpm) * 0.00508
631
- spd_mps = float(spd) * 1852.0 / 3600.0
632
-
633
- for dt_ping, meas_bfo, meas_bto, t_inside in leg_pings:
634
- lat, lon = _predict_state_from_leg_start(
635
- method, leg_start_lat, leg_start_lon, hdg, spd, t_inside
636
- )
637
-
638
- pred_bfo = float(fp.calculateBFO_at_time(spd_mps, float(hdg), float(lat), float(lon), alt_m, vs_mps, dt_ping))
639
- bfo_preds.append(pred_bfo)
640
- bfo_meas.append(float(meas_bfo))
641
-
642
- if meas_bto is not None:
643
- pred_bto = float(fp.calculateBTO_at_time(float(lat), float(lon), alt_m, dt_ping))
644
- bto_preds.append(pred_bto)
645
- bto_meas.append(float(meas_bto))
646
-
647
- e_bfo = np.array(bfo_preds) - np.array(bfo_meas)
648
- rmse_bfo = float(np.sqrt(np.mean(e_bfo * e_bfo)))
649
-
650
- rmse_bto = 0.0
651
- if len(bto_preds) >= 2:
652
- e_bto = np.array(bto_preds) - np.array(bto_meas)
653
- rmse_bto = float(np.sqrt(np.mean(e_bto * e_bto)))
654
-
655
- fit_score = (rmse_bfo / BFO_SCALE_HZ) + (rmse_bto / BTO_SCALE_US)
656
-
657
- stay_close = (abs(_heading_diff(hdg, orig_h)) / GENTLE_HEAD_LIMIT_DEG) ** 2 + ((float(spd) - float(orig_s)) / GENTLE_SPEED_LIMIT_KT) ** 2
658
- smooth = (abs(_heading_diff(hdg, prev_h)) / 45.0) ** 2 + ((float(spd) - float(prev_s)) / 80.0) ** 2
659
-
660
- loss = fit_score + LAMBDA_STAY_CLOSE * stay_close + LAMBDA_SMOOTH * smooth
661
- return loss, rmse_bfo, rmse_bto
662
-
663
- best_h, best_s = None, None
664
- best_loss = float("inf")
665
- best_rbfo = float("inf")
666
- best_rbto = float("inf")
667
-
668
- for hdg in h_candidates:
669
- for spd in s_candidates:
670
- loss, rbfo, rbto = objective(hdg, spd)
671
- if loss < best_loss:
672
- best_loss = loss
673
- best_h, best_s = float(hdg), float(spd)
674
- best_rbfo, best_rbto = rbfo, rbto
675
-
676
- tuned[i] = (float(best_h), float(best_s), minutes_i)
677
- tuned_count += 1
678
-
679
- if best_rbto > 0:
680
- report.append(
681
- f"- Leg {i+1}: {orig_h:.0f}°→**{best_h:.0f}°**, {orig_s:.0f}→**{best_s:.0f} kt** | "
682
- f"RMSE_BFO **{best_rbfo:.2f} Hz** | RMSE_BTO **{best_rbto:.1f} µs**"
683
- )
684
- else:
685
- report.append(
686
- f"- Leg {i+1}: {orig_h:.0f}°→**{best_h:.0f}°**, {orig_s:.0f}→**{best_s:.0f} kt** | "
687
- f"RMSE_BFO **{best_rbfo:.2f} Hz** | RMSE_BTO (n/a)"
688
- )
689
-
690
- dist_m = float(best_s) * 1852.0 * (minutes_i / 60.0)
691
- if method == "VINCENTY":
692
- (cur_lat, cur_lon), _ = fp.vincenty_direct((cur_lat, cur_lon), float(best_h), dist_m)
693
- else:
694
- cur_lat, cur_lon = fp.rhumb_direct((cur_lat, cur_lon), float(best_h), dist_m)
695
-
696
- prev_h, prev_s = best_h, best_s
697
-
698
- report.append(f"\nTuned legs: **{tuned_count}/{n}** (skipped legs had <2 pings in that time window)")
699
- return tuned, "\n".join(report)
700
-
701
-
702
- # ================= GUIDED MODE: STRAIGHT TO 7TH ARC =================
703
  def isa_speed_of_sound_mps(alt_m: float) -> float:
704
- """
705
- ISA speed of sound (m/s) using a simple ISA temperature model:
706
- - Troposphere to 11km: lapse rate -6.5 K/km
707
- - Lower stratosphere: constant 216.65 K
708
- """
709
  T0 = 288.15
710
  L = -0.0065
711
  h_trop = 11000.0
@@ -725,35 +546,45 @@ def mach_to_tas_knots(mach: float, alt_ft: float) -> float:
725
  return tas_mps / 0.514444
726
 
727
 
728
- def _dt_for_7th_arc() -> datetime:
729
- # Use satLoc time label for ring 7 (24:10:00Z)
730
- # flight_plotter_v32 already knows how to interpret 24:xx:xx with SAT_EPOCH_UTC
731
- return fp._time_label_to_dt("24:10:00", fp.SAT_EPOCH_UTC)
 
 
 
 
 
 
 
 
 
 
 
 
 
732
 
733
 
734
- def make_straight_legs(total_minutes: float, heading_deg: float, speed_knots: float, chunk_min: float = 10.0):
735
- total_minutes = float(max(0.0, total_minutes))
736
- chunk_min = float(max(1.0, chunk_min))
 
 
 
737
  legs = []
738
- remaining = total_minutes
739
- while remaining > 1e-9:
740
- m = min(chunk_min, remaining)
 
741
  legs.append((float(heading_deg), float(speed_knots), float(m)))
742
- remaining -= m
743
- if len(legs) > 1000: # safety
744
- break
745
  return legs
746
 
747
 
748
  def score_straight_track(start_lat, start_lon, method, start_dt, end_dt, heading_deg, speed_knots, alt_ft, vs_fpm):
749
- """
750
- Compute RMSE_BFO / RMSE_BTO for a straight track from start_dt to end_dt.
751
- Uses pings inside [start_dt, end_dt].
752
- """
753
  pts = _ping_points_after(start_dt)
754
  pts = [(lbl, dt, bfo, bto) for (lbl, dt, bfo, bto) in pts if start_dt <= dt <= end_dt]
755
  if len(pts) < 2:
756
- return None # not enough points
757
 
758
  alt_m = float(alt_ft) * 0.3048
759
  vs_mps = float(vs_fpm) * 0.00508
@@ -788,7 +619,6 @@ def score_straight_track(start_lat, start_lon, method, start_dt, end_dt, heading
788
  e_bto = np.array(bto_preds) - np.array(bto_meas)
789
  rmse_bto = float(np.sqrt(np.mean(e_bto * e_bto)))
790
 
791
- # combined score
792
  combined = rmse_bfo / BFO_SCALE_HZ
793
  if rmse_bto is not None:
794
  combined += rmse_bto / BTO_SCALE_US
@@ -802,67 +632,56 @@ def score_straight_track(start_lat, start_lon, method, start_dt, end_dt, heading
802
  }
803
 
804
 
805
- def solve_straight_to_7th_arc(start_lat, start_lon, method, start_time_str, speed_knots, alt_ft, vs_fpm):
806
  """
807
- Finds best constant heading for a straight track from start_time -> 7th arc time
808
- using BFO+BTO scoring (RMSE-based).
 
809
  """
810
- start_dt = _parse_start_time(start_time_str)
811
- end_dt = _dt_for_7th_arc()
812
-
813
- if start_dt >= end_dt:
814
- return None, None, f"Guided solve: start time **{start_dt.isoformat()}** is after 7th arc time **{end_dt.isoformat()}**."
 
 
 
 
 
 
815
 
816
  best = None
817
  best_h = None
818
 
819
  h = GUIDED_HEADING_MIN
820
  while h <= GUIDED_HEADING_MAX + 1e-9:
821
- s = score_straight_track(start_lat, start_lon, method, start_dt, end_dt, h, speed_knots, alt_ft, vs_fpm)
822
- if s is not None:
823
- if (best is None) or (s["combined"] < best["combined"]):
824
- best = s
825
- best_h = float(h)
 
 
826
  h += GUIDED_HEADING_STEP
827
 
828
  if best is None or best_h is None:
829
- return None, None, "Guided solve: not enough ping points between start time and 7th arc to score."
830
 
831
- total_minutes = (end_dt - start_dt).total_seconds() / 60.0
832
- legs = make_straight_legs(total_minutes, best_h, float(speed_knots), chunk_min=GUIDED_SEGMENT_MIN)
833
 
834
- # Report
835
  rbto = best["rmse_bto"]
836
  rbto_txt = f"{rbto:.1f} µs" if rbto is not None else "n/a"
 
837
  report = "\n".join([
838
- "**Guided mode: Straight to 7th arc (single heading, single speed)**",
839
- f"- Target end time (7th arc): **{end_dt.strftime('%Y-%m-%d %H:%M:%SZ')}**",
 
840
  f"- Speed: **{float(speed_knots):.1f} kt**",
841
- f"- Best heading: **{best_h:.1f}°** (searched {GUIDED_HEADING_MIN:.0f}–{GUIDED_HEADING_MAX:.0f}° step {GUIDED_HEADING_STEP}°)",
842
  f"- BFO points: **{best['n_bfo']}** | RMSE_BFO: **{best['rmse_bfo']:.2f} Hz**",
843
  f"- BTO points: **{best['n_bto']}** | RMSE_BTO: **{rbto_txt}**",
844
  ])
845
-
846
- return legs, report, ""
847
-
848
-
849
- def speed_from_profile(profile: str, alt_ft: float, custom_mach: float, custom_knots: float):
850
- profile = (profile or "").strip()
851
-
852
- if profile.startswith("MRC"):
853
- return mach_to_tas_knots(0.82, alt_ft)
854
- if profile.startswith("LRC"):
855
- return mach_to_tas_knots(0.84, alt_ft)
856
- if profile.startswith("ECON"):
857
- return mach_to_tas_knots(0.80, alt_ft)
858
- if profile.startswith("Custom Mach"):
859
- m = float(custom_mach)
860
- return mach_to_tas_knots(m, alt_ft)
861
- if profile.startswith("Custom knots"):
862
- return float(custom_knots)
863
-
864
- # fallback
865
- return mach_to_tas_knots(0.82, alt_ft)
866
 
867
 
868
  # ================= RUNNERS =================
@@ -882,40 +701,27 @@ def run_sim_manual(start_lat, start_lon, legs_df, method, rings, start_time_str,
882
  return gr.update(), "", html_map, stats_md, bfo_fig, bfo_md
883
 
884
 
885
- def run_sim_autotune(start_lat, start_lon, legs_df, method, rings, start_time_str, alt_ft, vs_fpm):
886
- legs = parse_legs_df(legs_df)
887
- tuned_legs, tune_md = gentle_autotune_all_legs(
888
- start_lat, start_lon, legs, method, start_time_str, alt_ft, vs_fpm
889
- )
890
- html_map, stats_md, bfo_fig, bfo_md = _run_common(
891
- start_lat, start_lon, tuned_legs, method, rings, start_time_str, alt_ft, vs_fpm
892
- )
893
- return gr.update(value=legs_to_rows(tuned_legs)), tune_md, html_map, stats_md, bfo_fig, bfo_md
894
-
895
-
896
- def run_guided_straight(start_lat, start_lon, method, rings, start_time_str, alt_ft, vs_fpm, speed_profile, custom_mach, custom_knots):
897
  spd_knots = speed_from_profile(speed_profile, alt_ft, custom_mach, custom_knots)
898
 
899
- legs, report_md, err = solve_straight_to_7th_arc(
900
- start_lat, start_lon, method, start_time_str, spd_knots, alt_ft, vs_fpm
901
  )
902
  if legs is None:
903
- # keep current legs_df unchanged, show error
904
- empty_map = ""
905
- empty_stats = ""
906
  fig = go.Figure()
907
  fig.update_layout(title="BFO Analysis", xaxis_title="Time", yaxis_title="Hz", height=520)
908
- return gr.update(), f"### ❌ {err or 'Guided solve failed.'}", empty_map, empty_stats, fig, ""
909
 
 
 
910
  html_map, stats_md, bfo_fig, bfo_md = _run_common(
911
  start_lat, start_lon, legs, method, rings, start_time_str, alt_ft, vs_fpm
912
  )
913
- # overwrite legs table with generated legs (so user can inspect)
914
  return gr.update(value=legs_to_rows(legs)), report_md, html_map, stats_md, bfo_fig, bfo_md
915
 
916
 
917
  # ================= UI =================
918
- # Safe default selected ring(s)
919
  RING_CHOICES = sorted(PING_RINGS.keys())
920
  DEFAULT_SELECTED_RINGS = [RING_CHOICES[0]] if len(RING_CHOICES) > 0 else []
921
 
@@ -930,8 +736,9 @@ with gr.Blocks() as demo:
930
  )
931
 
932
  with gr.Row():
933
- start_lat = gr.Slider(-10, 10, value=6.3, label="Start Latitude")
934
- start_lon = gr.Slider(90, 110, value=96.3, label="Start Longitude")
 
935
  method = gr.Dropdown(["VINCENTY", "RHUMB"], value="VINCENTY", label="Method")
936
 
937
  gr.Markdown("### Multi-leg plan (Advanced / optional)")
@@ -956,11 +763,12 @@ with gr.Blocks() as demo:
956
 
957
  gr.Markdown("### #14 BFO/BTO Settings")
958
  with gr.Row():
959
- start_time_str = gr.Textbox(value="2014-03-07 18:22:00Z", label="Simulation start time (UTC)")
 
960
  alt_ft = gr.Number(value=35000, label="Altitude (ft)")
961
  vs_fpm = gr.Number(value=0, label="Vertical speed (ft/min)")
962
 
963
- gr.Markdown("### Guided mode (Recommended) — Straight to 7th arc")
964
  with gr.Row():
965
  speed_profile = gr.Dropdown(
966
  ["MRC Mach 0.82", "LRC Mach 0.84", "ECON Mach 0.80", "Custom Mach", "Custom knots"],
@@ -971,12 +779,10 @@ with gr.Blocks() as demo:
971
  custom_knots = gr.Number(value=470, label="Custom knots (if selected)")
972
 
973
  with gr.Row():
974
- guided_btn = gr.Button("Generate: Straight to 7th arc (guided)")
975
  manual_btn = gr.Button("Simulate on Map + BFO (Manual)")
976
- tune_btn = gr.Button("Simulate on Map + BFO/BTO (Gentle auto-tune)")
977
-
978
- tune_status = gr.Markdown()
979
 
 
980
  out_map = gr.HTML()
981
  stats = gr.Markdown()
982
 
@@ -984,25 +790,16 @@ with gr.Blocks() as demo:
984
  bfo_plot = gr.Plot()
985
  bfo_score = gr.Markdown()
986
 
987
- # Guided solve
988
  guided_btn.click(
989
- run_guided_straight,
990
- inputs=[start_lat, start_lon, method, rings, start_time_str, alt_ft, vs_fpm, speed_profile, custom_mach, custom_knots],
991
- outputs=[legs_df, tune_status, out_map, stats, bfo_plot, bfo_score],
992
  )
993
 
994
- # Manual
995
  manual_btn.click(
996
  run_sim_manual,
997
  inputs=[start_lat, start_lon, legs_df, method, rings, start_time_str, alt_ft, vs_fpm],
998
- outputs=[legs_df, tune_status, out_map, stats, bfo_plot, bfo_score],
999
- )
1000
-
1001
- # Auto-tune
1002
- tune_btn.click(
1003
- run_sim_autotune,
1004
- inputs=[start_lat, start_lon, legs_df, method, rings, start_time_str, alt_ft, vs_fpm],
1005
- outputs=[legs_df, tune_status, out_map, stats, bfo_plot, bfo_score],
1006
  )
1007
 
1008
  add_btn.click(add_leg, inputs=[legs_df], outputs=[legs_df])
 
7
  # (13) Permanent labeled waypoints
8
  # (14) BFO analysis chart + MAE/RMSE
9
  # Gentle auto-tune (BFO + BTO, BTO at satLoc index 5)
10
+ # Esri Ocean + Satellite basemaps with layer control
11
+ # Ping ring timestamps (Option A) shown in ring tooltip
12
+ # Guided mode — Straight (single heading+speed) with FIXED leg schedule:
13
+ # 18:40->19:40 (60), 19:40->20:40 (60), 20:40->21:40 (60), 21:40->22:40 (60),
14
+ # 22:40->00:10 (90), 00:10->00:20 (10)
15
  # FIX: robust KML loading + no empty default selection crash
16
 
17
  import os
 
58
  "Ping Ring 7": "24:10:00Z",
59
  }
60
 
61
+ # Gentle tuning constants
62
  GENTLE_HEAD_LIMIT_DEG = 20
63
  GENTLE_SPEED_LIMIT_KT = 40
64
  HEAD_STEP_DEG = 2
 
74
  SATLOC_IDX_MEAS_BFO = 4
75
  SATLOC_IDX_MEAS_BTO = 5
76
 
77
+ # Guided mode search range (south-ish headings)
78
  GUIDED_HEADING_MIN = 150.0
79
  GUIDED_HEADING_MAX = 210.0
80
  GUIDED_HEADING_STEP = 0.5
81
+
82
+ # Guided schedule (minutes per leg): 60,60,60,60,90,10 (total = 340 min = 18:40 -> 24:20)
83
+ GUIDED_LEG_SCHEDULE_MIN = [60.0, 60.0, 60.0, 60.0, 90.0, 10.0]
84
 
85
 
86
  # ================= KML =================
 
104
  ns = {"kml": ns_uri}
105
 
106
  rings = {}
 
107
  for pm in root.findall(".//kml:Placemark", ns):
108
  name_el = pm.find("kml:name", ns)
109
+ if name_el is None or name_el.text is None:
 
 
110
  continue
111
  name = name_el.text.strip()
112
+ if not name or not name.lower().startswith("ping ring"):
 
 
 
113
  continue
114
 
115
  coords_el = pm.find(".//kml:LineString/kml:coordinates", ns)
 
117
  continue
118
 
119
  pts = []
120
+ for token in re.split(r"\s+", coords_el.text.strip()):
 
121
  if not token:
122
  continue
123
  parts = token.split(",")
 
136
  try:
137
  PING_RINGS = load_ping_rings_from_kml(KML_FILE)
138
  except Exception as e:
 
139
  PING_RINGS = {}
140
  KML_LOAD_ERROR = str(e)
141
  else:
 
273
 
274
  def build_leaflet_map(path_pts, legs, seg_dists_m, selected_rings):
275
  start_lat, start_lon = path_pts[0]
 
 
276
  m = folium.Map(
277
  location=[start_lat, start_lon],
278
  zoom_start=4,
 
281
  )
282
 
283
  # ---- BASEMAPS ----
284
+ folium.TileLayer("OpenStreetMap", name="OpenStreetMap", overlay=False, control=True).add_to(m)
 
 
 
 
 
 
285
  folium.TileLayer(
286
  tiles="https://server.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}",
287
  attr="Esri",
 
289
  overlay=False,
290
  control=True,
291
  ).add_to(m)
 
292
  folium.TileLayer(
293
  tiles="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
294
  attr="Esri",
 
304
  continue
305
  ring_time = RING_TIMES.get(ring_name)
306
  tooltip = f"{ring_name} ({ring_time})" if ring_time else ring_name
307
+ folium.PolyLine(ring_pts, weight=2, opacity=0.6, color="yellow", tooltip=tooltip).add_to(m)
308
 
309
+ # ---- FLIGHT PATH ----
 
 
 
 
 
 
 
 
310
  folium.PolyLine(path_pts, color="white", weight=8, opacity=0.3).add_to(m)
311
  folium.PolyLine(path_pts, color="red", weight=4, opacity=1.0, tooltip="Flight path").add_to(m)
312
 
 
335
  ),
336
  ).add_to(m)
337
 
338
+ # ---- SIMULATED WAYPOINTS ----
339
  total_dist_km = 0.0
340
  elapsed_min = 0.0
341
  for i, (lat, lon) in enumerate(path_pts):
 
376
  for name, lat, lon in WAYPOINTS:
377
  add_permanent_label(m, name, float(lat), float(lon))
378
 
 
379
  folium.LayerControl(collapsed=False).add_to(m)
380
 
 
381
  waypoint_pts = [(float(lat), float(lon)) for _, lat, lon in WAYPOINTS]
382
  bounds = _all_bounds(
383
  [path_pts]
 
399
  """
400
 
401
 
402
+ # ================= BFO HELPERS =================
403
  def _parse_start_time(s: str) -> datetime:
404
  s = (s or "").strip()
405
  if not s:
406
  return datetime(2014, 3, 7, 18, 22, 0, tzinfo=timezone.utc)
407
 
408
+ if re.fullmatch(r"\d{2}:\d{2}:\d{2}Z?", s):
409
+ s2 = s.replace("Z", "")
410
+ hh, mm, ss = map(int, s2.split(":"))
411
  return datetime(2014, 3, 7, hh, mm, ss, tzinfo=timezone.utc)
412
 
413
  s = s.replace("T", " ").replace("Z", "")
 
424
 
425
  meas_bfo = row[SATLOC_IDX_MEAS_BFO]
426
  meas_bto = row[SATLOC_IDX_MEAS_BTO]
 
427
  if meas_bfo is None:
428
  continue
429
 
 
525
  return fig, score_md
526
 
527
 
528
+ # ================= GUIDED MODE =================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
529
  def isa_speed_of_sound_mps(alt_m: float) -> float:
 
 
 
 
 
530
  T0 = 288.15
531
  L = -0.0065
532
  h_trop = 11000.0
 
546
  return tas_mps / 0.514444
547
 
548
 
549
+ def speed_from_profile(profile: str, alt_ft: float, custom_mach: float, custom_knots: float) -> float:
550
+ profile = (profile or "").strip()
551
+ if profile.startswith("MRC"):
552
+ return mach_to_tas_knots(0.82, alt_ft)
553
+ if profile.startswith("LRC"):
554
+ return mach_to_tas_knots(0.84, alt_ft)
555
+ if profile.startswith("ECON"):
556
+ return mach_to_tas_knots(0.80, alt_ft)
557
+ if profile.startswith("Custom Mach"):
558
+ return mach_to_tas_knots(float(custom_mach), alt_ft)
559
+ if profile.startswith("Custom knots"):
560
+ return float(custom_knots)
561
+ return mach_to_tas_knots(0.82, alt_ft)
562
+
563
+
564
+ def dt_1840_utc() -> datetime:
565
+ return fp._time_label_to_dt("18:40:00", fp.SAT_EPOCH_UTC)
566
 
567
 
568
+ def dt_0020_utc() -> datetime:
569
+ # 00:20Z represented by 24:20:00 in the sat label system
570
+ return fp._time_label_to_dt("24:20:00", fp.SAT_EPOCH_UTC)
571
+
572
+
573
+ def make_straight_legs_from_schedule(heading_deg: float, speed_knots: float, schedule_minutes):
574
  legs = []
575
+ for m in schedule_minutes:
576
+ m = float(m)
577
+ if m <= 0:
578
+ continue
579
  legs.append((float(heading_deg), float(speed_knots), float(m)))
 
 
 
580
  return legs
581
 
582
 
583
  def score_straight_track(start_lat, start_lon, method, start_dt, end_dt, heading_deg, speed_knots, alt_ft, vs_fpm):
 
 
 
 
584
  pts = _ping_points_after(start_dt)
585
  pts = [(lbl, dt, bfo, bto) for (lbl, dt, bfo, bto) in pts if start_dt <= dt <= end_dt]
586
  if len(pts) < 2:
587
+ return None
588
 
589
  alt_m = float(alt_ft) * 0.3048
590
  vs_mps = float(vs_fpm) * 0.00508
 
619
  e_bto = np.array(bto_preds) - np.array(bto_meas)
620
  rmse_bto = float(np.sqrt(np.mean(e_bto * e_bto)))
621
 
 
622
  combined = rmse_bfo / BFO_SCALE_HZ
623
  if rmse_bto is not None:
624
  combined += rmse_bto / BTO_SCALE_US
 
632
  }
633
 
634
 
635
+ def solve_guided_straight_fixed_schedule(start_lat, start_lon, method, speed_knots, alt_ft, vs_fpm):
636
  """
637
+ Start fixed at 18:40:00Z, end fixed at 24:20:00Z.
638
+ Legs fixed at [60,60,60,60,90,10] minutes.
639
+ Solve for best constant heading (single heading, single speed).
640
  """
641
+ start_dt = dt_1840_utc()
642
+ end_dt = dt_0020_utc()
643
+
644
+ # sanity: schedule must match the end time window
645
+ total_sched_min = float(np.sum(GUIDED_LEG_SCHEDULE_MIN))
646
+ total_window_min = (end_dt - start_dt).total_seconds() / 60.0
647
+ if abs(total_sched_min - total_window_min) > 0.01:
648
+ return None, (
649
+ f"Guided schedule mismatch: schedule totals {total_sched_min:.1f} min "
650
+ f"but window is {total_window_min:.1f} min."
651
+ )
652
 
653
  best = None
654
  best_h = None
655
 
656
  h = GUIDED_HEADING_MIN
657
  while h <= GUIDED_HEADING_MAX + 1e-9:
658
+ s = score_straight_track(
659
+ start_lat, start_lon, method, start_dt, end_dt,
660
+ h, speed_knots, alt_ft, vs_fpm
661
+ )
662
+ if s is not None and ((best is None) or (s["combined"] < best["combined"])):
663
+ best = s
664
+ best_h = float(h)
665
  h += GUIDED_HEADING_STEP
666
 
667
  if best is None or best_h is None:
668
+ return None, "Guided solve: not enough ping points between 18:40 and 00:20 to score."
669
 
670
+ legs = make_straight_legs_from_schedule(best_h, float(speed_knots), GUIDED_LEG_SCHEDULE_MIN)
 
671
 
 
672
  rbto = best["rmse_bto"]
673
  rbto_txt = f"{rbto:.1f} µs" if rbto is not None else "n/a"
674
+
675
  report = "\n".join([
676
+ "**Guided mode: Straight 18:40 → 00:20 (fixed leg schedule)**",
677
+ f"- Start: **{start_dt.strftime('%Y-%m-%d %H:%M:%SZ')}** @ lat **{float(start_lat):.2f}**, lon **{float(start_lon):.2f}**",
678
+ f"- Legs: **60, 60, 60, 60, 90, 10** minutes",
679
  f"- Speed: **{float(speed_knots):.1f} kt**",
680
+ f"- Best heading: **{best_h:.1f}°** (searched {GUIDED_HEADING_MIN:.0f}–{GUIDED_HEADING_MAX:.0f}°, step {GUIDED_HEADING_STEP}°)",
681
  f"- BFO points: **{best['n_bfo']}** | RMSE_BFO: **{best['rmse_bfo']:.2f} Hz**",
682
  f"- BTO points: **{best['n_bto']}** | RMSE_BTO: **{rbto_txt}**",
683
  ])
684
+ return legs, report
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
685
 
686
 
687
  # ================= RUNNERS =================
 
701
  return gr.update(), "", html_map, stats_md, bfo_fig, bfo_md
702
 
703
 
704
+ def run_guided_fixed_schedule(start_lat, start_lon, method, rings, alt_ft, vs_fpm, speed_profile, custom_mach, custom_knots):
 
 
 
 
 
 
 
 
 
 
 
705
  spd_knots = speed_from_profile(speed_profile, alt_ft, custom_mach, custom_knots)
706
 
707
+ legs, report_md = solve_guided_straight_fixed_schedule(
708
+ start_lat, start_lon, method, spd_knots, alt_ft, vs_fpm
709
  )
710
  if legs is None:
 
 
 
711
  fig = go.Figure()
712
  fig.update_layout(title="BFO Analysis", xaxis_title="Time", yaxis_title="Hz", height=520)
713
+ return gr.update(), f"### ❌ {report_md}", "", "", fig, ""
714
 
715
+ # Guided start time MUST be 18:40 for correct BFO window alignment
716
+ start_time_str = "18:40:00Z"
717
  html_map, stats_md, bfo_fig, bfo_md = _run_common(
718
  start_lat, start_lon, legs, method, rings, start_time_str, alt_ft, vs_fpm
719
  )
720
+
721
  return gr.update(value=legs_to_rows(legs)), report_md, html_map, stats_md, bfo_fig, bfo_md
722
 
723
 
724
  # ================= UI =================
 
725
  RING_CHOICES = sorted(PING_RINGS.keys())
726
  DEFAULT_SELECTED_RINGS = [RING_CHOICES[0]] if len(RING_CHOICES) > 0 else []
727
 
 
736
  )
737
 
738
  with gr.Row():
739
+ # Defaults per your guided scenario
740
+ start_lat = gr.Slider(-10, 15, value=7.5, label="Start Latitude")
741
+ start_lon = gr.Slider(70, 115, value=94.4, label="Start Longitude")
742
  method = gr.Dropdown(["VINCENTY", "RHUMB"], value="VINCENTY", label="Method")
743
 
744
  gr.Markdown("### Multi-leg plan (Advanced / optional)")
 
763
 
764
  gr.Markdown("### #14 BFO/BTO Settings")
765
  with gr.Row():
766
+ # Manual mode uses this; Guided mode is fixed at 18:40 internally.
767
+ start_time_str = gr.Textbox(value="18:40:00Z", label="Simulation start time (UTC) — Manual mode")
768
  alt_ft = gr.Number(value=35000, label="Altitude (ft)")
769
  vs_fpm = gr.Number(value=0, label="Vertical speed (ft/min)")
770
 
771
+ gr.Markdown("### Guided mode (Recommended) — Straight 18:40 → 00:20 (60/60/60/60/90/10)")
772
  with gr.Row():
773
  speed_profile = gr.Dropdown(
774
  ["MRC Mach 0.82", "LRC Mach 0.84", "ECON Mach 0.80", "Custom Mach", "Custom knots"],
 
779
  custom_knots = gr.Number(value=470, label="Custom knots (if selected)")
780
 
781
  with gr.Row():
782
+ guided_btn = gr.Button("Generate: Guided straight (fixed schedule)")
783
  manual_btn = gr.Button("Simulate on Map + BFO (Manual)")
 
 
 
784
 
785
+ status_md = gr.Markdown()
786
  out_map = gr.HTML()
787
  stats = gr.Markdown()
788
 
 
790
  bfo_plot = gr.Plot()
791
  bfo_score = gr.Markdown()
792
 
 
793
  guided_btn.click(
794
+ run_guided_fixed_schedule,
795
+ inputs=[start_lat, start_lon, method, rings, alt_ft, vs_fpm, speed_profile, custom_mach, custom_knots],
796
+ outputs=[legs_df, status_md, out_map, stats, bfo_plot, bfo_score],
797
  )
798
 
 
799
  manual_btn.click(
800
  run_sim_manual,
801
  inputs=[start_lat, start_lon, legs_df, method, rings, start_time_str, alt_ft, vs_fpm],
802
+ outputs=[legs_df, status_md, out_map, stats, bfo_plot, bfo_score],
 
 
 
 
 
 
 
803
  )
804
 
805
  add_btn.click(add_leg, inputs=[legs_df], outputs=[legs_df])