Skip to content

Consolidate quadratic solving#296

Merged
ekiefl merged 2 commits into
mainfrom
ek/consolidate-quadratic-solvers
May 18, 2026
Merged

Consolidate quadratic solving#296
ekiefl merged 2 commits into
mainfrom
ek/consolidate-quadratic-solvers

Conversation

@ekiefl
Copy link
Copy Markdown
Owner

@ekiefl ekiefl commented May 18, 2026

Consolidate quadratic solvers

Summary

pooltool/ptmath/roots/quadratic.py previously carried two solvers:

  • solve(a, b, c) -> tuple[float, float] — simple Cardano formula. Real-valued only; returns (nan, nan) for negative discriminant.
  • solve_complex(a, b, c) -> NDArray[np.complex128] — robust complex-valued implementation with a sign-trick and product-identity fallback to avoid catastrophic cancellation.

The 3d branch already collapsed these into a single solve that does what main's solve_complex does. Main carried a # TODO comment for this exact migration. This PR completes it.

After this PR: one solver, named solve, returning a length-2 complex array. The five legacy callers and one test fixture are migrated.

What's in this PR

Solver consolidation (pooltool/ptmath/roots/quadratic.py):

  • Deleted simple solve.
  • Renamed solve_complexsolve.
  • Removed the # TODO: In the branch 3d, ... this function has replaced solve comment.

Caller migrations (5 files):

  • pooltool/physics/utils.py::get_airborne_timemath.isnannp.isnan; explicit .imag check; max(t1.real, t2.real) for the descending-leg root. import math dropped.
  • pooltool/physics/motion/solve.py::ball_linear_cushion_collision_timefrom math import acos, isnanfrom math import acos; isnan(root)np.isnan(root); pass root.real (not the complex root) into evolve_ball_motion.
  • pooltool/physics/resolve/ball_ball/core.pyquadratic.solve_complex(...)quadratic.solve(...).
  • pooltool/physics/resolve/ball_cushion/core.py — same.
  • tests/evolution/event_based/test_simulate.py::true_time_to_collision — skip nan/complex roots, take .real.

Tests added (tests/ptmath/roots/test_quadratic.py):

  • Byte-faithful port from the 3d branch (103 lines, 6 tests). Main previously had zero direct test coverage for the quadratic solver. Covers: standard quadratics, perfect-square, complex roots, large-coefficient numerical stability, linear case, two degenerate cases.

Why this matters (and the risk that was managed)

The new solver is more numerically robust than the old. For typical inputs the difference is below machine epsilon, but in catastrophic-cancellation cases (one root much smaller than the other) the new solver can produce roots that differ in the last 5–7 digits from the old one. Cushion-collision detection (ball_linear_cushion_collision_time) is in the hot path for every shot, and event-detection results are sensitive to root precision — small shifts can cascade into different event ordering and different shot trajectories.

What all tests passing proves and doesn't prove

  • The shots in the test suite (test_case1, test_case2, test_case3, test_grazing_ball_ball_collision, test_high_speed_grazing_collisions, test_newtons_cradle_*, layouts, etc.) all produce event times within their existing pytest.approx tolerances after migration.
  • It does not prove that every conceivable shot produces the same event times. Borderline configurations not represented in the test suite (very tangent cushion approaches, very slow contacts at the edge of detection) could in principle compute a more accurate but different cushion-collision time with the new solver. The migration is in the right direction (more correct) but a regression test for a shot you haven't recorded yet could still surprise you.

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Improved collision detection in ball-to-ball and ball-to-cushion interactions with enhanced handling of complex numerical edge cases.
    • Increased numerical stability and precision in physics calculations through refined mathematical root-solving algorithms.
  • Tests

    • Added comprehensive test suite for quadratic equation solver covering standard equations, complex number roots, linear equation fallback scenarios, and degenerate boundary conditions.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 86340f6e-1f40-4e4d-8d89-2635da721dea

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

This PR migrates the quadratic root solver from a dual real-only/complex interface to a single unified complex-capable solver, and updates all physics collision and motion code to extract real parts and filter NaN values from the new complex return type.

Changes

Quadratic Solver Complex Domain Migration

Layer / File(s) Summary
Quadratic solver complex domain implementation
pooltool/ptmath/roots/quadratic.py
The solve() function changes return type from real tuple to NDArray[complex128]; imports cmath instead of math, applies sign-trick discriminant computation with numerator-size fallback, derives second root via product identity, and handles degenerate quadratic/linear/constant cases with complex NaN fills. The separate solve_complex() variant is removed.
Collision resolution complex root handling
pooltool/physics/resolve/ball_ball/core.py, pooltool/physics/resolve/ball_cushion/core.py
Both make_kiss methods switch from calling solve_complex() to the unified solve() function; existing filtering by relative imaginary magnitude and real-root selection logic is preserved.
Physics motion and utility layer complex root integration
pooltool/physics/motion/solve.py, pooltool/physics/utils.py
Linear-cushion collision handling replaces math.isnan() with np.isnan() for NaN checks on complex roots; evolve_ball_motion argument changes to root.real extraction. The get_airborne_time function refactored to compute roots via solve() and return max(t1.real, t2.real) instead of NaN-based discriminant fallback; math import removed.
Quadratic solver comprehensive test coverage
tests/ptmath/roots/test_quadratic.py
New test file validates standard real roots (distinct, repeated, difference-of-squares), complex conjugate roots, numerical stability with large coefficients, and degenerate cases (linear fallback, no solution, infinite solutions), asserting correct values and NaN handling.
Integration test complex root handling
tests/evolution/event_based/test_simulate.py
Collision-time filtering logic updated to compare t.real against bounds and assign collision_time = t.real instead of raw root value.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • ekiefl/pooltool#201: Both PRs adjust ball–linear-cushion collision-time computation to robustly filter invalid (NaN) quadratic roots before using them for time/position updates.
  • ekiefl/pooltool#186: Both PRs modify pooltool/ptmath/roots/quadratic.py's solve logic (handling near-zero/degenerate a cases), and this PR's broader solver-domain change builds directly on that same function.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Consolidate quadratic solving' clearly and concisely describes the main objective of the PR: replacing two separate quadratic solvers with a single consolidated solver.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ek/consolidate-quadratic-solvers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ekiefl
Copy link
Copy Markdown
Owner Author

ekiefl commented May 18, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 18, 2026

Codecov Report

❌ Patch coverage is 57.14286% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 47.45%. Comparing base (5e5a885) to head (1e85067).

Files with missing lines Patch % Lines
pooltool/physics/motion/solve.py 33.33% 2 Missing ⚠️
pooltool/physics/utils.py 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #296      +/-   ##
==========================================
+ Coverage   47.41%   47.45%   +0.03%     
==========================================
  Files         153      153              
  Lines       10509    10493      -16     
==========================================
- Hits         4983     4979       -4     
+ Misses       5526     5514      -12     
Flag Coverage Δ
service 47.45% <57.14%> (+0.03%) ⬆️
service-no-ani 57.97% <57.14%> (+0.08%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pooltool/physics/utils.py`:
- Around line 63-64: The code takes the .real part of roots from quadratic.solve
and can return a non-physical touchdown time when roots are complex; instead,
after calling quadratic.solve (variables t1, t2), check whether the roots are
real (e.g., test abs(t1.imag) and abs(t2.imag) against a tiny tolerance or use
numpy.isreal); if both are real return max(t1.real, t2.real) as before,
otherwise preserve the prior “no real root” behavior by returning None (or the
module’s sentinel for “no touchdown”) rather than coercing complex values to
real.

In `@tests/evolution/event_based/test_simulate.py`:
- Around line 372-374: The loop that uses quadratic.solve to pick a
collision_time currently reads t.real without ensuring the root is effectively
real; modify the loop that iterates over quadratic.solve(...) and only consider
roots whose imaginary part is negligible (e.g., abs(t.imag) < a small tolerance
like 1e-9) before using t.real, and ignore complex roots with significant
imaginary components so collision_time is set only from physically meaningful
real roots.

In `@tests/ptmath/roots/test_quadratic.py`:
- Line 69: The test currently attempts to sort complex roots with "solutions =
sorted([u1, u2])" which raises TypeError; update this to use a deterministic key
that compares real then imaginary parts like the other tests (e.g., sorted([u1,
u2], key=lambda z: (z.real, z.imag))) so the complex NDArray values returned by
solve() can be ordered; modify the line referencing u1 and u2 accordingly to
match existing tests' sorting approach.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e923af71-8c2b-4f08-893a-40faec28c561

📥 Commits

Reviewing files that changed from the base of the PR and between 5e5a885 and 7170e3d.

📒 Files selected for processing (7)
  • pooltool/physics/motion/solve.py
  • pooltool/physics/resolve/ball_ball/core.py
  • pooltool/physics/resolve/ball_cushion/core.py
  • pooltool/physics/utils.py
  • pooltool/ptmath/roots/quadratic.py
  • tests/evolution/event_based/test_simulate.py
  • tests/ptmath/roots/test_quadratic.py

Comment thread pooltool/physics/utils.py
Comment on lines 63 to +64
t1, t2 = quadratic.solve(-0.5 * g, rvw[1, 2], rvw[0, 2] - R)

if math.isnan(t1):
return np.inf

return max(t1, t2)
return max(t1.real, t2.real)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve no-real-root handling for airborne touchdown time.

Line 64 currently returns a finite time from complex roots by taking .real, which can schedule a non-physical touchdown when there is no real intersection.

Proposed fix
-    t1, t2 = quadratic.solve(-0.5 * g, rvw[1, 2], rvw[0, 2] - R)
-    return max(t1.real, t2.real)
+    t1, t2 = quadratic.solve(-0.5 * g, rvw[1, 2], rvw[0, 2] - R)
+    t1_is_real = np.abs(t1.imag) <= const.EPS
+    t2_is_real = np.abs(t2.imag) <= const.EPS
+
+    if not t1_is_real and not t2_is_real:
+        return np.inf
+
+    r1 = t1.real if t1_is_real else -np.inf
+    r2 = t2.real if t2_is_real else -np.inf
+    return max(r1, r2)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
t1, t2 = quadratic.solve(-0.5 * g, rvw[1, 2], rvw[0, 2] - R)
if math.isnan(t1):
return np.inf
return max(t1, t2)
return max(t1.real, t2.real)
t1, t2 = quadratic.solve(-0.5 * g, rvw[1, 2], rvw[0, 2] - R)
t1_is_real = np.abs(t1.imag) <= const.EPS
t2_is_real = np.abs(t2.imag) <= const.EPS
if not t1_is_real and not t2_is_real:
return np.inf
r1 = t1.real if t1_is_real else -np.inf
r2 = t2.real if t2_is_real else -np.inf
return max(r1, r2)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pooltool/physics/utils.py` around lines 63 - 64, The code takes the .real
part of roots from quadratic.solve and can return a non-physical touchdown time
when roots are complex; instead, after calling quadratic.solve (variables t1,
t2), check whether the roots are real (e.g., test abs(t1.imag) and abs(t2.imag)
against a tiny tolerance or use numpy.isreal); if both are real return
max(t1.real, t2.real) as before, otherwise preserve the prior “no real root”
behavior by returning None (or the module’s sentinel for “no touchdown”) rather
than coercing complex values to real.

Comment on lines 372 to +374
for t in quadratic.solve(0.5 * mu_r * g, -V0, eps):
if t >= 0 and t < collision_time:
collision_time = t
if t.real >= 0 and t.real < collision_time:
collision_time = t.real
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate that roots are real before using their real component.

The code extracts t.real without verifying that the imaginary part is negligible. For a collision-time calculation, only real-valued roots are physically meaningful. If the solver returns a root with a significant imaginary component (e.g., 2.0 + 0.1j), the current code would incorrectly use 2.0 as a valid collision time.

Since this helper computes the ground-truth collision time for test validation, it should be as strict as production code about root validity.

🛡️ Proposed fix to filter complex roots
     collision_time = np.inf
     for t in quadratic.solve(0.5 * mu_r * g, -V0, eps):
-        if t.real >= 0 and t.real < collision_time:
+        if not np.isnan(t) and np.isclose(t.imag, 0.0, atol=1e-9) and t.real >= 0 and t.real < collision_time:
             collision_time = t.real
     return collision_time

Alternatively, to match the pattern in other physics modules, check the imaginary part separately:

     collision_time = np.inf
     for t in quadratic.solve(0.5 * mu_r * g, -V0, eps):
-        if t.real >= 0 and t.real < collision_time:
+        if np.isnan(t) or not np.isclose(t.imag, 0.0, atol=1e-9):
+            continue
+        if t.real >= 0 and t.real < collision_time:
             collision_time = t.real
     return collision_time
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for t in quadratic.solve(0.5 * mu_r * g, -V0, eps):
if t >= 0 and t < collision_time:
collision_time = t
if t.real >= 0 and t.real < collision_time:
collision_time = t.real
for t in quadratic.solve(0.5 * mu_r * g, -V0, eps):
if not np.isnan(t) and np.isclose(t.imag, 0.0, atol=1e-9) and t.real >= 0 and t.real < collision_time:
collision_time = t.real
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/evolution/event_based/test_simulate.py` around lines 372 - 374, The
loop that uses quadratic.solve to pick a collision_time currently reads t.real
without ensuring the root is effectively real; modify the loop that iterates
over quadratic.solve(...) and only consider roots whose imaginary part is
negligible (e.g., abs(t.imag) < a small tolerance like 1e-9) before using
t.real, and ignore complex roots with significant imaginary components so
collision_time is set only from physically meaningful real roots.

Comment thread tests/ptmath/roots/test_quadratic.py Outdated
# This should give one very large and one very small solution.
a, b, c = 1.0, -1e7, 1.0
u1, u2 = solve(a, b, c)
solutions = sorted([u1, u2])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Run the specific test to confirm it fails with TypeError

pytest tests/ptmath/roots/test_quadratic.py::test_solve_large_values -v

Repository: ekiefl/pooltool

Length of output: 106


🏁 Script executed:

cat -n tests/ptmath/roots/test_quadratic.py | sed -n '60,80p'

Repository: ekiefl/pooltool

Length of output: 897


🏁 Script executed:

# Check if file exists and get basic info
wc -l tests/ptmath/roots/test_quadratic.py

Repository: ekiefl/pooltool

Length of output: 100


🏁 Script executed:

# Search for similar uses of sorted() in the test file to understand patterns
rg "sorted\(" tests/ptmath/roots/test_quadratic.py -B 2 -A 2

Repository: ekiefl/pooltool

Length of output: 1251


🏁 Script executed:

# Check the quadratic solver implementation to understand what types it returns
fd test_quadratic.py

Repository: ekiefl/pooltool

Length of output: 96


🏁 Script executed:

# Find and examine the solve() function implementation
fd -t f "*.py" | xargs rg "def solve" | grep -i quadratic

Repository: ekiefl/pooltool

Length of output: 463


🏁 Script executed:

# Check the imports and module structure
head -30 tests/ptmath/roots/test_quadratic.py

Repository: ekiefl/pooltool

Length of output: 946


🏁 Script executed:

# Verify Python's behavior with sorting complex numbers
python3 << 'EOF'
try:
    result = sorted([1+2j, 3+4j])
    print("Success:", result)
except TypeError as e:
    print("TypeError:", e)
EOF

Repository: ekiefl/pooltool

Length of output: 133


Complex numbers cannot be sorted without a key function.

Line 69 attempts to sort complex numbers directly, which will raise a TypeError at runtime because complex numbers do not support comparison operators (<, >). The solve() function returns NDArray[np.complex128], making both u1 and u2 complex128 values. This is inconsistent with all other tests in the file, which use sorted([u1, u2], key=lambda z: (z.real, z.imag)).

🐛 Proposed fix
-    solutions = sorted([u1, u2])
+    solutions = sorted([u1, u2], key=lambda z: (z.real, z.imag))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
solutions = sorted([u1, u2])
solutions = sorted([u1, u2], key=lambda z: (z.real, z.imag))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/ptmath/roots/test_quadratic.py` at line 69, The test currently attempts
to sort complex roots with "solutions = sorted([u1, u2])" which raises
TypeError; update this to use a deterministic key that compares real then
imaginary parts like the other tests (e.g., sorted([u1, u2], key=lambda z:
(z.real, z.imag))) so the complex NDArray values returned by solve() can be
ordered; modify the line referencing u1 and u2 accordingly to match existing
tests' sorting approach.

@ekiefl ekiefl merged commit 0d674f7 into main May 18, 2026
12 checks passed
@ekiefl ekiefl deleted the ek/consolidate-quadratic-solvers branch May 18, 2026 01:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant