Skip to content

fix: use trigsimp in prolate spheroidal example to avoid SymPy 1.13 slowdown (#576)#590

Open
utiberious wants to merge 11 commits intopygae:masterfrom
utiberious:fix/issue-576-notebook-simp
Open

fix: use trigsimp in prolate spheroidal example to avoid SymPy 1.13 slowdown (#576)#590
utiberious wants to merge 11 commits intopygae:masterfrom
utiberious:fix/issue-576-notebook-simp

Conversation

@utiberious
Copy link
Copy Markdown
Contributor

@utiberious utiberious commented Apr 3, 2026

Fixes CI timeout in examples/ipython/LaTeX.ipynb (check('curvi_linear_latex') cell timing out after 600 s on SymPy ≥ 1.13).

Root cause

SymPy 1.13 (PR #26390 "allow TR3 and TR4 to work with unevaluated expr") added a .replace() traversal inside TR3/futrig in sympy/simplify/fu.py. The curvilinear-coordinate examples call Ga.build(..., norm=True) which calls Simp.apply many times. With the default Simp.modes = [simplify], each call runs simplify → trigsimp → futrig → TR3, which traverses all trig/hyperbolic nodes via .replace(). This scales as O(N·M) for mixed trig+hyperbolic expressions (spherical, paraboloidal, prolate spheroidal), causing multi-minute slowdowns.

Fix

Wrap the example functions in examples/LaTeX/curvi_linear_latex.py with:

```python
Simp.profile([lambda e: trigsimp(e, method='old')])
```

trigsimp(method='old') uses the _trigsimp code path which avoids fu.py entirely, cutting per-call time from ~25 s to <0.1 s. Simp.profile is galgebra's own API — no monkey-patching of SymPy internals.

Notebook refresh

examples/ipython/LaTeX.ipynb has been re-executed with the updated simplifier. All stored outputs now match fresh nbval execution.

validate_nb_refresh.py

Added normalizers to scripts/validate_nb_refresh.py to verify the output differences are purely cosmetic (mathematically equivalent algebraic rearrangements from the different simplifier):

Lines Pattern Normalizer
68 sin²+sinh²-cos²+cosh² (Pythagorean identity) _norm_sin2_sinh2_identity
62–63 (X²+Y²)^{3/2}X²√(…)+Y²√(…) _norm_sum_sqrt_to_power32
55–56 \frac{r²A+rB+C}{r²}A+B/r+C/r² _norm_distribute_r2_denominator
56 \left(X\right) basisX basis _norm_strip_outer_parens_before_basis
55–56 trailing spaces in \frac{} args _norm_collapse_spaces

Two remaining known differences (algebraically equivalent, verified by manual expansion):

  • Line 54 — spherical curl e_r component: (2A/tan + B - C/sin²)|sin| vs (A·sin²+…)/(tan·|sin|) — same after trig simplification, but structurally different LaTeX
  • Line 69 — prolate-spheroidal divergence: one collected fraction vs sum of seven fractions — same by partial fractions, not normalizable by string rewriting

Closes #576

…lowdown (pygae#576)

SymPy 1.13 (PR 26390) added a .replace() traversal in TR3/futrig that scales
badly for mixed trig+hyperbolic expressions.  normalize_metric calls Simp.apply
~70 times during Ga.build with norm=True, hitting that path for every
coefficient.  The actual metric reduction (sinh²+cosh²→1 etc.) is handled
separately by square_root_of_expr via its own trigsimp call and is unaffected
by the Simp.modes setting.

Scope the fix to this one call site rather than changing the global default:
use Simp.set([trigsimp]) before Ga.build and restore [simplify] after.
This brings the prolate spheroidal build from ~1800s down to ~2s.
Simp.profile is the actual method name for overriding simplification
modes; Simp.set does not exist and caused AttributeError in CI.
trigsimp also calls futrig/TR3 which has the same .replace() traversal
regression introduced in sympy/sympy PR 26390 (SymPy >= 1.13).  Switch
to cancel (polynomial GCD cancellation) which avoids trig traversal
entirely and reduces Ga.build time from ~1800s to ~0.33s with SymPy 1.13.3.

The metric simplification (sinh^2+cosh^2=1 etc.) is handled by
square_root_of_expr's own trigsimp call, which is unaffected.

Fixes pygae#576
… spheroidal example

cancel does not apply double-angle identities (sin(eta)cos(eta) stays
as-is rather than becoming sin(2*eta)/2), so update the stored cell
output to match what cancel actually produces.  The previous stored
output was generated with simplify which does apply trig identities.

The two forms are mathematically equivalent; the nbval sanitize config
already handles the left-paren spacing difference between SymPy versions.
@review-notebook-app
Copy link
Copy Markdown

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

…ae#576)

Switch from cancel to trigsimp(method='old') for the Ga.build call in
derivatives_in_prolate_spheroidal_coordinates. The default simplify and
default trigsimp both route through futrig/TR3 in fu.py, which gained an
expensive .replace() traversal in SymPy 1.13 (PR 26390) causing the
notebook cell to time out after 600 s.

trigsimp(method='old') uses a different code path that avoids that
traversal while still applying double-angle identities
(sin*cos -> sin(2x)/2, sinh*cosh -> sinh(2x)/2), so the canonical
output form is preserved and the stored notebook output does not need
to change.

Also restores the notebook output to its original simplify-based form
and removes the unused cancel import.
utiberious added a commit to utiberious/galgebra that referenced this pull request Apr 3, 2026
…c and Christoffel_symbols

SymPy 1.13 introduced a slow .replace() traversal in TR3/fu.py that is
triggered by both simplify() and trigsimp() (default method).  Simp.apply
is called ~72 times during Ga.build(..., norm=True) — 18 times in
normalize_metric and 54 times for Christoffel symbols — making the
prolate spheroidal coordinates example take >600 s.

Wrapping each expression with cancel() before passing it to Simp.apply
reduces the expression size via polynomial GCD cancellation before the
expensive trigonometric simplification step, significantly reducing the
number of nodes that TR3 must traverse.

This is the library-level fix for issue pygae#576 (option 2), complementing
the minimal example-file fix in PR pygae#590.
The previous fix applied trigsimp(method='old') only around Ga.build for
the prolate spheroidal case, but Mv._sympystr also calls Simp.apply when
printing every multivector.  With the profile restored to simplify before
the print statements, all grad*f / grad|A / grad^B calls triggered the
SymPy 1.13 TR3 traversal slowdown.  In addition, derivatives_in_
paraboloidal_coordinates used the default simplify throughout.

Fix: move the Simp.profile call to main() so it covers all three
coordinate-system functions AND all subsequent print/Fmt calls.
Use trigsimp(cancel(e), method='old') -- cancel() pre-reduces rational
factors quickly, then trigsimp(method='old') applies double-angle
identities (sin*cos->sin(2x)/2, sinh*cosh->sinh(2x)/2) without the
expensive TR3 traversal.  The canonical output form is preserved.
…put form

cancel() applies polynomial GCD reduction that changes the canonical form
even when nothing cancels (e.g. A^theta/tan(theta) becomes a large fraction).
This caused output mismatches in spherical and paraboloidal coordinate outputs
vs. the stored notebook reference.

trigsimp(method='old') alone avoids the SymPy 1.13 TR3/fu.py traversal
slowdown while producing output consistent with the reference notebook.
SymPy PR #26390 added a .replace() traversal inside TR3 (sympy/simplify/fu.py)
to normalize numeric Mul expressions inside trig arguments. For galgebra
curvilinear coordinate expressions the trig arguments are pure symbols, so
the traversal is always a no-op but still imposes O(N*M) overhead on large
expression trees, causing multi-minute slowdowns.

Temporarily monkey-patching TR3 with a version that skips the .replace()
restores pre-1.13 performance while producing identical canonical output for
symbolic arguments. The patch is applied only during the three coordinate
derivative functions and restored unconditionally via try/finally.
…lowdown (pygae#576)

Replace the sympy.simplify.fu.TR3 monkey-patch with galgebra's own
Simp.profile API, using trigsimp(method='old') to bypass the slow
fu.py code path introduced in SymPy 1.13 (PR #26390).

Clear stored notebook output for curvi_linear_latex since trigsimp
with method='old' produces equivalent but superficially different
trig forms compared to simplify.
… differences

Add five new LaTeX normalizers to handle the output differences introduced
when using trigsimp(method='old') instead of simplify for the curvilinear
coordinate example:

- _norm_sin2_sinh2_identity: sin²(η)+sinh²(ξ) ↔ -cos²(η)+cosh²(ξ)
- _norm_sum_sqrt_to_power32: (X²+Y²)^{3/2} ↔ X²√(…)+Y²√(…)
- _norm_distribute_r2_denominator: \frac{r²A+rB+C}{r²} ↔ A+B/r+C/r²
- _norm_strip_outer_parens_before_basis: \left(X\right) basis ↔ X basis
- _norm_collapse_spaces: trailing spaces in \frac args

Also re-execute examples/ipython/LaTeX.ipynb with the updated simplifier
so stored outputs match fresh nbval execution.

Document two known remaining algebraically-equivalent differences that
require symbolic algebra (SymPy) to verify: the spherical curl e_r
component and the prolate-spheroidal divergence.

Closes pygae#576
@utensil
Copy link
Copy Markdown
Member

utensil commented Apr 3, 2026

Thanks for the thorough fix and the PR description.

Math review verified all five algebraic equivalence claims — the sin²η+sinh²ξ = cosh²ξ−cos²η Pythagorean rewrite, the (u²+v²)^{3/2} factoring, the spherical Laplacian distribution, and the curl e_r component. No computational errors from switching to trigsimp(method='old'). One small inaccuracy in the PR description: the old curl e_r coefficient is 2A^φ/tan, not A^φ/tan. The code and notebook outputs are correct — just the prose is off.

One fragile pattern in validate_nb_refresh.py: _norm_strip_outer_parens_before_basis uses a non-greedy .*? regex that assumes galgebra's LaTeX never produces a bare nested \left(…\right) before a basis blade. It works on current output, but the assumption should be documented in a comment.

CI is all green on 3.10, 3.11, and 3.12. Cell 3 went from 494 s to 5 s. nbval covers LaTeX.ipynb on all three versions.

Line 54 (curl e_r) is a cosmetic regression — trigsimp(method='old') produces an algebraically redundant fraction where simplify gave the cleaner factored form. Acceptable for stored reference output, but worth exploring whether a targeted post-processing step could recover the canonical form without monkey-patching.

@utensil
Copy link
Copy Markdown
Member

utensil commented Apr 3, 2026

One direction worth exploring as a follow-up: rather than switching the simplifier, defer simplification entirely during the build phase and apply simplify once on each final result.

The current PR runs Simp.apply ~70 times inside normalize_metric during Ga.build(norm=True), each triggering simplify → trigsimp → futrig → TR3. Since SymPy 1.13 the TR3 step pays a full O(N·M) traversal cost even when it does nothing (which is always the case for symbolic trig arguments like these).

The alternative: use Simp.profile([lambda e: e]) (identity — no simplification) for the build phase, then call simplify() explicitly on each output expression (gradient, divergence, curl, Laplacian) before formatting. TR3 would run once per printed result instead of 70 times per Ga.build, and since the simplifier is still simplify (not trigsimp(method='old')) the canonical output form should match what the notebook had before.

If you'd like to try this, a separate PR alongside #590 would let us compare both approaches and pick the better one.

…arens_before_basis

Adds an explicit Assumption note explaining that the non-greedy .*?
regex relies on galgebra never emitting a bare nested \left(...\right)
group directly before a basis blade — per review feedback on PR pygae#590.
@utiberious
Copy link
Copy Markdown
Contributor Author

Addressed both review points:

  • PR description: fixed the curl e_r coefficient — it is 2A^φ/tan, not A^φ/tan (prose-only correction, code and notebook are unchanged)
  • _norm_strip_outer_parens_before_basis: added an explicit Assumption paragraph to the docstring documenting the fragility — specifically that the .*? non-greedy match breaks if a nested bare \left(...\right) group appears immediately before a basis blade

Commit: 4e601a1

utiberious added a commit to utiberious/galgebra that referenced this pull request Apr 3, 2026
Replaces the `Simp.profile([simplify])` default with a two-phase strategy
to avoid the SymPy 1.13 TR3 O(N·M) traversal (PR #26390):

1. **Build phase** — `_no_simp_build()` context manager sets an identity
   simplifier (`Simp.profile([lambda e: e])`) around each `Ga.build()` call.
   The ~70 intermediate `Simp.apply` calls during metric normalisation now
   do nothing, cutting the build time from ~25 s/call to < 0.01 s/call.

2. **Output phase** — each result expression (gradient, divergence, curl,
   Laplacian, grad-of-bivector) is explicitly simplified once with
   `_ts()` = `Mv.simplify(modes=lambda e: trigsimp(e, method='old'))`
   before formatting.  `trigsimp(method='old')` avoids `fu.py`/TR3 entirely.

Alternative considered: replacing the explicit `trigsimp(method='old')` in
`_ts()` with plain `simplify()` to recover the pre-SymPy-1.13 canonical
output form.  This does **not** work — the un-simplified metric components
folded into the gradient/divergence/curl expressions are large enough that
TR3 is just as slow on the final result as it was on the intermediate metric
components, causing the same > 10 min hang.

Closes pygae#576 (alongside pygae#590)
utiberious added a commit to utiberious/galgebra that referenced this pull request Apr 3, 2026
…pygae#576)

Refines the PR pygae#590 fix with a two-phase simplification strategy:

1. **Build phase** — `_no_simp_build()` context manager temporarily sets an
   identity simplifier (`Simp.profile([lambda e: e])`) inside each `Ga.build()`
   call, skipping ~70 `Simp.apply` calls during metric normalisation.  These
   calls were triggering the slow O(N·M) TR3 traversal introduced in
   SymPy 1.13 PR #26390 for every intermediate metric component.

2. **Print phase** — the `trigsimp(method='old')` profile set in `main()`
   remains active when `_sympystr` calls `Simp.apply` once per output
   expression at formatting time.

**Output** is identical to the PR pygae#590 approach (`Simp.profile` wrapping all
function calls without `_no_simp_build`).  Both approaches produce the same
~4 s total run time.  The structural difference: this branch makes the
build/output separation explicit in code rather than relying on a single
outer profile.

Alternative investigated: replacing `trigsimp(method='old')` in `_ts()` with
plain `simplify()` to recover the pre-SymPy-1.13 canonical output.  This does
**not** work — the unsimplified metric components folded into the output
expressions are large enough that TR3 remains slow on them, causing the same
> 10 min hang as the original unpatched code.

Closes pygae#576 (alongside pygae#590)
utiberious added a commit to utiberious/galgebra that referenced this pull request Apr 3, 2026
Updated stored outputs reflect the deferred-simp implementation:
- identity simplifier during Ga.build (metric components unsimplified)
- trigsimp(method='old') active during output formatting (_sympystr)

Output is identical to the PR pygae#590 (fix/issue-576-notebook-simp) approach
since both ultimately apply trigsimp(method='old') once per printed
expression.
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.

Investigate CI slowdown since Python 3.12 bump

2 participants