Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -17,12 +17,18 @@
|
|
| 17 |
# 2) Restore BFO plot (it disappeared previously)
|
| 18 |
# - Re-added full BFO analysis computation + chart + MAE/RMSE markdown.
|
| 19 |
#
|
| 20 |
-
# NEW (
|
| 21 |
# 3) Plausible final major turn (18:22→18:40) can adjust BOTH heading and speed
|
| 22 |
# - Turn speed is no longer fixed to the southern cruise speed.
|
| 23 |
# - The solver searches a plausible TURN speed range and chooses the best-scoring solution.
|
| 24 |
# - After 18:40, the southern trajectory continues “as per usual” using the selected speed profile.
|
| 25 |
# - Applies to BOTH scenarios: Straight and Straight + drift.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
import os
|
| 28 |
import re
|
|
@@ -113,27 +119,38 @@ TURN_BANK_MAX_DEG = 30.0
|
|
| 113 |
TURN_BANK_DEFAULT_DEG = 25.0
|
| 114 |
TURN_STEP_SEC = 30.0
|
| 115 |
|
| 116 |
-
#
|
| 117 |
TURN_SPEED_SEARCH_DELTA_KT = 40.0 # search ± around the southern cruise speed
|
| 118 |
-
TURN_SPEED_STEP_KT = 5.0 # candidate spacing
|
| 119 |
TURN_SPEED_MIN_ABS_KT = 350.0 # hard min plausible
|
| 120 |
TURN_SPEED_MAX_ABS_KT = 550.0 # hard max plausible
|
| 121 |
|
| 122 |
# Guided heading search (south-ish)
|
| 123 |
GUIDED_HEADING_MIN = 150.0
|
| 124 |
GUIDED_HEADING_MAX = 210.0
|
| 125 |
-
STRAIGHT_STEP_DEG = 0.5
|
| 126 |
|
| 127 |
# Southern schedule (kept)
|
| 128 |
GUIDED_LEG_SCHEDULE_MIN = [12.0, 60.0, 60.0, 60.0, 60.0, 90.0, 10.0]
|
| 129 |
|
| 130 |
# Drift
|
| 131 |
DEFAULT_DRIFT_MAX_DEG = 2.0
|
| 132 |
-
DRIFT_STEP_DEG = 0.5
|
| 133 |
DEFAULT_WEIGHT_DRIFT = 0.30
|
| 134 |
DEFAULT_DRIFT_ITERS = 2
|
| 135 |
DRIFT_SCALE_DEG = 1.0
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
# Search area colors (optional)
|
| 138 |
SEARCH_AREA_COLORS = {
|
| 139 |
"Ocean Infinity": "#ff3b3b",
|
|
@@ -1151,7 +1168,7 @@ def speed_from_profile(profile: str, alt_ft: float, custom_mach: float, custom_k
|
|
| 1151 |
return mach_to_tas_knots(0.82, alt_ft)
|
| 1152 |
|
| 1153 |
|
| 1154 |
-
def _turn_speed_candidates(south_speed_knots: float):
|
| 1155 |
"""
|
| 1156 |
Plausible turn-speed candidates around the southern cruise speed.
|
| 1157 |
Hard-clipped to [TURN_SPEED_MIN_ABS_KT, TURN_SPEED_MAX_ABS_KT].
|
|
@@ -1159,7 +1176,7 @@ def _turn_speed_candidates(south_speed_knots: float):
|
|
| 1159 |
base = float(south_speed_knots)
|
| 1160 |
lo = max(TURN_SPEED_MIN_ABS_KT, base - float(TURN_SPEED_SEARCH_DELTA_KT))
|
| 1161 |
hi = min(TURN_SPEED_MAX_ABS_KT, base + float(TURN_SPEED_SEARCH_DELTA_KT))
|
| 1162 |
-
step = max(1.0, float(
|
| 1163 |
if hi < lo:
|
| 1164 |
lo, hi = hi, lo
|
| 1165 |
cand = list(np.arange(lo, hi + 1e-9, step))
|
|
@@ -1170,6 +1187,28 @@ def _turn_speed_candidates(south_speed_knots: float):
|
|
| 1170 |
return cand
|
| 1171 |
|
| 1172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1173 |
# ================= Guided Legs Creation =================
|
| 1174 |
def make_legs_from_schedule(headings, speed_knots, schedule_minutes):
|
| 1175 |
legs = []
|
|
@@ -1183,6 +1222,86 @@ def make_legs_from_schedule(headings, speed_knots, schedule_minutes):
|
|
| 1183 |
return legs
|
| 1184 |
|
| 1185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1186 |
# ================= Guided Solvers =================
|
| 1187 |
def _guided_start_dt():
|
| 1188 |
return RADAR_FIX_TIME_UTC
|
|
@@ -1196,11 +1315,6 @@ def solve_guided_straight(
|
|
| 1196 |
start_dt = _guided_start_dt()
|
| 1197 |
end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
|
| 1198 |
|
| 1199 |
-
best_h = None
|
| 1200 |
-
best_turn_spd = None
|
| 1201 |
-
best_full_legs = None
|
| 1202 |
-
best_score = None
|
| 1203 |
-
|
| 1204 |
# descent start
|
| 1205 |
descent_start_dt = None
|
| 1206 |
if (descent_start_choice or "").startswith("After Ping Ring 6"):
|
|
@@ -1208,45 +1322,70 @@ def solve_guided_straight(
|
|
| 1208 |
elif (descent_start_choice or "").startswith("After Ping Ring 7"):
|
| 1209 |
descent_start_dt = dt_ping7_utc()
|
| 1210 |
|
| 1211 |
-
#
|
| 1212 |
-
|
| 1213 |
-
|
| 1214 |
-
|
| 1215 |
-
|
| 1216 |
-
|
| 1217 |
-
|
| 1218 |
-
|
| 1219 |
-
|
| 1220 |
-
|
| 1221 |
-
|
| 1222 |
-
|
| 1223 |
-
|
| 1224 |
-
|
| 1225 |
-
|
| 1226 |
-
|
| 1227 |
-
|
| 1228 |
-
|
| 1229 |
-
|
| 1230 |
-
|
| 1231 |
-
|
| 1232 |
-
|
| 1233 |
-
|
| 1234 |
-
|
| 1235 |
-
|
| 1236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1237 |
if s is not None and (best_score is None or s["combined"] < best_score["combined"]):
|
| 1238 |
best_score = s
|
| 1239 |
best_h = float(h)
|
| 1240 |
best_turn_spd = float(spd_turn)
|
| 1241 |
-
best_full_legs =
|
| 1242 |
-
|
| 1243 |
-
h += STRAIGHT_STEP_DEG
|
| 1244 |
|
| 1245 |
if best_full_legs is None:
|
| 1246 |
return None, "Guided solve: not enough ping points to score.", None
|
| 1247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1248 |
report = "\n".join([
|
| 1249 |
"**Guided mode: Straight + computed turn (18:22→18:40), clipped to 00:20**",
|
|
|
|
| 1250 |
f"- Southern speed (speed profile): **{float(south_speed_knots):.1f} kt**",
|
| 1251 |
f"- Best turn speed (18:22→18:40): **{float(best_turn_spd):.1f} kt**",
|
| 1252 |
f"- Best southern heading: **{best_h:.1f}°**",
|
|
@@ -1263,15 +1402,9 @@ def solve_guided_straight_with_drift(
|
|
| 1263 |
descent_start_choice, weight_bfo, weight_bto,
|
| 1264 |
drift_max_deg, drift_weight
|
| 1265 |
):
|
| 1266 |
-
# lightweight drift version: use nominal heading search, but add a small constant drift
|
| 1267 |
start_dt = _guided_start_dt()
|
| 1268 |
end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
|
| 1269 |
|
| 1270 |
-
best_h0 = None
|
| 1271 |
-
best_turn_spd = None
|
| 1272 |
-
best_full_legs = None
|
| 1273 |
-
best_score = None
|
| 1274 |
-
|
| 1275 |
# descent start
|
| 1276 |
descent_start_dt = None
|
| 1277 |
if (descent_start_choice or "").startswith("After Ping Ring 6"):
|
|
@@ -1279,64 +1412,113 @@ def solve_guided_straight_with_drift(
|
|
| 1279 |
elif (descent_start_choice or "").startswith("After Ping Ring 7"):
|
| 1280 |
descent_start_dt = dt_ping7_utc()
|
| 1281 |
|
| 1282 |
-
#
|
| 1283 |
-
|
| 1284 |
-
if
|
| 1285 |
-
|
| 1286 |
-
|
| 1287 |
-
|
| 1288 |
-
|
| 1289 |
-
|
| 1290 |
-
|
| 1291 |
-
|
| 1292 |
-
|
| 1293 |
-
|
| 1294 |
-
|
| 1295 |
-
|
| 1296 |
-
|
| 1297 |
-
|
| 1298 |
-
|
| 1299 |
-
|
| 1300 |
-
|
| 1301 |
-
|
| 1302 |
-
|
| 1303 |
-
|
| 1304 |
-
|
| 1305 |
-
|
| 1306 |
-
|
| 1307 |
-
|
| 1308 |
-
|
| 1309 |
-
|
| 1310 |
-
|
| 1311 |
-
|
| 1312 |
-
|
| 1313 |
-
|
| 1314 |
-
|
| 1315 |
-
|
| 1316 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1317 |
|
| 1318 |
-
|
| 1319 |
-
combined = float(s["combined"]) + float(drift_weight) * (abs(float(d)) / DRIFT_SCALE_DEG)
|
| 1320 |
|
| 1321 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1322 |
best_score = dict(s)
|
| 1323 |
-
|
| 1324 |
-
best_score["drift_used"] = float(d)
|
| 1325 |
best_h0 = float(h0)
|
|
|
|
| 1326 |
best_turn_spd = float(spd_turn)
|
| 1327 |
-
best_full_legs =
|
| 1328 |
-
|
| 1329 |
-
h0 += 1.0
|
| 1330 |
|
| 1331 |
if best_full_legs is None:
|
| 1332 |
return None, "Guided drift solve: not enough ping points to score.", None
|
| 1333 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1334 |
report = "\n".join([
|
| 1335 |
"**Guided mode: Straight + drift + computed turn (18:22→18:40), clipped to 00:20**",
|
|
|
|
| 1336 |
f"- Southern speed (speed profile): **{float(south_speed_knots):.1f} kt**",
|
| 1337 |
f"- Best turn speed (18:22→18:40): **{float(best_turn_spd):.1f} kt**",
|
| 1338 |
f"- Best nominal heading: **{best_h0:.1f}°**",
|
| 1339 |
-
f"- Drift used: **{
|
| 1340 |
f"- BFO points: **{best_score['n_bfo']}** | RMSE_BFO: **{best_score['rmse_bfo']:.2f} Hz**",
|
| 1341 |
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
|
| 1342 |
f"- BTO points: **{best_score['n_bto']}** | RMSE_BTO: n/a",
|
|
@@ -1345,80 +1527,6 @@ def solve_guided_straight_with_drift(
|
|
| 1345 |
return best_full_legs, report, start_dt
|
| 1346 |
|
| 1347 |
|
| 1348 |
-
# ================= Scoring helper (used in guided solvers) =================
|
| 1349 |
-
def score_legs_bfo_bto(
|
| 1350 |
-
start_lat, start_lon, method, start_dt, end_dt, legs,
|
| 1351 |
-
alt_ft, vs_fpm, descent_start_dt,
|
| 1352 |
-
weight_bfo: float, weight_bto: float
|
| 1353 |
-
):
|
| 1354 |
-
pts = _ping_points_after(start_dt)
|
| 1355 |
-
pts = [(lbl, dt, bfo, bto) for (lbl, dt, bfo, bto) in pts if start_dt <= dt <= end_dt]
|
| 1356 |
-
if len(pts) < 2:
|
| 1357 |
-
return None
|
| 1358 |
-
|
| 1359 |
-
leg_secs = [float(m) * 60.0 for _, _, m in legs]
|
| 1360 |
-
boundaries = np.cumsum([0.0] + leg_secs)
|
| 1361 |
-
total_end = float(boundaries[-1])
|
| 1362 |
-
|
| 1363 |
-
leg_start_positions = _precompute_leg_starts(start_lat, start_lon, legs, method)
|
| 1364 |
-
|
| 1365 |
-
bfo_preds, bfo_meas = [], []
|
| 1366 |
-
bto_preds, bto_meas = [], []
|
| 1367 |
-
|
| 1368 |
-
for _lbl, dt_ping, meas_bfo, meas_bto in pts:
|
| 1369 |
-
t_sec = (dt_ping - start_dt).total_seconds()
|
| 1370 |
-
if t_sec < 0 or t_sec > total_end:
|
| 1371 |
-
continue
|
| 1372 |
-
|
| 1373 |
-
i = int(np.searchsorted(boundaries, t_sec, side="right") - 1)
|
| 1374 |
-
i = max(0, min(i, len(legs) - 1))
|
| 1375 |
-
t_inside = float(t_sec) - float(boundaries[i])
|
| 1376 |
-
|
| 1377 |
-
leg_start_lat, leg_start_lon = leg_start_positions[i]
|
| 1378 |
-
hdg, spd, _ = legs[i]
|
| 1379 |
-
lat, lon = _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg, spd, t_inside)
|
| 1380 |
-
|
| 1381 |
-
alt_now_ft = altitude_ft_at_dt(dt_ping, start_dt, float(alt_ft), float(vs_fpm), descent_start_dt)
|
| 1382 |
-
alt_m = alt_now_ft * 0.3048
|
| 1383 |
-
|
| 1384 |
-
vs_mps = float(vs_fpm) * 0.00508
|
| 1385 |
-
if descent_start_dt is not None and dt_ping < descent_start_dt:
|
| 1386 |
-
vs_mps = 0.0
|
| 1387 |
-
|
| 1388 |
-
spd_mps = float(spd) * 1852.0 / 3600.0
|
| 1389 |
-
pred_bfo = float(fp.calculateBFO_at_time(spd_mps, float(hdg), float(lat), float(lon), alt_m, vs_mps, dt_ping))
|
| 1390 |
-
bfo_preds.append(pred_bfo)
|
| 1391 |
-
bfo_meas.append(float(meas_bfo))
|
| 1392 |
-
|
| 1393 |
-
if meas_bto is not None:
|
| 1394 |
-
pred_bto = float(fp.calculateBTO_at_time(float(lat), float(lon), alt_m, dt_ping))
|
| 1395 |
-
bto_preds.append(pred_bto)
|
| 1396 |
-
bto_meas.append(float(meas_bto))
|
| 1397 |
-
|
| 1398 |
-
if len(bfo_meas) < 2:
|
| 1399 |
-
return None
|
| 1400 |
-
|
| 1401 |
-
e_bfo = np.array(bfo_preds) - np.array(bfo_meas)
|
| 1402 |
-
rmse_bfo = float(np.sqrt(np.mean(e_bfo * e_bfo)))
|
| 1403 |
-
|
| 1404 |
-
rmse_bto = None
|
| 1405 |
-
if len(bto_meas) >= 2:
|
| 1406 |
-
e_bto = np.array(bto_preds) - np.array(bto_meas)
|
| 1407 |
-
rmse_bto = float(np.sqrt(np.mean(e_bto * e_bto)))
|
| 1408 |
-
|
| 1409 |
-
combined = float(weight_bfo) * (rmse_bfo / BFO_SCALE_HZ)
|
| 1410 |
-
if rmse_bto is not None:
|
| 1411 |
-
combined += float(weight_bto) * (rmse_bto / BTO_SCALE_US)
|
| 1412 |
-
|
| 1413 |
-
return {
|
| 1414 |
-
"n_bfo": len(bfo_meas),
|
| 1415 |
-
"n_bto": len(bto_meas),
|
| 1416 |
-
"rmse_bfo": rmse_bfo,
|
| 1417 |
-
"rmse_bto": rmse_bto,
|
| 1418 |
-
"combined": combined,
|
| 1419 |
-
}
|
| 1420 |
-
|
| 1421 |
-
|
| 1422 |
# ================= RUNNERS =================
|
| 1423 |
def _run_common(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice):
|
| 1424 |
# In guided mode, ensure the polyline ends at 00:20 by clipping legs to end_dt first.
|
|
@@ -1466,7 +1574,7 @@ def run_guided(
|
|
| 1466 |
weight_bfo, weight_bto,
|
| 1467 |
drift_max_deg, drift_weight
|
| 1468 |
):
|
| 1469 |
-
# This is the SOUTHERN cruise speed (post-18:40). Turn speed is
|
| 1470 |
south_spd_knots = speed_from_profile(speed_profile, alt_ft, custom_mach, custom_knots)
|
| 1471 |
|
| 1472 |
if (guided_scenario or "").startswith("Straight + drift"):
|
|
@@ -1527,7 +1635,8 @@ with gr.Blocks() as demo:
|
|
| 1527 |
f"- **Turn completion (fixed):** 18:40:00Z\n"
|
| 1528 |
f"- **Initial track (fixed):** {RADAR_INITIAL_TRACK_DEG:.1f}°\n"
|
| 1529 |
f"- **Final resting point:** **00:20:00Z** (trajectory clipped exactly at this time)\n\n"
|
| 1530 |
-
f"**Turn behavior:** 18:22→18:40 turn is
|
|
|
|
| 1531 |
)
|
| 1532 |
|
| 1533 |
with gr.Row():
|
|
|
|
| 17 |
# 2) Restore BFO plot (it disappeared previously)
|
| 18 |
# - Re-added full BFO analysis computation + chart + MAE/RMSE markdown.
|
| 19 |
#
|
| 20 |
+
# NEW (PREVIOUS UPDATE):
|
| 21 |
# 3) Plausible final major turn (18:22→18:40) can adjust BOTH heading and speed
|
| 22 |
# - Turn speed is no longer fixed to the southern cruise speed.
|
| 23 |
# - The solver searches a plausible TURN speed range and chooses the best-scoring solution.
|
| 24 |
# - After 18:40, the southern trajectory continues “as per usual” using the selected speed profile.
|
| 25 |
# - Applies to BOTH scenarios: Straight and Straight + drift.
|
| 26 |
+
#
|
| 27 |
+
# NEW (THIS UPDATE):
|
| 28 |
+
# 4) Two-stage guided search (COARSE → REFINE) to make generation much faster
|
| 29 |
+
# - Stage 1: coarse grid search to find the region of the best solution
|
| 30 |
+
# - Stage 2: refined grid around the best candidate only
|
| 31 |
+
# - Applies to BOTH scenarios: Straight and Straight + drift
|
| 32 |
|
| 33 |
import os
|
| 34 |
import re
|
|
|
|
| 119 |
TURN_BANK_DEFAULT_DEG = 25.0
|
| 120 |
TURN_STEP_SEC = 30.0
|
| 121 |
|
| 122 |
+
# Turn-speed search parameters (turn only; southern uses speed profile)
|
| 123 |
TURN_SPEED_SEARCH_DELTA_KT = 40.0 # search ± around the southern cruise speed
|
| 124 |
+
TURN_SPEED_STEP_KT = 5.0 # refined candidate spacing
|
| 125 |
TURN_SPEED_MIN_ABS_KT = 350.0 # hard min plausible
|
| 126 |
TURN_SPEED_MAX_ABS_KT = 550.0 # hard max plausible
|
| 127 |
|
| 128 |
# Guided heading search (south-ish)
|
| 129 |
GUIDED_HEADING_MIN = 150.0
|
| 130 |
GUIDED_HEADING_MAX = 210.0
|
| 131 |
+
STRAIGHT_STEP_DEG = 0.5 # refined step
|
| 132 |
|
| 133 |
# Southern schedule (kept)
|
| 134 |
GUIDED_LEG_SCHEDULE_MIN = [12.0, 60.0, 60.0, 60.0, 60.0, 90.0, 10.0]
|
| 135 |
|
| 136 |
# Drift
|
| 137 |
DEFAULT_DRIFT_MAX_DEG = 2.0
|
| 138 |
+
DRIFT_STEP_DEG = 0.5 # refined step
|
| 139 |
DEFAULT_WEIGHT_DRIFT = 0.30
|
| 140 |
DEFAULT_DRIFT_ITERS = 2
|
| 141 |
DRIFT_SCALE_DEG = 1.0
|
| 142 |
|
| 143 |
+
# ===== NEW: Two-stage search knobs =====
|
| 144 |
+
# Stage 1 (coarse)
|
| 145 |
+
COARSE_HEADING_STEP_DEG = 2.0
|
| 146 |
+
COARSE_TURN_SPEED_STEP_KT = 10.0
|
| 147 |
+
COARSE_DRIFT_STEP_DEG = 1.0
|
| 148 |
+
|
| 149 |
+
# Stage 2 (refine around best)
|
| 150 |
+
REFINE_HEADING_HALF_WINDOW_DEG = 2.0 # +/- around best
|
| 151 |
+
REFINE_TURN_SPEED_HALF_WINDOW_KT = 10.0
|
| 152 |
+
REFINE_DRIFT_HALF_WINDOW_DEG = 1.0
|
| 153 |
+
|
| 154 |
# Search area colors (optional)
|
| 155 |
SEARCH_AREA_COLORS = {
|
| 156 |
"Ocean Infinity": "#ff3b3b",
|
|
|
|
| 1168 |
return mach_to_tas_knots(0.82, alt_ft)
|
| 1169 |
|
| 1170 |
|
| 1171 |
+
def _turn_speed_candidates(south_speed_knots: float, step_kt: float):
|
| 1172 |
"""
|
| 1173 |
Plausible turn-speed candidates around the southern cruise speed.
|
| 1174 |
Hard-clipped to [TURN_SPEED_MIN_ABS_KT, TURN_SPEED_MAX_ABS_KT].
|
|
|
|
| 1176 |
base = float(south_speed_knots)
|
| 1177 |
lo = max(TURN_SPEED_MIN_ABS_KT, base - float(TURN_SPEED_SEARCH_DELTA_KT))
|
| 1178 |
hi = min(TURN_SPEED_MAX_ABS_KT, base + float(TURN_SPEED_SEARCH_DELTA_KT))
|
| 1179 |
+
step = max(1.0, float(step_kt))
|
| 1180 |
if hi < lo:
|
| 1181 |
lo, hi = hi, lo
|
| 1182 |
cand = list(np.arange(lo, hi + 1e-9, step))
|
|
|
|
| 1187 |
return cand
|
| 1188 |
|
| 1189 |
|
| 1190 |
+
def _clamp(x, lo, hi):
|
| 1191 |
+
return max(lo, min(hi, x))
|
| 1192 |
+
|
| 1193 |
+
|
| 1194 |
+
def _frange(lo: float, hi: float, step: float):
|
| 1195 |
+
# inclusive hi, stable float stepping
|
| 1196 |
+
step = float(step)
|
| 1197 |
+
if step <= 0:
|
| 1198 |
+
return []
|
| 1199 |
+
n = int(math.floor((hi - lo) / step + 1e-9)) + 1
|
| 1200 |
+
out = [lo + i * step for i in range(max(0, n))]
|
| 1201 |
+
# trim due to floating errors
|
| 1202 |
+
out = [x for x in out if x <= hi + 1e-9]
|
| 1203 |
+
return out
|
| 1204 |
+
|
| 1205 |
+
|
| 1206 |
+
def _refine_range(center: float, half_window: float, step: float, lo: float, hi: float):
|
| 1207 |
+
lo2 = _clamp(center - half_window, lo, hi)
|
| 1208 |
+
hi2 = _clamp(center + half_window, lo, hi)
|
| 1209 |
+
return _frange(lo2, hi2, step)
|
| 1210 |
+
|
| 1211 |
+
|
| 1212 |
# ================= Guided Legs Creation =================
|
| 1213 |
def make_legs_from_schedule(headings, speed_knots, schedule_minutes):
|
| 1214 |
legs = []
|
|
|
|
| 1222 |
return legs
|
| 1223 |
|
| 1224 |
|
| 1225 |
+
# ================= Scoring helper (used in guided solvers) =================
|
| 1226 |
+
def score_legs_bfo_bto(
|
| 1227 |
+
start_lat, start_lon, method, start_dt, end_dt, legs,
|
| 1228 |
+
alt_ft, vs_fpm, descent_start_dt,
|
| 1229 |
+
weight_bfo: float, weight_bto: float,
|
| 1230 |
+
pts_window=None
|
| 1231 |
+
):
|
| 1232 |
+
# pts_window: optional precomputed list [(lbl, dt, meas_bfo, meas_bto), ...] already clipped to [start_dt, end_dt]
|
| 1233 |
+
if pts_window is None:
|
| 1234 |
+
pts = _ping_points_after(start_dt)
|
| 1235 |
+
pts = [(lbl, dt, bfo, bto) for (lbl, dt, bfo, bto) in pts if start_dt <= dt <= end_dt]
|
| 1236 |
+
else:
|
| 1237 |
+
pts = pts_window
|
| 1238 |
+
|
| 1239 |
+
if len(pts) < 2:
|
| 1240 |
+
return None
|
| 1241 |
+
|
| 1242 |
+
leg_secs = [float(m) * 60.0 for _, _, m in legs]
|
| 1243 |
+
boundaries = np.cumsum([0.0] + leg_secs)
|
| 1244 |
+
total_end = float(boundaries[-1])
|
| 1245 |
+
|
| 1246 |
+
leg_start_positions = _precompute_leg_starts(start_lat, start_lon, legs, method)
|
| 1247 |
+
|
| 1248 |
+
bfo_preds, bfo_meas = [], []
|
| 1249 |
+
bto_preds, bto_meas = [], []
|
| 1250 |
+
|
| 1251 |
+
for _lbl, dt_ping, meas_bfo, meas_bto in pts:
|
| 1252 |
+
t_sec = (dt_ping - start_dt).total_seconds()
|
| 1253 |
+
if t_sec < 0 or t_sec > total_end:
|
| 1254 |
+
continue
|
| 1255 |
+
|
| 1256 |
+
i = int(np.searchsorted(boundaries, t_sec, side="right") - 1)
|
| 1257 |
+
i = max(0, min(i, len(legs) - 1))
|
| 1258 |
+
t_inside = float(t_sec) - float(boundaries[i])
|
| 1259 |
+
|
| 1260 |
+
leg_start_lat, leg_start_lon = leg_start_positions[i]
|
| 1261 |
+
hdg, spd, _ = legs[i]
|
| 1262 |
+
lat, lon = _predict_state_from_leg_start(method, leg_start_lat, leg_start_lon, hdg, spd, t_inside)
|
| 1263 |
+
|
| 1264 |
+
alt_now_ft = altitude_ft_at_dt(dt_ping, start_dt, float(alt_ft), float(vs_fpm), descent_start_dt)
|
| 1265 |
+
alt_m = alt_now_ft * 0.3048
|
| 1266 |
+
|
| 1267 |
+
vs_mps = float(vs_fpm) * 0.00508
|
| 1268 |
+
if descent_start_dt is not None and dt_ping < descent_start_dt:
|
| 1269 |
+
vs_mps = 0.0
|
| 1270 |
+
|
| 1271 |
+
spd_mps = float(spd) * 1852.0 / 3600.0
|
| 1272 |
+
pred_bfo = float(fp.calculateBFO_at_time(spd_mps, float(hdg), float(lat), float(lon), alt_m, vs_mps, dt_ping))
|
| 1273 |
+
bfo_preds.append(pred_bfo)
|
| 1274 |
+
bfo_meas.append(float(meas_bfo))
|
| 1275 |
+
|
| 1276 |
+
if meas_bto is not None:
|
| 1277 |
+
pred_bto = float(fp.calculateBTO_at_time(float(lat), float(lon), alt_m, dt_ping))
|
| 1278 |
+
bto_preds.append(pred_bto)
|
| 1279 |
+
bto_meas.append(float(meas_bto))
|
| 1280 |
+
|
| 1281 |
+
if len(bfo_meas) < 2:
|
| 1282 |
+
return None
|
| 1283 |
+
|
| 1284 |
+
e_bfo = np.array(bfo_preds) - np.array(bfo_meas)
|
| 1285 |
+
rmse_bfo = float(np.sqrt(np.mean(e_bfo * e_bfo)))
|
| 1286 |
+
|
| 1287 |
+
rmse_bto = None
|
| 1288 |
+
if len(bto_meas) >= 2:
|
| 1289 |
+
e_bto = np.array(bto_preds) - np.array(bto_meas)
|
| 1290 |
+
rmse_bto = float(np.sqrt(np.mean(e_bto * e_bto)))
|
| 1291 |
+
|
| 1292 |
+
combined = float(weight_bfo) * (rmse_bfo / BFO_SCALE_HZ)
|
| 1293 |
+
if rmse_bto is not None:
|
| 1294 |
+
combined += float(weight_bto) * (rmse_bto / BTO_SCALE_US)
|
| 1295 |
+
|
| 1296 |
+
return {
|
| 1297 |
+
"n_bfo": len(bfo_meas),
|
| 1298 |
+
"n_bto": len(bto_meas),
|
| 1299 |
+
"rmse_bfo": rmse_bfo,
|
| 1300 |
+
"rmse_bto": rmse_bto,
|
| 1301 |
+
"combined": combined,
|
| 1302 |
+
}
|
| 1303 |
+
|
| 1304 |
+
|
| 1305 |
# ================= Guided Solvers =================
|
| 1306 |
def _guided_start_dt():
|
| 1307 |
return RADAR_FIX_TIME_UTC
|
|
|
|
| 1315 |
start_dt = _guided_start_dt()
|
| 1316 |
end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
|
| 1317 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1318 |
# descent start
|
| 1319 |
descent_start_dt = None
|
| 1320 |
if (descent_start_choice or "").startswith("After Ping Ring 6"):
|
|
|
|
| 1322 |
elif (descent_start_choice or "").startswith("After Ping Ring 7"):
|
| 1323 |
descent_start_dt = dt_ping7_utc()
|
| 1324 |
|
| 1325 |
+
# Precompute ping points once (windowed)
|
| 1326 |
+
pts_all = _ping_points_after(start_dt)
|
| 1327 |
+
pts_window = [(lbl, dt, bfo, bto) for (lbl, dt, bfo, bto) in pts_all if start_dt <= dt <= end_dt]
|
| 1328 |
+
if len(pts_window) < 2:
|
| 1329 |
+
return None, "Guided solve: not enough ping points to score.", None
|
| 1330 |
+
|
| 1331 |
+
def try_candidate(h_south: float, spd_turn: float):
|
| 1332 |
+
southern_legs = make_legs_from_schedule(h_south, float(south_speed_knots), GUIDED_LEG_SCHEDULE_MIN)
|
| 1333 |
+
preturn_legs, _turn_info = build_turn_legs(
|
| 1334 |
+
speed_knots=float(spd_turn),
|
| 1335 |
+
method=method,
|
| 1336 |
+
heading_final_deg=float(h_south),
|
| 1337 |
+
)
|
| 1338 |
+
full_legs = preturn_legs + southern_legs
|
| 1339 |
+
full_legs = clip_legs_to_end_dt(full_legs, start_dt, end_dt)
|
| 1340 |
+
s = score_legs_bfo_bto(
|
| 1341 |
+
RADAR_FIX_LAT, RADAR_FIX_LON, method, start_dt, end_dt, full_legs,
|
| 1342 |
+
alt_ft, vs_fpm, descent_start_dt,
|
| 1343 |
+
weight_bfo, weight_bto,
|
| 1344 |
+
pts_window=pts_window
|
| 1345 |
+
)
|
| 1346 |
+
return s, full_legs
|
| 1347 |
+
|
| 1348 |
+
# -------- Stage 1: COARSE search --------
|
| 1349 |
+
best_h = None
|
| 1350 |
+
best_turn_spd = None
|
| 1351 |
+
best_full_legs = None
|
| 1352 |
+
best_score = None
|
| 1353 |
+
|
| 1354 |
+
coarse_headings = _frange(GUIDED_HEADING_MIN, GUIDED_HEADING_MAX, COARSE_HEADING_STEP_DEG)
|
| 1355 |
+
coarse_turn_speeds = _turn_speed_candidates(float(south_speed_knots), COARSE_TURN_SPEED_STEP_KT)
|
| 1356 |
+
|
| 1357 |
+
for h in coarse_headings:
|
| 1358 |
+
for spd_turn in coarse_turn_speeds:
|
| 1359 |
+
s, legs = try_candidate(h, spd_turn)
|
| 1360 |
if s is not None and (best_score is None or s["combined"] < best_score["combined"]):
|
| 1361 |
best_score = s
|
| 1362 |
best_h = float(h)
|
| 1363 |
best_turn_spd = float(spd_turn)
|
| 1364 |
+
best_full_legs = legs
|
|
|
|
|
|
|
| 1365 |
|
| 1366 |
if best_full_legs is None:
|
| 1367 |
return None, "Guided solve: not enough ping points to score.", None
|
| 1368 |
|
| 1369 |
+
# -------- Stage 2: REFINE around best --------
|
| 1370 |
+
refine_headings = _refine_range(best_h, REFINE_HEADING_HALF_WINDOW_DEG, STRAIGHT_STEP_DEG, GUIDED_HEADING_MIN, GUIDED_HEADING_MAX)
|
| 1371 |
+
refine_turn_speeds = _refine_range(
|
| 1372 |
+
best_turn_spd, REFINE_TURN_SPEED_HALF_WINDOW_KT, TURN_SPEED_STEP_KT,
|
| 1373 |
+
max(TURN_SPEED_MIN_ABS_KT, float(south_speed_knots) - TURN_SPEED_SEARCH_DELTA_KT),
|
| 1374 |
+
min(TURN_SPEED_MAX_ABS_KT, float(south_speed_knots) + TURN_SPEED_SEARCH_DELTA_KT),
|
| 1375 |
+
)
|
| 1376 |
+
|
| 1377 |
+
for h in refine_headings:
|
| 1378 |
+
for spd_turn in refine_turn_speeds:
|
| 1379 |
+
s, legs = try_candidate(h, spd_turn)
|
| 1380 |
+
if s is not None and (best_score is None or s["combined"] < best_score["combined"]):
|
| 1381 |
+
best_score = s
|
| 1382 |
+
best_h = float(h)
|
| 1383 |
+
best_turn_spd = float(spd_turn)
|
| 1384 |
+
best_full_legs = legs
|
| 1385 |
+
|
| 1386 |
report = "\n".join([
|
| 1387 |
"**Guided mode: Straight + computed turn (18:22→18:40), clipped to 00:20**",
|
| 1388 |
+
f"- Search mode: **two-stage (coarse → refine)**",
|
| 1389 |
f"- Southern speed (speed profile): **{float(south_speed_knots):.1f} kt**",
|
| 1390 |
f"- Best turn speed (18:22→18:40): **{float(best_turn_spd):.1f} kt**",
|
| 1391 |
f"- Best southern heading: **{best_h:.1f}°**",
|
|
|
|
| 1402 |
descent_start_choice, weight_bfo, weight_bto,
|
| 1403 |
drift_max_deg, drift_weight
|
| 1404 |
):
|
|
|
|
| 1405 |
start_dt = _guided_start_dt()
|
| 1406 |
end_dt = _dt_from_label(GUIDED_FINAL_LABEL)
|
| 1407 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1408 |
# descent start
|
| 1409 |
descent_start_dt = None
|
| 1410 |
if (descent_start_choice or "").startswith("After Ping Ring 6"):
|
|
|
|
| 1412 |
elif (descent_start_choice or "").startswith("After Ping Ring 7"):
|
| 1413 |
descent_start_dt = dt_ping7_utc()
|
| 1414 |
|
| 1415 |
+
# Precompute ping points once (windowed)
|
| 1416 |
+
pts_all = _ping_points_after(start_dt)
|
| 1417 |
+
pts_window = [(lbl, dt, bfo, bto) for (lbl, dt, bfo, bto) in pts_all if start_dt <= dt <= end_dt]
|
| 1418 |
+
if len(pts_window) < 2:
|
| 1419 |
+
return None, "Guided drift solve: not enough ping points to score.", None
|
| 1420 |
+
|
| 1421 |
+
drift_max_deg = float(drift_max_deg)
|
| 1422 |
+
|
| 1423 |
+
def try_candidate(h0_nominal: float, drift_deg: float, spd_turn: float):
|
| 1424 |
+
h_eff = _wrap_heading_deg(float(h0_nominal) + float(drift_deg))
|
| 1425 |
+
|
| 1426 |
+
southern_legs = make_legs_from_schedule(h_eff, float(south_speed_knots), GUIDED_LEG_SCHEDULE_MIN)
|
| 1427 |
+
preturn_legs, _turn_info = build_turn_legs(
|
| 1428 |
+
speed_knots=float(spd_turn),
|
| 1429 |
+
method=method,
|
| 1430 |
+
heading_final_deg=float(h_eff),
|
| 1431 |
+
)
|
| 1432 |
+
full_legs = preturn_legs + southern_legs
|
| 1433 |
+
full_legs = clip_legs_to_end_dt(full_legs, start_dt, end_dt)
|
| 1434 |
+
|
| 1435 |
+
s = score_legs_bfo_bto(
|
| 1436 |
+
RADAR_FIX_LAT, RADAR_FIX_LON, method, start_dt, end_dt, full_legs,
|
| 1437 |
+
alt_ft, vs_fpm, descent_start_dt,
|
| 1438 |
+
weight_bfo, weight_bto,
|
| 1439 |
+
pts_window=pts_window
|
| 1440 |
+
)
|
| 1441 |
+
if s is None:
|
| 1442 |
+
return None, None, None
|
| 1443 |
+
|
| 1444 |
+
combined_plus = float(s["combined"]) + float(drift_weight) * (abs(float(drift_deg)) / DRIFT_SCALE_DEG)
|
| 1445 |
+
return s, combined_plus, full_legs
|
| 1446 |
+
|
| 1447 |
+
# -------- Stage 1: COARSE search --------
|
| 1448 |
+
best_h0 = None
|
| 1449 |
+
best_d = None
|
| 1450 |
+
best_turn_spd = None
|
| 1451 |
+
best_full_legs = None
|
| 1452 |
+
best_score = None
|
| 1453 |
+
best_combined_plus = None
|
| 1454 |
+
|
| 1455 |
+
coarse_headings = _frange(GUIDED_HEADING_MIN, GUIDED_HEADING_MAX, COARSE_HEADING_STEP_DEG)
|
| 1456 |
+
|
| 1457 |
+
# Drift candidates (coarse)
|
| 1458 |
+
if drift_max_deg <= 1e-9:
|
| 1459 |
+
coarse_drifts = [0.0]
|
| 1460 |
+
else:
|
| 1461 |
+
coarse_drifts = list(np.arange(-drift_max_deg, drift_max_deg + 1e-9, COARSE_DRIFT_STEP_DEG))
|
| 1462 |
+
if 0.0 not in coarse_drifts:
|
| 1463 |
+
coarse_drifts.append(0.0)
|
| 1464 |
+
coarse_drifts = sorted({round(float(x), 6) for x in coarse_drifts})
|
| 1465 |
|
| 1466 |
+
coarse_turn_speeds = _turn_speed_candidates(float(south_speed_knots), COARSE_TURN_SPEED_STEP_KT)
|
|
|
|
| 1467 |
|
| 1468 |
+
for h0 in coarse_headings:
|
| 1469 |
+
for d in coarse_drifts:
|
| 1470 |
+
for spd_turn in coarse_turn_speeds:
|
| 1471 |
+
s, combined_plus, legs = try_candidate(h0, d, spd_turn)
|
| 1472 |
+
if s is None:
|
| 1473 |
+
continue
|
| 1474 |
+
if best_combined_plus is None or float(combined_plus) < float(best_combined_plus):
|
| 1475 |
best_score = dict(s)
|
| 1476 |
+
best_combined_plus = float(combined_plus)
|
|
|
|
| 1477 |
best_h0 = float(h0)
|
| 1478 |
+
best_d = float(d)
|
| 1479 |
best_turn_spd = float(spd_turn)
|
| 1480 |
+
best_full_legs = legs
|
|
|
|
|
|
|
| 1481 |
|
| 1482 |
if best_full_legs is None:
|
| 1483 |
return None, "Guided drift solve: not enough ping points to score.", None
|
| 1484 |
|
| 1485 |
+
# -------- Stage 2: REFINE around best --------
|
| 1486 |
+
refine_headings = _refine_range(best_h0, REFINE_HEADING_HALF_WINDOW_DEG, 1.0, GUIDED_HEADING_MIN, GUIDED_HEADING_MAX)
|
| 1487 |
+
|
| 1488 |
+
# Refine drift around best_d with DRIFT_STEP_DEG (0.5)
|
| 1489 |
+
d_lo = max(-drift_max_deg, best_d - REFINE_DRIFT_HALF_WINDOW_DEG)
|
| 1490 |
+
d_hi = min(+drift_max_deg, best_d + REFINE_DRIFT_HALF_WINDOW_DEG)
|
| 1491 |
+
refine_drifts = _frange(d_lo, d_hi, DRIFT_STEP_DEG)
|
| 1492 |
+
if not refine_drifts:
|
| 1493 |
+
refine_drifts = [best_d]
|
| 1494 |
+
|
| 1495 |
+
refine_turn_speeds = _refine_range(
|
| 1496 |
+
best_turn_spd, REFINE_TURN_SPEED_HALF_WINDOW_KT, TURN_SPEED_STEP_KT,
|
| 1497 |
+
max(TURN_SPEED_MIN_ABS_KT, float(south_speed_knots) - TURN_SPEED_SEARCH_DELTA_KT),
|
| 1498 |
+
min(TURN_SPEED_MAX_ABS_KT, float(south_speed_knots) + TURN_SPEED_SEARCH_DELTA_KT),
|
| 1499 |
+
)
|
| 1500 |
+
|
| 1501 |
+
for h0 in refine_headings:
|
| 1502 |
+
for d in refine_drifts:
|
| 1503 |
+
for spd_turn in refine_turn_speeds:
|
| 1504 |
+
s, combined_plus, legs = try_candidate(h0, d, spd_turn)
|
| 1505 |
+
if s is None:
|
| 1506 |
+
continue
|
| 1507 |
+
if best_combined_plus is None or float(combined_plus) < float(best_combined_plus):
|
| 1508 |
+
best_score = dict(s)
|
| 1509 |
+
best_combined_plus = float(combined_plus)
|
| 1510 |
+
best_h0 = float(h0)
|
| 1511 |
+
best_d = float(d)
|
| 1512 |
+
best_turn_spd = float(spd_turn)
|
| 1513 |
+
best_full_legs = legs
|
| 1514 |
+
|
| 1515 |
report = "\n".join([
|
| 1516 |
"**Guided mode: Straight + drift + computed turn (18:22→18:40), clipped to 00:20**",
|
| 1517 |
+
f"- Search mode: **two-stage (coarse → refine)**",
|
| 1518 |
f"- Southern speed (speed profile): **{float(south_speed_knots):.1f} kt**",
|
| 1519 |
f"- Best turn speed (18:22→18:40): **{float(best_turn_spd):.1f} kt**",
|
| 1520 |
f"- Best nominal heading: **{best_h0:.1f}°**",
|
| 1521 |
+
f"- Drift used: **{best_d:+.1f}°** | Drift penalty weight: **{float(drift_weight):.2f}**",
|
| 1522 |
f"- BFO points: **{best_score['n_bfo']}** | RMSE_BFO: **{best_score['rmse_bfo']:.2f} Hz**",
|
| 1523 |
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
|
| 1524 |
f"- BTO points: **{best_score['n_bto']}** | RMSE_BTO: n/a",
|
|
|
|
| 1527 |
return best_full_legs, report, start_dt
|
| 1528 |
|
| 1529 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1530 |
# ================= RUNNERS =================
|
| 1531 |
def _run_common(start_lat, start_lon, legs, method, start_dt, alt_ft, vs_fpm, descent_start_choice):
|
| 1532 |
# In guided mode, ensure the polyline ends at 00:20 by clipping legs to end_dt first.
|
|
|
|
| 1574 |
weight_bfo, weight_bto,
|
| 1575 |
drift_max_deg, drift_weight
|
| 1576 |
):
|
| 1577 |
+
# This is the SOUTHERN cruise speed (post-18:40). Turn speed is searched independently.
|
| 1578 |
south_spd_knots = speed_from_profile(speed_profile, alt_ft, custom_mach, custom_knots)
|
| 1579 |
|
| 1580 |
if (guided_scenario or "").startswith("Straight + drift"):
|
|
|
|
| 1635 |
f"- **Turn completion (fixed):** 18:40:00Z\n"
|
| 1636 |
f"- **Initial track (fixed):** {RADAR_INITIAL_TRACK_DEG:.1f}°\n"
|
| 1637 |
f"- **Final resting point:** **00:20:00Z** (trajectory clipped exactly at this time)\n\n"
|
| 1638 |
+
f"**Turn behavior:** 18:22→18:40 turn is solved with **adjustable turn speed**.\n"
|
| 1639 |
+
f"**Performance:** guided solver uses **two-stage search (coarse → refine)** to run faster."
|
| 1640 |
)
|
| 1641 |
|
| 1642 |
with gr.Row():
|