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..c0fec8117 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -3,6 +3,32 @@ > **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 +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..1ee1baa80 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,12 +26,42 @@ | **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.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 +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..b5887109f 100644 --- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md +++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md @@ -1,5 +1,41 @@ # 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 + 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..4f96bb210 100644 --- a/src/rate_of_closure/AGENT_HANDOFF.md +++ b/src/rate_of_closure/AGENT_HANDOFF.md @@ -3,6 +3,32 @@ > **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 + 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..001110af7 --- /dev/null +++ b/src/rate_of_closure/flight_accepted_study.py @@ -0,0 +1,366 @@ +"""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 cast( + np.ndarray, + 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..f5ab165e9 --- /dev/null +++ b/src/rate_of_closure/ui/pyqt6/flight_explorer_run.py @@ -0,0 +1,324 @@ +"""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, + WindComparison, + 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 + _exploration: FlightExploration | None + wind_comparison: WindComparison | 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..76def9632 100644 --- a/src/rate_of_closure/ui/pyqt6/flight_view.py +++ b/src/rate_of_closure/ui/pyqt6/flight_view.py @@ -14,11 +14,10 @@ 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.QtCore import QEvent, QObject, pyqtSignal from PyQt6.QtWidgets import QCheckBox, QHBoxLayout, QVBoxLayout, QWidget from rate_of_closure.simulation import SimulationRun @@ -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) @@ -89,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) @@ -106,6 +125,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 +146,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 +161,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 +196,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 +308,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 +339,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 +354,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..59190059d --- /dev/null +++ b/src/rate_of_closure/ui/pyqt6/flight_view_inspector.py @@ -0,0 +1,157 @@ +"""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 PyQt6.QtGui import QKeyEvent + +from rate_of_closure.flight_sample_inspector import ( + FlightNavigation, + 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 _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 + 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._emit_sample_selection_failed(str(exc)[:512], restoration_failed) + return + self._canvas.resume_idle_draws() + self._canvas.setFocus() + 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 + 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 _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(event.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 False + + 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" /> - + {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", ], )