Skip to content
Merged
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,10 +301,10 @@ Use the benchmark script for broad flight/rollout trend checking and the Garmin
Use the calibration tooling under [`tools/shot_calibration/README.md`](/home/jesher/Code/Github/digitalhand/openfairway/tools/shot_calibration/README.md) for carry-focused analysis and iteration against source-of-truth data.

- Physics parameter tuning profile: `assets/data/calibration/calibration_profile.json`
- Calibration carry exception profile (regime + window targets): `assets/data/calibration/carry_exception_profile.json`
- Calibration carry exception profile (diagnostic-only, explicit opt-in): `assets/data/calibration/carry_exception_profile.json`
- Critical carry report output: `assets/data/openfairway_critical_carry_<timestamp>.csv`

The carry exception layer is calibration analysis tooling. It is applied in the compare/analyze pipeline and does not modify core runtime equations in `addons/openfairway/physics/`.
The carry exception layer is calibration analysis tooling and is disabled by default. It only applies when explicitly enabled via `--carry-exceptions`, and does not modify core runtime equations in `addons/openfairway/physics/`.

## Known Feature Gaps

Expand Down
135 changes: 133 additions & 2 deletions addons/openfairway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Realistic golf ball physics engine for Godot 4.5+ C# projects. Usable from both
- [Quick Start (GDScript)](#quick-start-gdscript)
- [Runtime Architecture](#runtime-architecture)
- [Calibration Tooling Note](#calibration-tooling-note)
- [Regime Tuning Workflow](#regime-tuning-workflow)
- [Game Integration: Ball and Surface Ownership](#game-integration-ball-and-surface-ownership)
- [Surface Authoring](#surface-authoring)
- [API Reference - GDScript Usage](#api-reference---gdscript-usage)
Expand Down Expand Up @@ -111,16 +112,146 @@ Source: [`assets/diagrams/physics-runtime-components.puml`](assets/diagrams/phys

## Calibration Tooling Note

Carry calibration for source-of-truth comparison is handled by tooling in `tools/shot_calibration/`, including a bounded carry exception layer profile at `assets/data/calibration/carry_exception_profile.json`.
Carry calibration for source-of-truth comparison is handled by tooling in `tools/shot_calibration/`, including an optional bounded carry exception layer profile at `assets/data/calibration/carry_exception_profile.json`.

That layer is part of the calibration compare/analyze pipeline (`compare_csv.py` and `calibrate.py`). It does not change the addon runtime equations or in-game flight integration path in `addons/openfairway/physics/`.
That layer is part of the calibration compare/analyze pipeline (`compare_csv.py` and `calibrate.py`) and is disabled by default unless explicitly enabled with `--carry-exceptions`. It does not change the addon runtime equations or in-game flight integration path in `addons/openfairway/physics/`.

For calibration commands and regime/window configuration details, see:

- `tools/shot_calibration/README.md`
- `assets/data/calibration/calibration_profile.json`
- `assets/data/calibration/carry_exception_profile.json`

## Regime Tuning Workflow

Use this workflow when the goal is to improve addon physics against FlightScope reference data without launching gameplay scenes.

### What actually improves shots

Only two things move the measured carry numbers:

1. Changes to addon physics code under `addons/openfairway/physics/`
2. Changes to `BallPhysicsProfile` input, including `RegimeScaleOverrides` in `assets/data/calibration/calibration_profile.json`

`compare_csv.py` and `calibrate.py analyze` do not simulate shots. They only score the most recent headless physics export.

### RegimeScaleOverrides

`BallPhysicsProfile` now supports regime-keyed scale overrides:

```json
{
"RegimeScaleOverrides": {
"I-S1a-V3-P2": {
"DragScaleMultiplier": 0.95,
"LiftScaleMultiplier": 1.05
},
"D-S3-V1-P2": {
"DragScaleMultiplier": 1.02,
"LiftScaleMultiplier": 0.99
}
}
}
```

The regime key format is:

```text
<family>-<speed_bin>-<launch_bin>-<spin_bin>
```

Families:

- `C`: chip / very low speed (`speed < 60 mph`)
- `D`: driver-wood style (`speed > 110 mph` and `launch < 18 deg`)
- `W`: very high loft (`launch > 30 deg`)
- `I`: everything else, usually irons / wedges / approaches

Bins:

- Speed: `S0`, `S1a` (60-72 mph), `S1b` (72-85 mph), `S2`, `S3`, `S4`
- Launch: `V0`, `V1`, `V2`, `V3`, `V4`
- Spin: `P0`, `P1`, `P2`, `P3`, `P4`

Resolution order is most-specific to least-specific:

1. `I-S1a-V3-P2`
2. `I-S1a-V3`
3. `I-S1a`
4. `I`

Default regime overrides are baked into `BallPhysicsProfile.BuildDefaultRegimeOverrides()` (22 keys as of iteration 072). The `calibration_profile.json` is optional and only needed for experimental overrides during tuning.

Prefer specific regime keys (e.g. `D-S4-V1-P0`) over broad catch-alls (e.g. `D-S4-V1`) when sub-bins have opposite carry directions. Removing the `D-S4-V1` catch-all and replacing it with `D-S4-V1-P0`, `D-S4-V1-P1`, `D-S4-V1-P2` was necessary because P0 shots were short while P1/P2 were long.

### What to change first

For carry tuning:

- Shot is too short: decrease `DragScaleMultiplier`, increase `LiftScaleMultiplier`
- Shot is too long: increase `DragScaleMultiplier`, decrease `LiftScaleMultiplier`

Use small steps:

- Drag: `0.01`
- Lift: `0.005` to `0.01`

Do not start with global multipliers if the misses are clustered in short-shot bins. Use regime overrides first so short-shot tuning does not reopen driver and wood behavior.

### Target windows

Use these carry targets when reviewing reports:

- `<115 yd`: primary target `+-1 yd`, stretch target `+-0.5 yd`
- `115-150 yd`: `+-3 yd`
- `150-180 yd`: `+-7 yd`
- `>200 yd` drivers: keep within `+-15 yd`

If a regime has mixed signs, do not keep pushing it. Split the regime more narrowly or leave the remainder to the residual carry regime layer.

### Required iteration loop

1. Update source defaults in `BallPhysicsProfile.cs` (or optionally `assets/data/calibration/calibration_profile.json` for experimental overrides)
2. Re-export physics headlessly
3. Re-run analysis against the same FlightScope corpus
4. Compare the new critical-carry report against the prior baseline

Example:

```bash
godot --headless --path . --script tools/shot_calibration/export_physics_csv.gd -- \
'--profile=assets/data/calibration/calibration_profile.json' \
'--dirs=res://assets/data|,res://assets/data/shot_session_2|s2,res://assets/data/shot_session_3|s3,res://assets/data/shot_session_4|s4' \
'--output=assets/data/calibration/physics.csv'

python tools/shot_calibration/calibrate.py analyze \
--show 129 \
--critical-baseline assets/data/openfairway_critical_carry_20260314_0146.csv
```

### How to judge an iteration

Accept a regime change only if:

- Physics-only `% within +-3 yd` improves or stays stable
- `<115 yd` `% within +-1 yd` improves
- The critical baseline shows more improved shots than regressed shots
- Long-shot windows stay inside guardrails

Read the generated summary in this order:

1. `physics_only.within_3yd_pct`
2. `short_shot_priority.actual_within_1yd_pct`
3. `short_shot_priority.actual_within_0.5yd_pct`
4. `critical_baseline.improved` vs `critical_baseline.regressed`
5. `residual_regime_candidates`

### When to use the residual regime layer

The residual carry regime layer is for the remaining outliers after physics-only tuning captures the main shot families.

Use it only after the base physics path has already improved the broad regime. The runtime fallback should stay regime-based, not shot-name based.

## Game Integration: Ball and Surface Ownership

The runtime split is intentional:
Expand Down
113 changes: 112 additions & 1 deletion addons/openfairway/physics/BallPhysicsProfile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,22 @@
/// </summary>
public sealed class BallPhysicsProfile
{
public float DragScaleMultiplier { get; set; } = 1.0f;
private static readonly HashSet<string> RegimeOverrideKnownKeys = new()
{
"DragScaleMultiplier", "LiftScaleMultiplier",
"KineticFrictionMultiplier", "RollingFrictionMultiplier",
"GrassViscosityMultiplier", "CriticalAngleOffsetRadians",
"SpinbackThetaBoostMultiplier",
};

public float DragScaleMultiplier { get; set; } = 1.01f;
public float LiftScaleMultiplier { get; set; } = 1.0f;
public float KineticFrictionMultiplier { get; set; } = 1.0f;
public float RollingFrictionMultiplier { get; set; } = 1.0f;
public float GrassViscosityMultiplier { get; set; } = 1.0f;
public float CriticalAngleOffsetRadians { get; set; } = 0.0f;
public float SpinbackThetaBoostMultiplier { get; set; } = 1.0f;
public Dictionary<string, RegimeScaleOverride> RegimeScaleOverrides { get; set; } = BuildDefaultRegimeOverrides();

public FlightProfile Flight { get; set; }
public BounceProfile Bounce { get; set; }
Expand All @@ -29,6 +38,7 @@ public sealed class BallPhysicsProfile
"KineticFrictionMultiplier", "RollingFrictionMultiplier",
"GrassViscosityMultiplier", "CriticalAngleOffsetRadians",
"SpinbackThetaBoostMultiplier",
"RegimeScaleOverrides",
"Flight", "Bounce", "Rollout",
};

Expand Down Expand Up @@ -60,6 +70,14 @@ public static BallPhysicsProfile FromJson(string json)
profile.CriticalAngleOffsetRadians = v.GetSingle();
if (root.TryGetProperty("SpinbackThetaBoostMultiplier", out v))
profile.SpinbackThetaBoostMultiplier = v.GetSingle();
if (root.TryGetProperty("RegimeScaleOverrides", out var regimeEl) && regimeEl.ValueKind == JsonValueKind.Object)
{
foreach (var prop in regimeEl.EnumerateObject())
{
WarnUnknownKeys(prop.Value, RegimeOverrideKnownKeys, $"RegimeScaleOverride[{prop.Name}]");
profile.RegimeScaleOverrides[prop.Name] = ParseRegimeScaleOverride(prop.Value);
}
}

if (root.TryGetProperty("Flight", out var flightEl))
{
Expand All @@ -76,6 +94,84 @@ public static BallPhysicsProfile FromJson(string json)
return profile;
}

public RegimeScaleOverride ResolveScaleOverride(
float speedMph,
float launchAngleDeg,
float totalSpinRpm,
out string regimeKey,
out string matchedOverrideKey)
{
regimeKey = ShotRegimeKey.Build(speedMph, launchAngleDeg, totalSpinRpm);
matchedOverrideKey = string.Empty;

if (RegimeScaleOverrides == null || RegimeScaleOverrides.Count == 0)
return RegimeScaleOverride.Neutral;

foreach (string candidate in ShotRegimeKey.BuildLookupKeys(speedMph, launchAngleDeg, totalSpinRpm))
{
if (RegimeScaleOverrides.TryGetValue(candidate, out var matched))
{
matchedOverrideKey = candidate;
return matched;
}
}

return RegimeScaleOverride.Neutral;
}

/// <summary>
/// Calibrated regime-specific scale overrides derived from FlightScope
/// reference data. These correct systematic carry biases per launch regime
/// (e.g. chip shots under-carry at low Re, driver shots over-carry at high Re).
/// </summary>
private static Dictionary<string, RegimeScaleOverride> BuildDefaultRegimeOverrides()
{
return new Dictionary<string, RegimeScaleOverride>
{
// Chip shots (speed < 60 mph): systematic under-carry at low Reynolds
["C-S0"] = new() { DragScaleMultiplier = 0.70f, LiftScaleMultiplier = 1.20f },
["C-S0-V1-P0"] = new() { DragScaleMultiplier = 0.55f, LiftScaleMultiplier = 1.15f },
["C-S0-V4-P3"] = new() { DragScaleMultiplier = 0.65f, LiftScaleMultiplier = 1.25f },

// Slow iron S1a (60-72 mph): larger systematic under-carry, more aggressive corrections
["I-S1a-V0-P1"] = new() { DragScaleMultiplier = 0.94f, LiftScaleMultiplier = 1.04f },
["I-S1a-V2-P1"] = new() { DragScaleMultiplier = 0.80f, LiftScaleMultiplier = 1.14f },
["I-S1a-V2-P2"] = new() { DragScaleMultiplier = 0.82f, LiftScaleMultiplier = 1.13f },
["I-S1a-V2-P3"] = new() { DragScaleMultiplier = 0.82f, LiftScaleMultiplier = 1.12f },
["I-S1a-V3-P2"] = new() { DragScaleMultiplier = 0.79f, LiftScaleMultiplier = 1.14f },
["I-S1a-V3-P3"] = new() { DragScaleMultiplier = 0.94f, LiftScaleMultiplier = 1.04f },
["I-S1a-V1-P2"] = new() { DragScaleMultiplier = 0.92f, LiftScaleMultiplier = 1.05f },

// Mid iron S1b (72-85 mph): smaller corrections
["I-S1b-V0-P0"] = new() { DragScaleMultiplier = 0.97f, LiftScaleMultiplier = 1.02f },
["I-S1b-V2-P2"] = new() { DragScaleMultiplier = 0.94f, LiftScaleMultiplier = 1.03f },
["I-S1b-V2-P3"] = new() { DragScaleMultiplier = 0.97f, LiftScaleMultiplier = 1.01f },
["I-S1b-V3-P2"] = new() { DragScaleMultiplier = 0.88f, LiftScaleMultiplier = 1.06f },
["I-S1b-V3-P3"] = new() { DragScaleMultiplier = 0.97f, LiftScaleMultiplier = 1.02f },
["I-S1b-V1-P2"] = new() { DragScaleMultiplier = 0.98f, LiftScaleMultiplier = 1.01f },

// Wedge lob (launch > 30 deg, 60-72 mph): under-carry
["W-S1a-V3-P3"] = new() { DragScaleMultiplier = 0.83f, LiftScaleMultiplier = 1.10f },

// Fast iron with high spin: over-carry
["I-S3-V2-P3"] = new() { DragScaleMultiplier = 1.08f, LiftScaleMultiplier = 0.96f },
["I-S2-V2-P3"] = new() { DragScaleMultiplier = 1.04f },

// Mid-speed iron: all short
["I-S2-V1-P1"] = new() { DragScaleMultiplier = 0.97f, LiftScaleMultiplier = 1.02f },

// Driver regime: slight over-carry
["D-S3-V1"] = new() { DragScaleMultiplier = 1.04f, LiftScaleMultiplier = 0.99f },
["D-S4-V0-P1"] = new() { DragScaleMultiplier = 1.03f, LiftScaleMultiplier = 0.98f },
["D-S4-V1-P0"] = new() { DragScaleMultiplier = 0.98f, LiftScaleMultiplier = 1.02f },
["D-S4-V1-P1"] = new() { DragScaleMultiplier = 1.04f },
["D-S4-V1-P2"] = new() { DragScaleMultiplier = 1.04f },

// High-speed wedge (launch > 30 deg, 85-105 mph): over-carry
["W-S2-V3-P4"] = new() { DragScaleMultiplier = 1.06f, LiftScaleMultiplier = 0.97f },
};
}

private static void WarnUnknownKeys(JsonElement element, HashSet<string> knownKeys, string context)
{
if (element.ValueKind != JsonValueKind.Object)
Expand Down Expand Up @@ -208,6 +304,21 @@ private static RolloutProfile ParseRolloutProfile(JsonElement el)
};
}

private static RegimeScaleOverride ParseRegimeScaleOverride(JsonElement el)
{
var scaleOverride = new RegimeScaleOverride();
return new RegimeScaleOverride
{
DragScaleMultiplier = TryFloat(el, "DragScaleMultiplier", scaleOverride.DragScaleMultiplier),
LiftScaleMultiplier = TryFloat(el, "LiftScaleMultiplier", scaleOverride.LiftScaleMultiplier),
KineticFrictionMultiplier = TryFloat(el, "KineticFrictionMultiplier", scaleOverride.KineticFrictionMultiplier),
RollingFrictionMultiplier = TryFloat(el, "RollingFrictionMultiplier", scaleOverride.RollingFrictionMultiplier),
GrassViscosityMultiplier = TryFloat(el, "GrassViscosityMultiplier", scaleOverride.GrassViscosityMultiplier),
CriticalAngleOffsetRadians = TryFloat(el, "CriticalAngleOffsetRadians", scaleOverride.CriticalAngleOffsetRadians),
SpinbackThetaBoostMultiplier = TryFloat(el, "SpinbackThetaBoostMultiplier", scaleOverride.SpinbackThetaBoostMultiplier),
};
}

private static float TryFloat(JsonElement el, string name, float defaultValue)
{
return el.TryGetProperty(name, out var prop) ? prop.GetSingle() : defaultValue;
Expand Down
18 changes: 9 additions & 9 deletions addons/openfairway/physics/FlightProfile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public sealed class FlightProfile
public float LowReCdFloor { get; init; } = 0.38f;
public float LowReBlendStart { get; init; } = 30000.0f;
public float CdAt50k { get; init; } = 0.4632f;
public float CdMin { get; init; } = 0.22f;
public float CdMin { get; init; } = 0.223f;

// --- Lift caps ---
public float ClMaxBase { get; init; } = 0.268f;
Expand All @@ -65,7 +65,7 @@ public sealed class FlightProfile

// --- High-Re lift ---
public float HighReStart { get; init; } = 75000.0f;
public float HighReMidSpinGain { get; init; } = 13.8f;
public float HighReMidSpinGain { get; init; } = 16.0f;
public float HighReSpinGain { get; init; } = 16.0f;
public float HighReGainReductionStart { get; init; } = 0.10f;
public float HighReGainReductionEnd { get; init; } = 0.18f;
Expand Down Expand Up @@ -96,17 +96,17 @@ public sealed class FlightProfile
// --- Progressive spin drag cap boost (increased form drag at high SR) ---
public float SpinDragProgressiveCapSrStart { get; init; } = 0.33f;
public float SpinDragProgressiveCapSrEnd { get; init; } = 0.50f;
public float SpinDragProgressiveCapBoostMax { get; init; } = 0.0f;
public float SpinDragProgressiveCapBoostMax { get; init; } = 0.25f;

// --- Mid-spin Cl boost (bell-shaped lift recovery for mid-iron SR regime) ---
public float MidSpinClBoostSrStart { get; init; } = 0.10f;
public float MidSpinClBoostSrEnd { get; init; } = 0.35f;
public float MidSpinClBoostMax { get; init; } = 0.0f;
public float MidSpinClBoostSrStart { get; init; } = 0.17f;
public float MidSpinClBoostSrEnd { get; init; } = 0.31f;
public float MidSpinClBoostMax { get; init; } = 0.50f;

// --- High-launch drag boost ---
public float HighLaunchDragBoostMax { get; init; } = 1.18f;
public float HighLaunchDragVlaStartDeg { get; init; } = 33.0f;
public float HighLaunchDragVlaFullDeg { get; init; } = 40.0f;
public float HighLaunchDragBoostMax { get; init; } = 1.24f;
public float HighLaunchDragVlaStartDeg { get; init; } = 24.5f;
public float HighLaunchDragVlaFullDeg { get; init; } = 31.5f;
public float HighLaunchDragSrStart { get; init; } = 0.50f;
public float HighLaunchDragSrEnd { get; init; } = 0.70f;

Expand Down
Loading
Loading