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