Skip to content
Open
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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ repos:

- id: eslint
name: eslint
entry: npm run lint
entry: bash -c 'cd ui && npm run lint'
language: system
files: ^ui/src/.*\.(ts|tsx)$
pass_filenames: false
82 changes: 60 additions & 22 deletions src/openflight/ballistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
# the rules rather than by a guess at the specific ball in play.
BALL_MASS_KG = 0.04593
BALL_RADIUS_M = 0.02135
BALL_AREA_M2 = math.pi * BALL_RADIUS_M ** 2
BALL_AREA_M2 = math.pi * BALL_RADIUS_M**2
AIR_DENSITY_STD = 1.225 # kg/m³ at sea level, 15 °C ISA

# Cd = CD_BASE + CD_SPIN_COEFF * Sp
Expand Down Expand Up @@ -65,6 +65,40 @@
# keeping payload size reasonable for UI/log consumers.
SAMPLE_INTERVAL_S = 0.05

# ISA (International Standard Atmosphere) tropospheric constants for air
# density at altitude. Valid from sea level to ~11 km (36,089 ft), which
# covers all realistic golf venues (highest course ≈ 4,300 m / 14,000 ft).
_ISA_SEA_LEVEL_TEMP_K = 288.15 # 15 °C
_ISA_LAPSE_RATE = 0.0065 # K/m
_ISA_SEA_LEVEL_PRESSURE_PA = 101325.0
_ISA_PRESSURE_EXPONENT = 5.25588 # g / (R·L) = 9.80665 / (287.058 · 0.0065)
_ISA_GAS_CONSTANT_DRY = 287.058 # J / (kg·K)


def air_density_at_altitude(altitude_m: float) -> float:
"""Return ISA dry-air density (kg/m³) at the given altitude in metres.

Uses the standard tropospheric lapse rate (valid to ~11 km / 36,000 ft),
which covers every realistic golf venue on Earth. The highest active
course is around 4,300 m (14,000 ft); at that altitude air density is
~63% of sea level, adding roughly 10-12% to carry compared with a
sea-level calculation.

Args:
altitude_m: Altitude above sea level in metres. Negative values
(below sea level, e.g. Dead Sea) are clamped to 0.

Returns:
Air density in kg/m³.
"""
h = max(0.0, float(altitude_m))
temp_k = _ISA_SEA_LEVEL_TEMP_K - _ISA_LAPSE_RATE * h
pressure_pa = (
_ISA_SEA_LEVEL_PRESSURE_PA * (temp_k / _ISA_SEA_LEVEL_TEMP_K) ** _ISA_PRESSURE_EXPONENT
)
return pressure_pa / (_ISA_GAS_CONSTANT_DRY * temp_k)


# Club-typical spin (RPM) from TrackMan PGA Tour averages.
# Used as fallback when measured spin is missing or low-confidence.
CLUB_TYPICAL_SPIN_RPM: dict[ClubType, float] = {
Expand Down Expand Up @@ -156,9 +190,7 @@ def resolve_launch(shot: Shot) -> Optional[LaunchConditions]:
spin_rpm = float(shot.spin_rpm)
source: Literal["measured", "club_typical"] = "measured"
else:
spin_rpm = CLUB_TYPICAL_SPIN_RPM.get(
shot.club, CLUB_TYPICAL_SPIN_RPM[ClubType.UNKNOWN]
)
spin_rpm = CLUB_TYPICAL_SPIN_RPM.get(shot.club, CLUB_TYPICAL_SPIN_RPM[ClubType.UNKNOWN])
source = "club_typical"

return LaunchConditions(
Expand Down Expand Up @@ -244,20 +276,20 @@ def _rk4_step(
k3 = _derivatives(s3, omega, axis, air_density)
s4 = tuple(state[i] + dt * k3[i] for i in range(6))
k4 = _derivatives(s4, omega, axis, air_density)
return tuple(
state[i] + (dt / 6.0) * (k1[i] + 2 * k2[i] + 2 * k3[i] + k4[i])
for i in range(6)
)
return tuple(state[i] + (dt / 6.0) * (k1[i] + 2 * k2[i] + 2 * k3[i] + k4[i]) for i in range(6))


def simulate(
conditions: LaunchConditions,
air_density: float = AIR_DENSITY_STD,
dt: float = DT_SECONDS,
altitude_m: Optional[float] = None,
) -> Trajectory:
"""
Integrate flight from launch to first ground contact (z = 0).
"""
if altitude_m is not None:
air_density = air_density_at_altitude(altitude_m)
v0 = conditions.ball_speed_mph * MPH_TO_MPS
la_v = math.radians(conditions.launch_angle_v)
la_h = math.radians(conditions.launch_angle_h)
Expand Down Expand Up @@ -311,15 +343,17 @@ def simulate(
final = tuple(state[i] + frac * (new_state[i] - state[i]) for i in range(6))
fx, fy, fz, fvx, fvy, fvz = final
v_final = math.sqrt(fvx * fvx + fvy * fvy + fvz * fvz)
landing_angle = math.degrees(
math.atan2(-fvz, math.sqrt(fvx * fvx + fvy * fvy))
landing_angle = math.degrees(math.atan2(-fvz, math.sqrt(fvx * fvx + fvy * fvy)))
points.append(
TrajectoryPoint(
t_hit,
fx * M_TO_YD,
fy * M_TO_YD,
max(fz, 0.0) * M_TO_YD,
v_final * MPS_TO_MPH,
omega * 60 / (2 * math.pi),
)
)
points.append(TrajectoryPoint(
t_hit,
fx * M_TO_YD, fy * M_TO_YD, max(fz, 0.0) * M_TO_YD,
v_final * MPS_TO_MPH,
omega * 60 / (2 * math.pi),
))
return Trajectory(
points=points,
carry_yards=fx * M_TO_YD,
Expand All @@ -335,12 +369,16 @@ def simulate(
if t - last_sample_t >= SAMPLE_INTERVAL_S:
sx_, sy_, sz_, svx, svy, svz = state
v = math.sqrt(svx * svx + svy * svy + svz * svz)
points.append(TrajectoryPoint(
t,
sx_ * M_TO_YD, sy_ * M_TO_YD, sz_ * M_TO_YD,
v * MPS_TO_MPH,
omega * 60 / (2 * math.pi),
))
points.append(
TrajectoryPoint(
t,
sx_ * M_TO_YD,
sy_ * M_TO_YD,
sz_ * M_TO_YD,
v * MPS_TO_MPH,
omega * 60 / (2 * math.pi),
)
)
last_sample_t = t

# Flight did not terminate — return current state as best-effort
Expand Down
9 changes: 8 additions & 1 deletion src/openflight/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1591,6 +1591,7 @@ def handle_get_debug_status():
"max_speed": 220,
"min_magnitude": 0,
"transmit_power": 0,
"altitude_m": 0,
}


Expand Down Expand Up @@ -1644,6 +1645,12 @@ def handle_set_radar_config(data):
radar_config["transmit_power"] = new_power
print(f"Set transmit power: {new_power}")

# Update altitude for ballistics
if "altitude_m" in data:
new_altitude = max(0, int(data["altitude_m"]))
radar_config["altitude_m"] = new_altitude
print(f"Set altitude: {new_altitude} m")

# Log config change
session_logger = get_session_logger()
if session_logger:
Expand Down Expand Up @@ -2368,7 +2375,7 @@ def on_shot_detected(shot: Shot):
if shot.carry_spin_adjusted is None and shot.mode != "mock":
conditions = resolve_launch(shot) if ballistics_enabled else None
if conditions is not None:
trajectory = simulate(conditions)
trajectory = simulate(conditions, altitude_m=radar_config.get("altitude_m", 0) or None)
shot.carry_spin_adjusted = trajectory.carry_yards
logger.info(
"[SERVER] Ballistic carry: %.0f yds (spin: %.0f rpm, source: %s)",
Expand Down
105 changes: 105 additions & 0 deletions tests/test_ballistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,108 @@ def test_zero_launch_angle_does_not_crash(self):
def test_total_distance_includes_rollout(self):
traj = simulate(_driver())
assert traj.total_yards > traj.carry_yards


class TestAirDensityAtAltitude:
"""Tests for the ISA air density function."""

def test_sea_level_matches_standard(self):
"""At 0 m altitude should return standard sea-level density."""
from openflight.ballistics import AIR_DENSITY_STD, air_density_at_altitude

assert air_density_at_altitude(0) == pytest.approx(AIR_DENSITY_STD, rel=1e-3)

def test_density_decreases_with_altitude(self):
"""Higher altitude should produce lower air density."""
from openflight.ballistics import air_density_at_altitude

assert air_density_at_altitude(1000) < air_density_at_altitude(0)
assert air_density_at_altitude(2000) < air_density_at_altitude(1000)

def test_denver_altitude(self):
"""Denver (~1609 m / 5280 ft) should be ~83% of sea-level density."""
from openflight.ballistics import AIR_DENSITY_STD, air_density_at_altitude

denver = air_density_at_altitude(1609)
ratio = denver / AIR_DENSITY_STD
assert 0.84 <= ratio <= 0.87

def test_high_altitude_course(self):
"""Leadville, CO (~3094 m / 10,152 ft) should be ~70% of sea level."""
from openflight.ballistics import AIR_DENSITY_STD, air_density_at_altitude

leadville = air_density_at_altitude(3094)
ratio = leadville / AIR_DENSITY_STD
assert 0.72 <= ratio <= 0.75

def test_negative_altitude_clamped_to_sea_level(self):
"""Below sea level (e.g. Dead Sea) should return sea-level density."""
from openflight.ballistics import air_density_at_altitude

assert air_density_at_altitude(-100) == pytest.approx(air_density_at_altitude(0), rel=1e-6)

def test_returns_float(self):
from openflight.ballistics import air_density_at_altitude

assert isinstance(air_density_at_altitude(1000), float)


class TestSimulateAltitude:
"""Tests for altitude_m parameter on simulate()."""

def _conditions(self):
from openflight.ballistics import LaunchConditions

return LaunchConditions(
ball_speed_mph=150.0,
launch_angle_v=12.0,
launch_angle_h=0.0,
spin_rpm=2700,
spin_axis_deg=0.0,
spin_source="club_typical",
)

def test_altitude_increases_carry(self):
"""Higher altitude should produce longer carry due to thinner air."""
from openflight.ballistics import simulate

sea_level = simulate(self._conditions(), altitude_m=0)
denver = simulate(self._conditions(), altitude_m=1609)
assert denver.carry_yards > sea_level.carry_yards

def test_high_altitude_carry_significantly_longer(self):
"""At 2000m carry should be meaningfully longer than sea level."""
from openflight.ballistics import simulate

sea_level = simulate(self._conditions(), altitude_m=0)
high = simulate(self._conditions(), altitude_m=2000)
# Expect at least 5% more carry at 2000m
assert high.carry_yards > sea_level.carry_yards * 1.03

def test_altitude_overrides_air_density(self):
"""altitude_m should override explicit air_density argument."""
from openflight.ballistics import AIR_DENSITY_STD, air_density_at_altitude, simulate

result_via_altitude = simulate(
self._conditions(), air_density=AIR_DENSITY_STD, altitude_m=1609
)
result_via_density = simulate(self._conditions(), air_density=air_density_at_altitude(1609))
assert result_via_altitude.carry_yards == pytest.approx(
result_via_density.carry_yards, rel=1e-6
)

def test_zero_altitude_matches_default(self):
"""altitude_m=0 should match the default sea-level simulation."""
from openflight.ballistics import simulate

default = simulate(self._conditions())
explicit = simulate(self._conditions(), altitude_m=0)
assert explicit.carry_yards == pytest.approx(default.carry_yards, rel=1e-3)

def test_none_altitude_uses_air_density_param(self):
"""altitude_m=None should leave air_density param untouched."""
from openflight.ballistics import AIR_DENSITY_STD, simulate

default = simulate(self._conditions())
explicit = simulate(self._conditions(), altitude_m=None, air_density=AIR_DENSITY_STD)
assert explicit.carry_yards == pytest.approx(default.carry_yards, rel=1e-6)
Loading