Chris773 commited on
Commit
bedfbf8
·
verified ·
1 Parent(s): 35b2975

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +224 -300
app.py CHANGED
@@ -8,17 +8,16 @@
8
  # Key behavior:
9
  # - Last radar spot FIXED: 18:22:00Z @ 6.578N, 96.341E
10
  # - Turn completion FIXED TIME: 18:40:00Z
11
- # - NEW: Turn completion LOCATION (18:40 lat/lon) is USER-ADJUSTABLE via sliders.
12
- # - Guided mode computes a realistic turn between 18:22→18:40 that ENDS at the 18:40 lat/lon,
13
- # and then simulates/solves the southern path.
14
  # - Guided validation timestamps: 18:22, 18:40, 19:40, 20:40, 21:40, 22:40, 00:10, 00:20
15
  #
16
- # FIXES IMPLEMENTED IN THIS VERSION:
17
- # 1) Remove trajectory (red line) AFTER 00:20
18
- # - In guided mode, we CLIP the simulated legs so the polyline ends exactly at 00:20Z.
19
- # 2) Restore BFO plot (it disappeared previously)
20
- # - Re-added full BFO analysis computation + chart + MAE/RMSE markdown.
21
- # 3) NEW: 18:40 turn completion lat/lon sliders drive the guided preturn geometry (plausible turn).
22
 
23
  import os
24
  import re
@@ -89,7 +88,7 @@ TURN_WINDOW_MIN = (TURN_COMPLETE_TIME_UTC - RADAR_FIX_TIME_UTC).total_seconds()
89
  # Initial track shown in your radar annotation
90
  RADAR_INITIAL_TRACK_DEG = 296.0
91
 
92
- # ===== NEW: Default 18:40 turn completion location (user can override with sliders) =====
93
  TURN_COMPLETE_DEFAULT_LAT = 6.76
94
  TURN_COMPLETE_DEFAULT_LON = 95.69
95
 
@@ -107,16 +106,24 @@ GUIDED_VALIDATION_LABELS = [
107
  ]
108
  GUIDED_FINAL_LABEL = "24:20:00" # 00:20Z FINAL RESTING POINT
109
 
110
- # Turn modeling parameters
111
- TURN_BANK_MIN_DEG = 15.0
112
- TURN_BANK_MAX_DEG = 30.0
113
- TURN_BANK_DEFAULT_DEG = 15.0
114
- TURN_STEP_SEC = 30.0
115
 
116
- # NEW: How tightly we try to hit the 18:40 point (km)
117
- TURN_POINT_TOL_KM = 0.50 # "hit" tolerance for reporting; solver still picks best even if > tol
118
- TURN_PRESEARCH_COARSE_SEC = 10.0 # coarse grid for pre-straight duration
119
- TURN_PRESEARCH_FINE_SEC = 1.0 # refinement grid around the best coarse solution
 
 
 
 
 
 
 
 
 
 
120
 
121
  # Guided heading search (south-ish)
122
  GUIDED_HEADING_MIN = 150.0
@@ -130,7 +137,6 @@ GUIDED_LEG_SCHEDULE_MIN = [12.0, 60.0, 60.0, 60.0, 60.0, 90.0, 10.0]
130
  DEFAULT_DRIFT_MAX_DEG = 2.0
131
  DRIFT_STEP_DEG = 0.5
132
  DEFAULT_WEIGHT_DRIFT = 0.30
133
- DEFAULT_DRIFT_ITERS = 2
134
  DRIFT_SCALE_DEG = 1.0
135
 
136
  # Search area colors (optional)
@@ -389,7 +395,6 @@ def _turn_rate_deg_per_sec(speed_knots: float, bank_deg: float) -> float:
389
 
390
 
391
  def _haversine_km(lat1, lon1, lat2, lon2) -> float:
392
- # quick & stable for our error scoring (km)
393
  R = 6371.0
394
  phi1 = math.radians(float(lat1))
395
  phi2 = math.radians(float(lat2))
@@ -399,6 +404,16 @@ def _haversine_km(lat1, lon1, lat2, lon2) -> float:
399
  return 2 * R * math.asin(min(1.0, math.sqrt(a)))
400
 
401
 
 
 
 
 
 
 
 
 
 
 
402
  def _direct(method: str, lat: float, lon: float, heading_deg: float, dist_m: float):
403
  if method == "VINCENTY":
404
  (lat2, lon2), _ = fp.vincenty_direct((float(lat), float(lon)), float(heading_deg), float(dist_m))
@@ -407,237 +422,195 @@ def _direct(method: str, lat: float, lon: float, heading_deg: float, dist_m: flo
407
  return float(lat2), float(lon2)
408
 
409
 
410
- def _simulate_discretized_turn(
 
411
  method: str,
412
- lat0: float,
413
- lon0: float,
414
  speed_knots: float,
415
  h0: float,
416
  h1: float,
417
- turn_sec: float,
418
- sign: float,
419
- delta_abs: float,
420
- step_sec: float
421
- ):
422
- # Returns endpoint after flying the discretized turn with mid-heading sampling
423
- lat, lon = float(lat0), float(lon0)
424
- if turn_sec <= 1e-9 or delta_abs <= 1e-9:
425
- return lat, lon
426
-
427
- step_sec = max(5.0, float(step_sec))
428
- n = int(math.ceil(float(turn_sec) / step_sec))
429
- if n < 1:
430
- n = 1
431
-
432
- remaining = float(turn_sec)
433
- for i in range(n):
434
- this = min(step_sec, remaining)
435
- remaining -= this
436
- frac_mid = (i + 0.5) / n
437
- hdg_mid = _wrap_heading_deg(float(h0) + float(sign) * float(delta_abs) * float(frac_mid))
438
- dist_m = float(speed_knots) * 1852.0 * (float(this) / 3600.0)
439
- lat, lon = _direct(method, lat, lon, hdg_mid, dist_m)
440
-
441
- return lat, lon
442
-
443
-
444
- def build_turn_legs_to_point(
445
- speed_knots: float,
446
- method: str,
447
- heading_final_deg: float,
448
- target_lat: float,
449
- target_lon: float,
450
- bank_pref_deg: float = TURN_BANK_DEFAULT_DEG,
451
- step_sec: float = TURN_STEP_SEC,
452
  ):
453
  """
454
- Build a plausible 18:22→18:40 turn profile that:
455
- - starts on RADAR_INITIAL_TRACK_DEG
456
- - ends at (target_lat, target_lon) exactly at TURN_COMPLETE_TIME_UTC (as close as numerically possible)
457
- - ends with heading = heading_final_deg (start of southern leg)
458
- - keeps constant speed_knots during the whole window
459
- Model:
460
- pre-straight on h0 for t_pre
461
- constant-bank discretized turn to h1 for t_turn (from bank angle)
462
- post-straight on h1 for t_post
463
- with t_pre + t_turn + t_post = total
464
- We brute-force t_pre (grid) and bank candidate to minimize endpoint error (km).
465
  """
466
  total_sec = (TURN_COMPLETE_TIME_UTC - RADAR_FIX_TIME_UTC).total_seconds()
467
  if total_sec <= 0:
468
- return [], {"error": "Invalid turn window."}
 
 
 
 
469
 
470
- h0 = float(RADAR_INITIAL_TRACK_DEG)
471
- h1 = _wrap_heading_deg(float(heading_final_deg))
472
  delta = _shortest_signed_delta_deg(h0, h1)
473
  sign = 1.0 if delta >= 0 else -1.0
474
  delta_abs = abs(delta)
475
 
476
- # Quick feasibility check: the endpoint straight-line distance must not exceed path length budget
477
- L_km = float(speed_knots) * 1.852 * (float(total_sec) / 3600.0)
478
- d_km = _haversine_km(RADAR_FIX_LAT, RADAR_FIX_LON, float(target_lat), float(target_lon))
479
- if d_km > L_km + 1e-6:
480
- return [], {
481
- "error": (
482
- f"18:40 point is too far for the 18-minute window at {float(speed_knots):.1f} kt.\n"
483
- f"Straight-line distance: {d_km:.1f} km; path-length budget: {L_km:.1f} km."
484
- ),
485
- "d_km": d_km,
486
- "L_km": L_km,
487
- "h0": h0,
488
- "h1": h1,
489
- "delta_deg": delta,
490
- }
491
 
492
- bank_candidates = [float(bank_pref_deg)] + list(np.linspace(TURN_BANK_MIN_DEG, TURN_BANK_MAX_DEG, 9))
493
- bank_candidates = sorted(list({round(b, 6): b for b in bank_candidates}.values()))
494
-
495
- best = None # dict with error_km, bank, t_pre, t_turn, t_post, end_lat, end_lon
496
-
497
- # coarse grid search
498
- coarse = float(TURN_PRESEARCH_COARSE_SEC)
499
- pre_candidates = np.arange(0.0, max(0.0, total_sec) + 1e-9, coarse)
500
-
501
- for b in bank_candidates:
502
- rate = _turn_rate_deg_per_sec(speed_knots, b)
503
- if rate <= 1e-9:
504
- continue
505
- t_turn = 0.0 if delta_abs <= 1e-9 else (delta_abs / rate)
506
- t_turn = max(0.0, min(float(t_turn), float(total_sec)))
507
-
508
- for t_pre in pre_candidates:
509
- t_post = float(total_sec) - float(t_pre) - float(t_turn)
510
- if t_post < -1e-9:
511
- continue
512
- t_post = max(0.0, t_post)
513
-
514
- # simulate:
515
- lat, lon = float(RADAR_FIX_LAT), float(RADAR_FIX_LON)
516
-
517
- # pre straight
518
- if t_pre > 1e-9:
519
- dist_m = float(speed_knots) * 1852.0 * (float(t_pre) / 3600.0)
520
- lat, lon = _direct(method, lat, lon, h0, dist_m)
521
-
522
- # turn
523
- lat, lon = _simulate_discretized_turn(
524
- method, lat, lon, speed_knots,
525
- h0, h1, t_turn, sign, delta_abs, step_sec
526
- )
527
-
528
- # post straight
529
- if t_post > 1e-9:
530
- dist_m = float(speed_knots) * 1852.0 * (float(t_post) / 3600.0)
531
- lat, lon = _direct(method, lat, lon, h1, dist_m)
532
-
533
- err_km = _haversine_km(lat, lon, float(target_lat), float(target_lon))
534
-
535
- if best is None or err_km < best["error_km"]:
536
- best = {
537
- "error_km": float(err_km),
538
- "bank_deg": float(b),
539
- "t_pre": float(t_pre),
540
- "t_turn": float(t_turn),
541
- "t_post": float(t_post),
542
- "end_lat": float(lat),
543
- "end_lon": float(lon),
544
- }
545
-
546
- if best is None:
547
- return [], {"error": "Unable to construct a valid turn profile."}
548
-
549
- # fine refinement around best t_pre, same bank
550
- fine = float(TURN_PRESEARCH_FINE_SEC)
551
- t_pre0 = best["t_pre"]
552
- b0 = best["bank_deg"]
553
- rate0 = _turn_rate_deg_per_sec(speed_knots, b0)
554
- t_turn0 = 0.0 if delta_abs <= 1e-9 else (delta_abs / rate0)
555
- t_turn0 = max(0.0, min(float(t_turn0), float(total_sec)))
556
-
557
- # search +/- 60 seconds around the best coarse t_pre
558
- lo = max(0.0, t_pre0 - 60.0)
559
- hi = min(float(total_sec), t_pre0 + 60.0)
560
- pre_candidates_fine = np.arange(lo, hi + 1e-9, fine)
561
-
562
- best2 = dict(best)
563
- best2["t_turn"] = float(t_turn0)
564
-
565
- for t_pre in pre_candidates_fine:
566
- t_post = float(total_sec) - float(t_pre) - float(t_turn0)
567
- if t_post < -1e-9:
568
- continue
569
- t_post = max(0.0, t_post)
570
 
571
- lat, lon = float(RADAR_FIX_LAT), float(RADAR_FIX_LON)
 
572
 
573
- if t_pre > 1e-9:
574
- dist_m = float(speed_knots) * 1852.0 * (float(t_pre) / 3600.0)
575
- lat, lon = _direct(method, lat, lon, h0, dist_m)
 
 
 
 
 
 
 
 
 
 
576
 
577
- lat, lon = _simulate_discretized_turn(
578
- method, lat, lon, speed_knots,
579
- h0, h1, t_turn0, sign, delta_abs, step_sec
580
- )
581
 
582
- if t_post > 1e-9:
583
- dist_m = float(speed_knots) * 1852.0 * (float(t_post) / 3600.0)
584
- lat, lon = _direct(method, lat, lon, h1, dist_m)
 
 
 
 
 
 
 
 
 
 
585
 
586
- err_km = _haversine_km(lat, lon, float(target_lat), float(target_lon))
 
587
 
588
- if err_km < best2["error_km"]:
589
- best2.update({
590
- "error_km": float(err_km),
591
- "t_pre": float(t_pre),
592
- "t_post": float(t_post),
593
- "end_lat": float(lat),
594
- "end_lon": float(lon),
595
- })
596
 
597
- # Build legs list from best2
598
- t_pre = best2["t_pre"]
599
- t_turn = best2["t_turn"]
600
- t_post = best2["t_post"]
601
- bank_deg = best2["bank_deg"]
602
 
603
- legs = []
604
- if t_pre > 1.0:
605
- legs.append((h0, float(speed_knots), float(t_pre) / 60.0))
606
-
607
- # discretized turn segments
608
- if t_turn > 1.0 and delta_abs > 1e-9:
609
- step_sec = max(5.0, float(step_sec))
610
- n = int(math.ceil(float(t_turn) / step_sec))
611
- if n < 1:
612
- n = 1
613
- remaining = float(t_turn)
614
- for i in range(n):
615
- this = min(step_sec, remaining)
616
- remaining -= this
617
- frac_mid = ((i + 0.5) / n)
618
- hdg_mid = _wrap_heading_deg(h0 + sign * delta_abs * frac_mid)
619
- legs.append((hdg_mid, float(speed_knots), float(this) / 60.0))
620
 
621
- if t_post > 1.0:
622
- legs.append((h1, float(speed_knots), float(t_post) / 60.0))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
623
 
624
  info = {
625
- "h0": h0,
626
- "h1": h1,
627
- "delta_deg": float(delta),
628
- "bank_deg": float(bank_deg),
629
- "pre_min": float(t_pre) / 60.0,
630
- "turn_min": float(t_turn) / 60.0,
631
- "post_min": float(t_post) / 60.0,
632
- "total_min": float(total_sec) / 60.0,
633
- "target_lat": float(target_lat),
634
- "target_lon": float(target_lon),
635
- "end_lat": float(best2["end_lat"]),
636
- "end_lon": float(best2["end_lon"]),
637
- "error_km": float(best2["error_km"]),
638
- "L_km": float(L_km),
639
- "d_km": float(d_km),
640
- "tol_km": float(TURN_POINT_TOL_KM),
641
  }
642
  return legs, info
643
 
@@ -832,10 +805,6 @@ def _map_marker_times(start_dt, legs):
832
 
833
  # ================= CLIP TRAJECTORY TO 00:20 (GUIDED) =================
834
  def clip_legs_to_end_dt(legs, start_dt: datetime, end_dt: datetime):
835
- """
836
- Returns a new legs list truncated so total duration ends exactly at end_dt.
837
- If legs already shorter than end_dt-start_dt, returns legs unchanged.
838
- """
839
  total_target_sec = (end_dt - start_dt).total_seconds()
840
  if total_target_sec <= 0:
841
  return []
@@ -851,7 +820,6 @@ def clip_legs_to_end_dt(legs, start_dt: datetime, end_dt: datetime):
851
  out.append((float(hdg), float(spd), float(minutes)))
852
  remaining -= sec
853
  else:
854
- # truncate this leg
855
  trunc_min = max(0.0, remaining / 60.0)
856
  if trunc_min > 1e-6:
857
  out.append((float(hdg), float(spd), float(trunc_min)))
@@ -1088,13 +1056,6 @@ def add_permanent_label(m, name, lat, lon):
1088
 
1089
 
1090
  def build_leaflet_map(path_pts, legs, method, start_dt):
1091
- """
1092
- - Ping rings always drawn
1093
- - Search areas always drawn (color-coded)
1094
- - Validation markers are the authoritative markers
1095
- - Guided mode does NOT draw a purple End marker
1096
- - Trajectory is clipped to 00:20 in guided mode by clipping legs before simulate_path
1097
- """
1098
  start_lat, start_lon = path_pts[0]
1099
  is_guided = (start_dt == RADAR_FIX_TIME_UTC)
1100
 
@@ -1239,7 +1200,6 @@ def build_leaflet_map(path_pts, legs, method, start_dt):
1239
  f"Distance flown: {dist_km:.2f} km",
1240
  ]
1241
 
1242
- # Make final point visually distinct
1243
  if is_final:
1244
  folium.CircleMarker(
1245
  location=(lat, lon),
@@ -1263,7 +1223,6 @@ def build_leaflet_map(path_pts, legs, method, start_dt):
1263
  popup=folium.Popup("<br>".join(popup_lines), max_width=360),
1264
  ).add_to(m)
1265
 
1266
- # Permanent labels
1267
  for name, lat, lon in WAYPOINTS:
1268
  add_permanent_label(m, name, float(lat), float(lon))
1269
 
@@ -1353,7 +1312,6 @@ def solve_guided_straight(
1353
  descent_start_choice, weight_bfo, weight_bto,
1354
  turn_complete_lat, turn_complete_lon
1355
  ):
1356
- # We clip everything to final 00:20Z; end_dt is fixed by label.
1357
  start_dt = _guided_start_dt()
1358
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1359
 
@@ -1362,7 +1320,6 @@ def solve_guided_straight(
1362
  best_score = None
1363
  best_turn_info = None
1364
 
1365
- # descent start
1366
  descent_start_dt = None
1367
  if (descent_start_choice or "").startswith("After Ping Ring 6"):
1368
  descent_start_dt = dt_ping6_utc()
@@ -1371,27 +1328,21 @@ def solve_guided_straight(
1371
 
1372
  h = GUIDED_HEADING_MIN
1373
  while h <= GUIDED_HEADING_MAX + 1e-9:
1374
- southern_legs = make_legs_from_schedule(h, float(speed_knots), GUIDED_LEG_SCHEDULE_MIN)
1375
-
1376
- preturn_legs, turn_info = build_turn_legs_to_point(
1377
- speed_knots=float(speed_knots),
1378
  method=method,
 
1379
  heading_final_deg=float(h),
1380
  target_lat=float(turn_complete_lat),
1381
  target_lon=float(turn_complete_lon),
1382
- bank_pref_deg=TURN_BANK_DEFAULT_DEG,
1383
- step_sec=TURN_STEP_SEC
1384
  )
1385
- if not preturn_legs and isinstance(turn_info, dict) and turn_info.get("error"):
1386
- h += STRAIGHT_STEP_DEG
1387
- continue
1388
 
1389
- full_legs = preturn_legs + southern_legs
 
1390
 
1391
- # clip to 00:20 so scoring window and plotting window are consistent
1392
  full_legs = clip_legs_to_end_dt(full_legs, start_dt, end_dt)
1393
 
1394
- # score using BFO/BTO inside start_dt→end_dt duration only
1395
  s = score_legs_bfo_bto(
1396
  RADAR_FIX_LAT, RADAR_FIX_LON, method, start_dt, end_dt, full_legs,
1397
  alt_ft, vs_fpm, descent_start_dt,
@@ -1406,21 +1357,15 @@ def solve_guided_straight(
1406
  h += STRAIGHT_STEP_DEG
1407
 
1408
  if best_full_legs is None:
1409
- return None, "Guided solve: not enough ping points to score (or 18:40 point unreachable at chosen speed).", None
1410
-
1411
- turn_line = ""
1412
- if isinstance(best_turn_info, dict) and "error_km" in best_turn_info:
1413
- ek = float(best_turn_info["error_km"])
1414
- if ek <= TURN_POINT_TOL_KM:
1415
- turn_line = f"- 18:40 target hit: **OK** (error {ek:.2f} km)\n"
1416
- else:
1417
- turn_line = f"- 18:40 target hit: **approx** (error {ek:.2f} km)\n"
1418
 
 
1419
  report = "\n".join([
1420
- "**Guided mode: Turn-to-18:40 (user lat/lon) + southern solve, clipped to 00:20**",
1421
- f"- Speed (constrained): **{float(speed_knots):.1f} kt**",
1422
- f"- Turn completion target (18:40): **lat {float(turn_complete_lat):.5f}, lon {float(turn_complete_lon):.5f}**",
1423
- turn_line.rstrip(),
 
1424
  f"- Best southern heading: **{best_h:.1f}°**",
1425
  f"- BFO points: **{best_score['n_bfo']}** | RMSE_BFO: **{best_score['rmse_bfo']:.2f} Hz**",
1426
  f"- BTO points: **{best_score['n_bto']}** | RMSE_BTO: **{best_score['rmse_bto']:.1f} µs**" if best_score.get("rmse_bto") is not None else
@@ -1436,7 +1381,6 @@ def solve_guided_straight_with_drift(
1436
  drift_max_deg, drift_weight,
1437
  turn_complete_lat, turn_complete_lon
1438
  ):
1439
- # lightweight drift version: use nominal heading search, but add a small constant drift
1440
  start_dt = _guided_start_dt()
1441
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1442
 
@@ -1445,14 +1389,12 @@ def solve_guided_straight_with_drift(
1445
  best_score = None
1446
  best_turn_info = None
1447
 
1448
- # descent start
1449
  descent_start_dt = None
1450
  if (descent_start_choice or "").startswith("After Ping Ring 6"):
1451
  descent_start_dt = dt_ping6_utc()
1452
  elif (descent_start_choice or "").startswith("After Ping Ring 7"):
1453
  descent_start_dt = dt_ping7_utc()
1454
 
1455
- # try drift candidates within ±drift_max_deg
1456
  drift_candidates = list(np.arange(-float(drift_max_deg), float(drift_max_deg) + 1e-9, DRIFT_STEP_DEG))
1457
  if not drift_candidates:
1458
  drift_candidates = [0.0]
@@ -1462,23 +1404,16 @@ def solve_guided_straight_with_drift(
1462
  for d in drift_candidates:
1463
  h_eff = _wrap_heading_deg(float(h0) + float(d))
1464
 
1465
- southern_legs = make_legs_from_schedule(h_eff, float(speed_knots), GUIDED_LEG_SCHEDULE_MIN)
1466
-
1467
- preturn_legs, turn_info = build_turn_legs_to_point(
1468
- speed_knots=float(speed_knots),
1469
  method=method,
 
1470
  heading_final_deg=float(h_eff),
1471
  target_lat=float(turn_complete_lat),
1472
  target_lon=float(turn_complete_lon),
1473
- bank_pref_deg=TURN_BANK_DEFAULT_DEG,
1474
- step_sec=TURN_STEP_SEC
1475
  )
1476
- if not preturn_legs and isinstance(turn_info, dict) and turn_info.get("error"):
1477
- continue
1478
 
 
1479
  full_legs = preturn_legs + southern_legs
1480
-
1481
- # clip to 00:20
1482
  full_legs = clip_legs_to_end_dt(full_legs, start_dt, end_dt)
1483
 
1484
  s = score_legs_bfo_bto(
@@ -1489,7 +1424,6 @@ def solve_guided_straight_with_drift(
1489
  if s is None:
1490
  continue
1491
 
1492
- # add drift penalty (RMS here is just |d| for constant drift)
1493
  combined = float(s["combined"]) + float(drift_weight) * (abs(float(d)) / DRIFT_SCALE_DEG)
1494
 
1495
  if best_score is None or combined < best_score["combined_plus"]:
@@ -1503,21 +1437,15 @@ def solve_guided_straight_with_drift(
1503
  h0 += 1.0
1504
 
1505
  if best_full_legs is None:
1506
- return None, "Guided drift solve: not enough ping points to score (or 18:40 point unreachable at chosen speed).", None
1507
-
1508
- turn_line = ""
1509
- if isinstance(best_turn_info, dict) and "error_km" in best_turn_info:
1510
- ek = float(best_turn_info["error_km"])
1511
- if ek <= TURN_POINT_TOL_KM:
1512
- turn_line = f"- 18:40 target hit: **OK** (error {ek:.2f} km)\n"
1513
- else:
1514
- turn_line = f"- 18:40 target hit: **approx** (error {ek:.2f} km)\n"
1515
 
 
1516
  report = "\n".join([
1517
- "**Guided mode: Turn-to-18:40 (user lat/lon) + southern solve (drift), clipped to 00:20**",
1518
- f"- Speed (constrained): **{float(speed_knots):.1f} kt**",
1519
- f"- Turn completion target (18:40): **lat {float(turn_complete_lat):.5f}, lon {float(turn_complete_lon):.5f}**",
1520
- turn_line.rstrip(),
 
1521
  f"- Best nominal heading: **{best_h0:.1f}°**",
1522
  f"- Drift used: **{best_score.get('drift_used', 0.0):+.1f}°** | Drift penalty weight: **{float(drift_weight):.2f}**",
1523
  f"- BFO points: **{best_score['n_bfo']}** | RMSE_BFO: **{best_score['rmse_bfo']:.2f} Hz**",
@@ -1604,7 +1532,6 @@ def score_legs_bfo_bto(
1604
 
1605
  # ================= RUNNERS =================
1606
  def _run_common(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice):
1607
- # In guided mode, ensure the polyline ends at 00:20 by clipping legs to end_dt first.
1608
  if start_dt == RADAR_FIX_TIME_UTC:
1609
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1610
  legs = clip_legs_to_end_dt(legs, start_dt, end_dt)
@@ -1614,7 +1541,6 @@ def _run_common(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, de
1614
  html_map = build_leaflet_map(path_pts, legs, method, start_dt)
1615
  stats_md = compute_stats(path_pts, seg_dists_m, total_time_min, legs)
1616
 
1617
- # BFO plot (restored)
1618
  bfo_fig, bfo_md = bfo_analysis_with_legs(
1619
  start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice
1620
  )
@@ -1650,7 +1576,6 @@ def run_guided(
1650
  drift_max_deg, drift_weight,
1651
  turn_complete_lat, turn_complete_lon
1652
  ):
1653
- # NOTE: start_lat/start_lon inputs are manual-only; guided always uses RADAR_FIX_LAT/LON.
1654
  spd_knots = speed_from_profile(speed_profile, alt_ft, custom_mach, custom_knots)
1655
 
1656
  if (guided_scenario or "").startswith("Straight + drift"):
@@ -1681,7 +1606,6 @@ def run_guided(
1681
  ], value=[])
1682
  return gr.update(), f"### ❌ {report_md}", "", "", fig, "", alt_fig, empty_table
1683
 
1684
- # Guided always starts at radar fix
1685
  html_map, stats_md, bfo_fig, bfo_md, alt_fig, headers, rows = _run_common(
1686
  RADAR_FIX_LAT, RADAR_FIX_LON, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice
1687
  )
@@ -1713,7 +1637,7 @@ with gr.Blocks() as demo:
1713
  f"- **Turn completion time (fixed):** 18:40:00Z\n"
1714
  f"- **Initial track (fixed):** {RADAR_INITIAL_TRACK_DEG:.1f}°\n"
1715
  f"- **Final resting point:** **00:20:00Z** (trajectory clipped exactly at this time)\n\n"
1716
- "✅ **New:** Use the sliders below to set the **18:40 turn-completion lat/lon**. Guided mode will build a plausible turn that ends at that point."
1717
  )
1718
 
1719
  gr.Markdown("### 18:40 Turn Completion Location (Guided mode)")
@@ -1750,7 +1674,7 @@ with gr.Blocks() as demo:
1750
  speed_profile = gr.Dropdown(
1751
  ["MRC Mach 0.82", "LRC Mach 0.84", "ECON Mach 0.80", "Custom Mach", "Custom knots"],
1752
  value="MRC Mach 0.82",
1753
- label="Speed profile (constrains speed during turn + southern leg)",
1754
  )
1755
  custom_mach = gr.Number(value=0.82, label="Custom Mach (if selected)")
1756
  custom_knots = gr.Number(value=470, label="Custom knots (if selected)")
 
8
  # Key behavior:
9
  # - Last radar spot FIXED: 18:22:00Z @ 6.578N, 96.341E
10
  # - Turn completion FIXED TIME: 18:40:00Z
11
+ # - 18:40 lat/lon is AUTHORITATIVE (hard-fixed) via sliders.
12
+ # - NEW (important): Program finds the *best* turn (bank + timing + speed factor) automatically.
13
+ # No user bank angle. No ugly kinked paths.
14
  # - Guided validation timestamps: 18:22, 18:40, 19:40, 20:40, 21:40, 22:40, 00:10, 00:20
15
  #
16
+ # FIXES IMPLEMENTED:
17
+ # 1) Remove trajectory (red line) AFTER 00:20 (guided) by clipping legs to 00:20
18
+ # 2) Restore BFO plot
19
+ # 3) 18:40 is a hard waypoint (no “approx error”); solver produces a smooth turn and then snaps a *tiny*
20
+ # correction segment (normally meters) if needed.
 
21
 
22
  import os
23
  import re
 
88
  # Initial track shown in your radar annotation
89
  RADAR_INITIAL_TRACK_DEG = 296.0
90
 
91
+ # ===== Default 18:40 turn completion location (user can override with sliders) =====
92
  TURN_COMPLETE_DEFAULT_LAT = 6.76
93
  TURN_COMPLETE_DEFAULT_LON = 95.69
94
 
 
106
  ]
107
  GUIDED_FINAL_LABEL = "24:20:00" # 00:20Z FINAL RESTING POINT
108
 
109
+ # Turn solver search space (automatic)
110
+ TURN_BANK_MIN_DEG = 10.0
111
+ TURN_BANK_MAX_DEG = 35.0
 
 
112
 
113
+ TURN_SIM_DT_SEC = 10.0 # internal turn integration resolution (smaller = smoother, slower)
114
+ TURN_PRE_FRAC_STEP_COARSE = 0.05
115
+ TURN_PRE_FRAC_STEP_FINE = 0.01
116
+ TURN_BANK_STEP_COARSE = 1.0
117
+ TURN_BANK_STEP_FINE = 0.25
118
+ TURN_SPEEDFAC_STEP_COARSE = 0.02
119
+ TURN_SPEEDFAC_STEP_FINE = 0.005
120
+
121
+ TURN_SPEEDFAC_MIN = 0.70
122
+ TURN_SPEEDFAC_MAX = 1.30
123
+
124
+ # If the best-fit endpoint is still off by a little, add a tiny correction segment to hit the waypoint exactly.
125
+ TURN_FINAL_SNAP_MAX_KM = 2.0 # if worse than this, we report it (should not happen normally)
126
+ TURN_FINAL_SNAP_SEC = 10.0 # duration of final snap segment (small)
127
 
128
  # Guided heading search (south-ish)
129
  GUIDED_HEADING_MIN = 150.0
 
137
  DEFAULT_DRIFT_MAX_DEG = 2.0
138
  DRIFT_STEP_DEG = 0.5
139
  DEFAULT_WEIGHT_DRIFT = 0.30
 
140
  DRIFT_SCALE_DEG = 1.0
141
 
142
  # Search area colors (optional)
 
395
 
396
 
397
  def _haversine_km(lat1, lon1, lat2, lon2) -> float:
 
398
  R = 6371.0
399
  phi1 = math.radians(float(lat1))
400
  phi2 = math.radians(float(lat2))
 
404
  return 2 * R * math.asin(min(1.0, math.sqrt(a)))
405
 
406
 
407
+ def _initial_bearing_deg(lat1, lon1, lat2, lon2) -> float:
408
+ phi1 = math.radians(float(lat1))
409
+ phi2 = math.radians(float(lat2))
410
+ dl = math.radians(float(lon2) - float(lon1))
411
+ y = math.sin(dl) * math.cos(phi2)
412
+ x = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(dl)
413
+ brng = math.degrees(math.atan2(y, x))
414
+ return (brng + 360.0) % 360.0
415
+
416
+
417
  def _direct(method: str, lat: float, lon: float, heading_deg: float, dist_m: float):
418
  if method == "VINCENTY":
419
  (lat2, lon2), _ = fp.vincenty_direct((float(lat), float(lon)), float(heading_deg), float(dist_m))
 
422
  return float(lat2), float(lon2)
423
 
424
 
425
+ # ================= TURN SOLVER (AUTOMATIC “BEST TURN”) =================
426
+ def _simulate_turn_window(
427
  method: str,
 
 
428
  speed_knots: float,
429
  h0: float,
430
  h1: float,
431
+ bank_deg: float,
432
+ pre_frac: float,
433
+ dt_sec: float,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  ):
435
  """
436
+ Simulate 18:22→18:40 as:
437
+ pre-straight at h0 for t_pre
438
+ constant-bank coordinated turn changing heading at omega for t_turn (to reach h1)
439
+ post-straight at h1 for remaining time
440
+ Returns final (lat,lon) plus a list of micro-legs (heading, speed, minutes) for the full window.
 
 
 
 
 
 
441
  """
442
  total_sec = (TURN_COMPLETE_TIME_UTC - RADAR_FIX_TIME_UTC).total_seconds()
443
  if total_sec <= 0:
444
+ return (RADAR_FIX_LAT, RADAR_FIX_LON), []
445
+
446
+ bank_deg = float(bank_deg)
447
+ speed_knots = float(speed_knots)
448
+ dt_sec = max(5.0, float(dt_sec))
449
 
 
 
450
  delta = _shortest_signed_delta_deg(h0, h1)
451
  sign = 1.0 if delta >= 0 else -1.0
452
  delta_abs = abs(delta)
453
 
454
+ # turn time based on coordinated turn rate
455
+ rate = max(1e-6, _turn_rate_deg_per_sec(speed_knots, bank_deg))
456
+ t_turn = delta_abs / rate
457
+ t_turn = min(t_turn, total_sec)
 
 
 
 
 
 
 
 
 
 
 
458
 
459
+ rem = max(0.0, total_sec - t_turn)
460
+ t_pre = float(pre_frac) * rem
461
+ t_post = rem - t_pre
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
 
463
+ lat, lon = float(RADAR_FIX_LAT), float(RADAR_FIX_LON)
464
+ legs = []
465
 
466
+ def _do_segment(hdg, seg_sec):
467
+ nonlocal lat, lon, legs
468
+ if seg_sec <= 0.1:
469
+ return
470
+ # break into dt steps
471
+ n = int(math.ceil(seg_sec / dt_sec))
472
+ for i in range(n):
473
+ this = min(dt_sec, seg_sec - i * dt_sec)
474
+ if this <= 0:
475
+ break
476
+ legs.append((float(hdg), float(speed_knots), float(this) / 60.0))
477
+ dist_m = speed_knots * 1852.0 * (this / 3600.0)
478
+ lat, lon = _direct(method, lat, lon, float(hdg), dist_m)
479
 
480
+ # pre straight
481
+ _do_segment(h0, t_pre)
 
 
482
 
483
+ # turn: heading changes smoothly while moving
484
+ if t_turn > 0.1 and delta_abs > 1e-6:
485
+ n = int(math.ceil(t_turn / dt_sec))
486
+ for i in range(n):
487
+ this = min(dt_sec, t_turn - i * dt_sec)
488
+ if this <= 0:
489
+ break
490
+ # heading at mid-step
491
+ frac = (min(t_turn, (i + 0.5) * dt_sec) / t_turn)
492
+ hdg = _wrap_heading_deg(h0 + sign * delta_abs * frac)
493
+ legs.append((float(hdg), float(speed_knots), float(this) / 60.0))
494
+ dist_m = speed_knots * 1852.0 * (this / 3600.0)
495
+ lat, lon = _direct(method, lat, lon, float(hdg), dist_m)
496
 
497
+ # post straight
498
+ _do_segment(h1, t_post)
499
 
500
+ return (lat, lon), legs
 
 
 
 
 
 
 
501
 
 
 
 
 
 
502
 
503
+ def build_turn_legs_best(
504
+ method: str,
505
+ speed_knots_nominal: float,
506
+ heading_final_deg: float,
507
+ target_lat: float,
508
+ target_lon: float,
509
+ ):
510
+ """
511
+ Finds the best (bank, pre_straight_frac, speed_factor) to land at 18:40 as smoothly as possible.
512
+ Produces legs for 18:22→18:40 that are already smooth.
513
+ If residual error remains, adds a tiny “snap” segment to hit the waypoint exactly (usually meters).
514
+ """
515
+ h0 = float(RADAR_INITIAL_TRACK_DEG)
516
+ h1 = _wrap_heading_deg(float(heading_final_deg))
 
 
 
517
 
518
+ best = None
519
+
520
+ # ---- coarse search
521
+ bank_vals = np.arange(TURN_BANK_MIN_DEG, TURN_BANK_MAX_DEG + 1e-9, TURN_BANK_STEP_COARSE)
522
+ pre_vals = np.arange(0.0, 1.0 + 1e-9, TURN_PRE_FRAC_STEP_COARSE)
523
+ spd_vals = np.arange(TURN_SPEEDFAC_MIN, TURN_SPEEDFAC_MAX + 1e-9, TURN_SPEEDFAC_STEP_COARSE)
524
+
525
+ for bank in bank_vals:
526
+ for pre in pre_vals:
527
+ for sf in spd_vals:
528
+ spd = float(speed_knots_nominal) * float(sf)
529
+ (lat_end, lon_end), legs = _simulate_turn_window(
530
+ method=method,
531
+ speed_knots=spd,
532
+ h0=h0,
533
+ h1=h1,
534
+ bank_deg=bank,
535
+ pre_frac=pre,
536
+ dt_sec=TURN_SIM_DT_SEC,
537
+ )
538
+ err_km = _haversine_km(lat_end, lon_end, float(target_lat), float(target_lon))
539
+ if best is None or err_km < best["err_km"]:
540
+ best = {
541
+ "bank": float(bank),
542
+ "pre": float(pre),
543
+ "sf": float(sf),
544
+ "spd": float(spd),
545
+ "err_km": float(err_km),
546
+ "legs": legs,
547
+ "lat_end": float(lat_end),
548
+ "lon_end": float(lon_end),
549
+ }
550
+
551
+ # ---- fine search around best
552
+ b0 = best["bank"]
553
+ p0 = best["pre"]
554
+ sf0 = best["sf"]
555
+
556
+ bank_vals = np.arange(max(TURN_BANK_MIN_DEG, b0 - 2.0), min(TURN_BANK_MAX_DEG, b0 + 2.0) + 1e-9, TURN_BANK_STEP_FINE)
557
+ pre_vals = np.arange(max(0.0, p0 - 0.10), min(1.0, p0 + 0.10) + 1e-9, TURN_PRE_FRAC_STEP_FINE)
558
+ spd_vals = np.arange(max(TURN_SPEEDFAC_MIN, sf0 - 0.05), min(TURN_SPEEDFAC_MAX, sf0 + 0.05) + 1e-9, TURN_SPEEDFAC_STEP_FINE)
559
+
560
+ for bank in bank_vals:
561
+ for pre in pre_vals:
562
+ for sf in spd_vals:
563
+ spd = float(speed_knots_nominal) * float(sf)
564
+ (lat_end, lon_end), legs = _simulate_turn_window(
565
+ method=method,
566
+ speed_knots=spd,
567
+ h0=h0,
568
+ h1=h1,
569
+ bank_deg=bank,
570
+ pre_frac=pre,
571
+ dt_sec=TURN_SIM_DT_SEC,
572
+ )
573
+ err_km = _haversine_km(lat_end, lon_end, float(target_lat), float(target_lon))
574
+ if err_km < best["err_km"]:
575
+ best = {
576
+ "bank": float(bank),
577
+ "pre": float(pre),
578
+ "sf": float(sf),
579
+ "spd": float(spd),
580
+ "err_km": float(err_km),
581
+ "legs": legs,
582
+ "lat_end": float(lat_end),
583
+ "lon_end": float(lon_end),
584
+ }
585
+
586
+ legs = list(best["legs"])
587
+ lat_end = best["lat_end"]
588
+ lon_end = best["lon_end"]
589
+ err_km = best["err_km"]
590
+
591
+ # Tiny snap-to-waypoint if needed, so 18:40 is exact.
592
+ # We keep it extremely small by relying on the search to get close.
593
+ snap_added = False
594
+ if err_km > 1e-6:
595
+ if err_km > TURN_FINAL_SNAP_MAX_KM:
596
+ # still force exact, but we will report it (this indicates search bounds are too tight)
597
+ pass
598
+ snap_sec = float(TURN_FINAL_SNAP_SEC)
599
+ hdg = _initial_bearing_deg(lat_end, lon_end, float(target_lat), float(target_lon))
600
+ dist_m = err_km * 1000.0
601
+ spd_kn = (dist_m / 1852.0) / (snap_sec / 3600.0)
602
+ legs.append((float(hdg), float(spd_kn), snap_sec / 60.0))
603
+ snap_added = True
604
 
605
  info = {
606
+ "bank_deg": float(best["bank"]),
607
+ "pre_straight_frac": float(best["pre"]),
608
+ "speed_factor": float(best["sf"]),
609
+ "turn_speed_knots": float(best["spd"]),
610
+ "end_error_km_before_snap": float(err_km),
611
+ "snap_added": bool(snap_added),
612
+ "h0": float(h0),
613
+ "h1": float(h1),
 
 
 
 
 
 
 
 
614
  }
615
  return legs, info
616
 
 
805
 
806
  # ================= CLIP TRAJECTORY TO 00:20 (GUIDED) =================
807
  def clip_legs_to_end_dt(legs, start_dt: datetime, end_dt: datetime):
 
 
 
 
808
  total_target_sec = (end_dt - start_dt).total_seconds()
809
  if total_target_sec <= 0:
810
  return []
 
820
  out.append((float(hdg), float(spd), float(minutes)))
821
  remaining -= sec
822
  else:
 
823
  trunc_min = max(0.0, remaining / 60.0)
824
  if trunc_min > 1e-6:
825
  out.append((float(hdg), float(spd), float(trunc_min)))
 
1056
 
1057
 
1058
  def build_leaflet_map(path_pts, legs, method, start_dt):
 
 
 
 
 
 
 
1059
  start_lat, start_lon = path_pts[0]
1060
  is_guided = (start_dt == RADAR_FIX_TIME_UTC)
1061
 
 
1200
  f"Distance flown: {dist_km:.2f} km",
1201
  ]
1202
 
 
1203
  if is_final:
1204
  folium.CircleMarker(
1205
  location=(lat, lon),
 
1223
  popup=folium.Popup("<br>".join(popup_lines), max_width=360),
1224
  ).add_to(m)
1225
 
 
1226
  for name, lat, lon in WAYPOINTS:
1227
  add_permanent_label(m, name, float(lat), float(lon))
1228
 
 
1312
  descent_start_choice, weight_bfo, weight_bto,
1313
  turn_complete_lat, turn_complete_lon
1314
  ):
 
1315
  start_dt = _guided_start_dt()
1316
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1317
 
 
1320
  best_score = None
1321
  best_turn_info = None
1322
 
 
1323
  descent_start_dt = None
1324
  if (descent_start_choice or "").startswith("After Ping Ring 6"):
1325
  descent_start_dt = dt_ping6_utc()
 
1328
 
1329
  h = GUIDED_HEADING_MIN
1330
  while h <= GUIDED_HEADING_MAX + 1e-9:
1331
+ # 1) best smooth turn to the authoritative 18:40 waypoint
1332
+ preturn_legs, turn_info = build_turn_legs_best(
 
 
1333
  method=method,
1334
+ speed_knots_nominal=float(speed_knots),
1335
  heading_final_deg=float(h),
1336
  target_lat=float(turn_complete_lat),
1337
  target_lon=float(turn_complete_lon),
 
 
1338
  )
 
 
 
1339
 
1340
+ # 2) southern legs
1341
+ southern_legs = make_legs_from_schedule(float(h), float(speed_knots), GUIDED_LEG_SCHEDULE_MIN)
1342
 
1343
+ full_legs = preturn_legs + southern_legs
1344
  full_legs = clip_legs_to_end_dt(full_legs, start_dt, end_dt)
1345
 
 
1346
  s = score_legs_bfo_bto(
1347
  RADAR_FIX_LAT, RADAR_FIX_LON, method, start_dt, end_dt, full_legs,
1348
  alt_ft, vs_fpm, descent_start_dt,
 
1357
  h += STRAIGHT_STEP_DEG
1358
 
1359
  if best_full_legs is None:
1360
+ return None, "Guided solve: not enough ping points to score.", None
 
 
 
 
 
 
 
 
1361
 
1362
+ ti = best_turn_info or {}
1363
  report = "\n".join([
1364
+ "**Guided mode: 18:40 waypoint fixed (authoritative) + BEST smooth turn + southern solve, clipped to 00:20**",
1365
+ f"- Speed (south leg constrained): **{float(speed_knots):.1f} kt**",
1366
+ f"- Turn completion (18:40 fixed): **lat {float(turn_complete_lat):.5f}, lon {float(turn_complete_lon):.5f}**",
1367
+ f"- Best turn bank: **{ti.get('bank_deg', float('nan')):.2f}°** | pre-straight frac: **{ti.get('pre_straight_frac', float('nan')):.2f}** | turn speed factor: **{ti.get('speed_factor', float('nan')):.3f}**",
1368
+ f"- 18:40 residual error before snap: **{ti.get('end_error_km_before_snap', 0.0):.3f} km** | snap added: **{ti.get('snap_added', False)}**",
1369
  f"- Best southern heading: **{best_h:.1f}°**",
1370
  f"- BFO points: **{best_score['n_bfo']}** | RMSE_BFO: **{best_score['rmse_bfo']:.2f} Hz**",
1371
  f"- BTO points: **{best_score['n_bto']}** | RMSE_BTO: **{best_score['rmse_bto']:.1f} µs**" if best_score.get("rmse_bto") is not None else
 
1381
  drift_max_deg, drift_weight,
1382
  turn_complete_lat, turn_complete_lon
1383
  ):
 
1384
  start_dt = _guided_start_dt()
1385
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1386
 
 
1389
  best_score = None
1390
  best_turn_info = None
1391
 
 
1392
  descent_start_dt = None
1393
  if (descent_start_choice or "").startswith("After Ping Ring 6"):
1394
  descent_start_dt = dt_ping6_utc()
1395
  elif (descent_start_choice or "").startswith("After Ping Ring 7"):
1396
  descent_start_dt = dt_ping7_utc()
1397
 
 
1398
  drift_candidates = list(np.arange(-float(drift_max_deg), float(drift_max_deg) + 1e-9, DRIFT_STEP_DEG))
1399
  if not drift_candidates:
1400
  drift_candidates = [0.0]
 
1404
  for d in drift_candidates:
1405
  h_eff = _wrap_heading_deg(float(h0) + float(d))
1406
 
1407
+ preturn_legs, turn_info = build_turn_legs_best(
 
 
 
1408
  method=method,
1409
+ speed_knots_nominal=float(speed_knots),
1410
  heading_final_deg=float(h_eff),
1411
  target_lat=float(turn_complete_lat),
1412
  target_lon=float(turn_complete_lon),
 
 
1413
  )
 
 
1414
 
1415
+ southern_legs = make_legs_from_schedule(h_eff, float(speed_knots), GUIDED_LEG_SCHEDULE_MIN)
1416
  full_legs = preturn_legs + southern_legs
 
 
1417
  full_legs = clip_legs_to_end_dt(full_legs, start_dt, end_dt)
1418
 
1419
  s = score_legs_bfo_bto(
 
1424
  if s is None:
1425
  continue
1426
 
 
1427
  combined = float(s["combined"]) + float(drift_weight) * (abs(float(d)) / DRIFT_SCALE_DEG)
1428
 
1429
  if best_score is None or combined < best_score["combined_plus"]:
 
1437
  h0 += 1.0
1438
 
1439
  if best_full_legs is None:
1440
+ return None, "Guided drift solve: not enough ping points to score.", None
 
 
 
 
 
 
 
 
1441
 
1442
+ ti = best_turn_info or {}
1443
  report = "\n".join([
1444
+ "**Guided mode: 18:40 waypoint fixed (authoritative) + BEST smooth turn + southern solve (drift), clipped to 00:20**",
1445
+ f"- Speed (south leg constrained): **{float(speed_knots):.1f} kt**",
1446
+ f"- Turn completion (18:40 fixed): **lat {float(turn_complete_lat):.5f}, lon {float(turn_complete_lon):.5f}**",
1447
+ f"- Best turn bank: **{ti.get('bank_deg', float('nan')):.2f}°** | pre-straight frac: **{ti.get('pre_straight_frac', float('nan')):.2f}** | turn speed factor: **{ti.get('speed_factor', float('nan')):.3f}**",
1448
+ f"- 18:40 residual error before snap: **{ti.get('end_error_km_before_snap', 0.0):.3f} km** | snap added: **{ti.get('snap_added', False)}**",
1449
  f"- Best nominal heading: **{best_h0:.1f}°**",
1450
  f"- Drift used: **{best_score.get('drift_used', 0.0):+.1f}°** | Drift penalty weight: **{float(drift_weight):.2f}**",
1451
  f"- BFO points: **{best_score['n_bfo']}** | RMSE_BFO: **{best_score['rmse_bfo']:.2f} Hz**",
 
1532
 
1533
  # ================= RUNNERS =================
1534
  def _run_common(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice):
 
1535
  if start_dt == RADAR_FIX_TIME_UTC:
1536
  end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
1537
  legs = clip_legs_to_end_dt(legs, start_dt, end_dt)
 
1541
  html_map = build_leaflet_map(path_pts, legs, method, start_dt)
1542
  stats_md = compute_stats(path_pts, seg_dists_m, total_time_min, legs)
1543
 
 
1544
  bfo_fig, bfo_md = bfo_analysis_with_legs(
1545
  start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice
1546
  )
 
1576
  drift_max_deg, drift_weight,
1577
  turn_complete_lat, turn_complete_lon
1578
  ):
 
1579
  spd_knots = speed_from_profile(speed_profile, alt_ft, custom_mach, custom_knots)
1580
 
1581
  if (guided_scenario or "").startswith("Straight + drift"):
 
1606
  ], value=[])
1607
  return gr.update(), f"### ❌ {report_md}", "", "", fig, "", alt_fig, empty_table
1608
 
 
1609
  html_map, stats_md, bfo_fig, bfo_md, alt_fig, headers, rows = _run_common(
1610
  RADAR_FIX_LAT, RADAR_FIX_LON, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice
1611
  )
 
1637
  f"- **Turn completion time (fixed):** 18:40:00Z\n"
1638
  f"- **Initial track (fixed):** {RADAR_INITIAL_TRACK_DEG:.1f}°\n"
1639
  f"- **Final resting point:** **00:20:00Z** (trajectory clipped exactly at this time)\n\n"
1640
+ "✅ **Auto-turn enabled:** Program finds the best bank + timing + speed-factor to reach the fixed 18:40 waypoint smoothly."
1641
  )
1642
 
1643
  gr.Markdown("### 18:40 Turn Completion Location (Guided mode)")
 
1674
  speed_profile = gr.Dropdown(
1675
  ["MRC Mach 0.82", "LRC Mach 0.84", "ECON Mach 0.80", "Custom Mach", "Custom knots"],
1676
  value="MRC Mach 0.82",
1677
+ label="Speed profile (constrains southern leg; turn uses best-fit speed factor)",
1678
  )
1679
  custom_mach = gr.Number(value=0.82, label="Custom Mach (if selected)")
1680
  custom_knots = gr.Number(value=470, label="Custom knots (if selected)")