You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
At the START of step 2, Python vy = 48.485757437912085 m/s, Kotlin vy = 48.48575743901033 m/s. The -1.098e-9 m/s offset was accumulated during step 1 of the trajectory. Both engines start with identical initial conditions; the step-1 RK4 computation introduces this divergence.
Post-Step-2 Position Delta Verification
Observed delta at end of step 2: py - kt = +1.31e-10 m
Parity Analysis: OpenBallistics vs py-ballisticcalc
Current State
1800 tests, 1027 failed
Tolerance target: 1e-6 across all checks
Failure Breakdown by Field
Field
Failures
Min delta
Median delta
Max delta
drop
400
8.7e-06 cm
3.7e-03 cm
2.1e-02 cm
windage
397
1.0e-06 cm
2.8e-04 cm
2.8e-03 cm
velocity
230
1.0e-06 m/s
2.6e-06 m/s
1.2e-05 m/s
tof
0
--
--
--
zero_angle
0
--
--
--
stability
0
--
--
--
Key Observations
TOF passes perfectly. Time is trivially t += dt -- no formula to diverge.
Zero angle passes perfectly. The zero-finding algorithm converges to the
same angle in both implementations.
Stability passes perfectly. Miller formula matches exactly.
Velocity fails with tiny deltas (order 1e-6). Smallest error category.
Drop fails with larger deltas (order 1e-3). Errors grow with distance.
Windage fails with medium deltas (order 1e-4). Also grows with distance.
The error pattern -- growing with distance, affecting position more than
velocity -- indicates a systematic per-step bias that compounds over
thousands of integration steps. Not floating-point noise (that would be
random and much smaller), but an actual formula difference somewhere.
Gravity: py 32.17405 ft/s^2, Kotlin 32.17405 * 0.3048 = 9.80665 m/s^2.
Both standard g.
Speed of sound: Both use sqrt(Kelvin) * 20.0467 for altitude-adjusted
calculation. Base case uses equivalent Rankine formula.
Wind decomposition: Clock-to-angle conventions match between py's
clock_to_degrees and Kotlin's (clock-12)*30. Sign conventions for
crosswind/headwind verified equivalent.
RK4 structure: Both fix km (drag factor) once per step and recompute
relative velocity at each k-substep. Structure is equivalent.
PCHIP drag table interpolation: Both use Fritsch-Carlson slope limiting
with cubic Hermite evaluation. Same algorithm.
Coriolis: Both use full 3D ENU frame transformation with same Earth
rotation constant 7.2921159e-5 rad/s.
Spin drift: py /12 (inches to feet), Kotlin *0.0254 (inches to meters).
Equivalent conversion.
Air density: Both use CIPM-2007 formula with identical coefficients.
What Has NOT Worked (Previous Attempts)
Multiple agents failed to complete this task. Most tries were baseless
and caused regressions instead of improvements.
Imperial RK4 rewrite attempted but produced a major regression (24cm
delta, 1251 failures -- far worse than current 1027).
The claim "imperial vs metric floating point differences compound" was
proven wrong: scripted analysis showed max difference from unit system
is ~1e-14, and the imperial rewrite proved the unit system is not the
cause.
Why Previous Approaches Failed
"Read both codebases and spot the difference" -- both implementations
look equivalent on the surface. If the bug were obvious from reading,
it would have been found. Multiple agents tried this and failed.
"Run experiments to isolate" (toggle corrections on/off) -- even if
we find "Coriolis causes it" by toggling, we still don't know WHY.
And if the raw RK4 already diverges, we learn nothing actionable.
"Compare constants" -- verified all constants match. Not the source.
"Rewrite to imperial" -- proved the problem is NOT the unit system,
but introduced a massive regression demonstrating how easy it is
to make things worse.
Warning: Do Not Guess Without Data
In this session, both the agent and Oracle consultant spent
significant time claiming "the zero angle is the root cause" --
that Kotlin's findZeroAngle converges to a different value than
py's, causing all downstream trajectory errors. This was treated
as a confirmed finding and written into this document as fact.
But the test data on line 16 of this file says:
| zero_angle | 0 | -- | -- | -- |
Zero failures. The zero angles match within tolerance. The entire
"zero angle is wrong" investigation was a rabbit hole built on
guesses, not data from the production code.
This happened because the agent reimplemented the algorithm in
test code, compared its output against py, saw deltas, and
attributed them to zero angle differences -- without verifying
the reimplementation matched production code. The guesses
compounded: "initial velocity differs" -> "therefore barrel
elevation differs" -> "therefore zero angle differs" -- each step
plausible but none verified against the actual running code.
Lesson: Do not invent conclusions. Only state what is proven by
data from the actual production code. If you cannot observe the
production code's internals, say so -- do not substitute guesses.
Key Constraint
py_ballisticcalc IS the oracle. We can run it programmatically and extract
any intermediate value at any step. Same with the Kotlin code via tests.
The methodology must leverage this to find the actual root cause -- not
just observations about which fields fail.
Methodology: Interactive Debugging with mcp-debugger
Why a debugger, not instrumentation
The error is a small systematic bias inside every single RK4 step. It is
NOT an error that appears at some specific step N -- it is structural,
repeating every step, compounding over thousands of steps.
Instrumentation (logging selected variables) is inferior to a debugger
because you must decide upfront what to log. If the bug is in an
intermediate you didn't think to log -- a temporary in PCHIP interpolation,
a clamped value in the barometric formula, an off-by-one in drag table
binary search -- you miss it. With a debugger you see everything.
Tool
mcp-debugger (https://github.com/debugmcp/mcp-debugger) -- a headless
MCP-based debugger supporting Python (debugpy) and Java/JVM (JDI bridge).
This gives interactive step-through debugging accessible via MCP tool calls.
Procedure
Pick ONE RK4 step -- the very first one. The bug is structural, so
step 1 shows it just as well as step 1000.
Set up two debug sessions with identical input state:
The FIRST intermediate value that differs from py's value IS the bug.
It doesn't matter which step we pick because the bug is structural --
the same wrong formula or wrong constant is applied every time.
What NOT to do
Do NOT try to find "which step" the error occurs at. It occurs at
every step.
Do NOT compare outputs across thousands of steps. Compare WITHIN
one step.
Do NOT instrument selected variables. Use the debugger to see all
of them.
Do NOT assume the unit system (imperial vs metric) is the cause.
This was disproven by scripting (max diff ~1e-14) and by failed
imperial rewrite (made things 1e+8 worse).
Do NOT toggle corrections on/off to "isolate" -- even if you find
which subsystem is involved, you still don't know why.
Instrumentation Experiment (2 rounds)
Attempted to find root cause by instrumenting both engines and
comparing intermediate values from one RK4 step.
Approach
py side: wrote a Python script that hooks into py_ballisticcalc
internals and dumps intermediate values from one RK4 step.
kt side: wrote a Kotlin test (DumpStepTest) that reimplements
the RK4 step logic with print statements.
Problem with the approach
The Kotlin instrumentation reimplemented the algorithm in test code
rather than observing the actual production code. There is no proof
that the test reimplementation matches the production code exactly.
Any conclusions drawn from comparing the test code's output against
py's output are unreliable -- they compare the test code, not the
production code.
What was observed (dimensionless values only -- reliable)
These values are unit-system-independent and were computed by
calling the actual production functions directly (not reimplemented):
Value
py
kt
delta
density_ratio
9.990529060172572e-01
9.990529060172572e-01
0
mach_number
2.300519634316875
2.300519634318955
2.1e-12
cd_from_table
2.806708950368146e-01
2.806708950366981e-01
1.2e-13
sinLat
-7.408045962867500e-01
identical
0
cosLat
6.717205893229903e-01
identical
0
Outcome
The experiment failed to identify the root cause. The dimensionless
inputs match, but conclusions about the RK4 step internals and
output state are unreliable because the Kotlin side was a
reimplementation, not the actual production code.
Status
Two rounds of instrumentation spent without finding the root cause.
Next step: interactive debugger (mcp-debugger) to observe the actual
production code's internal state during execution.
Python: debug_py_step.py paused at rk4.py:235 (velocity_vector update), step 1.
Condition: wind_vector.z < -0.5 and time < 0.001
Kotlin: DebugOneStep.kt (JVM JDWP port 5005) paused at TrajectorySolver.kt:321 (return State),
first hit from computeTrajectory (after findZeroAngle completes).
Conversion: 1/3.2808399 (Python fps → m/s)
Pre-Step Initial Conditions (t=0, before any RK4 step)
Variable
Python (imperial)
Python (metric)
Kotlin (metric)
Delta (Py-Kt)
vy
159.42842714014375 fps
48.59378451845326 m/s
48.5937845195534 m/s
-1.100e-9 m/s
vx
2462.3639022802413 fps
750.5285162742142 m/s
750.528516274143 m/s
+7.12e-11 m/s
vz
0.06271474999654547 fps
0.01911545576989 m/s
0.019115455785182 m/s
-1.54e-11 m/s
y
-0.29524680827046385 ft
-0.08999122702405 m
-0.08999122716084 m
+1.368e-10 m
Finding: vy diverges by -1.100e-9 m/s at t=0 before any RK4 step.
Atmospheric Parameters at Step 1
Parameter
Python
Kotlin
Delta
density_ratio
0.9990529060172572
0.9990529060172572
0
speed_of_sound
1072.3789197061753 fps = 326.86109422961334 m/s
326.86109422961334 m/s
0
mach_number
2.300519634316875
2.300519634316875
0
vRelMag
2467.028760211576 fps = 751.9503649695238 m/s
751.9503649695238 m/s
0
km
9.179877703954334e-4
9.179877703954333e-4
~1e-19 (negligible)
All atmospheric parameters match exactly. km is indistinguishable.
k1-k4 y-Components (dy and dvy)
dy (= vy at each substep)
Substep
Python (fps)
Python (m/s)
Kotlin (m/s)
Delta (m/s)
k1.dy
159.42842714014375
48.59378451845326
48.5937845195534
-1.1001e-9
k2.dy
159.25094610768270
48.53968829984136
48.53968830094055
-1.0992e-9
k3.dy
159.25121769811080
48.53977108060371
48.53977108170291
-1.0992e-9
k4.dy
159.07400737915293
48.48575737546746
48.48575737656571
-1.0983e-9
dvy (= acceleration at each substep)
Substep
Python (fps²)
Python (m/s²)
Kotlin (m/s²)
Delta (m/s²)
k1.dvy
-141.9848259688565
-43.276974889526
-43.276974890286
+7.594e-10
k2.dvy
-141.7675536263693
-43.210750279637
-43.210750280395
+7.581e-10
k3.dvy
-141.7679043963284
-43.210857194320
-43.210857195078
+7.581e-10
k4.dvy
-141.5511926737834
-43.144803461389
-43.144803462146
+7.568e-10
dvy Component Breakdown for k1
Python (metric)
Component
Value (m/s²)
gravity
-9.806650425093892
drag_y
-33.54337778443632
coriolis_y
+0.07305332000375
total dvy
-43.276974889526
Kotlin
Component
Value (m/s²)
gravity
-9.806650425093892
drag_y
-33.54337778519574
coriolis_y
+0.07305332000374
total dvy
-43.276974890286
Delta (Py - Kt)
Component
Delta (m/s²)
gravity
0
drag_y
+7.594e-10
coriolis_y
+1.05e-14 ≈ 0
total dvy
+7.594e-10
Root Cause Analysis
First divergence location
The divergence is NOT introduced within step 1. The vy offset of -1.100e-9 m/s is present
before any RK4 step executes (at t=0). The step-1 computation correctly propagates this offset
through all substeps; no new divergence mechanism appears within the step.
What causes the k1.dvy divergence
The drag term differs because vyAir (= vy, since wind has no y component) differs:
Gravity and Coriolis contribute zero to the divergence.
Origin of the initial vy offset
The vy0 difference comes from the residual zero-angle discrepancy. From prior analysis
(debugger-findings.md): after the PCHIP interpolation fix, the zero-angle residual is ~1.5e-12 rad.
This produces:
Δvy ≈ v0 × cos(barrelElevation) × Δ(zeroAngle)
≈ 752.1 × cos(0.065 rad) × 1.46e-12 rad
≈ 1.10e-9 m/s
Estimated from vy delta: ΔzeroAngle = 1.100e-9 / (752.1 × 0.9979) = 1.463e-12 rad -- matches the
1.5e-12 rad residual.
Why vRelMag Matches Despite vy Offset
Python vy is -1.100e-9 smaller, but vx is +7.1e-11 larger. The magnitude is:
So Python and Kotlin report identical vRelMag = 751.9503649695238 m/s.
What Changes Across Substeps
The vy offset decreases very slightly from k1 to k4:
k1: -1.1001e-9 m/s
k2: -1.0992e-9 m/s
k3: -1.0992e-9 m/s
k4: -1.0983e-9 m/s
This tiny reduction (~1e-12 per substep) is because Python's acceleration is slightly less negative
(+7.58e-10 m/s²), so Python's predicted vy at each substep is slightly larger.
Summary
Question
Answer
Does divergence originate WITHIN step 1?
No -- it exists at t=0
Which substep first shows divergence?
k1 (inherited from initial vy)
What component causes dvy divergence?
Drag (100%); gravity=0, coriolis≈0
Does km differ between engines?
No -- delta ≈ 1e-19 (negligible)
Does density_ratio/mach/SOS differ?
No -- all match exactly
Does vRelMag differ?
No -- identical 751.9503649695238 m/s
Root cause of vy0 offset
Residual zero-angle difference ~1.46e-12 rad
Size of vy0 offset
-1.100e-9 m/s (Python vy < Kotlin vy)
The chain
findZeroAngle() converges to slightly different values: Python vs Kotlin differ by ~1.5e-12 rad
This causes vy0 to differ by -1.100e-9 m/s at t=0
vyAir = vy (no y-component of wind), so vyAir differs by same amount
This scales through drag: Δ(drag) = km × vRelMag × Δvy = -7.59e-10 m/s²
Δ(dvy) = +7.59e-10 m/s² at every substep of every step
After step 1: vy post-step delta ≈ -1.098e-9 m/s (matches step-2 pre-step value from k1k4-comparison.md)
Debugging Session Details
Python MCP: port 3002, session 0d28fdf5-4531-485d-8736-9edde9b509f2
Kotlin MCP: port 3001, session 008369fa-5e48-4316-a41d-df97862cda95
Python breakpoint: rk4.py:235, condition wind_vector.z < -0.5 and time < 0.001
Kotlin breakpoint: DebugOneStep.kt:47 then TrajectorySolver.kt:321, first hit = step 1
Trajectory rk4Step Parity: Python vs Kotlin -- Fresh Debugging Session
Session overview
Fresh MCP debugger investigation of the TRAJECTORY (non-zero-finding) rk4Step.
Goal: find the first numeric discrepancy between engines, with special focus on
densityAndMachAtAltitude slow path (|y × M_TO_FT| ≥ 30ft).
Infrastructure:
Python: port 3002 / session e2de1fd4 / debug session 655c6bc1
Kotlin breakpoint:DebugOneStep.kt:47 then TrajectorySolver.kt:321
(return State), first hit from integrate = trajectory step 1.
Conversion: Python fps → m/s = 1 / 3.2808399
Part 1: Trajectory step 1 (t = 0, fast path)
1a. Initial state (before any rk4Step)
Variable
Python (imperial)
Python (metric)
Kotlin (metric)
Delta (Py−Kt)
y
−0.29524680827046385 ft
−0.08999122702405071 m
−0.08999122702405073 m
+1.39e−17 m ≈ 0
vx
2462.3639022802413 fps
750.5285162742142 m/s
750.5285162742096 m/s
+4.66e−12 m/s
vy
159.42842714014375 fps
48.59378451845326 m/s
48.59378451852677 m/s
−7.35e−11 m/s ← FIRST DISCREPANCY
vz
0.06271474999654547 fps
0.019115455769892 m/s
0.019115455770848 m/s
−9.57e−13 m/s
The first value that differs is vy0 = −7.35e−11 m/s (Python vy less than Kotlin vy).
This is 15× smaller than the previous session's −1.100e−9 m/s, indicating that
prior fixes have significantly reduced the zero-angle residual.
The densityAndMachAtAltitude slow path (previously un-debugger-checked) produces
identical results in both engines:
density_ratio: delta = 8.88e−16 (machine epsilon)
speedOfSound: delta = 0.0 after unit conversion
mach number: delta = 4.44e−16
Temperature, pressure, and density formulas match to 6e−14 °C / 1e−12 hPa / 8e−15 K.
All within float64 arithmetic noise. No bug here.
2. First discrepancy: initial vy = −7.35e−11 m/s
The FIRST value that differs in the trajectory rk4Step is vy at t = 0 (before any step):
Python vy0 = 48.59378451845326 m/s
Kotlin vy0 = 48.59378451852677 m/s
Δvy0 = −7.35e−11 m/s
Origin: zero-angle residual of ~9.8e−14 rad. This is 15× smaller than the previous
session's residual (−1.100e−9 m/s / 1.46e−12 rad), indicating the PCHIP fix and
other improvements have been highly effective.
3. Propagation of the vy0 offset
The vy0 offset propagates through drag at every substep:
Accumulated Δy per step ≈ 7.35e−11 × 0.0025 ≈ 1.84e−13 m
After 1000 steps: ≈ 1.84e−10 m = 1.84e−8 cm (50× below 1e−6 cm tolerance)
4. Why 179/300 failures persist (inference)
The per-step error from the vy0 residual is below tolerance. The 179/300 failures
mentioned in the task description are likely explained by one or more of:
a) The zero-angle residual itself (9.8e−14 rad) shifts the entire trajectory
slightly, causing drop differences at long range
b) The task's 179/300 figure may reflect a test scenario with different scale
than what the per-step analysis covers
c) There may be a different discrepancy not yet found in a code path not reached
in this session (e.g., very late trajectory steps where y becomes large negative,
extreme look angles, or corner cases in the drag table)
The slow path densityAndMachAtAltitude has now been debugger-verified and
is NOT the source of the remaining errors.
Debugging session details
Python MCP: port 3002, session e2de1fd4-c142-4eaa-9392-fc8a0030e6d4
Kotlin: org.openballistics.engine.TrajectorySolver:219 (line val heightDiffFt = result.y * M_TO_FT).
Fixture: zero-finding scenario from debug_py_step.py -- same fixture as previous RK4 investigation: G7 BC=0.209, v0=752.1 m/s, sight_height=90mm, zero_distance=100m, ICAO atmosphere, no wind, no Coriolis.
Iteration 0 (first zero-finding pass)
Python values at base_engine.py:941
Variable
Value
t.time
0.1394718993868613 s
t.distance >> Distance.Foot
328.0839895013124 ft
t.height >> Distance.Foot
-0.5984794650678591 ft
t.slant_height >> Distance.Foot
-0.5984794650678591 ft
t.slant_distance >> Distance.Foot
328.0839895013124 ft
float(t.velocity) (raw m/s)
683.6020680335728 m/s
t.angle >> Angular.Radian
-0.0019090777222547874 rad
props.barrel_elevation_rad
0.0
iterations_count
0
target_x_ft
328.0839895013124 ft
slant_range_ft
328.0839895013124 ft
look_angle_rad
0.0
After stepping through lines 941--955:
Variable
Value
height_diff_ft
-0.5984794650678591 ft
look_dist_ft
328.0839895013124 ft
range_diff_ft
0.0 ft
trajectory_angle
-0.0019090777222547874 rad
sensitivity
-0.0 (= tan(0)*tan(-0.00190) = 0)
denominator
328.0839895013124 ft
correction
0.0018241654095268343 rad
Kotlin values at TrajectorySolver.kt:219
Variable
Value
result.getX()
100.0 m
result.getY()
-0.18241654096248966 m
result.getVx()
683.600822212303 m/s
result.getVy()
-1.3050486881440964 m/s
barrelElevation
0.0
distFt
328.08398950131 ft
targetXft
328.08398950131 ft
After stepping through lines 219--229:
Variable
Value
heightDiffFt
-0.5984794651000274 ft
rangeDiffFt
0.0 ft
rangeErrorFt
0.0 ft
heightErrorFt
0.5984794651000274 ft
trajectoryAngle
-0.0019090777253160365 rad
sensitivity
-0.0 (= tan(0)*tan(-0.00190) = 0)
denominator
328.08398950131 ft
correction
0.0018241654096248965 rad
Iteration 0 comparison
Quantity
Python
Kotlin
Delta
height at target (ft)
-0.5984794650678591
-0.5984794651000274
+3.22e-11 ft
height at target (m)
-0.18241654...
-0.18241654096248966
+9.8e-12 m
trajectoryAngle (rad)
-0.0019090777222547874
-0.0019090777253160365
+3.06e-10 rad
sensitivity
-0.0
-0.0
~0
denominator (ft)
328.0839895013124
328.08398950131
+2.4e-12 ft
correction (rad)
0.0018241654095268343
0.0018241654096248965
+9.81e-13 rad
The height difference at target (9.8e-12 m) is smaller than the input sight-height discrepancy (1.37e-10 m) because the PCHIP interpolation at x=100m partially offsets the DC offset propagated through 53 RK4 steps.
The correction difference is 9.81e-13 rad (= 0.2 micro-arcsecond). This is floating-point noise.
barrelElevation += 0.0 * 0.7 = 0.0 -- ZERO correction applied at iter 0!
Convergence path comparison
Python (2 iterations):
Iter 0: applies correction 0.001824 rad → barrel_elevation = 0.001824
Iter 1: height_diff_ft = 7.14e-7 ft < ZERO_FINDING_ACCURACY → break (converged)
Kotlin (3 iterations):
Iter 0: damping triggered, applies 0.0 correction → barrel_elevation still 0.0
Iter 1: same trajectory as iter 0 (barrel still 0.0), heightErrorFt = 0.5984 = prevHeightError = 0.5984 → NOT strictly greater → resets dampingFactor=1.0 in else if (dampingFactor < 1.0) branch. Applies full correction: barrel_elevation = 0.001824
Iter 2: height_diff_ft ≈ same as Python iter 1 (7e-7 ft) < ZERO_FINDING_ACCURACY → break (converged)
Iteration 1 values
Python iteration 1 (pre-step at base_engine.py:941)
Variable
Value
t.time
0.13947213827831106 s
t.height >> Distance.Foot
7.136772288890479e-07 ft
t.angle >> Angular.Radian
-8.491915029834548e-05 rad
props.barrel_elevation_rad
0.0018241654095268343 rad
Post-step (after line 955):
Variable
Value
height_diff_ft
7.136772288890479e-07 ft
trajectory_angle
-8.491915029834548e-05 rad
sensitivity
-1.549067487745518e-07
denominator
328.0839386788882 ft
True correction
-2.175288530620692e-09 rad (computed as -height_diff_ft/denominator)
prev_height_error_ft
0.5984794650678591 ft
Python breaks after iter 1 (height_error_ft = 7.14e-7 < ZERO_FINDING_ACCURACY). True correction at iter 1 is negligible (-2.18e-9 rad).
Kotlin iteration 1 (at TrajectorySolver.kt:219)
At the start of Kotlin iter 1:
barrelElevation = 0.0 (no correction applied at iter 0 due to damping)
prevHeightError = 0.5984794651000274 (from iter 0)
dampingFactor = 0.7 (reduced by iter 0's damping branch)
lastCorrection = 0.0
Kotlin iter 1 runs the same trajectory as iter 0 (barrel still 0.0):
heightDiffFt = -0.5984794651000274 ft (same as iter 0)
trajectoryAngle = -0.0019090777253160365 rad (same as iter 0)
correction (computed) = 0.0018241654096248965 (same as iter 0)
Both engines converge to essentially the same zero angle. Python applies it in 2 iterations, Kotlin in 3 iterations (extra iteration wasted due to dampingFactor initialization difference).
Engine
Final barrel_elevation (rad)
Python (after iter 1 break)
0.0018241654095268343
Kotlin (after iter 1 update)
0.0018241654096248965
Delta
9.81e-13 rad
9.81e-13 rad corresponds to:
Trajectory height error at 100m: 9.81e-13 × 100 = 9.81e-11 m = 9.8e-9 cm
This is 100,000x smaller than the smallest observed test failure (1e-6 cm)
PCHIP interpolation at target
Python's _integrate with filter_flags=TrajFlag.NONE runs until x >= target_x_ft and returns the last trajectory point (the crossover). The Python height at 100m is the value from the PCHIP interpolation inside the _integrate engine.
Kotlin's simulateTrajectory uses 3-point PCHIP interpolation at targetXm:
Python: -0.5984794650678591 ft (= -0.18241654095... m)
Kotlin: -0.5984794651000274 ft (= -0.18241654096248966 m)
Delta: 3.22e-11 ft = 9.8e-12 m
This tiny difference is consistent with the 1.37e-10 m sight-height input discrepancy partially propagated through PCHIP interpolation of 53 RK4 steps. The PCHIP x-points differ in the last digits (Python uses ft-based RK4, Kotlin uses m-based), but both are within 1e-10 of the true physics.
Summary
What matches
The correction formula -height_diff_ft / denominator is identical in both engines.
The computed correction at iteration 0 differs by only 9.81e-13 rad (floating-point noise level).
Both engines converge to the same zero angle (verified: 0 test failures for zero_angle).
The PCHIP-interpolated height at 100m differs by only 3.22e-11 ft.
Structural difference found
Python initializes prev_height_error_ft = 9e9 (line 922), so no damping is ever triggered at iteration 0. Kotlin initializes prevHeightError = ZERO_FINDING_ACCURACY * 2.0 (line 207, a tiny value), so damping IS triggered at iteration 0. This causes Kotlin to waste one extra iteration but ultimately converge to the same zero angle.
Why fixing sight height made tests slightly WORSE (253→254 failures)
The sight-height discrepancy (1.37e-10 m) has essentially NO effect on the zero-finding correction (9.81e-13 rad difference in zero angle, which is 10,000x below any test tolerance). The DC offset of 1.37e-10 m in y0 propagates into every trajectory step, affecting drop and windage slightly. Fixing Kotlin's sight height changes the trajectory (not the zero angle), moving drop values in a direction that doesn't improve parity with Python's existing test fixtures.
Conclusion
The zero-finding correction is NOT the source of the 1027 test failures. Both engines produce essentially identical zero angles (delta: 9.81e-13 rad). The test failures in drop and windage must originate in the POST-zero-finding trajectory calculation, where per-step errors accumulate over hundreds of integration steps.
Method: MCP debugger (no reimplementation scripts).
Kotlin: JVM launched with JDWP (suspend=y, port 5005). Class:
org.openballistics.engine.DebugOneStep. Debugger: Docker MCP at
port 3001, Java/JDI adapter. Breakpoint: FQCN
org.openballistics.engine.TrajectorySolver, line 321 (start of
return State(...) inside rk4Step).
Python: MCP at port 3002, debugpy adapter. Script:
.ai/scripts/debug_py_step.py. Breakpoint: rk4.py:235 with
condition filter_flags == 0 and integration_step_count == 1.
Confirmed context:
Python: breakpoint at _integrate called from _zero_angle
(zero-finding path), barrel_elevation_rad = 0.0
Kotlin: paused at rk4Step called from simulateTrajectory
called from findZeroAngle, barrelElevation = 0.0
Both are at the first rk4Step of zero-finding (barrelElevation=0,
first iteration, integration_step_count=1).
Conversion: Python fps → metric via / 3.2808399 (as prescribed).
1. Initial state
Value
Python (raw)
Python (metric)
Kotlin
Delta
x
0.0 ft
0.0 m
0.0 m
0
y
-0.2952755905511810774 ft
-0.0899999998632 m
-0.09 m
+1.368e-10 m
z
0.0 ft
0.0 m
0.0 m
0
vx
2.467519688790000146e+03 fps
7.521000000000000e+02 m/s
7.521000000000000e+02 m/s
0
vy
0.0 fps
0.0 m/s
0.0 m/s
0
vz
0.0 fps
0.0 m/s
0.0 m/s
0
FIRST DISCREPANCY: initial y (sight height)
PY y0 = -90/304.8 ft / 3.2808399 = -8.999999986319999479e-02 m
KT y0 = -0.09 m = -8.999999999999999667e-02 m
Delta = +1.368e-10 m (Python is 1.37e-10 m LESS NEGATIVE = higher)
Root cause: Python converts sight height using
90 mm / 304.8 mm/ft = 0.29527559055...ft (exact SI definition:
1 ft = 304.8 mm). Kotlin converts directly: 90 mm / 1000 = 0.09 m.
When comparing via the velocity constant: 90/(304.8 * 3.2808399):
Python uses 1 ft = 304.8 mm (exact, no rounding) for distances, but
1 m/s = 3.2808399 fps (rounded) for velocities. These two constants
are inconsistent: 304.8 * 3.2808399 = 1000.00000152 != 1000.
2. Atmospheric values
Value
Python (raw)
Python (metric)
Kotlin
Delta
density_ratio
1.0004255875331192
1.0004255875331192
1.0004255875331192
0
speedOfSound
1116.4499224539381 fps
340.2939... m/s
340.29393584671357 m/s
~0
Mach (dimensionless)
2.2101481124799878
same
2.2101481124799878
0
relative_speed
2467.51968879 fps
752.1 m/s
752.1 m/s (vRelMag)
0
Note: |y0| = 0.295 ft < altThreshold = 30 ft, so Kotlin uses base
density directly (ignoring y). The y discrepancy does NOT affect
density or speed of sound.
3. Drag constants
Value
Python (raw)
Python (metric)
Kotlin
Delta
bc
0.209
0.209
0.209
0
cd
(via Mach lookup)
(same Mach)
0.2858162989141716
~0
km (k_m)
2.853236510428392e-04
9.361012187550235e-04
9.361012187550236e-04
1.1e-19
4. k1 derivatives (a1 in Python)
Value
Python (fps/fps²)
Python (metric)
Kotlin
Delta
dvx (a1.x)
-1.737236822178505918e+03 fps²
-5.295097825951538e+02 m/s²
-5.295097825951539e+02 m/s²
1.1e-13
dvy (a1.y)
-3.217405000000000115e+01 fps²
-9.806650425093892e+00 m/s²
-9.806650425093892e+00 m/s²
0
dvz
0.0
0.0
0.0
0
dx (v1.x)
2.467519688790000146e+03 fps
7.521000000000000e+02 m/s
7.521000000000000e+02 m/s
0
dy (v1.y)
0.0 fps
0.0 m/s
0.0 m/s
0
All deltas at 10^-13 level (floating-point rounding noise). No meaningful discrepancy.
5. k2 derivatives (a2 in Python)
Value
Python (metric)
Kotlin
Delta
dvx
-5.285782003124626e+02 m/s²
-5.285782003124627e+02 m/s²
1.1e-13
dvy
-9.798027656390975e+00 m/s²
-9.798027656390975e+00 m/s²
0
dx
7.514381127717561e+02 m/s
7.514381127717561e+02 m/s
0
dy
-1.225831303136737e-02 m/s
-1.225831303136737e-02 m/s
0
6. k3 derivatives (a3 in Python)
Value
Python (metric)
Kotlin
Delta
dvx
-5.285798385525998e+02 m/s²
-5.285798385525997e+02 m/s²
-1.1e-13
dvy
-9.798035224848261e+00 m/s²
-9.798035224847938e+00 m/s²
-3.2e-13
dx
7.514392772496095e+02 m/s
7.514392772496094e+02 m/s
1.1e-13
dy
-1.224753457048872e-02 m/s
-1.224753457048872e-02 m/s
0
7. k4 derivatives (a4 in Python)
Value
Python (metric)
Kotlin
Delta
dvx
-5.276507062121468e+02 m/s²
-5.276507062121468e+02 m/s²
0
dvy
-9.789435161674662e+00 m/s²
-9.789435161674012e+00 m/s²
-6.5e-13
dx
7.507785504036185e+02 m/s
7.507785504036185e+02 m/s
0
dy
-2.449508806212065e-02 m/s
-2.449508806211985e-02 m/s
-8.1e-16
8. Final state after rk4Step
Value
Python (metric)
Kotlin
Delta
x_new
1.878597221019312480e+00 m
1.878597221019312480e+00 m
0
y_new
-9.003062768956076e-02 m
-9.003062782636076e-02 m
+1.368e-10 m
vx_new
7.507785514306095e+02 m/s
7.507785514306095e+02 m/s
0
vy_new
4.848575...e-02 m/s
4.848575...e-02 m/s
-5.3e-16
Summary
First discrepancy: Initial y position (sight height) at ENTRY to rk4Step.
KT: y0 = -0.09 m (exact: 90 mm / 1000)
PY: y0 = -90/(304.8 * 3.2808399) m = -0.0899999998632 m
Delta = +1.368e-10 m
All intermediate computations inside rk4Step (density, mach, cd, km,
k1-k4, velocity update, position update) match to floating-point
precision (10^-13 or better). The y discrepancy is purely the input
difference propagated unchanged through the step.
Does this y discrepancy affect the result?
NO effect on density/speed of sound (|y| = 0.295 ft < 30 ft threshold
-- base values used directly in both engines)
NO effect on km, k1-k4, or velocity (all velocity-based, not position)
YES: affects final y position output by exactly 1.368e-10 m
CUMULATIVE: after N steps at 0.0025s timestep, y error grows as
N * 1.368e-10 m (since dy matches exactly). After 40 steps (0.1 s),
y error ≈ 5.5e-9 m. This is far smaller than ZERO_FINDING_ACCURACY
(5e-6 ft = 1.5e-6 m), so it does NOT affect the zero-finding
convergence in any meaningful way.
Conclusion: The y0 sight-height discrepancy (1.37e-10 m) is the
FIRST numeric difference when comparing step 1 of zero-finding. It
originates from Python's use of exact 1 ft = 304.8 mm for distances
(vs Kotlin's direct metric storage), combined with the rounded
3.2808399 ft/m constant for velocity conversion. The physical velocity
v0 = 752.1 m/s is represented identically in both engines when converted
via 3.2808399. All RK4 substep accelerations match to floating-point
precision.
The previously reported 1.1e-06 discrepancy in vx at step 2 of
zero-finding was observed using a DIFFERENT fixture (full shot, not ICAO
zero-finding). In the ICAO zero-finding scenario (barrelElevation=0,
no wind, no coriolis), step 1 of the zero-finding rk4Step shows no
meaningful discrepancy beyond 1.37e-10 m in y (sight height artifact).