From 900c16d7f87c65c785ad456acce32c15c442eb95 Mon Sep 17 00:00:00 2001 From: Ulysses Tiberious <132908333+utiberious@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:26:06 -0400 Subject: [PATCH 1/2] fix: avoid slow SymPy simplification path [AGENT: miro] --- doc/changelog.rst | 11 +- examples/LaTeX/curvi_linear_latex.py | 28 +--- examples/ipython/LaTeX.ipynb | 210 +++++++++++++-------------- galgebra/_utils/simplify.py | 51 +++++++ galgebra/metric.py | 3 +- scripts/validate_nb_refresh.py | 15 +- test/test_simplify.py | 92 ++++++++++++ 7 files changed, 264 insertions(+), 146 deletions(-) create mode 100644 galgebra/_utils/simplify.py create mode 100644 test/test_simplify.py diff --git a/doc/changelog.rst b/doc/changelog.rst index d02ede32..48987987 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -15,11 +15,12 @@ Changelog time out after 600 s on SymPy ≥ 1.13. SymPy PR #26390 added an O(N·M) ``.replace()`` traversal inside ``TR3``/``futrig`` that is a no-op for galgebra's symbolic trig arguments but dominated each of the ~70 - ``Simp.apply`` calls during ``Ga.build(norm=True)`` for curvilinear - coordinates. The fix uses ``trigsimp(method='old')`` via ``Simp.profile`` - for the affected example, cutting run time from > 600 s to < 6 s. - A notebook note documents the two cosmetic output differences from the - pre-1.13 form; a proper upstream fix is tracked in :issue:`576`. + ``Simp.apply`` calls for large curvilinear-coordinate expressions. + ``Simp`` now detects expressions likely to trigger that traversal and uses + ``trigsimp(method='old')`` for those expressions while retaining + ``simplify`` for smaller expressions. Explicit ``Simp.profile`` settings + continue to replace the default simplifier. This library-level fallback + also removes the need for an example-wide profile override. - :support:`589` Added Step 0 to the release-process runbook (``doc/dev/release-process.md``): open a release issue before preparing the diff --git a/examples/LaTeX/curvi_linear_latex.py b/examples/LaTeX/curvi_linear_latex.py index 77c54de2..b85eaa91 100644 --- a/examples/LaTeX/curvi_linear_latex.py +++ b/examples/LaTeX/curvi_linear_latex.py @@ -182,26 +182,14 @@ def main(): #Eprint() Format() - # SymPy >= 1.13 (PR #26390) added a slow O(N*M) traversal inside - # sympy.simplify.fu that causes timeouts on curvilinear coordinate - # expressions. Use trigsimp(method='old') via Simp.profile to avoid - # that code path entirely for this example. - from sympy import trigsimp - from galgebra.metric import Simp - - orig_modes = Simp.modes[:] - Simp.profile([lambda e: trigsimp(e, method='old')]) - try: - derivatives_in_spherical_coordinates() - derivatives_in_paraboloidal_coordinates() - # FIXME This takes ~600 seconds - # derivatives_in_elliptic_cylindrical_coordinates() - derivatives_in_prolate_spheroidal_coordinates() - #derivatives_in_oblate_spheroidal_coordinates() - #derivatives_in_bipolar_coordinates() - #derivatives_in_toroidal_coordinates() - finally: - Simp.profile(orig_modes) + derivatives_in_spherical_coordinates() + derivatives_in_paraboloidal_coordinates() + # FIXME This takes ~600 seconds + # derivatives_in_elliptic_cylindrical_coordinates() + derivatives_in_prolate_spheroidal_coordinates() + #derivatives_in_oblate_spheroidal_coordinates() + #derivatives_in_bipolar_coordinates() + #derivatives_in_toroidal_coordinates() # xpdf() xpdf(pdfprog=None) diff --git a/examples/ipython/LaTeX.ipynb b/examples/ipython/LaTeX.ipynb index d223f590..c28c2569 100644 --- a/examples/ipython/LaTeX.ipynb +++ b/examples/ipython/LaTeX.ipynb @@ -5,10 +5,10 @@ "execution_count": 1, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:13.252080Z", - "iopub.status.busy": "2026-04-03T11:45:13.251991Z", - "iopub.status.idle": "2026-04-03T11:45:13.256444Z", - "shell.execute_reply": "2026-04-03T11:45:13.255817Z" + "iopub.execute_input": "2026-07-30T01:23:45.019088Z", + "iopub.status.busy": "2026-07-30T01:23:45.018989Z", + "iopub.status.idle": "2026-07-30T01:23:45.025921Z", + "shell.execute_reply": "2026-07-30T01:23:45.025149Z" } }, "outputs": [], @@ -23,10 +23,10 @@ "execution_count": 2, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:13.257948Z", - "iopub.status.busy": "2026-04-03T11:45:13.257848Z", - "iopub.status.idle": "2026-04-03T11:45:13.792038Z", - "shell.execute_reply": "2026-04-03T11:45:13.791371Z" + "iopub.execute_input": "2026-07-30T01:23:45.027824Z", + "iopub.status.busy": "2026-07-30T01:23:45.027739Z", + "iopub.status.idle": "2026-07-30T01:23:45.619478Z", + "shell.execute_reply": "2026-07-30T01:23:45.618525Z" } }, "outputs": [ @@ -106,10 +106,10 @@ "execution_count": 3, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:13.806616Z", - "iopub.status.busy": "2026-04-03T11:45:13.806489Z", - "iopub.status.idle": "2026-04-03T11:45:18.812151Z", - "shell.execute_reply": "2026-04-03T11:45:18.811337Z" + "iopub.execute_input": "2026-07-30T01:23:45.638048Z", + "iopub.status.busy": "2026-07-30T01:23:45.637831Z", + "iopub.status.idle": "2026-07-30T01:23:51.049616Z", + "shell.execute_reply": "2026-07-30T01:23:51.048942Z" } }, "outputs": [ @@ -170,22 +170,22 @@ "\\begin{equation*} B = B^{r\\theta } \\boldsymbol{e}_{r}\\wedge \\boldsymbol{e}_{\\theta } + B^{r\\phi } \\boldsymbol{e}_{r}\\wedge \\boldsymbol{e}_{\\phi } + B^{\\theta \\phi } \\boldsymbol{e}_{\\theta }\\wedge \\boldsymbol{e}_{\\phi } \\end{equation*}\n", "\\begin{equation*} \\boldsymbol{\\nabla} f = \\partial_{r} f \\boldsymbol{e}_{r} + \\frac{\\partial_{\\theta } f }{r^{2}} \\boldsymbol{e}_{\\theta } + \\frac{\\partial_{\\phi } f }{r^{2} {\\sin{\\left (\\theta \\right )}}^{2}} \\boldsymbol{e}_{\\phi } \\end{equation*}\n", "\\begin{equation*} \\boldsymbol{\\nabla} \\cdot A = \\frac{A^{\\theta } }{\\tan{\\left (\\theta \\right )}} + \\partial_{\\phi } A^{\\phi } + \\partial_{r} A^{r} + \\partial_{\\theta } A^{\\theta } + \\frac{2 A^{r} }{r} \\end{equation*}\n", - "\\begin{equation*} \\boldsymbol{\\nabla} \\times A = -I (\\boldsymbol{\\nabla} \\W A) = \\frac{A^{\\phi } {\\sin{\\left (\\theta \\right )}}^{2} + A^{\\phi } \\sin{\\left (\\theta \\right )} \\cos{\\left (\\theta \\right )} \\tan{\\left (\\theta \\right )} + {\\sin{\\left (\\theta \\right )}}^{2} \\tan{\\left (\\theta \\right )} \\partial_{\\theta } A^{\\phi } - \\tan{\\left (\\theta \\right )} \\partial_{\\phi } A^{\\theta } }{\\tan{\\left (\\theta \\right )} \\left|{\\sin{\\left (\\theta \\right )}}\\right|} \\boldsymbol{e}_{r} - \\frac{r^{2} {\\sin{\\left (\\theta \\right )}}^{2} \\partial_{r} A^{\\phi } + 2 r A^{\\phi } {\\sin{\\left (\\theta \\right )}}^{2} - \\partial_{\\phi } A^{r} }{r^{2} \\left|{\\sin{\\left (\\theta \\right )}}\\right|} \\boldsymbol{e}_{\\theta } + \\frac{r^{2} \\partial_{r} A^{\\theta } + 2 r A^{\\theta } - \\partial_{\\theta } A^{r} }{r^{2} \\left|{\\sin{\\left (\\theta \\right )}}\\right|} \\boldsymbol{e}_{\\phi } \\end{equation*}\n", - "\\begin{equation*} \\nabla^{2}f = \\partial^{2}_{r} f + \\frac{2 \\partial_{r} f }{r} + \\frac{\\partial^{2}_{\\theta } f }{r^{2}} + \\frac{\\partial_{\\theta } f }{r^{2} \\tan{\\left (\\theta \\right )}} + \\frac{\\partial^{2}_{\\phi } f }{r^{2} {\\sin{\\left (\\theta \\right )}}^{2}} \\end{equation*}\n", - "\\begin{equation*} \\boldsymbol{\\nabla} \\W B = \\left ( \\partial_{r} B^{\\theta \\phi } + \\frac{4 B^{\\theta \\phi } }{r} - \\frac{2 B^{r\\phi } }{r^{2} \\tan{\\left (\\theta \\right )}} - \\frac{\\partial_{\\theta } B^{r\\phi } }{r^{2}} + \\frac{\\partial_{\\phi } B^{r\\theta } }{r^{2} {\\sin{\\left (\\theta \\right )}}^{2}}\\right ) \\boldsymbol{e}_{r}\\wedge \\boldsymbol{e}_{\\theta }\\wedge \\boldsymbol{e}_{\\phi } \\end{equation*}\n", + "\\begin{equation*} \\boldsymbol{\\nabla} \\times A = -I (\\boldsymbol{\\nabla} \\W A) = \\left(\\frac{2 A^{\\phi } }{\\tan{\\left (\\theta \\right )}} + \\partial_{\\theta } A^{\\phi } - \\frac{\\partial_{\\phi } A^{\\theta } }{{\\sin{\\left (\\theta \\right )}}^{2}}\\right) \\left|{\\sin{\\left (\\theta \\right )}}\\right| \\boldsymbol{e}_{r} + \\frac{- r^{2} {\\sin{\\left (\\theta \\right )}}^{2} \\partial_{r} A^{\\phi } - 2 r A^{\\phi } {\\sin{\\left (\\theta \\right )}}^{2} + \\partial_{\\phi } A^{r} }{r^{2} \\left|{\\sin{\\left (\\theta \\right )}}\\right|} \\boldsymbol{e}_{\\theta } + \\frac{r^{2} \\partial_{r} A^{\\theta } + 2 r A^{\\theta } - \\partial_{\\theta } A^{r} }{r^{2} \\left|{\\sin{\\left (\\theta \\right )}}\\right|} \\boldsymbol{e}_{\\phi } \\end{equation*}\n", + "\\begin{equation*} \\nabla^{2}f = \\frac{r^{2} \\partial^{2}_{r} f + 2 r \\partial_{r} f + \\partial^{2}_{\\theta } f + \\frac{\\partial_{\\theta } f }{\\tan{\\left (\\theta \\right )}} + \\frac{\\partial^{2}_{\\phi } f }{{\\sin{\\left (\\theta \\right )}}^{2}}}{r^{2}} \\end{equation*}\n", + "\\begin{equation*} \\boldsymbol{\\nabla} \\W B = \\frac{r^{2} \\partial_{r} B^{\\theta \\phi } + 4 r B^{\\theta \\phi } - \\frac{2 B^{r\\phi } }{\\tan{\\left (\\theta \\right )}} - \\partial_{\\theta } B^{r\\phi } + \\frac{\\partial_{\\phi } B^{r\\theta } }{{\\sin{\\left (\\theta \\right )}}^{2}}}{r^{2}} \\boldsymbol{e}_{r}\\wedge \\boldsymbol{e}_{\\theta }\\wedge \\boldsymbol{e}_{\\phi } \\end{equation*}\n", "Derivatives in Paraboloidal Coordinates\n", "\\begin{equation*} f = f \\end{equation*}\n", "\\begin{equation*} A = A^{u} \\boldsymbol{e}_{u} + A^{v} \\boldsymbol{e}_{v} + A^{\\phi } \\boldsymbol{e}_{\\phi } \\end{equation*}\n", "\\begin{equation*} B = B^{uv} \\boldsymbol{e}_{u}\\wedge \\boldsymbol{e}_{v} + B^{u\\phi } \\boldsymbol{e}_{u}\\wedge \\boldsymbol{e}_{\\phi } + B^{v\\phi } \\boldsymbol{e}_{v}\\wedge \\boldsymbol{e}_{\\phi } \\end{equation*}\n", "\\begin{equation*} \\boldsymbol{\\nabla} f = \\frac{\\partial_{u} f }{\\sqrt{u^{2} + v^{2}}} \\boldsymbol{e}_{u} + \\frac{\\partial_{v} f }{\\sqrt{u^{2} + v^{2}}} \\boldsymbol{e}_{v} + \\frac{\\partial_{\\phi } f }{u v} \\boldsymbol{e}_{\\phi } \\end{equation*}\n", - "\\begin{equation*} \\boldsymbol{\\nabla} \\cdot A = \\frac{u A^{u} }{u^{2} \\sqrt{u^{2} + v^{2}} + v^{2} \\sqrt{u^{2} + v^{2}}} + \\frac{v A^{v} }{u^{2} \\sqrt{u^{2} + v^{2}} + v^{2} \\sqrt{u^{2} + v^{2}}} + \\frac{\\partial_{u} A^{u} }{\\sqrt{u^{2} + v^{2}}} + \\frac{\\partial_{v} A^{v} }{\\sqrt{u^{2} + v^{2}}} + \\frac{A^{v} }{v \\sqrt{u^{2} + v^{2}}} + \\frac{A^{u} }{u \\sqrt{u^{2} + v^{2}}} + \\frac{\\partial_{\\phi } A^{\\phi } }{u v} \\end{equation*}\n", - "\\begin{equation*} \\boldsymbol{\\nabla} \\W B = \\left ( \\frac{u B^{v\\phi } }{u^{2} \\sqrt{u^{2} + v^{2}} + v^{2} \\sqrt{u^{2} + v^{2}}} - \\frac{v B^{u\\phi } }{u^{2} \\sqrt{u^{2} + v^{2}} + v^{2} \\sqrt{u^{2} + v^{2}}} - \\frac{\\partial_{v} B^{u\\phi } }{\\sqrt{u^{2} + v^{2}}} + \\frac{\\partial_{u} B^{v\\phi } }{\\sqrt{u^{2} + v^{2}}} - \\frac{B^{u\\phi } }{v \\sqrt{u^{2} + v^{2}}} + \\frac{B^{v\\phi } }{u \\sqrt{u^{2} + v^{2}}} + \\frac{\\partial_{\\phi } B^{uv} }{u v}\\right ) \\boldsymbol{e}_{u}\\wedge \\boldsymbol{e}_{v}\\wedge \\boldsymbol{e}_{\\phi } \\end{equation*}\n", + "\\begin{equation*} \\boldsymbol{\\nabla} \\cdot A = \\frac{u A^{u} }{\\left(u^{2} + v^{2}\\right)^{\\frac{3}{2}}} + \\frac{v A^{v} }{\\left(u^{2} + v^{2}\\right)^{\\frac{3}{2}}} + \\frac{\\partial_{u} A^{u} }{\\sqrt{u^{2} + v^{2}}} + \\frac{\\partial_{v} A^{v} }{\\sqrt{u^{2} + v^{2}}} + \\frac{A^{v} }{v \\sqrt{u^{2} + v^{2}}} + \\frac{A^{u} }{u \\sqrt{u^{2} + v^{2}}} + \\frac{\\partial_{\\phi } A^{\\phi } }{u v} \\end{equation*}\n", + "\\begin{equation*} \\boldsymbol{\\nabla} \\W B = \\left ( \\frac{u B^{v\\phi } }{\\left(u^{2} + v^{2}\\right)^{\\frac{3}{2}}} - \\frac{v B^{u\\phi } }{\\left(u^{2} + v^{2}\\right)^{\\frac{3}{2}}} - \\frac{\\partial_{v} B^{u\\phi } }{\\sqrt{u^{2} + v^{2}}} + \\frac{\\partial_{u} B^{v\\phi } }{\\sqrt{u^{2} + v^{2}}} - \\frac{B^{u\\phi } }{v \\sqrt{u^{2} + v^{2}}} + \\frac{B^{v\\phi } }{u \\sqrt{u^{2} + v^{2}}} + \\frac{\\partial_{\\phi } B^{uv} }{u v}\\right ) \\boldsymbol{e}_{u}\\wedge \\boldsymbol{e}_{v}\\wedge \\boldsymbol{e}_{\\phi } \\end{equation*}\n", "Derivatives in Prolate Spheroidal Coordinates\n", "\\begin{equation*} f = f \\end{equation*}\n", "\\begin{equation*} A = A^{\\xi } \\boldsymbol{e}_{\\xi } + A^{\\eta } \\boldsymbol{e}_{\\eta } + A^{\\phi } \\boldsymbol{e}_{\\phi } \\end{equation*}\n", "\\begin{equation*} B = B^{\\xi \\eta } \\boldsymbol{e}_{\\xi }\\wedge \\boldsymbol{e}_{\\eta } + B^{\\xi \\phi } \\boldsymbol{e}_{\\xi }\\wedge \\boldsymbol{e}_{\\phi } + B^{\\eta \\phi } \\boldsymbol{e}_{\\eta }\\wedge \\boldsymbol{e}_{\\phi } \\end{equation*}\n", - "\\begin{equation*} \\boldsymbol{\\nabla} f = \\frac{\\partial_{\\xi } f }{\\sqrt{- {\\cos{\\left (\\eta \\right )}}^{2} + {\\cosh{\\left (\\xi \\right )}}^{2}} \\left|{a}\\right|} \\boldsymbol{e}_{\\xi } + \\frac{\\partial_{\\eta } f }{\\sqrt{- {\\cos{\\left (\\eta \\right )}}^{2} + {\\cosh{\\left (\\xi \\right )}}^{2}} \\left|{a}\\right|} \\boldsymbol{e}_{\\eta } + \\frac{\\partial_{\\phi } f }{a \\sin{\\left (\\eta \\right )} \\sinh{\\left (\\xi \\right )}} \\boldsymbol{e}_{\\phi } \\end{equation*}\n", - "\\begin{equation*} \\boldsymbol{\\nabla} \\cdot A = \\frac{A^{\\eta } \\sin{\\left (2 \\eta \\right )}}{2 \\sqrt{- {\\cos{\\left (\\eta \\right )}}^{2} + {\\cosh{\\left (\\xi \\right )}}^{2}} {\\sin{\\left (\\eta \\right )}}^{2} \\left|{a}\\right|} + \\frac{A^{\\xi } \\sinh{\\left (2 \\xi \\right )}}{2 \\sqrt{- {\\cos{\\left (\\eta \\right )}}^{2} + {\\cosh{\\left (\\xi \\right )}}^{2}} {\\sinh{\\left (\\xi \\right )}}^{2} \\left|{a}\\right|} + \\frac{\\partial_{\\eta } A^{\\eta } }{\\sqrt{- {\\cos{\\left (\\eta \\right )}}^{2} + {\\cosh{\\left (\\xi \\right )}}^{2}} \\left|{a}\\right|} + \\frac{\\partial_{\\xi } A^{\\xi } }{\\sqrt{- {\\cos{\\left (\\eta \\right )}}^{2} + {\\cosh{\\left (\\xi \\right )}}^{2}} \\left|{a}\\right|} + \\frac{A^{\\eta } \\sin{\\left (\\eta \\right )} \\cos{\\left (\\eta \\right )}}{\\left(- \\left(\\cos{\\left (\\eta \\right )} - \\cosh{\\left (\\xi \\right )}\\right) \\left(\\cos{\\left (\\eta \\right )} + \\cosh{\\left (\\xi \\right )}\\right)\\right)^{\\frac{3}{2}} \\left|{a}\\right|} + \\frac{A^{\\xi } \\sinh{\\left (\\xi \\right )} \\cosh{\\left (\\xi \\right )}}{\\left(- \\left(\\cos{\\left (\\eta \\right )} - \\cosh{\\left (\\xi \\right )}\\right) \\left(\\cos{\\left (\\eta \\right )} + \\cosh{\\left (\\xi \\right )}\\right)\\right)^{\\frac{3}{2}} \\left|{a}\\right|} + \\frac{\\partial_{\\phi } A^{\\phi } }{a \\sin{\\left (\\eta \\right )} \\sinh{\\left (\\xi \\right )}} \\end{equation*}\n", + "\\begin{equation*} \\boldsymbol{\\nabla} f = \\frac{\\partial_{\\xi } f }{\\sqrt{{\\sin{\\left (\\eta \\right )}}^{2} + {\\sinh{\\left (\\xi \\right )}}^{2}} \\left|{a}\\right|} \\boldsymbol{e}_{\\xi } + \\frac{\\partial_{\\eta } f }{\\sqrt{{\\sin{\\left (\\eta \\right )}}^{2} + {\\sinh{\\left (\\xi \\right )}}^{2}} \\left|{a}\\right|} \\boldsymbol{e}_{\\eta } + \\frac{\\partial_{\\phi } f }{a \\sin{\\left (\\eta \\right )} \\sinh{\\left (\\xi \\right )}} \\boldsymbol{e}_{\\phi } \\end{equation*}\n", + "\\begin{equation*} \\boldsymbol{\\nabla} \\cdot A = \\frac{A^{\\eta } }{\\sqrt{- {\\cos{\\left (\\eta \\right )}}^{2} + {\\cosh{\\left (\\xi \\right )}}^{2}} \\tan{\\left (\\eta \\right )} \\left|{a}\\right|} + \\frac{A^{\\xi } }{\\sqrt{- {\\cos{\\left (\\eta \\right )}}^{2} + {\\cosh{\\left (\\xi \\right )}}^{2}} \\tanh{\\left (\\xi \\right )} \\left|{a}\\right|} + \\frac{\\partial_{\\eta } A^{\\eta } }{\\sqrt{- {\\cos{\\left (\\eta \\right )}}^{2} + {\\cosh{\\left (\\xi \\right )}}^{2}} \\left|{a}\\right|} + \\frac{\\partial_{\\xi } A^{\\xi } }{\\sqrt{- {\\cos{\\left (\\eta \\right )}}^{2} + {\\cosh{\\left (\\xi \\right )}}^{2}} \\left|{a}\\right|} + \\frac{A^{\\eta } \\sin{\\left (2 \\eta \\right )}}{2 \\left(- \\left(\\cos{\\left (\\eta \\right )} - \\cosh{\\left (\\xi \\right )}\\right) \\left(\\cos{\\left (\\eta \\right )} + \\cosh{\\left (\\xi \\right )}\\right)\\right)^{\\frac{3}{2}} \\left|{a}\\right|} + \\frac{A^{\\xi } \\sinh{\\left (2 \\xi \\right )}}{2 \\left(- \\left(\\cos{\\left (\\eta \\right )} - \\cosh{\\left (\\xi \\right )}\\right) \\left(\\cos{\\left (\\eta \\right )} + \\cosh{\\left (\\xi \\right )}\\right)\\right)^{\\frac{3}{2}} \\left|{a}\\right|} + \\frac{\\partial_{\\phi } A^{\\phi } }{a \\sin{\\left (\\eta \\right )} \\sinh{\\left (\\xi \\right )}} \\end{equation*}\n", "\\end{document}\n" ] }, @@ -202,20 +202,10 @@ "id": "sympy-1-13-note", "metadata": {}, "source": [ - "**Note \u2014 SymPy \u2265 1.13 workaround (issue [#576](https://github.com/pygae/galgebra/issues/576)):**\n", - "Two output expressions above differ cosmetically from the form produced by SymPy \u2264 1.12:\n", + "**Note — SymPy ≥ 1.13 compatibility fallback (issue [#576](https://github.com/pygae/galgebra/issues/576)):**\n", + "SymPy 1.13 PR #26390 introduced an O(N·M) traversal in `TR3`/`fu.py` that causes `simplify` to hang on large mixed trig+hyperbolic expressions. Galgebra now selects `trigsimp(method='old')` only for expressions likely to trigger that traversal, while smaller expressions continue to use `simplify`.\n", "\n", - "- **Spherical curl e_r component** (second-to-last output of the spherical section): ", - "SymPy \u2264 1.12 `simplify` factored this as `(2A^\u03c6/tan \u03b8 + \u2202A^\u03c6/\u2202\u03b8 \u2212 \u2202A^\u03b8/\u2202\u03c6/sin\u00b2\u03b8)\u00b7|sin \u03b8|`; ", - "the workaround `trigsimp(method='old')` produces an algebraically equivalent but less-factored fraction.\n", - "\n", - "- **Prolate-spheroidal divergence \u2207\u00b7A** (last output of the prolate spheroidal section): ", - "SymPy \u2264 1.12 produced a single collected fraction; `trigsimp(method='old')` produces seven ", - "separate fractions using `\u2212cos\u00b2(\u03b7)+cosh\u00b2(\u03be)` instead of the standard `sin\u00b2(\u03b7)+sinh\u00b2(\u03be)`.\n", - "\n", - "Both forms are algebraically identical. The workaround (`trigsimp(method='old')`) is used here because ", - "SymPy 1.13 PR #26390 introduced an O(N\u00b7M) traversal in `TR3`/`fu.py` that causes `simplify` to hang ", - "on mixed trig+hyperbolic expressions. A proper upstream fix is tracked in issue [#576](https://github.com/pygae/galgebra/issues/576)." + "The **prolate-spheroidal divergence ∇·A** above therefore differs cosmetically from the form produced by SymPy ≤ 1.12: it is written as separate fractions rather than one collected fraction. The two forms are algebraically identical." ] }, { @@ -223,10 +213,10 @@ "execution_count": 4, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:18.814004Z", - "iopub.status.busy": "2026-04-03T11:45:18.813890Z", - "iopub.status.idle": "2026-04-03T11:45:19.194345Z", - "shell.execute_reply": "2026-04-03T11:45:19.193661Z" + "iopub.execute_input": "2026-07-30T01:23:51.051151Z", + "iopub.status.busy": "2026-07-30T01:23:51.051079Z", + "iopub.status.idle": "2026-07-30T01:23:51.519105Z", + "shell.execute_reply": "2026-07-30T01:23:51.518429Z" } }, "outputs": [ @@ -300,10 +290,10 @@ "execution_count": 5, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:19.195968Z", - "iopub.status.busy": "2026-04-03T11:45:19.195868Z", - "iopub.status.idle": "2026-04-03T11:45:19.699697Z", - "shell.execute_reply": "2026-04-03T11:45:19.699043Z" + "iopub.execute_input": "2026-07-30T01:23:51.521198Z", + "iopub.status.busy": "2026-07-30T01:23:51.521095Z", + "iopub.status.idle": "2026-07-30T01:23:52.202383Z", + "shell.execute_reply": "2026-07-30T01:23:52.201425Z" } }, "outputs": [ @@ -400,10 +390,10 @@ "execution_count": 6, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:19.701933Z", - "iopub.status.busy": "2026-04-03T11:45:19.701776Z", - "iopub.status.idle": "2026-04-03T11:45:20.159832Z", - "shell.execute_reply": "2026-04-03T11:45:20.159132Z" + "iopub.execute_input": "2026-07-30T01:23:52.204488Z", + "iopub.status.busy": "2026-07-30T01:23:52.204385Z", + "iopub.status.idle": "2026-07-30T01:23:52.801123Z", + "shell.execute_reply": "2026-07-30T01:23:52.800519Z" } }, "outputs": [ @@ -477,10 +467,10 @@ "execution_count": 7, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:20.161935Z", - "iopub.status.busy": "2026-04-03T11:45:20.161849Z", - "iopub.status.idle": "2026-04-03T11:45:20.772636Z", - "shell.execute_reply": "2026-04-03T11:45:20.771988Z" + "iopub.execute_input": "2026-07-30T01:23:52.803291Z", + "iopub.status.busy": "2026-07-30T01:23:52.803191Z", + "iopub.status.idle": "2026-07-30T01:23:53.624668Z", + "shell.execute_reply": "2026-07-30T01:23:53.607862Z" } }, "outputs": [ @@ -565,10 +555,10 @@ "execution_count": 8, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:20.774160Z", - "iopub.status.busy": "2026-04-03T11:45:20.774073Z", - "iopub.status.idle": "2026-04-03T11:45:21.760222Z", - "shell.execute_reply": "2026-04-03T11:45:21.759445Z" + "iopub.execute_input": "2026-07-30T01:23:53.670377Z", + "iopub.status.busy": "2026-07-30T01:23:53.670067Z", + "iopub.status.idle": "2026-07-30T01:23:54.960299Z", + "shell.execute_reply": "2026-07-30T01:23:54.959127Z" } }, "outputs": [ @@ -658,10 +648,10 @@ "execution_count": 9, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:21.762255Z", - "iopub.status.busy": "2026-04-03T11:45:21.762144Z", - "iopub.status.idle": "2026-04-03T11:45:22.085345Z", - "shell.execute_reply": "2026-04-03T11:45:22.084497Z" + "iopub.execute_input": "2026-07-30T01:23:54.962211Z", + "iopub.status.busy": "2026-07-30T01:23:54.962064Z", + "iopub.status.idle": "2026-07-30T01:23:55.387261Z", + "shell.execute_reply": "2026-07-30T01:23:55.386319Z" } }, "outputs": [ @@ -738,10 +728,10 @@ "execution_count": 10, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:22.086832Z", - "iopub.status.busy": "2026-04-03T11:45:22.086748Z", - "iopub.status.idle": "2026-04-03T11:45:22.773578Z", - "shell.execute_reply": "2026-04-03T11:45:22.772730Z" + "iopub.execute_input": "2026-07-30T01:23:55.389911Z", + "iopub.status.busy": "2026-07-30T01:23:55.389066Z", + "iopub.status.idle": "2026-07-30T01:23:56.089197Z", + "shell.execute_reply": "2026-07-30T01:23:56.088676Z" } }, "outputs": [ @@ -904,10 +894,10 @@ "execution_count": 11, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:22.775265Z", - "iopub.status.busy": "2026-04-03T11:45:22.775178Z", - "iopub.status.idle": "2026-04-03T11:45:26.436837Z", - "shell.execute_reply": "2026-04-03T11:45:26.436130Z" + "iopub.execute_input": "2026-07-30T01:23:56.090753Z", + "iopub.status.busy": "2026-07-30T01:23:56.090651Z", + "iopub.status.idle": "2026-07-30T01:24:00.707813Z", + "shell.execute_reply": "2026-07-30T01:24:00.707126Z" } }, "outputs": [ @@ -1554,10 +1544,10 @@ "execution_count": 12, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:26.438737Z", - "iopub.status.busy": "2026-04-03T11:45:26.438649Z", - "iopub.status.idle": "2026-04-03T11:45:27.602180Z", - "shell.execute_reply": "2026-04-03T11:45:27.601410Z" + "iopub.execute_input": "2026-07-30T01:24:00.709452Z", + "iopub.status.busy": "2026-07-30T01:24:00.709370Z", + "iopub.status.idle": "2026-07-30T01:24:01.859802Z", + "shell.execute_reply": "2026-07-30T01:24:01.859263Z" } }, "outputs": [ @@ -1686,10 +1676,10 @@ "execution_count": 13, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:27.604358Z", - "iopub.status.busy": "2026-04-03T11:45:27.604259Z", - "iopub.status.idle": "2026-04-03T11:45:30.753990Z", - "shell.execute_reply": "2026-04-03T11:45:30.752008Z" + "iopub.execute_input": "2026-07-30T01:24:01.861386Z", + "iopub.status.busy": "2026-07-30T01:24:01.861292Z", + "iopub.status.idle": "2026-07-30T01:24:04.775812Z", + "shell.execute_reply": "2026-07-30T01:24:04.775225Z" } }, "outputs": [ @@ -1771,10 +1761,10 @@ "execution_count": 14, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:30.760597Z", - "iopub.status.busy": "2026-04-03T11:45:30.760247Z", - "iopub.status.idle": "2026-04-03T11:45:31.888018Z", - "shell.execute_reply": "2026-04-03T11:45:31.887325Z" + "iopub.execute_input": "2026-07-30T01:24:04.777347Z", + "iopub.status.busy": "2026-07-30T01:24:04.777272Z", + "iopub.status.idle": "2026-07-30T01:24:05.733160Z", + "shell.execute_reply": "2026-07-30T01:24:05.732576Z" } }, "outputs": [ @@ -1860,10 +1850,10 @@ "execution_count": 15, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:31.889630Z", - "iopub.status.busy": "2026-04-03T11:45:31.889558Z", - "iopub.status.idle": "2026-04-03T11:45:32.262697Z", - "shell.execute_reply": "2026-04-03T11:45:32.261823Z" + "iopub.execute_input": "2026-07-30T01:24:05.734670Z", + "iopub.status.busy": "2026-07-30T01:24:05.734566Z", + "iopub.status.idle": "2026-07-30T01:24:06.065371Z", + "shell.execute_reply": "2026-07-30T01:24:06.064640Z" } }, "outputs": [ @@ -1936,10 +1926,10 @@ "execution_count": 16, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:32.264596Z", - "iopub.status.busy": "2026-04-03T11:45:32.264502Z", - "iopub.status.idle": "2026-04-03T11:45:33.119752Z", - "shell.execute_reply": "2026-04-03T11:45:33.119052Z" + "iopub.execute_input": "2026-07-30T01:24:06.066719Z", + "iopub.status.busy": "2026-07-30T01:24:06.066649Z", + "iopub.status.idle": "2026-07-30T01:24:06.888614Z", + "shell.execute_reply": "2026-07-30T01:24:06.888075Z" } }, "outputs": [ @@ -2018,10 +2008,10 @@ "execution_count": 17, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:33.121964Z", - "iopub.status.busy": "2026-04-03T11:45:33.121860Z", - "iopub.status.idle": "2026-04-03T11:45:33.684660Z", - "shell.execute_reply": "2026-04-03T11:45:33.683976Z" + "iopub.execute_input": "2026-07-30T01:24:06.890251Z", + "iopub.status.busy": "2026-07-30T01:24:06.890154Z", + "iopub.status.idle": "2026-07-30T01:24:07.463221Z", + "shell.execute_reply": "2026-07-30T01:24:07.462661Z" } }, "outputs": [ @@ -2107,10 +2097,10 @@ "execution_count": 18, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:33.686158Z", - "iopub.status.busy": "2026-04-03T11:45:33.686080Z", - "iopub.status.idle": "2026-04-03T11:45:35.460486Z", - "shell.execute_reply": "2026-04-03T11:45:35.459508Z" + "iopub.execute_input": "2026-07-30T01:24:07.465009Z", + "iopub.status.busy": "2026-07-30T01:24:07.464923Z", + "iopub.status.idle": "2026-07-30T01:24:09.150063Z", + "shell.execute_reply": "2026-07-30T01:24:09.148709Z" } }, "outputs": [ @@ -2236,10 +2226,10 @@ "execution_count": 19, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:35.462148Z", - "iopub.status.busy": "2026-04-03T11:45:35.462061Z", - "iopub.status.idle": "2026-04-03T11:45:36.473752Z", - "shell.execute_reply": "2026-04-03T11:45:36.473116Z" + "iopub.execute_input": "2026-07-30T01:24:09.151657Z", + "iopub.status.busy": "2026-07-30T01:24:09.151563Z", + "iopub.status.idle": "2026-07-30T01:24:10.145651Z", + "shell.execute_reply": "2026-07-30T01:24:10.144923Z" } }, "outputs": [ @@ -2473,10 +2463,10 @@ "execution_count": 20, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:36.475599Z", - "iopub.status.busy": "2026-04-03T11:45:36.475512Z", - "iopub.status.idle": "2026-04-03T11:45:36.945398Z", - "shell.execute_reply": "2026-04-03T11:45:36.944653Z" + "iopub.execute_input": "2026-07-30T01:24:10.147007Z", + "iopub.status.busy": "2026-07-30T01:24:10.146936Z", + "iopub.status.idle": "2026-07-30T01:24:10.629989Z", + "shell.execute_reply": "2026-07-30T01:24:10.629217Z" } }, "outputs": [ @@ -2564,10 +2554,10 @@ "execution_count": 21, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:36.946821Z", - "iopub.status.busy": "2026-04-03T11:45:36.946716Z", - "iopub.status.idle": "2026-04-03T11:45:37.580138Z", - "shell.execute_reply": "2026-04-03T11:45:37.579208Z" + "iopub.execute_input": "2026-07-30T01:24:10.631509Z", + "iopub.status.busy": "2026-07-30T01:24:10.631431Z", + "iopub.status.idle": "2026-07-30T01:24:11.270855Z", + "shell.execute_reply": "2026-07-30T01:24:11.270208Z" } }, "outputs": [ @@ -2668,10 +2658,10 @@ "execution_count": 22, "metadata": { "execution": { - "iopub.execute_input": "2026-04-03T11:45:37.581988Z", - "iopub.status.busy": "2026-04-03T11:45:37.581853Z", - "iopub.status.idle": "2026-04-03T11:45:38.206471Z", - "shell.execute_reply": "2026-04-03T11:45:38.205753Z" + "iopub.execute_input": "2026-07-30T01:24:11.272333Z", + "iopub.status.busy": "2026-07-30T01:24:11.272234Z", + "iopub.status.idle": "2026-07-30T01:24:11.869981Z", + "shell.execute_reply": "2026-07-30T01:24:11.869359Z" } }, "outputs": [ @@ -2787,9 +2777,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.14" + "version": "3.12.13" } }, "nbformat": 4, "nbformat_minor": 4 -} \ No newline at end of file +} diff --git a/galgebra/_utils/simplify.py b/galgebra/_utils/simplify.py new file mode 100644 index 00000000..b4e63a78 --- /dev/null +++ b/galgebra/_utils/simplify.py @@ -0,0 +1,51 @@ +"""Compatibility helpers for simplification across SymPy releases.""" + +import re + +import sympy +from sympy import preorder_traversal, simplify, trigsimp +from sympy.functions.elementary.hyperbolic import HyperbolicFunction +from sympy.functions.elementary.trigonometric import TrigonometricFunction + + +def _major_minor(version): + """Return the leading major and minor numbers from a version string.""" + match = re.match(r'^(\d+)\.(\d+)', version) + if match is None: + return (0, 0) + return tuple(map(int, match.groups())) + + +_SYMPY_MAJOR_MINOR = _major_minor(sympy.__version__) + +# SymPy 1.13's gh-26390 added a nested replace traversal to the FU +# simplifier. Its cost is proportional to the expression tree size times the +# number of trig and hyperbolic nodes. Values below this limit are cheap enough +# to retain SymPy's canonical ``simplify`` output. +_FU_TRAVERSAL_COST_LIMIT = 4096 +_TRIG_FUNCTIONS = (TrigonometricFunction, HyperbolicFunction) + + +def _has_expensive_fu_traversal(expr): + """Whether ``simplify`` is likely to hit SymPy's slow FU traversal.""" + if _SYMPY_MAJOR_MINOR < (1, 13): + return False + + trig_nodes = 0 + for node_count, node in enumerate(preorder_traversal(expr), 1): + if isinstance(node, _TRIG_FUNCTIONS): + trig_nodes += 1 + if node_count * trig_nodes >= _FU_TRAVERSAL_COST_LIMIT: + return True + return False + + +def simplify_compat(expr): + """Simplify while avoiding a known SymPy 1.13+ performance regression. + + Remove this fallback after SymPy replaces the nested traversal introduced + by gh-26390 and galgebra's minimum supported SymPy includes that fix. + """ + if _has_expensive_fu_traversal(expr): + return trigsimp(expr, method='old') + return simplify(expr) diff --git a/galgebra/metric.py b/galgebra/metric.py index aa26f91b..ac2cb3f8 100644 --- a/galgebra/metric.py +++ b/galgebra/metric.py @@ -14,6 +14,7 @@ from . import printer from ._utils import cached_property as _cached_property +from ._utils.simplify import simplify_compat from .atoms import ( BasisVectorSymbol, DotProductSymbol, MatrixFunction, Determinant, ) @@ -299,7 +300,7 @@ def symbols_list(s, indices=None, sub=True, commutative=False): class Simp: - modes = [simplify] + modes = [simplify_compat] @staticmethod def profile(s): diff --git a/scripts/validate_nb_refresh.py b/scripts/validate_nb_refresh.py index e807d3c3..fd687f14 100644 --- a/scripts/validate_nb_refresh.py +++ b/scripts/validate_nb_refresh.py @@ -49,7 +49,7 @@ DeprecationWarnings from mpmath are environment-specific and ignored. Only ``display_data`` and ``execute_result`` outputs are compared. -5. SymPy 1.13 ``trigsimp(method='old')`` algebraic form differences +5. SymPy 1.13 compatibility-fallback algebraic form differences (curvilinear coordinates example, ``examples/ipython/LaTeX.ipynb``): a. Pythagorean identity: ``sin²(η)+sinh²(ξ)`` <-> ``-cos²(η)+cosh²(ξ)`` @@ -61,11 +61,6 @@ **Known remaining differences (require symbolic algebra to verify):** - * Spherical curl ``e_r`` component: SymPy writes a parenthesised - ``(A/tan + B - C/sin²)|sin|`` form; trigsimp produces a single - fraction over ``tan|sin|``. Both are mathematically equal but have - fundamentally different structure. - * Prolate-spheroidal divergence: SymPy collects all terms into one large fraction; trigsimp distributes into seven separate fractions. The two forms are mathematically equal and can be verified with @@ -293,10 +288,10 @@ def _norm_collapse_spaces(text: str) -> str: LATEX_NORMALIZERS = [ _norm_array_colspec, _norm_cdot, - # SymPy 1.13 (trigsimp method='old') uses different algebraic forms for - # some curvilinear-coordinate expressions. The normalizers below bring - # both forms to a common representation so the validator can confirm the - # changes are cosmetic. + # The SymPy 1.13 compatibility fallback uses different algebraic forms + # for some curvilinear-coordinate expressions. The normalizers below + # bring both forms to a common representation so the validator can + # confirm the changes are cosmetic. _norm_sin2_sinh2_identity, # sin²+sinh² ↔ -cos²+cosh² (prolate spheroidal) _norm_sum_sqrt_to_power32, # (X²+Y²)^{3/2} ↔ X²√(…)+Y²√(…) (paraboloidal) _norm_distribute_r2_denominator, # \frac{r²A+rB+C}{r²} ↔ A+B/r+C/r² (spherical) diff --git a/test/test_simplify.py b/test/test_simplify.py new file mode 100644 index 00000000..c3e39a47 --- /dev/null +++ b/test/test_simplify.py @@ -0,0 +1,92 @@ +from unittest import mock + +from sympy import Add, cos, cosh, sin, sinh, symbols + +from galgebra._utils import simplify as simplify_module +from galgebra.ga import Ga +from galgebra.metric import Simp + + +x = symbols('x') + + +def test_major_minor(): + assert simplify_module._major_minor('1.13.3') == (1, 13) + assert simplify_module._major_minor('1.15.dev') == (1, 15) + assert simplify_module._major_minor('unknown') == (0, 0) + + +def test_small_expression_uses_standard_simplify(): + expr = sin(x)**2 + cos(x)**2 + + with ( + mock.patch.object(simplify_module, 'simplify', return_value=1) as new, + mock.patch.object(simplify_module, 'trigsimp') as old, + ): + assert simplify_module.simplify_compat(expr) == 1 + + new.assert_called_once_with(expr) + old.assert_not_called() + + +def test_large_mixed_expression_avoids_standard_simplify(): + terms = [sin(x + i) + sinh(x + i) for i in range(40)] + expr = Add(*terms) + + with ( + mock.patch.object(simplify_module, 'simplify') as new, + mock.patch.object(simplify_module, 'trigsimp', return_value=expr) as old, + ): + assert simplify_module.simplify_compat(expr) == expr + + new.assert_not_called() + old.assert_called_once_with(expr, method='old') + + +def test_sympy_before_1_13_uses_standard_simplify(): + terms = [sin(x + i) + sinh(x + i) for i in range(40)] + expr = Add(*terms) + + with ( + mock.patch.object(simplify_module, '_SYMPY_MAJOR_MINOR', (1, 12)), + mock.patch.object(simplify_module, 'simplify', return_value=1) as new, + mock.patch.object(simplify_module, 'trigsimp') as old, + ): + assert simplify_module.simplify_compat(expr) == 1 + + new.assert_called_once_with(expr) + old.assert_not_called() + + +def test_custom_simp_profile_overrides_compatibility_default(): + original_modes = Simp.modes[:] + custom = mock.Mock(return_value=x) + Simp.profile([custom]) + try: + assert Simp.apply(sin(x)) == x + finally: + Simp.profile(original_modes) + + custom.assert_called_once_with(sin(x)) + + +def test_prolate_spheroidal_divergence_renders(): + a = symbols('a', real=True) + coords = xi, eta, phi = symbols('xi eta phi', real=True) + ps3d, *_ = Ga.build( + 'e_xi e_eta e_phi', + X=[ + a*sinh(xi)*sin(eta)*cos(phi), + a*sinh(xi)*sin(eta)*sin(phi), + a*cosh(xi)*cos(eta), + ], + coords=coords, + norm=True, + ) + vector = ps3d.mv('A', 'vector', f=True) + + rendered = str(ps3d.grad | vector) + + assert 'D{eta}A__eta' in rendered + assert 'D{phi}A__phi' in rendered + assert 'D{xi}A__xi' in rendered From 2b423162017879f9d79df9c19fb3120d9f7fece5 Mon Sep 17 00:00:00 2001 From: Ulysses Tiberious <132908333+utiberious@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:42:17 -0400 Subject: [PATCH 2/2] fix: validate scoped simplification output [AGENT: miro] --- examples/ipython/LaTeX.ipynb | 2 +- scripts/validate_nb_refresh.py | 54 ++++++++++++++++++ test/test_validate_nb_refresh.py | 97 ++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 test/test_validate_nb_refresh.py diff --git a/examples/ipython/LaTeX.ipynb b/examples/ipython/LaTeX.ipynb index c28c2569..01551430 100644 --- a/examples/ipython/LaTeX.ipynb +++ b/examples/ipython/LaTeX.ipynb @@ -205,7 +205,7 @@ "**Note — SymPy ≥ 1.13 compatibility fallback (issue [#576](https://github.com/pygae/galgebra/issues/576)):**\n", "SymPy 1.13 PR #26390 introduced an O(N·M) traversal in `TR3`/`fu.py` that causes `simplify` to hang on large mixed trig+hyperbolic expressions. Galgebra now selects `trigsimp(method='old')` only for expressions likely to trigger that traversal, while smaller expressions continue to use `simplify`.\n", "\n", - "The **prolate-spheroidal divergence ∇·A** above therefore differs cosmetically from the form produced by SymPy ≤ 1.12: it is written as separate fractions rather than one collected fraction. The two forms are algebraically identical." + "The previous notebook-wide workaround also changed small expressions. With scoped routing, the **spherical curl** returns to the standard `simplify` form: its radial coefficient is factored instead of written as one fraction, and a minus sign is distributed into its polar coefficient. Both presentations are algebraically identical to the former output. The **prolate-spheroidal divergence ∇·A** still takes the compatibility path and differs cosmetically from the form produced by SymPy ≤ 1.12: it is written as separate fractions rather than one collected fraction. Those two forms are also algebraically identical." ] }, { diff --git a/scripts/validate_nb_refresh.py b/scripts/validate_nb_refresh.py index fd687f14..ab2adbce 100644 --- a/scripts/validate_nb_refresh.py +++ b/scripts/validate_nb_refresh.py @@ -58,6 +58,10 @@ form <-> distributed ``A + B/r + C/r²`` form. d. Whitespace inside ``\\frac{...}`` arguments. e. Outer ``\\left(…\\right)`` wrapper before a basis blade. + f. Spherical curl: the former notebook-wide ``trigsimp(method='old')`` + profile wrote the radial coefficient as one fraction and kept a minus + sign outside the polar coefficient. Scoped routing restores the + standard ``simplify`` factored/sign-distributed forms. **Known remaining differences (require symbolic algebra to verify):** @@ -116,6 +120,55 @@ def _norm_sum_sqrt_to_power32(text: str) -> str: return re.sub(pattern, replacement, text) +def _norm_spherical_curl(text: str) -> str: + r"""Normalize the two known forms of the spherical-curl coefficients. + + The spherical coordinates in this notebook are real, so + ``sin(theta)**2 == Abs(sin(theta))**2``. Together with + ``sin(theta)*cos(theta)*tan(theta) == sin(theta)**2``, that converts the + old single fraction into the factored form below. The exact replacement + deliberately does not accept nearby expressions: an unverified change + must still fail validation. + """ + old_radial = ( + r'\frac{A^{\phi } {\sin{\left (\theta \right )}}^{2} + ' + r'A^{\phi } \sin{\left (\theta \right )} ' + r'\cos{\left (\theta \right )} \tan{\left (\theta \right )} + ' + r'{\sin{\left (\theta \right )}}^{2} ' + r'\tan{\left (\theta \right )} \partial_{\theta } A^{\phi } - ' + r'\tan{\left (\theta \right )} \partial_{\phi } A^{\theta } }' + r'{\tan{\left (\theta \right )} ' + r'\left|{\sin{\left (\theta \right )}}\right|} ' + r'\boldsymbol{e}_{r}' + ) + factored_radial = ( + r'\left(\frac{2 A^{\phi } }{\tan{\left (\theta \right )}} + ' + r'\partial_{\theta } A^{\phi } - ' + r'\frac{\partial_{\phi } A^{\theta } }' + r'{{\sin{\left (\theta \right )}}^{2}}\right) ' + r'\left|{\sin{\left (\theta \right )}}\right| ' + r'\boldsymbol{e}_{r}' + ) + old_polar = ( + r'- \frac{r^{2} {\sin{\left (\theta \right )}}^{2} ' + r'\partial_{r} A^{\phi } + 2 r A^{\phi } ' + r'{\sin{\left (\theta \right )}}^{2} - ' + r'\partial_{\phi } A^{r} }' + r'{r^{2} \left|{\sin{\left (\theta \right )}}\right|} ' + r'\boldsymbol{e}_{\theta }' + ) + sign_distributed_polar = ( + r'+ \frac{- r^{2} {\sin{\left (\theta \right )}}^{2} ' + r'\partial_{r} A^{\phi } - 2 r A^{\phi } ' + r'{\sin{\left (\theta \right )}}^{2} + ' + r'\partial_{\phi } A^{r} }' + r'{r^{2} \left|{\sin{\left (\theta \right )}}\right|} ' + r'\boldsymbol{e}_{\theta }' + ) + text = text.replace(old_radial, factored_radial) + return text.replace(old_polar, sign_distributed_polar) + + # --------------------------------------------------------------------------- # Brace-counting helpers for structural LaTeX normalizers # --------------------------------------------------------------------------- @@ -292,6 +345,7 @@ def _norm_collapse_spaces(text: str) -> str: # for some curvilinear-coordinate expressions. The normalizers below # bring both forms to a common representation so the validator can # confirm the changes are cosmetic. + _norm_spherical_curl, # factored/sign-distributed spherical-curl coefficients _norm_sin2_sinh2_identity, # sin²+sinh² ↔ -cos²+cosh² (prolate spheroidal) _norm_sum_sqrt_to_power32, # (X²+Y²)^{3/2} ↔ X²√(…)+Y²√(…) (paraboloidal) _norm_distribute_r2_denominator, # \frac{r²A+rB+C}{r²} ↔ A+B/r+C/r² (spherical) diff --git a/test/test_validate_nb_refresh.py b/test/test_validate_nb_refresh.py new file mode 100644 index 00000000..2cf3d75a --- /dev/null +++ b/test/test_validate_nb_refresh.py @@ -0,0 +1,97 @@ +from sympy import Abs, cos, simplify, sin, symbols, tan, trigsimp + +from scripts.validate_nb_refresh import _norm_spherical_curl + + +def test_spherical_curl_forms_are_symbolically_equal(): + theta = symbols('theta', real=True) + r = symbols('r', nonzero=True, real=True) + A_phi, dtheta_A_phi, dphi_A_theta = symbols( + 'A_phi dtheta_A_phi dphi_A_theta', real=True + ) + dr_A_phi, dphi_A_r = symbols('dr_A_phi dphi_A_r', real=True) + sin_theta = sin(theta) + + old_radial = ( + A_phi*sin_theta**2 + + A_phi*sin_theta*cos(theta)*tan(theta) + + sin_theta**2*tan(theta)*dtheta_A_phi + - tan(theta)*dphi_A_theta + ) / (tan(theta)*Abs(sin_theta)) + factored_radial = ( + 2*A_phi/tan(theta) + + dtheta_A_phi + - dphi_A_theta/sin_theta**2 + ) * Abs(sin_theta) + polar_numerator = ( + r**2*sin_theta**2*dr_A_phi + + 2*r*A_phi*sin_theta**2 + - dphi_A_r + ) + old_polar = -polar_numerator / (r**2*Abs(sin_theta)) + sign_distributed_polar = ( + -r**2*sin_theta**2*dr_A_phi + - 2*r*A_phi*sin_theta**2 + + dphi_A_r + ) / (r**2*Abs(sin_theta)) + + assert simplify(trigsimp(old_radial - factored_radial)) == 0 + assert simplify(old_polar - sign_distributed_polar) == 0 + + +def test_normalize_spherical_curl_radial_coefficient(): + old = ( + r'\frac{A^{\phi } {\sin{\left (\theta \right )}}^{2} + ' + r'A^{\phi } \sin{\left (\theta \right )} ' + r'\cos{\left (\theta \right )} \tan{\left (\theta \right )} + ' + r'{\sin{\left (\theta \right )}}^{2} ' + r'\tan{\left (\theta \right )} \partial_{\theta } A^{\phi } - ' + r'\tan{\left (\theta \right )} \partial_{\phi } A^{\theta } }' + r'{\tan{\left (\theta \right )} ' + r'\left|{\sin{\left (\theta \right )}}\right|} ' + r'\boldsymbol{e}_{r}' + ) + factored = ( + r'\left(\frac{2 A^{\phi } }{\tan{\left (\theta \right )}} + ' + r'\partial_{\theta } A^{\phi } - ' + r'\frac{\partial_{\phi } A^{\theta } }' + r'{{\sin{\left (\theta \right )}}^{2}}\right) ' + r'\left|{\sin{\left (\theta \right )}}\right| ' + r'\boldsymbol{e}_{r}' + ) + + assert _norm_spherical_curl(old) == factored + assert _norm_spherical_curl(factored) == factored + + +def test_normalize_spherical_curl_polar_coefficient_sign(): + old = ( + r'- \frac{r^{2} {\sin{\left (\theta \right )}}^{2} ' + r'\partial_{r} A^{\phi } + 2 r A^{\phi } ' + r'{\sin{\left (\theta \right )}}^{2} - ' + r'\partial_{\phi } A^{r} }' + r'{r^{2} \left|{\sin{\left (\theta \right )}}\right|} ' + r'\boldsymbol{e}_{\theta }' + ) + sign_distributed = ( + r'+ \frac{- r^{2} {\sin{\left (\theta \right )}}^{2} ' + r'\partial_{r} A^{\phi } - 2 r A^{\phi } ' + r'{\sin{\left (\theta \right )}}^{2} + ' + r'\partial_{\phi } A^{r} }' + r'{r^{2} \left|{\sin{\left (\theta \right )}}\right|} ' + r'\boldsymbol{e}_{\theta }' + ) + + assert _norm_spherical_curl(old) == sign_distributed + assert _norm_spherical_curl(sign_distributed) == sign_distributed + + +def test_spherical_curl_normalizer_rejects_nearby_change(): + changed = ( + r'\frac{A^{\phi } {\sin{\left (\theta \right )}}^{3}}' + r'{\tan{\left (\theta \right )} ' + r'\left|{\sin{\left (\theta \right )}}\right|} ' + r'\boldsymbol{e}_{r}' + ) + + assert _norm_spherical_curl(changed) == changed