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