Chris773 commited on
Commit
dd0f110
·
verified ·
1 Parent(s): 6226e1a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +20 -327
app.py CHANGED
@@ -8,25 +8,25 @@
8
  # (14) BFO analysis chart + MAE/RMSE
9
  # Guided mode scenarios:
10
  # A) Straight (single heading+speed) with FIXED schedule
11
- # A2) NEW: Straight (nominal heading) + small per-leg drift (±deg) with penalty
12
- # B) Turn after Ping Ring 6 (two headings: legs 1-5, legs 6-7)
13
- # C) Radar fix → user-chosen turn completion point (by 18:28Z) → southern leg solve (kept; you can ignore)
 
14
  # NEW: Altitude vs Time chart (visualize descent profile)
15
  # NEW: Validation table at schedule timestamps (lat/lon/hdg/spd + BFO/BTO measured vs calculated + errors)
16
  # NEW: Explicit weighting between BFO and BTO in guided solver (to emphasize BTO)
17
  # NEW: Heading-drift penalty (keeps drift small + defensible)
18
- # Esri Ocean + Satellite basemaps with layer control
19
- # Ping ring timestamps (Option A) shown in ring tooltip
20
- # FIX: robust KML loading + no empty default selection crash
21
  #
22
  # === YOUR REQUESTED CHANGES (IMPLEMENTED) ===
23
  # 1) Overlay "Search areas.kmz" on the map (PERMANENT, always shown)
24
- # - Supports Points + LineStrings + Polygons (and MultiGeometry variants)
25
- # - Tries /mnt/data/ automatically if file not found next to app.py
26
  # 2) Make ALL ping rings permanent (always shown)
27
  # 3) Remove ring toggles (no CheckboxGroup)
28
  # 4) Remove LayerControl entirely
29
- # 5) NEW: Color-code search items by name (Ocean Infinity / Australia / Malaysia / China / etc.)
 
 
 
 
30
 
31
  import os
32
  import re
@@ -99,26 +99,15 @@ GUIDED_LEG_SCHEDULE_MIN = [12.0, 60.0, 60.0, 60.0, 60.0, 90.0, 10.0] # total 35
99
 
100
  # Guided scenario-specific search resolution
101
  STRAIGHT_STEP_DEG = 0.5
102
- TURN_H1_STEP_DEG = 1.0
103
- TURN_DELTA_STEP_DEG = 1.0
104
- TURN_DELTA_MAX_DEG = 10.0 # heading2 = heading1 +/- up to 10 degrees
105
-
106
- # Optional: speed search around profile (kept for future extension)
107
- SPEED_SEARCH_PLUS_MINUS_KT = 15.0
108
- SPEED_SEARCH_STEP_KT = 5.0
109
 
110
- # ================= HEADING DRIFT (NEW) =================
111
- # We allow small per-leg deviations around a nominal heading, but add a penalty
112
- # so it doesn't "free-run" into overfitting.
113
  DEFAULT_DRIFT_MAX_DEG = 2.0 # typical: 1–2 degrees
114
  DRIFT_STEP_DEG = 0.5 # discrete search step for deltas
115
  DEFAULT_WEIGHT_DRIFT = 0.30 # penalty strength in "combined score" units
116
  DEFAULT_DRIFT_ITERS = 2 # coordinate descent passes
117
  DRIFT_SCALE_DEG = 1.0 # normalization for drift penalty (rms_deg / scale)
118
 
119
- # ================= SEARCH AREA COLORS (NEW) =================
120
- # Match is case-insensitive and substring-based.
121
- # Example: "Ocean Infinity Search 2018" will match "Ocean Infinity".
122
  SEARCH_AREA_COLORS = {
123
  "Ocean Infinity": "#ff3b3b", # red
124
  "Australia": "#1f77b4", # blue
@@ -129,16 +118,6 @@ SEARCH_AREA_COLORS = {
129
  }
130
  DEFAULT_SEARCH_COLOR = "#00ffff" # fallback cyan
131
 
132
- # ================= RADAR FIX (LAST KNOWN PRIMARY RADAR POINT) =================
133
- # You provided:
134
- # Start: 2014-03-07 18:22:12Z @ 6.578°N, 96.341°E, FL350
135
- RADAR_FIX_LAT = 6.578
136
- RADAR_FIX_LON = 96.341
137
- RADAR_FIX_TIME_UTC = datetime(2014, 3, 7, 18, 22, 12, tzinfo=timezone.utc)
138
-
139
- # Turn completion must be NO LATER than 18:28Z (straight after this)
140
- TURN_COMPLETE_TIME_UTC = datetime(2014, 3, 7, 18, 28, 0, tzinfo=timezone.utc)
141
-
142
 
143
  # ================= PATH RESOLUTION =================
144
  def resolve_local_or_mnt(path_like: str) -> str:
@@ -154,28 +133,23 @@ def resolve_local_or_mnt(path_like: str) -> str:
154
  if not p:
155
  return p
156
 
157
- # 1) as given
158
  if os.path.exists(p):
159
  return p
160
 
161
- # 2) relative to this file
162
  here = os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals() else os.getcwd()
163
  p2 = os.path.join(here, p)
164
  if os.path.exists(p2):
165
  return p2
166
 
167
- # 3) /mnt/data/filename
168
  base = os.path.basename(p)
169
  p3 = os.path.join("/mnt/data", base)
170
  if os.path.exists(p3):
171
  return p3
172
 
173
- # 4) /mnt/data/<path_like>
174
  p4 = os.path.join("/mnt/data", p)
175
  if os.path.exists(p4):
176
  return p4
177
 
178
- # fallback: return original (so error message shows expected name)
179
  return p
180
 
181
 
@@ -251,10 +225,6 @@ def _kml_ns_from_root(root):
251
 
252
 
253
  def _parse_kml_coordinates_text(txt: str):
254
- """
255
- Parse KML coordinates into [(lat, lon), ...]
256
- Accepts lon,lat or lon,lat,alt.
257
- """
258
  pts = []
259
  if not txt:
260
  return pts
@@ -277,14 +247,6 @@ def load_search_areas_from_kmz(kmz_path: str):
277
  - Placemark -> LineString
278
  - Placemark -> Polygon
279
  - MultiGeometry versions of the above
280
-
281
- Returns list of dicts:
282
- {
283
- "name": str,
284
- "points": [(lat,lon), ...],
285
- "lines": [ [(lat,lon), ...], ... ],
286
- "polygons":[ [(lat,lon), ...], ... ]
287
- }
288
  """
289
  kmz_path = resolve_local_or_mnt(kmz_path)
290
 
@@ -315,7 +277,6 @@ def load_search_areas_from_kmz(kmz_path: str):
315
  lines = []
316
  polys = []
317
 
318
- # Points
319
  for pt in pm.findall(".//kml:Point", ns):
320
  coords = pt.find(".//kml:coordinates", ns)
321
  if coords is None or coords.text is None:
@@ -324,7 +285,6 @@ def load_search_areas_from_kmz(kmz_path: str):
324
  if pts:
325
  points.extend(pts[:1])
326
 
327
- # LineStrings
328
  for ls in pm.findall(".//kml:LineString", ns):
329
  coords = ls.find(".//kml:coordinates", ns)
330
  if coords is None or coords.text is None:
@@ -333,7 +293,6 @@ def load_search_areas_from_kmz(kmz_path: str):
333
  if len(pts) >= 2:
334
  lines.append(pts)
335
 
336
- # Polygons
337
  for poly in pm.findall(".//kml:Polygon", ns):
338
  outer = poly.find(".//kml:outerBoundaryIs/kml:LinearRing/kml:coordinates", ns)
339
  if outer is None or outer.text is None:
@@ -490,13 +449,6 @@ def add_permanent_label(m, name, lat, lon):
490
 
491
 
492
  def build_leaflet_map(path_pts, legs, seg_dists_m):
493
- """
494
- UPDATED:
495
- - Ping rings are permanent (all rings drawn)
496
- - Search areas KMZ is permanent (Points + LineStrings + Polygons)
497
- - Search areas are color-coded by placemark name (Ocean Infinity / Australia / Malaysia / China / etc.)
498
- - No LayerControl
499
- """
500
  start_lat, start_lon = path_pts[0]
501
  m = folium.Map(
502
  location=[start_lat, start_lon],
@@ -505,7 +457,6 @@ def build_leaflet_map(path_pts, legs, seg_dists_m):
505
  control_scale=True,
506
  )
507
 
508
- # Basemaps (kept; without LayerControl user won't toggle, but you requested removing LayerControl)
509
  folium.TileLayer("OpenStreetMap", name="OpenStreetMap", overlay=False, control=True).add_to(m)
510
  folium.TileLayer(
511
  tiles="https://server.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}",
@@ -528,7 +479,6 @@ def build_leaflet_map(path_pts, legs, seg_dists_m):
528
  nm = area.get("name", "Search area")
529
  col = color_for_search_area(nm)
530
 
531
- # Points (e.g., hotspots)
532
  for (la, lo) in area.get("points", []) or []:
533
  search_area_pts_for_bounds.append((la, lo))
534
  folium.CircleMarker(
@@ -542,7 +492,6 @@ def build_leaflet_map(path_pts, legs, seg_dists_m):
542
  tooltip=nm,
543
  ).add_to(m)
544
 
545
- # LineStrings (e.g., proposed search outlines, arcs)
546
  for line in area.get("lines", []) or []:
547
  if not line:
548
  continue
@@ -555,7 +504,6 @@ def build_leaflet_map(path_pts, legs, seg_dists_m):
555
  tooltip=nm,
556
  ).add_to(m)
557
 
558
- # Polygons (if any)
559
  for poly in area.get("polygons", []) or []:
560
  if not poly:
561
  continue
@@ -650,8 +598,6 @@ def build_leaflet_map(path_pts, legs, seg_dists_m):
650
  for name, lat, lon in WAYPOINTS:
651
  add_permanent_label(m, name, float(lat), float(lon))
652
 
653
- # NO LayerControl (removed)
654
-
655
  waypoint_pts = [(float(lat), float(lon)) for _, lat, lon in WAYPOINTS]
656
  all_ring_pts = []
657
  for k in PING_RINGS:
@@ -660,7 +606,7 @@ def build_leaflet_map(path_pts, legs, seg_dists_m):
660
  bounds = _all_bounds(
661
  [path_pts]
662
  + [waypoint_pts]
663
- + [all_ring_pts] # all rings
664
  + ([search_area_pts_for_bounds] if search_area_pts_for_bounds else [])
665
  )
666
  if bounds:
@@ -777,63 +723,7 @@ def _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg_deg,
777
  return float(lat), float(lon)
778
 
779
 
780
- def _wrap_heading_deg(h: float) -> float:
781
- return float(h) % 360.0
782
-
783
-
784
- def _deg2rad(x: float) -> float:
785
- return float(x) * math.pi / 180.0
786
-
787
-
788
- def _rad2deg(x: float) -> float:
789
- return float(x) * 180.0 / math.pi
790
-
791
-
792
- def gc_distance_m_and_bearing_deg(lat1, lon1, lat2, lon2):
793
- """
794
- Great-circle (spherical) distance + initial bearing.
795
- Used to compute the radar-fix turn leg parameters robustly without needing inverse routines in fp.
796
- """
797
- R = 6371000.0
798
- phi1 = _deg2rad(lat1)
799
- phi2 = _deg2rad(lat2)
800
- dphi = _deg2rad(lat2 - lat1)
801
- dlmb = _deg2rad(lon2 - lon1)
802
-
803
- a = math.sin(dphi / 2.0) ** 2 + math.cos(phi1) * math.cos(phi2) * (math.sin(dlmb / 2.0) ** 2)
804
- c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(max(0.0, 1.0 - a)))
805
- dist_m = R * c
806
-
807
- y = math.sin(dlmb) * math.cos(phi2)
808
- x = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(dlmb)
809
- brng = (math.atan2(y, x) + 2.0 * math.pi) % (2.0 * math.pi)
810
- bearing_deg = _rad2deg(brng)
811
- return float(dist_m), float(bearing_deg)
812
-
813
-
814
- def compute_turn_leg_from_radar_fix(turn_lat, turn_lon, method):
815
- """
816
- Compute a single straight leg from the radar fix (18:22:12Z) to the user-selected
817
- turn completion point at 18:28:00Z.
818
- Returns (heading_deg, speed_knots, minutes, distance_km).
819
- """
820
- dt_sec = (TURN_COMPLETE_TIME_UTC - RADAR_FIX_TIME_UTC).total_seconds()
821
- if dt_sec <= 0:
822
- return None
823
-
824
- dist_m, bearing_deg = gc_distance_m_and_bearing_deg(
825
- float(RADAR_FIX_LAT), float(RADAR_FIX_LON),
826
- float(turn_lat), float(turn_lon)
827
- )
828
-
829
- speed_mps = dist_m / dt_sec
830
- speed_knots = speed_mps / 0.514444
831
- minutes = dt_sec / 60.0
832
-
833
- return float(bearing_deg), float(speed_knots), float(minutes), float(dist_m / 1000.0)
834
-
835
-
836
- # ================= BFO ANALYSIS (with descent profile) =================
837
  def bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_time_str, alt_ft, vs_fpm, descent_start_choice):
838
  start_dt = _parse_start_time(start_time_str)
839
  pts = _ping_points_after(start_dt)
@@ -956,7 +846,7 @@ def speed_from_profile(profile: str, alt_ft: float, custom_mach: float, custom_k
956
  return mach_to_tas_knots(0.82, alt_ft)
957
 
958
 
959
- # ================= GUIDED SCORING (general legs) =================
960
  def make_legs_from_schedule(headings, speed_knots, schedule_minutes):
961
  legs = []
962
  if isinstance(headings, (int, float)):
@@ -1080,6 +970,10 @@ def score_legs_bfo_bto_with_drift(
1080
  return s
1081
 
1082
 
 
 
 
 
1083
  def _apply_base_plus_deltas(base_headings, deltas_deg):
1084
  headings = []
1085
  for b, d in zip(base_headings, deltas_deg):
@@ -1105,7 +999,6 @@ def _coordinate_descent_deltas(
1105
  best_s = None
1106
  best_d = deltas[:]
1107
 
1108
- # simple coordinate descent
1109
  for _it in range(max(1, int(iters))):
1110
  improved = False
1111
  for j in range(n):
@@ -1131,7 +1024,6 @@ def _coordinate_descent_deltas(
1131
 
1132
  deltas[j] = best_local_dj
1133
 
1134
- # track best overall
1135
  if best_local_s is not None and (best_s is None or best_local_s["combined"] < best_s["combined"]):
1136
  best_s = best_local_s
1137
  best_d = deltas[:]
@@ -1140,7 +1032,6 @@ def _coordinate_descent_deltas(
1140
  if not improved:
1141
  break
1142
 
1143
- # final score (ensure consistency)
1144
  final_headings = _apply_base_plus_deltas(base_headings, best_d)
1145
  final_legs = make_legs_from_schedule(final_headings, float(speed_knots), schedule_minutes)
1146
  final_s = score_legs_bfo_bto_with_drift(
@@ -1158,9 +1049,6 @@ def _to_label_utc(dt: datetime) -> str:
1158
 
1159
 
1160
  def _lookup_measured_at_dt(dt_ping: datetime):
1161
- """
1162
- Return measured (BFO,BTO) nearest in time to dt_ping.
1163
- """
1164
  if hasattr(fp, "nearest_measured_for_time"):
1165
  idx = fp.nearest_measured_for_time(dt_ping)[0]
1166
  row = fp.satLoc[idx]
@@ -1168,7 +1056,6 @@ def _lookup_measured_at_dt(dt_ping: datetime):
1168
  meas_bto = row[SATLOC_IDX_MEAS_BTO]
1169
  return meas_bfo, meas_bto
1170
 
1171
- # fallback: brute force nearest
1172
  best_i = None
1173
  best_dt = None
1174
  for i, row in enumerate(fp.satLoc):
@@ -1196,18 +1083,15 @@ def build_validation_table(
1196
  elif (descent_start_choice or "").startswith("After Ping Ring 7"):
1197
  descent_start_dt = dt_ping7_utc()
1198
 
1199
- # Schedule timestamps: start + cumulative leg minutes
1200
  times = [start_dt]
1201
  cum = 0.0
1202
  for (_h, _s, m) in legs:
1203
  cum += float(m)
1204
  times.append(start_dt + timedelta(minutes=cum))
1205
 
1206
- # boundaries for segment lookup
1207
  leg_secs = [float(m) * 60.0 for _, _, m in legs]
1208
  boundaries = np.cumsum([0.0] + leg_secs)
1209
 
1210
- # precompute leg start positions
1211
  cur_lat = float(start_lat)
1212
  cur_lon = float(start_lon)
1213
  leg_start_positions = [(cur_lat, cur_lon)]
@@ -1244,7 +1128,6 @@ def build_validation_table(
1244
 
1245
  bfo_meas, bto_meas = _lookup_measured_at_dt(dt_k)
1246
 
1247
- # errors (calc - meas)
1248
  bfo_err = None if bfo_meas is None else (float(bfo_calc) - float(bfo_meas))
1249
  bto_err = None if bto_meas is None else (float(bto_calc) - float(bto_meas))
1250
 
@@ -1271,8 +1154,6 @@ def build_validation_table(
1271
 
1272
 
1273
  # ================= GUIDED SOLVERS =================
1274
- # (UNCHANGED BELOW — only wiring changes later)
1275
-
1276
  def solve_guided_straight(
1277
  start_lat, start_lon, method, speed_knots, alt_ft, vs_fpm,
1278
  descent_start_choice, weight_bfo, weight_bto
@@ -1353,8 +1234,6 @@ def solve_guided_straight_with_drift(
1353
  f"Guided schedule mismatch: schedule totals {total_sched_min:.1f} min but window is {total_window_min:.1f} min."
1354
  ), None
1355
 
1356
- base_step = 1.0 if float(drift_max_deg) > 0 else STRAIGHT_STEP_DEG
1357
-
1358
  best_s = None
1359
  best_legs = None
1360
  best_h0 = None
@@ -1384,7 +1263,7 @@ def solve_guided_straight_with_drift(
1384
  best_h0 = float(h0)
1385
  best_deltas = deltas
1386
 
1387
- h0 += float(base_step)
1388
 
1389
  if best_s is None or best_legs is None:
1390
  return None, f"Guided drift solve: not enough ping points between {GUIDED_START_LABEL} and {GUIDED_END_LABEL} to score.", None
@@ -1404,7 +1283,7 @@ def solve_guided_straight_with_drift(
1404
  f"- Start: lat **{float(start_lat):.2f}**, lon **{float(start_lon):.2f}**",
1405
  f"- Legs: **{sched_txt}**",
1406
  f"- Speed: **{float(speed_knots):.1f} kt**",
1407
- f"- Nominal heading H₀: **{best_h0:.1f}°** (searched {GUIDED_HEADING_MIN:.0f}–{GUIDED_HEADING_MAX:.0f}°, step {1.0 if float(drift_max_deg)>0 else STRAIGHT_STEP_DEG}°)",
1408
  f"- Drift bounds: **±{float(drift_max_deg):.1f}°** | Drift penalty weight: **{float(drift_weight):.2f}**",
1409
  f"- Drift RMS: **{rms_drift:.2f}°**",
1410
  f"- Drift per leg (Δ): {drift_str}" if drift_str else "",
@@ -1416,156 +1295,6 @@ def solve_guided_straight_with_drift(
1416
  return best_legs, report, start_dt
1417
 
1418
 
1419
- def solve_guided_turn_after_ping6(
1420
- start_lat, start_lon, method, speed_knots, alt_ft, vs_fpm,
1421
- descent_start_choice, weight_bfo, weight_bto
1422
- ):
1423
- start_dt = dt_guided_start_utc()
1424
- end_dt = dt_guided_end_utc()
1425
-
1426
- descent_start_dt = None
1427
- if (descent_start_choice or "").startswith("After Ping Ring 6"):
1428
- descent_start_dt = dt_ping6_utc()
1429
- elif (descent_start_choice or "").startswith("After Ping Ring 7"):
1430
- descent_start_dt = dt_ping7_utc()
1431
-
1432
- total_sched_min = float(np.sum(GUIDED_LEG_SCHEDULE_MIN))
1433
- total_window_min = (end_dt - start_dt).total_seconds() / 60.0
1434
- if abs(total_sched_min - total_window_min) > 0.01:
1435
- return None, (
1436
- f"Guided schedule mismatch: schedule totals {total_sched_min:.1f} min but window is {total_window_min:.1f} min."
1437
- ), None
1438
-
1439
- n = len(GUIDED_LEG_SCHEDULE_MIN)
1440
- if n != 7:
1441
- return None, "Turn scenario expects 7 legs in the schedule.", None
1442
-
1443
- best = None
1444
- best_h1 = None
1445
- best_h2 = None
1446
-
1447
- h1 = GUIDED_HEADING_MIN
1448
- while h1 <= GUIDED_HEADING_MAX + 1e-9:
1449
- delta = -TURN_DELTA_MAX_DEG
1450
- while delta <= TURN_DELTA_MAX_DEG + 1e-9:
1451
- h2 = (float(h1) + float(delta)) % 360.0
1452
- headings = [h1]*5 + [h2]*2
1453
- legs = make_legs_from_schedule(headings, float(speed_knots), GUIDED_LEG_SCHEDULE_MIN)
1454
-
1455
- s = score_legs_bfo_bto(
1456
- start_lat, start_lon, method, start_dt, end_dt, legs,
1457
- alt_ft, vs_fpm, descent_start_dt,
1458
- weight_bfo, weight_bto
1459
- )
1460
- if s is not None and (best is None or s["combined"] < best["combined"]):
1461
- best = s
1462
- best_h1 = float(h1)
1463
- best_h2 = float(h2)
1464
-
1465
- delta += TURN_DELTA_STEP_DEG
1466
- h1 += TURN_H1_STEP_DEG
1467
-
1468
- if best is None:
1469
- return None, f"Guided solve: not enough ping points between {GUIDED_START_LABEL} and {GUIDED_END_LABEL} to score.", None
1470
-
1471
- headings = [best_h1]*5 + [best_h2]*2
1472
- legs = make_legs_from_schedule(headings, float(speed_knots), GUIDED_LEG_SCHEDULE_MIN)
1473
-
1474
- rbto = best["rmse_bto"]
1475
- rbto_txt = f"{rbto:.1f} µs" if rbto is not None else "n/a"
1476
- sched_txt = ", ".join([f"{m:.0f}" for m in GUIDED_LEG_SCHEDULE_MIN]) + " min"
1477
-
1478
- report = "\n".join([
1479
- "**Guided mode: Turn after Ping Ring 6 (two headings)**",
1480
- f"- Window: **{GUIDED_START_LABEL}Z → 00:20:00Z**",
1481
- f"- Start: lat **{float(start_lat):.2f}**, lon **{float(start_lon):.2f}**",
1482
- f"- Legs: **{sched_txt}**",
1483
- f"- Speed: **{float(speed_knots):.1f} kt**",
1484
- f"- Heading legs 1–5 (to Ping Ring 6): **{best_h1:.1f}°**",
1485
- f"- Heading legs 6–7 (Ping Ring 6 → end): **{best_h2:.1f}°** (searched delta ±{TURN_DELTA_MAX_DEG:.0f}°)",
1486
- f"- Weights: BFO **{float(weight_bfo):.2f}**, BTO **{float(weight_bto):.2f}**",
1487
- f"- BFO points: **{best['n_bfo']}** | RMSE_BFO: **{best['rmse_bfo']:.2f} Hz**",
1488
- f"- BTO points: **{best['n_bto']}** | RMSE_BTO: **{rbto_txt}**",
1489
- ])
1490
- return legs, report, start_dt
1491
-
1492
-
1493
- def solve_guided_radar_fix_turn_then_southern(
1494
- turn_complete_lat, turn_complete_lon,
1495
- method,
1496
- speed_knots_southern,
1497
- alt_ft, vs_fpm,
1498
- descent_start_choice, weight_bfo, weight_bto
1499
- ):
1500
- out = compute_turn_leg_from_radar_fix(float(turn_complete_lat), float(turn_complete_lon), method)
1501
- if out is None:
1502
- return None, "Invalid radar-fix turn time window.", None
1503
-
1504
- turn_hdg, turn_spd_kt, turn_minutes, turn_dist_km = out
1505
-
1506
- if not (200.0 <= turn_spd_kt <= 650.0):
1507
- return None, (
1508
- f"Turn leg implies **{turn_spd_kt:.0f} kt** from radar fix to completion @18:28Z. "
1509
- f"That’s implausible — move the completion point closer/farther."
1510
- ), None
1511
-
1512
- start_dt = dt_guided_start_utc()
1513
- end_dt = dt_guided_end_utc()
1514
-
1515
- descent_start_dt = None
1516
- if (descent_start_choice or "").startswith("After Ping Ring 6"):
1517
- descent_start_dt = dt_ping6_utc()
1518
- elif (descent_start_choice or "").startswith("After Ping Ring 7"):
1519
- descent_start_dt = dt_ping7_utc()
1520
-
1521
- total_sched_min = float(np.sum(GUIDED_LEG_SCHEDULE_MIN))
1522
- total_window_min = (end_dt - start_dt).total_seconds() / 60.0
1523
- if abs(total_sched_min - total_window_min) > 0.01:
1524
- return None, (
1525
- f"Guided schedule mismatch: schedule totals {total_sched_min:.1f} min but window is {total_window_min:.1f} min."
1526
- ), None
1527
-
1528
- best = None
1529
- best_h = None
1530
- h = GUIDED_HEADING_MIN
1531
- while h <= GUIDED_HEADING_MAX + 1e-9:
1532
- southern_legs = make_legs_from_schedule(h, float(speed_knots_southern), GUIDED_LEG_SCHEDULE_MIN)
1533
- s = score_legs_bfo_bto(
1534
- float(turn_complete_lat), float(turn_complete_lon),
1535
- method, start_dt, end_dt, southern_legs,
1536
- alt_ft, vs_fpm, descent_start_dt,
1537
- weight_bfo, weight_bto
1538
- )
1539
- if s is not None and (best is None or s["combined"] < best["combined"]):
1540
- best = s
1541
- best_h = float(h)
1542
- h += STRAIGHT_STEP_DEG
1543
-
1544
- if best is None or best_h is None:
1545
- return None, "Radar-fix scenario: not enough ping points in 18:28→00:20 window to solve southern heading.", None
1546
-
1547
- southern_legs = make_legs_from_schedule(best_h, float(speed_knots_southern), GUIDED_LEG_SCHEDULE_MIN)
1548
- all_legs = [(turn_hdg, turn_spd_kt, turn_minutes)] + southern_legs
1549
-
1550
- rbto = best["rmse_bto"]
1551
- rbto_txt = f"{rbto:.1f} µs" if rbto is not None else "n/a"
1552
-
1553
- report = "\n".join([
1554
- "**Guided mode: Radar fix → turn completion (by 18:28Z) → southern solve**",
1555
- f"- Radar fix: **18:22:12Z** @ lat **{RADAR_FIX_LAT:.3f}**, lon **{RADAR_FIX_LON:.3f}**",
1556
- f"- Turn completion (fixed time): **18:28:00Z** @ lat **{float(turn_complete_lat):.3f}**, lon **{float(turn_complete_lon):.3f}**",
1557
- f"- Turn leg: heading **{turn_hdg:.1f}°**, speed **{turn_spd_kt:.1f} kt**, duration **{turn_minutes:.2f} min**, distance **{turn_dist_km:.1f} km**",
1558
- "",
1559
- f"- Southern speed: **{float(speed_knots_southern):.1f} kt**",
1560
- f"- Southern best heading: **{best_h:.1f}°** (searched {GUIDED_HEADING_MIN:.0f}–{GUIDED_HEADING_MAX:.0f}°, step {STRAIGHT_STEP_DEG}°)",
1561
- f"- Weights: BFO **{float(weight_bfo):.2f}**, BTO **{float(weight_bto):.2f}**",
1562
- f"- BFO points: **{best['n_bfo']}** | RMSE_BFO: **{best['rmse_bfo']:.2f} Hz**",
1563
- f"- BTO points: **{best['n_bto']}** | RMSE_BTO: **{rbto_txt}**",
1564
- ])
1565
-
1566
- return all_legs, report, RADAR_FIX_TIME_UTC
1567
-
1568
-
1569
  # ================= RUNNERS =================
1570
  def _run_common(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice):
1571
  path_pts, seg_dists_m, total_time_min = simulate_path(start_lat, start_lon, legs, method)
@@ -1604,7 +1333,6 @@ def run_guided(
1604
  start_lat, start_lon, method, alt_ft, vs_fpm, descent_start_choice,
1605
  speed_profile, custom_mach, custom_knots, guided_scenario,
1606
  weight_bfo, weight_bto,
1607
- turn_complete_lat, turn_complete_lon,
1608
  drift_max_deg, drift_weight
1609
  ):
1610
  spd_knots = speed_from_profile(speed_profile, alt_ft, custom_mach, custom_knots)
@@ -1617,26 +1345,6 @@ def run_guided(
1617
  )
1618
  sim_start_lat = start_lat
1619
  sim_start_lon = start_lon
1620
-
1621
- elif (guided_scenario or "").startswith("Radar fix"):
1622
- legs, report_md, start_dt = solve_guided_radar_fix_turn_then_southern(
1623
- turn_complete_lat, turn_complete_lon,
1624
- method,
1625
- spd_knots,
1626
- alt_ft, vs_fpm,
1627
- descent_start_choice, weight_bfo, weight_bto
1628
- )
1629
- sim_start_lat = RADAR_FIX_LAT
1630
- sim_start_lon = RADAR_FIX_LON
1631
-
1632
- elif (guided_scenario or "").startswith("Turn after Ping Ring 6"):
1633
- legs, report_md, start_dt = solve_guided_turn_after_ping6(
1634
- start_lat, start_lon, method, spd_knots, alt_ft, vs_fpm,
1635
- descent_start_choice, weight_bfo, weight_bto
1636
- )
1637
- sim_start_lat = start_lat
1638
- sim_start_lon = start_lon
1639
-
1640
  else:
1641
  legs, report_md, start_dt = solve_guided_straight(
1642
  start_lat, start_lon, method, spd_knots, alt_ft, vs_fpm,
@@ -1686,8 +1394,6 @@ with gr.Blocks() as demo:
1686
  start_lon = gr.Slider(70, 115, value=95.69, label="Start Longitude (used for scenarios A/B)")
1687
  method = gr.Dropdown(["VINCENTY", "RHUMB"], value="VINCENTY", label="Method")
1688
 
1689
- # REMOVED: rings CheckboxGroup (all rings are permanent now)
1690
-
1691
  gr.Markdown("### BFO/BTO Settings")
1692
  with gr.Row():
1693
  start_time_str = gr.Textbox(value=f"{GUIDED_START_LABEL}Z", label="Simulation start time (UTC) — Manual mode")
@@ -1705,8 +1411,6 @@ with gr.Blocks() as demo:
1705
  [
1706
  "Straight (single heading)",
1707
  "Straight + drift (per-leg ±deg, penalized)",
1708
- "Turn after Ping Ring 6 (two headings)",
1709
- "Radar fix → turn completion @18:28Z → southern solve"
1710
  ],
1711
  value="Straight (single heading)",
1712
  label="Scenario",
@@ -1724,16 +1428,6 @@ with gr.Blocks() as demo:
1724
  drift_max_deg = gr.Slider(0.0, 5.0, value=DEFAULT_DRIFT_MAX_DEG, step=0.1, label="Max drift per leg (±deg)")
1725
  drift_weight = gr.Slider(0.0, 2.0, value=DEFAULT_WEIGHT_DRIFT, step=0.05, label="Drift penalty weight (higher = less drift)")
1726
 
1727
- gr.Markdown("### Radar-fix turn completion point (Scenario C only)")
1728
- gr.Markdown(
1729
- f"- Radar fix is fixed at **18:22:12Z @ {RADAR_FIX_LAT:.3f}, {RADAR_FIX_LON:.3f}**\n"
1730
- f"- Turn completion time is fixed at **18:28:00Z** (latest allowed)\n"
1731
- f"- You choose the **completion latitude/longitude** below."
1732
- )
1733
- with gr.Row():
1734
- turn_complete_lat = gr.Slider(-10, 15, value=6.76, step=0.01, label="Turn completion latitude (°)")
1735
- turn_complete_lon = gr.Slider(70, 115, value=95.69, step=0.01, label="Turn completion longitude (°)")
1736
-
1737
  gr.Markdown("### Guided scoring weights")
1738
  with gr.Row():
1739
  weight_bfo = gr.Slider(0.0, 5.0, value=DEFAULT_WEIGHT_BFO, step=0.1, label="Weight: BFO")
@@ -1789,7 +1483,6 @@ with gr.Blocks() as demo:
1789
  start_lat, start_lon, method, alt_ft, vs_fpm, descent_start_choice,
1790
  speed_profile, custom_mach, custom_knots, guided_scenario,
1791
  weight_bfo, weight_bto,
1792
- turn_complete_lat, turn_complete_lon,
1793
  drift_max_deg, drift_weight
1794
  ],
1795
  outputs=[legs_df, status_md, out_map, stats, bfo_plot, bfo_score, alt_plot, validation_df],
 
8
  # (14) BFO analysis chart + MAE/RMSE
9
  # Guided mode scenarios:
10
  # A) Straight (single heading+speed) with FIXED schedule
11
+ # A2) Straight (nominal heading) + small per-leg drift (±deg) with penalty
12
+ # (REMOVED) Turn after Ping Ring 6
13
+ # (REMOVED) Radar fix → turn completion @18:28Z → southern solve
14
+ #
15
  # NEW: Altitude vs Time chart (visualize descent profile)
16
  # NEW: Validation table at schedule timestamps (lat/lon/hdg/spd + BFO/BTO measured vs calculated + errors)
17
  # NEW: Explicit weighting between BFO and BTO in guided solver (to emphasize BTO)
18
  # NEW: Heading-drift penalty (keeps drift small + defensible)
 
 
 
19
  #
20
  # === YOUR REQUESTED CHANGES (IMPLEMENTED) ===
21
  # 1) Overlay "Search areas.kmz" on the map (PERMANENT, always shown)
 
 
22
  # 2) Make ALL ping rings permanent (always shown)
23
  # 3) Remove ring toggles (no CheckboxGroup)
24
  # 4) Remove LayerControl entirely
25
+ # 5) Color-code search items by name (Ocean Infinity / Australia / Malaysia / China / etc.)
26
+ # 6) Remove guided scenarios:
27
+ # - "Turn after Ping Ring 6 (two headings)"
28
+ # - "Radar fix → turn completion @18:28Z → southern solve"
29
+ # + Remove the radar-fix UI controls
30
 
31
  import os
32
  import re
 
99
 
100
  # Guided scenario-specific search resolution
101
  STRAIGHT_STEP_DEG = 0.5
 
 
 
 
 
 
 
102
 
103
+ # ================= HEADING DRIFT =================
 
 
104
  DEFAULT_DRIFT_MAX_DEG = 2.0 # typical: 1–2 degrees
105
  DRIFT_STEP_DEG = 0.5 # discrete search step for deltas
106
  DEFAULT_WEIGHT_DRIFT = 0.30 # penalty strength in "combined score" units
107
  DEFAULT_DRIFT_ITERS = 2 # coordinate descent passes
108
  DRIFT_SCALE_DEG = 1.0 # normalization for drift penalty (rms_deg / scale)
109
 
110
+ # ================= SEARCH AREA COLORS =================
 
 
111
  SEARCH_AREA_COLORS = {
112
  "Ocean Infinity": "#ff3b3b", # red
113
  "Australia": "#1f77b4", # blue
 
118
  }
119
  DEFAULT_SEARCH_COLOR = "#00ffff" # fallback cyan
120
 
 
 
 
 
 
 
 
 
 
 
121
 
122
  # ================= PATH RESOLUTION =================
123
  def resolve_local_or_mnt(path_like: str) -> str:
 
133
  if not p:
134
  return p
135
 
 
136
  if os.path.exists(p):
137
  return p
138
 
 
139
  here = os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals() else os.getcwd()
140
  p2 = os.path.join(here, p)
141
  if os.path.exists(p2):
142
  return p2
143
 
 
144
  base = os.path.basename(p)
145
  p3 = os.path.join("/mnt/data", base)
146
  if os.path.exists(p3):
147
  return p3
148
 
 
149
  p4 = os.path.join("/mnt/data", p)
150
  if os.path.exists(p4):
151
  return p4
152
 
 
153
  return p
154
 
155
 
 
225
 
226
 
227
  def _parse_kml_coordinates_text(txt: str):
 
 
 
 
228
  pts = []
229
  if not txt:
230
  return pts
 
247
  - Placemark -> LineString
248
  - Placemark -> Polygon
249
  - MultiGeometry versions of the above
 
 
 
 
 
 
 
 
250
  """
251
  kmz_path = resolve_local_or_mnt(kmz_path)
252
 
 
277
  lines = []
278
  polys = []
279
 
 
280
  for pt in pm.findall(".//kml:Point", ns):
281
  coords = pt.find(".//kml:coordinates", ns)
282
  if coords is None or coords.text is None:
 
285
  if pts:
286
  points.extend(pts[:1])
287
 
 
288
  for ls in pm.findall(".//kml:LineString", ns):
289
  coords = ls.find(".//kml:coordinates", ns)
290
  if coords is None or coords.text is None:
 
293
  if len(pts) >= 2:
294
  lines.append(pts)
295
 
 
296
  for poly in pm.findall(".//kml:Polygon", ns):
297
  outer = poly.find(".//kml:outerBoundaryIs/kml:LinearRing/kml:coordinates", ns)
298
  if outer is None or outer.text is None:
 
449
 
450
 
451
  def build_leaflet_map(path_pts, legs, seg_dists_m):
 
 
 
 
 
 
 
452
  start_lat, start_lon = path_pts[0]
453
  m = folium.Map(
454
  location=[start_lat, start_lon],
 
457
  control_scale=True,
458
  )
459
 
 
460
  folium.TileLayer("OpenStreetMap", name="OpenStreetMap", overlay=False, control=True).add_to(m)
461
  folium.TileLayer(
462
  tiles="https://server.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}",
 
479
  nm = area.get("name", "Search area")
480
  col = color_for_search_area(nm)
481
 
 
482
  for (la, lo) in area.get("points", []) or []:
483
  search_area_pts_for_bounds.append((la, lo))
484
  folium.CircleMarker(
 
492
  tooltip=nm,
493
  ).add_to(m)
494
 
 
495
  for line in area.get("lines", []) or []:
496
  if not line:
497
  continue
 
504
  tooltip=nm,
505
  ).add_to(m)
506
 
 
507
  for poly in area.get("polygons", []) or []:
508
  if not poly:
509
  continue
 
598
  for name, lat, lon in WAYPOINTS:
599
  add_permanent_label(m, name, float(lat), float(lon))
600
 
 
 
601
  waypoint_pts = [(float(lat), float(lon)) for _, lat, lon in WAYPOINTS]
602
  all_ring_pts = []
603
  for k in PING_RINGS:
 
606
  bounds = _all_bounds(
607
  [path_pts]
608
  + [waypoint_pts]
609
+ + [all_ring_pts]
610
  + ([search_area_pts_for_bounds] if search_area_pts_for_bounds else [])
611
  )
612
  if bounds:
 
723
  return float(lat), float(lon)
724
 
725
 
726
+ # ================= BFO ANALYSIS =================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
727
  def bfo_analysis_with_legs(start_lat, start_lon, legs, method, start_time_str, alt_ft, vs_fpm, descent_start_choice):
728
  start_dt = _parse_start_time(start_time_str)
729
  pts = _ping_points_after(start_dt)
 
846
  return mach_to_tas_knots(0.82, alt_ft)
847
 
848
 
849
+ # ================= GUIDED SCORING =================
850
  def make_legs_from_schedule(headings, speed_knots, schedule_minutes):
851
  legs = []
852
  if isinstance(headings, (int, float)):
 
970
  return s
971
 
972
 
973
+ def _wrap_heading_deg(h: float) -> float:
974
+ return float(h) % 360.0
975
+
976
+
977
  def _apply_base_plus_deltas(base_headings, deltas_deg):
978
  headings = []
979
  for b, d in zip(base_headings, deltas_deg):
 
999
  best_s = None
1000
  best_d = deltas[:]
1001
 
 
1002
  for _it in range(max(1, int(iters))):
1003
  improved = False
1004
  for j in range(n):
 
1024
 
1025
  deltas[j] = best_local_dj
1026
 
 
1027
  if best_local_s is not None and (best_s is None or best_local_s["combined"] < best_s["combined"]):
1028
  best_s = best_local_s
1029
  best_d = deltas[:]
 
1032
  if not improved:
1033
  break
1034
 
 
1035
  final_headings = _apply_base_plus_deltas(base_headings, best_d)
1036
  final_legs = make_legs_from_schedule(final_headings, float(speed_knots), schedule_minutes)
1037
  final_s = score_legs_bfo_bto_with_drift(
 
1049
 
1050
 
1051
  def _lookup_measured_at_dt(dt_ping: datetime):
 
 
 
1052
  if hasattr(fp, "nearest_measured_for_time"):
1053
  idx = fp.nearest_measured_for_time(dt_ping)[0]
1054
  row = fp.satLoc[idx]
 
1056
  meas_bto = row[SATLOC_IDX_MEAS_BTO]
1057
  return meas_bfo, meas_bto
1058
 
 
1059
  best_i = None
1060
  best_dt = None
1061
  for i, row in enumerate(fp.satLoc):
 
1083
  elif (descent_start_choice or "").startswith("After Ping Ring 7"):
1084
  descent_start_dt = dt_ping7_utc()
1085
 
 
1086
  times = [start_dt]
1087
  cum = 0.0
1088
  for (_h, _s, m) in legs:
1089
  cum += float(m)
1090
  times.append(start_dt + timedelta(minutes=cum))
1091
 
 
1092
  leg_secs = [float(m) * 60.0 for _, _, m in legs]
1093
  boundaries = np.cumsum([0.0] + leg_secs)
1094
 
 
1095
  cur_lat = float(start_lat)
1096
  cur_lon = float(start_lon)
1097
  leg_start_positions = [(cur_lat, cur_lon)]
 
1128
 
1129
  bfo_meas, bto_meas = _lookup_measured_at_dt(dt_k)
1130
 
 
1131
  bfo_err = None if bfo_meas is None else (float(bfo_calc) - float(bfo_meas))
1132
  bto_err = None if bto_meas is None else (float(bto_calc) - float(bto_meas))
1133
 
 
1154
 
1155
 
1156
  # ================= GUIDED SOLVERS =================
 
 
1157
  def solve_guided_straight(
1158
  start_lat, start_lon, method, speed_knots, alt_ft, vs_fpm,
1159
  descent_start_choice, weight_bfo, weight_bto
 
1234
  f"Guided schedule mismatch: schedule totals {total_sched_min:.1f} min but window is {total_window_min:.1f} min."
1235
  ), None
1236
 
 
 
1237
  best_s = None
1238
  best_legs = None
1239
  best_h0 = None
 
1263
  best_h0 = float(h0)
1264
  best_deltas = deltas
1265
 
1266
+ h0 += 1.0 # coarse sweep when drift is enabled
1267
 
1268
  if best_s is None or best_legs is None:
1269
  return None, f"Guided drift solve: not enough ping points between {GUIDED_START_LABEL} and {GUIDED_END_LABEL} to score.", None
 
1283
  f"- Start: lat **{float(start_lat):.2f}**, lon **{float(start_lon):.2f}**",
1284
  f"- Legs: **{sched_txt}**",
1285
  f"- Speed: **{float(speed_knots):.1f} kt**",
1286
+ f"- Nominal heading H₀: **{best_h0:.1f}°** (searched {GUIDED_HEADING_MIN:.0f}–{GUIDED_HEADING_MAX:.0f}°, step 1.0°)",
1287
  f"- Drift bounds: **±{float(drift_max_deg):.1f}°** | Drift penalty weight: **{float(drift_weight):.2f}**",
1288
  f"- Drift RMS: **{rms_drift:.2f}°**",
1289
  f"- Drift per leg (Δ): {drift_str}" if drift_str else "",
 
1295
  return best_legs, report, start_dt
1296
 
1297
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1298
  # ================= RUNNERS =================
1299
  def _run_common(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice):
1300
  path_pts, seg_dists_m, total_time_min = simulate_path(start_lat, start_lon, legs, method)
 
1333
  start_lat, start_lon, method, alt_ft, vs_fpm, descent_start_choice,
1334
  speed_profile, custom_mach, custom_knots, guided_scenario,
1335
  weight_bfo, weight_bto,
 
1336
  drift_max_deg, drift_weight
1337
  ):
1338
  spd_knots = speed_from_profile(speed_profile, alt_ft, custom_mach, custom_knots)
 
1345
  )
1346
  sim_start_lat = start_lat
1347
  sim_start_lon = start_lon
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1348
  else:
1349
  legs, report_md, start_dt = solve_guided_straight(
1350
  start_lat, start_lon, method, spd_knots, alt_ft, vs_fpm,
 
1394
  start_lon = gr.Slider(70, 115, value=95.69, label="Start Longitude (used for scenarios A/B)")
1395
  method = gr.Dropdown(["VINCENTY", "RHUMB"], value="VINCENTY", label="Method")
1396
 
 
 
1397
  gr.Markdown("### BFO/BTO Settings")
1398
  with gr.Row():
1399
  start_time_str = gr.Textbox(value=f"{GUIDED_START_LABEL}Z", label="Simulation start time (UTC) — Manual mode")
 
1411
  [
1412
  "Straight (single heading)",
1413
  "Straight + drift (per-leg ±deg, penalized)",
 
 
1414
  ],
1415
  value="Straight (single heading)",
1416
  label="Scenario",
 
1428
  drift_max_deg = gr.Slider(0.0, 5.0, value=DEFAULT_DRIFT_MAX_DEG, step=0.1, label="Max drift per leg (±deg)")
1429
  drift_weight = gr.Slider(0.0, 2.0, value=DEFAULT_WEIGHT_DRIFT, step=0.05, label="Drift penalty weight (higher = less drift)")
1430
 
 
 
 
 
 
 
 
 
 
 
1431
  gr.Markdown("### Guided scoring weights")
1432
  with gr.Row():
1433
  weight_bfo = gr.Slider(0.0, 5.0, value=DEFAULT_WEIGHT_BFO, step=0.1, label="Weight: BFO")
 
1483
  start_lat, start_lon, method, alt_ft, vs_fpm, descent_start_choice,
1484
  speed_profile, custom_mach, custom_knots, guided_scenario,
1485
  weight_bfo, weight_bto,
 
1486
  drift_max_deg, drift_weight
1487
  ],
1488
  outputs=[legs_df, status_md, out_map, stats, bfo_plot, bfo_score, alt_plot, validation_df],