Spaces:
Sleeping
Sleeping
Update app.py
Browse files
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
|
| 12 |
-
# -
|
| 13 |
-
#
|
| 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
|
| 18 |
-
#
|
| 19 |
-
#
|
| 20 |
-
#
|
|
|
|
| 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
|
| 110 |
-
TURN_BANK_MIN_DEG =
|
| 111 |
-
TURN_BANK_MAX_DEG =
|
|
|
|
|
|
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 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 |
-
|
| 426 |
-
def _simulate_turn_window(
|
| 427 |
method: str,
|
|
|
|
|
|
|
| 428 |
speed_knots: float,
|
| 429 |
h0: float,
|
| 430 |
h1: float,
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
):
|
| 435 |
"""
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
"""
|
| 442 |
total_sec = (TURN_COMPLETE_TIME_UTC - RADAR_FIX_TIME_UTC).total_seconds()
|
| 443 |
if total_sec <= 0:
|
| 444 |
-
return
|
| 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 |
-
#
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
t_post = rem - t_pre
|
| 462 |
|
| 463 |
-
|
| 464 |
-
legs = []
|
| 465 |
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 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 |
-
|
| 481 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 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 |
-
|
| 498 |
-
|
| 499 |
|
| 500 |
-
|
|
|
|
|
|
|
|
|
|
| 501 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
|
| 518 |
-
|
| 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 |
-
"
|
| 607 |
-
"
|
| 608 |
-
"
|
| 609 |
-
"
|
| 610 |
-
"
|
| 611 |
-
"
|
| 612 |
-
"
|
| 613 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1332 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 1341 |
-
|
| 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
|
| 1365 |
-
f"- Speed (
|
| 1366 |
-
f"- Turn completion (18:40
|
| 1367 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1445 |
-
f"- Speed (
|
| 1446 |
-
f"- Turn completion (18:40
|
| 1447 |
-
|
| 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 |
-
"✅ **
|
| 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
|
| 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)")
|