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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +300 -224
app.py CHANGED
@@ -8,16 +8,17 @@
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,7 +89,7 @@ TURN_WINDOW_MIN = (TURN_COMPLETE_TIME_UTC - RADAR_FIX_TIME_UTC).total_seconds()
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,24 +107,16 @@ GUIDED_VALIDATION_LABELS = [
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,6 +130,7 @@ GUIDED_LEG_SCHEDULE_MIN = [12.0, 60.0, 60.0, 60.0, 60.0, 90.0, 10.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,6 +389,7 @@ def _turn_rate_deg_per_sec(speed_knots: float, bank_deg: float) -> float:
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,16 +399,6 @@ def _haversine_km(lat1, lon1, lat2, lon2) -> float:
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,195 +407,237 @@ def _direct(method: str, lat: float, lon: float, heading_deg: float, dist_m: flo
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,6 +832,10 @@ def _map_marker_times(start_dt, legs):
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,6 +851,7 @@ def clip_legs_to_end_dt(legs, start_dt: datetime, end_dt: datetime):
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,6 +1088,13 @@ def add_permanent_label(m, name, lat, lon):
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,6 +1239,7 @@ def build_leaflet_map(path_pts, legs, method, start_dt):
1200
  f"Distance flown: {dist_km:.2f} km",
1201
  ]
1202
 
 
1203
  if is_final:
1204
  folium.CircleMarker(
1205
  location=(lat, lon),
@@ -1223,6 +1263,7 @@ def build_leaflet_map(path_pts, legs, method, start_dt):
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,6 +1353,7 @@ def solve_guided_straight(
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,6 +1362,7 @@ def solve_guided_straight(
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,21 +1371,27 @@ def solve_guided_straight(
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,15 +1406,21 @@ def solve_guided_straight(
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,6 +1436,7 @@ def solve_guided_straight_with_drift(
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,12 +1445,14 @@ def solve_guided_straight_with_drift(
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,16 +1462,23 @@ def solve_guided_straight_with_drift(
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,6 +1489,7 @@ def solve_guided_straight_with_drift(
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,15 +1503,21 @@ def solve_guided_straight_with_drift(
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,6 +1604,7 @@ def score_legs_bfo_bto(
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,6 +1614,7 @@ def _run_common(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, de
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,6 +1650,7 @@ def run_guided(
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,6 +1681,7 @@ def run_guided(
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,7 +1713,7 @@ with gr.Blocks() as demo:
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,7 +1750,7 @@ with gr.Blocks() as demo:
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)")
 
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
  # 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
  ]
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
  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
 
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
  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
  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
 
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
  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
 
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
  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
  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
  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
  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
 
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
  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
  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
  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
  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
  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
  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
 
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
  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
  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
  ], 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
  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
  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)")