Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .codex-worktrees/friction-factors-3659
Submodule friction-factors-3659 added at 9c6731
1 change: 1 addition & 0 deletions .codex-worktrees/pr-3602-fix
Submodule pr-3602-fix added at e37b32
1 change: 1 addition & 0 deletions .codex-worktrees/pr-3752-movement
Submodule pr-3752-movement added at e5e013
1 change: 1 addition & 0 deletions .codex-worktrees/pr-3766-modern-robotics-dbc
Submodule pr-3766-modern-robotics-dbc added at 34ee67
1 change: 1 addition & 0 deletions .codex-worktrees/pr-3780-pressure-flow
Submodule pr-3780-pressure-flow added at b28657
1 change: 1 addition & 0 deletions .codex-worktrees/pr-3784-deterministic-te
Submodule pr-3784-deterministic-te added at 1e87cc
20 changes: 18 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
| **Owner** | D-sorganization |
| **Primary Language(s)** | Python 3.11+, Rust, JavaScript, TypeScript |
| **License** | MIT |
| **Current Version** | 1.13.10 |
| **Spec Version** | 1.13.10 |
| **Current Version** | 1.13.11 |
| **Spec Version** | 1.13.11 |
| **Last Spec Update** | 2026-08-06 |

## 2. Purpose & Mission
Expand All @@ -45,6 +45,22 @@ Comprehensive monorepo housing 45+ utility tools for data processing, scientific
module-size budget for the complete stacked Rate feature branches.

## 3. Goals & Non-Goals
### 2026-08-06 Reproducible ball-flight wind physics

- One versioned Python/TypeScript wind scenario defines wind-to velocity in
the flight frame, with an explicit meteorological from-bearing adapter,
vertical wind, altitude shear, declared smooth gusts, deterministic seeded
turbulence, and provenance.
- Every supported flight integrator evaluates relative air speed at physical
trajectory time and position. Dynamic wind is not silently collapsed into a
steady vector for the Rust fast path.
- React and PyQt6 run common-input no-wind and selected-wind trajectories,
show both paths, and report wind-minus-calm deltas. Two-dimensional and
three-dimensional flight plots use locked physical scale.
- The shared golden fixture pins wind-field parity. The synthetic turbulence
model is reproducible decision-support input, not a claim of site-specific
atmospheric prediction.

### 2026-08-06 Launch-monitor convention registry

- Python and TypeScript expose the same immutable, versioned catalog for app,
Expand Down
87 changes: 87 additions & 0 deletions docs/specs/WIND_PHYSICS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Reproducible Ball-Flight Wind Physics

## Scope

The wind layer supplies air velocity to the existing golf-ball aerodynamic
models. Aerodynamic forces use relative velocity

```text
v_relative = v_ball - v_wind
```

at the integrator's physical time and ball position. Wind affects the actual
drag and lift calculation; it is not a display-only offset.

The canonical flight frame is right-handed: `x` forward along the target
line, `y` left, and `z` up. `base_velocity_mps` is the direction the air moves
*to*. All components and positions are SI.

## Meteorological Adapter

The UI accepts a horizontal speed and a bearing the wind comes *from*, measured
clockwise from the target line:

- 0 degrees is a headwind from the target;
- 90 degrees comes from the player's right and moves toward flight-frame left;
- 180 degrees is a tailwind;
- 270 degrees comes from the player's left.

For speed `s`, from-bearing `b`, and upward component `w_z`, the flight-frame
wind-to vector is

```text
[-s cos(b), s sin(b), w_z]
```

Both interfaces label the from-bearing and show the corresponding wind-to
direction to avoid the common from/to ambiguity.

## Time- and Altitude-Varying Components

A `wind-scenario/v1` record may include:

- a constant three-dimensional base vector;
- linear fractional speed shear per 10 m above ground;
- declared gust events with start time, duration, and peak vector;
- deterministic seeded turbulence intensity and provenance.

Gusts use a squared-sine envelope, so each event is exactly zero at its start
and end and reaches the declared peak at its midpoint. The turbulence function
uses six deterministic harmonics per axis. It is intentionally a reproducible
perturbation for sensitivity and strategy studies; it is not a validated
von Karman/Kaimal spectrum or a forecast for a particular course. The shared
golden fixture pins Python and TypeScript field values to `1e-12` m/s.

The original reusable concepts were audited from UpstreamDrift's
`physics/aerodynamics/_wind.py`: base wind, sinusoidal gusts, turbulence,
altitude gradient, and seeded evaluation. Tools owns the reusable contract and
integrator coupling so downstream applications can import one implementation.

## Paired Comparison

Wind comparisons use identical launch, ball, environment, and flight-model
inputs. One trajectory has no wind; the other has the selected scenario.
Reported deltas are always

```text
selected wind result - no-wind result
```

for carry, lateral landing, apex, flight time, and landing angle. Both paths
are retained and rendered. The two-dimensional canvases and Matplotlib axes use
one metres-to-pixels/unit scale in each view, preventing a trajectory from
appearing steeper or flatter because the horizontal and vertical scales differ.

## Backend Boundaries

The Python literature models and TypeScript Waterloo/Penner model support the
full scenario evaluation implemented here. The current Rust flight API accepts
only one constant environmental wind vector; the facade therefore rejects
shear, gust, or turbulence instead of silently sampling them at launch.

The initial UI exposes the qualified steady horizontal case. Vertical wind,
shear, declared gust schedules, seeded turbulence, realized-history export,
wind-estimate error distributions, and strategy optimization remain explicit
follow-on integrations under issues #4198 and #4199. These limitations must
remain visible until their controls, exports, performance budgets, and
validation evidence are delivered.
3 changes: 2 additions & 1 deletion src/movement_optimizer/gui/motion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ def build_motion_colors() -> dict[str, QColor]:
def chain_path_length(chain_nodes: list[tuple[float, float]]) -> float:
"""Return the polyline length with the renderer's minimum view scale."""
distances = [
np.hypot(end[0] - start[0], end[1] - start[1]) for start, end in pairwise(chain_nodes)
np.hypot(end[0] - start[0], end[1] - start[1])
for start, end in pairwise(chain_nodes)
]
return max(float(sum(distances)), 0.5)

Expand Down
72 changes: 54 additions & 18 deletions src/movement_optimizer/gui/motion_tabs.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,18 @@ def _swing_overlay_scene(
origin = (float(field.com_m[0]), float(field.com_m[1]))
if gravity:
gravity_vec = (float(field.gravity_n[0]), float(field.gravity_n[1]))
arrows.append(ForceArrow(origin, gravity_vec, VectorStyle(LEG, label="gravity")))
arrows.append(
ForceArrow(origin, gravity_vec, VectorStyle(LEG, label="gravity"))
)
if tension:
tension_vec = (float(field.chain_tension_n[0]), float(field.chain_tension_n[1]))
arrows.append(ForceArrow(origin, tension_vec, VectorStyle(CHAIN, label="tension")))
arrows.append(
ForceArrow(origin, tension_vec, VectorStyle(CHAIN, label="tension"))
)
if torque:
for joint, magnitude in zip(SWING_POLICY_JOINT_NAMES, field.joint_torque_nm, strict=True):
for joint, magnitude in zip(
SWING_POLICY_JOINT_NAMES, field.joint_torque_nm, strict=True
):
point = field.joint_points_m[joint]
arcs.append(
TorqueArc(
Expand All @@ -123,7 +129,9 @@ def _swing_overlay_scene(
)
if com:
markers.append(ComMarker(origin, VectorStyle(ACCENT)))
return OverlayScene(arrows=tuple(arrows), torque_arcs=tuple(arcs), com_markers=tuple(markers))
return OverlayScene(
arrows=tuple(arrows), torque_arcs=tuple(arcs), com_markers=tuple(markers)
)


def _chain_overlay_scene(
Expand Down Expand Up @@ -710,16 +718,30 @@ def _build_policy_group(self) -> QGroupBox:
integer=True,
tooltip="Time steps simulated per evaluation when cycles are not used (up to 2000).",
)
self._add_control(form, "freq_min", "Freq min Hz", 0.2, 2.0, 0.45, refresh=False)
self._add_control(form, "freq_max", "Freq max Hz", 0.2, 2.0, 0.75, refresh=False)
self._add_control(
form, "freq_min", "Freq min Hz", 0.2, 2.0, 0.45, refresh=False
)
self._add_control(
form, "freq_max", "Freq max Hz", 0.2, 2.0, 0.75, refresh=False
)
self._add_control(
form, "freq_samples", "Freq samples", 1, 8, 3, integer=True, refresh=False
)
self._add_control(form, "hip_rate_min", "Hip min rad/s", 0.0, 3.0, 0.5, refresh=False)
self._add_control(form, "hip_rate_max", "Hip max rad/s", 0.0, 3.0, 1.3, refresh=False)
self._add_control(form, "hip_samples", "Hip samples", 1, 8, 2, integer=True, refresh=False)
self._add_control(form, "torso_rate_min", "Torso min rad/s", 0.0, 3.0, 0.3, refresh=False)
self._add_control(form, "torso_rate_max", "Torso max rad/s", 0.0, 3.0, 1.1, refresh=False)
self._add_control(
form, "hip_rate_min", "Hip min rad/s", 0.0, 3.0, 0.5, refresh=False
)
self._add_control(
form, "hip_rate_max", "Hip max rad/s", 0.0, 3.0, 1.3, refresh=False
)
self._add_control(
form, "hip_samples", "Hip samples", 1, 8, 2, integer=True, refresh=False
)
self._add_control(
form, "torso_rate_min", "Torso min rad/s", 0.0, 3.0, 0.3, refresh=False
)
self._add_control(
form, "torso_rate_max", "Torso max rad/s", 0.0, 3.0, 1.1, refresh=False
)
self._add_control(
form,
"torso_samples",
Expand All @@ -730,8 +752,12 @@ def _build_policy_group(self) -> QGroupBox:
integer=True,
refresh=False,
)
self._add_control(form, "knee_ratio_min", "Knee ratio min", 0.0, 1.5, 0.25, refresh=False)
self._add_control(form, "knee_ratio_max", "Knee ratio max", 0.0, 1.5, 0.65, refresh=False)
self._add_control(
form, "knee_ratio_min", "Knee ratio min", 0.0, 1.5, 0.25, refresh=False
)
self._add_control(
form, "knee_ratio_max", "Knee ratio max", 0.0, 1.5, 0.65, refresh=False
)
self._add_control(
form, "knee_samples", "Knee samples", 1, 8, 2, integer=True, refresh=False
)
Expand All @@ -745,7 +771,9 @@ def _build_policy_group(self) -> QGroupBox:
integer=True,
refresh=False,
)
self._add_control(form, "speed", "Playback speed", 0.25, 4.0, 1.0, refresh=False)
self._add_control(
form, "speed", "Playback speed", 0.25, 4.0, 1.0, refresh=False
)
layout.addLayout(form)
return group

Expand Down Expand Up @@ -995,15 +1023,23 @@ def _render_snapshot(self, snapshot: SwingSetSnapshot) -> None:
def _populate_analysis_panel(self) -> None:
if self._rollout is None:
return
history = swing_force_history(self._config(), self._rollout, DEFAULT_POLICY_DT_S)
history = swing_force_history(
self._config(), self._rollout, DEFAULT_POLICY_DT_S
)
self._force_history = history
self._force_fields = swing_force_fields(self._config(), self._rollout, DEFAULT_POLICY_DT_S)
self._force_fields = swing_force_fields(
self._config(), self._rollout, DEFAULT_POLICY_DT_S
)
panel = self.analysis_panel
panel.clear()
plot_renderer.plot_swing_joint_torques(panel.axes["torques"], history, legend=False)
plot_renderer.plot_swing_joint_torques(
panel.axes["torques"], history, legend=False
)
plot_renderer.plot_swing_joint_power(panel.axes["power"], history, legend=False)
plot_renderer.plot_swing_angle(panel.axes["angle"], history, legend=False)
plot_renderer.plot_swing_com_height(panel.axes["com_height"], history, legend=False)
plot_renderer.plot_swing_com_height(
panel.axes["com_height"], history, legend=False
)
plot_renderer.plot_swing_energy(panel.axes["energy"], history, legend=False)
plot_renderer.plot_swing_com_path(panel.axes["com_path"], history, legend=False)
self._apply_plot_legend_visibility()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ def make_polynomial_torque(
polynomials: list[TorquePolynomial] = []
for i, coeffs in enumerate(coeffs_per_joint):
if not (len(coeffs) >= 1):
raise ValueError(f"Need at least one coefficient for joint {i}, got {len(coeffs)}")
raise ValueError(
f"Need at least one coefficient for joint {i}, got {len(coeffs)}"
)
polynomials.append(TorquePolynomial(tuple(coeffs)))

def torque_func(t: float) -> tuple[float, ...]:
Expand Down
4 changes: 3 additions & 1 deletion src/pendulum_simulator/tests/test_torque_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ def test_zero_joints_raises(self):

def test_empty_coefficients_raises(self):
"""Each joint needs at least one coefficient."""
with pytest.raises((ValueError, TypeError), match="Need at least one coefficient"):
with pytest.raises(
(ValueError, TypeError), match="Need at least one coefficient"
):
make_polynomial_torque([])

def test_returns_tuple(self):
Expand Down
6 changes: 6 additions & 0 deletions src/rate_of_closure/simulation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@
from .flight_explorer import (
EXPLORER_METRIC_KEYS,
FlightExploration,
WindComparison,
compare_wind,
explore_flight,
explore_with_optional_wind,
launch_from_delivery,
launch_from_direct,
)
Expand Down Expand Up @@ -91,6 +94,7 @@
"SCREW_CSV_COLUMNS",
"AppFrameSwing",
"FlightExploration",
"WindComparison",
"KineticsSeries",
"ImpactOutcome",
"IMPACT_SCENE_FORMAT",
Expand All @@ -107,6 +111,8 @@
"TriplePendulumParameters",
"TriplePendulumSwing",
"compute_kinetics",
"compare_wind",
"explore_with_optional_wind",
"delivery_at",
"explore_flight",
"fit_run_torque_profile",
Expand Down
Loading