From aa34548fb91814c08e8fd227cfaf5c80cfdf1bfb Mon Sep 17 00:00:00 2001
From: codex-scheduled
Date: Thu, 13 Aug 2026 21:53:02 -0700
Subject: [PATCH 1/2] feat(rate): add synchronized flight sample inspector
---
.../workflows/rate-web-playwright-trusted.yml | 10 +
.github/workflows/rate-web-playwright.yml | 10 +
AGENT_HANDOFF.md | 17 +
SPEC.md | 25 +-
.../RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md | 26 ++
scripts/test_assertion_allowlist.txt | 2 +
src/rate_of_closure/AGENT_HANDOFF.md | 17 +
src/rate_of_closure/flight_accepted_study.py | 363 ++++++++++++++++
.../flight_sample_inspector.py | 257 ++++++++++++
.../simulation/flight_explorer.py | 29 +-
src/rate_of_closure/ui/pyqt6/figure_canvas.py | 34 +-
.../ui/pyqt6/flight_explorer_controls.py | 5 -
.../ui/pyqt6/flight_explorer_run.py | 321 ++++++++++++++
.../ui/pyqt6/flight_explorer_tab.py | 196 +++++----
.../ui/pyqt6/flight_playback_controls.py | 9 +
src/rate_of_closure/ui/pyqt6/flight_view.py | 36 +-
.../ui/pyqt6/flight_view_bundle.py | 200 +++++++++
.../ui/pyqt6/flight_view_inspector.py | 148 +++++++
.../ui/pyqt6/flight_wind_controls.py | 13 +
.../ui/pyqt6/spatial_target_workflow.py | 19 +-
.../visualization_tabs.v1.json | 4 +-
.../web/e2e/flight-sample-inspector.spec.ts | 88 ++++
.../src/components/FlightCanvases.test.tsx | 36 +-
.../web/src/components/FlightCanvases.tsx | 379 +++--------------
.../components/FlightExplorerPanel.test.tsx | 127 +++++-
.../src/components/FlightExplorerPanel.tsx | 181 ++++----
.../src/components/FlightPlayback3D.test.tsx | 9 +
.../web/src/components/FlightPlayback3D.tsx | 17 +-
.../components/flightAcceptedStudy.test.ts | 160 +++++++
.../web/src/components/flightAcceptedStudy.ts | 311 ++++++++++++++
.../src/components/flightCanvasContract.ts | 29 ++
.../web/src/components/flightCanvasDrawing.ts | 169 ++++++++
.../src/components/flightCanvasOverlays.ts | 171 ++++++++
.../src/components/flightExplorerContract.ts | 55 +++
.../components/useFlightSampleInspector.ts | 77 ++++
.../flight_sample_inspector_golden_v1.json | 19 +
.../web/src/model/flightExplorer.test.ts | 10 +-
.../web/src/model/flightExplorer.ts | 37 +-
.../src/model/flightSampleInspector.test.ts | 113 +++++
.../web/src/model/flightSampleInspector.ts | 170 ++++++++
.../ops/test_rate_web_playwright_workflow.py | 11 +
.../pyqt_flight_sample_inspector_probe.py | 157 +++++++
.../test_flight_accepted_study.py | 174 ++++++++
tests/rate_of_closure/test_flight_explorer.py | 18 +
.../test_flight_explorer_atomic_gui.py | 182 ++++++++
.../test_flight_sample_inspector.py | 137 ++++++
.../test_flight_sample_inspector_gui.py | 390 ++++++++++++++++++
...t_pyqt_flight_sample_inspector_rendered.py | 76 ++++
.../test_visualization_tab_manifest.py | 26 ++
tests/scripts/test_check_test_assertions.py | 1 +
50 files changed, 4549 insertions(+), 522 deletions(-)
create mode 100644 src/rate_of_closure/flight_accepted_study.py
create mode 100644 src/rate_of_closure/flight_sample_inspector.py
create mode 100644 src/rate_of_closure/ui/pyqt6/flight_explorer_run.py
create mode 100644 src/rate_of_closure/ui/pyqt6/flight_view_bundle.py
create mode 100644 src/rate_of_closure/ui/pyqt6/flight_view_inspector.py
create mode 100644 src/rate_of_closure/web/e2e/flight-sample-inspector.spec.ts
create mode 100644 src/rate_of_closure/web/src/components/flightAcceptedStudy.test.ts
create mode 100644 src/rate_of_closure/web/src/components/flightAcceptedStudy.ts
create mode 100644 src/rate_of_closure/web/src/components/flightCanvasContract.ts
create mode 100644 src/rate_of_closure/web/src/components/flightCanvasDrawing.ts
create mode 100644 src/rate_of_closure/web/src/components/flightCanvasOverlays.ts
create mode 100644 src/rate_of_closure/web/src/components/flightExplorerContract.ts
create mode 100644 src/rate_of_closure/web/src/components/useFlightSampleInspector.ts
create mode 100644 src/rate_of_closure/web/src/model/__fixtures__/flight_sample_inspector_golden_v1.json
create mode 100644 src/rate_of_closure/web/src/model/flightSampleInspector.test.ts
create mode 100644 src/rate_of_closure/web/src/model/flightSampleInspector.ts
create mode 100644 tests/rate_of_closure/pyqt_flight_sample_inspector_probe.py
create mode 100644 tests/rate_of_closure/test_flight_accepted_study.py
create mode 100644 tests/rate_of_closure/test_flight_explorer_atomic_gui.py
create mode 100644 tests/rate_of_closure/test_flight_sample_inspector.py
create mode 100644 tests/rate_of_closure/test_flight_sample_inspector_gui.py
create mode 100644 tests/rate_of_closure/test_pyqt_flight_sample_inspector_rendered.py
diff --git a/.github/workflows/rate-web-playwright-trusted.yml b/.github/workflows/rate-web-playwright-trusted.yml
index 738548029..e2153a4e1 100644
--- a/.github/workflows/rate-web-playwright-trusted.yml
+++ b/.github/workflows/rate-web-playwright-trusted.yml
@@ -8,6 +8,8 @@ on:
- "src/rate_of_closure/club/**"
- "src/rate_of_closure/club_camera.py"
- "src/rate_of_closure/club_mesh_source.py"
+ - "src/rate_of_closure/flight_accepted_study.py"
+ - "src/rate_of_closure/flight_sample_inspector.py"
- "src/rate_of_closure/mesh.py"
- "src/rate_of_closure/model.py"
- "src/rate_of_closure/plotting/**"
@@ -24,11 +26,18 @@ on:
- "src/shared/python/swing_sim/putting.py"
- "tests/rate_of_closure/pyqt_putting_sample_inspector_probe.py"
- "tests/rate_of_closure/pyqt_club_camera_probe.py"
+ - "tests/rate_of_closure/pyqt_flight_sample_inspector_probe.py"
- "tests/rate_of_closure/test_club_camera.py"
- "tests/rate_of_closure/test_club_mesh_source.py"
- "tests/rate_of_closure/test_club_view_camera.py"
+ - "tests/rate_of_closure/test_flight_accepted_study.py"
+ - "tests/rate_of_closure/test_flight_explorer.py"
+ - "tests/rate_of_closure/test_flight_explorer_atomic_gui.py"
+ - "tests/rate_of_closure/test_flight_sample_inspector.py"
+ - "tests/rate_of_closure/test_flight_sample_inspector_gui.py"
- "tests/rate_of_closure/test_mesh.py"
- "tests/rate_of_closure/test_pyqt_club_camera_rendered.py"
+ - "tests/rate_of_closure/test_pyqt_flight_sample_inspector_rendered.py"
- "tests/rate_of_closure/test_pyqt_putting_sample_inspector_rendered.py"
- "tests/rate_of_closure/pyqt_variation_render_probe.py"
- "tests/rate_of_closure/test_pyqt_variation_rendered_interactions.py"
@@ -91,6 +100,7 @@ jobs:
tests/rate_of_closure/test_pyqt_variation_visual_state_rendered.py
tests/rate_of_closure/test_pyqt_putting_sample_inspector_rendered.py
tests/rate_of_closure/test_pyqt_club_camera_rendered.py
+ tests/rate_of_closure/test_pyqt_flight_sample_inspector_rendered.py
tests/rate_of_closure/test_pyqt_visualization_tab_visibility.py -q -n 0
- name: Retain Playwright evidence
diff --git a/.github/workflows/rate-web-playwright.yml b/.github/workflows/rate-web-playwright.yml
index 4d9d7fb81..9ef4250b9 100644
--- a/.github/workflows/rate-web-playwright.yml
+++ b/.github/workflows/rate-web-playwright.yml
@@ -7,6 +7,8 @@ on:
- "src/rate_of_closure/club/**"
- "src/rate_of_closure/club_camera.py"
- "src/rate_of_closure/club_mesh_source.py"
+ - "src/rate_of_closure/flight_accepted_study.py"
+ - "src/rate_of_closure/flight_sample_inspector.py"
- "src/rate_of_closure/mesh.py"
- "src/rate_of_closure/model.py"
- "src/rate_of_closure/plotting/**"
@@ -23,11 +25,18 @@ on:
- "src/shared/python/swing_sim/putting.py"
- "tests/rate_of_closure/pyqt_putting_sample_inspector_probe.py"
- "tests/rate_of_closure/pyqt_club_camera_probe.py"
+ - "tests/rate_of_closure/pyqt_flight_sample_inspector_probe.py"
- "tests/rate_of_closure/test_club_camera.py"
- "tests/rate_of_closure/test_club_mesh_source.py"
- "tests/rate_of_closure/test_club_view_camera.py"
+ - "tests/rate_of_closure/test_flight_accepted_study.py"
+ - "tests/rate_of_closure/test_flight_explorer.py"
+ - "tests/rate_of_closure/test_flight_explorer_atomic_gui.py"
+ - "tests/rate_of_closure/test_flight_sample_inspector.py"
+ - "tests/rate_of_closure/test_flight_sample_inspector_gui.py"
- "tests/rate_of_closure/test_mesh.py"
- "tests/rate_of_closure/test_pyqt_club_camera_rendered.py"
+ - "tests/rate_of_closure/test_pyqt_flight_sample_inspector_rendered.py"
- "tests/rate_of_closure/test_pyqt_putting_sample_inspector_rendered.py"
- "tests/rate_of_closure/pyqt_variation_render_probe.py"
- "tests/rate_of_closure/test_pyqt_variation_rendered_interactions.py"
@@ -96,6 +105,7 @@ jobs:
tests/rate_of_closure/test_pyqt_variation_visual_state_rendered.py
tests/rate_of_closure/test_pyqt_putting_sample_inspector_rendered.py
tests/rate_of_closure/test_pyqt_club_camera_rendered.py
+ tests/rate_of_closure/test_pyqt_flight_sample_inspector_rendered.py
tests/rate_of_closure/test_pyqt_visualization_tab_visibility.py -q -n 0
- name: Retain Playwright evidence
diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md
index 0a78d7139..04dab3a4a 100644
--- a/AGENT_HANDOFF.md
+++ b/AGENT_HANDOFF.md
@@ -3,6 +3,23 @@
> **Update this file with every PR and every push to main.**
> Last updated: 2026-08-13
+## 2026-08-13 Flight synchronized sample inspector (#4433)
+
+Version 1.16.93 adds immutable, generation-bound accepted-flight bundles and
+matched React/PyQt raw-sample inspectors. The accepted exploration retains
+aligned time/position/velocity, while a maximum 1,002-sample plan binds exact
+current-primary time/position; the calm ghost
+is comparison-only. Twelve-CSS-pixel pointer picking and Arrow/Home/End/Escape
+navigation drive the sole 3D playback time without rerunning flight physics.
+React direct-entry and PyQt direct/delivery model, kernel, wind, launch,
+summary, origin, and landing evidence is validated before atomic publication;
+the separate target overlay is refreshed in the same PyQt transaction. Failed computation
+or UI/render publication retains prior scientific authority; if pixels cannot
+be restored the visible warning says the image may be stale. Three Chromium React
+selected viewport captures and eight PyQt selected/error window/canvas DPI PNGs are
+diagnostic-only, not approved goldens. Runtime-local sample indices are not a
+portable cross-solver identity, and #4433 remains open.
+
## 2026-08-13 Club Explorer hosted dtype closure (#4433)
Version 1.16.92 explicitly types the structured binary-STL NumPy dtype and
diff --git a/SPEC.md b/SPEC.md
index e2a23ecf7..4793c185f 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -26,12 +26,33 @@
| **Owner** | D-sorganization |
| **Primary Language(s)** | Python 3.11+, Rust, JavaScript, TypeScript |
| **License** | MIT |
-| **Current Version** | 1.16.92 |
-| **Spec Version** | 1.16.92 |
+| **Current Version** | 1.16.93 |
+| **Spec Version** | 1.16.93 |
| **Last Spec Update** | 2026-08-13 |
## 2. Purpose & Mission
+### 2026-08-13 Flight synchronized sample inspector (#4433)
+
+Version 1.16.93 adds matched React/PyQt inspection of exact runtime-local flight
+samples. React direct entry and PyQt direct/delivery each produce an immutable
+generation-bound bundle containing the complete producing context,
+launch/model/kernel/wind provenance, aligned time/position/velocity evidence,
+optional calm comparison, validated summaries, and a time/position plan capped
+at 1,002 samples before planner copying/allocation. The separate target overlay is
+transactionally refreshed from the accepted trajectory. Canonical tee origin,
+launch velocity, ground floor, landing,
+and wind-delta cohesion fail closed. The current primary cohort alone is
+selectable; the calm trace is a comparison ghost with no inferred raw-index
+correspondence. Twelve-pixel pointer selection and Arrow/Home/End/Escape update
+side/top markers, status, and the sole 3D playback timestamp without scientific
+recomputation. React and PyQt retain prior or honest empty authority on failure;
+PyQt publication spans target, renderer, controls, rows, deltas, statuses, and
+public references, while a failed pixel rollback is explicitly labeled stale.
+Three Chromium React selected viewport and eight PyQt selected/error window/canvas DPI PNGs are
+diagnostic-only, not approved goldens. Raw indices are runtime-local rather than
+portable solver identity, and broader #4433 tab/approval work remains open.
+
### 2026-08-13 Club Explorer hosted typing closure (#4433)
Version 1.16.91 closes the pinned Python 3.12 / MyPy 1.13 Club Explorer
diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md
index 0dddcc35d..498034c7f 100644
--- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md
+++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md
@@ -1,5 +1,31 @@
# Rate of Closure Campaign Handoff
+## 2026-08-13 #4433 Flight synchronized sample inspector
+
+- A Python-owned golden and strict Python/TypeScript twins cap raw evidence at
+ 1,002 samples before planner copying/allocation, preserve launch/apex/landing semantics,
+ and reject malformed, nonfinite, overflow-prone, translated, airborne-final,
+ below-ground, or provenance-incoherent trajectories.
+- React direct entry and PyQt direct/delivery each publish one immutable accepted
+ bundle with exact producing inputs, launch, model/kernel/wind provenance,
+ aligned time/position/velocity, optional calm comparison, derived summaries, and the
+ generation-bound time/position display plan. The separate target overlay is
+ transactionally refreshed from the accepted trajectory.
+ The calm ghost has no inferred correspondence and is never selectable.
+- Side/top pointer selection is bounded to 12 rendered pixels; keyboard
+ navigation and misses preserve focus and exact raw identity. Selection jumps
+ the sole 3D playback owner to the exact accepted timestamp without a solver
+ rerun. Canonical speed survives all presentation-unit switches.
+- PyQt commits target, view, controls, result rows, deltas, statuses, and public
+ references as one transaction. Failed repaint retains scientific authority;
+ failed pixel restoration is labeled stale/unavailable and idle redraw pauses
+ until a proven recovery. React retains prior or honest empty evidence with a
+ bounded alert and marks edited inputs as different from displayed context.
+- Three Chromium React selected viewport captures plus eight PyQt selected/error-prior
+ window/canvas DPI PNGs are diagnostic only. The retained raw grid differs by runtime,
+ so indices are runtime-local evidence, not cross-solver identity. SPEC is
+ 1.16.93; #4433 remains open for remaining tabs and approval/AT coverage.
+
## 2026-08-13 #4433 Club Explorer hosted dtype closure
- The structured binary-STL dtype is explicitly typed and three binding boolean
diff --git a/scripts/test_assertion_allowlist.txt b/scripts/test_assertion_allowlist.txt
index 818f9d64b..caa2121ac 100644
--- a/scripts/test_assertion_allowlist.txt
+++ b/scripts/test_assertion_allowlist.txt
@@ -24,5 +24,7 @@ tests/rate_of_closure/pyqt_variation_render_probe.py
tests/rate_of_closure/pyqt_variation_visual_state_probe.py
# Subprocess entrypoint; semantic assertions live in the owning rendered test.
tests/rate_of_closure/pyqt_putting_sample_inspector_probe.py
+# Subprocess entrypoint; semantic assertions live in the owning rendered test.
+tests/rate_of_closure/pyqt_flight_sample_inspector_probe.py
# Subprocess entrypoint; semantic assertions live in the owning tab-visibility test.
tests/rate_of_closure/pyqt_visualization_tab_probe.py
diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md
index d6752d1e3..53dea9bec 100644
--- a/src/rate_of_closure/AGENT_HANDOFF.md
+++ b/src/rate_of_closure/AGENT_HANDOFF.md
@@ -3,6 +3,23 @@
> **Update this file with every PR and every push to main.**
> Last updated: 2026-08-13
+## 2026-08-13 Flight synchronized sample inspector (#4433)
+
+- React direct entry and PyQt direct/delivery snapshot one complete accepted
+ flight: producing inputs, launch/model/kernel/wind provenance, aligned raw
+ time/position/velocity, optional calm comparison, summaries, and a bounded
+ time/position plan. The separate target overlay is transactionally aligned.
+- Only the current primary cohort is selectable. Exact raw-index selection is
+ generation-bound, uses a 12 CSS/display-pixel radius, retains on misses, and
+ synchronizes side/top markers, polite status, and the sole 3D playback time.
+- All target/view/row/delta/status/public-reference publication is transactional.
+ First failure stays empty; prior failure retains authority and selection. A
+ failed pixel rollback is reported as stale/unavailable rather than fabricated
+ retained imagery. Presentation-unit changes preserve canonical speed exactly.
+- Chromium React selected evidence covers 1440x900, 1280x720, and 390x844. Eight PyQt
+ selected/error-prior window/canvas PNGs cover DPI 1.0/1.5 with exact canvas identity. These
+ PNGs are diagnostic, not approved goldens. SPEC is 1.16.93; #4433 remains open.
+
## 2026-08-13 Club Explorer hosted dtype closure (#4433)
- The binary-STL record now has an explicit structured NumPy dtype, and Qt/
diff --git a/src/rate_of_closure/flight_accepted_study.py b/src/rate_of_closure/flight_accepted_study.py
new file mode 100644
index 000000000..bcfc4855e
--- /dev/null
+++ b/src/rate_of_closure/flight_accepted_study.py
@@ -0,0 +1,363 @@
+"""Atomic, immutable accepted-flight evidence for the PyQt explorer."""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Mapping
+from dataclasses import dataclass, replace
+from types import MappingProxyType
+from typing import cast
+
+import numpy as np
+
+from rate_of_closure.flight_sample_inspector import (
+ MAX_FLIGHT_POSITION_M,
+ MAX_FLIGHT_TIME_S,
+ FlightSamplePlan,
+ FlightSampleSeries,
+ plan_flight_samples,
+)
+from rate_of_closure.model import MPH_PER_MPS
+from rate_of_closure.simulation.flight_explorer import (
+ EXPLORER_METRIC_KEYS,
+ FlightExploration,
+ WindComparison,
+ launch_from_delivery,
+ launch_from_direct,
+)
+from shared.python.swing_sim.flight import (
+ LaunchConditions,
+ LaunchDirectionConvention,
+ WindScenario,
+)
+from shared.python.swing_sim.flight.registry import FlightModelType
+from shared.python.swing_sim.impact import DeliveryParameters
+
+DIRECT_INPUT_KEYS = (
+ "ball_speed_mph",
+ "launch_angle_deg",
+ "launch_direction_deg",
+ "spin_rpm",
+ "spin_axis_tilt_deg",
+)
+DELIVERY_INPUT_KEYS = (
+ "clubhead_speed_mps",
+ "club_path_deg",
+ "face_angle_deg",
+ "attack_angle_deg",
+ "dynamic_loft_deg",
+ "impact_offset_toe_mm",
+ "impact_offset_high_mm",
+ "lie_deg",
+)
+
+
+@dataclass(frozen=True)
+class FlightStudyContext:
+ """Complete normalized producing request, including resolved launch."""
+
+ entry_mode: str
+ input_values: tuple[tuple[str, float], ...]
+ direction_convention: LaunchDirectionConvention
+ model_name: str
+ wind_scenario: WindScenario | None
+ expected_launch: LaunchConditions
+ kernel_revision: str = "python-rk45-v1"
+
+ def __post_init__(self) -> None:
+ expected_keys = (
+ DIRECT_INPUT_KEYS if self.entry_mode == "direct" else DELIVERY_INPUT_KEYS
+ )
+ if self.entry_mode not in {"direct", "delivery"}:
+ raise ValueError("accepted flight entry mode is unknown")
+ if tuple(key for key, _value in self.input_values) != expected_keys:
+ raise ValueError("accepted flight input identity is incomplete")
+ if any(
+ isinstance(value, bool)
+ or not isinstance(value, (int, float))
+ or not math.isfinite(value)
+ for _key, value in self.input_values
+ ):
+ raise ValueError("accepted flight inputs must be finite real numbers")
+ if not isinstance(self.direction_convention, LaunchDirectionConvention):
+ raise ValueError("accepted launch direction convention is unknown")
+ if self.model_name not in {model.value for model in FlightModelType}:
+ raise ValueError("accepted flight model is unknown")
+ if self.kernel_revision != "python-rk45-v1":
+ raise ValueError("accepted flight kernel revision is unknown")
+ if not isinstance(self.expected_launch, LaunchConditions):
+ raise ValueError("accepted flight must bind resolved launch authority")
+ if self.expected_launch.wind_scenario != self.wind_scenario:
+ raise ValueError("resolved launch wind disagrees with accepted context")
+ if self.expected_launch.wind_speed != 0.0:
+ raise ValueError("legacy scalar wind is outside the explorer authority")
+ values = dict(self.input_values)
+ if self.entry_mode == "direct":
+ rebuilt = launch_from_direct(
+ values["ball_speed_mph"],
+ values["launch_angle_deg"],
+ values["launch_direction_deg"],
+ values["spin_rpm"],
+ values["spin_axis_tilt_deg"],
+ direction_convention=self.direction_convention,
+ )
+ else:
+ rebuilt = launch_from_delivery(
+ DeliveryParameters(
+ clubhead_speed_mps=values["clubhead_speed_mps"],
+ club_path_deg=values["club_path_deg"],
+ face_angle_deg=values["face_angle_deg"],
+ attack_angle_deg=values["attack_angle_deg"],
+ dynamic_loft_deg=values["dynamic_loft_deg"],
+ impact_offset_toe_mm=values["impact_offset_toe_mm"],
+ impact_offset_high_mm=values["impact_offset_high_mm"],
+ lie_deg=values["lie_deg"],
+ )
+ )
+ rebuilt = replace(rebuilt, wind_speed=0.0, wind_scenario=self.wind_scenario)
+ if rebuilt != self.expected_launch:
+ raise ValueError(
+ "raw flight inputs disagree with resolved launch authority"
+ )
+
+ def label(self) -> str:
+ inputs = ", ".join(f"{key} {value:.3f}" for key, value in self.input_values)
+ wind = (
+ "calm"
+ if self.wind_scenario is None
+ else "wind scenario "
+ f"{self.wind_scenario.schema_version}; "
+ f"{len(self.wind_scenario.gusts)} gusts; "
+ f"{str(self.wind_scenario.provenance)[:96]}"
+ )
+ convention = (
+ f"; {self.direction_convention.value}"
+ if self.entry_mode == "direct"
+ else ""
+ )
+ return (
+ f"{self.entry_mode}; {inputs}{convention}; {wind}; "
+ f"model {self.model_name}; {self.kernel_revision}"
+ )
+
+
+@dataclass(frozen=True)
+class AcceptedFlightStudy:
+ """Complete candidate published only after every derivation succeeds."""
+
+ generation: int
+ context: FlightStudyContext
+ exploration: FlightExploration
+ calm_comparison: FlightExploration | None
+ comparison: WindComparison | None
+ plan: FlightSamplePlan
+
+
+def _immutable_array(value: object, shape: tuple[int, ...], field: str) -> np.ndarray:
+ raw = np.asarray(value)
+ if (
+ raw.shape != shape
+ or raw.dtype.kind not in "fiu"
+ or not np.all(np.isfinite(raw))
+ ):
+ raise ValueError(f"{field} must contain finite aligned numeric evidence")
+ if np.any(
+ np.abs(raw) > (1_000 if field == "velocities" else MAX_FLIGHT_POSITION_M)
+ ):
+ raise ValueError(f"{field} exceeds the explorer evidence envelope")
+ source = np.asarray(raw, dtype=np.float64)
+ return np.frombuffer(source.tobytes(), dtype=np.float64).reshape(source.shape)
+
+
+def _snapshot(exploration: FlightExploration) -> FlightExploration:
+ if not isinstance(exploration, FlightExploration):
+ raise ValueError("accepted flight result must be a FlightExploration")
+ series = FlightSampleSeries.from_exploration(exploration)
+ count = len(series.times_s)
+ velocities = _immutable_array(exploration.velocities, (count, 3), "velocities")
+ metrics = exploration.metrics
+ if not isinstance(metrics, dict) or set(metrics) != set(EXPLORER_METRIC_KEYS):
+ raise ValueError("flight summary must contain the exact explorer metrics")
+ copied: dict[str, float] = {}
+ for key, value in metrics.items():
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ raise ValueError("flight summary metrics must be finite numbers")
+ copied[key] = float(value)
+ if any(not math.isfinite(value) for value in copied.values()):
+ raise ValueError("flight summary metrics must be finite numbers")
+ times = np.frombuffer(np.asarray(series.times_s).tobytes(), dtype=np.float64)
+ positions = _immutable_array(series.positions_m, (count, 3), "positions")
+ return FlightExploration(
+ exploration.launch,
+ str(exploration.model_name),
+ times,
+ positions,
+ velocities,
+ cast(dict[str, float], MappingProxyType(copied)),
+ )
+
+
+def _close(actual: float, expected: float, field: str) -> None:
+ tolerance = 1e-7 * max(1.0, abs(actual), abs(expected))
+ if not math.isclose(actual, expected, rel_tol=0.0, abs_tol=tolerance):
+ raise ValueError(f"{field} disagrees with exact trajectory evidence")
+
+
+def _validate(exploration: FlightExploration, plan: FlightSamplePlan) -> None:
+ first, last = plan.raw_sample(0), plan.raw_sample(plan.raw_count - 1)
+ metrics: Mapping[str, float] = exploration.metrics
+ _close(first.time_s, 0.0, "first sample time")
+ _close(first.downrange_m, 0.0, "launch downrange position")
+ _close(first.height_m, exploration.launch.ball_radius, "launch height")
+ _close(first.right_m, 0.0, "launch lateral position")
+ _close(last.height_m, exploration.launch.ball_radius, "landing height")
+ if any(
+ sample.height_m < exploration.launch.ball_radius - 1e-7
+ for sample in plan.samples
+ ):
+ raise ValueError("flight evidence falls below the canonical ground plane")
+ _close(metrics["flight_time_s"], last.time_s, "flight time")
+ _close(
+ metrics["carry_m"],
+ math.hypot(last.downrange_m - first.downrange_m, last.right_m - first.right_m),
+ "carry",
+ )
+ _close(metrics["lateral_m"], last.right_m - first.right_m, "lateral offset")
+ sampled_height = max(sample.height_m - first.height_m for sample in plan.samples)
+ _close(metrics["max_height_m"], sampled_height, "maximum height")
+ if (
+ not 0
+ <= metrics["carry_m"]
+ <= math.hypot(MAX_FLIGHT_POSITION_M, MAX_FLIGHT_POSITION_M)
+ ):
+ raise ValueError("carry is outside the explorer evidence envelope")
+ if not 0 <= metrics["flight_time_s"] <= MAX_FLIGHT_TIME_S:
+ raise ValueError("flight time is outside the explorer evidence envelope")
+ _close(
+ metrics["launch_direction_deg"],
+ metrics["launch_azimuth_deg"],
+ "direction alias",
+ )
+ _close(
+ metrics["ball_speed_mph"],
+ exploration.launch.ball_speed * MPH_PER_MPS,
+ "ball speed",
+ )
+ _close(
+ metrics["launch_angle_deg"],
+ math.degrees(exploration.launch.launch_angle),
+ "launch angle",
+ )
+ _close(
+ metrics["launch_direction_deg"],
+ -math.degrees(exploration.launch.azimuth_angle),
+ "launch direction",
+ )
+ _close(metrics["spin_rpm"], exploration.launch.spin_rate, "spin rate")
+ launch_velocity = exploration.velocities[0]
+ launch_horizontal = math.hypot(float(launch_velocity[0]), float(launch_velocity[2]))
+ _close(
+ float(np.linalg.norm(launch_velocity)),
+ exploration.launch.ball_speed,
+ "raw launch speed",
+ )
+ _close(
+ math.atan2(float(launch_velocity[1]), launch_horizontal),
+ exploration.launch.launch_angle,
+ "raw launch angle",
+ )
+ _close(
+ math.atan2(float(launch_velocity[2]), float(launch_velocity[0])),
+ -exploration.launch.azimuth_angle,
+ "raw launch direction",
+ )
+ landing_velocity = exploration.velocities[-1]
+ horizontal = math.hypot(float(landing_velocity[0]), float(landing_velocity[2]))
+ landing_angle = (
+ math.degrees(math.atan2(-float(landing_velocity[1]), horizontal))
+ if horizontal > 0.1
+ else 90.0
+ )
+ _close(metrics["landing_angle_deg"], landing_angle, "landing angle")
+
+
+def _same_exploration(left: FlightExploration, right: FlightExploration) -> bool:
+ return bool(
+ left.launch == right.launch
+ and left.model_name == right.model_name
+ and left.metrics == right.metrics
+ and np.array_equal(left.times, right.times)
+ and np.array_equal(left.positions, right.positions)
+ and np.array_equal(left.velocities, right.velocities)
+ )
+
+
+def build_accepted_flight_study(
+ generation: int,
+ context: FlightStudyContext,
+ exploration_input: FlightExploration,
+ comparison_input: WindComparison | None,
+) -> AcceptedFlightStudy:
+ """Validate and snapshot a complete candidate before publication."""
+ if (
+ isinstance(generation, bool)
+ or not isinstance(generation, int)
+ or not 1 <= generation <= 2**53 - 1
+ ):
+ raise ValueError("accepted flight generation must be a positive safe integer")
+ exploration = _snapshot(exploration_input)
+ if (
+ exploration.launch != context.expected_launch
+ or exploration.model_name != context.model_name
+ ):
+ raise ValueError("flight result authority disagrees with accepted request")
+ plan = plan_flight_samples(FlightSampleSeries.from_exploration(exploration))
+ _validate(exploration, plan)
+ calm: FlightExploration | None = None
+ comparison: WindComparison | None = None
+ if comparison_input is not None:
+ if (
+ context.wind_scenario is None
+ or comparison_input.scenario != context.wind_scenario
+ ):
+ raise ValueError("wind comparison scenario disagrees with accepted request")
+ wind = _snapshot(comparison_input.wind)
+ calm = _snapshot(comparison_input.calm)
+ if not _same_exploration(wind, exploration):
+ raise ValueError(
+ "wind comparison primary disagrees with accepted exploration"
+ )
+ expected_calm = replace(
+ context.expected_launch, wind_speed=0.0, wind_scenario=None
+ )
+ if calm.launch != expected_calm or calm.model_name != context.model_name:
+ raise ValueError(
+ "calm comparison does not share the accepted launch authority"
+ )
+ calm_plan = plan_flight_samples(FlightSampleSeries.from_exploration(calm))
+ _validate(calm, calm_plan)
+ delta_keys = {
+ "carry_m",
+ "max_height_m",
+ "flight_time_s",
+ "landing_angle_deg",
+ "lateral_m",
+ }
+ if set(comparison_input.deltas) != delta_keys:
+ raise ValueError("wind comparison deltas are incomplete")
+ deltas: dict[str, float] = {}
+ for key in delta_keys:
+ value = comparison_input.deltas[key]
+ if not math.isfinite(value):
+ raise ValueError("wind comparison deltas must be finite")
+ _close(value, wind.metrics[key] - calm.metrics[key], f"wind {key}")
+ deltas[key] = float(value)
+ comparison = WindComparison(
+ calm,
+ wind,
+ context.wind_scenario,
+ cast(dict[str, float], MappingProxyType(deltas)),
+ )
+ elif context.wind_scenario is not None:
+ raise ValueError("enabled wind request requires a cohesive comparison")
+ return AcceptedFlightStudy(generation, context, exploration, calm, comparison, plan)
diff --git a/src/rate_of_closure/flight_sample_inspector.py b/src/rate_of_closure/flight_sample_inspector.py
new file mode 100644
index 000000000..0be4e5884
--- /dev/null
+++ b/src/rate_of_closure/flight_sample_inspector.py
@@ -0,0 +1,257 @@
+"""Immutable exact-sample plans for synchronized flight inspection."""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Sequence
+from dataclasses import dataclass
+from numbers import Real
+from typing import Literal, cast
+
+import numpy as np
+
+from rate_of_closure.simulation.flight_explorer import FlightExploration
+
+MAX_FLIGHT_SAMPLES = 1_002
+MAX_FLIGHT_TIME_S = 10.001
+MAX_FLIGHT_POSITION_M = 10_000.0
+DEFAULT_FLIGHT_HIT_RADIUS_PX = 12.0
+
+FlightPhase = Literal["launch", "ascent", "apex", "descent", "landing"]
+FlightNavigation = Literal["previous", "next", "home", "end", "clear"]
+FlightCohort = Literal["current"]
+
+
+def _finite(value: object, field: str) -> float:
+ if isinstance(value, bool) or not isinstance(value, Real):
+ raise ValueError(f"{field} must be a finite real number")
+ number = float(value)
+ if not math.isfinite(number):
+ raise ValueError(f"{field} must be a finite real number")
+ return number
+
+
+@dataclass(frozen=True)
+class FlightSampleSeries:
+ """Deep immutable snapshot of one runtime-local flight trajectory."""
+
+ times_s: Sequence[float]
+ positions_m: Sequence[Sequence[float]]
+
+ def __post_init__(self) -> None:
+ times_value: object = self.times_s
+ positions_value: object = self.positions_m
+ if not isinstance(times_value, Sequence) or isinstance(
+ times_value, (str, bytes, bytearray)
+ ):
+ raise ValueError("times_s must be a sized numeric sequence")
+ if not isinstance(positions_value, Sequence) or isinstance(
+ positions_value, (str, bytes, bytearray)
+ ):
+ raise ValueError("positions_m must be a sized row sequence")
+ times_input = cast(Sequence[object], times_value)
+ positions_input = cast(Sequence[object], positions_value)
+ count = len(times_input)
+ if not 2 <= count <= MAX_FLIGHT_SAMPLES:
+ raise ValueError(
+ f"flight evidence must contain 2..{MAX_FLIGHT_SAMPLES} samples"
+ )
+ if len(positions_input) != count:
+ raise ValueError("flight positions must have shape (N, 3)")
+ checked_rows: list[Sequence[object]] = []
+ for row in positions_input:
+ if not isinstance(row, Sequence) or isinstance(
+ row, (str, bytes, bytearray)
+ ):
+ raise ValueError("flight positions must have shape (N, 3)")
+ if len(row) != 3:
+ raise ValueError("flight positions must have shape (N, 3)")
+ checked_rows.append(cast(Sequence[object], row))
+ times = tuple(_finite(value, "times_s") for value in times_input)
+ positions = tuple(
+ tuple(_finite(value, "positions_m") for value in row)
+ for row in checked_rows
+ )
+ if times[0] < 0 or any(
+ right <= left for left, right in zip(times, times[1:], strict=False)
+ ):
+ raise ValueError(
+ "flight sample times must be nonnegative and strictly increasing"
+ )
+ if times[-1] > MAX_FLIGHT_TIME_S:
+ raise ValueError("flight sample time exceeds the explorer contract")
+ if any(
+ abs(value) > MAX_FLIGHT_POSITION_M for row in positions for value in row
+ ):
+ raise ValueError("flight position exceeds the explorer contract")
+ object.__setattr__(self, "times_s", times)
+ object.__setattr__(self, "positions_m", positions)
+
+ @classmethod
+ def from_exploration(cls, exploration: FlightExploration) -> FlightSampleSeries:
+ """Copy a flight result before presentation publication."""
+ if not isinstance(exploration, FlightExploration):
+ raise ValueError("flight sample source must be a FlightExploration")
+ if not isinstance(exploration.times, np.ndarray) or not isinstance(
+ exploration.positions, np.ndarray
+ ):
+ raise ValueError("flight sample arrays must be NumPy arrays")
+ times = exploration.times
+ positions = exploration.positions
+ if (
+ times.ndim != 1
+ or positions.ndim != 2
+ or positions.shape[1:] != (3,)
+ or not 2 <= times.shape[0] <= MAX_FLIGHT_SAMPLES
+ or positions.shape[0] != times.shape[0]
+ ):
+ raise ValueError("flight sample arrays have invalid dimensions")
+ return cls(times.tolist(), positions.tolist())
+
+
+@dataclass(frozen=True)
+class FlightDisplaySample:
+ """One exact raw solver sample used by both profile markers."""
+
+ raw_index: int
+ time_s: float
+ downrange_m: float
+ height_m: float
+ right_m: float
+ phase: FlightPhase
+
+
+@dataclass(frozen=True)
+class FlightSampleSelection:
+ """Exact cohort-local identity; calm comparison is deliberately not selectable."""
+
+ cohort: FlightCohort
+ raw_index: int
+
+ def __post_init__(self) -> None:
+ if self.cohort != "current":
+ raise ValueError("only the current primary flight is selectable")
+ if isinstance(self.raw_index, bool) or not isinstance(self.raw_index, int):
+ raise ValueError("raw sample index must be an integer")
+ if self.raw_index < 0:
+ raise ValueError("raw sample index must be nonnegative")
+
+
+@dataclass(frozen=True)
+class FlightSamplePlan:
+ """Fixed exact geometry and phase authority for one accepted flight."""
+
+ series: FlightSampleSeries
+ samples: tuple[FlightDisplaySample, ...]
+ apex_raw_index: int
+
+ @property
+ def raw_count(self) -> int:
+ return len(self.samples)
+
+ @property
+ def raw_indices(self) -> tuple[int, ...]:
+ return tuple(range(self.raw_count))
+
+ def raw_sample(self, raw_index: int) -> FlightDisplaySample:
+ if isinstance(raw_index, bool) or not isinstance(raw_index, int):
+ raise ValueError("raw sample index must be an integer")
+ if not 0 <= raw_index < self.raw_count:
+ raise ValueError("raw sample index is outside the accepted flight")
+ return self.samples[raw_index]
+
+
+def plan_flight_samples(series: FlightSampleSeries) -> FlightSamplePlan:
+ """Build one immutable O(raw) plan without interpolation or decimation."""
+ if not isinstance(series, FlightSampleSeries):
+ raise ValueError("flight planner requires validated sample evidence")
+ heights = tuple(row[1] for row in series.positions_m)
+ apex = max(range(len(heights)), key=lambda index: (heights[index], -index))
+ last = len(heights) - 1
+ samples: list[FlightDisplaySample] = []
+ for index, (time_s, position) in enumerate(
+ zip(series.times_s, series.positions_m, strict=True)
+ ):
+ # Coincident events use launch > landing > apex precedence. Normal
+ # flights still expose an interior apex; a two-sample descending or
+ # monotone-ascent edge honestly cannot display every event label.
+ if index == 0:
+ phase: FlightPhase = "launch"
+ elif index == last:
+ phase = "landing"
+ elif index == apex:
+ phase = "apex"
+ elif index < apex:
+ phase = "ascent"
+ else:
+ phase = "descent"
+ samples.append(
+ FlightDisplaySample(
+ index,
+ time_s,
+ position[0],
+ position[1],
+ position[2],
+ phase,
+ )
+ )
+ return FlightSamplePlan(series, tuple(samples), apex)
+
+
+def navigate_flight_samples(
+ plan: FlightSamplePlan, current_raw_index: int | None, command: FlightNavigation
+) -> int | None:
+ """Navigate exact runtime-local raw samples only."""
+ if command not in {"previous", "next", "home", "end", "clear"}:
+ raise ValueError("unknown flight sample navigation command")
+ if command == "clear":
+ return None
+ if command == "home":
+ return 0
+ if command == "end":
+ return plan.raw_count - 1
+ if not isinstance(current_raw_index, int) or isinstance(current_raw_index, bool):
+ return 0 if command == "next" else plan.raw_count - 1
+ if not 0 <= current_raw_index < plan.raw_count:
+ return 0 if command == "next" else plan.raw_count - 1
+ delta = 1 if command == "next" else -1
+ return min(max(current_raw_index + delta, 0), plan.raw_count - 1)
+
+
+def nearest_flight_sample(
+ plan: FlightSamplePlan,
+ projected: Sequence[tuple[object, object, object, object]],
+ pointer_px: tuple[object, object],
+ *,
+ hit_radius_px: object = DEFAULT_FLIGHT_HIT_RADIUS_PX,
+) -> FlightSampleSelection | None:
+ """Pick the closest exact raw sample in rendered pixel space."""
+ if len(pointer_px) != 2:
+ raise ValueError("pointer must contain two finite pixel coordinates")
+ pointer_x, pointer_y = (_finite(value, "pointer") for value in pointer_px)
+ radius = _finite(hit_radius_px, "hit radius")
+ if not 0 < radius <= 100:
+ raise ValueError("hit radius must be a positive pixel distance")
+ candidates: list[tuple[float, int]] = []
+ seen: set[int] = set()
+ for item in projected:
+ if len(item) != 4:
+ raise ValueError("projected samples require cohort, index, and two pixels")
+ cohort, raw_index, raw_x, raw_y = item
+ if cohort != "current":
+ raise ValueError("calm comparison samples are not selectable")
+ if (
+ isinstance(raw_index, bool)
+ or not isinstance(raw_index, int)
+ or raw_index < 0
+ ):
+ raise ValueError("projected raw index must be a nonnegative integer")
+ if raw_index >= plan.raw_count or raw_index in seen:
+ raise ValueError("projected raw indices must be unique and in range")
+ seen.add(raw_index)
+ x, y = _finite(raw_x, "projected x"), _finite(raw_y, "projected y")
+ candidates.append((math.hypot(x - pointer_x, y - pointer_y), raw_index))
+ if seen != set(plan.raw_indices):
+ raise ValueError("projected samples must cover the complete primary plan")
+ distance, raw_index = min(candidates, key=lambda item: (item[0], item[1]))
+ return FlightSampleSelection("current", raw_index) if distance <= radius else None
diff --git a/src/rate_of_closure/simulation/flight_explorer.py b/src/rate_of_closure/simulation/flight_explorer.py
index 929596cf6..2d5428984 100644
--- a/src/rate_of_closure/simulation/flight_explorer.py
+++ b/src/rate_of_closure/simulation/flight_explorer.py
@@ -85,6 +85,7 @@ class FlightExploration:
model_name: str
times: np.ndarray
positions: np.ndarray
+ velocities: np.ndarray
metrics: dict[str, float]
@@ -127,11 +128,21 @@ def launch_from_direct(
Returns:
Flight-frame :class:`LaunchConditions`.
"""
+ if spin_rpm is None or spin_axis_tilt_deg is None:
+ raise TypeError("spin_rpm and spin_axis_tilt_deg are required")
+ direct_values = (ball_speed_mph, launch_angle_deg, spin_rpm, spin_axis_tilt_deg)
require(
- math.isfinite(ball_speed_mph) and ball_speed_mph > 0.0,
- "ball_speed_mph must be finite and > 0",
- ball_speed_mph,
+ all(
+ type(value) in (int, float) and math.isfinite(value)
+ for value in direct_values
+ ),
+ "direct flight inputs must be finite real numbers",
+ direct_values,
)
+ require(1 <= ball_speed_mph <= 250, "ball_speed_mph must be within 1..250")
+ require(abs(launch_angle_deg) <= 89, "launch_angle_deg must be within -89..89")
+ require(0 <= spin_rpm <= 15_000, "spin_rpm must be within 0..15000")
+ require(abs(spin_axis_tilt_deg) <= 60, "spin_axis_tilt_deg must be within -60..60")
# Accept the historical keyword without silently choosing between conflicts.
if launch_direction_deg is None:
if azimuth_deg is None:
@@ -141,8 +152,12 @@ def launch_from_direct(
launch_direction_deg, azimuth_deg, rel_tol=0.0, abs_tol=1e-12
):
raise ValueError("conflicting launch-direction and legacy azimuth values")
- if spin_rpm is None or spin_axis_tilt_deg is None:
- raise TypeError("spin_rpm and spin_axis_tilt_deg are required")
+ require(
+ type(launch_direction_deg) in (int, float)
+ and math.isfinite(launch_direction_deg)
+ and abs(launch_direction_deg) <= 45,
+ "launch_direction_deg must be within -45..45",
+ )
flight_azimuth_deg = launch_direction_to_flight_azimuth(
LaunchDirection(launch_direction_deg, direction_convention)
)
@@ -209,6 +224,9 @@ def explore_flight(
if len(flight.trajectory)
else np.zeros((0, 3))
)
+ velocities = np.asarray(
+ [from_flight_frame(point.velocity) for point in flight.trajectory], dtype=float
+ )
metrics = {
"ball_speed_mph": launch.ball_speed * MPH_PER_MPS,
"launch_angle_deg": math.degrees(launch.launch_angle),
@@ -232,6 +250,7 @@ def explore_flight(
model_name=model_name,
times=times,
positions=np.asarray(positions),
+ velocities=velocities,
metrics=metrics,
)
diff --git a/src/rate_of_closure/ui/pyqt6/figure_canvas.py b/src/rate_of_closure/ui/pyqt6/figure_canvas.py
index 22f0214af..7640e7523 100644
--- a/src/rate_of_closure/ui/pyqt6/figure_canvas.py
+++ b/src/rate_of_closure/ui/pyqt6/figure_canvas.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from collections.abc import Iterator
+from contextlib import contextmanager
from typing import overload
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
@@ -18,13 +20,22 @@ class LifecycleSafeFigureCanvas(FigureCanvasQTAgg):
def __init__(self, figure: Figure) -> None:
super().__init__(figure)
+ self._draw_pending: bool = False
self._idle_draw_timer = QTimer(self)
self._idle_draw_timer.setSingleShot(True)
self._idle_draw_timer.timeout.connect(self._draw_idle)
+ self._idle_draw_suppression_depth = 0
+ self._idle_draws_paused = False
def draw_idle(self) -> None:
"""Coalesce redraw requests on a timer owned by this canvas."""
- if sip.isdeleted(self) or self._draw_pending or self._is_drawing:
+ if (
+ sip.isdeleted(self)
+ or self._idle_draws_paused
+ or self._idle_draw_suppression_depth
+ or self._draw_pending
+ or self._is_drawing
+ ):
return
self._draw_pending = True
self._idle_draw_timer.start(0)
@@ -39,6 +50,27 @@ def has_pending_draw(self) -> bool:
"""Return whether this canvas owns a queued idle redraw."""
return self._draw_pending
+ @contextmanager
+ def suppress_idle_draws(self) -> Iterator[None]:
+ """Prevent backend callbacks from leaking work outside an atomic draw."""
+ self._idle_draw_suppression_depth += 1
+ self.cancel_pending_draw()
+ try:
+ yield
+ finally:
+ self.cancel_pending_draw()
+ self._idle_draw_suppression_depth -= 1
+
+ def pause_idle_draws(self) -> None:
+ """Hold deferred redraws after an un-restorable publication failure."""
+ self._idle_draws_paused = True
+ self.cancel_pending_draw()
+
+ def resume_idle_draws(self) -> None:
+ """Allow deferred redraws after a successful synchronous retry."""
+ self.cancel_pending_draw()
+ self._idle_draws_paused = False
+
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
"""Cancel queued drawing before Qt starts destroying the canvas."""
self.cancel_pending_draw()
diff --git a/src/rate_of_closure/ui/pyqt6/flight_explorer_controls.py b/src/rate_of_closure/ui/pyqt6/flight_explorer_controls.py
index f07f89e7e..ae8a5d738 100644
--- a/src/rate_of_closure/ui/pyqt6/flight_explorer_controls.py
+++ b/src/rate_of_closure/ui/pyqt6/flight_explorer_controls.py
@@ -12,14 +12,9 @@
QWidget,
)
-from rate_of_closure.model import MPH_PER_MPS
-
#: Entry modes, in combo order.
ENTRY_MODES: tuple[str, ...] = ("Direct Launch Conditions", "Impact Delivery")
-#: Speed display units: label -> factor from displayed to m/s.
-SPEED_UNITS: dict[str, float] = {"mph": 1.0 / MPH_PER_MPS, "m/s": 1.0}
-
#: (metric key, Title Case label, unit suffix) result rows in display order.
EXPLORER_ROWS: tuple[tuple[str, str, str], ...] = (
("carry_m", "Carry Distance", " m"),
diff --git a/src/rate_of_closure/ui/pyqt6/flight_explorer_run.py b/src/rate_of_closure/ui/pyqt6/flight_explorer_run.py
new file mode 100644
index 000000000..f4180a5f7
--- /dev/null
+++ b/src/rate_of_closure/ui/pyqt6/flight_explorer_run.py
@@ -0,0 +1,321 @@
+"""Atomic execution/publication mixin for the PyQt Flight Explorer."""
+
+from __future__ import annotations
+
+import logging
+import math
+from dataclasses import replace
+from typing import TYPE_CHECKING
+
+import numpy as np
+
+from rate_of_closure.flight_accepted_study import (
+ AcceptedFlightStudy,
+ FlightStudyContext,
+ build_accepted_flight_study,
+)
+from rate_of_closure.simulation import (
+ FlightExploration,
+ explore_with_optional_wind,
+ launch_from_delivery,
+ launch_from_direct,
+)
+from rate_of_closure.ui.pyqt6.flight_explorer_controls import (
+ DISTANCE_ROWS,
+ ENTRY_MODES,
+ EXPLORER_ROWS,
+)
+from rate_of_closure.ui.pyqt6.flight_view_bundle import FlightViewRestorationError
+from rate_of_closure.units import format_distance_m
+from shared.python.swing_sim.flight import LaunchDirectionConvention
+from shared.python.swing_sim.impact import DeliveryParameters
+
+if TYPE_CHECKING:
+ from PyQt6.QtWidgets import QComboBox, QDoubleSpinBox, QLabel
+
+ from rate_of_closure.ui.pyqt6.flight_playback_controls import FlightPlaybackPanel
+ from rate_of_closure.ui.pyqt6.flight_view import FlightView
+ from rate_of_closure.ui.pyqt6.flight_wind_controls import FlightWindControls
+ from rate_of_closure.ui.pyqt6.result_row import ResultRow
+ from rate_of_closure.ui.pyqt6.spatial_target_workflow import SpatialTargetWorkflow
+
+logger = logging.getLogger(__name__)
+
+
+class FlightExplorerRunMixin:
+ """Own one accepted bundle and publish it with strong result retention."""
+
+ if TYPE_CHECKING:
+ _accepted: AcceptedFlightStudy | None
+ _generation: int
+ _speed_spin: QDoubleSpinBox
+ _speed_unit: str
+ _direct_spins: dict[str, QDoubleSpinBox]
+ _delivery_spins: dict[str, QDoubleSpinBox]
+ _direction_convention_combo: QComboBox
+ _model_combo: QComboBox
+ _rows: dict[str, ResultRow]
+ _flight_view: FlightView
+ _flight_panel: FlightPlaybackPanel
+ _target_workflow: SpatialTargetWorkflow
+ _context_status: QLabel
+ _sample_status: QLabel
+ _error_status: QLabel
+ _error_origin: str | None
+ wind_controls: FlightWindControls
+
+ def mode(self) -> str: ...
+
+ def speed_mps(self) -> float: ...
+
+ def speed_mph(self) -> float: ...
+
+ def _candidate_context(self) -> tuple[object, FlightStudyContext]:
+ scenario = self.wind_controls.optional_scenario()
+ model_name = self._model_combo.currentText()
+ direction = self._direction_convention_combo.currentData()
+ speed_mph = self.speed_mph()
+ if self.mode() == ENTRY_MODES[0]:
+ values = tuple(
+ (key, self._direct_spins[key].value())
+ for key in (
+ "launch_angle_deg",
+ "launch_direction_deg",
+ "spin_rpm",
+ "spin_axis_tilt_deg",
+ )
+ )
+ inputs = (("ball_speed_mph", speed_mph), *values)
+ launch = launch_from_direct(
+ *(value for _key, value in inputs), direction_convention=direction
+ )
+ mode = "direct"
+ else:
+ direction = LaunchDirectionConvention.APP_NATIVE
+ delivery = DeliveryParameters(
+ clubhead_speed_mps=self.speed_mps(),
+ **{key: spin.value() for key, spin in self._delivery_spins.items()},
+ )
+ launch = launch_from_delivery(delivery)
+ inputs = (
+ ("clubhead_speed_mps", self.speed_mps()),
+ ("club_path_deg", delivery.club_path_deg),
+ ("face_angle_deg", delivery.face_angle_deg),
+ ("attack_angle_deg", delivery.attack_angle_deg),
+ ("dynamic_loft_deg", delivery.dynamic_loft_deg),
+ ("impact_offset_toe_mm", delivery.impact_offset_toe_mm),
+ ("impact_offset_high_mm", delivery.impact_offset_high_mm),
+ ("lie_deg", delivery.lie_deg),
+ )
+ mode = "delivery"
+ resolved = replace(launch, wind_speed=0.0, wind_scenario=scenario)
+ return launch, FlightStudyContext(
+ mode, inputs, direction, model_name, scenario, resolved
+ )
+
+ def run_now(self) -> FlightExploration | None:
+ """Build and atomically publish one complete accepted flight."""
+ prior = self._accepted
+ prior_selection = self._flight_view.selected_raw_index()
+ prior_playback_time = self._flight_panel.controls.current_time_s()
+ prior_rows = {key: row.value_label.text() for key, row in self._rows.items()}
+ prior_deltas = self.wind_controls.delta_texts()
+ prior_context = self._context_status.text()
+ prior_sample = self._sample_status.text()
+ prior_error = self._error_status.text()
+ prior_error_origin = self._error_origin
+ prior_target = self._target_workflow.publication_snapshot()
+ target_published = False
+ view_published = False
+ try:
+ launch, context = self._candidate_context()
+ exploration, comparison = explore_with_optional_wind(
+ launch, context.wind_scenario, context.model_name
+ )
+ candidate = build_accepted_flight_study(
+ self._generation + 1, context, exploration, comparison
+ )
+ row_texts = self._row_texts(candidate.exploration)
+ calm = candidate.calm_comparison
+ self._target_workflow.set_trajectory(candidate.exploration.positions)
+ target_published = True
+ self._flight_view.adopt_sample_bundle(
+ candidate.plan,
+ None if calm is None else calm.times,
+ None if calm is None else calm.positions,
+ )
+ view_published = True
+ self.wind_controls.set_comparison(candidate.comparison)
+ for key, text in row_texts.items():
+ self._rows[key].value_label.setText(text)
+ self._context_status.setText(
+ f"Displayed flight: {candidate.context.label()}"
+ )
+ self._sample_status.setText(
+ "Select the current primary trajectory; calm ghost is comparison-only."
+ )
+ self._error_status.clear()
+ self._error_origin = None
+ self._accepted = candidate
+ self._generation = candidate.generation
+ self._exploration = candidate.exploration
+ self.wind_comparison = candidate.comparison
+ except Exception as exc:
+ restoration_failed = self._restore_publication(
+ prior,
+ prior_selection,
+ prior_playback_time,
+ prior_rows,
+ prior_deltas,
+ prior_context,
+ prior_sample,
+ prior_error,
+ prior_error_origin,
+ prior_target,
+ target_published=target_published,
+ view_published=view_published,
+ )
+ logger.warning("flight exploration failed: %s", exc)
+ self._show_error(exc, restoration_failed=restoration_failed)
+ return None
+ return candidate.exploration
+
+ def _restore_publication(
+ self,
+ prior: AcceptedFlightStudy | None,
+ prior_selection: int | None,
+ prior_playback_time: float,
+ prior_rows: dict[str, str],
+ prior_deltas: dict[str, str],
+ prior_context: str,
+ prior_sample: str,
+ prior_error: str,
+ prior_error_origin: str | None,
+ prior_target: tuple[np.ndarray, str],
+ *,
+ target_published: bool,
+ view_published: bool,
+ ) -> bool:
+ """Restore every prior publication surface; report any failed seam."""
+ restoration_failed = False
+ if view_published:
+ try:
+ if prior is None:
+ self._flight_view.clear_sample_bundle()
+ else:
+ calm = prior.calm_comparison
+ self._flight_view.adopt_sample_bundle(
+ prior.plan,
+ None if calm is None else calm.times,
+ None if calm is None else calm.positions,
+ selected_raw_index=prior_selection,
+ playback_time_s=prior_playback_time,
+ )
+ self._flight_panel.controls.jump_to_time(prior_playback_time)
+ except Exception:
+ restoration_failed = True
+ logger.exception("flight view publication rollback failed")
+ try:
+ calm = None if prior is None else prior.calm_comparison
+ self._flight_view.force_sample_bundle_authority(
+ None if prior is None else prior.plan,
+ None if calm is None else calm.times,
+ None if calm is None else calm.positions,
+ selected_raw_index=prior_selection,
+ playback_time_s=prior_playback_time,
+ )
+ self._flight_panel.controls.jump_to_time(prior_playback_time)
+ except Exception:
+ logger.exception("flight view authority force-restore failed")
+ if target_published:
+ try:
+ self._target_workflow.restore_publication_snapshot(prior_target)
+ except Exception:
+ restoration_failed = True
+ logger.exception("flight target workflow rollback failed")
+ try:
+ self.wind_controls.restore_delta_texts(prior_deltas)
+ for key, text in prior_rows.items():
+ self._rows[key].value_label.setText(text)
+ self._context_status.setText(prior_context)
+ self._sample_status.setText(prior_sample)
+ self._error_status.setText(prior_error)
+ self._error_origin = prior_error_origin
+ except Exception:
+ restoration_failed = True
+ logger.exception("flight presentation rollback failed")
+ return restoration_failed
+
+ def _row_texts(self, exploration: FlightExploration) -> dict[str, str]:
+ result: dict[str, str] = {}
+ for key, _label, unit in EXPLORER_ROWS:
+ value = exploration.metrics[key]
+ if not math.isfinite(value):
+ raise ValueError("accepted flight row must be finite")
+ if key in DISTANCE_ROWS:
+ text = (
+ f"+{format_distance_m(value)}"
+ if value >= 0
+ else f"-{format_distance_m(-value)}"
+ )
+ else:
+ text = f"{value:+.1f}{unit}"
+ result[key] = text
+ return result
+
+ def refresh_units(self) -> None:
+ """Presentation-only row refresh preserves accepted identity and warning."""
+ if self._accepted is None:
+ return
+ for key, text in self._row_texts(self._accepted.exploration).items():
+ self._rows[key].value_label.setText(text)
+
+ def _on_sample_selected(self, raw_index: int) -> None:
+ if self._error_origin == "selection":
+ self._error_status.clear()
+ self._error_origin = None
+ accepted = self._accepted
+ if accepted is None or raw_index < 0:
+ self._sample_status.setText(
+ "Select the current primary trajectory; calm ghost is comparison-only."
+ )
+ return
+ sample = accepted.plan.raw_sample(raw_index)
+ self._flight_panel.controls.jump_to_time(sample.time_s)
+ sample_number = raw_index + 1
+ self._sample_status.setText(
+ f"Current primary flight, source sample {sample_number}/"
+ f"{accepted.plan.raw_count}; "
+ f"t {sample.time_s:.3f} s; downrange {sample.downrange_m:.3f} m; "
+ f"height {sample.height_m:.3f} m; right {sample.right_m:.3f} m; "
+ f"{sample.phase}."
+ )
+
+ def _mark_inputs_changed(self, *_args: object) -> None:
+ if self._accepted is not None:
+ self._context_status.setText(
+ f"Prior result — inputs changed: {self._accepted.context.label()}"
+ )
+
+ def _show_error(
+ self,
+ error: Exception,
+ *,
+ origin: str = "scientific",
+ restoration_failed: bool = False,
+ ) -> None:
+ retained = (
+ "Prior accepted authority is retained, but plot restoration failed; "
+ "the image may be stale or unavailable."
+ if restoration_failed or isinstance(error, FlightViewRestorationError)
+ else "The prior accepted flight remains displayed."
+ if self._accepted is not None
+ else "No accepted flight is available."
+ )
+ sanitized = "".join(
+ " " if ord(character) < 32 or 127 <= ord(character) <= 159 else character
+ for character in str(error)
+ )
+ text = " ".join(sanitized.split()) or "Flight computation failed"
+ self._error_status.setText(f"{text[: 238 - len(retained)]}. {retained}")
+ self._error_origin = origin
diff --git a/src/rate_of_closure/ui/pyqt6/flight_explorer_tab.py b/src/rate_of_closure/ui/pyqt6/flight_explorer_tab.py
index 16f8e45ad..3b8eacc0c 100644
--- a/src/rate_of_closure/ui/pyqt6/flight_explorer_tab.py
+++ b/src/rate_of_closure/ui/pyqt6/flight_explorer_tab.py
@@ -9,7 +9,6 @@
from __future__ import annotations
import logging
-import math
from typing import cast
from PyQt6.QtCore import pyqtSignal
@@ -22,7 +21,6 @@
QGroupBox,
QHBoxLayout,
QLabel,
- QMessageBox,
QPushButton,
QScrollArea,
QSplitter,
@@ -33,24 +31,18 @@
)
from rate_of_closure.derivation import LAUNCH_EXPLANATIONS
+from rate_of_closure.flight_accepted_study import AcceptedFlightStudy
from rate_of_closure.model import MPH_PER_MPS
-from rate_of_closure.simulation import (
- FlightExploration,
- WindComparison,
- explore_with_optional_wind,
- launch_from_delivery,
- launch_from_direct,
-)
+from rate_of_closure.simulation import FlightExploration, WindComparison
from rate_of_closure.ui.pyqt6.flight_explorer_controls import (
DELIVERY_FIELDS,
DIRECT_FIELDS,
- DISTANCE_ROWS,
ENTRY_MODES,
EXPLORER_ROWS,
- SPEED_UNITS,
field_label,
make_spin,
)
+from rate_of_closure.ui.pyqt6.flight_explorer_run import FlightExplorerRunMixin
from rate_of_closure.ui.pyqt6.flight_playback_controls import FlightPlaybackPanel
from rate_of_closure.ui.pyqt6.flight_view import FlightView
from rate_of_closure.ui.pyqt6.flight_wind_controls import FlightWindControls
@@ -58,21 +50,20 @@
from rate_of_closure.ui.pyqt6.spatial_target_workflow import (
build_spatial_target_workflow,
)
-from rate_of_closure.units import FIELD_GUIDANCE, format_distance_m
+from rate_of_closure.units import FIELD_GUIDANCE, SPEED_UNITS
from shared.python.swing_sim.flight import (
LAUNCH_DIRECTION_DEFINITIONS,
LaunchDirectionConvention,
launch_direction_sign_labels,
)
from shared.python.swing_sim.flight.registry import FlightModelType
-from shared.python.swing_sim.impact import DeliveryParameters
logger = logging.getLogger(__name__)
__all__ = ["EXPLORER_ROWS", "FlightExplorerTab"]
-class FlightExplorerTab(QWidget):
+class FlightExplorerTab(FlightExplorerRunMixin, QWidget):
"""Standalone flight explorer: launch entry, model picker, viewer."""
#: Emitted with a glossary term key when an explanation link is used.
@@ -82,11 +73,16 @@ def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._exploration: FlightExploration | None = None
self.wind_comparison: WindComparison | None = None
+ self._accepted: AcceptedFlightStudy | None = None
+ self._generation = 0
+ self._error_origin: str | None = None
self._rows: dict[str, ResultRow] = {}
self._direct_spins: dict[str, QDoubleSpinBox] = {}
self._delivery_spins: dict[str, QDoubleSpinBox] = {}
self._flight_view = FlightView()
self._flight_panel = FlightPlaybackPanel(self._flight_view)
+ self._flight_view.sampleSelected.connect(self._on_sample_selected)
+ self._flight_view.sampleSelectionFailed.connect(self._show_sample_error)
left_content = QWidget()
left_layout = QVBoxLayout(left_content)
@@ -108,7 +104,27 @@ def __init__(self, parent: QWidget | None = None) -> None:
splitter = QSplitter()
splitter.addWidget(left)
- splitter.addWidget(self._flight_panel)
+ right = QWidget()
+ right_layout = QVBoxLayout(right)
+ right_layout.setContentsMargins(0, 0, 0, 0)
+ self._error_status = QLabel()
+ self._error_status.setAccessibleName("Flight explorer error")
+ self._error_status.setWordWrap(True)
+ self._error_status.setStyleSheet("color: #ef4444")
+ self._error_status.setFixedHeight(64)
+ self._context_status = QLabel("No accepted flight is available.")
+ self._context_status.setAccessibleName("Displayed flight context")
+ self._context_status.setWordWrap(True)
+ self._sample_status = QLabel(
+ "Select the current primary trajectory; calm ghost is comparison-only."
+ )
+ self._sample_status.setAccessibleName("Selected flight sample")
+ self._sample_status.setWordWrap(True)
+ right_layout.addWidget(self._error_status)
+ right_layout.addWidget(self._context_status)
+ right_layout.addWidget(self._sample_status)
+ right_layout.addWidget(self._flight_panel, stretch=1)
+ splitter.addWidget(right)
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
layout = QHBoxLayout(self)
@@ -116,6 +132,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
layout.addWidget(splitter)
self._show_explanation(EXPLORER_ROWS[0][0])
+ self._connect_identity_editors()
# ── construction ────────────────────────────────────────────────
def _build_entry_box(self) -> QGroupBox:
@@ -137,6 +154,8 @@ def _build_entry_box(self) -> QGroupBox:
self._speed_unit_combo.setToolTip(FIELD_GUIDANCE["fx_speed_unit"])
self._speed_unit_combo.currentTextChanged.connect(self._on_speed_unit)
self._speed_unit = "mph"
+ self._speed_mph = 167.0
+ self._speed_spin.valueChanged.connect(self._on_speed_value_changed)
speed_row.addWidget(self._speed_spin, stretch=1)
speed_row.addWidget(self._speed_unit_combo)
form.addRow("Speed", speed_row)
@@ -251,9 +270,11 @@ def _build_explanation_box(self) -> QGroupBox:
# ── public API ──────────────────────────────────────────────────
def speed_mps(self) -> float:
"""The entered speed converted to m/s."""
- speed: float = self._speed_spin.value()
- factor: float = SPEED_UNITS[self._speed_unit]
- return speed * factor
+ return float(self._speed_mph / MPH_PER_MPS)
+
+ def speed_mph(self) -> float:
+ """The exact canonical speed authority in miles per hour."""
+ return self._speed_mph
def mode(self) -> str:
"""The selected entry mode label."""
@@ -265,86 +286,11 @@ def flight_view(self) -> FlightView:
def last_exploration(self) -> FlightExploration | None:
"""The most recent successful exploration, if any."""
- return self._exploration
-
- def run_now(self) -> FlightExploration | None:
- """Build launch conditions, run the flight, populate the views."""
- try:
- if self.mode() == ENTRY_MODES[0]:
- launch = launch_from_direct(
- ball_speed_mph=self.speed_mps() * MPH_PER_MPS,
- launch_angle_deg=self._direct_spins["launch_angle_deg"].value(),
- launch_direction_deg=self._direct_spins[
- "launch_direction_deg"
- ].value(),
- spin_rpm=self._direct_spins["spin_rpm"].value(),
- spin_axis_tilt_deg=self._direct_spins["spin_axis_tilt_deg"].value(),
- direction_convention=self._direction_convention_combo.currentData(),
- )
- else:
- launch = launch_from_delivery(
- DeliveryParameters(
- clubhead_speed_mps=self.speed_mps(),
- club_path_deg=self._delivery_spins["club_path_deg"].value(),
- face_angle_deg=self._delivery_spins["face_angle_deg"].value(),
- attack_angle_deg=self._delivery_spins[
- "attack_angle_deg"
- ].value(),
- dynamic_loft_deg=self._delivery_spins[
- "dynamic_loft_deg"
- ].value(),
- impact_offset_toe_mm=self._delivery_spins[
- "impact_offset_toe_mm"
- ].value(),
- impact_offset_high_mm=self._delivery_spins[
- "impact_offset_high_mm"
- ].value(),
- )
- )
- model_name = self._model_combo.currentText()
- exploration, comparison = explore_with_optional_wind(
- launch, self.wind_controls.optional_scenario(), model_name
- )
- except Exception as exc: # noqa: BLE001 — surface physics failures
- logger.warning("flight exploration failed: %s", exc)
- QMessageBox.warning(self, "Flight Failed", str(exc))
- return None
- self._exploration = exploration
- self.wind_comparison = comparison
- self.wind_controls.set_comparison(comparison)
- self._flight_view.set_timed_trajectory(exploration.times, exploration.positions)
- if comparison is None:
- self._flight_view.set_comparison_timed_trajectory(None, None)
- else:
- self._flight_view.set_comparison_timed_trajectory(
- comparison.calm.times, comparison.calm.positions
- )
- self._refresh_rows()
- self._target_workflow.set_trajectory(exploration.positions)
- return exploration
-
- def _refresh_rows(self) -> None:
- """Format the result rows; carry/lateral follow the distance
- display unit (#4125 H6 — yards default, apex stays metres)."""
- if self._exploration is None:
- return
- for key, _label, unit in EXPLORER_ROWS:
- value = self._exploration.metrics[key]
- if not math.isfinite(value):
- text = "—"
- elif key in DISTANCE_ROWS:
- text = (
- f"+{format_distance_m(value)}"
- if value >= 0
- else (f"-{format_distance_m(-value)}")
- )
- else:
- text = f"{value:+.1f}{unit}"
- self._rows[key].value_label.setText(text)
-
- def refresh_units(self) -> None:
- """Re-render distance rows after a display-unit change."""
- self._refresh_rows()
+ return None if self._accepted is None else self._accepted.exploration
+
+ def accepted_study(self) -> AcceptedFlightStudy | None:
+ """The complete immutable accepted authority, if one has committed."""
+ return self._accepted
# ── internals ──────────────────────────────────────────────────
def _on_mode_changed(self, index: int) -> None:
@@ -363,12 +309,62 @@ def _on_speed_unit(self, unit: str) -> None:
previous = self._speed_unit
if unit == previous:
return
- mps = self._speed_spin.value() * SPEED_UNITS[previous]
self._speed_unit = unit
self._speed_spin.blockSignals(True)
- self._speed_spin.setValue(mps / SPEED_UNITS[unit])
+ mph_per_display_unit = SPEED_UNITS[unit]
+ self._speed_spin.setDecimals(9)
+ self._speed_spin.setRange(
+ 1.0 / mph_per_display_unit,
+ 250.0 / mph_per_display_unit,
+ )
+ self._speed_spin.setValue(self._speed_mph / mph_per_display_unit)
self._speed_spin.blockSignals(False)
+ def _on_speed_value_changed(self, value: float) -> None:
+ self._speed_mph = value * SPEED_UNITS[self._speed_unit]
+
+ def _connect_identity_editors(self) -> None:
+ self._speed_spin.valueChanged.connect(self._mark_inputs_changed)
+ for spin in self._direct_spins.values():
+ spin.valueChanged.connect(self._mark_direct_inputs_changed)
+ for spin in self._delivery_spins.values():
+ spin.valueChanged.connect(self._mark_delivery_inputs_changed)
+ self._mode_combo.currentIndexChanged.connect(self._mark_inputs_changed)
+ self._model_combo.currentIndexChanged.connect(self._mark_inputs_changed)
+ self._direction_convention_combo.currentIndexChanged.connect(
+ self._mark_direction_changed
+ )
+ self.wind_controls.enabled_check.toggled.connect(self._mark_inputs_changed)
+ self.wind_controls.speed_spin.valueChanged.connect(
+ self._mark_wind_inputs_changed
+ )
+ self.wind_controls.bearing_spin.valueChanged.connect(
+ self._mark_wind_inputs_changed
+ )
+
+ def _mark_direct_inputs_changed(self) -> None:
+ if self.mode() == ENTRY_MODES[0]:
+ self._mark_inputs_changed()
+
+ def _mark_delivery_inputs_changed(self) -> None:
+ if self.mode() == ENTRY_MODES[1]:
+ self._mark_inputs_changed()
+
+ def _mark_wind_inputs_changed(self) -> None:
+ if self.wind_controls.enabled_check.isChecked():
+ self._mark_inputs_changed()
+
+ def _mark_direction_changed(self) -> None:
+ if self.mode() == ENTRY_MODES[0]:
+ self._mark_inputs_changed()
+
+ def _show_sample_error(self, message: str, restoration_failed: bool) -> None:
+ self._show_error(
+ RuntimeError(message),
+ origin="selection",
+ restoration_failed=restoration_failed,
+ )
+
def _refresh_direction_example(self) -> None:
convention = self._direction_convention_combo.currentData()
definition = LAUNCH_DIRECTION_DEFINITIONS[convention]
diff --git a/src/rate_of_closure/ui/pyqt6/flight_playback_controls.py b/src/rate_of_closure/ui/pyqt6/flight_playback_controls.py
index 30697feb5..df376e323 100644
--- a/src/rate_of_closure/ui/pyqt6/flight_playback_controls.py
+++ b/src/rate_of_closure/ui/pyqt6/flight_playback_controls.py
@@ -101,6 +101,7 @@ def _build_ui(self) -> None:
layout.setContentsMargins(0, 0, 0, 0)
layout.addLayout(row)
layout.addWidget(help_label)
+ self.set_timeline(0.0, 0.0)
@staticmethod
def _button(label: str, callback: Callable[[], None]) -> QPushButton:
@@ -124,6 +125,7 @@ def set_timeline(self, duration_s: float, apex_time_s: float) -> None:
self._set_time(0.0)
enabled = duration_s > 0.0
for control in (
+ self.launch_button,
self.play_button,
self.restart_button,
self.apex_button,
@@ -170,6 +172,13 @@ def jump_to_landing(self) -> None:
self.pause()
self._set_time(self._duration_s)
+ def jump_to_time(self, time_s: float) -> None:
+ """Pause at one exact accepted solver timestamp."""
+ if not math.isfinite(time_s):
+ raise ValueError("playback time must be finite")
+ self.pause()
+ self._set_time(time_s)
+
def current_time_s(self) -> float:
"""Current physical playback time [s]."""
return self._current_time_s
diff --git a/src/rate_of_closure/ui/pyqt6/flight_view.py b/src/rate_of_closure/ui/pyqt6/flight_view.py
index 3320d552b..e67bedf6c 100644
--- a/src/rate_of_closure/ui/pyqt6/flight_view.py
+++ b/src/rate_of_closure/ui/pyqt6/flight_view.py
@@ -14,9 +14,8 @@
from __future__ import annotations
-import logging
-
import numpy as np
+from matplotlib.axes import Axes
from matplotlib.figure import Figure
from PyQt6.QtCore import pyqtSignal
from PyQt6.QtWidgets import QCheckBox, QHBoxLayout, QVBoxLayout, QWidget
@@ -30,6 +29,8 @@
)
from rate_of_closure.ui.pyqt6.flight_playback_rendering import FlightPlaybackArtists
from rate_of_closure.ui.pyqt6.flight_view_axes import distance_axis
+from rate_of_closure.ui.pyqt6.flight_view_bundle import FlightViewBundleMixin
+from rate_of_closure.ui.pyqt6.flight_view_inspector import FlightViewInspectorMixin
from rate_of_closure.ui.pyqt6.flight_view_panels import FlightViewPanelsMixin
from rate_of_closure.ui.pyqt6.spatial_target_rendering import spatial_target_extents
from rate_of_closure.ui.pyqt6.spatial_target_trajectory import (
@@ -38,8 +39,6 @@
from rate_of_closure.units import FIELD_GUIDANCE
from shared.python.swing_sim.solver import SpatialTarget
-logger = logging.getLogger(__name__)
-
__all__ = ["FlightView", "distance_axis"]
#: Minimum plotted extents so degenerate flights stay readable.
@@ -59,10 +58,14 @@
)
-class FlightView(FlightViewPanelsMixin, QWidget):
+class FlightView(
+ FlightViewBundleMixin, FlightViewInspectorMixin, FlightViewPanelsMixin, QWidget
+):
"""Flight-scale trajectory viewer: side + top-down 2D panels + 3D."""
timelineChanged = pyqtSignal(float, float) # noqa: N815 - Qt signal convention
+ sampleSelected = pyqtSignal(int) # noqa: N815 - Qt signal convention
+ sampleSelectionFailed = pyqtSignal(str, bool) # noqa: N815 - Qt signal convention
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
@@ -82,6 +85,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
self._spatial_target: SpatialTarget | None = None
# (carry, lateral) landing scatter [m] from the Variation engine.
self._scatter: tuple[np.ndarray, np.ndarray] | None = None
+ self._initialize_sample_inspector()
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
@@ -106,6 +110,7 @@ def _build_param_bar(self) -> QHBoxLayout:
def set_run(self, run: SimulationRun | None) -> None:
"""Adopt the flight trajectory of a full simulation run."""
self._run = run
+ self.set_sample_plan(None)
self.comparison_positions = np.zeros((0, 3))
self._comparison_timed = None
self._positions = (
@@ -126,6 +131,7 @@ def set_trajectory(self, positions: np.ndarray | None) -> None:
z right of target [m].
"""
self._run = None
+ self.set_sample_plan(None)
self.comparison_positions = np.zeros((0, 3))
self._timed_trajectory = None
self._comparison_timed = None
@@ -140,6 +146,7 @@ def set_timed_trajectory(
) -> None:
"""Adopt a solver-timestamped app-frame trajectory for playback."""
self._run = None
+ self.set_sample_plan(None)
self._timed_trajectory = TimedTrajectory(times_s, positions_m)
self._positions = self._timed_trajectory.positions_m
self._reset_playback()
@@ -174,6 +181,8 @@ def set_playback_time(self, time_s: float) -> None:
if self._timed_trajectory is None:
return
frame = self._timed_trajectory.frame_at(time_s)
+ if frame.time_s == self._playback_time_s:
+ return
self._playback_time_s = frame.time_s
self._playback_artists.update(frame.position_m)
self._canvas.draw_idle()
@@ -284,8 +293,11 @@ def extents_m(self) -> tuple[float, float, float]:
return (carry, height, lateral)
# ── drawing ─────────────────────────────────────────────────────
- def _draw(self) -> None:
+ def _draw(self, *, sync: bool = False) -> None:
+ if sync:
+ self._canvas.cancel_pending_draw()
self._figure.clear()
+ self._inspector_axes: dict[str, Axes] = {}
pos = self._positions
frame = (
None
@@ -312,7 +324,7 @@ def _draw(self) -> None:
else:
title = "Enable a panel to display the flight"
axes.set_title(title)
- self._canvas.draw_idle()
+ self._publish_canvas(sync)
return
want_3d = "three_d" in panels
@@ -327,8 +339,16 @@ def _draw(self) -> None:
self._draw_side(axes, pos, extents)
else:
self._draw_top(axes, pos, extents)
+ self._draw_sample_marker(axes, name)
if want_3d:
spec = grid[:, 1] if left else grid[:, 0]
axes_3d = self._figure.add_subplot(spec, projection="3d")
self._draw_3d(axes_3d, pos, extents)
- self._canvas.draw_idle()
+ self._publish_canvas(sync)
+
+ def _publish_canvas(self, sync: bool) -> None:
+ if not sync:
+ self._canvas.draw_idle()
+ return
+ with self._canvas.suppress_idle_draws():
+ self._canvas.draw()
diff --git a/src/rate_of_closure/ui/pyqt6/flight_view_bundle.py b/src/rate_of_closure/ui/pyqt6/flight_view_bundle.py
new file mode 100644
index 000000000..c98729066
--- /dev/null
+++ b/src/rate_of_closure/ui/pyqt6/flight_view_bundle.py
@@ -0,0 +1,200 @@
+"""Atomic primary/comparison/sample-plan adoption for :mod:`flight_view`."""
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Any
+
+import numpy as np
+
+from rate_of_closure.flight_sample_inspector import FlightSamplePlan
+from rate_of_closure.simulation.flight_playback import TimedTrajectory
+
+if TYPE_CHECKING:
+ from rate_of_closure.simulation import SimulationRun
+ from rate_of_closure.ui.pyqt6.figure_canvas import LifecycleSafeFigureCanvas
+
+logger = logging.getLogger(__name__)
+
+
+class FlightViewRestorationError(RuntimeError):
+ """Accepted authority rolled back, but prior pixels could not be restored."""
+
+
+class FlightViewBundleMixin:
+ """Stage a complete inspector bundle and publish one rendered frame."""
+
+ if TYPE_CHECKING:
+ _run: SimulationRun | None
+ _timed_trajectory: TimedTrajectory | None
+ _comparison_timed: TimedTrajectory | None
+ _positions: np.ndarray
+ comparison_positions: np.ndarray
+ _sample_plan: FlightSamplePlan | None
+ _selected_raw_index: int | None
+ _playback_time_s: float
+ _canvas: LifecycleSafeFigureCanvas
+ timelineChanged: Any
+
+ def _draw(self, *, sync: bool = False) -> None: ...
+
+ def set_sample_plan(self, plan: FlightSamplePlan | None) -> None: ...
+
+ def playback_duration_s(self) -> float: ...
+
+ def playback_apex_time_s(self) -> float: ...
+
+ def adopt_sample_bundle(
+ self,
+ plan: FlightSamplePlan,
+ comparison_times: np.ndarray | None,
+ comparison_positions: np.ndarray | None,
+ *,
+ selected_raw_index: int | None = None,
+ playback_time_s: float = 0.0,
+ ) -> None:
+ """Publish primary, calm ghost, plan, and playback once or roll back."""
+ primary = TimedTrajectory(
+ np.asarray(plan.series.times_s), np.asarray(plan.series.positions_m)
+ )
+ if (comparison_times is None) != (comparison_positions is None):
+ raise ValueError("comparison times and positions must be provided together")
+ comparison = (
+ None
+ if comparison_times is None or comparison_positions is None
+ else TimedTrajectory(comparison_times, comparison_positions)
+ )
+ if selected_raw_index is not None:
+ plan.raw_sample(selected_raw_index)
+ self._publish_sample_state(
+ primary,
+ comparison,
+ plan,
+ selected_raw_index,
+ playback_time_s,
+ )
+
+ def clear_sample_bundle(self) -> None:
+ """Atomically publish the honest no-accepted-flight state."""
+ self._publish_sample_state(None, None, None, None, 0.0)
+
+ def force_sample_bundle_authority(
+ self,
+ plan: FlightSamplePlan | None,
+ comparison_times: np.ndarray | None,
+ comparison_positions: np.ndarray | None,
+ *,
+ selected_raw_index: int | None = None,
+ playback_time_s: float = 0.0,
+ ) -> None:
+ """Restore trusted prior authority when its pixels cannot be repainted."""
+ if plan is None:
+ if comparison_times is not None or comparison_positions is not None:
+ raise ValueError("an empty authority cannot carry a comparison")
+ primary = None
+ comparison = None
+ else:
+ primary = TimedTrajectory(
+ np.asarray(plan.series.times_s), np.asarray(plan.series.positions_m)
+ )
+ if (comparison_times is None) != (comparison_positions is None):
+ raise ValueError(
+ "comparison times and positions must be provided together"
+ )
+ comparison = (
+ None
+ if comparison_times is None or comparison_positions is None
+ else TimedTrajectory(comparison_times, comparison_positions)
+ )
+ if selected_raw_index is not None:
+ plan.raw_sample(selected_raw_index)
+ self._install_sample_state(
+ primary,
+ comparison,
+ plan,
+ selected_raw_index,
+ playback_time_s,
+ )
+ self._canvas.pause_idle_draws()
+ self.timelineChanged.emit(
+ self.playback_duration_s(), self.playback_apex_time_s()
+ )
+
+ def _publish_sample_state(
+ self,
+ primary: TimedTrajectory | None,
+ comparison: TimedTrajectory | None,
+ plan: FlightSamplePlan | None,
+ selected_raw_index: int | None,
+ playback_time_s: float,
+ ) -> None:
+ previous = (
+ self._run,
+ self._timed_trajectory,
+ self._comparison_timed,
+ self._positions,
+ self.comparison_positions,
+ self._sample_plan,
+ self._selected_raw_index,
+ self._playback_time_s,
+ )
+ self._install_sample_state(
+ primary,
+ comparison,
+ plan,
+ selected_raw_index,
+ playback_time_s,
+ )
+ try:
+ self._draw(sync=True)
+ except Exception as publication_error:
+ (
+ self._run,
+ self._timed_trajectory,
+ self._comparison_timed,
+ self._positions,
+ self.comparison_positions,
+ self._sample_plan,
+ self._selected_raw_index,
+ self._playback_time_s,
+ ) = previous
+ try:
+ self._draw(sync=True)
+ except Exception:
+ logger.exception("flight view rollback render failed")
+ self._canvas.pause_idle_draws()
+ raise FlightViewRestorationError(
+ "prior accepted authority was retained, but plot restoration "
+ "failed; the image may be stale or unavailable"
+ ) from publication_error
+ self._canvas.resume_idle_draws()
+ raise
+ self._canvas.resume_idle_draws()
+ self.timelineChanged.emit(
+ self.playback_duration_s(), self.playback_apex_time_s()
+ )
+
+ def _install_sample_state(
+ self,
+ primary: TimedTrajectory | None,
+ comparison: TimedTrajectory | None,
+ plan: FlightSamplePlan | None,
+ selected_raw_index: int | None,
+ playback_time_s: float,
+ ) -> None:
+ """Install already validated state without attempting a render."""
+ self._run = None
+ self._timed_trajectory = primary
+ self._comparison_timed = comparison
+ self._positions = np.zeros((0, 3)) if primary is None else primary.positions_m
+ self.comparison_positions = (
+ np.zeros((0, 3)) if comparison is None else comparison.positions_m
+ )
+ self.set_sample_plan(plan)
+ self._selected_raw_index = selected_raw_index
+ self._playback_time_s = (
+ 0.0 if primary is None else primary.frame_at(playback_time_s).time_s
+ )
+
+
+__all__ = ["FlightViewBundleMixin", "FlightViewRestorationError"]
diff --git a/src/rate_of_closure/ui/pyqt6/flight_view_inspector.py b/src/rate_of_closure/ui/pyqt6/flight_view_inspector.py
new file mode 100644
index 000000000..9079bbbe9
--- /dev/null
+++ b/src/rate_of_closure/ui/pyqt6/flight_view_inspector.py
@@ -0,0 +1,148 @@
+"""Exact sample picking and keyboard navigation for :mod:`flight_view`."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, cast
+
+from PyQt6.QtCore import QEvent, QObject, Qt
+
+from rate_of_closure.flight_sample_inspector import (
+ FlightSamplePlan,
+ navigate_flight_samples,
+ nearest_flight_sample,
+)
+from rate_of_closure.ui.course import get_chart_color
+
+if TYPE_CHECKING:
+ from matplotlib.axes import Axes
+ from matplotlib.backend_bases import MouseEvent
+
+ from rate_of_closure.ui.pyqt6.figure_canvas import LifecycleSafeFigureCanvas
+
+
+class FlightViewInspectorMixin:
+ """Presentation-only selection behavior; calm comparison is never selectable."""
+
+ if TYPE_CHECKING:
+ _canvas: LifecycleSafeFigureCanvas
+ _sample_plan: FlightSamplePlan | None
+ _selected_raw_index: int | None
+ _inspector_axes: dict[str, Axes]
+
+ def _draw(self, *, sync: bool = False) -> None: ...
+
+ def set_playback_time(self, time_s: float) -> None: ...
+
+ def sampleSelected(self, raw_index: int) -> None: ... # noqa: N802
+
+ def _initialize_sample_inspector(self) -> None:
+ self._sample_plan = None
+ self._selected_raw_index = None
+ self._inspector_axes = {}
+ self._canvas.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
+ self._canvas.setAccessibleName("Flight trajectory sample inspector")
+ self._canvas.setAccessibleDescription(
+ "Current primary side and top profiles. Click within 12 pixels; "
+ "Left and Right move, Home and End jump, Escape clears."
+ )
+ self._canvas.setToolTip(
+ "Click the current primary side/top trajectory. Left/Right move exact "
+ "samples; Home/End jump; Escape clears. Calm ghost is comparison-only."
+ )
+ self._canvas.installEventFilter(cast(QObject, self))
+ self._canvas.mpl_connect("button_press_event", self._on_sample_click)
+
+ def set_sample_plan(self, plan: FlightSamplePlan | None) -> None:
+ """Adopt exact primary sample authority and clear prior selection."""
+ if plan is not None and not isinstance(plan, FlightSamplePlan):
+ raise TypeError("sample plan must be a FlightSamplePlan or None")
+ self._sample_plan = plan
+ self._selected_raw_index = None
+
+ def selected_raw_index(self) -> int | None:
+ """Current exact raw index, if selected."""
+ return self._selected_raw_index
+
+ def _select_raw_sample(self, raw_index: int | None) -> None:
+ plan = self._sample_plan
+ if plan is None:
+ return
+ if raw_index is not None:
+ plan.raw_sample(raw_index)
+ previous = self._selected_raw_index
+ self._selected_raw_index = raw_index
+ try:
+ self._draw(sync=True)
+ except Exception as exc:
+ self._selected_raw_index = previous
+ restoration_failed = False
+ try:
+ self._draw(sync=True)
+ except Exception:
+ restoration_failed = True
+ if restoration_failed:
+ self._canvas.pause_idle_draws()
+ else:
+ self._canvas.resume_idle_draws()
+ self.sampleSelectionFailed.emit( # type: ignore[attr-defined]
+ str(exc)[:512], restoration_failed
+ )
+ return
+ self._canvas.resume_idle_draws()
+ self._canvas.setFocus()
+ self.sampleSelected.emit(-1 if raw_index is None else raw_index) # type: ignore[attr-defined]
+
+ def _on_sample_click(self, event: MouseEvent) -> None:
+ plan = self._sample_plan
+ name = next(
+ (key for key, axes in self._inspector_axes.items() if axes is event.inaxes),
+ None,
+ )
+ if (
+ plan is None
+ or name not in {"side", "top"}
+ or event.x is None
+ or event.y is None
+ ):
+ return
+ projected = []
+ for sample in plan.samples:
+ vertical = sample.height_m if name == "side" else sample.right_m
+ x_pixel, y_pixel = self._inspector_axes[name].transData.transform(
+ (sample.downrange_m, vertical)
+ )
+ projected.append(("current", sample.raw_index, x_pixel, y_pixel))
+ selection = nearest_flight_sample(plan, projected, (event.x, event.y))
+ if selection is not None:
+ self._canvas.setFocus()
+ self._select_raw_sample(selection.raw_index)
+
+ def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802
+ if watched is self._canvas and event.type() == QEvent.Type.KeyPress:
+ key = cast(object, event).key() # type: ignore[attr-defined]
+ commands = {
+ Qt.Key.Key_Left: "previous",
+ Qt.Key.Key_Right: "next",
+ Qt.Key.Key_Home: "home",
+ Qt.Key.Key_End: "end",
+ Qt.Key.Key_Escape: "clear",
+ }
+ command = commands.get(key)
+ if command and self._sample_plan is not None:
+ self._select_raw_sample(
+ navigate_flight_samples(
+ self._sample_plan, self._selected_raw_index, command
+ )
+ )
+ return True
+ return bool(super().eventFilter(watched, event)) # type: ignore[misc]
+
+ def _draw_sample_marker(self, axes: Axes, name: str) -> None:
+ self._inspector_axes[name] = axes
+ if self._sample_plan is None or self._selected_raw_index is None:
+ return
+ sample = self._sample_plan.raw_sample(self._selected_raw_index)
+ vertical = sample.height_m if name == "side" else sample.right_m
+ axes.scatter(
+ [sample.downrange_m], [vertical], s=48, color=get_chart_color(4), zorder=20
+ )
diff --git a/src/rate_of_closure/ui/pyqt6/flight_wind_controls.py b/src/rate_of_closure/ui/pyqt6/flight_wind_controls.py
index b40a5f7a9..15390f1c5 100644
--- a/src/rate_of_closure/ui/pyqt6/flight_wind_controls.py
+++ b/src/rate_of_closure/ui/pyqt6/flight_wind_controls.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from collections.abc import Mapping
+
from PyQt6.QtWidgets import (
QAbstractSpinBox,
QCheckBox,
@@ -113,6 +115,17 @@ def delta_text(self, key: str) -> str:
raise KeyError(key)
return str(self._delta_labels[key].text())
+ def delta_texts(self) -> dict[str, str]:
+ """Snapshot presentation texts for an atomic explorer publication."""
+ return {key: label.text() for key, label in self._delta_labels.items()}
+
+ def restore_delta_texts(self, texts: Mapping[str, str]) -> None:
+ """Restore a previously snapshotted delta presentation."""
+ if set(texts) != set(self._delta_labels):
+ raise ValueError("wind delta presentation snapshot is incomplete")
+ for key, label in self._delta_labels.items():
+ label.setText(texts[key])
+
def _refresh_direction(self) -> None:
to_bearing = (self.bearing_spin.value() + 180.0) % 360.0
self.direction_label.setText(
diff --git a/src/rate_of_closure/ui/pyqt6/spatial_target_workflow.py b/src/rate_of_closure/ui/pyqt6/spatial_target_workflow.py
index a9d98b512..600a7b738 100644
--- a/src/rate_of_closure/ui/pyqt6/spatial_target_workflow.py
+++ b/src/rate_of_closure/ui/pyqt6/spatial_target_workflow.py
@@ -34,8 +34,25 @@ def set_trajectory(self, positions_m: np.ndarray | None) -> None:
)
if positions.ndim != 2 or positions.shape[1:] != (3,):
raise ValueError("positions_m must have shape (N, 3)")
+ previous_positions = self._positions
+ previous_miss = self._panel.miss_label().text()
self._positions = positions
- self._refresh_miss()
+ try:
+ self._refresh_miss()
+ except Exception:
+ self._positions = previous_positions
+ self._panel.miss_label().setText(previous_miss)
+ raise
+
+ def publication_snapshot(self) -> tuple[np.ndarray, str]:
+ """Snapshot the exact accepted target authority and visible residual."""
+ return self._positions.copy(), self._panel.miss_label().text()
+
+ def restore_publication_snapshot(self, snapshot: tuple[np.ndarray, str]) -> None:
+ """Restore a trusted prior snapshot without fallible miss recomputation."""
+ positions, miss_text = snapshot
+ self._positions = positions.copy()
+ self._panel.miss_label().setText(miss_text)
def set_unavailable(self, reason: str) -> None:
"""Clear retained trajectory and expose a specific unavailable reason."""
diff --git a/src/rate_of_closure/visualization_tabs.v1.json b/src/rate_of_closure/visualization_tabs.v1.json
index d8e06bf0c..be6ea0dcb 100644
--- a/src/rate_of_closure/visualization_tabs.v1.json
+++ b/src/rate_of_closure/visualization_tabs.v1.json
@@ -11,7 +11,7 @@
{ "surface": "react", "tab_id": "calculation", "classification": "reference-utility", "landmark_kind": "semantic-content", "minimum_visible_height_px": 1, "primary_visual_locator": "section[aria-label='Calculation description']", "states": { "empty": "not-applicable", "loading": "not-applicable", "result": "reference-content", "error": "formula-fallback" } },
{ "surface": "react", "tab_id": "simulation", "classification": "form-led-live-preview", "landmark_kind": "visual", "minimum_visible_height_px": 240, "primary_visual_locator": "canvas[aria-label='Simulation scene with selectable screw-axis motion glyph']", "states": { "empty": "not-run-preview", "loading": "synchronous", "result": "simulation-scene", "error": "status-and-prior-scene" } },
{ "surface": "react", "tab_id": "plots", "classification": "visual-first", "landmark_kind": "visual", "minimum_visible_height_px": 240, "primary_visual_locator": "canvas[aria-label$=' plot']", "states": { "empty": "not-applicable", "loading": "synchronous", "result": "plot-canvas", "error": "inline-alert" } },
- { "surface": "react", "tab_id": "flight", "classification": "form-led-live-preview", "landmark_kind": "visual", "minimum_visible_height_px": 240, "primary_visual_locator": "canvas[aria-label='Flight side profile (height vs carry)']", "states": { "empty": "placeholder-canvas", "loading": "synchronous", "result": "flight-profiles", "error": "alert-and-prior-view" } },
+ { "surface": "react", "tab_id": "flight", "classification": "form-led-live-preview", "landmark_kind": "visual", "minimum_visible_height_px": 240, "primary_visual_locator": "canvas[aria-label='Flight side profile (height vs carry)']", "states": { "empty": "placeholder-canvas", "loading": "synchronous", "result": "bounded-synchronized-sample-inspector", "error": "alert-and-prior-or-empty-inspector" } },
{ "surface": "react", "tab_id": "launch-monitor-analytics", "classification": "form-led-live-preview", "landmark_kind": "visual", "minimum_visible_height_px": 240, "primary_visual_locator": "svg[aria-label$=' scatter plot']", "states": { "empty": "demo-preview", "loading": "file-read-pending", "result": "scatter-and-results", "error": "inline-alert" } },
{ "surface": "react", "tab_id": "variation", "classification": "form-led-live-preview", "landmark_kind": "visual", "minimum_visible_height_px": 240, "primary_visual_locator": "svg[aria-label='Variation analysis workflow preview']", "states": { "empty": "analysis-workflow-preview", "loading": "progress-and-prior-result", "result": "linked-visualizations", "error": "status-and-prior-result" } },
{ "surface": "react", "tab_id": "putting", "classification": "visual-first", "landmark_kind": "visual", "minimum_visible_height_px": 240, "primary_visual_locator": "svg[aria-label='Interactive putt path sample inspector']", "states": { "empty": "inputs-out-of-range", "loading": "synchronous", "result": "bounded-synchronized-sample-inspector", "error": "alert-and-prior-or-empty-inspector" } },
@@ -20,7 +20,7 @@
{ "surface": "pyqt", "tab_id": "plots", "classification": "visual-first", "landmark_kind": "visual", "minimum_visible_height_px": 240, "primary_visual_locator": "attr:_canvas", "states": { "empty": "built-in-plot", "loading": "synchronous", "result": "plot-canvas", "error": "status-and-prior-plot" } },
{ "surface": "pyqt", "tab_id": "calculation_description", "classification": "reference-utility", "landmark_kind": "semantic-content", "minimum_visible_height_px": 1, "primary_visual_locator": "attr:_intro", "states": { "empty": "not-applicable", "loading": "not-applicable", "result": "reference-content", "error": "formula-fallback" } },
{ "surface": "pyqt", "tab_id": "simulation", "classification": "form-led-live-preview", "landmark_kind": "visual", "minimum_visible_height_px": 240, "primary_visual_locator": "attr:_view._canvas", "states": { "empty": "initialized-preview", "loading": "worker-pending", "result": "simulation-scenes", "error": "status-and-prior-scene" } },
- { "surface": "pyqt", "tab_id": "flight_explorer", "classification": "form-led-live-preview", "landmark_kind": "visual", "minimum_visible_height_px": 240, "primary_visual_locator": "attr:_flight_view._canvas", "states": { "empty": "placeholder-canvas", "loading": "synchronous", "result": "flight-playback", "error": "message-and-prior-view" } },
+ { "surface": "pyqt", "tab_id": "flight_explorer", "classification": "form-led-live-preview", "landmark_kind": "visual", "minimum_visible_height_px": 240, "primary_visual_locator": "attr:_flight_view._canvas", "states": { "empty": "placeholder-canvas", "loading": "synchronous", "result": "bounded-synchronized-sample-inspector", "error": "status-and-prior-or-empty-inspector;stale-warning-on-restoration-failure" } },
{ "surface": "pyqt", "tab_id": "launch_monitor_analytics", "classification": "form-led-live-preview", "landmark_kind": "visual", "minimum_visible_height_px": 240, "primary_visual_locator": "attr:preview", "states": { "empty": "demo-scatter-preview", "loading": "file-read-pending", "result": "scatter-and-statistical-results", "error": "message-and-prior-results" } },
{ "surface": "pyqt", "tab_id": "variation", "classification": "form-led-live-preview", "landmark_kind": "visual", "minimum_visible_height_px": 240, "primary_visual_locator": "attr:_landing", "states": { "empty": "landing-dispersion-preview", "loading": "worker-progress", "result": "linked-visualizations", "error": "status-and-prior-result" } },
{ "surface": "pyqt", "tab_id": "putting", "classification": "visual-first", "landmark_kind": "visual", "minimum_visible_height_px": 240, "primary_visual_locator": "attr:_canvas", "states": { "empty": "not-applicable", "loading": "synchronous", "result": "bounded-synchronized-sample-inspector", "error": "status-and-prior-or-empty-inspector" } },
diff --git a/src/rate_of_closure/web/e2e/flight-sample-inspector.spec.ts b/src/rate_of_closure/web/e2e/flight-sample-inspector.spec.ts
new file mode 100644
index 000000000..912e8853c
--- /dev/null
+++ b/src/rate_of_closure/web/e2e/flight-sample-inspector.spec.ts
@@ -0,0 +1,88 @@
+import { expect, test, type Locator } from "@playwright/test";
+
+import { capturePageErrors } from "./variationTestSupport";
+
+const visibleIntersection = async (locator: Locator) => locator.evaluate((element) => {
+ let rect = element.getBoundingClientRect();
+ let ancestor = element.parentElement;
+ while (ancestor !== null) {
+ const style = getComputedStyle(ancestor);
+ if ([style.overflow, style.overflowX, style.overflowY]
+ .some((value) => ["hidden", "clip", "scroll", "auto"].includes(value))) {
+ const clip = ancestor.getBoundingClientRect();
+ rect = new DOMRect(
+ Math.max(rect.left, clip.left),
+ Math.max(rect.top, clip.top),
+ Math.max(0, Math.min(rect.right, clip.right) - Math.max(rect.left, clip.left)),
+ Math.max(0, Math.min(rect.bottom, clip.bottom) - Math.max(rect.top, clip.top)),
+ );
+ }
+ ancestor = ancestor.parentElement;
+ }
+ return {
+ width: Math.max(0, Math.min(rect.right, innerWidth) - Math.max(rect.left, 0)),
+ height: Math.max(0, Math.min(rect.bottom, innerHeight) - Math.max(rect.top, 0)),
+ };
+});
+
+for (const viewport of [
+ { width: 1440, height: 900 },
+ { width: 1280, height: 720 },
+ { width: 390, height: 844 },
+]) {
+ test(`flight sample inspector is synchronized at ${viewport.width}x${viewport.height}`, async (
+ { page },
+ testInfo,
+ ) => {
+ test.skip(testInfo.project.name !== "chromium-desktop", "canonical flight viewports");
+ const pageErrors = capturePageErrors(page);
+ await page.setViewportSize(viewport);
+ await page.goto("/");
+ await page.locator("#primary-tab-flight").click();
+ await page.getByRole("checkbox", {
+ name: "Compare No Wind and Selected Wind",
+ }).click();
+ await page.getByRole("button", { name: "Run Flight" }).click();
+ const side = page.getByLabel("Flight side profile (height vs carry)");
+ const top = page.getByLabel("Flight top-down view (lateral vs carry)");
+ await expect(side).toBeVisible();
+ await expect(top).toBeVisible();
+ const minimumWidth = viewport.width < 1280 ? 120 : 240;
+ const minimumHeight = viewport.width < 1280 ? 180 : 240;
+ await expect.poll(async () => (await visibleIntersection(side)).width)
+ .toBeGreaterThanOrEqual(minimumWidth);
+ await expect.poll(async () => (await visibleIntersection(side)).height)
+ .toBeGreaterThanOrEqual(minimumHeight);
+ expect(await page.evaluate(() => Math.max(
+ document.body.scrollWidth - document.body.clientWidth,
+ document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ ))).toBe(0);
+
+ const context = page.getByRole("status", { name: "Displayed flight context" });
+ const acceptedContext = await context.textContent();
+ const status = page.getByRole("status", { name: "Selected flight sample" });
+ const playback = page.getByLabel("Ball flight playback position");
+ const sideBox = await side.boundingBox();
+ if (sideBox === null) throw new Error("flight side profile has no rendered rectangle");
+ await page.mouse.click(
+ sideBox.x + sideBox.width * (34 / 860),
+ sideBox.y + sideBox.height * (226 / 260),
+ );
+ await expect(status).toContainText("source sample 1/");
+ await expect(status).toContainText("launch");
+ await expect(playback).toContainText(/^0\.00/);
+ expect((await visibleIntersection(status)).height).toBeGreaterThan(0);
+ expect((await visibleIntersection(playback)).height).toBeGreaterThan(0);
+ await side.press("End");
+ await expect(status).toContainText("landing");
+ await side.press("Home");
+ await expect(status).toContainText("source sample 1/");
+ await expect(side).toBeFocused();
+ await expect(context).toHaveText(acceptedContext ?? "");
+ await testInfo.attach(`flight-selected-${viewport.width}x${viewport.height}`, {
+ body: await page.screenshot(),
+ contentType: "image/png",
+ });
+ expect(pageErrors).toEqual([]);
+ });
+}
diff --git a/src/rate_of_closure/web/src/components/FlightCanvases.test.tsx b/src/rate_of_closure/web/src/components/FlightCanvases.test.tsx
index f7ec42dcd..99613f332 100644
--- a/src/rate_of_closure/web/src/components/FlightCanvases.test.tsx
+++ b/src/rate_of_closure/web/src/components/FlightCanvases.test.tsx
@@ -1,8 +1,9 @@
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FlightCanvases } from "./FlightCanvases";
import { spatialTargetFromRegion, DEFAULT_TARGET } from "../model/targets";
+import { planFlightSamples } from "../model/flightSampleInspector";
describe("FlightCanvases responsive layout", () => {
beforeEach(() => {
@@ -93,4 +94,37 @@ describe("FlightCanvases responsive layout", () => {
.toHaveAttribute("aria-description", expect.stringContaining("Green Target"));
},
);
+
+ it("synchronizes pointer and keyboard selection without selecting the calm ghost", () => {
+ const points = [
+ { time: 0, position: [0, 0, 0] as [number, number, number], velocity: [1, 1, 0] as [number, number, number] },
+ { time: 1, position: [100, 0, 0] as [number, number, number], velocity: [1, -1, 0] as [number, number, number] },
+ ];
+ const calm = points.map((point) => ({
+ ...point,
+ position: [point.position[0], 3, point.position[2]] as [number, number, number],
+ }));
+ const plan = planFlightSamples({ timesS: [0, 1], positionsM: points.map((point) => point.position) });
+ const onSelectionChange = vi.fn();
+ render( );
+ const side = screen.getByLabelText("Flight side profile (height vs carry)");
+ vi.spyOn(side, "getBoundingClientRect").mockReturnValue({
+ left: 0, top: 0, width: 860, height: 260, right: 860, bottom: 260,
+ x: 0, y: 0, toJSON: () => ({}),
+ });
+ fireEvent.click(side, { clientX: 34, clientY: 226 });
+ expect(onSelectionChange).toHaveBeenLastCalledWith({ cohort: "current", rawIndex: 0 });
+ const afterHit = onSelectionChange.mock.calls.length;
+ fireEvent.click(side, { clientX: 34, clientY: 203 });
+ expect(onSelectionChange).toHaveBeenCalledTimes(afterHit);
+ fireEvent.click(side, { clientX: 34, clientY: 10 });
+ expect(onSelectionChange).toHaveBeenCalledTimes(afterHit);
+ side.focus();
+ fireEvent.keyDown(side, { key: "End" });
+ expect(onSelectionChange).toHaveBeenLastCalledWith({ cohort: "current", rawIndex: 1 });
+ expect(side).toHaveFocus();
+ fireEvent.keyDown(side, { key: "Escape" });
+ expect(onSelectionChange).toHaveBeenLastCalledWith(null);
+ });
});
diff --git a/src/rate_of_closure/web/src/components/FlightCanvases.tsx b/src/rate_of_closure/web/src/components/FlightCanvases.tsx
index 72ad34a43..96fe7914b 100644
--- a/src/rate_of_closure/web/src/components/FlightCanvases.tsx
+++ b/src/rate_of_closure/web/src/components/FlightCanvases.tsx
@@ -1,311 +1,18 @@
-/**
- * Flight-scale profile canvases (epic #4120, V2 web parity).
- *
- * Side profile (height vs carry) and top-down (lateral vs carry)
- * canvases for an app-frame trajectory, auto-scaled to the flight
- * regime with the landing point annotated — the web twin of the PyQt6
- * FlightView's 2D panels.
- */
+/** Responsive, selectable side and top flight profiles. */
import { useEffect, useRef } from "react";
+import { DEFAULT_COURSE_LAYOUT } from "../model/course";
+import { observeCanvas } from "./canvasDisplay";
+import { drawFlightPanel } from "./flightCanvasDrawing";
import {
- courseColors,
- DEFAULT_COURSE_LAYOUT,
- type CourseLayout,
-} from "../model/course";
-import { type FlightPoint } from "../model/flight";
-import { type TargetRegionTs } from "../model/targets";
-import { spatialTargetHalfExtents, type SpatialTargetTs } from "../model/spatialTarget";
-import { formatDistanceM } from "../model/units";
-import { withAlpha } from "../model/theme";
-import { canvasContext, observeCanvas, type LogicalCanvasSize } from "./canvasDisplay";
+ responsiveFlightCanvasStyle,
+ SIDE_CANVAS_SIZE,
+ TOP_CANVAS_SIZE,
+ type FlightCanvasProps,
+} from "./flightCanvasContract";
import { spatialTargetSummary } from "./spatialTargetPresentation";
-
-interface Props {
- /** App-frame trajectory (x downrange, y up, z right), tee-origin. */
- points: FlightPoint[];
- /** Optional common-input no-wind trajectory rendered as a dashed ghost. */
- comparisonPoints?: FlightPoint[];
- emptyText?: string;
- /** Course furniture layout (#4125 H7a); defaults to the driver hole. */
- layout?: CourseLayout;
- /** Render the fairway/green/flag course elements (default on). */
- showCourse?: boolean;
- /** Target region (#4125 H7b): dashed boundary in the top-down view. */
- target?: TargetRegionTs;
- /** Canonical 3D target rendered in both orthographic views. */
- spatialTarget?: SpatialTargetTs;
- /** Ball-flight distance display unit (#4125 H6): yards default. */
- distanceUnit?: string;
-}
-
-const MIN_CARRY_M = 10.0;
-const MIN_HEIGHT_M = 5.0;
-const MIN_LATERAL_M = 5.0;
-const MARGIN = 34;
-const SIDE_CANVAS_SIZE = { width: 860, height: 260 } as const;
-const TOP_CANVAS_SIZE = { width: 860, height: 220 } as const;
-
-function responsiveCanvasStyle(size: { width: number; height: number }) {
- return {
- width: "100%",
- height: "auto",
- aspectRatio: `${size.width} / ${size.height}`,
- };
-}
-
-function drawCourse(
- ctx: CanvasRenderingContext2D,
- vertical: "height" | "lateral",
- px: (x: number) => number,
- py: (v: number) => number,
- width: number,
- layout: CourseLayout,
-): void {
- // Course styling (#4125 H7a) — palette-derived tones (model/course.ts).
- const course = courseColors();
- const { greenDistanceM: d, greenRadiusM: r, fairwayHalfWidthM: hw } = layout;
- if (vertical === "lateral") {
- // Fairway strip along the target line, green disc + hole/flag, tee.
- ctx.fillStyle = withAlpha(course.fairway, 0.4);
- ctx.fillRect(0, py(hw), width, py(-hw) - py(hw));
- if (px(d - r) <= width) {
- ctx.fillStyle = withAlpha(course.green, 0.6);
- ctx.beginPath();
- ctx.ellipse(px(d), py(0), px(d + r) - px(d), py(0) - py(r), 0, 0, 2 * Math.PI);
- ctx.fill();
- ctx.fillStyle = course.hole;
- ctx.beginPath();
- ctx.arc(px(d), py(0), 2.5, 0, 2 * Math.PI);
- ctx.fill();
- ctx.fillStyle = course.flag;
- ctx.beginPath();
- ctx.moveTo(px(d) + 3, py(0) - 7);
- ctx.lineTo(px(d) + 10, py(0) - 4);
- ctx.lineTo(px(d) + 3, py(0) - 1);
- ctx.closePath();
- ctx.fill();
- }
- } else if (px(d - r) <= width) {
- // Side profile: green band on the ground + flagstick at the hole.
- ctx.fillStyle = withAlpha(course.green, 0.85);
- ctx.fillRect(px(d - r), py(0) - 2, px(d + r) - px(d - r), 4);
- ctx.strokeStyle = course.flag;
- ctx.beginPath();
- ctx.moveTo(px(d), py(0));
- ctx.lineTo(px(d), py(0) - 16);
- ctx.stroke();
- ctx.fillStyle = course.flag;
- ctx.beginPath();
- ctx.moveTo(px(d), py(0) - 16);
- ctx.lineTo(px(d) + 8, py(0) - 12.5);
- ctx.lineTo(px(d), py(0) - 9);
- ctx.closePath();
- ctx.fill();
- }
- ctx.fillStyle = course.tee;
- ctx.fillRect(px(0) - 2, py(0) - 2, 4, 4);
-}
-
-function drawTarget(
- ctx: CanvasRenderingContext2D,
- target: TargetRegionTs,
- px: (x: number) => number,
- py: (v: number) => number,
-): void {
- // Dashed target boundary (#4125 H7b), palette flag tone.
- ctx.strokeStyle = courseColors().flag;
- ctx.setLineDash([6, 4]);
- ctx.lineWidth = 1.6;
- ctx.beginPath();
- if (target.kind === "green") {
- const { distanceM: d, radiusM: r, lateralM: z } = target;
- ctx.ellipse(px(d), py(z), px(d + r) - px(d), py(0) - py(r), 0, 0, 2 * Math.PI);
- } else {
- const { distanceM: d, bandHalfLengthM: b, halfWidthM: w } = target;
- ctx.rect(px(d - b), py(w), px(d + b) - px(d - b), py(-w) - py(w));
- }
- ctx.stroke();
- ctx.setLineDash([]);
- ctx.lineWidth = 1;
-}
-
-function drawSpatialTarget(
- ctx: CanvasRenderingContext2D,
- target: SpatialTargetTs,
- vertical: "height" | "lateral",
- px: (value: number) => number,
- py: (value: number) => number,
- logicalWidth: number,
-): void {
- const [downrange, elevation, right] = target.point.appCoordinatesM;
- const [halfDownrange, halfElevation, halfRight] = spatialTargetHalfExtents(target);
- const center = vertical === "height" ? elevation : right;
- const halfVertical = vertical === "height" ? halfElevation : halfRight;
- ctx.strokeStyle = "#f59e0b";
- ctx.fillStyle = withAlpha("#f59e0b", 0.14);
- ctx.setLineDash([5, 3]);
- ctx.lineWidth = 2;
- ctx.beginPath();
- if (target.tolerance.kind === "sphere" || target.tolerance.kind === "surface_circle") {
- ctx.ellipse(
- px(downrange), py(center),
- Math.abs(px(downrange + halfDownrange) - px(downrange)),
- Math.max(2, Math.abs(py(center + halfVertical) - py(center))),
- 0, 0, 2 * Math.PI,
- );
- } else {
- ctx.rect(
- px(downrange - halfDownrange), py(center + halfVertical),
- px(downrange + halfDownrange) - px(downrange - halfDownrange),
- Math.max(4, py(center - halfVertical) - py(center + halfVertical)),
- );
- }
- ctx.fill();
- ctx.stroke();
- ctx.setLineDash([]);
- ctx.lineWidth = 1;
- ctx.fillStyle = "#fbbf24";
- ctx.font = "bold 11px sans-serif";
- const label = `ACTIVE · ${target.label}`;
- const anchorX = px(downrange);
- const proposedX = anchorX + 6;
- const wouldClipRight = proposedX + ctx.measureText(label).width > logicalWidth - 4;
- ctx.textAlign = wouldClipRight ? "right" : "left";
- ctx.fillText(label, wouldClipRight ? anchorX - 6 : proposedX, Math.max(14, py(center) - 7));
- ctx.textAlign = "left";
-}
-
-function drawPanel(
- canvas: HTMLCanvasElement,
- logicalSize: LogicalCanvasSize,
- points: FlightPoint[],
- comparisonPoints: FlightPoint[],
- vertical: "height" | "lateral",
- emptyText: string,
- layout: CourseLayout,
- showCourse: boolean,
- target?: TargetRegionTs,
- distanceUnit = "yd",
- spatialTarget?: SpatialTargetTs,
-): void {
- const ctx = canvasContext(canvas, logicalSize);
- if (!ctx) return;
- const { width, height } = logicalSize;
- ctx.clearRect(0, 0, width, height);
- const allPoints = [...points, ...comparisonPoints];
- if (points.length < 2 && !spatialTarget) {
- ctx.fillStyle = "#64748b";
- ctx.font = "13px sans-serif";
- ctx.fillText(emptyText, 14, 24);
- return;
- }
-
- const spatialCenter = spatialTarget?.point.appCoordinatesM;
- const spatialExtents = spatialTarget ? spatialTargetHalfExtents(spatialTarget) : [0, 0, 0];
- const spatialCarry = spatialCenter ? spatialCenter[0] + spatialExtents[0] : 0;
- const carryExt = Math.max(MIN_CARRY_M, spatialCarry, ...allPoints.map((p) => p.position[0])) * 1.05;
- const value = (p: FlightPoint) =>
- vertical === "height" ? p.position[1] : p.position[2];
- const targetVertical = spatialCenter
- ? Math.abs(vertical === "height" ? spatialCenter[1] : spatialCenter[2]) +
- (vertical === "height" ? spatialExtents[1] : spatialExtents[2])
- : 0;
- const vertExt =
- vertical === "height"
- ? Math.max(MIN_HEIGHT_M, targetVertical, ...allPoints.map((p) => p.position[1])) * 1.2
- : Math.max(MIN_LATERAL_M, targetVertical, ...allPoints.map((p) => Math.abs(p.position[2]))) * 1.3;
- const zeroY = vertical === "height" ? height - MARGIN : height / 2;
- const usableY = vertical === "height" ? height - 2 * MARGIN : height / 2 - MARGIN;
- // A single metres-to-pixels scale prevents trajectory distortion.
- const physicalScale = Math.min((width - 2 * MARGIN) / carryExt, usableY / vertExt);
- const px = (x: number) => MARGIN + x * physicalScale;
- const py = (v: number) => zeroY - v * physicalScale;
-
- // Course-styled ground (#4125 H7a): grass fill + ground/target line.
- const course = courseColors();
- if (vertical === "height") {
- ctx.fillStyle = withAlpha(course.rough, 0.35);
- ctx.fillRect(0, py(0), width, height - py(0));
- } else {
- ctx.fillStyle = withAlpha(course.rough, 0.25);
- ctx.fillRect(0, 0, width, height);
- }
- ctx.strokeStyle = course.fairway;
- ctx.beginPath();
- ctx.moveTo(0, py(0));
- ctx.lineTo(width, py(0));
- ctx.stroke();
- if (showCourse) drawCourse(ctx, vertical, px, py, width, layout);
- if (target && vertical === "lateral") drawTarget(ctx, target, px, py);
- if (spatialTarget) drawSpatialTarget(ctx, spatialTarget, vertical, px, py, width);
-
- if (points.length < 2) {
- ctx.fillStyle = "#64748b";
- ctx.font = "13px sans-serif";
- ctx.fillText(emptyText, 14, 24);
- return;
- }
-
- if (comparisonPoints.length >= 2) {
- ctx.strokeStyle = "#60a5fa";
- ctx.setLineDash([7, 5]);
- ctx.lineWidth = 1.5;
- ctx.beginPath();
- comparisonPoints.forEach((point, index) => {
- const verticalValue = value(point);
- if (index === 0) ctx.moveTo(px(point.position[0]), py(verticalValue));
- else ctx.lineTo(px(point.position[0]), py(verticalValue));
- });
- ctx.stroke();
- ctx.setLineDash([]);
- }
-
- // Selected-wind trajectory polyline.
- ctx.strokeStyle = "#34d399";
- ctx.lineWidth = 2;
- ctx.beginPath();
- points.forEach((p, i) => {
- if (i === 0) ctx.moveTo(px(p.position[0]), py(value(p)));
- else ctx.lineTo(px(p.position[0]), py(value(p)));
- });
- ctx.stroke();
- ctx.lineWidth = 1;
-
- // Landing annotation.
- const last = points[points.length - 1];
- ctx.fillStyle = "#facc15";
- ctx.beginPath();
- ctx.arc(px(last.position[0]), py(value(last)), 4, 0, 2 * Math.PI);
- ctx.fill();
- ctx.fillStyle = "#94a3b8";
- ctx.font = "11px sans-serif";
- // Landing annotation follows the distance display unit (#4125 H6).
- const label =
- vertical === "height"
- ? `carry ${formatDistanceM(last.position[0], distanceUnit)}`
- : `lateral ${last.position[2] >= 0 ? "+" : "-"}${formatDistanceM(
- Math.abs(last.position[2]),
- distanceUnit,
- )}`;
- ctx.textAlign = "right";
- ctx.fillText(label, px(last.position[0]) - 8, py(value(last)) - 8);
- ctx.textAlign = "left";
- ctx.fillText(
- vertical === "height"
- ? `Side profile (height [m] vs carry [${distanceUnit}])`
- : `Top-down (right + vs carry [${distanceUnit}])`,
- 10,
- 16,
- );
- if (comparisonPoints.length >= 2) {
- ctx.fillStyle = "#60a5fa";
- ctx.fillText("- - No wind", width - 142, 16);
- ctx.fillStyle = "#34d399";
- ctx.fillText("— Selected wind", width - 76, 16);
- }
-}
+import { useFlightSampleInspector } from "./useFlightSampleInspector";
export function FlightCanvases({
points,
@@ -316,17 +23,41 @@ export function FlightCanvases({
target,
spatialTarget,
distanceUnit = "yd",
-}: Props) {
+ plan = null,
+ selection = null,
+ onSelectionChange = () => undefined,
+ prominenceRef,
+}: FlightCanvasProps) {
const sideRef = useRef(null);
const topRef = useRef(null);
const placeholder = emptyText ?? "Run a flight to populate the view.";
const courseLayout = layout ?? DEFAULT_COURSE_LAYOUT;
const course = showCourse ?? true;
+ const shared = {
+ plan,
+ selection,
+ onSelectionChange,
+ points,
+ comparisonPoints,
+ spatialTarget,
+ };
+ const sideInspector = useFlightSampleInspector({
+ ...shared,
+ vertical: "height",
+ logicalWidth: SIDE_CANVAS_SIZE.width,
+ logicalHeight: SIDE_CANVAS_SIZE.height,
+ });
+ const topInspector = useFlightSampleInspector({
+ ...shared,
+ vertical: "lateral",
+ logicalWidth: TOP_CANVAS_SIZE.width,
+ logicalHeight: TOP_CANVAS_SIZE.height,
+ });
useEffect(() => {
const drawSide = () => {
if (!sideRef.current) return;
- drawPanel(
+ drawFlightPanel(
sideRef.current,
SIDE_CANVAS_SIZE,
points,
@@ -338,11 +69,12 @@ export function FlightCanvases({
undefined,
distanceUnit,
spatialTarget,
+ selection?.rawIndex,
);
};
const drawTop = () => {
if (!topRef.current) return;
- drawPanel(
+ drawFlightPanel(
topRef.current,
TOP_CANVAS_SIZE,
points,
@@ -354,6 +86,7 @@ export function FlightCanvases({
target,
distanceUnit,
spatialTarget,
+ selection?.rawIndex,
);
};
const stopSide = observeCanvas(sideRef, drawSide);
@@ -362,31 +95,53 @@ export function FlightCanvases({
stopSide();
stopTop();
};
- }, [points, comparisonPoints, placeholder, courseLayout, course, target, spatialTarget, distanceUnit]);
+ }, [
+ points,
+ comparisonPoints,
+ placeholder,
+ courseLayout,
+ course,
+ target,
+ spatialTarget,
+ distanceUnit,
+ selection,
+ ]);
const targetDescription = spatialTarget
? ` Plot includes ${spatialTargetSummary(spatialTarget)}`
: undefined;
+ const instructions =
+ "Select current primary samples. Arrow keys move; Home/End jump; " +
+ "Escape clears.";
return (
{
+ sideRef.current = node;
+ if (prominenceRef) prominenceRef.current = node;
+ }}
width={SIDE_CANVAS_SIZE.width}
height={SIDE_CANVAS_SIZE.height}
- style={responsiveCanvasStyle(SIDE_CANVAS_SIZE)}
- className="min-h-[180px] w-full min-w-0 rounded-lg border border-slate-800 bg-slate-950/60 sm:min-h-0"
+ style={responsiveFlightCanvasStyle(SIDE_CANVAS_SIZE)}
+ className="min-h-[180px] w-full min-w-0 rounded-lg border border-slate-800 bg-slate-950/60 outline-none focus-visible:ring-2 focus-visible:ring-sky-400 sm:min-h-0"
aria-label="Flight side profile (height vs carry)"
+ aria-keyshortcuts="ArrowLeft ArrowRight Home End Escape"
aria-description={targetDescription}
+ title={instructions}
/>
);
diff --git a/src/rate_of_closure/web/src/components/FlightExplorerPanel.test.tsx b/src/rate_of_closure/web/src/components/FlightExplorerPanel.test.tsx
index 68b877bf7..9c21c735b 100644
--- a/src/rate_of_closure/web/src/components/FlightExplorerPanel.test.tsx
+++ b/src/rate_of_closure/web/src/components/FlightExplorerPanel.test.tsx
@@ -1,9 +1,10 @@
-import { fireEvent, render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { FlightExplorerPanel } from "./FlightExplorerPanel";
import { DEFAULT_TARGET, spatialTargetFromRegion } from "../model/targets";
+import { directLaunch, exploreFlight } from "../model/flightExplorer";
function FlightExplorerHarness() {
const [target, setTarget] = useState(() => spatialTargetFromRegion(DEFAULT_TARGET));
@@ -20,6 +21,7 @@ beforeAll(() => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(
ctx as CanvasRenderingContext2D,
);
+ HTMLElement.prototype.scrollIntoView = vi.fn();
});
describe("FlightExplorerPanel input editing", () => {
@@ -98,4 +100,127 @@ describe("FlightExplorerPanel input editing", () => {
expect(screen.getByLabelText("Flight side profile (height vs carry)"))
.toBeInTheDocument();
});
+
+ it("retains the atomic prior flight, context, and selection after a failed rerun", () => {
+ const accepted = exploreFlight(directLaunch({
+ ballSpeedMph: 167, launchAngleDeg: 10.9, launchDirectionDeg: 0,
+ spinRpm: 2686, spinAxisTiltDeg: 0,
+ }));
+ const execute = vi.fn()
+ .mockReturnValueOnce(accepted)
+ .mockImplementationOnce(() => { throw new Error("planted executor failure"); });
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: "Run Flight" }));
+ const side = screen.getByLabelText("Flight side profile (height vs carry)");
+ side.focus();
+ fireEvent.keyDown(side, { key: "Home" });
+ expect(screen.getByRole("status", { name: "Selected flight sample" }))
+ .toHaveTextContent("source sample 1/");
+ expect(screen.getByLabelText("Ball flight playback position")).toHaveTextContent(/^0\.00/);
+ fireEvent.keyDown(side, { key: "End" });
+ expect(screen.getByLabelText("Ball flight playback position")).not.toHaveTextContent(/^0\.00/);
+ fireEvent.keyDown(side, { key: "Home" });
+ expect(screen.getByLabelText("Ball flight playback position")).toHaveTextContent(/^0\.00/);
+ const displayed = screen.getByRole("status", { name: "Displayed flight context" }).textContent;
+
+ fireEvent.click(screen.getByRole("button", { name: "Run Flight" }));
+ expect(screen.getByRole("alert")).toHaveTextContent(/prior accepted flight remains displayed/i);
+ expect(screen.getByRole("status", { name: "Displayed flight context" }).textContent).toBe(displayed);
+ expect(screen.getByRole("status", { name: "Selected flight sample" }))
+ .toHaveTextContent("source sample 1/");
+ expect(execute).toHaveBeenCalledTimes(2);
+ });
+
+ it("uses the injected execution authority for both wind cohorts and retains prior evidence", () => {
+ let calls = 0;
+ const execute = vi.fn((launch: Parameters[0]) => {
+ calls += 1;
+ if (calls > 2) throw new Error("planted paired executor failure");
+ return exploreFlight(launch);
+ });
+ render( );
+ fireEvent.click(screen.getByRole("checkbox", {
+ name: "Compare No Wind and Selected Wind",
+ }));
+ fireEvent.click(screen.getByRole("button", { name: "Run Flight" }));
+ const displayed = screen.getByRole("status", {
+ name: "Displayed flight context",
+ }).textContent;
+ expect(execute).toHaveBeenCalledTimes(2);
+
+ fireEvent.click(screen.getByRole("button", { name: "Run Flight" }));
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ /prior accepted flight remains displayed/i,
+ );
+ expect(screen.getByRole("status", { name: "Displayed flight context" }).textContent)
+ .toBe(displayed);
+ expect(execute).toHaveBeenCalledTimes(3);
+ });
+
+ it("shows an honest empty error when the paired wind executor fails initially", () => {
+ const execute = vi.fn(() => {
+ throw new Error(`\u0000${"initial paired failure ".repeat(60)}`);
+ });
+ render( );
+ fireEvent.click(screen.getByRole("checkbox", {
+ name: "Compare No Wind and Selected Wind",
+ }));
+ fireEvent.click(screen.getByRole("button", { name: "Run Flight" }));
+ const alert = screen.getByRole("alert");
+ expect(alert).toHaveTextContent(/no accepted flight is available/i);
+ expect(alert.textContent).not.toContain("\u0000");
+ expect(alert.textContent?.length).toBeLessThanOrEqual(512);
+ expect(screen.queryByRole("status", { name: "Displayed flight context" }))
+ .not.toBeInTheDocument();
+ });
+
+ it("labels retained evidence as prior when current scientific inputs change", () => {
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: "Run Flight" }));
+ expect(screen.getByRole("status", { name: "Displayed flight context" }))
+ .toHaveTextContent(/^Displayed flight:/);
+ fireEvent.change(screen.getByLabelText("Launch Angle"), { target: { value: "12" } });
+ fireEvent.blur(screen.getByLabelText("Launch Angle"));
+ expect(screen.getByRole("status", { name: "Displayed flight context" }))
+ .toHaveTextContent(/^Prior result — inputs changed:/);
+ });
+
+ it("keeps speed canonical across presentation-unit switches and reruns", () => {
+ const execute = vi.fn((launch: Parameters[0]) =>
+ exploreFlight(launch));
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: "Run Flight" }));
+ const status = screen.getByRole("status", {
+ name: "Displayed flight context",
+ });
+ const displayed = status.textContent;
+ fireEvent.change(screen.getByLabelText("Ball speed unit"), {
+ target: { value: "m/s" },
+ });
+ expect(status.textContent).toBe(displayed);
+ expect(status).toHaveTextContent(/^Displayed flight:/);
+ expect(execute).toHaveBeenCalledTimes(1);
+ const firstSpeed = execute.mock.calls[0][0].ballSpeedMps;
+ fireEvent.click(screen.getByRole("button", { name: "Run Flight" }));
+ expect(execute.mock.calls[1][0].ballSpeedMps).toBe(firstSpeed);
+ });
+
+ it("reveals the accepted visual once after a pointer run without moving focus", async () => {
+ render( );
+ const run = screen.getByRole("button", { name: "Run Flight" });
+ run.focus();
+ fireEvent.click(run, { detail: 1 });
+ await waitFor(() => expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledOnce());
+ expect(vi.mocked(HTMLElement.prototype.scrollIntoView).mock.instances[0])
+ .toHaveAccessibleName("Flight side profile (height vs carry)");
+ expect(document.activeElement).toBe(run);
+ });
});
+
+function FlightExplorerHarnessWithExecutor({ execute }: {
+ execute: typeof exploreFlight;
+}) {
+ const [target, setTarget] = useState(() => spatialTargetFromRegion(DEFAULT_TARGET));
+ return ;
+}
diff --git a/src/rate_of_closure/web/src/components/FlightExplorerPanel.tsx b/src/rate_of_closure/web/src/components/FlightExplorerPanel.tsx
index a4d402c01..b555b87a4 100644
--- a/src/rate_of_closure/web/src/components/FlightExplorerPanel.tsx
+++ b/src/rate_of_closure/web/src/components/FlightExplorerPanel.tsx
@@ -9,19 +9,20 @@
* until the P7 WASM kernels land.
*/
-import { useState } from "react";
+import { useRef, useState } from "react";
import { DecimalInput } from "./DecimalInput";
import { FieldInfo } from "./FieldInfo";
import { FlightCanvases } from "./FlightCanvases";
import { FlightPlayback3D } from "./FlightPlayback3D";
+import {
+ buildAcceptedFlightStudy, type AcceptedFlightStudy,
+} from "./flightAcceptedStudy";
import { SpatialTargetSection } from "./SpatialTargetSection";
import {
compareWind,
directLaunch,
exploreFlight,
- type FlightExplorationTs,
- type WindComparisonTs,
} from "../model/flightExplorer";
import {
LAUNCH_DIRECTION_DEFINITIONS,
@@ -30,69 +31,31 @@ import {
} from "../model/launchDirection";
import { FIELD_GUIDANCE, formatDistanceM } from "../model/units";
import { meteorologicalWind } from "../model/wind";
+import { scheduleMeaningfulVisualReveal } from "../model/variationVisualProminence";
+import type { FlightSampleSelection } from "../model/flightSampleInspector";
import type { SpatialTargetTs } from "../model/spatialTarget";
-
-const SPEED_UNITS: Record = { mph: 1.0, "m/s": 2.236936292054402 };
-
-const DIRECTION_CONVENTIONS: Array<{
- value: string;
- label: string;
- disabled?: boolean;
- title?: string;
-}> = [
- { value: "app_native", label: "App Native (+ Right)" },
- {
- value: "trackman_comparable",
- label: "TrackMan-Comparable (+ Right)",
- },
- {
- value: "foresight_comparable",
- label: "Foresight-Comparable (Sign Unavailable)",
- disabled: true,
- title: "Unavailable: the general public sign convention is not established independently of player handedness.",
- },
-];
-
-const RESULT_ROWS: Array<{
- key: keyof WindComparisonTs["deltas"];
- label: string;
- unit: string;
-}> = [
- { key: "carryM", label: "Carry Distance", unit: "m" },
- { key: "maxHeightM", label: "Apex Height", unit: "m" },
- { key: "flightTimeS", label: "Flight Time", unit: "s" },
- { key: "landingAngleDeg", label: "Landing Angle", unit: "°" },
- { key: "lateralM", label: "Lateral Landing Offset", unit: "m" },
-];
-
-interface FieldSpec {
- key: "launchAngleDeg" | "launchDirectionDeg" | "spinRpm" | "spinAxisTiltDeg";
- label: string;
- unit: string;
- guidance: string;
-}
-
-const FIELDS: FieldSpec[] = [
- { key: "launchAngleDeg", label: "Launch Angle", unit: "deg", guidance: "fxLaunchAngle" },
- { key: "launchDirectionDeg", label: "Launch Direction", unit: "deg", guidance: "fxLaunchDirection" },
- { key: "spinRpm", label: "Total Spin", unit: "rpm", guidance: "fxSpinRpm" },
- { key: "spinAxisTiltDeg", label: "Spin-Axis Tilt", unit: "deg", guidance: "fxSpinAxisTilt" },
-];
+import {
+ boundedFlightError, DIRECTION_CONVENTIONS, FLIGHT_FIELDS, RESULT_ROWS,
+ SPEED_UNITS,
+} from "./flightExplorerContract";
interface Props {
/** Ball-flight distance display unit (#4125 H6): yards default. */
distanceUnit?: string;
spatialTarget: SpatialTargetTs;
onSpatialTargetChange: (target: SpatialTargetTs) => void;
+ executeFlight?: typeof exploreFlight;
}
export function FlightExplorerPanel({
distanceUnit = "yd",
spatialTarget,
onSpatialTargetChange,
+ executeFlight = exploreFlight,
}: Props) {
- const [speed, setSpeed] = useState(167.0);
+ const [speedMph, setSpeedMph] = useState(167.0);
const [speedUnit, setSpeedUnit] = useState("mph");
+ const displayedSpeed = speedMph / SPEED_UNITS[speedUnit];
const [directionConvention, setDirectionConvention] =
useState("app_native");
const [fields, setFields] = useState({
@@ -104,27 +67,63 @@ export function FlightExplorerPanel({
const [windEnabled, setWindEnabled] = useState(false);
const [windSpeedMph, setWindSpeedMph] = useState(10.0);
const [windFromDeg, setWindFromDeg] = useState(0.0);
- const [result, setResult] = useState(null);
- const [windComparison, setWindComparison] = useState(null);
+ const [accepted, setAccepted] = useState(null);
+ const [selection, setSelection] = useState<(
+ FlightSampleSelection & { generation: number; commandId: number }
+ ) | null>(null);
const [error, setError] = useState(null);
+ const generation = useRef(0);
+ const selectionCommand = useRef(0);
+ const visualCanvasRef = useRef(null);
+ const result = accepted?.exploration ?? null;
+ const windComparison = accepted?.comparison ?? null;
+ const currentWind = windEnabled
+ ? meteorologicalWind(windSpeedMph / SPEED_UNITS["m/s"], windFromDeg) : null;
+ const inputsChanged = accepted !== null && (
+ accepted.context.ballSpeedMph !== speedMph ||
+ accepted.context.launchAngleDeg !== fields.launchAngleDeg ||
+ accepted.context.launchDirectionDeg !== fields.launchDirectionDeg ||
+ accepted.context.spinRpm !== fields.spinRpm ||
+ accepted.context.spinAxisTiltDeg !== fields.spinAxisTiltDeg ||
+ accepted.context.directionConvention !== directionConvention ||
+ JSON.stringify(accepted.context.windScenario) !== JSON.stringify(currentWind)
+ );
const directionSigns = launchDirectionSignLabels(directionConvention);
- const run = () => {
+ const run = (allowAutomaticReveal = false) => {
try {
const launch = directLaunch({
- ballSpeedMph: speed * (SPEED_UNITS[speedUnit] / SPEED_UNITS.mph),
+ ballSpeedMph: speedMph,
launchDirectionConvention: directionConvention,
...fields,
});
- const comparison = windEnabled
- ? compareWind(launch, meteorologicalWind(windSpeedMph / SPEED_UNITS["m/s"], windFromDeg))
- : null;
- const exploration = comparison?.wind ?? exploreFlight(launch);
- setResult(exploration);
- setWindComparison(comparison);
+ const windScenario = windEnabled
+ ? meteorologicalWind(windSpeedMph / SPEED_UNITS["m/s"], windFromDeg) : null;
+ const comparison = windScenario
+ ? compareWind(launch, windScenario, executeFlight) : null;
+ const exploration = comparison?.wind ?? executeFlight(launch);
+ const nextGeneration = generation.current + 1;
+ const candidate = buildAcceptedFlightStudy(nextGeneration, {
+ entryMode: "direct",
+ ballSpeedMph: speedMph,
+ launchAngleDeg: fields.launchAngleDeg,
+ launchDirectionDeg: fields.launchDirectionDeg,
+ spinRpm: fields.spinRpm,
+ spinAxisTiltDeg: fields.spinAxisTiltDeg,
+ directionConvention,
+ windScenario,
+ model: "waterloo_penner",
+ kernelRevision: "web-rk4-10ms-sampled-v1",
+ }, exploration, comparison);
+ generation.current = nextGeneration;
+ setAccepted(candidate);
+ setSelection(null);
setError(null);
+ if (allowAutomaticReveal) {
+ scheduleMeaningfulVisualReveal(() => visualCanvasRef.current);
+ }
} catch (exc) {
- setError(exc instanceof Error ? exc.message : String(exc));
+ setError(boundedFlightError(exc, accepted !== null));
}
};
@@ -143,23 +142,18 @@ export function FlightExplorerPanel({
setSpeedMph(value * SPEED_UNITS[speedUnit])}
className="no-spinner w-full min-w-16 rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-slate-100 focus:border-blue-500 focus:outline-none"
/>
{
- const next = e.target.value;
- // Convert the displayed value in place (canonical mph).
- const mph = speed * (SPEED_UNITS[speedUnit] / SPEED_UNITS.mph);
- setSpeed(Number((mph * (SPEED_UNITS.mph / SPEED_UNITS[next])).toFixed(2)));
- setSpeedUnit(next);
- }}
+ onChange={(event) => setSpeedUnit(event.target.value)}
className="min-w-16 rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-slate-100"
aria-label="Ball speed unit"
>
@@ -191,7 +185,7 @@ export function FlightExplorerPanel({
0° = straight · + = {directionSigns.positive} · − = {directionSigns.negative} · {LAUNCH_DIRECTION_DEFINITIONS[directionConvention].quantityStatus}
- {FIELDS.map(({ key, label, unit, guidance }) => (
+ {FLIGHT_FIELDS.map(({ key, label, unit, guidance }) => (
@@ -203,7 +197,10 @@ export function FlightExplorerPanel({
value={fields[key]}
aria-label={label}
title={FIELD_GUIDANCE[guidance]}
- min={key === "spinRpm" ? 0 : undefined}
+ min={key === "spinRpm" ? 0 : key === "launchAngleDeg" ? -89 :
+ key === "launchDirectionDeg" ? -45 : -60}
+ max={key === "spinRpm" ? 15000 : key === "launchAngleDeg" ? 89 :
+ key === "launchDirectionDeg" ? 45 : 60}
onCommit={(value) => setFields((f) => ({ ...f, [key]: value }))}
className="no-spinner w-full min-w-16 rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-slate-100 focus:border-blue-500 focus:outline-none"
/>
@@ -211,7 +208,7 @@ export function FlightExplorerPanel({
))}
run(event.detail > 0)}
title="Integrate the ball flight for the entered launch conditions"
className="mt-1 w-full rounded-lg border border-sky-400/60 bg-sky-500/10 px-3 py-2 text-sm font-semibold text-sky-300 transition-all hover:bg-sky-500/20"
>
@@ -222,6 +219,13 @@ export function FlightExplorerPanel({
{error}
)}
+ {accepted && (
+
+ {inputsChanged ? "Prior result — inputs changed: " : "Displayed flight: "}
+ {accepted.contextLabel}
+
+ )}
Waterloo/Penner flight physics, parity-banded against the
Python explorer (which adds the full 7-model literature picker
@@ -255,6 +259,7 @@ export function FlightExplorerPanel({
-
+
setSelection(
+ next && accepted ? {
+ ...next, generation: accepted.generation,
+ commandId: ++selectionCommand.current,
+ } : null,
+ )}
+ prominenceRef={visualCanvasRef}
/>
+
+ {selection && accepted && selection.generation === accepted.generation
+ ? (() => {
+ const sample = accepted.plan.rawSample(selection.rawIndex);
+ return `Current primary flight, source sample ${sample.rawIndex + 1}/${accepted.plan.rawCount}; ` +
+ `t ${sample.timeS.toFixed(3)} s; downrange ${sample.downrangeM.toFixed(3)} m; ` +
+ `height ${sample.heightM.toFixed(3)} m; right ${sample.rightM.toFixed(3)} m; ${sample.phase}.`;
+ })()
+ : "Select the current primary trajectory; the calm dashed ghost is comparison-only."}
+
diff --git a/src/rate_of_closure/web/src/components/FlightPlayback3D.test.tsx b/src/rate_of_closure/web/src/components/FlightPlayback3D.test.tsx
index dab656fba..362b60c4d 100644
--- a/src/rate_of_closure/web/src/components/FlightPlayback3D.test.tsx
+++ b/src/rate_of_closure/web/src/components/FlightPlayback3D.test.tsx
@@ -43,6 +43,15 @@ describe("FlightPlayback3D", () => {
expect(screen.getByText("0.00 / 2.00 s")).toBeInTheDocument();
});
+ it("replays an exact repeated selection command after manual scrubbing", () => {
+ const view = render( );
+ fireEvent.click(screen.getByRole("button", { name: "Jump to Landing" }));
+ expect(screen.getByLabelText("Ball flight playback position")).toHaveTextContent("2.00 / 2.00 s");
+ view.rerender( );
+ expect(screen.getByLabelText("Ball flight playback position")).toHaveTextContent("0.00 / 2.00 s");
+ });
+
it("preserves its intrinsic aspect ratio at responsive widths", () => {
render( );
diff --git a/src/rate_of_closure/web/src/components/FlightPlayback3D.tsx b/src/rate_of_closure/web/src/components/FlightPlayback3D.tsx
index 4ca985f2e..f08b220bb 100644
--- a/src/rate_of_closure/web/src/components/FlightPlayback3D.tsx
+++ b/src/rate_of_closure/web/src/components/FlightPlayback3D.tsx
@@ -17,6 +17,8 @@ interface Props {
points: readonly FlightPoint[];
comparisonPoints?: readonly FlightPoint[];
spatialTarget?: SpatialTargetTs;
+ selectedTimeS?: number | null;
+ selectedCommandId?: number;
}
const INITIAL_CAMERA: PlaybackCamera = {
@@ -26,7 +28,9 @@ const INITIAL_CAMERA: PlaybackCamera = {
};
const SPEEDS = [0.25, 0.5, 1, 2, 4];
-export function FlightPlayback3D({ points, comparisonPoints = [], spatialTarget }: Props) {
+export function FlightPlayback3D({
+ points, comparisonPoints = [], spatialTarget, selectedTimeS = null, selectedCommandId = 0,
+}: Props) {
const canvasRef = useRef(null);
const dragRef = useRef<{ pointerId: number; x: number; y: number } | null>(null);
const timeRef = useRef(0);
@@ -54,6 +58,14 @@ export function FlightPlayback3D({ points, comparisonPoints = [], spatialTarget
timeRef.current = time;
}, [time]);
+ useEffect(() => {
+ if (selectedTimeS === null || !Number.isFinite(selectedTimeS)) return;
+ const next = Math.max(0, Math.min(duration, selectedTimeS));
+ setPlaying(false);
+ timeRef.current = next;
+ setTime(next);
+ }, [selectedTimeS, selectedCommandId, duration]);
+
useEffect(() => {
if (!playing || duration <= 0) return;
let animationId = 0;
@@ -179,7 +191,8 @@ export function FlightPlayback3D({ points, comparisonPoints = [], spatialTarget
))}
-
+
{time.toFixed(2)} / {duration.toFixed(2)} s
diff --git a/src/rate_of_closure/web/src/components/flightAcceptedStudy.test.ts b/src/rate_of_closure/web/src/components/flightAcceptedStudy.test.ts
new file mode 100644
index 000000000..bd259f986
--- /dev/null
+++ b/src/rate_of_closure/web/src/components/flightAcceptedStudy.test.ts
@@ -0,0 +1,160 @@
+import { describe, expect, it } from "vitest";
+
+import { buildAcceptedFlightStudy, type FlightStudyContext } from "./flightAcceptedStudy";
+import {
+ compareWind, directLaunch, exploreFlight, type FlightExplorationTs,
+} from "../model/flightExplorer";
+import { BALL_POSITION } from "../model/simulation";
+import { meteorologicalWind } from "../model/wind";
+
+const context: FlightStudyContext = {
+ entryMode: "direct", ballSpeedMph: 100, launchAngleDeg: 10,
+ launchDirectionDeg: 0, spinRpm: 2000, spinAxisTiltDeg: 0,
+ directionConvention: "app_native", windScenario: null, model: "waterloo_penner",
+ kernelRevision: "web-rk4-10ms-sampled-v1",
+};
+
+function exploration(): FlightExplorationTs {
+ const speedMps = 100 / 2.236936292054402;
+ const launchRad = 10 * Math.PI / 180;
+ const launchVelocity: [number, number, number] = [
+ speedMps * Math.cos(launchRad), speedMps * Math.sin(launchRad), 0,
+ ];
+ return {
+ points: [
+ { time: 0, position: [...BALL_POSITION], velocity: launchVelocity },
+ { time: 0.5, position: [1, BALL_POSITION[1] + 1, 0.25], velocity: [2, 0, 0.5] },
+ { time: 1, position: [2, BALL_POSITION[1], 0.5], velocity: [1, -1, 0] },
+ ],
+ metrics: {
+ ballSpeedMph: 100, launchAngleDeg: 10, launchDirectionDeg: 0,
+ launchAzimuthDeg: 0, spinRpm: 2000, carryM: Math.hypot(2, 0.5), maxHeightM: 1,
+ flightTimeS: 1, landingAngleDeg: 45, lateralM: 0.5,
+ },
+ execution: {
+ model: "waterloo_penner", kernelRevision: "web-rk4-10ms-sampled-v1",
+ windScenario: null,
+ launch: directLaunch({
+ ballSpeedMph: 100, launchAngleDeg: 10, launchDirectionDeg: 0,
+ spinRpm: 2000, spinAxisTiltDeg: 0,
+ }),
+ },
+ };
+}
+
+describe("accepted flight study", () => {
+ it("deep-snapshots complete validated evidence before publication", () => {
+ const input = exploration();
+ const accepted = buildAcceptedFlightStudy(1, context, input, null);
+ input.points[2].position[0] = 99;
+ input.metrics.carryM = 99;
+ expect(accepted.plan.rawSample(2).downrangeM).toBe(2);
+ expect(accepted.exploration.metrics.carryM).toBe(Math.hypot(2, 0.5));
+ expect(accepted.contextLabel).toContain("kernel web-rk4-10ms-sampled-v1");
+ });
+
+ it.each(["carryM", "maxHeightM", "flightTimeS", "lateralM"] as const)(
+ "rejects summary/raw disagreement for %s",
+ (field) => {
+ const input = exploration();
+ input.metrics[field] += field === "maxHeightM" ? -2 : 1;
+ expect(() => buildAcceptedFlightStudy(1, context, input, null)).toThrow(
+ /disagrees|below sampled/,
+ );
+ },
+ );
+
+ it("rejects malformed points before any accepted bundle exists", () => {
+ const input = exploration();
+ input.points[1].position[1] = Number.NaN;
+ expect(() => buildAcceptedFlightStudy(1, context, input, null)).toThrow(/finite/);
+ });
+
+ it("binds canonical origin, landing floor, and all retained heights", () => {
+ const translated = exploration();
+ translated.points.forEach((point) => {
+ point.position[0] += 10;
+ point.position[1] += 2;
+ point.position[2] += 3;
+ });
+ expect(() => buildAcceptedFlightStudy(1, context, translated, null)).toThrow(
+ /launch downrange position/,
+ );
+ const airborne = exploration();
+ airborne.points[airborne.points.length - 1].position[1] += 1;
+ expect(() => buildAcceptedFlightStudy(1, context, airborne, null)).toThrow(
+ /landing height/,
+ );
+ const belowGround = exploration();
+ belowGround.points[1].position[1] = -1;
+ expect(() => buildAcceptedFlightStudy(1, context, belowGround, null)).toThrow(
+ /ground plane/,
+ );
+ });
+
+ it("rejects implausible hidden apex evidence and wind mislabeled as calm", () => {
+ const huge = exploration();
+ huge.metrics.maxHeightM = 9_999;
+ expect(() => buildAcceptedFlightStudy(1, context, huge, null)).toThrow(
+ /sampled-apex allowance/,
+ );
+ const scenario = meteorologicalWind(4, 90);
+ const windy = exploreFlight({
+ ...directLaunch({
+ ballSpeedMph: 100, launchAngleDeg: 10, launchDirectionDeg: 0,
+ spinRpm: 2000, spinAxisTiltDeg: 0,
+ }),
+ windScenario: scenario,
+ });
+ expect(() => buildAcceptedFlightStudy(1, context, windy, null)).toThrow(/provenance/);
+ });
+
+ it("rejects an undefined top-level execution wind declaration", () => {
+ const input = exploration();
+ input.execution = { ...input.execution, windScenario: undefined } as unknown as
+ FlightExplorationTs["execution"];
+ expect(() => buildAcceptedFlightStudy(1, context, input, null)).toThrow(
+ /provenance is incomplete/,
+ );
+ });
+
+ it("rejects a different spin-axis launch under the same displayed summary", () => {
+ const input = exploration();
+ input.execution = {
+ ...input.execution,
+ launch: directLaunch({
+ ballSpeedMph: 100, launchAngleDeg: 10, launchDirectionDeg: 0,
+ spinRpm: 2000, spinAxisTiltDeg: 10,
+ }),
+ };
+ expect(() => buildAcceptedFlightStudy(1, context, input, null)).toThrow(/fingerprint/);
+ });
+
+ it.each(["launchAzimuthDeg", "landingAngleDeg"] as const)(
+ "rejects forged derived %s before publication", (field) => {
+ const input = exploration();
+ input.metrics[field] += 5;
+ expect(() => buildAcceptedFlightStudy(1, context, input, null)).toThrow();
+ },
+ );
+
+ it("accepts a real lateral production flight and a cohesive crosswind comparison", () => {
+ const lateralContext = { ...context, launchDirectionDeg: 3, spinAxisTiltDeg: 5 };
+ const launch = directLaunch({
+ ballSpeedMph: 100, launchAngleDeg: 10, launchDirectionDeg: 3,
+ spinRpm: 2000, spinAxisTiltDeg: 5,
+ });
+ const calm = exploreFlight(launch);
+ expect(buildAcceptedFlightStudy(2, lateralContext, calm, null).plan.rawCount).toBeGreaterThan(2);
+ const scenario = meteorologicalWind(4, 90);
+ const comparison = compareWind(launch, scenario);
+ const windyContext = { ...lateralContext, windScenario: scenario };
+ expect(buildAcceptedFlightStudy(3, windyContext, comparison.wind, comparison).comparison)
+ .not.toBeNull();
+
+ const forged = { ...comparison, deltas: { ...comparison.deltas, carryM: 999 } };
+ expect(() => buildAcceptedFlightStudy(4, windyContext, comparison.wind, forged)).toThrow();
+ expect(() => buildAcceptedFlightStudy(4, { ...windyContext, ballSpeedMph: Number.NaN },
+ comparison.wind, comparison)).toThrow();
+ });
+});
diff --git a/src/rate_of_closure/web/src/components/flightAcceptedStudy.ts b/src/rate_of_closure/web/src/components/flightAcceptedStudy.ts
new file mode 100644
index 000000000..7cfbc2173
--- /dev/null
+++ b/src/rate_of_closure/web/src/components/flightAcceptedStudy.ts
@@ -0,0 +1,311 @@
+import type { FlightPoint } from "../model/flight";
+import {
+ directLaunch, type FlightExplorationTs, type WindComparisonTs,
+} from "../model/flightExplorer";
+import { BALL_POSITION, MPH_PER_MPS } from "../model/simulation";
+import type { LaunchDirectionConvention } from "../model/launchDirection";
+import { WIND_SCHEMA_VERSION, type WindScenario } from "../model/wind";
+import {
+ flightSampleSource,
+ MAX_FLIGHT_VELOCITY_MPS,
+ planFlightSamples,
+ type FlightSamplePlan,
+} from "../model/flightSampleInspector";
+
+export interface FlightStudyContext {
+ readonly entryMode: "direct";
+ readonly ballSpeedMph: number;
+ readonly launchAngleDeg: number;
+ readonly launchDirectionDeg: number;
+ readonly spinRpm: number;
+ readonly spinAxisTiltDeg: number;
+ readonly directionConvention: LaunchDirectionConvention;
+ readonly windScenario: WindScenario | null;
+ readonly model: "waterloo_penner";
+ readonly kernelRevision: "web-rk4-10ms-sampled-v1";
+}
+
+export interface AcceptedFlightStudy {
+ readonly generation: number;
+ readonly context: FlightStudyContext;
+ readonly contextLabel: string;
+ readonly exploration: FlightExplorationTs;
+ readonly calmComparison: FlightExplorationTs | null;
+ readonly comparison: WindComparisonTs | null;
+ readonly plan: FlightSamplePlan;
+}
+
+function snapshotPoint(point: FlightPoint): FlightPoint {
+ if (!Number.isFinite(point.time) ||
+ point.position.some((value) => !Number.isFinite(value)) ||
+ point.velocity.some((value) => !Number.isFinite(value))) {
+ throw new RangeError("flight point evidence must be finite");
+ }
+ if (point.velocity.some((value) => Math.abs(value) > MAX_FLIGHT_VELOCITY_MPS)) {
+ throw new RangeError("flight velocity exceeds the explorer contract");
+ }
+ return Object.freeze({
+ time: point.time,
+ position: Object.freeze([...point.position]) as unknown as [number, number, number],
+ velocity: Object.freeze([...point.velocity]) as unknown as [number, number, number],
+ });
+}
+
+function snapshotExploration(exploration: FlightExplorationTs): FlightExplorationTs {
+ if (typeof exploration !== "object" || exploration === null ||
+ !Array.isArray(exploration.points) || exploration.points.length < 2 ||
+ exploration.points.length > 1_002 || exploration.points.some((point) =>
+ typeof point !== "object" || point === null ||
+ !Array.isArray(point.position) || point.position.length !== 3 ||
+ !Array.isArray(point.velocity) || point.velocity.length !== 3)) {
+ throw new RangeError("flight point evidence must contain 2..1002 aligned samples");
+ }
+ const metricKeys = [
+ "ballSpeedMph", "launchAngleDeg", "launchDirectionDeg", "launchAzimuthDeg",
+ "spinRpm", "carryM", "maxHeightM", "flightTimeS", "landingAngleDeg", "lateralM",
+ ] as const;
+ if (typeof exploration.metrics !== "object" || exploration.metrics === null ||
+ Object.keys(exploration.metrics).sort().join() !== [...metricKeys].sort().join() ||
+ metricKeys.some((key) => typeof exploration.metrics[key] !== "number" ||
+ !Number.isFinite(exploration.metrics[key]))) {
+ throw new RangeError("flight summary evidence must be finite");
+ }
+ if (typeof exploration.execution !== "object" || exploration.execution === null ||
+ Object.keys(exploration.execution).sort().join() !==
+ ["kernelRevision", "launch", "model", "windScenario"].sort().join() ||
+ typeof exploration.execution.launch !== "object" || exploration.execution.launch === null ||
+ !Array.isArray(exploration.execution.launch.spinAxis) ||
+ exploration.execution.launch.spinAxis.length !== 3 ||
+ !(exploration.execution.windScenario === null ||
+ (typeof exploration.execution.windScenario === "object" &&
+ exploration.execution.windScenario !== null))) {
+ throw new RangeError("flight execution provenance is incomplete");
+ }
+ const metrics = Object.freeze({ ...exploration.metrics });
+ const launch = exploration.execution.launch;
+ return Object.freeze({
+ points: Object.freeze(exploration.points.map(snapshotPoint)) as FlightPoint[],
+ metrics,
+ execution: Object.freeze({
+ model: exploration.execution?.model,
+ kernelRevision: exploration.execution?.kernelRevision,
+ windScenario: snapshotWind(exploration.execution?.windScenario ?? null),
+ launch: Object.freeze({
+ ...launch,
+ spinAxis: Object.freeze([...launch.spinAxis]) as [number, number, number],
+ windScenario: snapshotWind(launch.windScenario ?? null) ?? undefined,
+ }),
+ }) as FlightExplorationTs["execution"],
+ });
+}
+
+function requireClose(actual: number, expected: number, field: string): void {
+ if (!Number.isFinite(actual) || !Number.isFinite(expected)) {
+ throw new RangeError(`${field} must be finite`);
+ }
+ const tolerance = 1e-7 * Math.max(1, Math.abs(actual), Math.abs(expected));
+ if (Math.abs(actual - expected) > tolerance) {
+ throw new RangeError(`${field} disagrees with exact trajectory evidence`);
+ }
+}
+
+function validateCoherence(exploration: FlightExplorationTs, plan: FlightSamplePlan): void {
+ const first = plan.rawSample(0);
+ const last = plan.rawSample(plan.rawCount - 1);
+ requireClose(first.timeS, 0, "first sample time");
+ requireClose(first.downrangeM, BALL_POSITION[0], "launch downrange position");
+ requireClose(first.heightM, BALL_POSITION[1], "launch height");
+ requireClose(first.rightM, BALL_POSITION[2], "launch lateral position");
+ requireClose(last.heightM, BALL_POSITION[1], "landing height");
+ if (plan.samples.some((sample) => sample.heightM < BALL_POSITION[1] - 1e-7)) {
+ throw new RangeError("flight evidence falls below the canonical ground plane");
+ }
+ requireClose(exploration.metrics.flightTimeS, last.timeS - first.timeS, "flight time");
+ requireClose(
+ exploration.metrics.carryM,
+ Math.hypot(last.downrangeM - first.downrangeM, last.rightM - first.rightM),
+ "carry",
+ );
+ requireClose(
+ exploration.metrics.lateralM, last.rightM - first.rightM, "lateral landing offset",
+ );
+ const sampledHeight = Math.max(...plan.samples.map((sample) => sample.heightM - first.heightM));
+ if (exploration.metrics.maxHeightM + 1e-7 < sampledHeight) {
+ throw new RangeError("maximum height is below sampled trajectory evidence");
+ }
+ if (exploration.metrics.maxHeightM < 0 || exploration.metrics.maxHeightM > 10_000) {
+ throw new RangeError("maximum height exceeds the explorer evidence envelope");
+ }
+ const maximumSampleGap = Math.max(...plan.samples.slice(1).map((sample, index) =>
+ sample.timeS - plan.samples[index].timeS));
+ const hiddenApexAllowance = maximumSampleGap * MAX_FLIGHT_VELOCITY_MPS;
+ if (exploration.metrics.maxHeightM > sampledHeight + hiddenApexAllowance + 1e-7) {
+ throw new RangeError("maximum height exceeds the bounded sampled-apex allowance");
+ }
+}
+
+function snapshotWind(scenario: WindScenario | null): WindScenario | null {
+ if (scenario === null) return null;
+ if (scenario.schemaVersion !== WIND_SCHEMA_VERSION ||
+ !Array.isArray(scenario.baseVelocityMps) || scenario.baseVelocityMps.length !== 3 ||
+ !Array.isArray(scenario.gusts) || scenario.gusts.length > 128 ||
+ typeof scenario.provenance !== "string" || scenario.gusts.some((gust) =>
+ typeof gust !== "object" || gust === null || !Array.isArray(gust.peakVelocityMps) ||
+ gust.peakVelocityMps.length !== 3)) throw new RangeError("wind scenario is malformed");
+ const finiteValues = [
+ ...scenario.baseVelocityMps, scenario.shearFractionPer10m,
+ scenario.turbulenceIntensityMps, scenario.seed,
+ ];
+ if (finiteValues.some((value) => typeof value !== "number" || !Number.isFinite(value)) ||
+ !Number.isSafeInteger(scenario.seed) || !scenario.provenance.trim() ||
+ scenario.shearFractionPer10m < 0 || scenario.turbulenceIntensityMps < 0) {
+ throw new RangeError("wind scenario is malformed");
+ }
+ const gusts = Object.freeze(scenario.gusts.map((gust) => Object.freeze({
+ startTimeS: gust.startTimeS,
+ durationS: gust.durationS,
+ peakVelocityMps: Object.freeze([...gust.peakVelocityMps]) as [number, number, number],
+ })));
+ if (gusts.some((gust) => !Number.isFinite(gust.startTimeS) || gust.startTimeS < 0 ||
+ !Number.isFinite(gust.durationS) || gust.durationS <= 0 ||
+ gust.peakVelocityMps.some((value) => !Number.isFinite(value)))) {
+ throw new RangeError("wind scenario is malformed");
+ }
+ return Object.freeze({
+ ...scenario,
+ baseVelocityMps: Object.freeze([...scenario.baseVelocityMps]) as [number, number, number],
+ gusts,
+ });
+}
+
+function validateContext(context: FlightStudyContext, exploration: FlightExplorationTs): void {
+ const values = [
+ context.ballSpeedMph, context.launchAngleDeg, context.launchDirectionDeg,
+ context.spinRpm, context.spinAxisTiltDeg,
+ ];
+ if (values.some((value) => !Number.isFinite(value)) || context.ballSpeedMph < 1 ||
+ context.ballSpeedMph > 250 || Math.abs(context.launchAngleDeg) > 89 ||
+ Math.abs(context.launchDirectionDeg) > 45 || context.spinRpm < 0 ||
+ context.spinRpm > 15_000 || Math.abs(context.spinAxisTiltDeg) > 60 ||
+ !["app_native", "trackman_comparable"].includes(context.directionConvention) ||
+ context.entryMode !== "direct" || context.model !== "waterloo_penner" ||
+ context.kernelRevision !== "web-rk4-10ms-sampled-v1") {
+ throw new RangeError("accepted flight context is outside the direct-entry domain");
+ }
+ requireClose(exploration.metrics.ballSpeedMph, context.ballSpeedMph, "ball speed context");
+ requireClose(exploration.metrics.launchAngleDeg, context.launchAngleDeg, "launch angle context");
+ requireClose(
+ exploration.metrics.launchDirectionDeg, context.launchDirectionDeg, "direction context",
+ );
+ requireClose(exploration.metrics.spinRpm, context.spinRpm, "spin context");
+ if (exploration.execution?.model !== context.model ||
+ exploration.execution.kernelRevision !== context.kernelRevision ||
+ JSON.stringify(snapshotWind(exploration.execution.windScenario)) !==
+ JSON.stringify(snapshotWind(context.windScenario))) {
+ throw new RangeError("flight execution provenance disagrees with accepted context");
+ }
+ const expectedLaunch = directLaunch({
+ ballSpeedMph: context.ballSpeedMph,
+ launchAngleDeg: context.launchAngleDeg,
+ launchDirectionDeg: context.launchDirectionDeg,
+ spinRpm: context.spinRpm,
+ spinAxisTiltDeg: context.spinAxisTiltDeg,
+ launchDirectionConvention: context.directionConvention,
+ });
+ const expectedWithWind = { ...expectedLaunch, windScenario: context.windScenario ?? undefined };
+ if (JSON.stringify(exploration.execution.launch) !== JSON.stringify(expectedWithWind)) {
+ throw new RangeError("flight launch fingerprint disagrees with accepted context");
+ }
+ requireClose(
+ exploration.metrics.launchAzimuthDeg,
+ exploration.metrics.launchDirectionDeg,
+ "launch direction alias",
+ );
+ const velocity = exploration.points[0].velocity;
+ const horizontal = Math.hypot(velocity[0], velocity[2]);
+ requireClose(Math.hypot(...velocity) * MPH_PER_MPS, context.ballSpeedMph, "raw launch speed");
+ requireClose(
+ Math.atan2(velocity[1], horizontal) * 180 / Math.PI,
+ context.launchAngleDeg,
+ "raw launch angle",
+ );
+ requireClose(
+ Math.atan2(velocity[2], velocity[0]) * 180 / Math.PI,
+ context.launchDirectionDeg,
+ "raw launch direction",
+ );
+ const landingVelocity = exploration.points[exploration.points.length - 1].velocity;
+ const landingHorizontal = Math.hypot(landingVelocity[0], landingVelocity[2]);
+ const landingAngle = landingHorizontal > 0.1
+ ? Math.atan2(-landingVelocity[1], landingHorizontal) * 180 / Math.PI : 90;
+ requireClose(exploration.metrics.landingAngleDeg, landingAngle, "landing angle");
+}
+
+export function flightContextLabel(context: FlightStudyContext): string {
+ return [
+ `direct ${context.ballSpeedMph.toFixed(2)} mph`,
+ `launch ${context.launchAngleDeg.toFixed(2)} deg`,
+ `direction ${context.launchDirectionDeg.toFixed(2)} deg (${context.directionConvention})`,
+ `spin ${context.spinRpm.toFixed(0)} rpm`,
+ `axis ${context.spinAxisTiltDeg.toFixed(2)} deg`,
+ context.windScenario === null ? "calm" :
+ `wind [${context.windScenario.baseVelocityMps.map((value) => value.toFixed(3)).join(", ")}] m/s (${context.windScenario.provenance})`,
+ `model ${context.model}`, `kernel ${context.kernelRevision}`,
+ ].join("; ");
+}
+
+export function buildAcceptedFlightStudy(
+ generation: number,
+ context: FlightStudyContext,
+ explorationInput: FlightExplorationTs,
+ comparisonInput: WindComparisonTs | null,
+): AcceptedFlightStudy {
+ if (!Number.isSafeInteger(generation) || generation < 1) {
+ throw new RangeError("accepted flight generation must be a positive safe integer");
+ }
+ const exploration = snapshotExploration(explorationInput);
+ const plan = planFlightSamples(flightSampleSource(exploration));
+ validateCoherence(exploration, plan);
+ validateContext(context, exploration);
+ const windScenario = snapshotWind(context.windScenario);
+ const calmComparison = comparisonInput ? snapshotExploration(comparisonInput.calm) : null;
+ if (comparisonInput) {
+ const wind = snapshotExploration(comparisonInput.wind);
+ if (JSON.stringify(wind) !== JSON.stringify(exploration)) {
+ throw new RangeError("wind comparison primary disagrees with accepted exploration");
+ }
+ if (windScenario === null || JSON.stringify(snapshotWind(comparisonInput.scenario)) !==
+ JSON.stringify(windScenario)) {
+ throw new RangeError("wind comparison scenario disagrees with accepted context");
+ }
+ const calmPlan = planFlightSamples(flightSampleSource(calmComparison as FlightExplorationTs));
+ validateCoherence(calmComparison as FlightExplorationTs, calmPlan);
+ validateContext(
+ { ...context, windScenario: null }, calmComparison as FlightExplorationTs,
+ );
+ const deltaKeys = ["carryM", "maxHeightM", "flightTimeS", "landingAngleDeg", "lateralM"] as const;
+ if (typeof comparisonInput.deltas !== "object" || comparisonInput.deltas === null ||
+ Object.keys(comparisonInput.deltas).sort().join() !== [...deltaKeys].sort().join()) {
+ throw new RangeError("wind deltas must contain the exact comparison metrics");
+ }
+ for (const key of deltaKeys) {
+ const delta = comparisonInput.deltas[key];
+ if (!Number.isFinite(delta)) throw new RangeError("wind deltas must be finite");
+ requireClose(delta, exploration.metrics[key] - comparisonInput.calm.metrics[key], `wind ${key}`);
+ }
+ } else if (windScenario !== null) {
+ throw new RangeError("enabled wind context requires a cohesive comparison");
+ }
+ const comparison = comparisonInput ? Object.freeze({
+ calm: calmComparison as FlightExplorationTs,
+ wind: exploration,
+ deltas: Object.freeze({ ...comparisonInput.deltas }),
+ scenario: windScenario as WindScenario,
+ }) : null;
+ return Object.freeze({
+ generation,
+ context: Object.freeze({ ...context, windScenario }),
+ contextLabel: flightContextLabel({ ...context, windScenario }),
+ exploration, calmComparison, comparison, plan,
+ });
+}
diff --git a/src/rate_of_closure/web/src/components/flightCanvasContract.ts b/src/rate_of_closure/web/src/components/flightCanvasContract.ts
new file mode 100644
index 000000000..e38b50052
--- /dev/null
+++ b/src/rate_of_closure/web/src/components/flightCanvasContract.ts
@@ -0,0 +1,29 @@
+import type { CourseLayout } from "../model/course";
+import type { FlightPoint } from "../model/flight";
+import type {
+ FlightSamplePlan, FlightSampleSelection,
+} from "../model/flightSampleInspector";
+import type { SpatialTargetTs } from "../model/spatialTarget";
+import type { TargetRegionTs } from "../model/targets";
+
+export const SIDE_CANVAS_SIZE = { width: 860, height: 260 } as const;
+export const TOP_CANVAS_SIZE = { width: 860, height: 220 } as const;
+
+export const responsiveFlightCanvasStyle = (size: { width: number; height: number }) => ({
+ width: "100%", height: "auto", aspectRatio: `${size.width} / ${size.height}`,
+});
+
+export interface FlightCanvasProps {
+ points: FlightPoint[];
+ comparisonPoints?: FlightPoint[];
+ emptyText?: string;
+ layout?: CourseLayout;
+ showCourse?: boolean;
+ target?: TargetRegionTs;
+ spatialTarget?: SpatialTargetTs;
+ distanceUnit?: string;
+ plan?: FlightSamplePlan | null;
+ selection?: FlightSampleSelection | null;
+ onSelectionChange?: (selection: FlightSampleSelection | null) => void;
+ prominenceRef?: { current: HTMLCanvasElement | null };
+}
diff --git a/src/rate_of_closure/web/src/components/flightCanvasDrawing.ts b/src/rate_of_closure/web/src/components/flightCanvasDrawing.ts
new file mode 100644
index 000000000..1bade267b
--- /dev/null
+++ b/src/rate_of_closure/web/src/components/flightCanvasDrawing.ts
@@ -0,0 +1,169 @@
+import { courseColors, type CourseLayout } from "../model/course";
+import type { FlightPoint } from "../model/flight";
+import type { TargetRegionTs } from "../model/targets";
+import {
+ spatialTargetHalfExtents,
+ type SpatialTargetTs,
+} from "../model/spatialTarget";
+import { formatDistanceM } from "../model/units";
+import { withAlpha } from "../model/theme";
+import { canvasContext, type LogicalCanvasSize } from "./canvasDisplay";
+import { drawCourse, drawSpatialTarget, drawTarget } from "./flightCanvasOverlays";
+
+const MIN_CARRY_M = 10;
+const MIN_HEIGHT_M = 5;
+const MIN_LATERAL_M = 5;
+const MARGIN = 34;
+
+export function drawFlightPanel(
+ canvas: HTMLCanvasElement,
+ logicalSize: LogicalCanvasSize,
+ points: FlightPoint[],
+ comparisonPoints: FlightPoint[],
+ vertical: "height" | "lateral",
+ emptyText: string,
+ layout: CourseLayout,
+ showCourse: boolean,
+ target?: TargetRegionTs,
+ distanceUnit = "yd",
+ spatialTarget?: SpatialTargetTs,
+ selectedRawIndex?: number,
+): void {
+ const ctx = canvasContext(canvas, logicalSize);
+ if (!ctx) return;
+ const { width, height } = logicalSize;
+ ctx.clearRect(0, 0, width, height);
+ const allPoints = [...points, ...comparisonPoints];
+ if (points.length < 2 && !spatialTarget) {
+ ctx.fillStyle = "#64748b";
+ ctx.font = "13px sans-serif";
+ ctx.fillText(emptyText, 14, 24);
+ return;
+ }
+
+ const spatialCenter = spatialTarget?.point.appCoordinatesM;
+ const spatialExtents = spatialTarget
+ ? spatialTargetHalfExtents(spatialTarget)
+ : [0, 0, 0];
+ const spatialCarry = spatialCenter ? spatialCenter[0] + spatialExtents[0] : 0;
+ const carryExt =
+ Math.max(
+ MIN_CARRY_M,
+ spatialCarry,
+ ...allPoints.map((point) => point.position[0]),
+ ) * 1.05;
+ const value = (point: FlightPoint) =>
+ vertical === "height" ? point.position[1] : point.position[2];
+ const targetVertical = spatialCenter
+ ? Math.abs(vertical === "height" ? spatialCenter[1] : spatialCenter[2]) +
+ (vertical === "height" ? spatialExtents[1] : spatialExtents[2])
+ : 0;
+ const vertExt =
+ vertical === "height"
+ ? Math.max(
+ MIN_HEIGHT_M,
+ targetVertical,
+ ...allPoints.map((point) => point.position[1]),
+ ) * 1.2
+ : Math.max(
+ MIN_LATERAL_M,
+ targetVertical,
+ ...allPoints.map((point) => Math.abs(point.position[2])),
+ ) * 1.3;
+ const zeroY = vertical === "height" ? height - MARGIN : height / 2;
+ const usableY =
+ vertical === "height" ? height - 2 * MARGIN : height / 2 - MARGIN;
+ const physicalScale = Math.min(
+ (width - 2 * MARGIN) / carryExt,
+ usableY / vertExt,
+ );
+ const px = (x: number) => MARGIN + x * physicalScale;
+ const py = (verticalValue: number) => zeroY - verticalValue * physicalScale;
+
+ const course = courseColors();
+ if (vertical === "height") {
+ ctx.fillStyle = withAlpha(course.rough, 0.35);
+ ctx.fillRect(0, py(0), width, height - py(0));
+ } else {
+ ctx.fillStyle = withAlpha(course.rough, 0.25);
+ ctx.fillRect(0, 0, width, height);
+ }
+ ctx.strokeStyle = course.fairway;
+ ctx.beginPath();
+ ctx.moveTo(0, py(0));
+ ctx.lineTo(width, py(0));
+ ctx.stroke();
+ if (showCourse) drawCourse(ctx, vertical, px, py, width, layout);
+ if (target && vertical === "lateral") drawTarget(ctx, target, px, py);
+ if (spatialTarget) {
+ drawSpatialTarget(ctx, spatialTarget, vertical, px, py, width);
+ }
+
+ if (points.length < 2) {
+ ctx.fillStyle = "#64748b";
+ ctx.font = "13px sans-serif";
+ ctx.fillText(emptyText, 14, 24);
+ return;
+ }
+ if (comparisonPoints.length >= 2) {
+ ctx.strokeStyle = "#60a5fa";
+ ctx.setLineDash([7, 5]);
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ comparisonPoints.forEach((point, index) => {
+ const verticalValue = value(point);
+ if (index === 0) ctx.moveTo(px(point.position[0]), py(verticalValue));
+ else ctx.lineTo(px(point.position[0]), py(verticalValue));
+ });
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+
+ ctx.strokeStyle = "#34d399";
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ points.forEach((point, index) => {
+ if (index === 0) ctx.moveTo(px(point.position[0]), py(value(point)));
+ else ctx.lineTo(px(point.position[0]), py(value(point)));
+ });
+ ctx.stroke();
+ ctx.lineWidth = 1;
+ if (selectedRawIndex !== undefined && points[selectedRawIndex]) {
+ const selected = points[selectedRawIndex];
+ ctx.fillStyle = "#f472b6";
+ ctx.beginPath();
+ ctx.arc(px(selected.position[0]), py(value(selected)), 5, 0, 2 * Math.PI);
+ ctx.fill();
+ }
+
+ const last = points[points.length - 1];
+ ctx.fillStyle = "#facc15";
+ ctx.beginPath();
+ ctx.arc(px(last.position[0]), py(value(last)), 4, 0, 2 * Math.PI);
+ ctx.fill();
+ ctx.fillStyle = "#94a3b8";
+ ctx.font = "11px sans-serif";
+ const label =
+ vertical === "height"
+ ? `carry ${formatDistanceM(last.position[0], distanceUnit)}`
+ : `lateral ${last.position[2] >= 0 ? "+" : "-"}${formatDistanceM(
+ Math.abs(last.position[2]),
+ distanceUnit,
+ )}`;
+ ctx.textAlign = "right";
+ ctx.fillText(label, px(last.position[0]) - 8, py(value(last)) - 8);
+ ctx.textAlign = "left";
+ ctx.fillText(
+ vertical === "height"
+ ? `Side profile (height [m] vs carry [${distanceUnit}])`
+ : `Top-down (right + vs carry [${distanceUnit}])`,
+ 10,
+ 16,
+ );
+ if (comparisonPoints.length >= 2) {
+ ctx.fillStyle = "#60a5fa";
+ ctx.fillText("- - No wind", width - 142, 16);
+ ctx.fillStyle = "#34d399";
+ ctx.fillText("— Selected wind", width - 76, 16);
+ }
+}
diff --git a/src/rate_of_closure/web/src/components/flightCanvasOverlays.ts b/src/rate_of_closure/web/src/components/flightCanvasOverlays.ts
new file mode 100644
index 000000000..92c1e10fd
--- /dev/null
+++ b/src/rate_of_closure/web/src/components/flightCanvasOverlays.ts
@@ -0,0 +1,171 @@
+import {
+ courseColors,
+ type CourseLayout,
+} from "../model/course";
+import type { TargetRegionTs } from "../model/targets";
+import {
+ spatialTargetHalfExtents,
+ type SpatialTargetTs,
+} from "../model/spatialTarget";
+import { withAlpha } from "../model/theme";
+
+export function drawCourse(
+ ctx: CanvasRenderingContext2D,
+ vertical: "height" | "lateral",
+ px: (x: number) => number,
+ py: (v: number) => number,
+ width: number,
+ layout: CourseLayout,
+): void {
+ const course = courseColors();
+ const { greenDistanceM: distance, greenRadiusM: radius, fairwayHalfWidthM } = layout;
+ if (vertical === "lateral") {
+ ctx.fillStyle = withAlpha(course.fairway, 0.4);
+ ctx.fillRect(
+ 0,
+ py(fairwayHalfWidthM),
+ width,
+ py(-fairwayHalfWidthM) - py(fairwayHalfWidthM),
+ );
+ if (px(distance - radius) <= width) {
+ ctx.fillStyle = withAlpha(course.green, 0.6);
+ ctx.beginPath();
+ ctx.ellipse(
+ px(distance),
+ py(0),
+ px(distance + radius) - px(distance),
+ py(0) - py(radius),
+ 0,
+ 0,
+ 2 * Math.PI,
+ );
+ ctx.fill();
+ ctx.fillStyle = course.hole;
+ ctx.beginPath();
+ ctx.arc(px(distance), py(0), 2.5, 0, 2 * Math.PI);
+ ctx.fill();
+ ctx.fillStyle = course.flag;
+ ctx.beginPath();
+ ctx.moveTo(px(distance) + 3, py(0) - 7);
+ ctx.lineTo(px(distance) + 10, py(0) - 4);
+ ctx.lineTo(px(distance) + 3, py(0) - 1);
+ ctx.closePath();
+ ctx.fill();
+ }
+ } else if (px(distance - radius) <= width) {
+ ctx.fillStyle = withAlpha(course.green, 0.85);
+ ctx.fillRect(
+ px(distance - radius),
+ py(0) - 2,
+ px(distance + radius) - px(distance - radius),
+ 4,
+ );
+ ctx.strokeStyle = course.flag;
+ ctx.beginPath();
+ ctx.moveTo(px(distance), py(0));
+ ctx.lineTo(px(distance), py(0) - 16);
+ ctx.stroke();
+ ctx.fillStyle = course.flag;
+ ctx.beginPath();
+ ctx.moveTo(px(distance), py(0) - 16);
+ ctx.lineTo(px(distance) + 8, py(0) - 12.5);
+ ctx.lineTo(px(distance), py(0) - 9);
+ ctx.closePath();
+ ctx.fill();
+ }
+ ctx.fillStyle = course.tee;
+ ctx.fillRect(px(0) - 2, py(0) - 2, 4, 4);
+}
+
+export function drawTarget(
+ ctx: CanvasRenderingContext2D,
+ target: TargetRegionTs,
+ px: (x: number) => number,
+ py: (v: number) => number,
+): void {
+ ctx.strokeStyle = courseColors().flag;
+ ctx.setLineDash([6, 4]);
+ ctx.lineWidth = 1.6;
+ ctx.beginPath();
+ if (target.kind === "green") {
+ const { distanceM: distance, radiusM: radius, lateralM: lateral } = target;
+ ctx.ellipse(
+ px(distance),
+ py(lateral),
+ px(distance + radius) - px(distance),
+ py(0) - py(radius),
+ 0,
+ 0,
+ 2 * Math.PI,
+ );
+ } else {
+ const { distanceM: distance, bandHalfLengthM, halfWidthM } = target;
+ ctx.rect(
+ px(distance - bandHalfLengthM),
+ py(halfWidthM),
+ px(distance + bandHalfLengthM) - px(distance - bandHalfLengthM),
+ py(-halfWidthM) - py(halfWidthM),
+ );
+ }
+ ctx.stroke();
+ ctx.setLineDash([]);
+ ctx.lineWidth = 1;
+}
+
+export function drawSpatialTarget(
+ ctx: CanvasRenderingContext2D,
+ target: SpatialTargetTs,
+ vertical: "height" | "lateral",
+ px: (value: number) => number,
+ py: (value: number) => number,
+ logicalWidth: number,
+): void {
+ const [downrange, elevation, right] = target.point.appCoordinatesM;
+ const [halfDownrange, halfElevation, halfRight] = spatialTargetHalfExtents(target);
+ const center = vertical === "height" ? elevation : right;
+ const halfVertical = vertical === "height" ? halfElevation : halfRight;
+ ctx.strokeStyle = "#f59e0b";
+ ctx.fillStyle = withAlpha("#f59e0b", 0.14);
+ ctx.setLineDash([5, 3]);
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ if (
+ target.tolerance.kind === "sphere" ||
+ target.tolerance.kind === "surface_circle"
+ ) {
+ ctx.ellipse(
+ px(downrange),
+ py(center),
+ Math.abs(px(downrange + halfDownrange) - px(downrange)),
+ Math.max(2, Math.abs(py(center + halfVertical) - py(center))),
+ 0,
+ 0,
+ 2 * Math.PI,
+ );
+ } else {
+ ctx.rect(
+ px(downrange - halfDownrange),
+ py(center + halfVertical),
+ px(downrange + halfDownrange) - px(downrange - halfDownrange),
+ Math.max(4, py(center - halfVertical) - py(center + halfVertical)),
+ );
+ }
+ ctx.fill();
+ ctx.stroke();
+ ctx.setLineDash([]);
+ ctx.lineWidth = 1;
+ ctx.fillStyle = "#fbbf24";
+ ctx.font = "bold 11px sans-serif";
+ const label = `ACTIVE · ${target.label}`;
+ const anchorX = px(downrange);
+ const proposedX = anchorX + 6;
+ const wouldClipRight =
+ proposedX + ctx.measureText(label).width > logicalWidth - 4;
+ ctx.textAlign = wouldClipRight ? "right" : "left";
+ ctx.fillText(
+ label,
+ wouldClipRight ? anchorX - 6 : proposedX,
+ Math.max(14, py(center) - 7),
+ );
+ ctx.textAlign = "left";
+}
diff --git a/src/rate_of_closure/web/src/components/flightExplorerContract.ts b/src/rate_of_closure/web/src/components/flightExplorerContract.ts
new file mode 100644
index 000000000..1bd2d6ecc
--- /dev/null
+++ b/src/rate_of_closure/web/src/components/flightExplorerContract.ts
@@ -0,0 +1,55 @@
+import type { WindComparisonTs } from "../model/flightExplorer";
+import { SPEED_UNITS as CANONICAL_SPEED_UNITS } from "../model/units";
+
+export const SPEED_UNITS = CANONICAL_SPEED_UNITS;
+
+export function boundedFlightError(error: unknown, retained: boolean): string {
+ const message = error instanceof Error ? error.message : String(error);
+ const normalized = [...message].map((character) => {
+ const codePoint = character.codePointAt(0) ?? 0;
+ return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)
+ ? " " : character;
+ }).join("").trim();
+ const suffix = retained
+ ? "The prior accepted flight remains displayed."
+ : "No accepted flight is available.";
+ const diagnostic = normalized || "Flight computation failed";
+ return `${diagnostic.slice(0, 510 - suffix.length)}. ${suffix}`;
+}
+
+interface DirectionConventionOption {
+ value: string;
+ label: string;
+ disabled?: boolean;
+ title?: string;
+}
+
+export const DIRECTION_CONVENTIONS: readonly DirectionConventionOption[] = [
+ { value: "app_native", label: "App Native (+ Right)" },
+ { value: "trackman_comparable", label: "TrackMan-Comparable (+ Right)" },
+ {
+ value: "foresight_comparable",
+ label: "Foresight-Comparable (Sign Unavailable)",
+ disabled: true,
+ title: "Unavailable: the general public sign convention is not established independently of player handedness.",
+ },
+] as const;
+
+export const RESULT_ROWS: Array<{
+ key: keyof WindComparisonTs["deltas"];
+ label: string;
+ unit: string;
+}> = [
+ { key: "carryM", label: "Carry Distance", unit: "m" },
+ { key: "maxHeightM", label: "Apex Height", unit: "m" },
+ { key: "flightTimeS", label: "Flight Time", unit: "s" },
+ { key: "landingAngleDeg", label: "Landing Angle", unit: "°" },
+ { key: "lateralM", label: "Lateral Landing Offset", unit: "m" },
+];
+
+export const FLIGHT_FIELDS = [
+ { key: "launchAngleDeg", label: "Launch Angle", unit: "deg", guidance: "fxLaunchAngle" },
+ { key: "launchDirectionDeg", label: "Launch Direction", unit: "deg", guidance: "fxLaunchDirection" },
+ { key: "spinRpm", label: "Total Spin", unit: "rpm", guidance: "fxSpinRpm" },
+ { key: "spinAxisTiltDeg", label: "Spin-Axis Tilt", unit: "deg", guidance: "fxSpinAxisTilt" },
+] as const;
diff --git a/src/rate_of_closure/web/src/components/useFlightSampleInspector.ts b/src/rate_of_closure/web/src/components/useFlightSampleInspector.ts
new file mode 100644
index 000000000..924ed23c8
--- /dev/null
+++ b/src/rate_of_closure/web/src/components/useFlightSampleInspector.ts
@@ -0,0 +1,77 @@
+import type { KeyboardEvent, MouseEvent } from "react";
+
+import {
+ navigateFlightSamples,
+ nearestFlightSample,
+ type FlightSamplePlan,
+ type FlightSampleSelection,
+} from "../model/flightSampleInspector";
+import type { FlightPoint } from "../model/flight";
+import { spatialTargetHalfExtents, type SpatialTargetTs } from "../model/spatialTarget";
+
+const MARGIN = 34;
+
+interface InspectorOptions {
+ plan: FlightSamplePlan | null;
+ selection: FlightSampleSelection | null;
+ onSelectionChange: (selection: FlightSampleSelection | null) => void;
+ points: readonly FlightPoint[];
+ comparisonPoints: readonly FlightPoint[];
+ spatialTarget?: SpatialTargetTs;
+ vertical: "height" | "lateral";
+ logicalWidth: number;
+ logicalHeight: number;
+}
+
+function profileProjection(options: InspectorOptions, cssWidth: number, cssHeight: number) {
+ const { plan, points, comparisonPoints, spatialTarget, vertical, logicalWidth, logicalHeight } = options;
+ if (!plan || points.length !== plan.rawCount) return [];
+ const allPoints = [...points, ...comparisonPoints];
+ const center = spatialTarget?.point.appCoordinatesM;
+ const extents = spatialTarget ? spatialTargetHalfExtents(spatialTarget) : [0, 0, 0];
+ const carry = Math.max(10, center ? center[0] + extents[0] : 0,
+ ...allPoints.map((point) => point.position[0])) * 1.05;
+ const targetVertical = center
+ ? Math.abs(vertical === "height" ? center[1] : center[2]) +
+ (vertical === "height" ? extents[1] : extents[2]) : 0;
+ const verticalExtent = vertical === "height"
+ ? Math.max(5, targetVertical, ...allPoints.map((point) => point.position[1])) * 1.2
+ : Math.max(5, targetVertical, ...allPoints.map((point) => Math.abs(point.position[2]))) * 1.3;
+ const zeroY = vertical === "height" ? logicalHeight - MARGIN : logicalHeight / 2;
+ const usableY = vertical === "height"
+ ? logicalHeight - 2 * MARGIN : logicalHeight / 2 - MARGIN;
+ const scale = Math.min((logicalWidth - 2 * MARGIN) / carry, usableY / verticalExtent);
+ return plan.samples.map((sample) => {
+ const value = vertical === "height" ? sample.heightM : sample.rightM;
+ return [
+ "current", sample.rawIndex,
+ (MARGIN + sample.downrangeM * scale) * cssWidth / logicalWidth,
+ (zeroY - value * scale) * cssHeight / logicalHeight,
+ ] as const;
+ });
+}
+
+export function useFlightSampleInspector(options: InspectorOptions) {
+ const onClick = (event: MouseEvent) => {
+ if (!options.plan) return;
+ const rect = event.currentTarget.getBoundingClientRect();
+ if (!(rect.width > 0 && rect.height > 0)) return;
+ const projected = profileProjection(options, rect.width, rect.height);
+ const next = nearestFlightSample(
+ options.plan, projected, [event.clientX - rect.left, event.clientY - rect.top],
+ );
+ if (next) options.onSelectionChange(next);
+ };
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (!options.plan) return;
+ const commands = {
+ ArrowLeft: "previous", ArrowRight: "next", Home: "home", End: "end", Escape: "clear",
+ } as const;
+ const command = commands[event.key as keyof typeof commands];
+ if (!command) return;
+ event.preventDefault();
+ const rawIndex = navigateFlightSamples(options.plan, options.selection?.rawIndex ?? null, command);
+ options.onSelectionChange(rawIndex === null ? null : { cohort: "current", rawIndex });
+ };
+ return { onClick, onKeyDown, tabIndex: 0 };
+}
diff --git a/src/rate_of_closure/web/src/model/__fixtures__/flight_sample_inspector_golden_v1.json b/src/rate_of_closure/web/src/model/__fixtures__/flight_sample_inspector_golden_v1.json
new file mode 100644
index 000000000..4d1169d66
--- /dev/null
+++ b/src/rate_of_closure/web/src/model/__fixtures__/flight_sample_inspector_golden_v1.json
@@ -0,0 +1,19 @@
+{
+ "schema_id": "rate-of-closure/flight-sample-inspector-golden",
+ "schema_version": 1,
+ "series": {
+ "times_s": [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6],
+ "positions_m": [[0.0, 0.0, 0.0], [2.0, 1.0, 0.1], [4.0, 2.0, 0.4], [6.0, 3.0, 0.8], [8.0, 3.0, 1.0], [10.0, 1.0, 1.2], [12.0, 0.0, 1.3]]
+ },
+ "expected": {
+ "apex_raw_index": 3,
+ "phases": ["launch", "ascent", "ascent", "apex", "descent", "descent", "landing"],
+ "navigation_from_3": {"previous": 2, "next": 4, "home": 0, "end": 6, "clear": null},
+ "nearest": {
+ "projected": [["current", 0, 10.0, 10.0], ["current", 1, 14.0, 20.0], ["current", 2, 16.0, 25.0], ["current", 3, 20.0, 10.0], ["current", 4, 22.0, 20.0], ["current", 5, 24.0, 25.0], ["current", 6, 20.0, 30.0]],
+ "pointer": [15.0, 10.0],
+ "hit_radius_px": 12.0,
+ "selection": {"cohort": "current", "raw_index": 0}
+ }
+ }
+}
diff --git a/src/rate_of_closure/web/src/model/flightExplorer.test.ts b/src/rate_of_closure/web/src/model/flightExplorer.test.ts
index c29d266c1..3c95f4a57 100644
--- a/src/rate_of_closure/web/src/model/flightExplorer.test.ts
+++ b/src/rate_of_closure/web/src/model/flightExplorer.test.ts
@@ -33,8 +33,16 @@ describe("directLaunch", () => {
expect(launch.spinAxis[2]).toBeCloseTo(0.0, 12);
});
- it("rejects non-positive ball speed", () => {
+ it("enforces exact finite direct-entry domains", () => {
expect(() => directLaunch({ ...PINNED, ballSpeedMph: 0 })).toThrow();
+ expect(() => directLaunch({ ...PINNED, ballSpeedMph: 250.01 })).toThrow();
+ expect(() => directLaunch({ ...PINNED, launchAngleDeg: 89.01 })).toThrow();
+ expect(() => directLaunch({ ...PINNED, launchDirectionDeg: -45.01 })).toThrow();
+ expect(() => directLaunch({ ...PINNED, spinRpm: 15001 })).toThrow();
+ expect(() => directLaunch({ ...PINNED, spinAxisTiltDeg: Number.NaN })).toThrow();
+ expect(() => directLaunch({ ...PINNED, spinRpm: true as never })).toThrow();
+ expect(() => directLaunch({ ...PINNED, ballSpeedMph: 1 })).not.toThrow();
+ expect(() => directLaunch({ ...PINNED, ballSpeedMph: 250 })).not.toThrow();
});
});
diff --git a/src/rate_of_closure/web/src/model/flightExplorer.ts b/src/rate_of_closure/web/src/model/flightExplorer.ts
index 2c699eb19..da233ff5e 100644
--- a/src/rate_of_closure/web/src/model/flightExplorer.ts
+++ b/src/rate_of_closure/web/src/model/flightExplorer.ts
@@ -42,10 +42,17 @@ export interface DirectLaunchInput {
/** Twin of `launch_from_direct` (app signs -> flight frame). */
export function directLaunch(input: DirectLaunchInput): Launch {
- if (!(input.ballSpeedMph > 0)) {
- throw new Error("ballSpeedMph must be > 0");
+ const values = [input.ballSpeedMph, input.launchAngleDeg, input.spinRpm, input.spinAxisTiltDeg];
+ if (values.some((value) => typeof value !== "number" || !Number.isFinite(value)) ||
+ input.ballSpeedMph < 1 || input.ballSpeedMph > 250 ||
+ Math.abs(input.launchAngleDeg) > 89 || input.spinRpm < 0 || input.spinRpm > 15_000 ||
+ Math.abs(input.spinAxisTiltDeg) > 60) {
+ throw new RangeError("direct flight inputs are outside the supported domain");
}
const direction = launchDirectionFromRecord(input as unknown as Record);
+ if (!Number.isFinite(direction.degrees) || Math.abs(direction.degrees) > 45) {
+ throw new RangeError("launch direction must be finite and within -45..45 degrees");
+ }
// App direction + = right; flight-frame azimuth + = left: flip. The
// fade-side tilt (+) needs a downward (-z flight) sidespin component,
// so the legacy spin-axis-angle decomposition gets the flipped angle
@@ -86,6 +93,12 @@ export interface FlightExplorationTs {
landingAngleDeg: number;
lateralM: number; // + = right of target
};
+ execution: {
+ readonly model: "waterloo_penner";
+ readonly kernelRevision: "web-rk4-10ms-sampled-v1";
+ readonly windScenario: WindScenario | null;
+ readonly launch: Launch;
+ };
}
export interface WindComparisonTs {
@@ -122,13 +135,27 @@ export function exploreFlight(launch: Launch): FlightExplorationTs {
// Flight lateral + = left; app lateral + = right.
lateralM: -result.lateralM,
},
+ execution: Object.freeze({
+ model: "waterloo_penner",
+ kernelRevision: "web-rk4-10ms-sampled-v1",
+ windScenario: launch.windScenario ?? null,
+ launch: Object.freeze({
+ ...launch,
+ spinAxis: Object.freeze([...launch.spinAxis]) as Vec3,
+ windScenario: launch.windScenario,
+ }),
+ }),
};
}
/** Run common-input no-wind and wind trajectories and retain auditable deltas. */
-export function compareWind(launch: Launch, scenario: WindScenario): WindComparisonTs {
- const calm = exploreFlight({ ...launch, windScenario: undefined });
- const wind = exploreFlight({ ...launch, windScenario: scenario });
+export function compareWind(
+ launch: Launch,
+ scenario: WindScenario,
+ execute: (request: Launch) => FlightExplorationTs = exploreFlight,
+): WindComparisonTs {
+ const calm = execute({ ...launch, windScenario: undefined });
+ const wind = execute({ ...launch, windScenario: scenario });
return {
calm,
wind,
diff --git a/src/rate_of_closure/web/src/model/flightSampleInspector.test.ts b/src/rate_of_closure/web/src/model/flightSampleInspector.test.ts
new file mode 100644
index 000000000..b4613e507
--- /dev/null
+++ b/src/rate_of_closure/web/src/model/flightSampleInspector.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it } from "vitest";
+
+import fixture from "./__fixtures__/flight_sample_inspector_golden_v1.json";
+import {
+ flightSampleSource, navigateFlightSamples, nearestFlightSample, planFlightSamples,
+} from "./flightSampleInspector";
+
+describe("flight sample inspector", () => {
+ it("matches the Python-owned phase, navigation, and pixel-tie golden", () => {
+ expect(Object.keys(fixture).sort()).toEqual(["expected", "schema_id", "schema_version", "series"]);
+ expect(fixture.schema_id).toBe("rate-of-closure/flight-sample-inspector-golden");
+ expect(fixture.schema_version).toBe(1);
+ const plan = planFlightSamples({
+ timesS: fixture.series.times_s,
+ positionsM: fixture.series.positions_m as [number, number, number][],
+ });
+ expect(plan.apexRawIndex).toBe(fixture.expected.apex_raw_index);
+ expect(plan.samples.map((sample) => sample.phase)).toEqual(fixture.expected.phases);
+ Object.entries(fixture.expected.navigation_from_3).forEach(([command, index]) => {
+ expect(navigateFlightSamples(plan, 3, command as "next")).toBe(index);
+ });
+ const nearest = fixture.expected.nearest;
+ expect(nearestFlightSample(
+ plan,
+ nearest.projected as ["current", number, number, number][], nearest.pointer as [number, number],
+ nearest.hit_radius_px,
+ )).toEqual({ cohort: "current", rawIndex: nearest.selection.raw_index });
+ });
+
+ it("deep-snapshots evidence and rejects malformed or oversized inputs", () => {
+ const times = [0, 0.1];
+ const positions: [number, number, number][] = [[0, 0, 0], [1, 0, 0]];
+ const plan = planFlightSamples({ timesS: times, positionsM: positions });
+ times[1] = 9;
+ positions[1][0] = 9;
+ expect(plan.rawSample(1)).toMatchObject({ timeS: 0.1, downrangeM: 1 });
+ expect(() => planFlightSamples({ timesS: [0, 0], positionsM: [[0, 0, 0], [1, 0, 0]] })).toThrow();
+ expect(() => planFlightSamples({
+ timesS: Array.from({ length: 1003 }, (_, index) => index),
+ positionsM: Array.from({ length: 1003 }, () => [0, 0, 0] as const),
+ })).toThrow();
+ expect(planFlightSamples({
+ timesS: Array.from({ length: 1002 }, (_, index) => index * 0.001),
+ positionsM: Array.from({ length: 1002 }, () => [0, 0, 0] as const),
+ }).rawCount).toBe(1002);
+ expect(() => planFlightSamples({
+ timesS: new Proxy(new Array(1003), {
+ get(target, property, receiver) {
+ if (property !== "length") throw new Error("oversize evidence was traversed");
+ return Reflect.get(target, property, receiver);
+ },
+ }),
+ positionsM: new Array(1003),
+ })).toThrow(/2\.\.1002/);
+ });
+
+ it("uses first maximum height for apex and a bounded 12 CSS-pixel hit", () => {
+ const plan = planFlightSamples({
+ timesS: [0, 1, 2, 3],
+ positionsM: [[0, 0, 0], [1, 2, 0], [2, 2, 0], [3, 0, 0]],
+ });
+ expect(plan.apexRawIndex).toBe(1);
+ const projected = plan.samples.map((sample) => [
+ "current", sample.rawIndex, sample.rawIndex === 0 ? 0 : 100, 0,
+ ] as const);
+ expect(nearestFlightSample(plan, projected, [12, 0])).toEqual({ cohort: "current", rawIndex: 0 });
+ expect(nearestFlightSample(plan, projected, [12.01, 0])).toBeNull();
+ });
+
+ it("rejects calm, partial, duplicate, and out-of-range projected identities", () => {
+ const plan = planFlightSamples({ timesS: [0, 1], positionsM: [[0, 0, 0], [1, 0, 0]] });
+ expect(() => nearestFlightSample(
+ plan, [["calm" as "current", 0, 0, 0], ["calm" as "current", 1, 1, 0]], [0, 0],
+ )).toThrow();
+ expect(() => nearestFlightSample(plan, [["current", 0, 0, 0]], [0, 0])).toThrow();
+ expect(() => nearestFlightSample(
+ plan, [["current", 0, 0, 0], ["current", 0, 1, 0]], [0, 0],
+ )).toThrow();
+ });
+
+ it("documents coincident launch/apex/landing precedence", () => {
+ const descending = planFlightSamples({ timesS: [0, 1], positionsM: [[0, 2, 0], [1, 0, 0]] });
+ const rising = planFlightSamples({ timesS: [0, 1], positionsM: [[0, 0, 0], [1, 2, 0]] });
+ expect(descending.samples.map((sample) => sample.phase)).toEqual(["launch", "landing"]);
+ expect(rising.samples.map((sample) => sample.phase)).toEqual(["launch", "landing"]);
+ expect([descending.apexRawIndex, rising.apexRawIndex]).toEqual([0, 1]);
+ });
+
+ it("normalizes forged exploration points and snapshots public source coordinates", () => {
+ expect(() => flightSampleSource({ points: [null, null] } as never)).toThrow(RangeError);
+ const position: [number, number, number] = [0, 0, 0];
+ const source = flightSampleSource({
+ points: [
+ { time: 0, position, velocity: [1, 0, 0] },
+ { time: 1, position: [1, 0, 0], velocity: [1, 0, 0] },
+ ],
+ metrics: {} as never,
+ execution: {} as never,
+ });
+ position[0] = 99;
+ expect(source.positionsM[0][0]).toBe(0);
+ });
+
+ it.each([
+ [[0, 10.001001], [[0, 0, 0], [1, 0, 0]]],
+ [[0, 1], [[0, 0, 0], [10_000.001, 0, 0]]],
+ [[0, 1], [[0, 0, 0], [Number.MAX_VALUE, 0, 0]]],
+ ] as Array<[number[], Array<[number, number, number]>]>)(
+ "rejects finite but unrenderable evidence", (timesS, positionsM) => {
+ expect(() => planFlightSamples({ timesS, positionsM })).toThrow();
+ },
+ );
+});
diff --git a/src/rate_of_closure/web/src/model/flightSampleInspector.ts b/src/rate_of_closure/web/src/model/flightSampleInspector.ts
new file mode 100644
index 000000000..fd253ec66
--- /dev/null
+++ b/src/rate_of_closure/web/src/model/flightSampleInspector.ts
@@ -0,0 +1,170 @@
+import type { FlightExplorationTs } from "./flightExplorer";
+
+export const MAX_FLIGHT_SAMPLES = 1_002;
+export const MAX_FLIGHT_TIME_S = 10.001;
+export const MAX_FLIGHT_POSITION_M = 10_000;
+export const MAX_FLIGHT_VELOCITY_MPS = 1_000;
+export const DEFAULT_FLIGHT_HIT_RADIUS_PX = 12;
+
+export type FlightPhase = "launch" | "ascent" | "apex" | "descent" | "landing";
+export type FlightNavigation = "previous" | "next" | "home" | "end" | "clear";
+export type FlightCohort = "current";
+
+export interface FlightSampleSource {
+ readonly timesS: readonly number[];
+ readonly positionsM: readonly (readonly [number, number, number])[];
+}
+
+export interface FlightDisplaySample {
+ readonly rawIndex: number;
+ readonly timeS: number;
+ readonly downrangeM: number;
+ readonly heightM: number;
+ readonly rightM: number;
+ readonly phase: FlightPhase;
+}
+
+export interface FlightSamplePlan {
+ readonly source: FlightSampleSource;
+ readonly samples: readonly FlightDisplaySample[];
+ readonly rawCount: number;
+ readonly apexRawIndex: number;
+ readonly rawSample: (index: number) => FlightDisplaySample;
+}
+
+export interface FlightSampleSelection {
+ readonly cohort: FlightCohort;
+ readonly rawIndex: number;
+}
+
+function finite(value: unknown, field: string): number {
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ throw new RangeError(`${field} must be a finite number`);
+ }
+ return value;
+}
+
+export function flightSampleSource(exploration: FlightExplorationTs): FlightSampleSource {
+ if (typeof exploration !== "object" || exploration === null || !Array.isArray(exploration.points)) {
+ throw new RangeError("flight sample source must be a flight exploration");
+ }
+ if (exploration.points.length < 2 || exploration.points.length > MAX_FLIGHT_SAMPLES) {
+ throw new RangeError(`flight evidence must contain 2..${MAX_FLIGHT_SAMPLES} samples`);
+ }
+ if (exploration.points.some((point) => typeof point !== "object" || point === null ||
+ !Array.isArray(point.position) || point.position.length !== 3)) {
+ throw new RangeError("flight positions must have shape (N, 3)");
+ }
+ return {
+ timesS: exploration.points.map((point) => point.time),
+ positionsM: exploration.points.map((point) => Object.freeze([...point.position]) as
+ readonly [number, number, number]),
+ };
+}
+
+export function planFlightSamples(input: FlightSampleSource): FlightSamplePlan {
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
+ throw new RangeError("flight planner requires aligned sample evidence");
+ }
+ if (!Array.isArray(input.timesS) || !Array.isArray(input.positionsM)) {
+ throw new RangeError("flight planner requires aligned sample evidence");
+ }
+ const count = input.timesS.length;
+ if (count < 2 || count > MAX_FLIGHT_SAMPLES) {
+ throw new RangeError(`flight evidence must contain 2..${MAX_FLIGHT_SAMPLES} samples`);
+ }
+ if (input.positionsM.length !== count ||
+ input.positionsM.some((row) => !Array.isArray(row) || row.length !== 3)) {
+ throw new RangeError("flight positions must have shape (N, 3)");
+ }
+ const times = Object.freeze(input.timesS.map((value) => finite(value, "timesS")));
+ const positions = Object.freeze(input.positionsM.map((row) => {
+ if (!Array.isArray(row) || row.length !== 3) {
+ throw new RangeError("flight positions must have shape (N, 3)");
+ }
+ return Object.freeze(row.map((value) => finite(value, "positionsM"))) as unknown as
+ readonly [number, number, number];
+ }));
+ if (times[0] < 0 || times.slice(1).some((value, index) => value <= times[index])) {
+ throw new RangeError("flight sample times must be nonnegative and strictly increasing");
+ }
+ if (times[times.length - 1] > MAX_FLIGHT_TIME_S) {
+ throw new RangeError("flight sample time exceeds the explorer contract");
+ }
+ if (positions.some((row) => row.some((value) => Math.abs(value) > MAX_FLIGHT_POSITION_M))) {
+ throw new RangeError("flight position exceeds the explorer contract");
+ }
+ let apexRawIndex = 0;
+ positions.forEach((position, index) => {
+ if (position[1] > positions[apexRawIndex][1]) apexRawIndex = index;
+ });
+ const last = times.length - 1;
+ const samples = Object.freeze(times.map((timeS, rawIndex): FlightDisplaySample => {
+ let phase: FlightPhase;
+ if (rawIndex === 0) phase = "launch";
+ else if (rawIndex === last) phase = "landing";
+ else if (rawIndex === apexRawIndex) phase = "apex";
+ else if (rawIndex < apexRawIndex) phase = "ascent";
+ else phase = "descent";
+ return Object.freeze({
+ rawIndex, timeS, downrangeM: positions[rawIndex][0],
+ heightM: positions[rawIndex][1], rightM: positions[rawIndex][2], phase,
+ });
+ }));
+ const source = Object.freeze({ timesS: times, positionsM: positions });
+ const rawSample = (index: number): FlightDisplaySample => {
+ if (!Number.isSafeInteger(index) || index < 0 || index >= samples.length) {
+ throw new RangeError("raw sample index is outside the accepted flight");
+ }
+ return samples[index];
+ };
+ return Object.freeze({ source, samples, rawCount: samples.length, apexRawIndex, rawSample });
+}
+
+export function navigateFlightSamples(
+ plan: FlightSamplePlan, current: number | null, command: FlightNavigation,
+): number | null {
+ if (!["previous", "next", "home", "end", "clear"].includes(command)) {
+ throw new RangeError("unknown flight sample navigation command");
+ }
+ if (command === "clear") return null;
+ if (command === "home") return 0;
+ if (command === "end") return plan.rawCount - 1;
+ if (!Number.isSafeInteger(current) || current === null || current < 0 || current >= plan.rawCount) {
+ return command === "next" ? 0 : plan.rawCount - 1;
+ }
+ return command === "next" ? Math.min(current + 1, plan.rawCount - 1) : Math.max(current - 1, 0);
+}
+
+export function nearestFlightSample(
+ plan: FlightSamplePlan,
+ projected: readonly (readonly [FlightCohort, number, number, number])[],
+ pointerPx: readonly [number, number],
+ hitRadiusPx = DEFAULT_FLIGHT_HIT_RADIUS_PX,
+): FlightSampleSelection | null {
+ const [pointerX, pointerY] = pointerPx.map((value) => finite(value, "pointer"));
+ const radius = finite(hitRadiusPx, "hit radius");
+ if (radius <= 0 || radius > 100) throw new RangeError("hit radius must be a positive pixel distance");
+ let nearest: readonly [number, number] | null = null;
+ const seen = new Set();
+ for (const [cohort, rawIndex, rawX, rawY] of projected) {
+ if (cohort !== "current") throw new RangeError("calm comparison samples are not selectable");
+ if (!Number.isSafeInteger(rawIndex) || rawIndex < 0) {
+ throw new RangeError("projected raw index must be a nonnegative integer");
+ }
+ if (rawIndex >= plan.rawCount || seen.has(rawIndex)) {
+ throw new RangeError("projected raw indices must be unique and in range");
+ }
+ seen.add(rawIndex);
+ const x = finite(rawX, "projected x");
+ const y = finite(rawY, "projected y");
+ const candidate: readonly [number, number] = [Math.hypot(x - pointerX, y - pointerY), rawIndex];
+ if (nearest === null || candidate[0] < nearest[0] ||
+ (candidate[0] === nearest[0] && candidate[1] < nearest[1])) nearest = candidate;
+ }
+ if (seen.size !== plan.rawCount) {
+ throw new RangeError("projected samples must cover the complete primary plan");
+ }
+ return nearest !== null && nearest[0] <= radius
+ ? Object.freeze({ cohort: "current", rawIndex: nearest[1] }) : null;
+}
diff --git a/tests/ops/test_rate_web_playwright_workflow.py b/tests/ops/test_rate_web_playwright_workflow.py
index 01cc9a9ff..549fd7fc0 100644
--- a/tests/ops/test_rate_web_playwright_workflow.py
+++ b/tests/ops/test_rate_web_playwright_workflow.py
@@ -25,6 +25,8 @@
"src/rate_of_closure/club/**",
"src/rate_of_closure/club_camera.py",
"src/rate_of_closure/club_mesh_source.py",
+ "src/rate_of_closure/flight_accepted_study.py",
+ "src/rate_of_closure/flight_sample_inspector.py",
"src/rate_of_closure/mesh.py",
"src/rate_of_closure/model.py",
"src/rate_of_closure/plotting/**",
@@ -41,11 +43,18 @@
"src/shared/python/swing_sim/putting.py",
"tests/rate_of_closure/pyqt_putting_sample_inspector_probe.py",
"tests/rate_of_closure/pyqt_club_camera_probe.py",
+ "tests/rate_of_closure/pyqt_flight_sample_inspector_probe.py",
"tests/rate_of_closure/test_club_camera.py",
"tests/rate_of_closure/test_club_mesh_source.py",
"tests/rate_of_closure/test_club_view_camera.py",
+ "tests/rate_of_closure/test_flight_accepted_study.py",
+ "tests/rate_of_closure/test_flight_explorer.py",
+ "tests/rate_of_closure/test_flight_explorer_atomic_gui.py",
+ "tests/rate_of_closure/test_flight_sample_inspector.py",
+ "tests/rate_of_closure/test_flight_sample_inspector_gui.py",
"tests/rate_of_closure/test_mesh.py",
"tests/rate_of_closure/test_pyqt_club_camera_rendered.py",
+ "tests/rate_of_closure/test_pyqt_flight_sample_inspector_rendered.py",
"tests/rate_of_closure/test_pyqt_putting_sample_inspector_rendered.py",
"tests/rate_of_closure/pyqt_variation_render_probe.py",
"tests/rate_of_closure/test_pyqt_variation_rendered_interactions.py",
@@ -147,6 +156,7 @@ def test_pr_runs_locked_cross_browser_gate_and_trusted_keeps_chromium_gate() ->
"tests/rate_of_closure/test_pyqt_variation_visual_state_rendered.py "
"tests/rate_of_closure/test_pyqt_putting_sample_inspector_rendered.py "
"tests/rate_of_closure/test_pyqt_club_camera_rendered.py "
+ "tests/rate_of_closure/test_pyqt_flight_sample_inspector_rendered.py "
"tests/rate_of_closure/test_pyqt_visualization_tab_visibility.py -q -n 0"
)
assert (
@@ -161,6 +171,7 @@ def test_pr_runs_locked_cross_browser_gate_and_trusted_keeps_chromium_gate() ->
"tests/rate_of_closure/test_pyqt_variation_visual_state_rendered.py "
"tests/rate_of_closure/test_pyqt_putting_sample_inspector_rendered.py "
"tests/rate_of_closure/test_pyqt_club_camera_rendered.py "
+ "tests/rate_of_closure/test_pyqt_flight_sample_inspector_rendered.py "
"tests/rate_of_closure/test_pyqt_visualization_tab_visibility.py -q -n 0"
)
diff --git a/tests/rate_of_closure/pyqt_flight_sample_inspector_probe.py b/tests/rate_of_closure/pyqt_flight_sample_inspector_probe.py
new file mode 100644
index 000000000..e5a69d16f
--- /dev/null
+++ b/tests/rate_of_closure/pyqt_flight_sample_inspector_probe.py
@@ -0,0 +1,157 @@
+"""DPI-isolated production Flight inspector evidence probe."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+from pathlib import Path
+
+import matplotlib
+from PyQt6.QtCore import QPoint, QRect, Qt
+from PyQt6.QtGui import QFont, QFontDatabase, QFontMetrics
+from PyQt6.QtTest import QTest
+from PyQt6.QtWidgets import QApplication, QScrollArea
+
+import rate_of_closure.ui.pyqt6.flight_explorer_run as run_module
+from rate_of_closure.ui.pyqt6.flight_explorer_tab import FlightExplorerTab
+from rate_of_closure.ui.pyqt6.main_window import RateOfClosureMainWindow
+from rate_of_closure.ui.pyqt6.visualization_tab_audit import visible_intersection
+
+
+class MemorySettings:
+ def __init__(self) -> None:
+ self.values: dict[str, object] = {}
+
+ def value(self, key: str, default_value: object = None) -> object:
+ return self.values.get(key, default_value)
+
+ def setValue(self, key: str, value: object) -> None: # noqa: N802
+ self.values[key] = value
+
+
+def _install_evidence_font(application: QApplication) -> dict[str, object]:
+ font_path = Path(matplotlib.get_data_path()) / "fonts" / "ttf" / "DejaVuSans.ttf"
+ font_id = QFontDatabase.addApplicationFont(str(font_path))
+ if font_id < 0:
+ raise RuntimeError("could not load the bundled Flight evidence font")
+ families = QFontDatabase.applicationFontFamilies(font_id)
+ if not families:
+ raise RuntimeError("the bundled Flight evidence font has no family")
+ font = QFont(families[0])
+ metrics = QFontMetrics(font)
+ sanity_text = "Flight 0123 m/s"
+ if not all(metrics.inFontUcs4(ord(character)) for character in sanity_text):
+ raise RuntimeError("the Flight evidence font lacks required ASCII glyphs")
+ application.setFont(font)
+ return {
+ "font_id": font_id,
+ "font_family": families[0],
+ "font_ascii_supported": True,
+ }
+
+
+def _capture(
+ window: RateOfClosureMainWindow,
+ tab: FlightExplorerTab,
+ output: Path,
+ state: str,
+) -> dict[str, object]:
+ QApplication.processEvents()
+ window_target = output / f"flight-{state}.png"
+ canvas_target = output / f"flight-{state}-canvas.png"
+ if not window.grab().save(str(window_target), "PNG"):
+ raise RuntimeError(f"could not capture Flight {state}")
+ canvas = tab.flight_view()._canvas
+ if not canvas.grab().save(str(canvas_target), "PNG"):
+ raise RuntimeError(f"could not capture Flight canvas {state}")
+ canvas_bytes = canvas_target.read_bytes()
+ visible = visible_intersection(canvas, tab)
+ visual = QRect(canvas.mapTo(tab, QPoint()), canvas.size())
+ controls = tab.findChild(QScrollArea)
+ if controls is None:
+ raise RuntimeError("Flight setup scroll area is unavailable")
+ control_rect = QRect(controls.mapTo(tab, QPoint()), controls.size())
+ accepted = tab.accepted_study()
+ return {
+ "state": state,
+ "accepted_generation": None if accepted is None else accepted.generation,
+ "accepted_context": None if accepted is None else accepted.context.label(),
+ "raw_sample_count": None if accepted is None else accepted.plan.raw_count,
+ "selected_raw_index": tab.flight_view().selected_raw_index(),
+ "playback_time_s": tab._flight_panel.controls.current_time_s(),
+ "status": tab._sample_status.text(),
+ "error": tab._error_status.text(),
+ "context": tab._context_status.text(),
+ "visible_visual": [
+ visible.x(),
+ visible.y(),
+ visible.width(),
+ visible.height(),
+ ],
+ "tab_size": [tab.width(), tab.height()],
+ "control_overlap": visual.intersects(control_rect),
+ "canvas_has_focus": QApplication.focusWidget() is canvas,
+ "device_pixel_ratio": canvas.devicePixelRatioF(),
+ "window_screenshot": window_target.name,
+ "window_bytes": window_target.stat().st_size,
+ "canvas_screenshot": canvas_target.name,
+ "canvas_bytes": len(canvas_bytes),
+ "canvas_sha256": hashlib.sha256(canvas_bytes).hexdigest(),
+ }
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--output", type=Path, required=True)
+ parser.add_argument("--scale", type=float, required=True)
+ args = parser.parse_args()
+ args.output.mkdir(parents=True, exist_ok=True)
+ application = QApplication.instance() or QApplication([])
+ application.setQuitOnLastWindowClosed(False)
+ font_evidence = _install_evidence_font(application)
+ window = RateOfClosureMainWindow(navigation_settings=MemorySettings())
+ window.resize(1440, 900)
+ window.show()
+ window._tabs.setCurrentIndex(window.primary_tab_ids().index("flight_explorer"))
+ tab = window._flight_explorer_tab
+ if tab.run_now() is None:
+ raise RuntimeError("Flight probe could not produce an accepted flight")
+ canvas = tab.flight_view()._canvas
+ canvas.setFocus()
+ QTest.keyClick(canvas, Qt.Key.Key_Home)
+ accepted = tab.accepted_study()
+ states = [_capture(window, tab, args.output, "selected-result")]
+
+ original = run_module.explore_with_optional_wind
+
+ def fail(*_args: object, **_kwargs: object) -> None:
+ raise OSError("diagnostic flight authority unavailable")
+
+ run_module.explore_with_optional_wind = fail
+ tab._direct_spins["launch_angle_deg"].stepUp()
+ if tab.run_now() is not None or tab.accepted_study() is not accepted:
+ raise RuntimeError("failed flight recompute discarded accepted evidence")
+ states.append(_capture(window, tab, args.output, "error-prior"))
+ run_module.explore_with_optional_wind = original
+ (args.output / "manifest.json").write_text(
+ json.dumps(
+ {
+ "artifact_policy": "diagnostic PNG; semantic tests are authority",
+ "requested_scale": args.scale,
+ "requested_window": [1440, 900],
+ "actual_window": [window.width(), window.height()],
+ "font": font_evidence,
+ "states": states,
+ },
+ indent=2,
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ window.close()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/rate_of_closure/test_flight_accepted_study.py b/tests/rate_of_closure/test_flight_accepted_study.py
new file mode 100644
index 000000000..a5266633f
--- /dev/null
+++ b/tests/rate_of_closure/test_flight_accepted_study.py
@@ -0,0 +1,174 @@
+"""Atomic accepted-flight authority and immutability contracts."""
+
+from __future__ import annotations
+
+from dataclasses import replace
+
+import pytest
+
+from rate_of_closure.flight_accepted_study import (
+ FlightStudyContext,
+ build_accepted_flight_study,
+)
+from rate_of_closure.simulation import (
+ compare_wind,
+ explore_flight,
+ launch_from_delivery,
+ launch_from_direct,
+)
+from shared.python.swing_sim.flight import (
+ LaunchDirectionConvention,
+ WindScenario,
+)
+from shared.python.swing_sim.impact import DeliveryParameters
+
+pytestmark = [pytest.mark.unit, pytest.mark.headless_safe]
+
+
+def _direct_context(
+ scenario: WindScenario | None = None,
+ *,
+ inputs: tuple[tuple[str, float], ...] | None = None,
+) -> FlightStudyContext:
+ values = inputs or (
+ ("ball_speed_mph", 150.0),
+ ("launch_angle_deg", 12.0),
+ ("launch_direction_deg", 3.0),
+ ("spin_rpm", 2700.0),
+ ("spin_axis_tilt_deg", 5.0),
+ )
+ base = launch_from_direct(
+ *(value for _key, value in values),
+ direction_convention=LaunchDirectionConvention.APP_NATIVE,
+ )
+ return FlightStudyContext(
+ "direct",
+ values,
+ LaunchDirectionConvention.APP_NATIVE,
+ "waterloo_penner",
+ scenario,
+ replace(base, wind_speed=0.0, wind_scenario=scenario),
+ )
+
+
+def test_direct_candidate_is_complete_immutable_and_summary_coherent() -> None:
+ context = _direct_context()
+ result = explore_flight(context.expected_launch, context.model_name)
+ accepted = build_accepted_flight_study(1, context, result, None)
+ assert accepted.plan.raw_count == len(result.times)
+ assert accepted.exploration.metrics["carry_m"] == result.metrics["carry_m"]
+ with pytest.raises(ValueError):
+ accepted.exploration.positions.setflags(write=True)
+ with pytest.raises(TypeError):
+ accepted.exploration.metrics["carry_m"] = 0.0
+
+
+def test_context_rederives_direct_inputs_and_rejects_wrong_authority() -> None:
+ context = _direct_context()
+ wrong_inputs = tuple(
+ (key, 149.0 if key == "ball_speed_mph" else value)
+ for key, value in context.input_values
+ )
+ with pytest.raises(ValueError, match="raw flight inputs"):
+ replace(context, input_values=wrong_inputs)
+ with pytest.raises(ValueError, match="legacy scalar wind"):
+ replace(
+ context, expected_launch=replace(context.expected_launch, wind_speed=1.0)
+ )
+ result = explore_flight(context.expected_launch, context.model_name)
+ result.metrics["landing_angle_deg"] += 5.0
+ with pytest.raises(ValueError, match="landing angle"):
+ build_accepted_flight_study(1, context, result, None)
+
+
+def test_raw_origin_landing_floor_and_launch_velocity_are_bound() -> None:
+ context = _direct_context()
+ translated = explore_flight(context.expected_launch, context.model_name)
+ translated.positions[:] += (10.0, 2.0, 3.0)
+ with pytest.raises(ValueError, match="launch downrange position"):
+ build_accepted_flight_study(1, context, translated, None)
+
+ airborne = explore_flight(context.expected_launch, context.model_name)
+ airborne.positions[-1, 1] += 1.0
+ with pytest.raises(ValueError, match="landing height"):
+ build_accepted_flight_study(1, context, airborne, None)
+
+ below_ground = explore_flight(context.expected_launch, context.model_name)
+ below_ground.positions[1, 1] = -1.0
+ with pytest.raises(ValueError, match="ground plane"):
+ build_accepted_flight_study(1, context, below_ground, None)
+
+ forged_velocity = explore_flight(context.expected_launch, context.model_name)
+ forged_velocity.velocities[0] *= 0.5
+ with pytest.raises(ValueError, match="raw launch speed"):
+ build_accepted_flight_study(1, context, forged_velocity, None)
+
+
+def test_delivery_context_includes_lie_and_does_not_confuse_club_and_ball_speed() -> (
+ None
+):
+ delivery = DeliveryParameters(
+ clubhead_speed_mps=50.0,
+ club_path_deg=1.0,
+ face_angle_deg=2.0,
+ attack_angle_deg=-1.0,
+ dynamic_loft_deg=12.0,
+ impact_offset_toe_mm=1.0,
+ impact_offset_high_mm=2.0,
+ lie_deg=1.5,
+ )
+ launch = launch_from_delivery(delivery)
+ inputs = (
+ ("clubhead_speed_mps", 50.0),
+ ("club_path_deg", 1.0),
+ ("face_angle_deg", 2.0),
+ ("attack_angle_deg", -1.0),
+ ("dynamic_loft_deg", 12.0),
+ ("impact_offset_toe_mm", 1.0),
+ ("impact_offset_high_mm", 2.0),
+ ("lie_deg", 1.5),
+ )
+ context = FlightStudyContext(
+ "delivery",
+ inputs,
+ LaunchDirectionConvention.APP_NATIVE,
+ "waterloo_penner",
+ None,
+ launch,
+ )
+ accepted = build_accepted_flight_study(
+ 1, context, explore_flight(launch, "waterloo_penner"), None
+ )
+ assert accepted.context.input_values[-1] == ("lie_deg", 1.5)
+
+
+def test_wind_pair_is_snapshotted_and_deltas_are_recomputed() -> None:
+ scenario = WindScenario.from_meteorological(5.0, 90.0)
+ context = _direct_context(scenario)
+ comparison = compare_wind(
+ replace(context.expected_launch, wind_scenario=None),
+ scenario,
+ context.model_name,
+ )
+ accepted = build_accepted_flight_study(2, context, comparison.wind, comparison)
+ before = accepted.comparison.deltas["carry_m"]
+ comparison.deltas["carry_m"] = 999.0
+ comparison.wind.positions[0, 0] = 999.0
+ assert accepted.comparison.deltas["carry_m"] == before
+ assert accepted.exploration.positions[0, 0] != 999.0
+ forged = compare_wind(
+ replace(context.expected_launch, wind_scenario=None),
+ scenario,
+ context.model_name,
+ )
+ forged.deltas["carry_m"] += 1.0
+ with pytest.raises(ValueError, match="wind carry_m"):
+ build_accepted_flight_study(3, context, forged.wind, forged)
+
+
+@pytest.mark.parametrize("generation", [True, 0, 2**53])
+def test_generation_must_be_a_positive_safe_integer(generation: object) -> None:
+ context = _direct_context()
+ result = explore_flight(context.expected_launch)
+ with pytest.raises(ValueError):
+ build_accepted_flight_study(generation, context, result, None)
diff --git a/tests/rate_of_closure/test_flight_explorer.py b/tests/rate_of_closure/test_flight_explorer.py
index 83be6ef92..f48a1ca3f 100644
--- a/tests/rate_of_closure/test_flight_explorer.py
+++ b/tests/rate_of_closure/test_flight_explorer.py
@@ -12,6 +12,7 @@
import numpy as np
import pytest
+from rate_of_closure._contracts import PreconditionError
from rate_of_closure.simulation import (
BALL_POSITION_M,
EXPLORER_METRIC_KEYS,
@@ -78,6 +79,23 @@ def test_rejects_nonpositive_ball_speed(self) -> None:
with pytest.raises(Exception, match="ball_speed"):
launch_from_direct(0.0, 12.0, 0.0, 2500.0, 0.0)
+ @pytest.mark.parametrize(
+ "values",
+ [
+ (250.01, 12.0, 0.0, 2500.0, 0.0),
+ (150.0, 89.01, 0.0, 2500.0, 0.0),
+ (150.0, 12.0, 45.01, 2500.0, 0.0),
+ (150.0, 12.0, 0.0, 15001.0, 0.0),
+ (150.0, 12.0, 0.0, 2500.0, float("nan")),
+ (True, 12.0, 0.0, 2500.0, 0.0),
+ ],
+ )
+ def test_enforces_finite_direct_entry_domains(
+ self, values: tuple[object, ...]
+ ) -> None:
+ with pytest.raises(PreconditionError):
+ launch_from_direct(*values)
+
class TestLaunchFromDelivery:
def test_square_driver_delivery_pins(self) -> None:
diff --git a/tests/rate_of_closure/test_flight_explorer_atomic_gui.py b/tests/rate_of_closure/test_flight_explorer_atomic_gui.py
new file mode 100644
index 000000000..5debde4c7
--- /dev/null
+++ b/tests/rate_of_closure/test_flight_explorer_atomic_gui.py
@@ -0,0 +1,182 @@
+"""First-failure and no-recompute checks for the PyQt flight explorer."""
+
+from __future__ import annotations
+
+import numpy as np
+from PyQt6.QtCore import Qt
+
+import rate_of_closure.ui.pyqt6.flight_explorer_run as run_module
+from rate_of_closure.ui.pyqt6.flight_explorer_tab import FlightExplorerTab
+
+
+def test_first_execution_failure_is_honest_empty_and_bounded(
+ qtbot, monkeypatch
+) -> None: # type: ignore[no-untyped-def]
+ def fail_execution(*_args: object) -> None:
+ raise OSError("\x00" + "planted first execution failure " * 30)
+
+ monkeypatch.setattr(run_module, "explore_with_optional_wind", fail_execution)
+ tab = FlightExplorerTab()
+ qtbot.addWidget(tab)
+ tab.show()
+ assert tab.run_now() is None
+ assert tab.accepted_study() is None
+ assert len(tab.flight_view().trajectory()) == 0
+ assert not tab._flight_panel.controls.play_button.isEnabled()
+ assert not tab._flight_panel.controls.landing_button.isEnabled()
+ assert "No accepted flight is available" in tab._error_status.text()
+ assert "remains displayed" not in tab._error_status.text()
+ assert "\x00" not in tab._error_status.text()
+ assert len(tab._error_status.text()) <= 240
+
+
+def test_selection_playback_units_and_display_do_not_rerun_solver(
+ qtbot, monkeypatch
+) -> None: # type: ignore[no-untyped-def]
+ tab = FlightExplorerTab()
+ qtbot.addWidget(tab)
+ tab.show()
+ assert tab.run_now() is not None
+ calls = 0
+ original = run_module.explore_with_optional_wind
+
+ def count_execution(*args: object): # type: ignore[no-untyped-def]
+ nonlocal calls
+ calls += 1
+ return original(*args)
+
+ monkeypatch.setattr(run_module, "explore_with_optional_wind", count_execution)
+ canvas = tab.flight_view()._canvas
+ canvas.setFocus()
+ qtbot.keyClick(canvas, Qt.Key.Key_Home)
+ tab._flight_panel.controls.jump_to_landing()
+ tab._speed_unit_combo.setCurrentText("m/s")
+ tab.flight_view().display_check("top").setChecked(False)
+ tab.flight_view().display_check("top").setChecked(True)
+ assert calls == 0
+
+
+def test_post_render_ui_failure_restores_complete_prior_publication(
+ qtbot, monkeypatch
+) -> None: # type: ignore[no-untyped-def]
+ tab = FlightExplorerTab()
+ qtbot.addWidget(tab)
+ tab.show()
+ assert tab.run_now() is not None
+ accepted = tab.accepted_study()
+ assert accepted is not None
+ view = tab.flight_view()
+ canvas = view._canvas
+ canvas.setFocus()
+ qtbot.keyClick(canvas, Qt.Key.Key_End)
+ prior_view = view.trajectory().copy()
+ prior_target = tab._target_workflow._positions.copy()
+ prior_selection = view.selected_raw_index()
+ prior_time = tab._flight_panel.controls.current_time_s()
+ prior_rows = {key: row.value_label.text() for key, row in tab._rows.items()}
+ prior_deltas = tab.wind_controls.delta_texts()
+ prior_sample = tab._sample_status.text()
+
+ tab._direct_spins["launch_angle_deg"].setValue(
+ tab._direct_spins["launch_angle_deg"].value() + 1.0
+ )
+ prior_context = tab._context_status.text()
+
+ def fail_comparison(_comparison: object) -> None:
+ raise RuntimeError("planted post-render comparison failure")
+
+ monkeypatch.setattr(tab.wind_controls, "set_comparison", fail_comparison)
+ assert tab.run_now() is None
+ assert tab.accepted_study() is accepted
+ assert tab._generation == accepted.generation
+ assert tab.last_exploration() is accepted.exploration
+ assert tab.wind_comparison is accepted.comparison
+ assert np.array_equal(view.trajectory(), prior_view)
+ assert np.array_equal(tab._target_workflow._positions, prior_target)
+ assert view.selected_raw_index() == prior_selection
+ assert tab._flight_panel.controls.current_time_s() == prior_time
+ assert {key: row.value_label.text() for key, row in tab._rows.items()} == prior_rows
+ assert tab.wind_controls.delta_texts() == prior_deltas
+ assert tab._context_status.text() == prior_context
+ assert tab._sample_status.text() == prior_sample
+ assert "planted post-render comparison failure" in tab._error_status.text()
+ assert "prior accepted flight remains displayed" in tab._error_status.text().lower()
+
+
+def test_nested_view_rollback_forces_prior_authority_when_pixels_are_stale(
+ qtbot, monkeypatch
+) -> None: # type: ignore[no-untyped-def]
+ tab = FlightExplorerTab()
+ qtbot.addWidget(tab)
+ tab.show()
+ assert tab.run_now() is not None
+ accepted = tab.accepted_study()
+ assert accepted is not None
+ view = tab.flight_view()
+ canvas = view._canvas
+ canvas.setFocus()
+ qtbot.keyClick(canvas, Qt.Key.Key_End)
+ prior_selection = view.selected_raw_index()
+ prior_time = tab._flight_panel.controls.current_time_s()
+ tab._direct_spins["launch_angle_deg"].setValue(
+ tab._direct_spins["launch_angle_deg"].value() + 1.0
+ )
+ original_draw = canvas.draw
+ draw_calls = 0
+
+ def staged_draw() -> None:
+ nonlocal draw_calls
+ draw_calls += 1
+ original_draw()
+ if draw_calls == 2:
+ raise RuntimeError("planted prior repaint failure")
+
+ def fail_comparison(_comparison: object) -> None:
+ raise RuntimeError("planted post-render comparison failure")
+
+ monkeypatch.setattr(canvas, "draw", staged_draw)
+ monkeypatch.setattr(tab.wind_controls, "set_comparison", fail_comparison)
+ assert tab.run_now() is None
+ assert draw_calls == 3
+ assert tab.accepted_study() is accepted
+ assert np.array_equal(view.trajectory(), accepted.exploration.positions)
+ assert view._sample_plan is accepted.plan
+ assert view.selected_raw_index() == prior_selection
+ assert tab._flight_panel.controls.current_time_s() == prior_time
+ assert not canvas.has_pending_draw()
+ assert "plot restoration failed" in tab._error_status.text().lower()
+ assert "image may be stale" in tab._error_status.text().lower()
+
+
+def test_post_render_failure_force_restores_target_without_recomputation(
+ qtbot, monkeypatch
+) -> None: # type: ignore[no-untyped-def]
+ tab = FlightExplorerTab()
+ qtbot.addWidget(tab)
+ tab.show()
+ assert tab.run_now() is not None
+ accepted = tab.accepted_study()
+ assert accepted is not None
+ prior_target = tab._target_workflow._positions.copy()
+ prior_miss = tab._target_workflow._panel.miss_label().text()
+ tab._direct_spins["launch_angle_deg"].setValue(
+ tab._direct_spins["launch_angle_deg"].value() + 1.0
+ )
+ original_refresh = tab._target_workflow._refresh_miss
+
+ def fail_if_prior_is_recomputed() -> None:
+ if np.array_equal(tab._target_workflow._positions, prior_target):
+ raise RuntimeError("planted prior target refresh failure")
+ original_refresh()
+
+ def fail_comparison(_comparison: object) -> None:
+ raise RuntimeError("planted post-render comparison failure")
+
+ monkeypatch.setattr(
+ tab._target_workflow, "_refresh_miss", fail_if_prior_is_recomputed
+ )
+ monkeypatch.setattr(tab.wind_controls, "set_comparison", fail_comparison)
+ assert tab.run_now() is None
+ assert tab.accepted_study() is accepted
+ assert np.array_equal(tab._target_workflow._positions, prior_target)
+ assert tab._target_workflow._panel.miss_label().text() == prior_miss
diff --git a/tests/rate_of_closure/test_flight_sample_inspector.py b/tests/rate_of_closure/test_flight_sample_inspector.py
new file mode 100644
index 000000000..2fe7e92ba
--- /dev/null
+++ b/tests/rate_of_closure/test_flight_sample_inspector.py
@@ -0,0 +1,137 @@
+"""Contracts for exact synchronized flight sample inspection."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+from rate_of_closure.flight_sample_inspector import (
+ FlightSampleSelection,
+ FlightSampleSeries,
+ navigate_flight_samples,
+ nearest_flight_sample,
+ plan_flight_samples,
+)
+
+pytestmark = [pytest.mark.unit, pytest.mark.headless_safe]
+
+
+def _golden() -> dict[str, object]:
+ path = Path(__file__).parents[2] / (
+ "src/rate_of_closure/web/src/model/__fixtures__/"
+ "flight_sample_inspector_golden_v1.json"
+ )
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def test_python_owned_golden_pins_phase_navigation_and_pixel_tie() -> None:
+ fixture = _golden()
+ assert set(fixture) == {"schema_id", "schema_version", "series", "expected"}
+ assert fixture["schema_id"] == "rate-of-closure/flight-sample-inspector-golden"
+ assert fixture["schema_version"] == 1
+ source, expected = fixture["series"], fixture["expected"]
+ assert isinstance(source, dict) and isinstance(expected, dict)
+ plan = plan_flight_samples(FlightSampleSeries(**source))
+ assert plan.apex_raw_index == expected["apex_raw_index"]
+ assert [sample.phase for sample in plan.samples] == expected["phases"]
+ for command, raw_index in expected["navigation_from_3"].items():
+ assert navigate_flight_samples(plan, 3, command) == raw_index
+ nearest = expected["nearest"]
+ selection = nearest_flight_sample(
+ plan,
+ [tuple(item) for item in nearest["projected"]],
+ tuple(nearest["pointer"]),
+ hit_radius_px=nearest["hit_radius_px"],
+ )
+ assert selection is not None
+ assert {"cohort": selection.cohort, "raw_index": selection.raw_index} == nearest[
+ "selection"
+ ]
+
+
+@pytest.mark.parametrize(
+ ("times", "positions"),
+ [
+ ([0.0], [[0.0, 0.0, 0.0]]),
+ ([0.0, 0.0], [[0.0, 0.0, 0.0]] * 2),
+ ([0.0, 0.1], [[0.0, 0.0], [1.0, 0.0]]),
+ ([0.0, 0.1], [[0.0, float("nan"), 0.0], [1.0, 0.0, 0.0]]),
+ ],
+)
+def test_malformed_sample_evidence_fails_closed(
+ times: object, positions: object
+) -> None:
+ with pytest.raises(ValueError):
+ plan_flight_samples(FlightSampleSeries(times, positions))
+
+
+def test_plan_snapshots_mutable_inputs_and_rejects_over_cap() -> None:
+ times = [0.0, 0.1]
+ positions = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]
+ plan = plan_flight_samples(FlightSampleSeries(times, positions))
+ times[1] = 9.0
+ positions[1][0] = 9.0
+ assert plan.raw_sample(1).time_s == 0.1
+ assert plan.raw_sample(1).downrange_m == 1.0
+ with pytest.raises(ValueError):
+ FlightSampleSeries(range(1003), [[0.0, 0.0, 0.0]] * 1003)
+ with pytest.raises(ValueError):
+ FlightSampleSelection("current", -1)
+
+
+def test_exact_cap_is_accepted_without_decimation() -> None:
+ plan = plan_flight_samples(
+ FlightSampleSeries(
+ tuple(index * 0.001 for index in range(1002)),
+ ((0.0, 0.0, 0.0),) * 1002,
+ )
+ )
+ assert plan.raw_count == 1002
+
+
+def test_picker_is_plan_bound_and_calm_comparison_is_not_selectable() -> None:
+ plan = plan_flight_samples(
+ FlightSampleSeries([0.0, 0.1], [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]])
+ )
+ with pytest.raises(ValueError):
+ nearest_flight_sample(
+ plan, [("calm", 0, 0.0, 0.0), ("calm", 1, 1.0, 0.0)], (0.0, 0.0)
+ )
+ with pytest.raises(ValueError):
+ nearest_flight_sample(plan, [("current", 0, 0.0, 0.0)], (0.0, 0.0))
+ with pytest.raises(ValueError):
+ nearest_flight_sample(
+ plan,
+ [("current", 0, 0.0, 0.0), ("current", 0, 1.0, 0.0)],
+ (0.0, 0.0),
+ )
+
+
+def test_coincident_phase_precedence_is_honest_for_edge_trajectories() -> None:
+ descending = plan_flight_samples(
+ FlightSampleSeries([0.0, 1.0], [[0.0, 2.0, 0.0], [1.0, 0.0, 0.0]])
+ )
+ rising = plan_flight_samples(
+ FlightSampleSeries([0.0, 1.0], [[0.0, 0.0, 0.0], [1.0, 2.0, 0.0]])
+ )
+ assert [sample.phase for sample in descending.samples] == ["launch", "landing"]
+ assert [sample.phase for sample in rising.samples] == ["launch", "landing"]
+ assert descending.apex_raw_index == 0
+ assert rising.apex_raw_index == 1
+
+
+@pytest.mark.parametrize(
+ ("times", "positions"),
+ [
+ ([0.0, 10.001001], [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]),
+ ([0.0, 1.0], [[0.0, 0.0, 0.0], [10_000.001, 0.0, 0.0]]),
+ ([0.0, 1.0], [[0.0, 0.0, 0.0], [float("1e308"), 0.0, 0.0]]),
+ ],
+)
+def test_finite_but_unrenderable_evidence_fails_closed(
+ times: object, positions: object
+) -> None:
+ with pytest.raises(ValueError):
+ FlightSampleSeries(times, positions)
diff --git a/tests/rate_of_closure/test_flight_sample_inspector_gui.py b/tests/rate_of_closure/test_flight_sample_inspector_gui.py
new file mode 100644
index 000000000..8e3372114
--- /dev/null
+++ b/tests/rate_of_closure/test_flight_sample_inspector_gui.py
@@ -0,0 +1,390 @@
+"""Rendered interaction and transaction tests for the PyQt flight inspector."""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+from matplotlib.backend_bases import MouseButton, MouseEvent
+from PyQt6.QtCore import Qt
+
+from rate_of_closure.ui.pyqt6.flight_explorer_tab import FlightExplorerTab
+from rate_of_closure.units import SPEED_UNITS
+from shared.python.swing_sim.flight import LaunchDirectionConvention
+
+
+@pytest.fixture
+def accepted_tab(qtbot): # type: ignore[no-untyped-def]
+ tab = FlightExplorerTab()
+ qtbot.addWidget(tab)
+ tab.resize(1200, 760)
+ tab.show()
+ assert tab.run_now() is not None
+ return tab
+
+
+def test_keyboard_selection_drives_the_single_playback_owner(
+ accepted_tab, qtbot
+) -> None: # type: ignore[no-untyped-def]
+ tab = accepted_tab
+ view = tab.flight_view()
+ canvas = view._canvas
+ controls = tab._flight_panel.controls
+ accepted = tab.accepted_study()
+ assert accepted is not None
+ assert canvas.accessibleName() == "Flight trajectory sample inspector"
+ assert "Home and End" in canvas.accessibleDescription()
+
+ canvas.setFocus()
+ qtbot.keyClick(canvas, Qt.Key.Key_Home)
+ assert view.selected_raw_index() == 0
+ assert controls.current_time_s() == pytest.approx(
+ accepted.plan.raw_sample(0).time_s
+ )
+ assert "source sample 1/" in tab._sample_status.text()
+
+ controls.jump_to_landing()
+ assert controls.current_time_s() == pytest.approx(controls._duration_s)
+ qtbot.keyClick(canvas, Qt.Key.Key_Home)
+ assert controls.current_time_s() == pytest.approx(0.0)
+ assert canvas.hasFocus()
+
+
+@pytest.mark.parametrize("dpi_scale", [1.0, 1.5])
+def test_pointer_selects_primary_but_ignores_distinct_calm_and_miss(
+ qtbot, dpi_scale: float
+) -> None: # type: ignore[no-untyped-def]
+ tab = FlightExplorerTab()
+ qtbot.addWidget(tab)
+ tab.resize(1200, 760)
+ tab.show()
+ tab.wind_controls.enabled_check.setChecked(True)
+ tab.wind_controls.speed_spin.setValue(100.0)
+ tab.wind_controls.bearing_spin.setValue(90.0)
+ assert tab.run_now() is not None
+ accepted = tab.accepted_study()
+ assert accepted is not None and accepted.calm_comparison is not None
+ view = tab.flight_view()
+ canvas = view._canvas
+ view._figure.set_dpi(100 * dpi_scale)
+ view._draw(sync=True)
+ assert not canvas.has_pending_draw()
+ tab.activateWindow()
+ canvas.setFocus()
+ qtbot.waitUntil(canvas.hasFocus)
+ axes = view._inspector_axes["top"]
+ current_pixels = np.array(
+ [
+ axes.transData.transform((sample.downrange_m, sample.right_m))
+ for sample in accepted.plan.samples
+ ]
+ )
+ primary_index = 0
+ primary_x, primary_y = current_pixels[primary_index]
+ canvas.callbacks.process(
+ "button_press_event",
+ MouseEvent(
+ "button_press_event",
+ canvas,
+ primary_x,
+ primary_y,
+ button=MouseButton.LEFT,
+ ),
+ )
+ assert view.selected_raw_index() == primary_index
+ assert canvas.hasFocus()
+ selected_time = tab._flight_panel.controls.current_time_s()
+
+ calm_pixels = np.array(
+ [
+ axes.transData.transform((position[0], position[2]))
+ for position in accepted.calm_comparison.positions
+ ]
+ )
+ calm_distances = np.min(
+ np.linalg.norm(calm_pixels[:, None, :] - current_pixels[None, :, :], axis=2),
+ axis=1,
+ )
+ calm_x, calm_y = calm_pixels[int(np.argmax(calm_distances))]
+ assert float(np.max(calm_distances)) > 12.0
+ canvas.callbacks.process(
+ "button_press_event",
+ MouseEvent(
+ "button_press_event",
+ canvas,
+ calm_x,
+ calm_y,
+ button=MouseButton.LEFT,
+ ),
+ )
+ assert view.selected_raw_index() == primary_index
+ assert tab._flight_panel.controls.current_time_s() == pytest.approx(selected_time)
+
+ corners = np.array(
+ [
+ [axes.bbox.xmin + 2, axes.bbox.ymin + 2],
+ [axes.bbox.xmax - 2, axes.bbox.ymax - 2],
+ ]
+ )
+ miss_distances = np.min(
+ np.linalg.norm(corners[:, None, :] - current_pixels[None, :, :], axis=2),
+ axis=1,
+ )
+ miss_x, miss_y = corners[int(np.argmax(miss_distances))]
+ assert float(np.max(miss_distances)) > 12.0
+ canvas.callbacks.process(
+ "button_press_event",
+ MouseEvent(
+ "button_press_event",
+ canvas,
+ miss_x,
+ miss_y,
+ button=MouseButton.LEFT,
+ ),
+ )
+ assert view.selected_raw_index() == primary_index
+ assert canvas.hasFocus()
+
+
+def test_target_post_assignment_failure_retains_every_accepted_owner(
+ accepted_tab, monkeypatch
+) -> None: # type: ignore[no-untyped-def]
+ tab = accepted_tab
+ accepted = tab.accepted_study()
+ assert accepted is not None
+ old_view = tab.flight_view().trajectory().copy()
+ old_target = tab._target_workflow._positions.copy()
+ old_miss = tab._spatial_target_panel.miss_label().text()
+ tab._flight_panel.controls.jump_to_landing()
+ old_time = tab._flight_panel.controls.current_time_s()
+ tab._direct_spins["launch_angle_deg"].setValue(12.0)
+
+ def fail_after_assignment() -> None:
+ raise RuntimeError("planted target residual failure")
+
+ monkeypatch.setattr(tab._target_workflow, "_refresh_miss", fail_after_assignment)
+ assert tab.run_now() is None
+ assert tab.accepted_study() is accepted
+ assert np.array_equal(tab.flight_view().trajectory(), old_view)
+ assert np.array_equal(tab._target_workflow._positions, old_target)
+ assert tab._spatial_target_panel.miss_label().text() == old_miss
+ assert tab._flight_panel.controls.current_time_s() == pytest.approx(old_time)
+ assert "prior accepted flight remains displayed" in tab._error_status.text().lower()
+
+
+def test_view_render_failure_rolls_back_target_playback_and_authority(
+ accepted_tab, monkeypatch
+) -> None: # type: ignore[no-untyped-def]
+ tab = accepted_tab
+ view = tab.flight_view()
+ accepted = tab.accepted_study()
+ assert accepted is not None
+ old_view = view.trajectory().copy()
+ old_target = tab._target_workflow._positions.copy()
+ old_miss = tab._spatial_target_panel.miss_label().text()
+ tab._flight_panel.controls.jump_to_landing()
+ old_time = tab._flight_panel.controls.current_time_s()
+ tab._direct_spins["launch_angle_deg"].setValue(12.0)
+ original_draw = view._draw
+ calls = 0
+
+ def fail_after_drawing_candidate(*, sync: bool = False) -> None:
+ nonlocal calls
+ calls += 1
+ original_draw(sync=sync)
+ if calls == 1:
+ raise RuntimeError("planted view publication failure")
+
+ monkeypatch.setattr(view, "_draw", fail_after_drawing_candidate)
+ assert tab.run_now() is None
+ assert tab.accepted_study() is accepted
+ assert np.array_equal(view.trajectory(), old_view)
+ assert np.array_equal(tab._target_workflow._positions, old_target)
+ assert tab._spatial_target_panel.miss_label().text() == old_miss
+ assert tab._flight_panel.controls.current_time_s() == pytest.approx(old_time)
+
+
+def test_double_view_failure_reports_unrestored_pixels_honestly(
+ accepted_tab, qtbot, monkeypatch
+) -> None: # type: ignore[no-untyped-def]
+ tab = accepted_tab
+ view = tab.flight_view()
+ accepted = tab.accepted_study()
+ assert accepted is not None
+ original_canvas_draw = view._canvas.draw
+ calls = 0
+
+ def publish_then_fail_and_block_rollback() -> None:
+ nonlocal calls
+ calls += 1
+ if calls == 1:
+ original_canvas_draw()
+ raise RuntimeError("candidate pixels published")
+ raise RuntimeError("prior pixels could not be restored")
+
+ tab._direct_spins["launch_angle_deg"].setValue(12.0)
+ monkeypatch.setattr(view._canvas, "draw", publish_then_fail_and_block_rollback)
+ assert tab.run_now() is None
+ assert tab.accepted_study() is accepted
+ assert calls == 2
+ assert "prior accepted authority is retained" in tab._error_status.text().lower()
+ assert "image may be stale or unavailable" in tab._error_status.text().lower()
+ assert "remains displayed" not in tab._error_status.text().lower()
+ assert not view._canvas.has_pending_draw()
+ monkeypatch.undo()
+ assert tab.run_now() is not None
+ assert tab.accepted_study() is not accepted
+ assert tab._error_status.text() == ""
+ qtbot.waitUntil(lambda: not view._canvas.has_pending_draw())
+ view._canvas.draw_idle()
+ assert view._canvas.has_pending_draw()
+ view._canvas.cancel_pending_draw()
+ assert not view._canvas.has_pending_draw()
+
+
+def test_selection_render_failure_recovers_without_erasing_scientific_errors(
+ accepted_tab, qtbot, monkeypatch
+) -> None: # type: ignore[no-untyped-def]
+ tab = accepted_tab
+ view = tab.flight_view()
+ canvas = view._canvas
+ controls = tab._flight_panel.controls
+ canvas.setFocus()
+ qtbot.keyClick(canvas, Qt.Key.Key_Home)
+ original_draw = view._draw
+ calls = 0
+
+ def fail_once_after_drawing(*, sync: bool = False) -> None:
+ nonlocal calls
+ calls += 1
+ original_draw(sync=sync)
+ if calls == 1:
+ raise RuntimeError("planted selection render failure")
+
+ monkeypatch.setattr(view, "_draw", fail_once_after_drawing)
+ qtbot.keyClick(canvas, Qt.Key.Key_End)
+ assert view.selected_raw_index() == 0
+ assert controls.current_time_s() == pytest.approx(0.0)
+ assert tab._error_origin == "selection"
+ assert tab._error_status.text()
+
+ qtbot.keyClick(canvas, Qt.Key.Key_End)
+ accepted = tab.accepted_study()
+ assert accepted is not None
+ assert view.selected_raw_index() == accepted.plan.raw_count - 1
+ assert controls.current_time_s() == pytest.approx(controls._duration_s)
+ assert tab._error_status.text() == ""
+ assert tab._error_origin is None
+
+ tab._show_error(RuntimeError("retained scientific failure"))
+ qtbot.keyClick(canvas, Qt.Key.Key_Home)
+ assert tab._error_status.text()
+ assert tab._error_origin == "scientific"
+
+
+def test_double_selection_failure_warns_that_marker_pixels_may_be_stale(
+ accepted_tab, qtbot, monkeypatch
+) -> None: # type: ignore[no-untyped-def]
+ tab = accepted_tab
+ view = tab.flight_view()
+ canvas = view._canvas
+ controls = tab._flight_panel.controls
+ canvas.setFocus()
+ qtbot.keyClick(canvas, Qt.Key.Key_Home)
+ original_canvas_draw = canvas.draw
+ calls = 0
+
+ def publish_then_fail_and_block_rollback() -> None:
+ nonlocal calls
+ calls += 1
+ if calls == 1:
+ original_canvas_draw()
+ raise RuntimeError("candidate marker pixels published")
+ raise RuntimeError("prior marker pixels could not be restored")
+
+ monkeypatch.setattr(canvas, "draw", publish_then_fail_and_block_rollback)
+ qtbot.keyClick(canvas, Qt.Key.Key_End)
+ assert view.selected_raw_index() == 0
+ assert controls.current_time_s() == pytest.approx(0.0)
+ assert calls == 2
+ assert "plot restoration failed" in tab._error_status.text().lower()
+ assert "image may be stale or unavailable" in tab._error_status.text().lower()
+ assert not canvas.has_pending_draw()
+ monkeypatch.undo()
+ qtbot.keyClick(canvas, Qt.Key.Key_End)
+ accepted = tab.accepted_study()
+ assert accepted is not None
+ assert view.selected_raw_index() == accepted.plan.raw_count - 1
+ assert tab._error_status.text() == ""
+ qtbot.waitUntil(lambda: not canvas.has_pending_draw())
+
+
+def test_public_bundle_identity_and_delivery_ignore_direction_presentation(
+ accepted_tab,
+) -> None: # type: ignore[no-untyped-def]
+ tab = accepted_tab
+ first = tab.accepted_study()
+ assert first is not None
+ assert tab.last_exploration() is first.exploration
+
+ tab._mode_combo.setCurrentIndex(1)
+ tab._speed_spin.setValue(112.0)
+ assert tab.run_now() is not None
+ delivery = tab.accepted_study()
+ assert delivery is not None and delivery is not first
+ assert delivery.generation == first.generation + 1
+ assert delivery.context.direction_convention is LaunchDirectionConvention.APP_NATIVE
+ displayed = tab._context_status.text()
+
+ tab._direction_convention_combo.setCurrentIndex(1)
+ assert tab._context_status.text() == displayed
+ assert "trackman" not in delivery.context.label().lower()
+
+
+def test_speed_unit_switch_preserves_canonical_value_and_domain(accepted_tab) -> None: # type: ignore[no-untyped-def]
+ tab = accepted_tab
+ before = tab.speed_mps()
+ before_mph = tab.speed_mph()
+ before_accepted = tab.accepted_study()
+ assert before_accepted is not None
+ displayed = tab._context_status.text()
+ for unit, factor in SPEED_UNITS.items():
+ tab._speed_unit_combo.setCurrentText(unit)
+ assert tab.speed_mps() == before
+ assert tab.speed_mph() == before_mph
+ assert tab._context_status.text() == displayed
+ assert tab._speed_spin.minimum() == pytest.approx(1.0 / factor)
+ assert tab._speed_spin.maximum() == pytest.approx(250.0 / factor)
+ assert tab._speed_spin.value() == pytest.approx(before_mph / factor)
+ assert tab.run_now() is not None
+ accepted = tab.accepted_study()
+ assert accepted is not None
+ assert (
+ accepted.context.expected_launch.ball_speed
+ == before_accepted.context.expected_launch.ball_speed
+ )
+ for unit, factor in SPEED_UNITS.items():
+ tab._speed_unit_combo.setCurrentText(unit)
+ tab._speed_spin.setValue(100.0 / factor)
+ assert tab.speed_mph() == pytest.approx(tab._speed_spin.value() * factor)
+
+
+def test_active_scientific_spin_changes_mark_prior_without_waiting_for_blur(
+ accepted_tab,
+) -> None: # type: ignore[no-untyped-def]
+ tab = accepted_tab
+ tab._direct_spins["launch_angle_deg"].stepUp()
+ assert tab._context_status.text().startswith("Prior result — inputs changed:")
+
+ assert tab.run_now() is not None
+ tab.wind_controls.speed_spin.stepUp()
+ assert tab._context_status.text().startswith("Displayed flight:")
+ tab.wind_controls.enabled_check.setChecked(True)
+ assert tab.run_now() is not None
+ tab.wind_controls.speed_spin.stepUp()
+ assert tab._context_status.text().startswith("Prior result — inputs changed:")
+
+ tab.wind_controls.enabled_check.setChecked(False)
+ tab._mode_combo.setCurrentIndex(1)
+ assert tab.run_now() is not None
+ tab._delivery_spins["dynamic_loft_deg"].stepUp()
+ assert tab._context_status.text().startswith("Prior result — inputs changed:")
diff --git a/tests/rate_of_closure/test_pyqt_flight_sample_inspector_rendered.py b/tests/rate_of_closure/test_pyqt_flight_sample_inspector_rendered.py
new file mode 100644
index 000000000..3c8ae82d9
--- /dev/null
+++ b/tests/rate_of_closure/test_pyqt_flight_sample_inspector_rendered.py
@@ -0,0 +1,76 @@
+"""Rendered Flight inspector evidence at both supported DPI scales."""
+
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+pytest.importorskip("PyQt6")
+pytestmark = [pytest.mark.unit, pytest.mark.headless_safe]
+
+
+@pytest.mark.parametrize("scale", [1.0, 1.5])
+def test_flight_selected_and_error_prior_are_visible(
+ tmp_path: Path, scale: float
+) -> None:
+ output_root = Path(os.environ.get("RATE_PYQT_EVIDENCE_DIR", str(tmp_path)))
+ output = output_root / f"flight-inspector-scale-{scale:g}"
+ environment = dict(os.environ)
+ repository = Path(__file__).resolve().parents[2]
+ environment.update(
+ {
+ "QT_QPA_PLATFORM": "offscreen",
+ "QT_SCALE_FACTOR": str(scale),
+ "MPLBACKEND": "qtagg",
+ "PYTHONPATH": os.pathsep.join((str(repository / "src"), str(repository))),
+ }
+ )
+ subprocess.run(
+ [
+ sys.executable,
+ str(Path(__file__).with_name("pyqt_flight_sample_inspector_probe.py")),
+ "--output",
+ str(output),
+ "--scale",
+ str(scale),
+ ],
+ check=True,
+ env=environment,
+ timeout=90,
+ )
+ manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8"))
+ assert manifest["artifact_policy"].startswith("diagnostic PNG")
+ assert manifest["requested_scale"] == scale
+ assert manifest["font"]["font_id"] >= 0
+ assert manifest["font"]["font_family"] == "DejaVu Sans"
+ assert manifest["font"]["font_ascii_supported"]
+ assert [state["state"] for state in manifest["states"]] == [
+ "selected-result",
+ "error-prior",
+ ]
+ selected, error_prior = manifest["states"]
+ for state in manifest["states"]:
+ assert state["window_bytes"] > 10_000
+ assert state["canvas_bytes"] > 10_000
+ assert state["visible_visual"][2] >= 240
+ assert state["visible_visual"][3] >= 240
+ assert state["visible_visual"][2] <= state["tab_size"][0]
+ assert state["visible_visual"][3] <= state["tab_size"][1]
+ assert not state["control_overlap"]
+ assert state["canvas_has_focus"]
+ assert state["device_pixel_ratio"] == pytest.approx(scale, rel=0.02)
+ assert state["selected_raw_index"] == 0
+ assert state["playback_time_s"] == 0.0
+ assert "source sample 1/" in state["status"].lower()
+ assert state["context"]
+ assert selected["accepted_generation"] == error_prior["accepted_generation"]
+ assert selected["accepted_context"] == error_prior["accepted_context"]
+ assert selected["raw_sample_count"] == error_prior["raw_sample_count"]
+ assert selected["canvas_sha256"] == error_prior["canvas_sha256"]
+ assert selected["error"] == ""
+ assert "prior accepted flight remains displayed" in error_prior["error"].lower()
diff --git a/tests/rate_of_closure/test_visualization_tab_manifest.py b/tests/rate_of_closure/test_visualization_tab_manifest.py
index aca0a2b19..f0cdb2cb2 100644
--- a/tests/rate_of_closure/test_visualization_tab_manifest.py
+++ b/tests/rate_of_closure/test_visualization_tab_manifest.py
@@ -62,6 +62,32 @@ def test_manifest_v1_covers_every_registered_pyqt_tab() -> None:
)
+def test_flight_manifest_names_synchronous_atomic_inspector_states() -> None:
+ manifest = load_visualization_tab_manifest()
+ react = next(
+ entry
+ for entry in manifest.tabs
+ if entry.surface == "react" and entry.tab_id == "flight"
+ )
+ pyqt = next(
+ entry
+ for entry in manifest.tabs
+ if entry.surface == "pyqt" and entry.tab_id == "flight_explorer"
+ )
+ assert react.states == MappingProxyType(
+ {
+ "empty": "placeholder-canvas",
+ "loading": "synchronous",
+ "result": "bounded-synchronized-sample-inspector",
+ "error": "alert-and-prior-or-empty-inspector",
+ }
+ )
+ assert pyqt.states["result"] == "bounded-synchronized-sample-inspector"
+ assert pyqt.states["error"] == (
+ "status-and-prior-or-empty-inspector;stale-warning-on-restoration-failure"
+ )
+
+
def test_governance_rejects_a_missing_or_duplicate_registered_tab() -> None:
manifest = load_visualization_tab_manifest()
pyqt = manifest.for_surface("pyqt")
diff --git a/tests/scripts/test_check_test_assertions.py b/tests/scripts/test_check_test_assertions.py
index 6e7de1c96..812ec35e2 100644
--- a/tests/scripts/test_check_test_assertions.py
+++ b/tests/scripts/test_check_test_assertions.py
@@ -118,6 +118,7 @@ def test_plot_definition_support_exemption_is_exact(tmp_path: Path) -> None:
"pyqt_variation_render_probe.py",
"pyqt_variation_visual_state_probe.py",
"pyqt_putting_sample_inspector_probe.py",
+ "pyqt_flight_sample_inspector_probe.py",
"pyqt_visualization_tab_probe.py",
],
)
From c3b3878f24909f096610645ed2cf036beb5f101b Mon Sep 17 00:00:00 2001
From: codex-scheduled
Date: Thu, 13 Aug 2026 22:05:26 -0700
Subject: [PATCH 2/2] fix(rate): satisfy hosted Flight typing gate
---
AGENT_HANDOFF.md | 9 ++++++
SPEC.md | 13 ++++++--
.../RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md | 10 ++++++
src/rate_of_closure/AGENT_HANDOFF.md | 9 ++++++
src/rate_of_closure/flight_accepted_study.py | 5 ++-
.../ui/pyqt6/flight_explorer_run.py | 3 ++
src/rate_of_closure/ui/pyqt6/flight_view.py | 17 +++++++++-
.../ui/pyqt6/flight_view_inspector.py | 31 ++++++++++++-------
8 files changed, 82 insertions(+), 15 deletions(-)
diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md
index 04dab3a4a..c0fec8117 100644
--- a/AGENT_HANDOFF.md
+++ b/AGENT_HANDOFF.md
@@ -3,6 +3,15 @@
> **Update this file with every PR and every push to main.**
> Last updated: 2026-08-13
+## 2026-08-13 Flight hosted typing closure (#4433)
+
+Version 1.16.94 narrows the immutable NumPy return, keeps Qt signals and
+`eventFilter` on the concrete `FlightView`, and gives the execution mixin
+type-check-only optional result contracts. This closes the hosted Python 3.12 /
+MyPy 1.13 findings without runtime stubs or an MRO change. Flight authority,
+interaction, rendering, diagnostic evidence, and the open #4433 limitations
+are unchanged.
+
## 2026-08-13 Flight synchronized sample inspector (#4433)
Version 1.16.93 adds immutable, generation-bound accepted-flight bundles and
diff --git a/SPEC.md b/SPEC.md
index 4793c185f..1ee1baa80 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -26,12 +26,21 @@
| **Owner** | D-sorganization |
| **Primary Language(s)** | Python 3.11+, Rust, JavaScript, TypeScript |
| **License** | MIT |
-| **Current Version** | 1.16.93 |
-| **Spec Version** | 1.16.93 |
+| **Current Version** | 1.16.94 |
+| **Spec Version** | 1.16.94 |
| **Last Spec Update** | 2026-08-13 |
## 2. Purpose & Mission
+### 2026-08-13 Flight hosted typing closure (#4433)
+
+Version 1.16.94 makes the accepted-flight NumPy snapshot return explicit,
+keeps Qt signals and event filtering on the concrete view, and supplies
+`TYPE_CHECKING`-only optional result fields to the execution mixin. Pinned
+Python 3.12 / MyPy 1.13 accepts all 13 changed Flight source files. Runtime
+science, atomic publication, interaction, artifacts, and open #4433 boundaries
+are unchanged.
+
### 2026-08-13 Flight synchronized sample inspector (#4433)
Version 1.16.93 adds matched React/PyQt inspection of exact runtime-local flight
diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md
index 498034c7f..b5887109f 100644
--- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md
+++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md
@@ -1,5 +1,15 @@
# Rate of Closure Campaign Handoff
+## 2026-08-13 #4433 Flight hosted typing closure
+
+- The accepted-flight NumPy snapshot has an explicit array return, Qt signals
+ and `eventFilter` remain on the concrete viewer, and optional accepted-result
+ fields exist only under `TYPE_CHECKING` in the execution mixin.
+- Pinned Python 3.12 / MyPy 1.13 accepts all 13 changed Flight sources; focused
+ Python/Qt behavior remains 36/36. This is static-only: science, interaction,
+ rendering, diagnostic evidence, and open boundaries are unchanged. SPEC is
+ 1.16.94; #4433 remains open.
+
## 2026-08-13 #4433 Flight synchronized sample inspector
- A Python-owned golden and strict Python/TypeScript twins cap raw evidence at
diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md
index 53dea9bec..4f96bb210 100644
--- a/src/rate_of_closure/AGENT_HANDOFF.md
+++ b/src/rate_of_closure/AGENT_HANDOFF.md
@@ -3,6 +3,15 @@
> **Update this file with every PR and every push to main.**
> Last updated: 2026-08-13
+## 2026-08-13 Flight hosted typing closure (#4433)
+
+- The immutable NumPy return is explicitly narrowed, Qt signal/event ownership
+ stays on the concrete `FlightView`, and the execution mixin's optional result
+ fields are declared under `TYPE_CHECKING` only.
+- Pinned Python 3.12 / MyPy 1.13 accepts all 13 changed Flight sources. This is
+ static-only: runtime MRO, scientific authority, interaction, rendering,
+ artifacts, and open #4433 limits are unchanged. SPEC is 1.16.94.
+
## 2026-08-13 Flight synchronized sample inspector (#4433)
- React direct entry and PyQt direct/delivery snapshot one complete accepted
diff --git a/src/rate_of_closure/flight_accepted_study.py b/src/rate_of_closure/flight_accepted_study.py
index bcfc4855e..001110af7 100644
--- a/src/rate_of_closure/flight_accepted_study.py
+++ b/src/rate_of_closure/flight_accepted_study.py
@@ -166,7 +166,10 @@ def _immutable_array(value: object, shape: tuple[int, ...], field: str) -> np.nd
):
raise ValueError(f"{field} exceeds the explorer evidence envelope")
source = np.asarray(raw, dtype=np.float64)
- return np.frombuffer(source.tobytes(), dtype=np.float64).reshape(source.shape)
+ return cast(
+ np.ndarray,
+ np.frombuffer(source.tobytes(), dtype=np.float64).reshape(source.shape),
+ )
def _snapshot(exploration: FlightExploration) -> FlightExploration:
diff --git a/src/rate_of_closure/ui/pyqt6/flight_explorer_run.py b/src/rate_of_closure/ui/pyqt6/flight_explorer_run.py
index f4180a5f7..f5ab165e9 100644
--- a/src/rate_of_closure/ui/pyqt6/flight_explorer_run.py
+++ b/src/rate_of_closure/ui/pyqt6/flight_explorer_run.py
@@ -16,6 +16,7 @@
)
from rate_of_closure.simulation import (
FlightExploration,
+ WindComparison,
explore_with_optional_wind,
launch_from_delivery,
launch_from_direct,
@@ -47,6 +48,8 @@ class FlightExplorerRunMixin:
if TYPE_CHECKING:
_accepted: AcceptedFlightStudy | None
+ _exploration: FlightExploration | None
+ wind_comparison: WindComparison | None
_generation: int
_speed_spin: QDoubleSpinBox
_speed_unit: str
diff --git a/src/rate_of_closure/ui/pyqt6/flight_view.py b/src/rate_of_closure/ui/pyqt6/flight_view.py
index e67bedf6c..76def9632 100644
--- a/src/rate_of_closure/ui/pyqt6/flight_view.py
+++ b/src/rate_of_closure/ui/pyqt6/flight_view.py
@@ -17,7 +17,7 @@
import numpy as np
from matplotlib.axes import Axes
from matplotlib.figure import Figure
-from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtCore import QEvent, QObject, pyqtSignal
from PyQt6.QtWidgets import QCheckBox, QHBoxLayout, QVBoxLayout, QWidget
from rate_of_closure.simulation import SimulationRun
@@ -93,6 +93,21 @@ def __init__(self, parent: QWidget | None = None) -> None:
layout.addWidget(self._canvas)
self._draw()
+ def _emit_sample_selected(self, raw_index: int) -> None:
+ self.sampleSelected.emit(raw_index)
+
+ def _emit_sample_selection_failed(
+ self, message: str, restoration_failed: bool
+ ) -> None:
+ self.sampleSelectionFailed.emit(message, restoration_failed)
+
+ def eventFilter( # noqa: N802 - Qt override
+ self, watched: QObject | None, event: QEvent | None
+ ) -> bool:
+ if self._handle_sample_inspector_event(watched, event):
+ return True
+ return bool(super().eventFilter(watched, event))
+
def _build_param_bar(self) -> QHBoxLayout:
bar = QHBoxLayout()
bar.setContentsMargins(4, 4, 4, 0)
diff --git a/src/rate_of_closure/ui/pyqt6/flight_view_inspector.py b/src/rate_of_closure/ui/pyqt6/flight_view_inspector.py
index 9079bbbe9..59190059d 100644
--- a/src/rate_of_closure/ui/pyqt6/flight_view_inspector.py
+++ b/src/rate_of_closure/ui/pyqt6/flight_view_inspector.py
@@ -5,8 +5,10 @@
from typing import TYPE_CHECKING, cast
from PyQt6.QtCore import QEvent, QObject, Qt
+from PyQt6.QtGui import QKeyEvent
from rate_of_closure.flight_sample_inspector import (
+ FlightNavigation,
FlightSamplePlan,
navigate_flight_samples,
nearest_flight_sample,
@@ -33,7 +35,11 @@ def _draw(self, *, sync: bool = False) -> None: ...
def set_playback_time(self, time_s: float) -> None: ...
- def sampleSelected(self, raw_index: int) -> None: ... # noqa: N802
+ def _emit_sample_selected(self, raw_index: int) -> None: ...
+
+ def _emit_sample_selection_failed(
+ self, message: str, restoration_failed: bool
+ ) -> None: ...
def _initialize_sample_inspector(self) -> None:
self._sample_plan = None
@@ -84,13 +90,11 @@ def _select_raw_sample(self, raw_index: int | None) -> None:
self._canvas.pause_idle_draws()
else:
self._canvas.resume_idle_draws()
- self.sampleSelectionFailed.emit( # type: ignore[attr-defined]
- str(exc)[:512], restoration_failed
- )
+ self._emit_sample_selection_failed(str(exc)[:512], restoration_failed)
return
self._canvas.resume_idle_draws()
self._canvas.setFocus()
- self.sampleSelected.emit(-1 if raw_index is None else raw_index) # type: ignore[attr-defined]
+ self._emit_sample_selected(-1 if raw_index is None else raw_index)
def _on_sample_click(self, event: MouseEvent) -> None:
plan = self._sample_plan
@@ -117,17 +121,22 @@ def _on_sample_click(self, event: MouseEvent) -> None:
self._canvas.setFocus()
self._select_raw_sample(selection.raw_index)
- def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802
- if watched is self._canvas and event.type() == QEvent.Type.KeyPress:
- key = cast(object, event).key() # type: ignore[attr-defined]
- commands = {
+ def _handle_sample_inspector_event(
+ self, watched: QObject | None, event: QEvent | None
+ ) -> bool:
+ if (
+ watched is self._canvas
+ and isinstance(event, QKeyEvent)
+ and event.type() == QEvent.Type.KeyPress
+ ):
+ commands: dict[int, FlightNavigation] = {
Qt.Key.Key_Left: "previous",
Qt.Key.Key_Right: "next",
Qt.Key.Key_Home: "home",
Qt.Key.Key_End: "end",
Qt.Key.Key_Escape: "clear",
}
- command = commands.get(key)
+ command = commands.get(event.key())
if command and self._sample_plan is not None:
self._select_raw_sample(
navigate_flight_samples(
@@ -135,7 +144,7 @@ def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802
)
)
return True
- return bool(super().eventFilter(watched, event)) # type: ignore[misc]
+ return False
def _draw_sample_marker(self, axes: Axes, name: str) -> None:
self._inspector_axes[name] = axes