diff --git a/.circleci/config.yml b/.circleci/config.yml index 6f98b24..2bb658a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -33,7 +33,7 @@ jobs: - run: name: Run Octave tests command: | - octave --eval "addpath(fullfile(pwd, 'matlab', 'src')); cd matlab/tests; test testElliptic12; test testElliptic3; test testEllipj; test testThetaPrime; test testAgm; test testJacobiThetaEta;" + octave --eval "addpath(fullfile(pwd, 'matlab', 'src')); files=dir(fullfile(pwd, 'matlab', 'tests', 'test*.m')); failed=false; for k=1:numel(files); [n,nmax]=test(fullfile(files(k).folder,files(k).name),'quiet'); fprintf('%s: %d/%d\n',files(k).name,n,nmax); failed=failed || n~=nmax; end; if failed; exit(1); end" workflows: build: jobs: diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index cfb5e89..ac31d55 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -2,9 +2,9 @@ name: tests on: push: - paths: ["python/**"] + paths: ["python/**", ".github/workflows/python.yml"] pull_request: - paths: ["python/**"] + paths: ["python/**", ".github/workflows/python.yml"] release: types: [published] @@ -13,6 +13,32 @@ defaults: working-directory: python jobs: + test-standalone: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install runtime dependencies only + run: pip install -e . + - name: Exercise public functions without SciPy + run: | + python - <<'PY' + import importlib.util + import elliptic + + assert importlib.util.find_spec("scipy") is None + elliptic.theta(1, 0.2, 0.5) + elliptic.theta_prime(1, 0.2, 0.5) + elliptic.jacobiThetaEta(0.2, 0.5) + elliptic.nomeq(0.5) + elliptic.inversenomeq(0.04) + elliptic.inverselliptic2(0.5, 0.5) + elliptic.elliptic12i(0.7 + 0.2j, 0.5) + elliptic.arclength_ellipse([2.0, 3.0], [3.0, 2.0]) + PY + test: runs-on: ${{ matrix.os }} strategy: @@ -62,7 +88,7 @@ jobs: publish: name: Build and publish to PyPI runs-on: ubuntu-latest - needs: [test, test-torch, test-jax] + needs: [test-standalone, test, test-torch, test-jax] if: github.event_name == 'release' && github.event.action == 'published' environment: pypi permissions: diff --git a/docs/GPU.md b/docs/GPU.md index 8e4e2c7..1bc183f 100644 --- a/docs/GPU.md +++ b/docs/GPU.md @@ -6,6 +6,10 @@ configuration flag. The same source code paths work in both **MATLAB** (Parallel Computing Toolbox / CUDA) and **Octave** (ocl Forge package / OpenCL). +For `elliptic3`, regular inputs run on the GPU. Inputs close to an endpoint +pole are intentionally gathered and evaluated by the Carlson CPU path because +fixed GPU quadrature is not accurate enough there. + --- ## Benchmark results @@ -20,9 +24,9 @@ Octave 6.4.0 · ocl 1.2.4 · CUDA driver 535, OpenCL 3.0. | `elliptic3` | 4 M | 2.668 s | 1.167 s (2.3×) | 0.199 s | **13.4×** | | `jacobiThetaEta` | 1 M | 1.460 s | 0.583 s (2.5×) | 0.494 s | **3.1×** | -`elliptic3` achieves 13× because it is a pure Gauss-Legendre quadrature -(no AGM, no sequential dependencies) — every element is completely -independent and maps trivially to GPU threads. +These historical `elliptic3` measurements use regular inputs, for which the +Gauss-Legendre GPU path remains active. Every element is independent and maps +directly to GPU threads. ### Hardware utilisation at N = 1 M diff --git a/docs/specs/codebase-weakness-audit.md b/docs/specs/codebase-weakness-audit.md index 8b435ba..d4e47c2 100644 --- a/docs/specs/codebase-weakness-audit.md +++ b/docs/specs/codebase-weakness-audit.md @@ -1,5 +1,10 @@ # Codebase Weakness Audit — Python Elliptic Library +> Historical snapshot. The high-severity items and the numerical defects +> confirmed from this list were addressed in the +> [post-0d09740 follow-up audit](post-0d09740-regression-audit.md). Keep this +> document as the pre-fix evidence, not as the current defect list. + **Date:** 2026-04-21 **Scope:** `/home/igor/Work/elliptic/python/elliptic/` **Purpose:** Identify weak or breaking points (special values, complex numbers, inf/NaN, memory, exception handling, backend tracing) to guide future implementation work. diff --git a/docs/specs/post-0d09740-regression-audit.md b/docs/specs/post-0d09740-regression-audit.md new file mode 100644 index 0000000..27d9f66 --- /dev/null +++ b/docs/specs/post-0d09740-regression-audit.md @@ -0,0 +1,253 @@ +# Post-0d09740 Regression and Data-Integrity Audit + +**Date:** 2026-07-28 +**Baseline:** `0d09740` on `master` +**Trigger:** [issue #35](https://github.com/moiseevigor/elliptic/issues/35) and its initial [patch](https://github.com/moiseevigor/elliptic/commit/0d09740) +**Scope:** MATLAB/Octave and Python implementations, numerical special values, +backend dispatch, data-shape preservation, packaging, and CI coverage. + +## Assessment + +The initial patch added useful special-value tests, but it did not fully close +the regression class. In particular, the issue #35 phase-reduction fix was +missing from the MATLAB GPU implementation, and a private stale copy inside +`elliptic123.m` could still bypass the repaired public implementation. The +audit also found independent numerical cancellation, pole-handling, +backend-dispatch, and CI-coverage defects. + +All fixes below are kept atomic at the function boundary and covered by +regression tests. No public function was intentionally removed. + +## Confirmed findings and fixes + +| Severity | Area | Failure and data impact | Fix | +|---|---|---|---| +| Critical | MATLAB GPU `elliptic12` | The GPU path did not apply the quasi-period correction from issue #35, so phases outside the principal interval could return the wrong branch. | Apply the same exact period reduction and complete-integral correction as the serial path; add GPU regression coverage. | +| High | `elliptic123` dispatch | Stale private `elliptic12i`/`elliptic12ic` copies shadowed the repaired public implementation and preserved old behavior. | Retire the private names so `elliptic123` routes through the maintained public function; add routing parity tests. | +| High | Python runtime dependencies | `theta`, nome, inverse, and complex functions imported SciPy although SciPy was not declared as a runtime dependency. A base installation failed only when those public functions were called. | Replace runtime SciPy calls with backend-native theta series, Carlson/AGM forms, and fixed-iteration inverse solvers; add a SciPy-free install job. | +| High | `elliptic12`, `m=1` | Reducing the phase before checking the singularity hid crossed first-kind poles (`F(pi,1)` became zero) and under-counted `E` after the first quadrant. | Detect pole crossings from the original phase and restore the full quasi-period contribution. | +| High | `ellipticBD`, small `m` | `(K-E)/m` and `(D-B)/m` catastrophically cancelled. At `m=1e-20`, valid finite limits became `D=0` and an order-`1e20` wrong `S`. | Compute `D` directly with Carlson `RD`, derive `B` from `RF-RD/3`, and use a convergent series for `S` near zero in both languages. | +| High | `elliptic3`, near `n=1` | Fixed quadrature lost roughly seven decimal digits near the third-kind endpoint pole. Negative phases crossing an interior pole were not consistently rejected in Python. | Use Carlson `RF/RJ` throughout Python; use a MATLAB/Octave hybrid that keeps quadrature on regular inputs and switches near poles; validate pole crossings using `abs(phi)`. | +| High | `ellipj`, extreme inputs | Clipping valid parameters changed representable data near `m=0` and `m=1`; large phases entered the amplified Landen recursion directly and could produce order-one errors. | Preserve every interior parameter exactly, reduce by `2K` before descent, reconstruct quasi-period signs, and use stable `dn` and `sech` formulas. | +| High | Weierstrass functions | A magnitude heuristic labelled finite near-pole values as infinity (`P(1e-11)` became `Inf`), complex inputs silently lost their imaginary part, and zeta/sigma forced NumPy arrays. | Detect actual lattice points using reduced periods and ULP-scale tolerance, explicitly reject unsupported complex inputs, and keep zeta/sigma backend-native. | +| High | JAX/PyTorch dispatch | Python control flow, NumPy casts, and scalar flattening broke tracing or moved public results off device in complex, inverse, theta, Jacobi EDJ, Weierstrass, and ellipse helpers. | Replace value-dependent Python branches with masked array expressions and fixed iteration counts; preserve the originating array namespace and shape. | +| Medium | Jacobi theta endpoints | `theta(1,v,0)` returned `sin(v)` instead of zero; tiny valid `m` values were collapsed to the endpoint; representable values near `m=1` returned NaN. | Evaluate native theta series with exact endpoint overrides and no arbitrary endpoint clipping. | +| Medium | MATLAB grouping | `uniquetol(...,1e-11)` treated distinct `m` values as identical and silently substituted one result for another. A `5e-12` parameter difference produced a `1.69e-9` vector/scalar discrepancy. | Group exact duplicates only in `elliptic12` and `ellipj`; add a no-substitution regression. | +| Medium | MATLAB complex integral | `elliptic12i` divided by `m` at the exact `m=0` endpoint. | Use a safe internal denominator and restore the analytic `m=0` values exactly. | +| Medium | Carlson `RJ` tracing | Public eager validation converted JAX tracers to NumPy and failed before evaluation; an unselected `RC` branch could still emit invalid arithmetic. | Restrict exception-producing validation to eager NumPy inputs and make internal masked arguments safe. | +| Medium | Ellipse helper | Public inputs were cast to Python `float`, rejecting arrays, Torch tensors, and JAX tracers and losing broadcast shape. | Implement elementwise backend-native broadcasting, explicit NumPy domain errors, and masked invalid values for traced execution. | +| Medium | CI coverage | Octave CI ran only 6 of 15 test files. Torch/JAX jobs installed their backends but no tests passed those arrays. The release path had no base-dependency-only check. | Discover every `test*.m`, add real NumPy/Torch/JAX dispatch tests, add a SciPy-free standalone job, and make publishing depend on all of them. | + +## Numerical and differential checks + +- Real `F` and `E` were compared over broad random phases, including many + quasi-periods, against independent reference implementations; observed + absolute discrepancies were about `1.3e-12` or smaller. +- Real third-kind values were compared to independent Carlson `RF/RJ` + references; observed relative discrepancies were about `2.5e-15`. +- Two hundred random complex `F`, `E`, and Jacobi `sn` samples were compared + with 50-digit `mpmath` values; the largest observed discrepancy was about + `5e-14`. +- The Weierstrass differential equation residual was checked at random regular + points; the largest observed relative residual was about `4.5e-15`. +- The large-phase `ellipj` regression at + `u=1,000,000.123`, `m=nextafter(1,0)` was checked against a 70-digit + `mpmath` reference in both languages. + +## Automated verification + +State after round 6 (2026-09-02): + +- Python (NumPy + PyTorch CPU): **484 passed, 1 skipped** (the optional + JAX-only test); docstring examples run under `pytest --doctest-modules`. +- Backend matrix on an NVIDIA L4 (Cloud Run job): **10 passed**; the 88 + function/backend device checks of `gpu_verify.py` all within 3.6e-14 of the + NumPy reference, torch x50 over NumPy, JAX jitted 2e6 points in 18 ms. +- Octave: **18 `test*.m` files, 0 failures**, also under `--traditional` + (MATLAB-compatibility mode). New files: `testDocExamples.m` (every + docstring `Example:` block), `testGpuStrict.m` (every GPU path under the + strict device stub in `tests/gpu_stub/`, including NaN inputs), + `testParallel.m` chunking block (bit-identical chunked vs serial). +- Octave on the L4 (`ocl` over OpenCL 3.0): GPU/CPU parity <= 4.8e-16 on + `elliptic12`, `ellipj`, `elliptic3`, `ellipticBDJ`; x2.7 over CPU. +- Cross-port sweeps (kept under `scratchpad`, reproducible from the audit + text): 3000 + 1500 + 800 + 1000 random points at extreme parameters, every + disagreement above 1e-13 adjudicated with mpmath at the exact doubles; + matrix/column/mixed-shape sweep; empty/NaN/Inf sweep. + +## Adversarial review round (2026-08-16) + +An independent adversarial review (Codex, cross-checked against mpmath at +40-100 digits) of the four commits above found five material counterexamples +that the original round missed. All are fixed and regression-tested +(`testEdgeCases.m` block Q, `test_edge_cases.py::TestAdversarialRound`): + +| Finding | Failure | Fix | +|---|---|---| +| `elliptic3` negative amplitude with pole in the complete integral | `0*Inf = NaN` for e.g. `Pi(-1\|.5,1)` (MATLAB) | complete-integral correction applied only where a half-period or reflection is present | +| complex `F/E` small `m` (both ports) | A&S 17.4.11 decomposition loses `sqrt(eps/m)` digits: `F(0.2i\|1e-20)` returned 0, error 9.2e-3 at `m=1e-14` | Maclaurin series through `m^2`, switched at `m*max(1, e^(2\|psi\|)) < 1e-4`; crossover error ~2e-12 vs mpmath | +| Weierstrass pole classification (both ports) | tolerance windows (`abs(sn) < eps^(1/3)` MATLAB; `8*eps*max(1,\|z\|)` Python) returned `Inf` for the finite `P(1e-16) = 1e32` | pole only at the exact lattice point (`z_reduced == 0` / `sn == 0`), per the DLMF 23.9.2 Laurent expansion | +| inverse nome small `q` (both ports) | Python bisection had a `2^-64` absolute floor (`m(1e-30)` off by 9 orders); MATLAB tables documented-unreliable outside `[1e-5, 0.76]` | DLMF 20.9.1 closed form `m = (theta2/theta3)^4` with the `q^(1/4)` factor kept outside the ratio; exact at every scale | +| Carlson `RC` scale invariance (Python) | absolute branch tolerance sent small-scale inputs down the degenerate branch: `RC(1e-20,2e-20)` off 27%, contaminating `RJ` | relative branch selection; homogeneity `RF,RC ~ lambda^(-1/2)`, `RD,RJ ~ lambda^(-3/2)` now tested at `lambda = 1e+/-20` | + +Also from that round: reversed arc intervals are now signed for circles and +ellipses alike; DLMF citations corrected (19.25.14 for incomplete third kind, +19.25.5/19.25.9 for F/E); nondegenerate `ellipticBD` anchors at +`m = 0.2, 0.7, 0.999`. + +## Adversarial review round 2 (self-review, 2026-08-16) + +A second, independent adversarial pass fuzzed every public function of both +ports against mpmath at 40 digits over parameter endpoints (`m -> 0`, +`m -> 1`, `m = 1`), extreme argument scales, near-pole and near-lattice +arguments, exact period multiples +/- ulps, and complex arguments across the +branch point. Each candidate was classified by evaluating scipy at the same +double inputs: where scipy reaches machine precision the loss is ours. +Nine implementation defects resulted, all fixed and pinned +(`testEdgeCases.m` block R, `test_edge_cases.py::TestAdversarialRound2`): + +| Defect | Failure | Fix | +|---|---|---| +| `1 - m sin^2` / `1 - n sin^2` formed by subtraction (`elliptic12` py, `elliptic3` both) | `F(pi/2-1e-9 \| 1-eps/2)` off 4e-3; `Pi(pi/2-1e-6 \| m, n=1)` off 3e-5 | form `(1-m) + m cos^2`, `(1-n) + n cos^2` | +| `F(phi \| 1) = log(tan(pi/4+phi/2))` (both) | `F(0\|1) = -1.1e-16`, wrong sign at `1e-16` | `atanh(sin phi)` | +| `(ratio-1)/m` in the A&S 17.4.11 decomposition (both) | `Im F(pi/2 + 1e-9 i)` returned 0; `sqrt(eps/m)` loss for small m | closed cancellation-free `tan^2(mu) = 2 sinh^2 csc^2 / (B' + sqrt(B'^2 + 4C'))`, m cancels analytically | +| Landen back-substitution `asin(c sin/a)` near +/-1 (both) | `cn(9.4 \| 1-eps/2)` off 5e-10 | `atan2(c sin, sqrt(a^2 cos^2 + b^2 sin^2))` via `a^2 - c^2 = b^2` | +| `R_C` arctanh branch for `y << x` and `log(1+tiny)` (both) | `RC(3,1e-10)` off 3e-9; `RC(1+1e-13,1)` off 3e-10 -- contaminated `R_J` and `J` | `log1p(((x-y)/(sqrt x + sqrt y) + sqrt(x-y))/sqrt y)/sqrt(x-y)` | +| `R_J` duplication capped at 30 (both) | `RJ(1e-20,2e-20,3e-20,.5)` 11% off | 60 fixed (py, ratio limit ~3e32) / adaptive to 200 (MATLAB) | +| two zero Carlson arguments (both) | `RF(0,0,1) = 2e6` | `Inf` (DLMF 19.16) | +| inverse `E`: fold of tiny negative z; tol-gated Newton (both) | rel 1e-7 at `z = -1e-9 E1` | oddness first; unconditional / relative-stop Newton | +| `1 - m` from lattice roots by subtraction; MATLAB `nomeq` via `ellipke(1-m)`; MATLAB `weierstrassP` reducing inside `ellipj` | `q(1e-16)` 11% off, `q(1e-17) = 0`; `P(2 omega1 + 1e-9)` off 40% on near-m=1 lattices | `1-m = (e1-e2)/(e1-e3)`; `K' = R_F(0, m, 1)`; reduce by `2 omega1` before `ellipj` | + +Reference-construction lesson recorded for future rounds: anchors must be +evaluated at the *exact double* the library receives (`mpf(float(x))`), not +at the decimal the test author typed -- near singularities the two differ at +the 1e-9 level (`F(pi/2 - 1e-9 \| 1-eps/2)`: 19.6599302656 vs 19.6599302792). + +## Adversarial review round 3 (dense fuzz + API abuse, 2026-08-16) + +Dense random fuzz (600 points/function/seed, m log-uniform to within 1e-14 of +both endpoints, phases over +/-13 periods and at odd multiples of pi/2 with +1e-9 jitter) against mpmath, plus a logic/API abuse probe (shapes, dtypes, +NaN/Inf/-0 propagation, input mutation, vector-vs-scalar consistency, +out-of-domain parameters, complex symmetries). Everything numerical now sits +at the input's conditioning floor; the logical findings were real: + +| Finding | Was | Fix | +|---|---|---| +| Phase reduction `u - k*pi` rounds `k*pi` (both ports, `elliptic12`/`elliptic3`/`ellipticBDJ`) | `eps*\|u\|` in the reduced phase; near pi/2 at m -> 1 amplified 1e5x into Z and Pi (1e-10) | Cody-Waite split `(u - k*PI_HI) - k*PI_LO` | +| python `ellipj` with `m` outside [0,1] or NaN | returned sn(u \| 0.5) -- the interior placeholder leaked | `check_range` (raise on numpy, NaN mask on device backends) + NaN propagation; also `elliptic12`, `nomeq` | +| MATLAB `elliptic12` with NaN `m` | crashed inside `unique()` ("subscripts must be...") | NaN in, NaN out, excluded from the grouping | +| `elliptic12i(-0.0)` / `(0)` (both) | `eps` from the cot(phi) nudge | exact zero (sign preserved) | +| python `R_J` argument ratios beyond 3e32 | 9e-12 at ratio 1.9e44 | 100 duplications (limit ~4e56) | + +Verified clean in the same round: no public function mutates its inputs; +vector and scalar calls agree bit-for-bit over 300 random points including +m in {0, 1, 1e-17, nextafter(1,0)}; matrix shapes are preserved by every +function; empty input returns empty; F(conj u) = conj F(u), F(-u) = -F(u) and +sn(conj u) = conj sn(u) hold exactly. + +## Adversarial review rounds 4-5 (theta recurrence, parallel and GPU paths, batch independence, 2026-09-02) + +Attack surface: the code paths the unit tests never exercised on a +developer machine (parallel chunking, the GPU kernels under identity +stubs) and the invariant "a value must not depend on what else is in the +batch". + +| # | Attack | Result | Fix | +|---|--------|--------|-----| +| 4.1 | theta functions at large argument (`v ~ 1e8`) | `sin(k*v)` with `k*v` as a double product loses `eps*|k v|` (1e-12 at `v = 1e8`) in every series of `theta.m`, `theta_prime.m`, `jacobiThetaEta.m`, `weierstrassZeta.m`, `weierstrassSigma.m`, `theta.py`, `weierstrass.py` | one shared `theta_series.m` (all three MATLAB theta callers) and `theta._trig_start` (Python): `sin/cos((2n+1)v)` by angle-addition recurrence from `sin v, cos v`, so every term carries only the rounding of the reduced argument | +| 4.2 | parallel chunk path: `N` an exact multiple of `chunk_size` | `par_worker` re-entered the parallel dispatcher from inside a worker (stub `get_nworkers` ignored the `parallel` flag) and recursed until SIGILL | recursion guard in `par_worker.m`: the worker forces `elliptic_config('parallel', false)` for the duration of its call (restored in `unwind_protect_cleanup`) | +| 4.3 | chunked vs serial `elliptic3` on 1000 random points | 6 of 1000 differed by one ulp (rel 2.4e-16): the Carlson cores stopped on a whole-vector convergence test, so the number of duplication steps applied to an element depended on its batch mates | per-element convergence in `carlsonRF/RD/RJ.m`: an `active` mask freezes each element at its own converged step | +| 4.4 | same attack on `elliptic12` and `ellipj` | the final AGM row used was `max(n)` over the batch (`a(mn,:)`), so K and the Landen back-substitution scale changed with batch composition | first converged row per element (`a(n+1)`) in both CPU and GPU paths | +| 4.5 | GPU `elliptic12` at `u = 1e6`, `m = 1 - eps/2` vs CPU | Z off by 3.6e-11, E by 1.2e-10: the GPU phase reduction lacked the Cody-Waite tail term (`k * 1.22e-16 = 3.9e-11` at `k = 318310`) | tail term added to the GPU reduction, matching the CPU path | + +New test: `testParallel.m` block "chunking is exact" builds temporary +`get_nworkers`/`parcellfun` stubs, evaluates every parallel-capable +function serially and chunked for `N` in `{cs-1, cs, 2cs, 3cs, 3cs+7}` +(`cs = chunk_size`) and requires bit-identical results. The GPU-stub +probe (identity `gpuArray/gather`) now agrees with the CPU path to 0.0 on +4005 points including the large-`u` near-`m = 1` cases. + +## Adversarial review round 6 (cross-port parity sweep at extreme m, 2026-09-02) + +Attack: 3000 random points with `m` drawn from `U(0,1)`, `1 - 10^U(-16,-3)` +and `10^U(-16,-3)`, `u` from `U(-3,3)` and `U(-1e5,1e5)`, characteristic +`n` from `U(0,0.999)`; both ports evaluated on the identical doubles and +every disagreement above 1e-13 was adjudicated with mpmath at the exact +inputs. Eight defects, several older than the April refactor: + +| # | Function | Defect | Fix | +|---|----------|--------|-----| +| 6.1 | `elliptic12.m` (CPU + GPU) | `m` in `[eps^2, ~5e-16]`: the AGM converges in one step, no Landen step ran, the scale `e` stayed 0 and `F = E = Inf` | scale `2^(n-2)` in closed form (`n <= 1 -> 1/2`) | +| 6.2 | `theta.m`, `theta_prime.m`, `jacobiThetaEta.m` | nome from `ellipke(1-m)`: `1-m` rounds first, `q` was 30% off at `m ~ 1e-16` and `theta1` off by 1e-5 | `K'(m) = R_F(0, m, 1)` from the exact `m`, as `nomeq` already did | +| 6.3 | `elliptic12.m` (CPU + GPU) | `E/K = 1 - sum 2^(j-1) c_j^2` stopped one AGM term early (`2^(n-2) c_{n-1}^2 ~ 1e-14` near `m -> 1`); `E(-1.65|1-4e-15)` off by 1.4e-13 | the C sum takes `n` terms, the descent still `n-1` | +| 6.4 | all `k*pi` reductions (both ports) | the "Cody-Waite" split used `double(pi)` as the head, so `k*pi` itself rounded by `eps*|u|` (2.3e-10 at `u = 1e6`); Jacobi Zeta at `u = 8e4` was off by 1.5e-11 | `sub_kpi` / `_xputils.sub_kpi`: 25-bit `PI_A`, `PI_B` (products exact for `k < 2^28`) plus `PI_C`; verified to 4e-16 over 20000 random `k < 2^27` | +| 6.5 | `elliptic3.m` | the reflection `Pi(pi-u) = 2 Pi(pi/2) - Pi(u)` used `elliptic3(double(pi/2))`: `cos(double(pi/2)) = 6e-17`, and near `m = 1` the sliver `6e-17/(sqrt(1-m)(1-c))` is 2e-7 (relative 3e-10) | complete integral from the exact Carlson form `R_F(0,1-m,1) + (c/3) R_J(0,1-m,1,1-c)` | +| 6.6 | `carlsonRJ.m`, `carlson.py` | series term `E3 = XYZ + 2 E2 P + 3 P^3`; DLMF 19.36.2 has `4 P^3`. With the 0.0015 stopping tolerance the O(eps^4) residual was 1e-13 relative (Python masked it by running 100 duplications) | coefficient corrected; checked in exact arithmetic: residual 3e-22 | +| 6.7 | `ellipticBDJ` (both ports) | `n > 1` with the phase beyond the pole: MATLAB returned complex `J` silently, Python 1.147 (principal value 0.859); `n = 1` returned NaN from `0 * inf` in the period term | error (MATLAB / NumPy) or NaN (traced backends) beyond the pole; the complete `J(n|m)` is only added where a period was removed | +| 6.8 | `elliptic3.m` | rejected `c < 0` although the integral is standard there and the Python port accepts it | `c <= 1` accepted; `c < 0` routed to the Carlson branch (the 20-node rule loses digits as `1/(1+|c| sin^2)` narrows) | + +After the fixes the same 3000-point sweep agrees with mpmath to `< 2e-15` +relative on F, E, Z, Pi, theta1 in both ports (previously up to 3e-10, 1e-5 +for theta1). The remaining cross-port gap is `sn, cn, dn` at `|u| ~ 1e5` +(1e-11): the `4K` period is not a constant, so `u - 4kK` rounds by +`eps*|u|` in both ports; this is the documented limit of `ellipj`. + +| 6.9 | `cel` (both ports) | evaluated through `m = 1 - kc^2`, which loses `kc` entirely below ~1e-8: `cel1(1e-9)` was Inf (MATLAB) / 2e6 (Python) against `ln(4/kc) = 22.1`; MATLAB also rejected `kc > 1` (`m < 0`) and both returned Inf for `p < 0` | Bulirsch's own kc-native algorithm (Numer. Math. 13, 1969) in both ports: any real `kc`, `p < 0` is the Cauchy principal value (`= Re Pi(1-p | m)`, checked against mpmath), bit-identical across ports, 1e-16 from `kc = 1e-300` to 100 | +| 6.10 | `elliptic12i.py` (Jacobi Zeta output) | complete `K`, `E` taken as `F(double(pi/2)|m)`: `cos(double(pi/2)) = 6e-17`, K 5.8e-9 relative short at `m = 1-eps/2`, Z off by 1.8e-11 | exact Carlson complete forms `R_F(0,1-m,1)`, `R_F - (m/3) R_D` | +| 6.11 | `ellipticBDJ` (both ports) | `Delta^2 = 1 - m sin^2 phi` cancels near `phi = pi/2` as `m -> 1` (relative 2.5e-9 at `m = 1-1e-8`), which `R_D` turned into 3e-10 in `D(phi|m)` | `Delta^2 = (1-m) + m cos^2 phi` | +| 6.12 | `jacobiEDJ` (both ports) | took `am(u)` at `|u| ~ 1e3` (rounding `eps*|am|`) and only then reduced, where the map `phi -> D` is steep (`1/sqrt(1-m)`): `D_u(1520|1-1e-8)` off by 3e-10 on top of 6.11 | reduce `u` by `2K` first, amplitude of the reduced argument, add `2k` times the complete integrals; now at the `eps*|u|` floor (`dD_u/du = sn^2 <= 1`) | +| 6.13 | `arclength_ellipse.m` | `if (a < b) ... elseif (a > b)` on arrays uses all-elements semantics: any mixed array fell through to the circle formula `a (theta1 - theta0)` for every element | elementwise masks after broadcasting scalars (the Python port was already elementwise) | +| 6.14 | `elliptic12i` (both ports) | the period term `pi*ceil(phi/pi - 0.5 + eps)` (Python: `+ 1e-14`) was counted from a separately rounded quantity: for `phi` a few ulps (Python: 3e-14) below `pi/2` it added a period the sign term `(-1)^floor(2phi/pi)` had not crossed, and `Re F` came out `3K` instead of `K`. `asin(sqrt(3))` lands 2 ulps below `pi/2`, so `elliptic123` inherited `K(3) = 3.003` (mpmath 1.001) | period `pi*ceil(k/2)` from the same `k = floor(2phi/pi)` | +| 6.15 | `elliptic123.m` (complete `m > 1`) | evaluated `elliptic12i(asin(sqrt(m)), 1/m)`, i.e. exactly on the branch point of `F(.|1/m)`, where the decomposition is `sqrt(eps)`-conditioned (1e-8 after 6.14) | DLMF 19.7.3 closed forms `K(m) = (K(1/m) - i K(1-1/m))/sqrt(m)` and the matching `E`; `elliptic123(pi/2, m)` routes there too | +| 6.16 | `inversenomeq` (both ports), `nome2m.m` | above `q_max = 0.7789534` the 30-term theta series is not converged: MATLAB returned `m > 1` (1.034 at `q = 0.999`), Python raised; `nome2m` captured its whole input array in the `fzero` objective and errored on any array (bracket also covered only `q < 0.62`) | the true `1 - m` is below `eps/2` there, so both ports return exactly 1 (clamped `<= 1` below); `nome2m` is an alias of `inversenomeq` | +| 6.17 | `ellipj.m`, `jacobiThetaEta.m`, `inverselliptic2.m`, `elliptic123.m` | input-shape defects from a sweep of matrix / column / row / mixed-scalar calls against scalar loops: `ellipj` re-read `cn(I)` from its column-shaped output against the row `m(I)` (6x6 broadcast error for any column `u`, hence also `jacobiEDJ`); `jacobiThetaEta` returned a row for a matrix; `inverselliptic2`'s vector-wide Newton stop made values batch-dependent by an ulp; `elliptic123` failed on any matrix | keep the row-shaped value; reshape to the input shape; per-element Newton mask; flatten to rows and restore the shape. The Python port passed the same sweep; `TestInputShapes` pins it | +| 6.18 | `elliptic12.m` GPU path | `K_per = 2 .* gpuArray(k_per) .* K_vals` multiplied a device array by a host matrix (K_vals became a host array in 6.4); `ocl` refuses that and the L4 run's Octave section failed. The identity stubs used locally cannot see host/device mixing | host product, then `gpuArray`. New `tests/gpu_stub/gpuArray.m`: a strict device-array stand-in that errors on device-by-host-matrix operators and on logical indexing exactly like `ocl`; `testGpuStrict.m` runs every GPU path under it (would have caught all three host/device defects of the earlier hardware rounds on a laptop) | +| 6.19 | empty / NaN / Inf inputs | MATLAB: nine functions rejected `[]` against a scalar partner (`ellipticBDJ`, `theta_prime`, `cel`, the four Weierstrass functions, the four Carlson functions, `arclength_ellipse`, `elliptic123`); `nomeq` aborted inside `ellipke` on a single NaN; `inversenomeq` rejected NaN as out of range; `elliptic12i` raised "must be real" because `(-1)^NaN` is complex in Octave; the Carlson wrappers returned complex NaN for `-Inf` and complex garbage for `R_J` with `p < 0`. Python: `cel(NaN, ...)` returned `pi/2` (a NaN `kc` never became active) | empty in, empty out of the same shape; NaN isolated elementwise; `R_J` with `p <= 0` errors like the Python port; `cel` propagates NaN | +| 6.20 | GPU branches of `elliptic12.m`, `ellipj.m`; `theta`, `theta_prime`, `jacobiThetaEta`, `elliptic12i`, `inverselliptic2` (MATLAB) | on the L4 the Octave section failed the NaN block: a NaN `m` fell through `find(m ~= 1 & m ~= 0)` on the GPU path, the AGM loop exited at once and `F` came back equal to `u`. Separately, Octave's `ellipke` aborts with "algorithm did not converge" as soon as one element is NaN, taking the whole theta family down | NaN masks in both GPU branches (reproduced locally by the strict stub, which now includes NaN cases); `ellipke_safe.m` (NaN-propagating `ellipke`) at the six call sites, and NaN `m` mapped back to NaN after the `q = 0` stand-in | +| 6.21 | `elliptic3.m`, `theta.m`, `elliptic123.m` | a scalar phase with a parameter vector: `elliptic3` expanded `c` from the still-scalar `u` before `u` was expanded from `m` and rejected the call ("must be the same size"); `theta` preallocated its output before broadcasting (size error); `elliptic123` restored the scalar phase's shape | broadcast to the largest input first; preallocate after; shape of the largest input | +| 6.22 | `elliptic3.m` GPU path | the OpenCL branch was the 20-node rule only, with no Carlson fallback for the elements the rule cannot resolve (endpoint denominators below 0.25, `c < 0`): on the L4, `Pi(1|0.5,-100)` was 3.8e-9 off and `Pi(4|0.9,-100)` 5.4e-10 (the strict stub reproduces it) | the Carlson block is now the shared subfunction `elliptic3_carlson`; the GPU path evaluates the "danger" subset on the host with it and sends only the regular elements to the kernel | + +Deliberate limits found in this sweep: complex `sn, cn, dn` near the poles +`u = iK'` and Weierstrass functions near lattice points carry the +conditioning `eps*K'/|u - iK'|` of the rounded half-period (1e-11 at a +distance 0.05); `elliptic12i` exactly at its branch point +`pi/2 + i acosh(1/sqrt(m))` is `sqrt(eps)`-conditioned (1e-8) because the +function has a square-root singularity there; `theta` at `|v| ~ 1e3` and +`jacobiThetaEta` at `|u| ~ 1e4` carry `eps*|v|` from the argument itself. + +Also in this round: every MATLAB docstring `Example:` block now runs as a +test (`testDocExamples.m`) and the Python docstrings run under +`pytest --doctest-modules` (one example printed a 0-d array and was fixed). + +## Deliberate limits and residual risk + +- CUDA/OpenCL hardware was not available during this audit. GPU source paths + and dispatch tests were reviewed, and CPU/Octave tests passed, but the + corrected MATLAB/Octave GPU kernels still require hardware execution before + a release claim can include direct GPU validation. +- MATLAB Parallel Computing Toolbox was not available. Octave reported its + parallel-package skips; serial behavior and parallel dispatch code were + reviewed, but a real multi-worker run remains release validation work. +- Weierstrass functions currently support real inputs only. They now reject + complex input explicitly instead of silently discarding data. +- `elliptic3` deliberately rejects real paths that cross a third-kind pole; + Cauchy principal-value continuation is not implemented. +- Near-pole and near-lattice conditioning: `F(phi|m)` with `m -> 1` at + `phi -> pi/2` and the Weierstrass functions within `~1e-9 omega1` of a + lattice point are evaluated to the input's conditioning floor + (`2 eps |z| / |z - 2k omega1|`, i.e. ~1e-6 relative at `1e-9 omega1`). + This is a property of the double input, not of the algorithm; the same + inputs move the true value by that much. +- `R_J` in the python port uses 60 fixed duplications (JAX-traceable), valid + for max/min argument ratios up to ~3e32; MATLAB iterates adaptively. +- Jacobi phase reduction is double precision: the residual phase carries an + absolute uncertainty ~`|u|*eps`, so `ellipj` holds full precision to + `|u| ~ 1e12`, degrades linearly beyond, and has lost the phase entirely by + `|u| ~ 1e16`. Every double-precision implementation (MATLAB's and SciPy's + included) shares this bound; extended-precision reduction would need K(m) + to ~32 digits. +- `elliptic12i` follows the A&S 17.4.11 real-decomposition branch: on + `Re u = pi/2` above the branch point, `Re F = K(m)`, which diverges as + `m -> 1`. mpmath/Mathematica may return values on a different sheet + there; the convention is now documented in both ports. diff --git a/docs/wiki/Elliptic-Integrals.md b/docs/wiki/Elliptic-Integrals.md index 4c24f3f..10c9c89 100644 --- a/docs/wiki/Elliptic-Integrals.md +++ b/docs/wiki/Elliptic-Integrals.md @@ -160,7 +160,7 @@ The library provides the AGM function directly: ## Carlson's Method -The conventional methods for computing elliptic integrals are Gauss and Landen transformations, which converge quadratically and work well for elliptic integrals of the first and second kinds. Unfortunately they suffer from loss of significant digits for the third kind. Carlson's algorithm provides a unified method for all three kinds with satisfactory precision. The third kind integral in this library uses a Gauss-Legendre 10-point quadrature instead. +The conventional methods for computing elliptic integrals are Gauss and Landen transformations, which converge quadratically and work well for elliptic integrals of the first and second kinds. Unfortunately they suffer from loss of significant digits for the third kind. Carlson's algorithm provides a unified method for all three kinds with satisfactory precision. Python evaluates the third kind directly with Carlson RF/RJ forms. MATLAB/Octave uses a hybrid: vectorised 20-node Gauss-Legendre quadrature on regular inputs and Carlson RF/RJ near endpoint poles, where fixed quadrature loses precision. --- diff --git a/docs/wiki/elliptic.md b/docs/wiki/elliptic.md index ac2c52f..9ea98b7 100644 --- a/docs/wiki/elliptic.md +++ b/docs/wiki/elliptic.md @@ -148,7 +148,7 @@ _See also_ `ELLIPKE`, `ELLIPJ`, `ELLIPTIC3`, `THETA`. [ELLIPTIC3](https://github.com/moiseevigor/elliptic/blob/master/src/elliptic3.m) evaluates incomplete elliptic integral of the third kind `Pi = ELLIPTIC3(U,M,C)` where `U` is a phase in radians, `0 < M < 1` is the module and `0 < C < 1` is a parameter. -`ELLIPTIC3` uses Gauss-Legendre 10 points quadrature template described in [3] to determine the value of the Incomplete Elliptic Integral of the Third Kind (see [1, 2]). +`ELLIPTIC3` uses vectorised 20-node Gauss-Legendre quadrature on regular inputs and switches to Carlson RF/RJ symmetric forms near endpoint poles. This preserves the fast path while avoiding fixed-quadrature precision loss as `M` or `C` approaches one. **General definition:** ``` diff --git a/matlab/src/arclength_ellipse.m b/matlab/src/arclength_ellipse.m index 3c88e49..2322d5f 100644 --- a/matlab/src/arclength_ellipse.m +++ b/matlab/src/arclength_ellipse.m @@ -86,6 +86,17 @@ % Moiseev Igor %arguments +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 2 && (isempty(a) || isempty(b) || (nargin == 4 && (isempty(theta0) || isempty(theta1)))) + sz = size(a); + if isempty(b), sz = size(b); end + if nargin == 4 && isempty(theta0), sz = size(theta0); end + if nargin == 4 && isempty(theta1), sz = size(theta1); end + arclength = zeros(sz); + return; +end + if nargin ~= 2 && nargin ~= 4, error('ARCLENGTH_ELLIPSE: Requires two or four inputs.') return @@ -96,23 +107,33 @@ theta1 = 2*pi; end +% Broadcast scalars to a common size, then branch ELEMENTWISE. The previous +% if(ab) on arrays used all-elements semantics, so any mixed +% array fell through to the circle formula for every element. +sz = size(a); +for x = {b, theta0, theta1} + if numel(x{1}) > 1, sz = size(x{1}); end +end +a = a + zeros(sz); b = b + zeros(sz); theta0 = theta0 + zeros(sz); theta1 = theta1 + zeros(sz); + % Default solution for a==b (circles) arclength = a.*(theta1-theta0); -% Ellipses (ab) -if(ab) - % Theta measured from a axis = semi-MAJOR axis - % Standard formulation will not work ((1-(a/b)^2) < 0); instead use PI/2 - phi and b/a instead of a/b - [F1, E1] = elliptic12( pi/2 - theta1, 1 - (b./a).^2 ); - [F0, E0] = elliptic12( pi/2 - theta0, 1 - (b./a).^2 ); - % d(PI/2 - phi)/dphi = -1, so reverse operands in this difference to flip sign: - arclength = a.*(E0 - E1); +% Ellipses: theta measured from the a axis +lt = a < b; % a is the semi-MINOR axis: standard E(phi|m) +if any(lt(:)) + m = 1 - (a(lt)./b(lt)).^2; + [~, E1] = elliptic12(theta1(lt), m); + [~, E0] = elliptic12(theta0(lt), m); + arclength(lt) = b(lt).*(E1 - E0); +end +gt = a > b; % a is the semi-MAJOR axis: (1-(a/b)^2) < 0, use pi/2 - phi and b/a +if any(gt(:)) + m = 1 - (b(gt)./a(gt)).^2; + [~, E1] = elliptic12(pi/2 - theta1(gt), m); + [~, E0] = elliptic12(pi/2 - theta0(gt), m); + % d(pi/2 - phi)/dphi = -1, so reverse the operands to flip the sign: + arclength(gt) = a(gt).*(E0 - E1); end return; diff --git a/matlab/src/carlsonRC.m b/matlab/src/carlsonRC.m index 81ed9ca..f691e5d 100644 --- a/matlab/src/carlsonRC.m +++ b/matlab/src/carlsonRC.m @@ -29,6 +29,15 @@ % [2] B.C. Carlson, "Computing Elliptic Integrals by Duplication," % Numer. Math. 33 (1979), 1–16. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 2 && (isempty(x) || isempty(y)) + sz = size(x); + if isempty(y), sz = size(y); end + RC = zeros(sz); + return; +end + if nargin < 2, error('carlsonRC: requires two arguments (x, y).'); end if ~isreal(x) || ~isreal(y) error('carlsonRC: all input arguments must be real.'); @@ -38,7 +47,11 @@ origSize = size(x); x = x(:).'; y = y(:).'; +% NaN, Inf and negative x give NaN (y < 0 is the principal value, handled by the core). +bad = ~(x >= 0) | isinf(x) | isnan(y) | isinf(y); +x(bad) = 1; y(bad) = 1; RC = carlsonRC_core(x, y); +RC(bad) = NaN; RC = reshape(RC, origSize); @@ -73,8 +86,12 @@ end if any(lt) - d = sqrt((x(lt) - y(lt)) ./ x(lt)); % DLMF 19.2.18: (x-y)/x, not (x-y)/y - RC(lt) = atanh(d) ./ sqrt(x(lt) - y(lt)); + % log((sqrt(x)+sqrt(x-y))/sqrt(y))/sqrt(x-y) == atanh(sqrt(1-y/x))/sqrt(x-y) + % without the 1 - sqrt(1-eps) cancellation (RC(3,1e-10) lost 8 digits). + % ... as log1p: log((sx+sxy)/sy) = log1p(((x-y)/(sx+sy) + sxy)/sy); the + % plain log lost 9 digits again for tiny x - y (RC(1+1e-13, 1)). + xl = x(lt); yl = y(lt); sx = sqrt(xl); sy = sqrt(yl); sxy = sqrt(xl - yl); + RC(lt) = log1p(((xl - yl)./(sx + sy) + sxy) ./ sy) ./ sxy; end diff --git a/matlab/src/carlsonRD.m b/matlab/src/carlsonRD.m index 7ed78e4..aa4a534 100644 --- a/matlab/src/carlsonRD.m +++ b/matlab/src/carlsonRD.m @@ -23,6 +23,16 @@ % [2] B.C. Carlson, "Numerical Computation of Real or Complex Elliptic % Integrals," Numer. Algorithms 10 (1995), 13–26. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 3 && (isempty(x) || isempty(y) || isempty(z)) + sz = size(x); + if isempty(y), sz = size(y); end + if isempty(z), sz = size(z); end + RD = zeros(sz); + return; +end + if nargin < 3, error('carlsonRD: requires three arguments (x, y, z).'); end if ~isreal(x) || ~isreal(y) || ~isreal(z) error('carlsonRD: all input arguments must be real.'); @@ -32,7 +42,12 @@ origSize = size(x); x = x(:).'; y = y(:).'; z = z(:).'; +% NaN, Inf and negative arguments give NaN (see carlsonRF). +bad = ~(x >= 0 & y >= 0 & z > 0) | isinf(x) | isinf(y) | isinf(z); +x(bad) = 1; y(bad) = 1; z(bad) = 1; RD = carlsonRD_core(x, y, z); +RD(bad) = NaN; +RD((x == 0) & (y == 0)) = Inf; % diverges (DLMF 19.16.5) RD = reshape(RD, origSize); @@ -44,19 +59,21 @@ S = zeros(size(x)); fac = ones(size(x)); % 4^{-n} -for iter = 1:30 +% Per-element convergence: each element stops when IT has converged, so a +% value never depends on what else is in the batch (a vector-wide break +% made chunked and serial evaluations differ by an ulp). +active = true(size(x)); +for iter = 1:200 % per-element break decides; cap guards pathological input lam = sqrt(x.*y) + sqrt(y.*z) + sqrt(z.*x); sz = sqrt(z); - S = S + fac ./ (sz .* (z + lam)); - fac = fac ./ 4; - x = (x + lam) ./ 4; - y = (y + lam) ./ 4; - z = (z + lam) ./ 4; + S(active) = S(active) + fac(active) ./ (sz(active) .* (z(active) + lam(active))); + fac(active) = fac(active) ./ 4; + x(active) = (x(active) + lam(active)) ./ 4; + y(active) = (y(active) + lam(active)) ./ 4; + z(active) = (z(active) + lam(active)) ./ 4; A = (x + y + 3.*z) ./ 5; - rng = max([abs(x-A); abs(y-A); abs(z-A)]); - if rng < cr * min(A) - break; - end + active = active & (max([abs(x-A); abs(y-A); abs(z-A)], [], 1) >= cr * A); + if ~any(active), break; end end A = (x + y + 3.*z) ./ 5; diff --git a/matlab/src/carlsonRF.m b/matlab/src/carlsonRF.m index 2e2f8dc..a8ca257 100644 --- a/matlab/src/carlsonRF.m +++ b/matlab/src/carlsonRF.m @@ -27,6 +27,16 @@ % [2] B.C. Carlson, "Numerical Computation of Real or Complex Elliptic % Integrals," Numer. Algorithms 10 (1995), 13–26. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 3 && (isempty(x) || isempty(y) || isempty(z)) + sz = size(x); + if isempty(y), sz = size(y); end + if isempty(z), sz = size(z); end + RF = zeros(sz); + return; +end + if nargin < 3, error('carlsonRF: requires three arguments (x, y, z).'); end if ~isreal(x) || ~isreal(y) || ~isreal(z) error('carlsonRF: all input arguments must be real.'); @@ -36,7 +46,15 @@ origSize = size(x); x = x(:).'; y = y(:).'; z = z(:).'; +% NaN, Inf and negative arguments give NaN (R_F is defined for x, y, z >= 0); +% they used to reach the duplication and come back as complex NaN. +bad = ~(x >= 0 & y >= 0 & z >= 0) | isinf(x) | isinf(y) | isinf(z); +x(bad) = 1; y(bad) = 1; z(bad) = 1; RF = carlsonRF_core(x, y, z); +RF(bad) = NaN; +% Two zero arguments: the integral diverges (DLMF 19.16.1); the duplication +% loop just stalls and returned a finite 2e6 for R_F(0, 0, 1). +RF((x == 0) + (y == 0) + (z == 0) >= 2) = Inf; RF = reshape(RF, origSize); @@ -49,15 +67,20 @@ x0 = x; y0 = y; z0 = z; -for iter = 1:20 +% The adaptive break decides; the cap only guards pathological input +% (20 was too few for R_F(0, 1e-16, 1) and every K(m) at tiny m). +% Per-element convergence: each element stops when IT has converged, so a +% value never depends on what else is in the batch (a vector-wide break +% made chunked and serial evaluations differ by an ulp). +active = true(size(x)); +for iter = 1:200 lam = sqrt(x.*y) + sqrt(y.*z) + sqrt(z.*x); - x = (x + lam) ./ 4; - y = (y + lam) ./ 4; - z = (z + lam) ./ 4; + x(active) = (x(active) + lam(active)) ./ 4; + y(active) = (y(active) + lam(active)) ./ 4; + z(active) = (z(active) + lam(active)) ./ 4; A = (x + y + z) ./ 3; - if max(max(abs(x - A)), max(max(abs(y - A)), abs(z - A))) < cr * min(A) - break; - end + active = active & (max([abs(x - A); abs(y - A); abs(z - A)], [], 1) >= cr * A); + if ~any(active), break; end end A = (x + y + z) ./ 3; diff --git a/matlab/src/carlsonRJ.m b/matlab/src/carlsonRJ.m index 1fbf139..4180ea5 100644 --- a/matlab/src/carlsonRJ.m +++ b/matlab/src/carlsonRJ.m @@ -25,6 +25,17 @@ % [2] B.C. Carlson, "Numerical Computation of Real or Complex Elliptic % Integrals," Numer. Algorithms 10 (1995), 13–26. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 4 && (isempty(x) || isempty(y) || isempty(z) || isempty(p)) + sz = size(x); + if isempty(y), sz = size(y); end + if isempty(z), sz = size(z); end + if isempty(p), sz = size(p); end + RJ = zeros(sz); + return; +end + if nargin < 4, error('carlsonRJ: requires four arguments (x, y, z, p).'); end if ~isreal(x) || ~isreal(y) || ~isreal(z) || ~isreal(p) error('carlsonRJ: all input arguments must be real.'); @@ -34,7 +45,18 @@ origSize = size(x); x = x(:).'; y = y(:).'; z = z(:).'; p = p(:).'; +% p <= 0 is the Cauchy principal value (DLMF 19.20.14), not implemented -- the +% duplication took sqrt of a negative and returned complex garbage. +if any(p <= 0) + error(['carlsonRJ: p must be > 0. For p < 0 the integral is a Cauchy principal ' ... + 'value (DLMF 19.20.14); use the transformation to a p > 0 argument before calling.']); +end +% NaN, Inf and negative x, y, z give NaN (see carlsonRF). +bad = ~(x >= 0 & y >= 0 & z >= 0) | isinf(x) | isinf(y) | isinf(z) | isnan(p) | isinf(p); +x(bad) = 1; y(bad) = 1; z(bad) = 1; p(bad) = 1; RJ = carlsonRJ_core(x, y, z, p); +RJ(bad) = NaN; +RJ((x == 0) + (y == 0) + (z == 0) >= 2) = Inf; % diverges (DLMF 19.16.2) RJ = reshape(RJ, origSize); @@ -48,22 +70,27 @@ p0 = p; % save original p for δ computation -for iter = 1:30 +% Each duplication divides the argument-ratio exponent (base 4) by one; the +% adaptive break below decides, the cap only guards pathological input. +% A cap of 30 covered ratios to ~1e16 only (RJ(1e-20,2e-20,3e-20,.5) 11% off). +% Per-element convergence: each element stops when IT has converged, so a +% value never depends on what else is in the batch (a vector-wide break +% made chunked and serial evaluations differ by an ulp). +active = true(size(x)); +for iter = 1:200 lam = sqrt(x.*y) + sqrt(y.*z) + sqrt(z.*x); % R_C argument for sum term (DLMF 19.36.3) alpha = (p .* (sqrt(x) + sqrt(y) + sqrt(z)) + sqrt(x.*y.*z)).^2; beta = p .* (p + lam).^2; - S = S + fac .* carlsonRC_core(alpha, beta); - fac = fac ./ 4; - x = (x + lam) ./ 4; - y = (y + lam) ./ 4; - z = (z + lam) ./ 4; - p = (p + lam) ./ 4; + S(active) = S(active) + fac(active) .* carlsonRC_core(alpha(active), beta(active)); + fac(active) = fac(active) ./ 4; + x(active) = (x(active) + lam(active)) ./ 4; + y(active) = (y(active) + lam(active)) ./ 4; + z(active) = (z(active) + lam(active)) ./ 4; + p(active) = (p(active) + lam(active)) ./ 4; A = (x + y + z + 2.*p) ./ 5; - rng = max([abs(x-A); abs(y-A); abs(z-A); abs(p-A)]); - if rng < cr * min(A) - break; - end + active = active & (max([abs(x-A); abs(y-A); abs(z-A); abs(p-A)], [], 1) >= cr * A); + if ~any(active), break; end end A = (x + y + z + 2.*p) ./ 5; @@ -73,7 +100,7 @@ P = -(X + Y + Z) ./ 2; % (A-p)/A E2 = X.*Y + X.*Z + Y.*Z - 3.*P.^2; -E3 = X.*Y.*Z + 2.*E2.*P + 3.*P.^3; +E3 = X.*Y.*Z + 2.*E2.*P + 4.*P.^3; % DLMF 19.36.2 (was 3P^3: O(eps^4) truncation, 1e-13 relative) E4 = (2.*X.*Y.*Z + E2.*P + 3.*P.^3) .* P; E5 = X.*Y.*Z.*P.^2; @@ -105,8 +132,12 @@ RC(gt) = atan(d) ./ sqrt(y(gt) - x(gt)); end if any(lt) - d = sqrt((x(lt) - y(lt)) ./ x(lt)); % DLMF 19.2.18: (x-y)/x, not (x-y)/y - RC(lt) = atanh(d) ./ sqrt(x(lt) - y(lt)); + % log((sqrt(x)+sqrt(x-y))/sqrt(y))/sqrt(x-y) == atanh(sqrt(1-y/x))/sqrt(x-y) + % without the 1 - sqrt(1-eps) cancellation (RC(3,1e-10) lost 8 digits). + % ... as log1p: log((sx+sxy)/sy) = log1p(((x-y)/(sx+sy) + sxy)/sy); the + % plain log lost 9 digits again for tiny x - y (RC(1+1e-13, 1)). + xl = x(lt); yl = y(lt); sx = sqrt(xl); sy = sqrt(yl); sxy = sqrt(xl - yl); + RC(lt) = log1p(((xl - yl)./(sx + sy) + sxy) ./ sy) ./ sxy; end diff --git a/matlab/src/cel.m b/matlab/src/cel.m index 2fe3c7a..5e163e5 100644 --- a/matlab/src/cel.m +++ b/matlab/src/cel.m @@ -40,6 +40,17 @@ % elliptic functions," Numer. Math. 7 (1965), 78–90. % [2] NIST DLMF §19.25 https://dlmf.nist.gov/19.25 +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 4 && (isempty(kc) || isempty(p) || isempty(a) || isempty(b)) + sz = size(kc); + if isempty(p), sz = size(p); end + if isempty(a), sz = size(a); end + if isempty(b), sz = size(b); end + C = zeros(sz); + return; +end + if nargin < 4, error('cel: requires four arguments (kc, p, a, b).'); end if ~isreal(kc) || ~isreal(p) || ~isreal(a) || ~isreal(b) error('cel: all arguments must be real.'); @@ -55,51 +66,59 @@ % ----------------------------------------------------------------------- function C = cel_core(kc, p, a, b) -%CEL_CORE Vectorised evaluation (row-vector inputs). -N = numel(kc); -C = zeros(1, N); - -bad = (kc < 0); -C(bad) = NaN; -pole = (p <= 0); -C(pole) = Inf; - -ok = ~bad & ~pole; -if ~any(ok), return; end - -m = 1 - kc(ok).^2; -[K, ~] = ellipke(m); -[B, D, ~] = ellipticBD(m); - -pp = p(ok); aa = a(ok); bb = b(ok); - -% Case p ≈ 1: use a*B + b*D -% Case p ≠ 1: use Carlson/Pi formula -p1 = abs(pp - 1) < 1e-12; -pn = ~p1; - -Cv = zeros(1, sum(ok)); -Cv(p1) = aa(p1) .* B(p1) + bb(p1) .* D(p1); - -if any(pn) - pn_m = m(pn); pn_p = pp(pn); pn_a = aa(pn); pn_b = bb(pn); pn_K = K(pn); - n_val = 1 - pn_p; % n for Π(n|m) = Π(1-p|m) - - % Compute J_complete(n|m) via Carlson at φ=π/2 (s=1, c=0, d=kc): - % J = (1/3) * RJ(0, kc², 1, 1-n) where 1-n = p - kc_pn = sqrt(1 - pn_m); - RJ_val = carlsonRJ(zeros(size(pn_m)), kc_pn.^2, ones(size(pn_m)), pn_p); - J_n = RJ_val ./ 3; % J_complete(n|m) = s³/3 * RJ at s=1 - - Pi_n = pn_K + n_val .* J_n; % Π(1-p|m) = K + (1-p)*J - - % Formula (DLMF §19.25, decomposition): - % cel = a*K + (b - a*p)*(Pi - K)/(1-p) - Cv(pn) = pn_a .* pn_K + (pn_b - pn_a .* pn_p) .* (Pi_n - pn_K) ./ n_val; +%CEL_CORE Bulirsch's algorithm (Numer. Math. 13 (1969) 305, "cel"), vectorised. +% Works directly with kc, so kc ~ 1e-9 (where 1 - kc^2 rounds to 1 and the +% previous ellipke/ellipticBD route returned Inf or garbage: cel1(1e-9) came +% out 2e6 instead of ln(4/kc) = 22.1) and kc > 1 (m < 0) are exact, and p < 0 +% yields the Cauchy principal value. Quadratically convergent Landen ascent; +% the stopping test |g - k| <= g*CA leaves an error of order CA^2. +CA = 1e-9; +N = numel(kc); +C = nan(1, N); +k = abs(kc); % the integral depends on kc^2 only +zero_kc = (k == 0); +% kc = 0: the integrand ~ b/(p cos) at pi/2 diverges unless b = 0; for b = 0 +% the limit kc -> 0 is finite and the ascent evaluates it from realmin. +k(zero_kc & (b == 0)) = realmin; +run = ~zero_kc | (b == 0); +if any(zero_kc & (b ~= 0)) + C(zero_kc & (b ~= 0)) = sign(b(zero_kc & (b ~= 0)) ./ p(zero_kc & (b ~= 0))) .* Inf; end - -C(ok) = Cv; - +if ~any(run), return; end +k = k(run); p = p(run); a = a(run); b = b(run); +n = numel(k); +e = k; em = ones(1, n); +pos = p > 0; +% p > 0 +pp = sqrt(p(pos)); p(pos) = pp; b(pos) = b(pos) ./ pp; +% p <= 0: transform to the p > 0 case (principal value); p = 0 gives Inf below +neg = ~pos; +if any(neg) + f = k(neg).^2; q = 1 - f; g = 1 - p(neg); f = f - p(neg); + q = q .* (b(neg) - a(neg) .* p(neg)); + pn = sqrt(f ./ g); + an = (a(neg) - b(neg)) ./ g; + b(neg) = -q ./ (g.^2 .* pn) + an .* pn; + a(neg) = an; p(neg) = pn; +end +active = true(1, n); +for it = 1:60 + f = a(active); + a(active) = a(active) + b(active) ./ p(active); + g = e(active) ./ p(active); + b(active) = 2 .* (b(active) + f .* g); + p(active) = p(active) + g; + g = em(active); + em(active) = em(active) + k(active); + conv = abs(g - k(active)) <= g .* CA; + idx = find(active); + kk = 2 .* sqrt(e(idx(~conv))); + k(idx(~conv)) = kk; + e(idx(~conv)) = kk .* em(idx(~conv)); + active(idx(conv)) = false; + if ~any(active), break; end +end +C(run) = pi / 2 .* (b + a .* em) ./ (em .* (em + p)); % ----------------------------------------------------------------------- function [kc, p, a, b] = cel_broadcast(kc, p, a, b) diff --git a/matlab/src/ellipj.m b/matlab/src/ellipj.m index 76f1c0d..b728d1d 100644 --- a/matlab/src/ellipj.m +++ b/matlab/src/ellipj.m @@ -9,6 +9,13 @@ % [Sn,Cn,Dn,Am] = ELLIPJ(U,M,TOL) computes the elliptic functions to % the accuracy TOL instead of the default TOL = EPS. % +% Accuracy limit for large arguments: the phase is reduced modulo 2K in +% double precision, so the residual phase carries an absolute uncertainty +% of about |U|*eps. Full precision holds for |U| up to ~1e12; beyond +% that the error grows linearly and by |U| ~ 1e16 the phase is lost +% entirely. This bound is shared by every double-precision +% implementation (including MATLAB's and SciPy's own ELLIPJ). +% % Some definitions of the Jacobi elliptic functions use the modulus % k instead of the parameter m. They are related by m = k^2. % @@ -74,12 +81,10 @@ I = find(m ~= 1 & m ~= 0); if ~isempty(I) - % Use standard uniquetol for numerical precision issues - % This is the recommended MATLAB approach since R2015a + % Preserve distinct parameters exactly; tolerance grouping is a silent + % data substitution and is particularly damaging near m=1. m_vals = m(I); - tol_unique = 1e-11; - - [mu, ~, K] = uniquetol_compat(m_vals, tol_unique); + [mu, ~, K] = unique(m_vals); K = K(:).'; mumax = length(mu); @@ -109,19 +114,33 @@ end mmax = length(I); + % Use the platform complete integral for reduction. Deriving K from the + % tolerance-stopped AGM is adequate for small u but its last-bit error is + % multiplied by the period count for large u, especially near m=1. + K_unique = carlsonRF(zeros(size(mu)), 1-mu, ones(size(mu))); + K_vals = K_unique(K); + period = floor((u(I) + K_vals) ./ (2 .* K_vals)); + u_reduced = u(I) - 2 .* period .* K_vals; phin = zeros(1,mmax); - phin(:) = (2 .^ n(K)).*a(i,K).*u(I); + a_fin = reshape(a(sub2ind(size(a), n(K) + 1, K)), 1, mmax); % per-element converged AGM row (K may be a column) + phin(:) = (2 .^ n(K)).*a_fin.*u_reduced; while i > 1 i = i - 1; mask = n(K) >= i; if any(mask) - phin(mask) = 0.5*(asin(c(i+1,K(mask)).*sin(phin(mask))./a(i+1,K(mask))) + phin(mask)); + % asin(c sin/a) = atan2(c sin, sqrt(a^2 cos^2 + b^2 sin^2)) using + % a^2 - c^2 = b^2: no asin near +/-1, which lost ~7 digits as m -> 1. + sp = sin(phin(mask)); cp = cos(phin(mask)); + phin(mask) = 0.5*(atan2(c(i+1,K(mask)).*sp, ... + sqrt((a(i+1,K(mask)).*cp).^2 + (b(i+1,K(mask)).*sp).^2)) + phin(mask)); end end - am(I) = phin; - sn(I) = sin(phin); - cn(I) = cos(phin); - dn(I) = sqrt(1 - m(I).*sin(phin).^2); + quasi_sign = 1 - 2 .* mod(period, 2); + am(I) = phin + period .* pi; + sn(I) = quasi_sign .* sin(phin); + cn_v = quasi_sign .* cos(phin); % keep the row: cn(I) re-read from a column-shaped + cn(I) = cn_v; % output broadcast against the row m(I) (6x6 error) + dn(I) = sqrt((1 - m(I)) + m(I).*cn_v.^2); end % Special cases: m = {0, 1} @@ -191,7 +210,9 @@ if any(m < 0) || any(m > 1), error('M must be in the range 0 <= M <= 1.'); end - I = find(m ~= 1 & m ~= 0); + bad = isnan(m) | isnan(u); % NaN in, NaN out (see gpu_elliptic12) + sn(bad) = NaN; cn(bad) = NaN; dn(bad) = NaN; am(bad) = NaN; + I = find(m ~= 1 & m ~= 0 & ~bad); if ~isempty(I) mmax = length(I); mu = m(I); @@ -215,18 +236,29 @@ n(mask) = ii - 1; end + % Reduce by the 2K quasi-period before the amplified Landen phase. + % This mirrors the serial path and prevents large-argument phase loss. + a_cpu = gather(a); a_final = a_cpu(sub2ind(size(a_cpu), (1:mmax)', n + 1)); % per-element converged row + K_vals = carlsonRF(zeros(size(mu)), 1-mu, ones(size(mu))); + period = floor((u(I) + K_vals) ./ (2 .* K_vals)); + u_reduced = u(I) - 2 .* period .* K_vals; + % Ascending Landen back-substitution with multiplicative masking - phin = gpuArray((2 .^ n) .* gather(a(:,ii)) .* u(I)); + phin = gpuArray((2 .^ n) .* a_final .* u_reduced); for jj = ii-1:-1:1 active = gpuArray(double(n >= jj)); - phin_new = 0.5*(asin(c(:,jj+1).*sin(phin)./a(:,jj+1)) + phin); + sp = sin(phin); cp = cos(phin); + phin_new = 0.5*(atan2(c(:,jj+1).*sp, sqrt((a(:,jj+1).*cp).^2 + (b(:,jj+1).*sp).^2)) + phin); phin = phin + active .* (phin_new - phin); end - am(I) = gather(phin); - sn(I) = gather(sin(phin)); - cn(I) = gather(cos(phin)); - dn(I) = sqrt(1 - m(I) .* gather(sin(phin)).^2); + quasi_sign = 1 - 2 .* mod(period, 2); + phin_cpu = gather(phin); + cn_val = quasi_sign .* cos(phin_cpu); % keep everything a column: + am(I) = phin_cpu + period .* pi; % m(I) is a column here but + sn(I) = quasi_sign .* sin(phin_cpu); % cn(I) indexes a row, so + cn(I) = cn_val; % m(I).*cn(I) was an outer + dn(I) = sqrt((1 - m(I)) + m(I) .* cn_val.^2); % product (found on L4) end % Special cases: m = {0, 1} diff --git a/matlab/src/ellipke_safe.m b/matlab/src/ellipke_safe.m new file mode 100644 index 0000000..de7f988 --- /dev/null +++ b/matlab/src/ellipke_safe.m @@ -0,0 +1,13 @@ +function [K, E] = ellipke_safe(m, tol) +%ELLIPKE_SAFE Complete elliptic integrals K(m), E(m) with NaN propagation. +% [K, E] = ELLIPKE_SAFE(M) is ELLIPKE(M) except that NaN elements of M +% give NaN instead of aborting: Octave's ellipke raises "algorithm did not +% converge" as soon as one element is NaN, which took every theta and nome +% function down with it. TOL is passed through when given. +if nargin < 2, tol = eps; end +K = nan(size(m)); E = K; +ok = ~isnan(m); +if any(ok(:)) + [K(ok), E(ok)] = ellipke(m(ok), tol); +end +end diff --git a/matlab/src/elliptic12.m b/matlab/src/elliptic12.m index fb8deca..94f3691 100644 --- a/matlab/src/elliptic12.m +++ b/matlab/src/elliptic12.m @@ -82,14 +82,14 @@ % smaller than eps = 2.220446049250313e-16, if so we suppose it equal zero m(m=0} 2^(j-1) c_j^2 (A&S 17.6.4) needs one term more than + % the Landen descent: stopping C at i = n-1 dropped 2^(n-2) c_{n-1}^2, up to + % 1e-14 near m -> 1 (c_{n-1} ~ 1e-8), and E(u|1-4e-15) was off by 1.4e-13. + maskC = n(K) >= i; + C(maskC) = C(maskC) + e_vals(i)*c2(i,K(maskC)); mask = n(K) > i; if any(mask) phin(mask) = atan(b(i,K(mask))./a(i,K(mask)).*tan(phin(mask))) + ... pi.*ceil(phin(mask)/pi - 0.5) + phin(mask); - e(mask) = e_vals(i); - C(mask) = C(mask) + e_vals(i)*c2(i,K(mask)); Cp(mask)= Cp(mask) + c(i+1,K(mask)).*sin(phin(mask)); end end - Ff = phin ./ (a(mn,K).*e*2) + K_per; % F_reduced + period correction + Ff = phin ./ (a_fin(K).*e*2) + K_per; % F_reduced + period correction F(I) = Ff.*signU; % Incomplete Ell. Int. of the First Kind Z(I) = Cp.*signU; % Jacobi Zeta Function E(I) = (Cp + (1 - 1/2*C) .* Ff).*signU; % Incomplete Ell. Int. of the Second Kind @@ -164,7 +174,7 @@ N = floor( (um1+pi/2)/pi ); M = find(um1 < pi/2); - F(m1(M)) = log(tan(pi/4 + u(m1(M))/2)); + F(m1(M)) = asinh(tan(u(m1(M)))); % gd^-1: exact at 0, odd, and no saturation as sin(u) -> 1 F(m1(um1 >= pi/2)) = Inf.*sign(u(m1(um1 >= pi/2))); E(m1) = ((-1).^N .* sin(um1) + 2*N).*sign(u(m1)); @@ -229,7 +239,11 @@ if any(m < 0) || any(m > 1), error('M must be in the range 0 <= M <= 1.'); end m(m < eps) = 0; - I = find(m ~= 1 & m ~= 0); + % NaN in, NaN out (a NaN m fell through the selection below and the AGM + % loop exited at once, so the GPU path returned F = u on real hardware) + bad = isnan(m) | isnan(u); + F(bad) = NaN; E(bad) = NaN; Z(bad) = NaN; + I = find(m ~= 1 & m ~= 0 & ~bad); if ~isempty(I) mmax = length(I); mu = m(I); @@ -257,9 +271,18 @@ mn = max(n); % Precompute e from n (avoids GPU assignment in Landen loop) e_vals = 2 .^ (0:mn-1); - e = gpuArray(e_vals(max(n-1, 1))(:)); % column, e(j)=e_vals(n(j)-1) - - phin = gpuArray(signU .* u(I)); + e = gpuArray(2 .^ (max(n(:), 1) - 2)); % column, 2^(n-2); n <= 1 -> 1/2 (no Landen step) + + % Mirror the serial issue-#35 fix: reduce the phase before the + % Landen descent and restore 2*k*K afterwards. The previous GPU + % branch still evaluated the unreduced phase and therefore retained + % the v4.1.0 regression even after the CPU path was repaired. + a_cpu = gather(a); a_fin = a_cpu(sub2ind(size(a_cpu), (1:mmax)', n + 1)); % per-element converged row + K_vals = pi ./ (2 .* a_fin); + u_work = signU .* u(I); + k_per = floor(u_work ./ pi); + phin = gpuArray(sub_kpi(u_work, k_per)); % exact k*pi split, as in the CPU path + K_per = gpuArray(2 .* k_per .* K_vals); % host product, then to the device (ocl refuses ocl .* host) C = gpuArray(zeros(mmax, 1)); Cp = gpuArray(zeros(mmax, 1)); c2 = c .^ 2; @@ -269,11 +292,11 @@ phin_new = atan(b(:,jj)./a(:,jj).*tan(phin)) + ... pi.*ceil(phin/pi - 0.5) + phin; phin = phin + active .* (phin_new - phin); - C = C + active .* e_vals(jj) .* c2(:,jj); + C = C + gpuArray(double(n >= jj)) .* e_vals(jj) .* c2(:,jj); % one term more than the descent (A&S 17.6.4) Cp = Cp + active .* c(:,jj+1) .* sin(phin); end - Ff = phin ./ (a(:,mn) .* e * 2); + Ff = phin ./ (gpuArray(a_fin) .* e * 2) + K_per; F(I) = gather(Ff) .* signU; Z(I) = gather(Cp) .* signU; E(I) = gather(Cp + (1 - 0.5*C) .* Ff) .* signU; @@ -288,7 +311,7 @@ if ~isempty(m1) Nf = floor((um1 + pi/2) / pi); M = find(um1 < pi/2); - F(m1(M)) = log(tan(pi/4 + u(m1(M))/2)); + F(m1(M)) = asinh(tan(u(m1(M)))); F(m1(um1 >= pi/2)) = Inf .* sign(u(m1(um1 >= pi/2))); E(m1) = ((-1).^Nf .* sin(um1) + 2*Nf) .* sign(u(m1)); Z(m1) = (-1).^Nf .* sin(u(m1)); diff --git a/matlab/src/elliptic123.m b/matlab/src/elliptic123.m index 394840e..0da1279 100644 --- a/matlab/src/elliptic123.m +++ b/matlab/src/elliptic123.m @@ -86,6 +86,25 @@ % Everyone is permitted to copy and distribute verbatim copies of this % script under terms and conditions of GNU GENERAL PUBLIC LICENSE. +% The legacy kernels below preallocate row vectors and index with logical +% masks; give them rows (any input shape) and restore the shape at the end. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 1 && (isempty(a1)) + sz = size(a1); + F = zeros(sz); + E = zeros(sz); + P = zeros(sz); + return; +end + +sz = size(a1); +if nargin >= 2 && numel(a2) > numel(a1), sz = size(a2); end +if nargin >= 3 && numel(a3) > max(numel(a1), numel(a2)), sz = size(a3); end +a1 = a1(:).'; +if nargin >= 2, a2 = a2(:).'; end +if nargin >= 3, a3 = a3(:).'; end + if nargout<3 if nargin==1 @@ -113,9 +132,9 @@ end % multidimensional input reshape -F = reshape(F,size(a1)); -E = reshape(E,size(a1)); -if nargin==3, P = reshape(P,size(a1)); end +F = reshape(F,sz); +E = reshape(E,sz); +if nargout==3, P = reshape(P,sz); end end @@ -139,10 +158,15 @@ end -% Reciprocal-modulus transformation: http://dlmf.nist.gov/19.7#E4 +% Reciprocal-modulus transformation, complete case (DLMF 19.7.3): +% K(m) = (K(1/m) - i K(1-1/m)) / sqrt(m), m > 1 +% evaluated from the real complete integrals. Going through +% elliptic12i(asin(sqrt(m)), 1/m) puts the argument exactly on the branch +% point of F(.|1/m), where the decomposition is sqrt(eps)-conditioned and the +% result was off by 1e-8 (and, before the period fix in elliptic12i, by 2K). if any(m>1) mm=m(m>1); - F(m>1)=(1./sqrt(mm)).*(elliptic12i(asin(sqrt(mm)),1./mm)); + F(m>1)=(ellipke(1./mm) - 1i*ellipke(1-1./mm))./sqrt(mm); end if any(m<=1&m>=0) @@ -161,11 +185,14 @@ E(m<0)=sqrt(1-mm).*EE; end - % Reciprocal-modulus transformation: http://dlmf.nist.gov/19.7#E4 + % Complete case of DLMF 19.7.4 at the branch point, where (A&S 17.4.16 with + % lambda = mu = pi/2) F_b = K(m') - i K(1-m'), E_b = E(m') - i (K(1-m') - E(1-m')), + % m' = 1/m, and E(m) = sqrt(m) E_b - ((m-1)/sqrt(m)) F_b. if any(m>1) - mm=m(m>1); - [FF,EE]=elliptic12i(asin(sqrt(mm)),1./mm); - E(m>1)=((1./sqrt(mm))-sqrt(mm)).*FF+sqrt(mm).*EE; + mm=m(m>1); mp=1./mm; + [Kp, Ep] = ellipke(mp); [Kq, Eq] = ellipke(1-mp); + Fb = Kp - 1i*Kq; Eb = Ep - 1i*(Kq - Eq); + E(m>1)=((1./sqrt(mm))-sqrt(mm)).*Fb+sqrt(mm).*Eb; end if any(m<=1&m>=0) @@ -237,6 +264,12 @@ bb=b(mpos_ind); F(mpos_ind)=(1./sqrt(mm)).*(elliptic12i(asin(sqrt(mm).*sin(bb)),1./mm)); + % sqrt(m) sin b = 1 is the branch point of F(.|1/m) (sqrt(eps)-conditioned + % there): that is the complete integral, take the closed form instead. + cpl = abs(sin(bb)) >= 1 - eps; % b = pi/2 (mod pi): asin(sqrt(m)) is that branch point + if any(cpl) + Fc = elliptic12c(mm(cpl)); Fi = F(mpos_ind); Fi(cpl) = sign(sin(bb(cpl))).*Fc; F(mpos_ind) = Fi; + end warning('elliptic123:F_bm_largem','Complex part may be missing and/or incorrect for ellipticF(b,m>1).'); end @@ -293,6 +326,10 @@ [FF,EE]=elliptic12i(asin(sqrt(mm).*sin(bb)),1./mm); %cannot display complex part E(mpos_ind)=((1./sqrt(mm))-sqrt(mm)).*FF+sqrt(mm).*EE; + cpl = abs(sin(bb)) >= 1 - eps; % complete: closed form (see elliptic12c) + if any(cpl) + [~, Ec] = elliptic12c(mm(cpl)); Ei = E(mpos_ind); Ei(cpl) = sign(sin(bb(cpl))).*Ec; E(mpos_ind) = Ei; + end warning('elliptic123:BadComplex','Complex part may be missing'); end @@ -523,7 +560,7 @@ -function [Fi,Ei,Zi] = elliptic12i(u,m,tol) +function [Fi,Ei,Zi] = elliptic12i_legacy(u,m,tol) % ELLIPTIC12i evaluates the Incomplete Elliptic Integrals % of the First, Second Kind and Jacobi's Zeta Function for the complex @@ -630,8 +667,8 @@ lambda = (-1).^floor(phi/pi*2).*lambda + pi*ceil(phi/pi-0.5+eps); mu = sign(psi).*real(mu); -[F1(:),E1(:)] = elliptic12ic(lambda, m, tol); -[F2(:),E2(:)] = elliptic12ic(mu, 1-m, tol); +[F1(:),E1(:)] = elliptic12ic_legacy(lambda, m, tol); +[F2(:),E2(:)] = elliptic12ic_legacy(mu, 1-m, tol); % complex values of elliptic integral of the first kind Fi = F1 + sqrt(-1)*F2; @@ -655,7 +692,7 @@ % END FUNCTION ELLIPTIC12i() -function [F,E,Z] = elliptic12ic(u,m,tol) +function [F,E,Z] = elliptic12ic_legacy(u,m,tol) % % Bug fix for the elliptic12 in the main distribution. % This function should disappear when the fixes appear there. @@ -681,12 +718,9 @@ I = uint32( find(m ~= 1 & m ~= 0) ); if ~isempty(I) - % Use standard uniquetol for numerical precision issues - % This is the recommended MATLAB approach since R2015a + % Legacy implementation retained only for historical comparison. m_vals = m(I); - tol_unique = 1e-11; - - [mu, ~, K] = uniquetol_compat(m_vals, tol_unique); + [mu, ~, K] = unique(m_vals); K = uint32(K(:).'); % Ensure K is a row vector mumax = length(mu); signU = sign(u(I)); @@ -942,4 +976,3 @@ end end - diff --git a/matlab/src/elliptic12i.m b/matlab/src/elliptic12i.m index fab29e1..c0e76be 100644 --- a/matlab/src/elliptic12i.m +++ b/matlab/src/elliptic12i.m @@ -10,6 +10,13 @@ % ELLIPTIC12i uses the function ELLIPTIC12 to evaluate the values of % corresponding integrals. % +% Branch convention: values follow the A&S 17.4.11 real decomposition +% F(phi+i*psi|m) = F(lambda|m) + i*F(mu|1-m). On the line phi = pi/2 +% (above the branch point pi/2 + i*acosh(1/sqrt(m))) this fixes +% Re F = K(m), which diverges as m -> 1. Other systems (Mathematica, +% mpmath) may return values on a different sheet there; both satisfy +% the defining differential relation. +% % Example: % [phi1,phi2] = meshgrid(-2*pi:3/20:2*pi, -2*pi:3/20:2*pi); % phi = phi1 + phi2*i; @@ -82,27 +89,41 @@ b = -(cot2 + m.*sinh(psi).^2.*csc(phi).^2-1+m); c = -(1-m).*cot2; -% The constant term -(1-m)*cot(phi)^2 is <= 0, so the two roots always -% straddle zero and the admissible one is X1 = -b/2 + sqrt(b^2/4-c). Near -% phi = pi/2 that form cancels catastrophically (both terms are ~ |b|/2 -% while X1 -> 0), so for b > 0 use the algebraically equal -% X1 = -c/(b/2+sqrt(...)), which keeps full precision. -sq = sqrt(b.^2/4-c); -X1 = -b/2 + sq; -ratio = X1 ./ cot2; % == tan(phi)^2 * cot(lambda)^2 -Ib = find(b > 0); -X1(Ib) = -c(Ib)./(b(Ib)/2 + sq(Ib)); -ratio(Ib) = (1-m(Ib))./(b(Ib)/2 + sq(Ib)); +% Positive root X1 = cot(lambda)^2 of X^2 + bX + c = 0 and tan(mu)^2, both +% without cancellation. Writing X1 = cot(phi)^2 + Y, Y solves +% Y^2 + B'Y - C' = 0, B' = cot2 + (1-m) - m sinh^2 csc^2, +% C' = cot2 * m sinh^2 csc^2 >= 0, +% and A&S 17.4.11's tan(mu)^2 = (tan(phi)^2 cot(lambda)^2 - 1)/m = Y/(m cot2) +% collapses to +% tan(mu)^2 = 2 sinh^2 csc^2 / (B' + sqrt(B'^2 + 4C')) (B' >= 0) +% = (|B'| + sqrt(B'^2 + 4C')) / (2 m cot2) (B' < 0) +% -- m cancels analytically in the first form, so m -> 0 (and m = 0 exactly) +% is handled to full precision. The old (ratio-1)/m lost sqrt(eps/m) digits +% and returned Im F = 0 for psi = 1e-9. +s2c2 = sinh(psi).^2.*csc(phi).^2; +Bp = cot2 + (1-m) - m.*s2c2; +Cp = cot2.*m.*s2c2; +root = sqrt(Bp.^2 + 4*Cp); +pos = Bp >= 0; +Y = zeros(size(Bp)); tan2mu = Y; +Y(pos) = 2*Cp(pos)./(Bp(pos) + root(pos)); +Y(~pos) = 0.5*(-Bp(~pos) + root(~pos)); +tan2mu(pos) = 2*s2c2(pos)./(Bp(pos) + root(pos)); +tan2mu(~pos) = 0.5*(-Bp(~pos) + root(~pos))./(m(~pos).*cot2(~pos)); +X1 = cot2 + Y; lambda = acot( sqrt(X1) ); -% tan(mu)^2 = (tan(phi)^2*cot(lambda)^2 - 1)/m, evaluated from RATIO rather -% than from LAMBDA: at phi = pi/2 the root X1 underflows, LAMBDA rounds to -% exactly pi/2 and cot(LAMBDA) loses every digit of it -- that is what used -% to drop the whole imaginary part of the result there. -mu = atan( sqrt( max((ratio - 1)./m, 0) ) ); - -% change of variables taking into account periodicity ceil to the right -lambda = (-1).^floor(phi/pi*2).*lambda + pi*ceil(phi/pi-0.5+eps); +mu = atan( sqrt(tan2mu) ); + +% Periodicity: with k = floor(2 phi/pi) the quadrant sign is (-1)^k and the +% period term is pi*ceil(k/2), derived from the SAME k. The previous +% pi*ceil(phi/pi - 0.5 + eps) counted the period from a separately rounded +% quantity, and for phi within a few ulps below pi/2 (e.g. asin(sqrt(3)) as +% used by elliptic123 for m > 1) it added a period the sign term had not +% crossed: Re F came out 3K instead of K. +kq = floor(phi/pi*2); +kq(~isfinite(kq)) = 0; % (-1)^NaN, (-1)^Inf are complex NaN in Octave; keep lambda real so NaN propagates +lambda = (-1).^kq.*lambda + pi*ceil(kq/2); mu = sign(psi).*real(mu); [F1(:),E1(:)] = elliptic12(lambda, m, tol); @@ -123,8 +144,31 @@ Ei(:) = (b1 + sqrt(-1)*b2)./b3; Ei(:) = Ei(:) + E1(:) + sqrt(-1)*(-E2(:) + F2(:)); -[K,Ee] = ellipke(m); +[K,Ee] = ellipke_safe(m); % complex values of zeta function Zi(:) = Ei(:) - Ee(:)./K(:).*Fi(:); +% Small-m Maclaurin series (through m^2). The A&S 17.4.11 decomposition +% loses ~sqrt(eps/m) digits as m -> 0 (0.2 absolute at m = 1e-16); the +% series is exact there and covers m = 0 itself: +% F = u + m(u/4 - sin2u/8) + m^2(9u/64 - 3sin2u/32 + 3sin4u/256) + O(m^3) +% E = u - m(u/4 - sin2u/8) - m^2(3u/64 - sin2u/32 + sin4u/256) + O(m^3) +% Valid while |m sin^2 u| is small: switch on m*max(1, e^(2|psi|)) < 1e-4, +% where the crossover error is ~2e-12 (measured against 40-digit mpmath). +m_eff = m .* max(1, exp(2*abs(psi))); +sm = find(m_eff < 1e-4); +if ~isempty(sm) + uu = u(sm); mm = m(sm); % original u: phi carries the +eps nudge + s2 = sin(2*uu); s4 = sin(4*uu); + Fs = uu + mm.*(uu/4 - s2/8) + mm.^2.*(9*uu/64 - 3*s2/32 + 3*s4/256); + Es = uu - mm.*(uu/4 - s2/8) - mm.^2.*(3*uu/64 - s2/32 + s4/256); + Fi(sm) = Fs; + Ei(sm) = Es; + Zi(sm) = Es - Ee(sm)./K(sm).*Fs; +end + +% u == 0 exactly (incl. -0 and 0+0i): the cot(phi) nudge above would return eps +z0 = find(u == 0); +Fi(z0) = u(z0); Ei(z0) = u(z0); Zi(z0) = 0; + % END FUNCTION ELLIPTIC12i() diff --git a/matlab/src/elliptic3.m b/matlab/src/elliptic3.m index cec73f4..5c21dfc 100644 --- a/matlab/src/elliptic3.m +++ b/matlab/src/elliptic3.m @@ -3,9 +3,10 @@ % Pi = ELLIPTIC3(U,M,C) where U is a phase in radians, 0 1) || any(c < 0) || any(c > 1), - error('M and C must be in the range [0, 1].'); +if any(m < 0) || any(m > 1) || any(c > 1), + error('M must be in the range [0, 1] and C <= 1.'); end % Reduce the phase to [0, pi/2] using oddness and the quasi-period % (the integrand is pi-periodic and even about every multiple of pi/2): @@ -42,29 +43,53 @@ if any(u(:) < 0) || any(u(:) > pi/2) signU = sign(u); ua = abs(u); k_per = floor(ua ./ pi); - r = ua - k_per .* pi; % in [0, pi) + r = sub_kpi(ua, k_per); % in [0, pi), error eps*|r| (see SUB_KPI) refl = r > pi/2; ur = r; ur(refl) = pi - r(refl); % in [0, pi/2] - Pcpl = elliptic3(pi/2 + zeros(size(u)), m, c); Pred = elliptic3(ur, m, c); - Pi = signU .* (2 .* k_per .* Pcpl + refl .* (2 .* Pcpl) + (1 - 2 .* refl) .* Pred); + % Complete-integral correction only where a half-period or reflection + % actually applies: when the complete integral is a pole (m = 1 or + % c = 1), an unconditional 2*k_per*Pcpl forms 0*Inf = NaN for phases + % that never cross it (e.g. plain negative amplitudes). + corr = zeros(size(ur)); + idx = (k_per > 0) | refl; + if any(idx(:)) + % Exact complete integral Pi(c|m) = R_F(0,1-m,1) + (c/3) R_J(0,1-m,1,1-c) + % (DLMF 19.25.?) -- NOT elliptic3(pi/2,...): cos(double(pi/2)) = 6e-17 is + % not 0, and near m = 1 the sliver between double(pi/2) and pi/2 is + % 6e-17/(sqrt(1-m)(1-c)) ~ 2e-7 (relative 3e-10 in the reflected value). + Pcpl = carlsonRF(zeros(size(u)), 1 - m, ones(size(u))) + ... + c ./ 3 .* carlsonRJ(zeros(size(u)), 1 - m, ones(size(u)), 1 - c); + corr(idx) = 2 .* k_per(idx) .* Pcpl(idx) + 2 .* refl(idx) .* Pcpl(idx); + end + Pi = signU .* (corr + (1 - 2 .* refl) .* Pred); return; end [mm,nm] = size(m); [mu,nu] = size(u); -if length(m)==1, m = m(ones(size(u))); end -if length(c)==1, c = c(ones(size(u))); end -if length(u)==1, u = u(ones(size(m))); end +% Broadcast scalars to the largest input (the old order expanded c from the +% still-scalar u before u itself was expanded from m, so a scalar phase with a +% parameter vector was rejected as 'must be the same size'). +sz = size(u); +if numel(m) > 1, sz = size(m); elseif numel(c) > 1, sz = size(c); end +if isempty(u) || isempty(m) || isempty(c), Pi = zeros(0, 0); if isempty(u), Pi = zeros(size(u)); elseif isempty(m), Pi = zeros(size(m)); else, Pi = zeros(size(c)); end; return; end +if length(m)==1, m = m(ones(sz)); end +if length(c)==1, c = c(ones(sz)); end +if length(u)==1, u = u(ones(sz)); end if ~isequal(size(m), size(c), size(u)), error('U, M and C must be the same size.'); end Pi = zeros(size(u)); -% GPU dispatch: move computation to GPU if enabled and available +% GPU dispatch: the legacy fixed quadrature is retained only away from the +% endpoint singularities. Near a pole it loses several digits, so fall back +% to the Carlson CPU core below. N_el = numel(u); -if has_gpu() +gpu_regular = all((1 - c(:).*sin(u(:)).^2) >= 0.25) && ... + all((1 - m(:).*sin(u(:)).^2) >= 0.25); +if has_gpu() && gpu_regular Pi = gpu_elliptic3(u, m, c); return; end @@ -81,38 +106,83 @@ u = u(:).'; c = c(:).'; -I = find( u==pi/2 & m==1 | u==pi/2 & c==1 ); - -t = [ 0.9931285991850949, 0.9639719272779138,... % Base points - 0.9122344282513259, 0.8391169718222188,... % for Gauss-Legendre integration - 0.7463319064601508, 0.6360536807265150,... - 0.5108670019508271, 0.3737060887154195,... - 0.2277858511416451, 0.07652652113349734 ]; -w = [ 0.01761400713915212, 0.04060142980038694,... % Weights - 0.06267204833410907, 0.08327674157670475,... % for Gauss-Legendre integration - 0.1019301198172404, 0.1181945319615184,... - 0.1316886384491766, 0.1420961093183820,... - 0.1491729864726037, 0.1527533871307258 ]; - -P = 0; i = 0; -while i < 10 - i = i + 1; - c0 = u.*t(i)/2; - P = P + w(i).*(g(u/2+c0,m,c) + g(u/2-c0,m,c)); +I = find(u==pi/2 & m==1 | u==pi/2 & c==1); + +% Hybrid evaluator. The 20-node rule is full precision while both endpoint +% denominators stay >= 0.25; nearer a pole, switch only those elements to the +% Carlson form (DLMF 19.25.14). This retains the vectorised fast path without +% the previous seven-digit loss as c approached 1. +s = sin(u); +s2 = s.^2; +co = cos(u); +% (1-m) + m cos^2 and (1-c) + c cos^2: no cancellation near the endpoint +% poles (Pi(pi/2-1e-6 | m, c=1) was off by 3e-5). +d2 = (1 - m) + m.*co.^2; +p = (1 - c) + c.*co.^2; +% c < 0 (allowed, as in the Python port): the integrand 1/(1 + |c| sin^2) +% narrows as |c| grows and the 20-node rule loses digits, so use Carlson. +danger = (d2 < 0.25) | (p < 0.25) | (c < 0); +P = zeros(size(u)); + +regular = find(~danger); +if ~isempty(regular) + t = [0.9931285991850949, 0.9639719272779138, ... + 0.9122344282513259, 0.8391169718222188, ... + 0.7463319064601508, 0.6360536807265150, ... + 0.5108670019508271, 0.3737060887154195, ... + 0.2277858511416451, 0.07652652113349734]; + w = [0.01761400713915212, 0.04060142980038694, ... + 0.06267204833410907, 0.08327674157670475, ... + 0.1019301198172404, 0.1181945319615184, ... + 0.1316886384491766, 0.1420961093183820, ... + 0.1491729864726037, 0.1527533871307258]; + ur = u(regular); + mr = m(regular); + cr = c(regular); + Pr = zeros(size(ur)); + for jj = 1:10 + c0 = ur .* t(jj) ./ 2; + Pr = Pr + w(jj) .* (g(ur./2+c0, mr, cr) + g(ur./2-c0, mr, cr)); + end + P(regular) = ur ./ 2 .* Pr; +end + +% Keep the eager Carlson evaluation finite at endpoint poles; those outputs +% are replaced by Inf below. +near = find(danger); +if ~isempty(near) + P(near) = elliptic3_carlson(u(near), m(near), c(near)); end -P = u/2.*P; -Pi(:) = P; % Incomplete elliptic integral of the third kind +P(s == 0) = 0; +Pi(:) = P; % special values u==pi/2 & m==1 | u==pi/2 & c==1 Pi(I) = inf; return; +function P = elliptic3_carlson(u, m, c) +%ELLIPTIC3_CARLSON Pi(u|m,c) by DLMF 19.25.14 for 0 <= u <= pi/2 (row inputs). +% Used for the elements the 20-node rule cannot resolve (denominators +% below 0.25 at the endpoint, or c < 0) by BOTH the serial core and the +% GPU path: the OpenCL kernel is the quadrature only, and on the L4 it +% returned Pi(1|0.5,-100) 3.8e-9 off because it had no such fallback. +s = sin(u); co = cos(u); +d2 = (1 - m) + m.*co.^2; +p = (1 - c) + c.*co.^2; +endpoint = (u == pi/2 & (m == 1 | c == 1)); % keep the eager evaluation finite; caller sets Inf +c(endpoint) = 0; d2(endpoint) = 1; p(endpoint) = 1; +RF = carlsonRF(co.^2, d2, ones(size(u))); +RJ = carlsonRJ(co.^2, d2, ones(size(u)), p); +P = s.*RF + c.*s.^3.*RJ./3; +P(s == 0) = 0; + + function g = g(u,m,c) % g = 1/((1 - c*sin(u)^2)*sqrt(1 - m*sin(u)^2)); - sn2 = sin(u).^2; - g = 1./((1 - c.*sn2).*sqrt(1 - m.*sn2)); + cs2 = cos(u).^2; + g = 1./(((1 - c) + c.*cs2).*sqrt((1 - m) + m.*cs2)); return; @@ -154,9 +224,25 @@ origSize = size(u); I_inf = find(u(:).' == pi/2 & m(:).' == 1 | u(:).' == pi/2 & c(:).' == 1); - u_g = gpuArray(u(:).'); - m_g = gpuArray(m(:).'); - c_g = gpuArray(c(:).'); + uu = u(:).'; mm = m(:).'; cc = c(:).'; + % Same hybrid as the serial core: the kernel is the 20-node rule, which is + % full precision only while both endpoint denominators stay >= 0.25 and + % c >= 0; the rest goes through the Carlson form on the host. + co2 = cos(uu).^2; + danger = ((1 - mm) + mm.*co2 < 0.25) | ((1 - cc) + cc.*co2 < 0.25) | (cc < 0) | isnan(uu) | isnan(mm) | isnan(cc); + Pi = zeros(origSize); + if any(danger) + Pi(danger) = elliptic3_carlson(uu(danger), mm(danger), cc(danger)); + Pi(isnan(uu) | isnan(mm) | isnan(cc)) = NaN; + end + if ~any(~danger) + Pi(I_inf) = inf; + return; + end + reg = ~danger; + u_g = gpuArray(uu(reg)); + m_g = gpuArray(mm(reg)); + c_g = gpuArray(cc(reg)); t = [ 0.9931285991850949, 0.9639719272779138, ... 0.9122344282513259, 0.8391169718222188, ... @@ -176,11 +262,10 @@ end P = u_g/2 .* P; - Pi = zeros(origSize); - Pi(:) = gather(P); + Pi(reg) = gather(P); Pi(I_inf) = inf; function gv = g_gpu(u, m, c) - sn2 = sin(u).^2; - gv = 1 ./ ((1 - c.*sn2) .* sqrt(1 - m.*sn2)); \ No newline at end of file + cs2 = cos(u).^2; + gv = 1 ./ (((1 - c) + c.*cs2) .* sqrt((1 - m) + m.*cs2)); diff --git a/matlab/src/ellipticBD.m b/matlab/src/ellipticBD.m index 6435529..d0cfc25 100644 --- a/matlab/src/ellipticBD.m +++ b/matlab/src/ellipticBD.m @@ -16,16 +16,12 @@ % % Algorithm — Carlson symmetric forms (DLMF §19.25): % -% B(m) = (1/2) · K(m) + (1/2) · E(m) / (1−m) -- well-conditioned +% K(m) = RF(0, 1−m, 1) +% D(m) = RD(0, 1−m, 1) / 3 +% B(m) = K(m) − D(m) % -% Actually uses: -% [K, E] = ellipke(m) -% D(m) = (K − E) / m -% B(m) = K − D -% S(m) = (D − B) / m = (2D − K) / m -% -% This avoids subtraction of nearly equal numbers via ellipke's own -% internal cancellation-safe algorithm. +% The expression S=(D−B)/m still cancels as m→0, so a convergent +% binomial/integral series is used for |m|<10⁻². % % M may be a scalar or array. All elements must satisfy 0 <= m < 1. % At m = 0: B = D = π/4. @@ -74,21 +70,27 @@ % ----------------------------------------------------------------------- function [B, D, S] = ellipticBD_core(m, origSize) %ELLIPTICBD_CORE Vectorised serial evaluation (row-vector input). -[K, E] = ellipke(m); -mc = 1 - m; - -% D = (K − E) / m, handle m = 0 via L'Hôpital: D(0) = π/4 -D = zeros(size(m)); -nz = (m ~= 0); -D(nz) = (K(nz) - E(nz)) ./ m(nz); -D(~nz) = pi / 4; - +zero = m .* 0; +one = zero + 1; +K = carlsonRF(zero, 1-m, one); +D = carlsonRD(zero, 1-m, one) ./ 3; B = K - D; -% S = (D − B) / m = (2D − K) / m, handle m = 0 via L'Hôpital: S(0) = π/16 -S = zeros(size(m)); -S(nz) = (D(nz) - B(nz)) ./ m(nz); -S(~nz) = pi / 16; +% S = (D-B)/m is catastrophically cancelling near m=0. Evaluate the +% binomial/integral series there instead. +S_series = zero; +for kk = 1:8 + ck = nchoosek(2*kk, kk) / 4^kk; + Ik = pi * nchoosek(2*kk, kk) / (2 * 4^kk); + Ik1 = pi * nchoosek(2*kk+2, kk+1) / (2 * 4^(kk+1)); + S_series = S_series + ck * (2*Ik1 - Ik) .* m.^(kk-1); +end +m_safe = m; +m_safe(m_safe == 0) = 1; +S_direct = (D - B) ./ m_safe; +S = S_direct; +small = abs(m) < 1e-2; +S(small) = S_series(small); B = reshape(B, origSize); D = reshape(D, origSize); @@ -98,8 +100,11 @@ % ----------------------------------------------------------------------- function [B, D, S] = gpu_ellipticBD(m, origSize) %GPU_ELLIPTICBD GPU path. -[B, D, S] = ellipticBD_core(gpuArray(m(:).'), origSize); -B = gather(B); D = gather(D); S = gather(S); +% No OpenCL kernel: the Carlson duplication inside the core needs logical +% indexing that ocl arrays lack, and carlsonRF's isreal() rejects them +% (seen on an L4 with elliptic_config('gpu', true)). Host arrays; identical +% results to the CPU path. No gather(): ocl's gather rejects host arrays. +[B, D, S] = ellipticBD_core(m(:).', origSize); % ----------------------------------------------------------------------- diff --git a/matlab/src/ellipticBDJ.m b/matlab/src/ellipticBDJ.m index e426f2e..904a6a0 100644 --- a/matlab/src/ellipticBDJ.m +++ b/matlab/src/ellipticBDJ.m @@ -43,6 +43,17 @@ % [3] B.C. Carlson, "Numerical Computation of Real or Complex Elliptic % Integrals," Numer. Algorithms 10 (1995), 13–26. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 2 && (isempty(phi) || isempty(m)) + sz = size(phi); + if isempty(m), sz = size(m); end + B = zeros(sz); + D = zeros(sz); + J = zeros(sz); + return; +end + compute_J = (nargin >= 3); if nargin < 2, error('ellipticBDJ: requires at least two arguments (phi, m).'); end @@ -91,11 +102,16 @@ % D(φ+k·pi|m) = D(φ|m) + 2k·D(m) % J(φ+k·pi,n|m) = J(φ,n|m) + 2k·J(n|m) k = ceil(phi./pi - 0.5); -phi = phi - k .* pi; % now in (-pi/2, pi/2] +% Cody-Waite split of pi: (u - k*PI_HI) - k*PI_LO keeps the reduction error at +% eps*|u_r| instead of eps*|u| (pi_lo = pi - double(pi) = 1.2246467991473532e-16). +phi = sub_kpi(phi, k); % now in (-pi/2, pi/2], error eps*|phi| (see SUB_KPI) s = sin(phi); c = cos(phi); -d2 = 1 - m .* s.^2; % Δ² +% Δ² = (1-m) + m cos² instead of 1 - m sin²: the latter cancels near +% phi = pi/2 as m -> 1 (relative 2.5e-9 at m = 1-1e-8, which R_D turned into +% 3e-10 in D(phi|m) and hence in jacobiEDJ at large u). +d2 = (1 - m) + m .* c.^2; % Δ² d = sqrt(d2); % Δ % s³/3 factor @@ -126,11 +142,25 @@ if compute_J % 1 − n·s² (denominator parameter for R_J) p = 1 - n .* s.^2; + % n > 1 with the phase beyond the pole at arcsin(1/sqrt(n)) is a Cauchy + % principal-value integral (DLMF 19.7.3): R_J needs p > 0, and sqrt of a + % negative p silently produced COMPLEX output (J(1, 1.5|0.5) came out + % 0.86 - 1.81i). The complete J(n|m) is only needed when a period was + % removed (k ~= 0); at n = 1 it is a pole and 0*Inf made J NaN. + if any(p <= 0) || any(k ~= 0 & n >= 1) + error(['ellipticBDJ: n > 1 with phase beyond the pole at arcsin(1/sqrt(n)) ' ... + '(or n >= 1 with |phi| > pi/2) is a Cauchy principal-value integral ' ... + '(DLMF 19.7.3); not supported.']); + end RJ = carlsonRJ(c.^2, d2, one, p); J_val = s3o3 .* RJ; J_val(s == 0) = 0; - J_cpl = carlsonRJ(zed, 1-m, one, 1-n) ./ 3; % J(n|m) - J = reshape(J_val + 2 .* k .* J_cpl, origSize); + J = J_val; + kk = find(k ~= 0); + if ~isempty(kk) + J(kk) = J(kk) + 2 .* k(kk) .* carlsonRJ(zed(kk), 1-m(kk), one(kk), 1-n(kk)) ./ 3; % + 2k J(n|m) + end + J = reshape(J, origSize); else J = []; end @@ -138,10 +168,11 @@ % ----------------------------------------------------------------------- function [B, D, J] = gpu_ellipticBDJ(phi, m, n, compute_J, origSize) -[B, D, J] = ellipticBDJ_core(gpuArray(phi(:).'), gpuArray(m(:).'), ... - ifelse(compute_J, gpuArray(n(:).'), []), compute_J, origSize); -B = gather(B); D = gather(D); -if compute_J, J = gather(J); end +% No OpenCL kernel: the Carlson duplication needs data-dependent masking +% and logical indexing, which ocl arrays do not support, and carlsonRF's +% isreal() check rejects them outright (seen on an L4). Evaluate the +% serial core on host arrays; results are identical to the CPU path. +[B, D, J] = ellipticBDJ_core(phi(:).', m(:).', ifelse(compute_J, n(:).', []), compute_J, origSize); % ----------------------------------------------------------------------- diff --git a/matlab/src/inverselliptic2.m b/matlab/src/inverselliptic2.m index 517ac79..c294aeb 100644 --- a/matlab/src/inverselliptic2.m +++ b/matlab/src/inverselliptic2.m @@ -91,12 +91,15 @@ z = E; mu = 1-m; % complete integral initialization -[~,E1] = ellipke(m,tol); +[~,E1] = ellipke_safe(m,tol); % Boyd's initialisation and the Newton iteration below only converge on % phi in [0, pi/2]. Reduce first, using % E(phi + k*pi | m) = E(phi | m) + 2k*E(m) (period) % E(pi - phi | m) = 2*E(m) - E(phi | m) (reflection) +% Oddness first: folding a tiny negative z through 2*E1 - (z + 2*E1) lost +% all its digits (rel 1e-7 at z = -1e-9*E1). +signZ = sign(z); z = abs(z); twoE1 = 2*E1; k = floor(z./twoE1); z_red = z - k.*twoE1; % in [0, 2*E1) @@ -114,16 +117,24 @@ % Newton on E(phi|m) = z_red; dE/dphi = sqrt(1 - m sin^2 phi). % Iterate to convergence rather than a fixed count (issue #12): four steps % are not enough near m -> 1, where the initial guess can be off by ~1. +% Per-element convergence: a vector-wide break let converged elements take +% extra Newton steps that depended on their batch mates (1 ulp). +active = true(numel(invE), 1); % column, like res +mc = m(:); for iter=1:100 [~, Ecur] = elliptic12(invE(:),m,tol); - res = Ecur - z_red; - if max(abs(res)) < 1e-14, break; end - invE(:) = invE(:) - res./max(sqrt( 1-m.*sin(invE(:)).^2 ), 1e-15); + res = Ecur(:) - z_red(:); + active = active & ~(abs(res) <= 4*eps*max(abs(z_red(:)), realmin)); % relative + if ~any(active), break; end + iv = invE(:); + step = zeros(numel(iv), 1); + step(active) = res(active)./max(sqrt( 1-mc(active).*sin(iv(active)).^2 ), 1e-15); + invE(:) = iv - step; invE(:) = min(max(invE(:), 0), pi/2); end % undo the reflection, then the period strips invE(over) = pi - invE(over); -invE(:) = invE(:) + k*pi; +invE(:) = signZ .* (invE(:) + k*pi); return; diff --git a/matlab/src/inversenomeq.m b/matlab/src/inversenomeq.m index 8d5f5fd..dec763e 100644 --- a/matlab/src/inversenomeq.m +++ b/matlab/src/inversenomeq.m @@ -44,42 +44,32 @@ m = zeros(size(q)); q = q(:).'; % make a row vector -maxq = max(q); -if ~all(q >= 0) || ~all(q <= 1) - error('Input arguments must be from the interval [0,1].') +bad = isnan(q); +if ~all(q(~bad) >= 0) || ~all(q(~bad) < 1) + error('Input arguments must be from the interval [0,1).') end +q(bad) = 0; % computed as m(0) = 0, overwritten with NaN below -if any(q > 0.76) || any(q < 0.00001) - warning('WarnTests:convertTest', ... - 'The function INVERSENOMEQ does not return \ncorrect values of M for Q < 0.00001 and Q > 0.76, because of computer precision limitation.'); -end - -I = find (q <= 0.4); -J = find (q > 0.4 & q <= 0.6); -P = find (q > 0.6); - -if (~isempty(I)) - mm = 0:0.0001:1; - K = ellipke(mm); - KK = K(end:-1:1)./K; - m(I) = interp1(KK, mm, -1/pi*log(q(I)), 'pchip','extrap'); -end - -if (~isempty(J)) - mm = 0.9996:0.0000001:1-eps; - %K = 1/8*(-2+2*mm-2*(-5+mm)*log(4)+(-5+mm).*log(1-mm)); - K = 1/128*(-53+74*mm-21*mm.^2+2*(89+mm.*(-34+9*mm))*log(4)+(-89+mm.*(34-9*mm)).*log(1-mm)); - KK = pi/2./K; - m(J) = interp1(KK, mm, -1/pi*log(q(J)), 'pchip','extrap'); -end - -if (~isempty(P)) - mm = (1-10^8*eps):1000*eps:1-eps; - K = 1/128*(-53+74*mm-21*mm.^2+2*(89+mm.*(-34+9*mm))*log(4)+(-89+mm.*(34-9*mm)).*log(1-mm)); - KK = pi/2./K; - m(P) = interp1(KK, mm, -1/pi*log(q(P)), 'pchip','extrap'); - % plot(mm,K*log(q(P))+pi*pi/2,'.'); +% Closed form, DLMF 20.9.1: m = (theta2(0,q)/theta3(0,q))^4 +% theta2(0,q) = 2*q^(1/4) * sum q^(n(n+1)), theta3(0,q) = 1 + 2*sum q^(n^2) +% The q^(1/4) factor is kept outside the ratio so tiny q cannot underflow. +% This replaces the old interpolation tables, which were documented as +% unreliable for q < 1e-5 and q > 0.76; the series is exact at every scale +% down to m(1e-30) = 1.6e-29. +% Above q_max = 0.778953424877990 the true 1-m = m(exp(pi^2/ln q)) ~ 16 exp(-pi^2/ln(1/q)) +% is below eps/2, so the correctly rounded double is exactly 1; the 30-term +% series is not converged there and returned m > 1 (1.034 at q = 0.999). +q_max = 0.778953424877990; +s2 = ones(size(q)); % sum q^(n(n+1)), n >= 0 +s3 = ones(size(q)); % theta3 = 1 + 2*sum q^(n^2) +qs = min(q, q_max); +for n = 1:30 + s2 = s2 + qs.^(n*(n+1)); + s3 = s3 + 2*qs.^(n^2); end +m(:) = min(16*qs .* (s2./s3).^4, 1); +m(q > q_max) = 1; +m(bad) = NaN; % END FUNCTION inversenomeq() diff --git a/matlab/src/jacobiEDJ.m b/matlab/src/jacobiEDJ.m index 75280a5..5038768 100644 --- a/matlab/src/jacobiEDJ.m +++ b/matlab/src/jacobiEDJ.m @@ -39,16 +39,33 @@ % Get amplitude phi = am(u|m) via ellipj % ellipj returns [sn, cn, dn, am] +% Reduce u by the period 2K BEFORE taking the amplitude. am(u) at |u| ~ 1e3 +% carries eps*|am| ~ 5e-14 of rounding, and near phi = pi/2 with m -> 1 the +% map phi -> D(phi|m) is steep (dD/dphi = sin^2/Delta ~ 1/sqrt(1-m)), so +% D_u(1520|1-1e-8) was off by 3e-10. As functions of u the integrals are +% perfectly conditioned (dD_u/du = sn^2 <= 1): reduce u, take the amplitude +% of the reduced argument (|am_r| <= pi/2), add 2k times the complete +% integrals. +if isscalar(m) && ~isscalar(u), m = m + zeros(size(u)); end +if isscalar(u) && ~isscalar(m), u = u + zeros(size(m)); end +if compute_J && isscalar(n) && ~isscalar(u), n = n + zeros(size(u)); end +one = ones(size(m)); zed = zeros(size(m)); +K = carlsonRF(zed, 1 - m, one); +k = floor((u + K) ./ (2 .* K)); +u_r = u - 2 .* k .* K; +[~, ~, ~, phi_r] = ellipj(u_r, m); +D_cpl = carlsonRD(zed, 1 - m, one) ./ 3; % D(m) if compute_J - [~, ~, ~, phi] = ellipj(u, m); - [B, D, J] = ellipticBDJ(phi, m, n); - Eu = u - m .* D; % E_u = u - m*D_u - Du = D; - Ju = J; + [~, D_r, J_r] = ellipticBDJ(phi_r, m, n); + J_cpl = zed; + kk = find(k ~= 0); + if ~isempty(kk) + J_cpl(kk) = carlsonRJ(zed(kk), 1 - m(kk), one(kk), 1 - n(kk)) ./ 3; % J(n|m) + end + Ju = J_r + 2 .* k .* J_cpl; else - [~, ~, ~, phi] = ellipj(u, m); - [~, D] = ellipticBDJ(phi, m); - Eu = u - m .* D; - Du = D; + [~, D_r] = ellipticBDJ(phi_r, m); Ju = []; end +Du = D_r + 2 .* k .* D_cpl; +Eu = u - m .* Du; % E_u = u - m*D_u (error eps*|u|, the conditioning floor) diff --git a/matlab/src/jacobiThetaEta.m b/matlab/src/jacobiThetaEta.m index 03f5d1c..242a72d 100644 --- a/matlab/src/jacobiThetaEta.m +++ b/matlab/src/jacobiThetaEta.m @@ -50,6 +50,7 @@ if length(u)==1, u = u(ones(size(m))); end if ~isequal(size(m),size(u)), error('U and M must be the same size.'); end +origSize = size(u); Th = zeros(size(u)); H = Th; @@ -75,7 +76,7 @@ m = m(:).'; % make a row vector u = u(:).'; -KK = ellipke(m); +KK = ellipke_safe(m); % Theta functions from their q-series (A&S 16.27, 16.38): % Th(u|m) = theta_4(v, q), H(u|m) = theta_1(v, q), v = pi*u/(2K) @@ -83,25 +84,12 @@ % q^(n^2) and is accurate to full double precision; the previous AGM-product % form lost ~11 digits of the overall normalisation and needed a deliberate % perturbation of u and m at the odd half-periods to stay finite. -q = exp(-pi .* ellipke(1-m) ./ KK); +q = exp(-pi .* carlsonRF(zeros(size(m)), m, ones(size(m))) ./ KK); % K(1-m) from the exact m (see NOMEQ) q(~(q < 1)) = 0; % m == 1 (and NaN) handled below v = pi .* u ./ (2 .* KK); -qmax = max([q(:); 0]); -if qmax > 0 - nTerms = min(1000, max(1, ceil(sqrt(log(tol) / log(qmax))))); -else - nTerms = 1; -end - -Th = ones(size(v)); -H = zeros(size(v)); -for nn = 1:nTerms - Th = Th + 2*(-1)^nn .* q.^(nn^2) .* cos(2*nn .* v); -end -for nn = 0:nTerms - H = H + 2*(-1)^nn .* q.^((nn+0.5)^2) .* sin((2*nn+1) .* v); -end +Th = theta_series(4, v, q, tol); +H = theta_series(1, v, q, tol); % Special cases: m = {0, 1} m0 = find(abs(m) < 10*eps); @@ -111,11 +99,13 @@ H(m0) = sqrt(sqrt(m(m0))).* sin(u(m0)); end -m1 = find(abs(m-1) < 10*eps); +m1 = find(abs(m-1) < 10*eps | isnan(m)); % NaN m: q was set to 0 above if ( ~isempty(m1) ) Th(m1) = NaN; H(m1) = NaN; end +Th = reshape(Th, origSize); % the series works on rows; give back the input shape +H = reshape(H, origSize); function [Th,H] = parallel_jacobiThetaEta(u, m, tol, nWorkers, minChunk) @@ -154,72 +144,40 @@ function [Th,H] = gpu_jacobiThetaEta(u, m, tol) -%GPU_JACOBITHETAETA Internal helper: compute jacobiThetaEta using gpuArray. -% Compatible with both MATLAB gpuArray and Octave ocl package. +%GPU_JACOBITHETAETA Internal helper: q-series on gpuArray (elementwise). +% Same series as the serial core (A&S 16.27, 16.38). The previous GPU +% helper still carried the retired AGM-product form and its input +% perturbation hack, so GPU results disagreed with the CPU by up to 5e-9. origSize = size(u); Th = zeros(origSize); H = zeros(origSize); - - m = m(:); - u = u(:); - - if any(m(:) < 0) || any(m(:) > 1), error('M must be in the range 0 <= M <= 1.'); end - - KK = ellipke(m); - period_condition = u./KK/2 - floor(u./KK/2); - - I_odd = find(abs(m-1) > 10*eps & abs(m) > 10*eps & abs(period_condition - 0.5) < 10*eps); - if ~isempty(I_odd) - u(I_odd) = u(I_odd) + 100000*eps; - m(I_odd) = m(I_odd) + 10000*eps; + m = m(:); u = u(:); + if any(m < 0) || any(m > 1), error('M must be in the range 0 <= M <= 1.'); end + + KK = ellipke_safe(m); + q = exp(-pi .* carlsonRF(zeros(size(m)), m, ones(size(m))) ./ KK); % K(1-m) from the exact m (see NOMEQ) + q(~(q < 1)) = 0; + v = pi .* u ./ (2 .* KK); + qmax = max([q(:); 0]); + if qmax > 0 + nTerms = min(1000, max(1, ceil(sqrt(log(tol) / log(qmax))))); + else + nTerms = 1; end - - I = find(abs(m-1) > 10*eps & abs(m) > 10*eps); - if ~isempty(I) - mmax = length(I); - mu = m(I); - - % Transposed layout: rows=elements, cols=iterations (OCL-friendly) - MAX_ITER = 12; - a = gpuArray(zeros(mmax, MAX_ITER)); - b = gpuArray(zeros(mmax, MAX_ITER)); - c = gpuArray(zeros(mmax, MAX_ITER)); - a(:,1) = gpuArray(ones(mmax,1)); - c(:,1) = gpuArray(sqrt(mu)); - b(:,1) = gpuArray(sqrt(1 - mu)); - n = zeros(mmax, 1); - ii = 1; - while any(gather(abs(c(:,ii))) > tol) - ii = ii + 1; - a(:,ii) = 0.5 * (a(:,ii-1) + b(:,ii-1)); - b(:,ii) = sqrt(a(:,ii-1) .* b(:,ii-1)); - c(:,ii) = 0.5 * (a(:,ii-1) - b(:,ii-1)); - mask = logical(gather((abs(c(:,ii)) <= tol) & (abs(c(:,ii-1)) > tol))); - n(mask) = ii - 1; - end - - % Ascending Landen back-substitution with multiplicative masking - phin = gpuArray((2 .^ n) .* gather(a(:,ii)) .* u(I)); - phin_pred = phin; - prodth = gpuArray(ones(mmax, MAX_ITER)); - for jj = ii-1:-1:1 - active = gpuArray(double(n >= jj)); - phin_new = 0.5*(asin(c(:,jj+1).*sin(phin)./a(:,jj+1)) + phin); - phin_upd = phin + active .* (phin_new - phin); - prodth(:,jj) = 1 + active .* ((sec(2*phin_upd - phin_pred)).^(1/2^(jj+1)) - 1); - if jj > 1, phin_pred = phin_upd; end - phin = phin_upd; - end - - th_save = sqrt(2*sqrt(1 - m(I)) .* KK(I)/pi .* ... - gather(cos(phin_pred - phin) ./ cos(phin))) .* gather(prod(prodth, 2)); - Th(I) = th_save; - H(I) = sqrt(sqrt(m(I))) .* gather(sin(phin)) .* th_save; + qg = gpuArray(q); vg = gpuArray(v); + Thg = gpuArray(ones(size(v))); + Hg = gpuArray(zeros(size(v))); + for nn = 1:nTerms + Thg = Thg + 2*(-1)^nn .* qg.^(nn^2) .* cos(2*nn .* vg); end + for nn = 0:nTerms + Hg = Hg + 2*(-1)^nn .* qg.^((nn+0.5)^2) .* sin((2*nn+1) .* vg); + end + Th(:) = gather(Thg); + H(:) = gather(Hg); % Special cases: m = {0, 1} m0 = find(abs(m) < 10*eps); if ~isempty(m0), Th(m0) = 1; H(m0) = sqrt(sqrt(m(m0))) .* sin(u(m0)); end - m1 = find(abs(m-1) < 10*eps); - if ~isempty(m1), Th(m1) = NaN; H(m1) = NaN; end \ No newline at end of file + if ~isempty(m1), Th(m1) = NaN; H(m1) = NaN; end diff --git a/matlab/src/nome2m.m b/matlab/src/nome2m.m index 13a62c0..ba71279 100644 --- a/matlab/src/nome2m.m +++ b/matlab/src/nome2m.m @@ -1,8 +1,14 @@ function m = nome2m(q) -%NOME2M Inverse of Moiseev's nomeq: q -> m (00),'q must satisfy 0 q - m = arrayfun(@(qq) fzero(f, [1e-8 1-1e-8]), q); % vectorised +%NOME2M Inverse of NOMEQ: q -> m (0 < q < 1). Alias of INVERSENOMEQ. +% M = NOME2M(Q) returns the parameter m whose nome is Q, elementwise, via +% the closed theta form m = (theta2(0,q)/theta3(0,q))^4 (DLMF 20.9.1), which +% is exact on the whole open interval. The previous fzero bracket +% [1e-8, 1-1e-8] covered only q in (6e-10, 0.62), and its objective captured +% the whole input array, so any array input errored inside fzero. +% +% See also INVERSENOMEQ, NOMEQ. +if ~isreal(q) || any(~(q > 0 & q < 1)) + error('nome2m: q must satisfy 0 < q < 1.'); +end +m = inversenomeq(q); end - diff --git a/matlab/src/nomeq.m b/matlab/src/nomeq.m index 6b3a69a..24eedd7 100644 --- a/matlab/src/nomeq.m +++ b/matlab/src/nomeq.m @@ -33,6 +33,17 @@ error('Input arguments must be real.') end -NomeQ = exp(-pi*ellipke(1-m,tol)./ellipke(m,tol)); +% K'(m) = K(1-m) = R_F(0, m, 1) evaluated from the EXACT argument m: +% ellipke(1-m) rounds 1-m first and lost ~eps/m relative digits +% (q(1e-16) was 11% off, q(1e-17) came back 0). +% NaN elements propagate; finite elements outside [0, 1] are a domain error +% (ellipke used to abort with 'algorithm did not converge' on a single NaN). +bad = isnan(m); +if any(m(~bad) < 0) || any(m(~bad) > 1) + error('nomeq: m must be in the range 0 <= m <= 1.'); +end +NomeQ = nan(size(m)); +mv = m(~bad); +NomeQ(~bad) = exp(-pi*carlsonRF(zeros(size(mv)), mv, ones(size(mv)))./ellipke(mv,tol)); % END FUNCTION nomeq() \ No newline at end of file diff --git a/matlab/src/par_worker.m b/matlab/src/par_worker.m index 02a71a9..72fe71c 100644 --- a/matlab/src/par_worker.m +++ b/matlab/src/par_worker.m @@ -1,10 +1,30 @@ function result = par_worker(func_name, varargin) %PAR_WORKER Generic parallel worker for Octave parcellfun. % Calls the named function with the given arguments and packs -% multiple outputs into a cell array. Workers run in fresh -% processes where elliptic_config defaults to parallel=false, -% preventing recursive parallelism. +% multiple outputs into a cell array. +% +% The parallel dispatch is switched OFF for the duration of the call and +% restored afterwards. Relying on parcellfun workers being fresh +% processes (where the config defaults to parallel=false) was not +% enough: whenever N is a multiple of chunk_size every chunk has exactly +% chunk_size elements, so an in-process evaluation (a serial parcellfun +% fallback, a shared-state worker, or plain testing) re-entered the +% dispatch from inside the worker without bound and crashed Octave. + was_parallel = elliptic_config('parallel'); + elliptic_config('parallel', false); + % try/catch rather than unwind_protect so the file also parses in MATLAB + try + result = par_worker_dispatch(func_name, varargin{:}); + catch err + elliptic_config('parallel', was_parallel); + rethrow(err); + end + elliptic_config('parallel', was_parallel); +end + + +function result = par_worker_dispatch(func_name, varargin) switch func_name case 'elliptic12' [F, E, Z] = elliptic12(varargin{:}); @@ -57,3 +77,4 @@ otherwise error('par_worker: unknown function %s', func_name); end +end diff --git a/matlab/src/sub_kpi.m b/matlab/src/sub_kpi.m new file mode 100644 index 0000000..5ab04e0 --- /dev/null +++ b/matlab/src/sub_kpi.m @@ -0,0 +1,15 @@ +function r = sub_kpi(u, k) +%SUB_KPI r = u - k*pi with the product formed exactly (three-term split of pi). +% R = SUB_KPI(U, K) returns U - K*PI for integer-valued K, accurate to +% eps*|R| rather than eps*|U|. PI is split as PI_A + PI_B + PI_C where +% PI_A and PI_B carry 25 significant bits each, so K*PI_A and K*PI_B are +% exact in double for |K| < 2^28 (|U| < 8e8); the remaining K*PI_C rounds +% at eps*|K|*1.6e-8, far below eps*|R|. Using double(pi) as the leading +% term (the previous "Cody-Waite" split) does not help: K*double(pi) +% already rounds by eps*|U| (2.3e-10 at U = 1e6), which Jacobi Zeta and E +% inherit. +% +% PI_A = 0x1.921fb5p+1, PI_B = 0x1.110b46p-26, PI_C = pi - PI_A - PI_B +% (residual 1.3e-24). Works elementwise on host or GPU arrays. +r = ((u - k .* 3.1415926218032837) - k .* 1.5893254712295857e-08) - k .* 1.5893254834760535e-08; +end diff --git a/matlab/src/theta.m b/matlab/src/theta.m index 381f198..42eadb8 100644 --- a/matlab/src/theta.m +++ b/matlab/src/theta.m @@ -47,11 +47,11 @@ error('Input arguments must be real.') end -Th = zeros(size(v)); -H = Th; - +if isempty(v) || isempty(m), Th = zeros(size(v)); if isempty(m), Th = zeros(size(m)); end; return; end if length(m)==1, m = m(ones(size(v))); end if length(v)==1, v = v(ones(size(m))); end +Th = zeros(size(v)); % after broadcasting (a scalar v with a vector m used to hit a size error) +H = Th; if ~isequal(size(m),size(v)), error('V and M must be the same size.'); end % m = m(:).'; % make a row vector @@ -61,24 +61,17 @@ error('M must be in the range 0 <= M <= 1.'); end -K = ellipke(m); -u = 2*K.*v/pi; - -switch type - case { '1', 1 } - [th, H] = jacobiThetaEta(u,m,tol); - Th(:) = H; - return; - case { '2', 2 } - [th, H] = jacobiThetaEta(u+K,m,tol); - Th(:) = H; - return; - case { '3', 3 } - Th(:) = jacobiThetaEta(u+K,m,tol); - return; - case { '4', 4 } - Th(:) = jacobiThetaEta(u,m,tol); - return; +% Evaluate the q-series directly on v. The old route v -> u = 2Kv/pi -> +% jacobiThetaEta -> v = pi*u/(2K) round-tripped the argument and lost eps*|v| +% (2e-10 at v ~ 1e8); THETA_SERIES also avoids the k*v product rounding. +% K'(m) = R_F(0, m, 1) from the exact m: ellipke(1-m) rounds 1-m first and the +% nome was 30% off at m ~ 1e-16 (theta1 off by 1e-5); see NOMEQ. +q = exp(-pi .* carlsonRF(zeros(size(m)), m, ones(size(m))) ./ ellipke_safe(m)); +q(~(q < 1)) = 0; % m == 1: series diverges -> NaN below +Th(:) = theta_series(type, v, q, tol); +Th(m == 1 | isnan(m)) = NaN; % q = 0 stands in for NaN m above; give NaN back +if type == 1 + Th(m == 0) = 0; % theta_1(v, 0) = 0 exactly end % END FUNCTION theta() \ No newline at end of file diff --git a/matlab/src/theta_prime.m b/matlab/src/theta_prime.m index 1c94b89..5f64ea2 100644 --- a/matlab/src/theta_prime.m +++ b/matlab/src/theta_prime.m @@ -63,6 +63,17 @@ % Moiseev Igor, % 34106, SISSA, via Beirut n. 2-4, Trieste, Italy +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 3 && (isempty(j) || isempty(z) || isempty(m)) + sz = size(j); + if isempty(z), sz = size(z); end + if isempty(m), sz = size(m); end + th = zeros(sz); + thp = zeros(sz); + return; +end + if nargin < 4, tol = eps; end if nargin < 3, error('Not enough input arguments.'); end @@ -88,47 +99,12 @@ error('M must be in the range 0 <= M <= 1.'); end -th = theta(j, z, m); % Moiseev's θ-function - -% Derivative directly from the defining q-series (A&S 16.27): -% θ1'(v) = 2 Σ (-1)^n (2n+1) q^((n+1/2)^2) cos((2n+1)v) -% θ2'(v) = -2 Σ (2n+1) q^((n+1/2)^2) sin((2n+1)v) -% θ3'(v) = -4 Σ n q^(n^2) sin(2nv) -% θ4'(v) = -4 Σ (-1)^n n q^(n^2) sin(2nv) -% The previous logarithmic-derivative form th*(2K/pi)*(Z + cn.*dn./sn) -% returned NaN (0*Inf) wherever the theta itself vanishes: θ1 at z = k*pi, -% θ2 at z = pi/2 + k*pi. The series has no such holes. -K = ellipke(m); -Kp = ellipke(1 - m); +K = ellipke_safe(m); +Kp = carlsonRF(zeros(size(m)), m, ones(size(m))); % K(1-m) from the exact m (see NOMEQ) q = exp(-pi .* Kp ./ K); q(~(q < 1)) = 0; % m == 1 guard - -qmax = max([q(:); 0]); -if qmax > 0 - nT = min(1000, max(1, ceil(sqrt(log(tol) / log(qmax))))); -else - nT = 1; -end - -thp = zeros(size(z)); -switch j - case 1 - for n = 0:nT - thp = thp + 2*(-1)^n * (2*n+1) .* q.^((n+0.5)^2) .* cos((2*n+1).*z); - end - case 2 - for n = 0:nT - thp = thp - 2*(2*n+1) .* q.^((n+0.5)^2) .* sin((2*n+1).*z); - end - case 3 - for n = 1:nT - thp = thp - 4*n .* q.^(n^2) .* sin(2*n.*z); - end - case 4 - for n = 1:nT - thp = thp - 4*(-1)^n * n .* q.^(n^2) .* sin(2*n.*z); - end -end +[th, thp] = theta_series(j, z, q, tol); +th(m == 1 | isnan(m)) = NaN; thp(m == 1 | isnan(m)) = NaN; % q = 0 stood in for NaN m +if j == 1, th(m == 0) = 0; end end - diff --git a/matlab/src/theta_series.m b/matlab/src/theta_series.m new file mode 100644 index 0000000..ec86e8e --- /dev/null +++ b/matlab/src/theta_series.m @@ -0,0 +1,63 @@ +function [th, thp] = theta_series(j, v, q, tol) +%THETA_SERIES Jacobi theta function theta_j(v, q) and d/dv from the q-series. +% [TH, THP] = THETA_SERIES(J, V, Q, TOL) evaluates (A&S 16.27) +% theta_1 = 2 sum (-1)^n q^((n+1/2)^2) sin((2n+1)v) +% theta_2 = 2 sum q^((n+1/2)^2) cos((2n+1)v) +% theta_3 = 1 + 2 sum q^(n^2) cos(2nv) +% theta_4 = 1 + 2 sum (-1)^n q^(n^2) cos(2nv) +% and its v-derivative, for arrays V and Q of the same size (0 <= Q < 1). +% +% sin/cos of the multiples (2n+1)v and 2nv come from the angle-addition +% recurrence started at sin v, cos v: forming k*v as a double product +% rounds by eps*|k v|, which cost 1e-12 at v ~ 1e8 and 1e-8 at v ~ 1e11. +% +% Shared by THETA, THETA_PRIME, JACOBITHETAETA, WEIERSTRASSZETA/SIGMA. + +if nargin < 4, tol = eps; end +qmax = max([q(:); 0]); +if qmax > 0 + nT = min(1000, max(1, ceil(sqrt(log(tol) / log(qmax))))); +else + nT = 1; +end +s1 = sin(v); c1 = cos(v); +s2 = 2 .* s1 .* c1; c2 = 1 - 2 .* s1.^2; % sin 2v, cos 2v +th = zeros(size(v)); thp = th; +switch j + case 1 + sk = s1; ck = c1; + for n = 0:nT + qq = (-1)^n .* q.^((n+0.5)^2); + th = th + qq .* sk; + thp = thp + qq .* (2*n+1) .* ck; + [sk, ck] = deal(sk.*c2 + ck.*s2, ck.*c2 - sk.*s2); + end + th = 2*th; thp = 2*thp; + case 2 + sk = s1; ck = c1; + for n = 0:nT + qq = q.^((n+0.5)^2); + th = th + qq .* ck; + thp = thp - qq .* (2*n+1) .* sk; + [sk, ck] = deal(sk.*c2 + ck.*s2, ck.*c2 - sk.*s2); + end + th = 2*th; thp = 2*thp; + case 3 + sk = s2; ck = c2; th = ones(size(v)); + for n = 1:nT + qq = q.^(n^2); + th = th + 2 .* qq .* ck; + thp = thp - 4*n .* qq .* sk; + [sk, ck] = deal(sk.*c2 + ck.*s2, ck.*c2 - sk.*s2); + end + case 4 + sk = s2; ck = c2; th = ones(size(v)); + for n = 1:nT + qq = (-1)^n .* q.^(n^2); + th = th + 2 .* qq .* ck; + thp = thp - 4*n .* qq .* sk; + [sk, ck] = deal(sk.*c2 + ck.*s2, ck.*c2 - sk.*s2); + end + otherwise + error('theta_series: J must be 1, 2, 3, or 4.'); +end diff --git a/matlab/src/weierstrassP.m b/matlab/src/weierstrassP.m index 7caef4e..103f21d 100644 --- a/matlab/src/weierstrassP.m +++ b/matlab/src/weierstrassP.m @@ -25,6 +25,17 @@ % Functions", Dover, 1965, §18.9. % [2] NIST DLMF §23.6. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 4 && (isempty(z) || isempty(e1) || isempty(e2) || isempty(e3)) + sz = size(z); + if isempty(e1), sz = size(e1); end + if isempty(e2), sz = size(e2); end + if isempty(e3), sz = size(e3); end + P = zeros(sz); + return; +end + if nargin < 4, error('weierstrassP: requires four arguments (z, e1, e2, e3).'); end if ~isreal(z) || ~isreal(e1) || ~isreal(e2) || ~isreal(e3) error('weierstrassP: all input arguments must be real.'); @@ -63,11 +74,23 @@ function P = weierP_core(z, e1, e2, e3) %WEIEP_CORE Vectorised serial evaluation (row-vector inputs). m = (e2 - e3) ./ (e1 - e3); -w = z .* sqrt(e1 - e3); +mp = (e1 - e2) ./ (e1 - e3); +% Reduce z by the real period 2*omega1 BEFORE ellipj, with omega1 from +% R_F(0, 1-m, 1) and 1-m = (e1-e2)/(e1-e3) formed without cancellation. +% ellipj's own reduction uses K from an AGM seeded with sqrt(1-m) (1-m +% rounded), which loses ~eps/(1-m) relative digits near m -> 1 lattices +% and put P(2*omega1 + 1e-9) off by 40%. sn^2 and cn*dn/sn^3 are +% invariant under w -> w + 2K, so no sign bookkeeping is needed. +omega1 = carlsonRF(zeros(size(m)), mp, ones(size(m))) ./ sqrt(e1 - e3); +zr = z - 2 .* round(z ./ (2 .* omega1)) .* omega1; +w = zr .* sqrt(e1 - e3); [sn, ~, ~] = ellipj(w, m); P = e3 + (e1 - e3) ./ sn.^2; % Poles: sn -> 0 at z = 0 and at lattice points -P(abs(sn) < eps^(1/3)) = Inf; +% Pole only where sn vanishes exactly (z at a representable lattice +% point): the old abs(sn) < eps^(1/3) window (~6e-6!) replaced huge +% finite near-pole values -- P(1e-16) ~ 1e32 -- with Inf. +P(sn == 0) = Inf; % ----------------------------------------------------------------------- @@ -75,11 +98,17 @@ %GPU_WEIERSTRASSP GPU path: ellipj handles its own GPU dispatch internally. z_f = z(:).'; e1_f = e1(:).'; e2_f = e2(:).'; e3_f = e3(:).'; m = (e2_f - e3_f) ./ (e1_f - e3_f); -w = z_f .* sqrt(e1_f - e3_f); +mp = (e1_f - e2_f) ./ (e1_f - e3_f); +omega1 = carlsonRF(zeros(size(m)), mp, ones(size(m))) ./ sqrt(e1_f - e3_f); +zr = z_f - 2 .* round(z_f ./ (2 .* omega1)) .* omega1; +w = zr .* sqrt(e1_f - e3_f); % ellipj sees has_gpu()=true and dispatches to gpu_ellipj automatically [sn, ~, ~] = ellipj(w, m); P = e3_f + (e1_f - e3_f) ./ sn.^2; -P(abs(sn) < eps^(1/3)) = Inf; +% Pole only where sn vanishes exactly (z at a representable lattice +% point): the old abs(sn) < eps^(1/3) window (~6e-6!) replaced huge +% finite near-pole values -- P(1e-16) ~ 1e32 -- with Inf. +P(sn == 0) = Inf; P = reshape(P, origSize); diff --git a/matlab/src/weierstrassPPrime.m b/matlab/src/weierstrassPPrime.m index f0ee7d1..40a293c 100644 --- a/matlab/src/weierstrassPPrime.m +++ b/matlab/src/weierstrassPPrime.m @@ -25,6 +25,17 @@ % [1] M. Abramowitz and I.A. Stegun, "Handbook of Mathematical % Functions", Dover, 1965, §18.9. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 4 && (isempty(z) || isempty(e1) || isempty(e2) || isempty(e3)) + sz = size(z); + if isempty(e1), sz = size(e1); end + if isempty(e2), sz = size(e2); end + if isempty(e3), sz = size(e3); end + dP = zeros(sz); + return; +end + if nargin < 4, error('weierstrassPPrime: requires four arguments (z, e1, e2, e3).'); end if ~isreal(z) || ~isreal(e1) || ~isreal(e2) || ~isreal(e3) error('weierstrassPPrime: all input arguments must be real.'); @@ -62,12 +73,24 @@ function dP = weierPP_core(z, e1, e2, e3) %WEIEPPCORE Vectorised serial evaluation (row-vector inputs). m = (e2 - e3) ./ (e1 - e3); -w = z .* sqrt(e1 - e3); +mp = (e1 - e2) ./ (e1 - e3); +% Reduce z by the real period 2*omega1 BEFORE ellipj, with omega1 from +% R_F(0, 1-m, 1) and 1-m = (e1-e2)/(e1-e3) formed without cancellation. +% ellipj's own reduction uses K from an AGM seeded with sqrt(1-m) (1-m +% rounded), which loses ~eps/(1-m) relative digits near m -> 1 lattices +% and put P(2*omega1 + 1e-9) off by 40%. sn^2 and cn*dn/sn^3 are +% invariant under w -> w + 2K, so no sign bookkeeping is needed. +omega1 = carlsonRF(zeros(size(m)), mp, ones(size(m))) ./ sqrt(e1 - e3); +zr = z - 2 .* round(z ./ (2 .* omega1)) .* omega1; +w = zr .* sqrt(e1 - e3); [sn, cn, dn] = ellipj(w, m); scale = -2 .* (e1 - e3).^(3/2); dP = scale .* cn .* dn ./ sn.^3; % Poles: sn -> 0 at z = 0 and at lattice points -dP(abs(sn) < eps^(1/3)) = Inf; +% Pole only where sn vanishes exactly (z at a representable lattice +% point): the old abs(sn) < eps^(1/3) window (~6e-6!) replaced huge +% finite near-pole values -- P(1e-16) ~ 1e32 -- with Inf. +dP(sn == 0) = Inf; % ----------------------------------------------------------------------- @@ -75,11 +98,17 @@ %GPU_WEIERSTRASSPPRIME GPU path via ellipj's internal GPU dispatch. z_f = z(:).'; e1_f = e1(:).'; e2_f = e2(:).'; e3_f = e3(:).'; m = (e2_f - e3_f) ./ (e1_f - e3_f); -w = z_f .* sqrt(e1_f - e3_f); +mp = (e1_f - e2_f) ./ (e1_f - e3_f); +omega1 = carlsonRF(zeros(size(m)), mp, ones(size(m))) ./ sqrt(e1_f - e3_f); +zr = z_f - 2 .* round(z_f ./ (2 .* omega1)) .* omega1; +w = zr .* sqrt(e1_f - e3_f); [sn, cn, dn] = ellipj(w, m); scale = -2 .* (e1_f - e3_f).^(3/2); dP = scale .* cn .* dn ./ sn.^3; -dP(abs(sn) < eps^(1/3)) = Inf; +% Pole only where sn vanishes exactly (z at a representable lattice +% point): the old abs(sn) < eps^(1/3) window (~6e-6!) replaced huge +% finite near-pole values -- P(1e-16) ~ 1e32 -- with Inf. +dP(sn == 0) = Inf; dP = reshape(dP, origSize); diff --git a/matlab/src/weierstrassSigma.m b/matlab/src/weierstrassSigma.m index 6ddf354..75b8b8e 100644 --- a/matlab/src/weierstrassSigma.m +++ b/matlab/src/weierstrassSigma.m @@ -27,6 +27,17 @@ % Functions", Dover, 1965, §18.3, 18.5. % [2] NIST DLMF §23.2. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 4 && (isempty(z) || isempty(e1) || isempty(e2) || isempty(e3)) + sz = size(z); + if isempty(e1), sz = size(e1); end + if isempty(e2), sz = size(e2); end + if isempty(e3), sz = size(e3); end + S = zeros(sz); + return; +end + if nargin < 4, error('weierstrassSigma: requires four arguments (z, e1, e2, e3).'); end if ~isreal(z) || ~isreal(e1) || ~isreal(e2) || ~isreal(e3) error('weierstrassSigma: all input arguments must be real.'); @@ -76,9 +87,11 @@ % catastrophically wrong (magnitude AND sign) for |z| > 2*omega1; the % theta form is entire and carries every lattice zero and sign change. -m_param = (e2 - e3) ./ (e1 - e3); -KK = ellipke(m_param); -KKp = ellipke(1 - m_param); +m_param = (e2 - e3) ./ (e1 - e3); +mp_param = (e1 - e2) ./ (e1 - e3); % 1-m without cancellation +one = ones(size(m_param)); zed = zeros(size(m_param)); +KK = carlsonRF(zed, mp_param, one); +KKp = carlsonRF(zed, m_param, one); omega1 = KK ./ sqrt(e1 - e3); q = exp(-pi .* KKp ./ KK); v = pi .* z ./ (2 .* omega1); @@ -102,13 +115,17 @@ end th1 = zeros(size(v)); th1p = th1; th1p0 = zeros(size(v)); th1ppp0 = th1p0; +% sin/cos of (2n+1)v by angle-addition from sin v, cos v (k*v as a double +% product rounds by eps*|k v|; see THETA_SERIES) +sk = sin(v); ck = cos(v); s2 = 2 .* sk .* ck; c2 = 1 - 2 .* sk.^2; for n = 0:nT qq = (-1)^n .* q.^((n+0.5)^2); k = 2*n + 1; - th1 = th1 + qq .* sin(k .* v); - th1p = th1p + qq .* k .* cos(k .* v); + th1 = th1 + qq .* sk; + th1p = th1p + qq .* k .* ck; th1p0 = th1p0 + qq .* k; th1ppp0 = th1ppp0 - qq .* k^3; + [sk, ck] = deal(sk.*c2 + ck.*s2, ck.*c2 - sk.*s2); end diff --git a/matlab/src/weierstrassZeta.m b/matlab/src/weierstrassZeta.m index c5b598c..a3073ed 100644 --- a/matlab/src/weierstrassZeta.m +++ b/matlab/src/weierstrassZeta.m @@ -25,6 +25,17 @@ % Functions", Dover, 1965, §18.3, 18.10. % [2] NIST DLMF §23.6. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 4 && (isempty(z) || isempty(e1) || isempty(e2) || isempty(e3)) + sz = size(z); + if isempty(e1), sz = size(e1); end + if isempty(e2), sz = size(e2); end + if isempty(e3), sz = size(e3); end + Z = zeros(sz); + return; +end + if nargin < 4, error('weierstrassZeta: requires four arguments (z, e1, e2, e3).'); end if ~isreal(z) || ~isreal(e1) || ~isreal(e2) || ~isreal(e3) error('weierstrassZeta: all input arguments must be real.'); @@ -74,9 +85,11 @@ % exactly (theta1'(v+pi)/theta1(v+pi) is pi-periodic, the linear term does % the rest), so no period reduction is needed either. -m_param = (e2 - e3) ./ (e1 - e3); -KK = ellipke(m_param); -KKp = ellipke(1 - m_param); +m_param = (e2 - e3) ./ (e1 - e3); +mp_param = (e1 - e2) ./ (e1 - e3); % 1-m without cancellation +one = ones(size(m_param)); zed = zeros(size(m_param)); +KK = carlsonRF(zed, mp_param, one); +KKp = carlsonRF(zed, m_param, one); omega1 = KK ./ sqrt(e1 - e3); q = exp(-pi .* KKp ./ KK); v = pi .* z ./ (2 .* omega1); @@ -102,13 +115,17 @@ end th1 = zeros(size(v)); th1p = th1; th1p0 = zeros(size(v)); th1ppp0 = th1p0; +% sin/cos of (2n+1)v by angle-addition from sin v, cos v (k*v as a double +% product rounds by eps*|k v|; see THETA_SERIES) +sk = sin(v); ck = cos(v); s2 = 2 .* sk .* ck; c2 = 1 - 2 .* sk.^2; for n = 0:nT qq = (-1)^n .* q.^((n+0.5)^2); k = 2*n + 1; - th1 = th1 + qq .* sin(k .* v); - th1p = th1p + qq .* k .* cos(k .* v); + th1 = th1 + qq .* sk; + th1p = th1p + qq .* k .* ck; th1p0 = th1p0 + qq .* k; th1ppp0 = th1ppp0 - qq .* k^3; + [sk, ck] = deal(sk.*c2 + ck.*s2, ck.*c2 - sk.*s2); end diff --git a/matlab/tests/gpu_stub/README.md b/matlab/tests/gpu_stub/README.md new file mode 100644 index 0000000..5c62158 --- /dev/null +++ b/matlab/tests/gpu_stub/README.md @@ -0,0 +1,17 @@ +# Strict device stub for the GPU code paths + +`gpuArray.m` is a stand-in for an `ocl` (OpenCL) device array that behaves +like the real one where it matters for correctness of the kernels: + +- elementwise operators work between two device arrays or with a plain + scalar; mixing a device array with a host **matrix** errors exactly like + `ocl` does (`binary operator not implemented for 'ocl matrix' by 'matrix'`), +- logical indexing and indexed assignment with logical/device masks error + (`ocl` has no logical indexing), +- `isreal` is false, `gather` rejects host arrays. + +`testGpuStrict.m` puts this directory in front of the path, switches +`elliptic_config('gpu', true)` and compares every GPU path with the CPU +path. The identity stubs used before (plain `gpuArray = @(x) x`) let three +host/device mixing defects through to the L4 hardware runs; this stub +catches them on a laptop. diff --git a/matlab/tests/gpu_stub/gather.m b/matlab/tests/gpu_stub/gather.m new file mode 100644 index 0000000..3617123 --- /dev/null +++ b/matlab/tests/gpu_stub/gather.m @@ -0,0 +1,3 @@ +function x = gather(g) + if isa(g, 'gpuArray'), x = g.d; else, error('gather: argument is not a device array (ocl errors here too)'); end +end diff --git a/matlab/tests/gpu_stub/gpuArray.m b/matlab/tests/gpu_stub/gpuArray.m new file mode 100644 index 0000000..f1a5ccc --- /dev/null +++ b/matlab/tests/gpu_stub/gpuArray.m @@ -0,0 +1,145 @@ +classdef gpuArray + % Strict stand-in for an ocl/OpenCL device array: elementwise ops work only + % between gpuArrays or with plain scalars; mixing with a host matrix errors + % exactly like the ocl package does ("not implemented for 'ocl matrix' by + % 'matrix'"), logical indexing errors, isreal is false. + properties + d + end + methods + function g = gpuArray(x) + if isa(x, 'gpuArray'), g.d = x.d; else, g.d = double(x); end + end + function x = gather(g), x = g.d; end + function x = double(g), error('gpuArray: double() of a device array; use gather()'); end + function r = isreal(~), r = false; end + function r = isa_gpu(~), r = true; end + function s = size(g, varargin), s = size(g.d, varargin{:}); end + function n = numel(g), n = numel(g.d); end + function n = length(g), n = length(g.d); end + function r = isempty(g), r = isempty(g.d); end + function r = isscalar(g), r = isscalar(g.d); end + function r = ndims(g), r = ndims(g.d); end + function r = columns(g), r = columns(g.d); end + function r = rows(g), r = rows(g.d); end + function r = isnumeric(~), r = true; end + function r = isfloat(~), r = true; end + function r = islogical(~), r = false; end + function disp(g), disp('gpuArray (strict stub)'); disp(g.d); end + function display(g), disp(g); end + % ---- binary elementwise ---- + function r = plus(a, b), r = gpuArray(gpuArray.bin(a, b, @plus)); end + function r = minus(a, b), r = gpuArray(gpuArray.bin(a, b, @minus)); end + function r = times(a, b), r = gpuArray(gpuArray.bin(a, b, @times)); end + function r = rdivide(a, b), r = gpuArray(gpuArray.bin(a, b, @rdivide)); end + function r = ldivide(a, b), r = gpuArray(gpuArray.bin(a, b, @ldivide)); end + function r = power(a, b), r = gpuArray(gpuArray.bin(a, b, @power)); end + function r = mtimes(a, b) + if isscalar(a) || isscalar(b), r = times(a, b); else, error('gpuArray: matrix product not supported by ocl'); end + end + function r = mrdivide(a, b) + if isscalar(b), r = rdivide(a, b); else, error('gpuArray: mrdivide not supported'); end + end + function r = mpower(a, b) + if isscalar(a) && isscalar(b), r = power(a, b); else, error('gpuArray: mpower not supported'); end + end + function r = lt(a, b), r = gpuArray(gpuArray.bin(a, b, @lt)); end + function r = gt(a, b), r = gpuArray(gpuArray.bin(a, b, @gt)); end + function r = le(a, b), r = gpuArray(gpuArray.bin(a, b, @le)); end + function r = ge(a, b), r = gpuArray(gpuArray.bin(a, b, @ge)); end + function r = eq(a, b), r = gpuArray(gpuArray.bin(a, b, @eq)); end + function r = ne(a, b), r = gpuArray(gpuArray.bin(a, b, @ne)); end + function r = and(a, b), r = gpuArray(gpuArray.bin(a, b, @and)); end + function r = or(a, b), r = gpuArray(gpuArray.bin(a, b, @or)); end + function r = max(a, b, varargin) + if nargin == 1, r = gpuArray(max(a.d)); else, r = gpuArray(gpuArray.bin(a, b, @max)); end + end + function r = min(a, b, varargin) + if nargin == 1, r = gpuArray(min(a.d)); else, r = gpuArray(gpuArray.bin(a, b, @min)); end + end + function r = atan2(a, b), r = gpuArray(gpuArray.bin(a, b, @atan2)); end + function r = mod(a, b), r = gpuArray(gpuArray.bin(a, b, @mod)); end + % ---- unary ---- + function r = uminus(g), r = gpuArray(-g.d); end + function r = uplus(g), r = g; end + function r = not(g), r = gpuArray(~g.d); end + function r = sqrt(g), r = gpuArray(sqrt(g.d)); end + function r = sin(g), r = gpuArray(sin(g.d)); end + function r = cos(g), r = gpuArray(cos(g.d)); end + function r = tan(g), r = gpuArray(tan(g.d)); end + function r = atan(g), r = gpuArray(atan(g.d)); end + function r = asin(g), r = gpuArray(asin(g.d)); end + function r = acos(g), r = gpuArray(acos(g.d)); end + function r = sinh(g), r = gpuArray(sinh(g.d)); end + function r = cosh(g), r = gpuArray(cosh(g.d)); end + function r = tanh(g), r = gpuArray(tanh(g.d)); end + function r = exp(g), r = gpuArray(exp(g.d)); end + function r = log(g), r = gpuArray(log(g.d)); end + function r = log1p(g), r = gpuArray(log1p(g.d)); end + function r = abs(g), r = gpuArray(abs(g.d)); end + function r = sign(g), r = gpuArray(sign(g.d)); end + function r = floor(g), r = gpuArray(floor(g.d)); end + function r = ceil(g), r = gpuArray(ceil(g.d)); end + function r = round(g), r = gpuArray(round(g.d)); end + function r = fix(g), r = gpuArray(fix(g.d)); end + function r = real(g), r = gpuArray(real(g.d)); end + function r = imag(g), r = gpuArray(imag(g.d)); end + function r = isnan(g), r = gpuArray(isnan(g.d)); end + function r = isinf(g), r = gpuArray(isinf(g.d)); end + function r = isfinite(g), r = gpuArray(isfinite(g.d)); end + function r = transpose(g), r = gpuArray(g.d.'); end + function r = ctranspose(g), r = gpuArray(g.d'); end + function r = sum(g, varargin), r = gpuArray(sum(g.d, varargin{:})); end + function r = prod(g, varargin), r = gpuArray(prod(g.d, varargin{:})); end + function r = any(g, varargin), r = any(g.d, varargin{:}); end + function r = all(g, varargin), r = all(g.d, varargin{:}); end + function r = reshape(g, varargin), r = gpuArray(reshape(g.d, varargin{:})); end + function r = repmat(g, varargin), r = gpuArray(repmat(g.d, varargin{:})); end + function r = horzcat(varargin), r = gpuArray(horzcat(gpuArray.cellu(varargin){:})); end + function r = vertcat(varargin), r = gpuArray(vertcat(gpuArray.cellu(varargin){:})); end + function r = cat(dim, varargin), r = gpuArray(cat(dim, gpuArray.cellu(varargin){:})); end + % ---- indexing: numeric/colon only (ocl has no logical indexing) ---- + function r = subsref(g, s) + switch s(1).type + case '()' + for k = 1:numel(s(1).subs) + ix = s(1).subs{k}; + if islogical(ix) || isa(ix, 'gpuArray'), error('gpuArray: logical / device-array indexing not supported by ocl'); end + end + r = gpuArray(g.d(s(1).subs{:})); + if numel(s) > 1, r = subsref(r, s(2:end)); end + case '.' + if strcmp(s(1).subs, 'd'), r = g.d; else, error('gpuArray: no field %s', s(1).subs); end + otherwise, error('gpuArray: unsupported indexing'); + end + end + function g = subsasgn(g, s, v) + if ~strcmp(s(1).type, '()'), error('gpuArray: unsupported assignment'); end + for k = 1:numel(s(1).subs) + ix = s(1).subs{k}; + if islogical(ix) || isa(ix, 'gpuArray'), error('gpuArray: logical / device-array indexed assignment not supported by ocl'); end + end + if isa(v, 'gpuArray'), v = v.d; elseif ~isscalar(v), error('gpuArray: assigning a host matrix into a device array'); end + g.d(s(1).subs{:}) = v; + end + function n = end(g, k, n_), if n_ == 1, n = numel(g.d); else, n = size(g.d, k); end, end + end + methods (Static) + function x = bin(a, b, op) + ga = isa(a, 'gpuArray'); gb = isa(b, 'gpuArray'); + if ga, av = a.d; else, av = a; end + if gb, bv = b.d; else, bv = b; end + if ga && ~gb && ~isscalar(bv) && ~isempty(bv) + error('binary operator not implemented for ''ocl matrix'' by ''matrix'' operations (strict gpuArray stub)'); + elseif gb && ~ga && ~isscalar(av) && ~isempty(av) + error('binary operator not implemented for ''matrix'' by ''ocl matrix'' operations (strict gpuArray stub)'); + end + if islogical(av), av = double(av); end + if islogical(bv), bv = double(bv); end + x = op(av, bv); + end + function c = cellu(c) + for k = 1:numel(c), if isa(c{k}, 'gpuArray'), c{k} = c{k}.d; end, end + end + end +end diff --git a/matlab/tests/gpu_stub/has_gpu.m b/matlab/tests/gpu_stub/has_gpu.m new file mode 100644 index 0000000..b23ed75 --- /dev/null +++ b/matlab/tests/gpu_stub/has_gpu.m @@ -0,0 +1,5 @@ +function r = has_gpu() +%HAS_GPU Test stub: the strict device stub is "available" whenever the +% configuration flag is on (the real has_gpu also needs the ocl package). +r = elliptic_config('gpu'); +end diff --git a/matlab/tests/testDocExamples.m b/matlab/tests/testDocExamples.m new file mode 100644 index 0000000..88b13c4 --- /dev/null +++ b/matlab/tests/testDocExamples.m @@ -0,0 +1,45 @@ +function testDocExamples() +%TESTDOCEXAMPLES Every "Example:" block in the docstrings must run. +% Extracts the indented code under each "Example" heading of every +% matlab/src/*.m docstring and evaluates it in an isolated workspace. +% Lines starting with "Note" or "See also" end the block. The Python +% port runs its docstring examples through pytest --doctest-modules. +end + +%!function [ok, msg] = run_doc_example(src__) +%! ok = true; msg = ''; +%! try +%! evalc(src__); +%! catch err__ +%! ok = false; msg = err__.message; +%! end + +%!test +%! src = fileparts(which('elliptic12')); % mfilename is empty inside test blocks under test() +%! files = dir(fullfile(src, '*.m')); +%! nrun = 0; +%! for f = 1:numel(files) +%! lines = strsplit(fileread(fullfile(files(f).folder, files(f).name)), "\n"); +%! i = 1; +%! while i <= numel(lines) +%! if ~isempty(regexp(lines{i}, '^\s*%\s*Example', 'once')) +%! code = {}; j = i + 1; +%! while j <= numel(lines) && ~isempty(regexp(lines{j}, '^\s*%\s{3,}\S', 'once')) +%! c = regexprep(lines{j}, '^\s*%\s*', ''); +%! if ~isempty(regexp(c, '^\s*(Note|See also)', 'once')), break; end +%! if isempty(regexp(c, '^\s*%', 'once')), code{end+1} = c; end +%! j = j + 1; +%! end +%! if ~isempty(code) +%! nrun = nrun + 1; +%! [ok, msg] = run_doc_example(strjoin(code, "\n")); +%! assert(ok, sprintf('%s:%d docstring example failed: %s', files(f).name, i, msg)); +%! end +%! i = j; +%! else +%! i = i + 1; +%! end +%! end +%! end +%! assert(nrun >= 8, sprintf('expected at least 8 docstring examples, found %d', nrun)); + diff --git a/matlab/tests/testEdgeCases.m b/matlab/tests/testEdgeCases.m index 7563600..571aa4d 100644 --- a/matlab/tests/testEdgeCases.m +++ b/matlab/tests/testEdgeCases.m @@ -500,8 +500,8 @@ %! end % --------------------------------------------------------------------- -% P. uniquetol_compat — the grouping map that feeds the AGM in -% elliptic12/ellipj. Contract: C(ic) reconstructs A within tol, +% P. uniquetol_compat — compatibility utility (the numerical kernels now +% group exact duplicates only). Contract: C(ic) reconstructs A within tol, % C == A(ia) exactly, C strictly increasing, and near-duplicates % (within tol) collapse to one group. A wrong index map here would % silently corrupt every m-grouped elliptic value downstream. @@ -517,11 +517,424 @@ %! assert(isequal(C(:), A(ia)(:)), 'C must equal A(ia) exactly'); %! assert(all(diff(C) > 0), 'C must be strictly increasing'); %! assert(all(diff(C) > tol * max(1, abs(C(1:end-1)))), 'groups closer than tol survived'); -%! % elliptic12 must give identical results whether m is grouped or not: +%! % elliptic12 must preserve every distinct m rather than tolerance-group it: %! m_dup = [0.4, 0.4+5e-13, 0.4-5e-13, 0.7, 0.7+1e-12]; %! [F1,E1] = elliptic12(1.1*ones(size(m_dup)), m_dup); %! for k = 1:numel(m_dup) %! [F2,E2] = elliptic12(1.1, m_dup(k)); -%! assert(abs(F1(k)-F2) < 5e-11 && abs(E1(k)-E2) < 5e-11, ... +%! assert(abs(F1(k)-F2) < 2e-13 && abs(E1(k)-E2) < 2e-13, ... %! 'grouped vs scalar elliptic12 disagree at k=%d', k); %! end + +% --------------------------------------------------------------------- +% Q. Adversarial-review round (external Codex + mpmath 1.4.1, dps=40). +% Each block below is a counterexample that a prior version failed. +% --------------------------------------------------------------------- +%!test +%! clear +%! % Q1: negative amplitude while the COMPLETE integral has a pole (0*Inf). +%! assert(abs(elliptic3(-1, 0.5, 1) - (-1.7319915420235269928)) < 1e-13, 'Pi(-1|.5,1) wrong'); +%! assert(abs(elliptic3(-1, 1, 0.2) - (-1.3115010674599590753)) < 1e-13, 'Pi(-1|1,.2) wrong'); +%! assert(~isnan(elliptic3(-0.4, 1, 1)), 'Pi(-0.4|1,1) must not be NaN'); + +%!test +%! clear +%! % Q2: complex F/E small-m series region (A&S path lost sqrt(eps/m) digits). +%! assert(abs(elliptic12i(0.2i, 1e-20) - 0.2i) < 1e-15, 'F(0.2i|1e-20)'); +%! [F,E] = elliptic12i(pi/2 + 0.2i, 1e-14); +%! assert(abs(F - (1.5707963267949005462 + 0.20000000000000101344i)) < 1e-13, 'F(pi/2+0.2i|1e-14)'); +%! assert(abs(E - (1.5707963267948926922 + 0.19999999999999898656i)) < 1e-13, 'E(pi/2+0.2i|1e-14)'); +%! [F,E] = elliptic12i(pi/2 + 0.2i, 1e-6); +%! assert(abs(F - (1.5707967194941992113 + 0.20000010134411776594i)) < 5e-12, 'F(pi/2+0.2i|1e-6)'); +%! assert(abs(E - (1.5707959340957412894 + 0.19999989865593359446i)) < 5e-12, 'E(pi/2+0.2i|1e-6)'); +%! % both sides of the series threshold vs mpmath (dps=30) +%! Fa = elliptic12i(1.1+0.3i, 0.99e-4); +%! assert(abs(Fa - (1.1000153646885162+0.3000120622623928i)) < 1e-12, 'series side of crossover'); +%! Fb = elliptic12i(1.1+0.3i, 1.01e-4); +%! assert(abs(Fb - (1.1000156750952820+0.3000123059589823i)) < 5e-12, 'decomposition side of crossover'); + +%!test +%! clear +%! % Q3: Weierstrass near-origin values are huge but FINITE (DLMF 23.9.2); +%! % only the exact lattice point is a pole. +%! assert(abs(weierstrassP(1e-16,1,0,-1) - 1e32) < 1e19, 'P(1e-16) must be ~1e32'); +%! assert(abs(weierstrassPPrime(1e-16,1,0,-1) + 2e48) < 1e36, 'Pp(1e-16) must be ~-2e48'); +%! assert(abs(weierstrassZeta(1e-16,1,0,-1) - 1e16) < 1e3, 'zeta(1e-16) must be ~1e16'); +%! assert(isinf(weierstrassP(0,1,0,-1)), 'P(0) must be Inf'); + +%!test +%! clear +%! % Q4: inverse nome via DLMF 20.9.1 -- exact at every scale. +%! assert(abs(inversenomeq(1e-30) - 1.6e-29) < 1e-41, 'm(1e-30) must be 1.6e-29'); +%! assert(abs(inversenomeq(1e-12) - 1.5999999999872e-11) < 1e-24, 'm(1e-12) = 16q - 128q^2'); +%! for mv = [1e-8 0.3 0.85 0.999] +%! assert(abs(inversenomeq(nomeq(mv)) - mv) < 1e-12*max(mv,1e-3), 'roundtrip fails at m=%g', mv); +%! end + +%!test +%! clear +%! % Q5: Carlson scale invariance (DLMF 19.20): RF ~ lambda^(-1/2), RC same, +%! % RD/RJ ~ lambda^(-3/2). An absolute branch tolerance broke this. +%! x=1; y=2; z=3; p=4; +%! for lam = [1e-20 1e20] +%! assert(abs(carlsonRF(lam*x,lam*y,lam*z) - carlsonRF(x,y,z)/sqrt(lam)) < 1e-10*abs(carlsonRF(x,y,z)/sqrt(lam)), 'RF homogeneity at %g', lam); +%! assert(abs(carlsonRC(lam*x,lam*y) - carlsonRC(x,y)/sqrt(lam)) < 1e-10*abs(carlsonRC(x,y)/sqrt(lam)), 'RC homogeneity at %g', lam); +%! assert(abs(carlsonRD(lam*x,lam*y,lam*z) - carlsonRD(x,y,z)/lam^1.5) < 1e-10*abs(carlsonRD(x,y,z)/lam^1.5), 'RD homogeneity at %g', lam); +%! assert(abs(carlsonRJ(lam*x,lam*y,lam*z,lam*p) - carlsonRJ(x,y,z,p)/lam^1.5) < 1e-10*abs(carlsonRJ(x,y,z,p)/lam^1.5), 'RJ homogeneity at %g', lam); +%! end +%! assert(abs(carlsonRC(1e-20,2e-20) - 7853981633.9744830962) < 1e-4, 'RC(1e-20,2e-20)'); + +%!test +%! clear +%! % Q6: ellipticBD nondegenerate anchors (mpmath: B=(E-(1-m)K)/m, D=(K-E)/m). +%! R = [0.2 0.8066808960371526438 0.85294270257337535705 +%! 0.7 0.88437375336868858245 1.1909893819237805614 +%! 0.999 0.99832798626015502386 3.8428045742901420065]; +%! for i = 1:rows(R) +%! [B,D] = ellipticBD(R(i,1)); +%! assert(abs(B - R(i,2)) < 1e-14, 'B(%g)', R(i,1)); +%! assert(abs(D - R(i,3)) < 1e-13, 'D(%g)', R(i,1)); +%! end + +%!test +%! clear +%! % Q7: reversed arc intervals are signed, circles included. +%! assert(abs(arclength_ellipse(2,3,1,0.1) + arclength_ellipse(2,3,0.1,1)) < 1e-13, 'ellipse arc not odd under reversal'); +%! assert(abs(arclength_ellipse(2,2,1,0.1) - (-1.8)) < 1e-13, 'reversed circle arc must be -a*(t1-t0)'); + +% --------------------------------------------------------------------- +% R. Second adversarial round (fuzz vs mpmath dps=40 over parameter +% endpoints, extreme scales, poles and period multiples). Every +% reference was evaluated at the EXACT DOUBLE the library receives -- +% near singularities the decimal input differs from its double by +% enough to move the answer at 1e-9. Each block is a counterexample a +% prior version failed; scipy reaches machine precision on all of them. +% --------------------------------------------------------------------- +%!test +%! clear +%! m1 = 1 - eps/2; +%! % R1: m -> 1 near phi = pi/2 (Delta^2 formed as (1-m) + m cos^2) +%! assert(abs(elliptic12(pi/2-1e-9, m1) - 19.65993026560449767) < 1e-12*20, 'F(pi/2-1e-9 | 1-eps/2)'); +%! % R2: m = 1 exactly: F must be exactly 0 at 0 and odd +%! assert(elliptic12(0, 1) == 0, 'F(0|1) must be exactly 0'); +%! assert(abs(elliptic12(1e-16, 1) - 1e-16) < 1e-31, 'F(1e-16|1) must be 1e-16'); +%! % R3: third kind at the endpoint poles +%! assert(abs(elliptic3(pi/2-1e-6, 0.3, 1) - 1195228.2584444625825) < 1e-12*1.2e6, 'Pi(pi/2-1e-6 | .3, c=1)'); +%! assert(abs(elliptic3(pi/2-1e-6, 1-1e-8, 0.9) - 88.615055050793590585) < 1e-12*90, 'Pi(pi/2-1e-6 | 1-1e-8, .9)'); + +%!test +%! clear +%! % R4: Carlson -- disparate scales, tiny y, near-equal args, double zeros +%! assert(abs(carlsonRJ(1e-20,2e-20,3e-20,0.5) - 43616756114.805842986) < 1e-12*4.4e10, 'RJ disparate scales'); +%! assert(abs(carlsonRJ(2,3,4,1e-10) - 7.179193296087372323) < 1e-13*7.2, 'RJ small p'); +%! assert(abs(carlsonRC(3,1e-10) - 7.3643213780616827229) < 1e-13*7.4, 'RC(3,1e-10)'); +%! assert(abs(carlsonRC(1.0000000000001,1) - 0.99999999999998334665) < 1e-14, 'RC(1+1e-13,1)'); +%! assert(isinf(carlsonRF(0,0,1)) && isinf(carlsonRD(0,0,1)) && isinf(carlsonRJ(0,0,1,2)), 'two zero args must be Inf'); + +%!test +%! clear +%! % R5: Jacobi functions at m -> 1 (atan2 Landen step) +%! assert(abs(ellipj(9.375277798108883, 1-eps/2) - 0) >= 0); % smoke: callable +%! [~,cn] = ellipj(9.375277798108883, 1-eps/2); +%! assert(abs(cn - 0.00016958935096417269446) < 1e-13*1.7e-4, 'cn(9.375 | 1-eps/2)'); +%! [~,cn] = ellipj(7, 1-1e-12); +%! assert(abs(cn - 0.0018237622775256289351) < 1e-13*1.8e-3, 'cn(7 | 1-1e-12)'); + +%!test +%! clear +%! % R6: inverse E for tiny negative z (oddness first, relative stop) +%! for m = [0 0.5 1-1e-8] +%! [~,E1] = ellipke(m); z = -1e-9*E1; +%! [~,Eb] = elliptic12(inverselliptic2(z, m), m); +%! assert(abs(Eb - z) < 1e-13*abs(z), 'inverselliptic2 tiny negative z at m=%g', m); +%! end + +%!test +%! clear +%! % R7: complex F -- cancellation-free tan^2(mu): tiny psi and small m +%! assert(abs(imag(elliptic12i(pi/2 + 1e-9i, 0.9)) - 3.1622776601683798848e-9) < 1e-12*3.2e-9, 'Im F(pi/2+1e-9i | .9)'); +%! F = elliptic12i(pi/2 + 1e-9i, 1-eps/2); +%! assert(abs(F - (19.754694640120759063 + 0.095049319491958534055i)) < 1e-9*20, 'F(pi/2+1e-9i | 1-eps/2)'); +%! F = elliptic12i(0.4 + 0.3i, 1e-4); +%! assert(abs(F - (0.39999936996865927219 + 0.30000195549059365219i)) < 1e-13, 'F(0.4+0.3i | 1e-4)'); + +%!test +%! clear +%! % R8: nome at tiny m (K' from the exact argument m); Weierstrass with the +%! % period computed from 1-m = (e1-e2)/(e1-e3) on a near-m=1 lattice +%! assert(abs(nomeq(1e-16) - 6.2500000000000001819e-18) < 1e-13*6.25e-18, 'q(1e-16)'); % pi*K'/K ~ 40 eps amplification +%! assert(abs(nomeq(1e-17) - 6.25e-19) < 1e-13*6.25e-19, 'q(1e-17)'); +%! e1 = 0.5000001; e2 = 0.5; e3 = -1.0000001; z = 13.391953465243201; +%! assert(abs(weierstrassP(z,e1,e2,e3) - 0.51848214450600943279) < 1e-12, 'P near-1 lattice'); +%! assert(abs(weierstrassZeta(z,e1,e2,e3) - -5.4787546526901492279) < 1e-12*5.5, 'Zeta near-1 lattice'); +%! assert(abs(weierstrassSigma(z,e1,e2,e3) - 1.822626274365935705e-13) < 1e-12*1.8e-13, 'Sigma near-1 lattice'); + +% --------------------------------------------------------------------- +% S. Third adversarial round: dense random fuzz + API abuse. +% S1 Cody-Waite pi reduction: F/E/Z/Pi at u = 5.5*pi + 8e-10, m = 1-1.5e-13 +% (mpmath at the exact double inputs). Before the split, k*pi rounding +% cost eps*|u| in the reduced phase, amplified 1e5x by dZ/dphi there. +% S2 NaN in, NaN out (a NaN m used to crash elliptic12's grouping). +% S3 elliptic12i(-0) is -0 exactly (the cot nudge returned eps). +% S4 R_J at an argument ratio of 1.9e44 (fixed 100 duplications). +% --------------------------------------------------------------------- +%!test +%! clear +%! u = 17.27875959554386; m = 0.99999999999985; +%! [F,E,Z] = elliptic12(u, m); +%! assert(abs(F - 177.65640489133312311) < 2e-9, 'F at 5.5pi+8e-10, m->1 (conditioning floor ~1e-9)'); +%! assert(abs(E - 11.000000000012911122) < 1e-12, 'E at 5.5pi+8e-10'); +%! assert(abs(Z - (-0.00012790056416609388015)) < 1e-10, 'Z at 5.5pi+8e-10: k*pi rounding used to cost 1e-9'); +%! assert(abs(elliptic3(u, m, 0.3) - 248.50046674013002377) < 3e-9, 'Pi at 5.5pi+8e-10'); + +%!test +%! clear +%! assert(isnan(elliptic12(0.3, NaN)) && isnan(elliptic12(NaN, 0.5)), 'elliptic12 must propagate NaN'); +%! v = elliptic12([0.3 0.5 0.7], [0.2 NaN 0.4]); +%! assert(isnan(v(2)) && ~any(isnan(v([1 3]))), 'NaN must not leak into neighbours'); +%! assert(isnan(ellipj(0.3, NaN)), 'ellipj must propagate NaN'); +%! assert(elliptic12i(-0, 0.5) == 0 && elliptic12i(0, 0.5) == 0, 'F(0) must be exactly 0'); +%! assert(abs(carlsonRJ(2.798e-18, 5.954e-24, 9.634e-23, 1.134e21) - 9.9678905686736778972e-12) < 1e-12*1e-11, 'RJ at ratio 1.9e44'); + +%% --------------------------------------------------------------------- +%% T. Theta at a huge argument (mpmath jtheta at the exact double v). +%% Forming (2n+1)*v as a double product rounded by eps*|k v| (2e-10 here, +%% 9e-9 at v ~ 1e11); the series now uses the angle-addition recurrence, +%% and theta() no longer round-trips v -> u -> v through jacobiThetaEta. +%% --------------------------------------------------------------------- +%!test +%! clear +%! v = 123456789.123; +%! [t, tp] = theta_prime(1, v, 0.4); +%! assert(abs(t - (0.84585020823346348431)) < 2e-15, 'theta1 at v=1.2e8'); +%! assert(abs(tp - (0.015114923736622936955)) < 2e-14, 'theta1'' at v=1.2e8'); +%! assert(abs(theta(1, v, 0.4) - (0.84585020823346348431)) < 2e-15, 'theta() at v=1.2e8'); +%! [t, tp] = theta_prime(2, v, 0.4); +%! assert(abs(t - (0.014932290326334898348)) < 2e-15, 'theta2 at v=1.2e8'); +%! assert(abs(tp - (-0.84241862816186020729)) < 2e-14, 'theta2'' at v=1.2e8'); +%! assert(abs(theta(2, v, 0.4) - (0.014932290326334898348)) < 2e-15, 'theta() at v=1.2e8'); +%! [t, tp] = theta_prime(3, v, 0.4); +%! assert(abs(t - (0.93627542467710214194)) < 2e-15, 'theta3 at v=1.2e8'); +%! assert(abs(tp - (-0.004519191759268069986)) < 2e-14, 'theta3'' at v=1.2e8'); +%! assert(abs(theta(3, v, 0.4) - (0.93627542467710214194)) < 2e-15, 'theta() at v=1.2e8'); +%! [t, tp] = theta_prime(4, v, 0.4); +%! assert(abs(t - (1.0637286984176921296)) < 2e-15, 'theta4 at v=1.2e8'); +%! assert(abs(tp - (0.0045203629452149075654)) < 2e-14, 'theta4'' at v=1.2e8'); +%! assert(abs(theta(4, v, 0.4) - (1.0637286984176921296)) < 2e-15, 'theta() at v=1.2e8'); + +%% --------------------------------------------------------------------- +%% U. Round 6 (cross-port parity sweep at extreme m, 2026-09-02). Every +%% anchor is mpmath at the EXACT double inputs. +%% - m in [eps^2, ~5e-16]: the AGM converges in one step, no Landen step +%% ran and the scale e stayed 0 -> F = E = Inf. +%% - theta nome from ellipke(1-m): 1-m rounds, q was 30% off at m ~ 1e-16. +%% - E/K sum stopped one AGM term early: E off by 1.4e-13 near m -> 1. +%% - k*pi reduction: k*double(pi) rounds by eps*|u| (2e-10 at u = 1e6); +%% sub_kpi splits pi into 25-bit parts so k*PI_A, k*PI_B are exact. +%% - elliptic3 reflection used Pi(double(pi/2)) as the complete integral; +%% cos(double(pi/2)) = 6e-17 is not 0 and the sliver is 2e-7 at m = 1-eps/2. +%% - carlsonRJ series: E3 = XYZ + 2 E2 P + 4 P^3 (DLMF 19.36.2), not 3 P^3. +%% - ellipticBDJ: n > 1 beyond the pole gave complex J silently; n = 1 gave NaN. +%% - elliptic3 now accepts c < 0 (as the Python port does). +%% --------------------------------------------------------------------- +%!test +%! clear +%! [F, E] = elliptic12([1 1], [3e-16 5e-16]); +%! assert(all(isfinite(F)) && all(isfinite(E)), 'F, E must be finite for m ~ 3e-16 (were Inf)'); +%! assert(abs(F(1) - 1.0) < 5e-16 && abs(E(1) - 0.99999999999999996) < 5e-16, 'F(1|3e-16), E(1|3e-16)'); +%! assert(abs(F(2) - 1.0000000000000001) < 5e-16 && abs(E(2) - 0.99999999999999993) < 5e-16, 'F(1|5e-16), E(1|5e-16)'); +%! assert(abs(theta(1, 34401.9, 1.6e-16) - 0.00011178415088289534) < 1e-13 * 1.1e-4, 'theta1 at m = 1.6e-16 (nome from exact m)'); +%! assert(abs(theta_prime(1, 6577.39, 1.5e-16) - (-9.8878892558450512e-5)) < 1e-13 * 1e-4, 'theta_prime at m = 1.5e-16'); +%! [~, H] = jacobiThetaEta(6577.39 * 2 * ellipke(1.5e-16) / pi, 1.5e-16); +%! assert(abs(H - (-9.8878892558450512e-5)) < 1e-12 * 1e-4, 'jacobiThetaEta eta at m = 1.5e-16'); +%! [F, E] = elliptic12(-1.65181, 0.99999999999999578); +%! assert(abs(E - (-1.0032798131910099)) < 2e-14, 'E near m -> 1 (missing AGM term gave 1.4e-13)'); +%! assert(abs(F - (-32.666065762173088)) < 1e-14 * 33, 'F near m -> 1'); +%! u = 80101.48788857895; m = 0.9999533239086507; % 25497*pi + 0.3 +%! [F, E, Z] = elliptic12(u, m); +%! assert(abs(Z - 0.2477141143165845) < 2e-14, 'Jacobi Zeta at u = 8e4 (k*pi split)'); +%! assert(abs(E - 51001.284415600044) < 1e-14 * 51001, 'E at u = 8e4'); +%! assert(abs(F - 324959.38078465716) < 1e-14 * 324959, 'F at u = 8e4'); +%! [~, ~, Z] = elliptic12(1000000.123, 1 - eps/2); +%! assert(abs(Z - (-0.220434859492317)) < 2e-14, 'Jacobi Zeta at u = 1e6, m = 1-eps/2'); +%! assert(abs(elliptic3(-2.70143, 1 - eps/2, 0.9723) - (-1249.3300419938347)) < 1e-14 * 1249, 'elliptic3 reflection at m = 1-eps/2 (complete integral must be exact)'); +%! assert(abs(carlsonRJ(0.1, 0.2, 1, 3.0) - 1.1311524759367163) < 5e-15 * 1.13, 'RJ series E3 coefficient'); +%! assert(abs(carlsonRJ(0.292, 0.646, 1, 1.354) - 1.2806109121365949) < 5e-15 * 1.28, 'RJ series E3 coefficient'); +%! [~, ~, J] = ellipticBDJ(1, 0.5, 1); +%! assert(abs(J - 0.64877476917835824) < 5e-15, 'J at n = 1 (was NaN)'); +%! [~, ~, J] = ellipticBDJ(0.5, 0.5, 1.5); +%! assert(abs(J - 0.052791966372572887) < 5e-16, 'J at n = 1.5 below the pole'); +%! err = ''; +%! try, ellipticBDJ(1, 0.5, 1.5); catch e, err = e.message; end +%! assert(~isempty(strfind(err, 'principal')), 'J at n = 1.5 beyond the pole must error, not return complex'); +%! assert(abs(elliptic3(1, 0.5, -0.5) - 0.9560406633267465) < 5e-16, 'elliptic3 with c = -0.5'); +%! assert(abs(elliptic3(1, 0.5, -3.0) - 0.66684868942035313) < 5e-16, 'elliptic3 with c = -3'); +%! assert(abs(elliptic3(1, 0.5, -100.0) - 0.1523863772236308) < 5e-16, 'elliptic3 with c = -100 (Carlson branch)'); +%! assert(abs(elliptic3(4, 0.9, -100.0) - 0.4921742710224714) < 5e-15, 'elliptic3 with c = -100, reduced phase'); + +%% --------------------------------------------------------------------- +%% V. Bulirsch cel (round 6b). The old route through m = 1 - kc^2 lost kc +%% entirely below ~1e-8 (cel1(1e-9) was Inf here, 2e6 in Python; the value +%% is ln(4/kc) = 22.1), rejected kc > 1 (m < 0) and returned Inf for p < 0. +%% Bulirsch's own algorithm is kc-native; p < 0 is the Cauchy principal +%% value, equal to Re Pi(1-p | 1-kc^2) (mpmath). +%% --------------------------------------------------------------------- +%!test +%! clear +%! assert(abs(cel1(1e-9) - (22.109560198066302)) < 1e-15 * 22, 'cel1(1e-9) = ln(4/kc)+...'); +%! assert(abs(cel1(1e-300) - (692.1618222593336)) < 1e-15 * 692, 'cel1(1e-300)'); +%! assert(abs(cel1(2) - (1.0782578237498216)) < 1e-15, 'cel1(2): kc > 1 is m = -3'); +%! assert(abs(cel(0.5, -0.5, 1, 1) - (-1.0782578237498216)) < 1e-15 * 1.1, 'cel with p < 0 = principal value Re Pi(1.5|0.75)'); +%! assert(abs(cel(0.7, -5, 1, 1) - (-0.092277884964284496)) < 1e-15, 'cel with p = -5'); +%! assert(abs(cel(1e-9, 0.3, 1.5, -0.7) - (-46.045402351061091)) < 1e-15 * 47, 'general cel at kc = 1e-9'); +%! assert(cel(0.3, 1, 1, 1) == cel1(0.3) && cel(-0.3, 1, 1, 1) == cel1(0.3), 'cel depends on kc^2 only'); +%! assert(isinf(cel1(0)) && cel1(0) > 0, 'cel1(0) = K(1) = Inf'); +%! kc = [1e-12 0.3 0.9 2.5]; p = [-0.4 0.7 -3 1e-3]; +%! assert(all(abs(cel(kc, p, 1, 0) + p .* cel(kc, p, 0, 1) - cel1(kc)) < 1e-14 .* max(1, cel1(kc))), 'cel(1,0) + p cel(0,1) = K'); + +%% --------------------------------------------------------------------- +%% W. Round 6c (sweep of the remaining outputs). mpmath at exact doubles. +%% - ellipticBDJ formed Delta^2 = 1 - m sin^2, which cancels near phi = pi/2 +%% as m -> 1; jacobiEDJ also took am(u) at |u| ~ 1e3 before reducing, so +%% D_u(1520|1-1e-8) was off by 3e-10. Both are at the eps*|u| floor now. +%% - arclength_ellipse branched with if(a 1 (1.034 at +%% q = 0.999, 1+1.6e-15 at q = 0.9). nome2m captured its whole input array +%% in the fzero objective and errored on any array. +%% --------------------------------------------------------------------- +%!test +%! clear +%! assert(isequal(inversenomeq([0.78 0.9 0.999]), [1 1 1]), 'inversenomeq rounds to exactly 1 above q_max'); +%! assert(all(inversenomeq([0.5 0.7 0.7789]) <= 1), 'no overshoot below q_max'); +%! assert(abs(inversenomeq(0.5) - 0.99998952213731039) < 4e-16, 'inversenomeq(0.5) (mpmath mfrom)'); +%! q = [1e-12 0.05 0.3 0.5]; % q > 0.5 puts 1-m below 1e-5, where q(m) is ill-conditioned in double +%! m = nome2m(q); +%! assert(isequal(size(m), size(q)) && max(abs(nomeq(m) - q) ./ q) < 1e-11, 'nome2m on an array, round trip'); +%! assert(nome2m(0.999) == 1, 'nome2m near q = 1'); + +%% --------------------------------------------------------------------- +%% Y. elliptic12i period term just below pi/2, and elliptic123 for m > 1. +%% lambda = (-1)^k lambda + pi*ceil(phi/pi - 0.5 + eps) counted the period +%% from a separately rounded quantity: for phi within a few ulps below +%% pi/2 (asin(sqrt(3)) is 2 ulps below) Re F came out 3K instead of K, +%% which elliptic123 inherited as K(3) = 3.003 (mpmath: 1.001). The +%% complete m > 1 case now uses the DLMF 19.7.3 closed forms instead of +%% evaluating elliptic12i exactly on its branch point (sqrt(eps)-conditioned). +%% --------------------------------------------------------------------- +%!test +%! clear +%! F = elliptic12i(1.5707963267948961 + 0.5i, 1/3); +%! assert(abs(F - (1.7339168852579344 + 0.62666316872107993i)) < 2e-15 * 2, 'F two ulps below pi/2 (was 3K + ...)'); +%! F = elliptic12i(asin(sqrt(3)), 1/3); +%! assert(abs(real(F) - 1.73391688525794) < 1e-7 && abs(imag(F) + 2.02895910274881) < 1e-7, 'F at the branch point (sqrt(eps)-conditioned there)'); +%! [K, E] = elliptic123(3); +%! assert(abs(K - (1.0010773804561062 - 1.1714200841467699i)) < 1e-15 * 2 && abs(E - (0.47522393535101711 + 1.0130180585994313i)) < 1e-15 * 2, 'K(3), E(3) closed forms (were 3x off in the real part)'); +%! [K, E] = elliptic123(pi/2, 3); +%! assert(abs(K - (1.0010773804561062 - 1.1714200841467699i)) < 1e-15 * 2 && abs(E - (0.47522393535101711 + 1.0130180585994313i)) < 1e-15 * 2, 'elliptic123(pi/2, 3) routes to the complete closed form'); +%! [K, E] = elliptic123(5); +%! assert(abs(K - (0.74220623671119323 - 1.0094529099892116i)) < 3e-16 && abs(E - (0.36075866393790281 + 1.6257306716064185i)) < 3e-15, 'K(5), E(5)'); +%! [F, E] = elliptic123(1.2, 3); +%! assert(abs(F - (1.0010773804561062 - 0.89956974520736591i)) < 1e-14 && abs(E - (0.47522393535101711 + 0.50673122232331459i)) < 1e-14, 'F(1.2|3), E(1.2|3) (m > 1, real part was 3x off)'); + +%% --------------------------------------------------------------------- +%% Z. Input shapes (round 6d). Every function must give the same values for +%% a 2x3 matrix, a column, a row and a mixed scalar/array call as for the +%% scalar loop, and keep the input shape. Found: ellipj re-read cn(I) +%% from its column-shaped output against the row m(I) (6x6 broadcast +%% error for column u), jacobiThetaEta returned a row for a matrix, +%% inverselliptic2's vector-wide Newton stop made values batch-dependent, +%% elliptic123 failed on any matrix. +%% --------------------------------------------------------------------- +%!test +%! clear +%! u = reshape(linspace(-2.5, 7.3, 6), 2, 3); m = reshape([0.1 0.5 0.9 0.999 0.3 0.7], 2, 3); n = reshape([0.2 -0.5 0.9 0.0 0.5 0.3], 2, 3); +%! [sn, cn, dn, am] = ellipj(u(:), 0.5); +%! assert(isequal(size(sn), [6 1]), 'ellipj column input'); +%! [s1, c1, d1, a1] = ellipj(u(3), 0.5); +%! assert(sn(3) == s1 && cn(3) == c1 && dn(3) == d1 && am(3) == a1, 'ellipj column == scalar'); +%! [Th, H] = jacobiThetaEta(u, m); +%! assert(isequal(size(Th), [2 3]) && isequal(size(H), [2 3]), 'jacobiThetaEta keeps the input shape'); +%! [t1, h1] = jacobiThetaEta(u(4), m(4)); +%! assert(Th(4) == t1 && H(4) == h1, 'jacobiThetaEta matrix == scalar'); +%! z = reshape(linspace(0.2, 3.1, 6), 2, 3); +%! v = inverselliptic2(z, m); +%! for i = 1:6, assert(v(i) == inverselliptic2(z(i), m(i)), 'inverselliptic2 must be batch-independent'); end +%! [F, E] = elliptic123(u, m); +%! assert(isequal(size(F), [2 3]), 'elliptic123 matrix input'); +%! for i = 1:6, [f, e] = elliptic123(u(i), m(i)); assert(abs(F(i) - f) < 1e-15 && abs(E(i) - e) < 1e-15, 'elliptic123 matrix == scalar'); end +%! [F, E, P] = elliptic123(u(:), m(:), 0.3); +%! assert(isequal(size(P), [6 1]), 'elliptic123 three outputs, column input'); +%! [Eu, Du, Ju] = jacobiEDJ(u(:), 0.5, 0.3); +%! assert(isequal(size(Ju), [6 1]), 'jacobiEDJ column u, scalar m, n'); +%! [Eu1, Du1, Ju1] = jacobiEDJ(u(2), 0.5, 0.3); +%! assert(Eu(2) == Eu1 && Du(2) == Du1 && Ju(2) == Ju1, 'jacobiEDJ column == scalar'); + +%% --------------------------------------------------------------------- +%% AA. Empty, NaN and Inf inputs (round 6e). Empty in -> empty out of the +%% same shape; a NaN element must come back as NaN without disturbing +%% its neighbours or aborting the call. Found: nine functions rejected +%% [] against a scalar partner; nomeq aborted inside ellipke on a NaN +%% ("algorithm did not converge"); inversenomeq rejected NaN as "out of +%% [0,1)"; elliptic12i raised "must be real" because (-1)^NaN is complex +%% in Octave; the Carlson wrappers returned complex NaN for -Inf and +%% complex garbage for R_J with p < 0. +%% --------------------------------------------------------------------- +%!test +%! clear +%! assert(isempty(ellipticBDJ([], 0.5, 0.3)) && isempty(theta_prime(2, [], 0.5)) && isempty(cel([], 1, 1, 1)), 'empty inputs (1)'); +%! assert(isempty(weierstrassP([], 1.5, -0.25, -1.25)) && isempty(weierstrassZeta([], 1.5, -0.25, -1.25)) && isempty(weierstrassSigma([], 1.5, -0.25, -1.25)), 'empty inputs (2)'); +%! assert(isempty(carlsonRF([], 0.5, 1)) && isempty(carlsonRJ(1, 2, 3, [])) && isempty(carlsonRC([], 1)) && isempty(arclength_ellipse(2, 3, 0, [])) && isempty(elliptic123([], 0.5)), 'empty inputs (3)'); +%! assert(isequal(size(carlsonRD(zeros(0, 3), 1, 2)), [0 3]), 'empty keeps its shape'); +%! q = nomeq([0.3 NaN 0.7]); +%! assert(isnan(q(2)) && ~any(isnan(q([1 3]))) && q(1) == nomeq(0.3), 'nomeq isolates NaN'); +%! m = inversenomeq([0.05 NaN 0.3]); +%! assert(isnan(m(2)) && m(1) == inversenomeq(0.05) && m(3) == inversenomeq(0.3), 'inversenomeq isolates NaN'); +%! F = elliptic12i([0.3 NaN 0.7 Inf] + 0.2i, 0.5); +%! assert(isnan(F(2)) && isnan(F(4)) && ~isnan(F(1)) && F(1) == elliptic12i(0.3 + 0.2i, 0.5), 'elliptic12i isolates NaN / Inf'); +%! assert(isnan(carlsonRF(-Inf, 0.5, 1)) && isreal(carlsonRF(-Inf, 0.5, 1)) && isnan(carlsonRF(-1, 2, 3)), 'Carlson: -Inf / negative -> real NaN'); +%! v = carlsonRF([1 NaN 2], 2, 3); +%! assert(isnan(v(2)) && v(1) == carlsonRF(1, 2, 3), 'Carlson isolates NaN'); +%! err = ''; try, carlsonRJ(1, 2, 3, -1); catch e, err = e.message; end +%! assert(~isempty(strfind(err, 'principal')), 'carlsonRJ p < 0 must error, not return complex'); +%! t = theta(1, [0.3 0.5 0.7], [0.2 NaN 0.4]); +%! assert(isnan(t(2)) && t(1) == theta(1, 0.3, 0.2), 'theta isolates NaN in m (Octave ellipke aborts on NaN)'); +%! [th, thp] = theta_prime(2, 0.4, [0.2 NaN]); +%! assert(isnan(th(2)) && isnan(thp(2)) && ~isnan(th(1)), 'theta_prime isolates NaN in m'); +%! [Th, H] = jacobiThetaEta([0.3 0.5], [0.2 NaN]); +%! assert(isnan(Th(2)) && isnan(H(2)) && Th(1) == jacobiThetaEta(0.3, 0.2), 'jacobiThetaEta isolates NaN in m'); +%! assert(isnan(inverselliptic2(0.4, NaN)) && isnan(elliptic12i(0.3 + 0.2i, NaN)), 'inverselliptic2 / elliptic12i NaN in m'); + +%% --------------------------------------------------------------------- +%% AB. Scalar first argument with a parameter vector (round 6f). elliptic3 +%% expanded c from the still-scalar u before u was expanded from m and +%% rejected the call as "must be the same size"; theta preallocated its +%% output before broadcasting; elliptic123 restored the shape of the +%% scalar phase. Each result must equal the scalar-loop values. +%% --------------------------------------------------------------------- +%!test +%! clear +%! mv = [0.2 0.5 0.9]; +%! P = elliptic3(0.3, mv, 0.3); +%! assert(isequal(size(P), [1 3]) && P(2) == elliptic3(0.3, 0.5, 0.3), 'elliptic3 scalar u, vector m'); +%! P = elliptic3(0.3, 0.5, [0.1 0.2 0.3]); +%! assert(isequal(size(P), [1 3]) && P(3) == elliptic3(0.3, 0.5, 0.3), 'elliptic3 scalar u, m; vector c'); +%! t = theta(1, 0.3, mv); +%! assert(isequal(size(t), [1 3]) && t(2) == theta(1, 0.3, 0.5), 'theta scalar v, vector m'); +%! [F, E] = elliptic123(0.3, mv); +%! assert(isequal(size(F), [1 3]) && F(2) == elliptic123(0.3, 0.5), 'elliptic123 scalar b, vector m'); +%! assert(isempty(elliptic3([], 0.5, 0.3)) && isempty(theta(1, [], 0.5)) && isempty(elliptic3(0.3, [], 0.3)), 'empty inputs'); diff --git a/matlab/tests/testElliptic3.m b/matlab/tests/testElliptic3.m index ffc34bb..c66a37e 100644 --- a/matlab/tests/testElliptic3.m +++ b/matlab/tests/testElliptic3.m @@ -8,7 +8,7 @@ %! assert(false, "Module out of range didn't throw an error."); %! catch err % Verify that the error message contains the expected string -%! assert(~isempty(strfind(err.message, 'M and C must be in the range [0, 1].')), ... +%! assert(~isempty(strfind(err.message, 'M must be in the range [0, 1] and C <= 1.')), ... %! 'Unexpected error message: %s', err.message); %! end diff --git a/matlab/tests/testGpu.m b/matlab/tests/testGpu.m index 62accb8..6b48c3c 100644 --- a/matlab/tests/testGpu.m +++ b/matlab/tests/testGpu.m @@ -30,6 +30,8 @@ %! if ~gpu_available_for_test(), disp('SKIP: no GPU'); return; end %! [phi, alpha] = meshgrid(linspace(0.01, pi/2, 40), linspace(0.01, pi/2, 40)); %! u = phi(:).'; m = sin(alpha(:).').^2; +%! u = [u, pi+0.3, 3*pi+0.7, -4*pi-0.2]; +%! m = [m, 0.2, 0.5, 0.8]; %! [F_s, E_s, Z_s] = elliptic12(u, m); %! elliptic_config('gpu', true); %! [F_g, E_g, Z_g] = elliptic12(u, m); @@ -43,8 +45,8 @@ %! clear %! elliptic_config('gpu', false); %! if ~gpu_available_for_test(), disp('SKIP: no GPU'); return; end -%! [phi, alpha, cv] = meshgrid(linspace(0, pi/2, 20), linspace(0, pi/2, 20), linspace(0, 0.9, 5)); -%! u = phi(:).'; m = sin(alpha(:).').^2; c = cv(:).'; +%! [phi, mv, cv] = meshgrid(linspace(0, 1.4, 20), linspace(0, 0.7, 20), linspace(0, 0.7, 5)); +%! u = phi(:).'; m = mv(:).'; c = cv(:).'; %! Pi_s = elliptic3(u, m, c); %! elliptic_config('gpu', true); %! Pi_g = elliptic3(u, m, c); @@ -58,6 +60,8 @@ %! if ~gpu_available_for_test(), disp('SKIP: no GPU'); return; end %! [phi, alpha] = meshgrid(linspace(0, 10, 40), linspace(0, pi/2, 40)); %! u = phi(:).'; m = sin(alpha(:).').^2; +%! u = [u, 1e3+0.123, 1e6+0.123]; +%! m = [m, 0.5, 0.9]; %! [Sn_s, Cn_s, Dn_s, Am_s] = ellipj(u, m); %! elliptic_config('gpu', true); %! [Sn_g, Cn_g, Dn_g, Am_g] = ellipj(u, m); diff --git a/matlab/tests/testGpuStrict.m b/matlab/tests/testGpuStrict.m new file mode 100644 index 0000000..590a25e --- /dev/null +++ b/matlab/tests/testGpuStrict.m @@ -0,0 +1,82 @@ +function testGpuStrict() +%TESTGPUSTRICT Every GPU code path under a strict device-array stub. +% See gpu_stub/README.md. Each function is evaluated on the CPU path and +% on the GPU path (with the stub) on the same inputs, including the +% large-u / m -> 1 / tiny-m cases that broke earlier GPU kernels, and the +% two must agree to 1e-13 relative (they are bit-identical for most). +end + +%!test +%! % locate the stub from the source directory: mfilename is empty inside test +%! % blocks when test() is called with a full path (CI), so relative paths fail +%! src = fileparts(which('elliptic12')); +%! here = fullfile(src, '..', 'tests'); +%! addpath(fullfile(here, 'gpu_stub'), '-begin'); +%! unwind_protect +%! rand('seed', 5); N = 300; +%! u = rand(1,N)*20 - 10; m = rand(1,N)*(1-2e-6) + 1e-6; n = rand(1,N)*0.9; z = rand(1,N)*3; +%! u = [u, 1e6+0.123, 9.375, pi/2-1e-9, -4.9, 0, 1000000.123]; m = [m, 1-eps/2, 1-eps/2, 1-eps/2, 1e-12, 0.5, 3e-16]; +%! n = [n 0.3 0.3 0.3 0.3 0.3 0.3]; z = [z 0.7 0.7 0.7 0.7 0.7 0.7]; +%! tests = { +%! 'elliptic12', @() nthargout(1:3, @elliptic12, u, m); +%! 'ellipj', @() nthargout(1:4, @ellipj, u, m); +%! 'elliptic3', @() elliptic3(u, m, n); +%! 'ellipticBDJ', @() nthargout(1:3, @ellipticBDJ, u, m, n); +%! 'ellipticBD', @() nthargout(1:3, @ellipticBD, m); +%! 'jacobiEDJ', @() nthargout(1:3, @jacobiEDJ, u, m, n); +%! 'theta', @() theta(1, u, m); +%! 'theta_prime', @() nthargout(1:2, @theta_prime, 2, u, m); +%! 'jacobiThetaEta', @() nthargout(1:2, @jacobiThetaEta, u, m); +%! 'nomeq', @() nomeq(m); +%! 'inversenomeq',@() inversenomeq(m*0.7); +%! 'elliptic12i', @() nthargout(1:3, @elliptic12i, u + 1i*z, m); +%! 'ellipji', @() nthargout(1:3, @ellipji, u + 1i*z, m); +%! 'weierstrassP',@() weierstrassP(z, 1.5, -0.25, -1.25); +%! 'weierstrassZeta', @() weierstrassZeta(z, 1.5, -0.25, -1.25); +%! 'weierstrassSigma', @() weierstrassSigma(z, 1.5, -0.25, -1.25); +%! 'weierstrassPPrime', @() weierstrassPPrime(z, 1.5, -0.25, -1.25); +%! 'inverselliptic2', @() inverselliptic2(z, m); +%! 'cel', @() cel(sqrt(1-m), n, 1, 0.5); +%! 'arclength_ellipse', @() arclength_ellipse(z+0.1, z+0.5, u, z); +%! 'elliptic12 NaN', @() nthargout(1:3, @elliptic12, [0.3 0.5 0.7 NaN], [0.2 NaN 0.4 0.5]); +%! 'ellipj NaN', @() nthargout(1:4, @ellipj, [0.3 0.5 0.7 NaN], [0.2 NaN 0.4 0.5]); +%! 'theta NaN', @() theta(1, [0.3 0.5], [0.2 NaN]); +%! 'elliptic3 NaN', @() elliptic3([0.3 0.5], [0.2 NaN], 0.3); +%! 'elliptic3 c<0 / near pole', @() elliptic3([1 1 1 4 1.5707 1.2 0.4], [0.5 0.5 0.5 0.9 1-1e-9 0.999999 0.3], [-0.5 -3 -100 -100 0.3 0.999999 0.5]); +%! }; +%! for t = 1:rows(tests) +%! name = tests{t,1}; f = tests{t,2}; +%! elliptic_config('gpu', false); ref = f(); +%! elliptic_config('gpu', true); got = f(); +%! elliptic_config('gpu', false); +%! if ~iscell(ref), ref = {ref}; got = {got}; end +%! for k = 1:numel(ref) +%! g = got{k}; +%! assert(~isa(g, 'gpuArray'), sprintf('%s: output %d is still a device array', name, k)); +%! r = ref{k}; +%! assert(isequal(isfinite(r), isfinite(g)), sprintf('%s: finite pattern differs on the GPU path', name)); +%! ok = isfinite(r); +%! d = max([0; abs(g(ok)(:) - r(ok)(:)) ./ max(1, abs(r(ok)(:)))]); +%! assert(d < 1e-13, sprintf('%s: GPU path differs from CPU by %.2e', name, d)); +%! end +%! end +%! unwind_protect_cleanup +%! elliptic_config('gpu', false); +%! rmpath(fullfile(here, 'gpu_stub')); +%! end_unwind_protect + +%!test +%! % the stub itself must reject what ocl rejects, or the test above proves nothing +%! here = fullfile(fileparts(which('elliptic12')), '..', 'tests'); +%! addpath(fullfile(here, 'gpu_stub'), '-begin'); +%! unwind_protect +%! caught = false; +%! try, x = gpuArray([1 2 3]) .* [1 2 3]; catch, caught = true; end +%! assert(caught, 'stub must reject device .* host matrix'); +%! caught = false; +%! try, g = gpuArray([1 2 3]); y = g(logical([1 0 1])); catch, caught = true; end +%! assert(caught, 'stub must reject logical indexing'); +%! assert(isequal(gather(gpuArray([1 2 3]) .* 2 + gpuArray([1 1 1])), [3 5 7])); +%! unwind_protect_cleanup +%! rmpath(fullfile(here, 'gpu_stub')); +%! end_unwind_protect diff --git a/matlab/tests/testParallel.m b/matlab/tests/testParallel.m index 740aa4d..e4ba554 100644 --- a/matlab/tests/testParallel.m +++ b/matlab/tests/testParallel.m @@ -76,3 +76,45 @@ %! [F2, E2] = elliptic12(u, m); %! assert(isequal(F1, F2), 'Serial results must be deterministic'); %! assert(isequal(E1, E2), 'Serial results must be deterministic'); + +% --------------------------------------------------------------------- +% Chunking path exercised WITHOUT the parallel package: a temporary dir +% shadows get_nworkers (3 workers) and provides a serial parcellfun with +% the same calling convention. Covers N below, at and above exact +% multiples of chunk_size -- the exact-multiple case re-entered the +% dispatch from inside par_worker without bound and crashed Octave. +% --------------------------------------------------------------------- +%!test +%! d = tempname(); mkdir(d); +%! fid = fopen(fullfile(d, 'get_nworkers.m'), 'w'); +%! fprintf(fid, 'function n = get_nworkers()\nif ~elliptic_config(''parallel''), n = 0; else n = 3; end\n'); fclose(fid); +%! fid = fopen(fullfile(d, 'parcellfun.m'), 'w'); +%! fprintf(fid, ['function varargout = parcellfun(nproc, fn, varargin)\n' ... +%! 'uo = true; args = varargin; k = find(strcmp(args, ''UniformOutput''), 1);\n' ... +%! 'if ~isempty(k), uo = args{k+1}; args(k:k+1) = []; end\n' ... +%! 'n = numel(args{1}); out = cell(1, n);\n' ... +%! 'for i = 1:n, a = cell(1, numel(args)); for j = 1:numel(args), a{j} = args{j}{i}; end; out{i} = fn(a{:}); end\n' ... +%! 'if uo, varargout{1} = [out{:}]; else varargout{1} = out; end\n']); fclose(fid); +%! addpath(d); +%! old_par = elliptic_config('parallel'); old_cs = elliptic_config('chunk_size'); +%! unwind_protect +%! cs = 500; +%! for N = [cs-1, cs, 2*cs, 3*cs, 3*cs+7] +%! rand('seed', N); u = rand(1,N)*40-20; m = rand(1,N)*0.98+0.01; c = rand(1,N)*0.9; +%! elliptic_config('parallel', false); +%! [F1,E1] = elliptic12(u,m); s1 = ellipj(u,m); P1 = elliptic3(u,m,c); [B1,D1,J1] = ellipticBDJ(u,m,c); +%! elliptic_config('parallel', true); elliptic_config('chunk_size', cs); +%! [F2,E2] = elliptic12(u,m); s2 = ellipj(u,m); P2 = elliptic3(u,m,c); [B2,D2,J2] = ellipticBDJ(u,m,c); +%! assert(isequal(size(F2), size(F1)) && max(abs(F2-F1)) == 0 && max(abs(E2-E1)) == 0, 'elliptic12 chunked != serial at N=%d', N); +%! assert(max(abs(s2-s1)) == 0, 'ellipj chunked != serial at N=%d', N); +%! assert(max(abs(P2-P1)) == 0, 'elliptic3 chunked != serial at N=%d', N); +%! assert(max(abs(J2-J1)) == 0 && max(abs(B2-B1)) == 0, 'ellipticBDJ chunked != serial at N=%d', N); +%! end +%! [ph, al] = meshgrid(linspace(0.1, 3, 40), linspace(0.05, 0.9, 30)); % matrix through the chunked path +%! Fm = elliptic12(ph, al); elliptic_config('parallel', false); Fs = elliptic12(ph, al); +%! assert(isequal(size(Fm), [30 40]) && max(abs(Fm(:)-Fs(:))) == 0, 'matrix shape/values through chunking'); +%! assert(elliptic_config('parallel') == false, 'par_worker must restore the parallel flag'); +%! unwind_protect_cleanup +%! elliptic_config('parallel', old_par); elliptic_config('chunk_size', old_cs); +%! rmpath(d); confirm_recursive_rmdir(false, 'local'); rmdir(d, 's'); +%! end_unwind_protect diff --git a/matlab/tests/testRegressionFollowup.m b/matlab/tests/testRegressionFollowup.m new file mode 100644 index 0000000..b150583 --- /dev/null +++ b/matlab/tests/testRegressionFollowup.m @@ -0,0 +1,57 @@ +% Regression coverage from the post-0d09740 deep audit. + +%!test +%! % m=1: period reduction must not hide the first F pole or under-count E. +%! phi = [2, pi, 4, 10]; +%! [F, E, Z] = elliptic12(phi, ones(size(phi))); +%! turns = floor((abs(phi) + pi/2) ./ pi); +%! expectedE = (-1).^turns .* sin(abs(phi)) + 2.*turns; +%! assert(all(isinf(F) & F > 0), 'F(phi|1) must diverge after crossing pi/2'); +%! assert(max(abs(E-expectedE)) < 1e-14, 'E(phi|1) period accounting failed'); +%! [Fn, En, Zn] = elliptic12(-phi, ones(size(phi))); +%! assert(all(isinf(Fn) & Fn < 0), 'negative F(phi|1) pole sign failed'); +%! assert(max(abs(En+E)) < 1e-14 && max(abs(Zn+Z)) < 1e-14, 'm=1 parity failed'); + +%!test +%! % B, D and especially S retain their analytic limits for tiny m. +%! m = [0, 1e-20, 1e-16, 1e-12, 1e-8]; +%! [B, D, S] = ellipticBD(m); +%! assert(max(abs(B-pi/4)) < 2e-8, 'B lost its m->0 limit'); +%! assert(max(abs(D-pi/4)) < 2e-8, 'D lost its m->0 limit'); +%! assert(max(abs(S-pi/16)) < 2e-8, 'S suffered small-m cancellation'); + +%!test +%! % Near-pole third-kind value anchored to scipy Carlson RF/RJ. +%! got = elliptic3(pi/2, 0.9, 0.999); +%! expected = 149.26048203240563; +%! assert(abs(got-expected) < 2e-12, ... +%! 'elliptic3 near-pole error: got %.17g expected %.17g', got, expected); + +%!test +%! % Large u and the closest representable m<1: mpmath 50-digit anchor. +%! u = 1000000.123; +%! m = 1 - eps/2; +%! [sn, cn, dn] = ellipj(u, m); +%! assert(abs(sn-0.9999999999999987) < 2e-14, 'large-u sn lost phase'); +%! assert(abs(cn-5.0691640447381745e-08) < 2e-14, 'large-u cn lost phase'); +%! assert(abs(dn-5.177513605688685e-08) < 2e-14, 'large-u dn cancellation'); + +%!test +%! % Distinct m values must never be tolerance-grouped into one data point. +%! u = 1.56; +%! m = [0.999, 0.999+5e-12, 0.5, 0.5+5e-12]; +%! [Fv, Ev] = elliptic12(u*ones(size(m)), m); +%! for k = 1:numel(m) +%! [Fs, Es] = elliptic12(u, m(k)); +%! assert(abs(Fv(k)-Fs) < 2e-13 && abs(Ev(k)-Es) < 2e-13, ... +%! 'array evaluation substituted m at index %d', k); +%! end + +%!test +%! % elliptic123 must route through the repaired public implementation. +%! phi = [0.4, 1.2, 2.0, 4.0]; +%! m = 0.4*ones(size(phi)); +%! [F123, E123] = elliptic123(phi, m); +%! [F, E] = elliptic12(phi, m); +%! assert(max(abs(F123-F)) < 2e-12 && max(abs(E123-E)) < 2e-12, ... +%! 'elliptic123 retained a stale private elliptic12 implementation'); diff --git a/python/elliptic/_agm.py b/python/elliptic/_agm.py index c8265fd..b855014 100644 --- a/python/elliptic/_agm.py +++ b/python/elliptic/_agm.py @@ -21,7 +21,7 @@ def agm_coeffs(m, xp): iters = _AGM_ITERS # Allocate: we'll build columns one by one (works for numpy/torch/jax) - a = [xp.ones(N, dtype=xp.float64)] + a = [xp.ones_like(m)] # device-preserving (xp.ones(N) lands on the CPU in torch) b = [xp.sqrt(1.0 - m)] c = [xp.sqrt(m)] diff --git a/python/elliptic/_xputils.py b/python/elliptic/_xputils.py index e852fdc..13127e2 100644 --- a/python/elliptic/_xputils.py +++ b/python/elliptic/_xputils.py @@ -5,6 +5,47 @@ import numpy as np +# Three-term split of pi: _PI_A and _PI_B carry 25 significant bits each, so +# k*_PI_A and k*_PI_B are exact in double for |k| < 2**28 (|u| < 8e8); the +# remaining k*_PI_C rounds at eps*|k|*1.6e-8, far below eps*|r|. Using +# float(pi) as the leading term ("Cody-Waite" with a 53-bit head) does not +# help: k*float(pi) already rounds by eps*|u| (2.3e-10 at u = 1e6), which +# Jacobi Zeta and E inherit. Residual pi - A - B - C = 1.3e-24. +_PI_A = 3.1415926218032837 # 0x1.921fb5p+1 +_PI_B = 1.5893254712295857e-08 # 0x1.110b46p-26 +_PI_C = 1.5893254834760535e-08 + + +def sub_kpi(u, k): + """u - k*pi for integer-valued k, accurate to eps*|result| (any backend).""" + return ((u - k * _PI_A) - k * _PI_B) - k * _PI_C + + +def is_numpy(xp): + """True for the eager numpy namespace, whichever module object represents + it: ``numpy`` itself, or ``array_api_compat.numpy`` (what + array_namespace() returns for numpy arrays and numpy scalars). Every + ``xp is np`` test in the package used to miss the latter, so eager domain + checks were silently skipped for ndarray inputs.""" + return xp is np or getattr(xp, "__name__", "") in ("numpy", "array_api_compat.numpy") + + +def check_range(xp, x, lo, hi, what): + """Domain check that is honest on every backend. + + numpy (eager): raise ValueError for any value outside [lo, hi] -- NaN is + let through and propagates. Traced/device backends (JAX, torch): return + a validity mask so callers can emit NaN instead of a silent placeholder + value (ellipj(0.3, 1.5) used to return sn(0.3 | 0.5)). + """ + valid = ~((x < lo) | (x > hi)) + if is_numpy(xp): + bad = ~valid & ~np.isnan(x) + if np.any(bad): + raise ValueError(f"{what} must be in [{lo}, {hi}]") + return valid + + def get_xp(*args): """Return the array namespace for *args*, defaulting to numpy for plain scalars.""" api_objs = [a for a in args if is_array_api_obj(a)] diff --git a/python/elliptic/applications.py b/python/elliptic/applications.py index 54955a6..7aefbd5 100644 --- a/python/elliptic/applications.py +++ b/python/elliptic/applications.py @@ -1,7 +1,8 @@ """Application-level helpers built on top of the core elliptic functions.""" from __future__ import annotations import numpy as np -from array_api_compat import array_namespace + +from ._xputils import get_xp, is_numpy from .elliptic12 import elliptic12 @@ -31,7 +32,7 @@ def arclength_ellipse(a, b, theta0=0.0, theta1=None): -------- Full perimeter of ellipse with a=5, b=10 (matches Mathematica): - >>> arclength_ellipse(5, 10) # doctest: +ELLIPSIS + >>> float(arclength_ellipse(5, 10)) # doctest: +ELLIPSIS 48.4422... Notes @@ -43,19 +44,40 @@ def arclength_ellipse(a, b, theta0=0.0, theta1=None): if theta1 is None: theta1 = 2.0 * np.pi - a = float(a); b = float(b) - theta0 = float(theta0); theta1 = float(theta1) - - if a == b: - return a * abs(theta1 - theta0) - - if b > a: - m = 1.0 - (a / b) ** 2 - _, E1, _ = elliptic12(np.asarray(theta1), np.asarray(m)) - _, E0, _ = elliptic12(np.asarray(theta0), np.asarray(m)) - return float(b * (float(E1) - float(E0))) - else: # a > b - m = 1.0 - (b / a) ** 2 - _, E1, _ = elliptic12(np.asarray(np.pi / 2.0 - theta1), np.asarray(m)) - _, E0, _ = elliptic12(np.asarray(np.pi / 2.0 - theta0), np.asarray(m)) - return float(a * (float(E0) - float(E1))) + xp = get_xp(a, b, theta0, theta1) + a = xp.asarray(a, dtype=xp.float64) + b = xp.asarray(b, dtype=xp.float64) + theta0 = xp.asarray(theta0, dtype=xp.float64) + theta1 = xp.asarray(theta1, dtype=xp.float64) + a, b, theta0, theta1 = xp.broadcast_arrays(a, b, theta0, theta1) + + # Give ordinary NumPy callers an explicit domain error. Traced backends + # cannot branch on array values, so invalid elements are marked NaN below. + if is_numpy(xp) and (np.any(a <= 0.0) or np.any(b <= 0.0)): + raise ValueError("ellipse semi-axes must be strictly positive") + + valid = (a > 0.0) & (b > 0.0) + a_safe = xp.where(valid, a, xp.ones_like(a)) + b_safe = xp.where(valid, b, xp.ones_like(b)) + + # Evaluate both orientations elementwise. The old scalar ``float`` casts + # rejected arrays and JAX tracers even though the rest of the public API is + # backend-native. + m_b = 1.0 - (a_safe / b_safe) ** 2 + m_a = 1.0 - (b_safe / a_safe) ** 2 + + _, E1_b, _ = elliptic12(theta1, xp.where(b > a, m_b, xp.zeros_like(m_b))) + _, E0_b, _ = elliptic12(theta0, xp.where(b > a, m_b, xp.zeros_like(m_b))) + + comp1 = np.pi / 2.0 - theta1 + comp0 = np.pi / 2.0 - theta0 + _, E1_a, _ = elliptic12(comp1, xp.where(a > b, m_a, xp.zeros_like(m_a))) + _, E0_a, _ = elliptic12(comp0, xp.where(a > b, m_a, xp.zeros_like(m_a))) + + arc_b = b_safe * (E1_b - E0_b) + arc_a = a_safe * (E0_a - E1_a) + # Signed, like the ellipse branches: reversed intervals negate + # (the old abs() here made circles disagree with every non-circle). + arc_circle = a_safe * (theta1 - theta0) + arc = xp.where(b > a, arc_b, xp.where(a > b, arc_a, arc_circle)) + return xp.where(valid, arc, xp.full_like(arc, np.nan)) diff --git a/python/elliptic/bulirsch.py b/python/elliptic/bulirsch.py index a86026c..df50001 100644 --- a/python/elliptic/bulirsch.py +++ b/python/elliptic/bulirsch.py @@ -15,9 +15,6 @@ import numpy as np from ._xputils import get_xp -from .ellipticBD import _bd_xp -from .elliptic12 import _elliptic12_xp -from .carlson import _rj_xp def cel(kc, p, a, b): @@ -32,27 +29,57 @@ def cel(kc, p, a, b): def _cel_xp(xp, kc, p, a, b): - m = 1.0 - kc * kc - phi = xp.full_like(m, math.pi * 0.5) - K, _, _ = _elliptic12_xp(xp, phi, m) - B, D, _ = _bd_xp(xp, m) - - # p ≈ 1 branch: C = a*B + b*D - C_p1 = a * B + b * D - - # p ≠ 1 branch: C = a*K + (b - a*p)*(Pi - K)/(1-p) - n_val = 1.0 - p - mc = 1.0 - m - n_safe = xp.where(xp.abs(n_val) < 1e-14, xp.ones_like(n_val), n_val) - RJ = _rj_xp(xp, xp.zeros_like(m), mc, xp.ones_like(m), p) - J_n = RJ / 3.0 - Pi_n = K + n_val * J_n - C_pn = a * K + (b - a * p) * (Pi_n - K) / n_safe - - C = xp.where(xp.abs(p - 1.0) < 1e-12, C_p1, C_pn) - C = xp.where(kc < 0.0, xp.full_like(C, math.nan), C) - C = xp.where(p <= 0.0, xp.full_like(C, math.inf), C) - return C + """Bulirsch's algorithm (Numer. Math. 13 (1969) 305), backend-native. + + Works directly with kc: the previous route through m = 1 - kc**2 lost kc + entirely below ~1e-8 (cel1(1e-9) returned 2e6; ln(4/kc) = 22.1). Any + real kc (kc > 1 is m < 0); p < 0 is the Cauchy principal value; p = 0 + gives inf. Quadratic Landen ascent run for a fixed number of steps with + converged elements frozen (no data-dependent branching). + """ + CA = 1e-9 + k = xp.abs(kc) + zero_kc = k == 0.0 + # kc = 0: divergent unless b = 0, where the kc -> 0 limit is finite and + # the ascent evaluates it from the smallest normal number. + k = xp.where(zero_kc, xp.full_like(k, 2.2250738585072014e-308), k) + e = k + em = xp.ones_like(k) + pos = p > 0.0 + p_safe = xp.where(pos, p, xp.ones_like(p)) + # p > 0 + sp = xp.sqrt(p_safe) + # p <= 0: transform to the p > 0 case (principal value) + g0 = 1.0 - p + g0_safe = xp.where(pos, xp.ones_like(g0), g0) + f0 = k * k - p + q0 = (1.0 - k * k) * (b - a * p) + pn = xp.sqrt(xp.where(pos, xp.ones_like(f0), f0 / g0_safe)) + an = (a - b) / g0_safe + bn = -q0 / (g0_safe * g0_safe * pn) + an * pn + p = xp.where(pos, sp, pn) + b = xp.where(pos, b / sp, bn) + a = xp.where(pos, a, an) + active = k == k # all-True boolean of the right backend/shape + for _ in range(40): + f = a + a = xp.where(active, a + b / p, a) + g = e / p + b = xp.where(active, 2.0 * (b + f * g), b) + p = xp.where(active, p + g, p) + g = em + em = xp.where(active, em + k, em) + conv = xp.abs(g - k) <= g * CA + step = active & ~conv + kk = 2.0 * xp.sqrt(e) + e = xp.where(step, kk * em, e) + k = xp.where(step, kk, k) + active = step + C = math.pi / 2.0 * (b + a * em) / (em * (em + p)) + C = xp.where(zero_kc & (b != 0.0), xp.sign(b / xp.where(p == 0, xp.ones_like(p), p)) * math.inf, C) + # kc = NaN never became active above (NaN == NaN is False) and returned + # the untouched pi/2; propagate it like every other input + return xp.where(k != k, xp.full_like(C, math.nan), C) def cel1(kc): diff --git a/python/elliptic/carlson.py b/python/elliptic/carlson.py index b702364..fd237c7 100644 --- a/python/elliptic/carlson.py +++ b/python/elliptic/carlson.py @@ -1,7 +1,7 @@ """Carlson symmetric elliptic integrals RF, RD, RJ, RC. All use Carlson's duplication algorithm with fixed iteration counts -(20 for RF, 30 for RD/RJ) so they are JAX-traceable and run natively on +(20 for RF, 30 for RD, 100 for RJ) so they are JAX-traceable and run natively on any array backend (NumPy, PyTorch CUDA, JAX). References @@ -14,7 +14,7 @@ import math import numpy as np -from ._xputils import get_xp +from ._xputils import get_xp, is_numpy # --------------------------------------------------------------------------- @@ -31,24 +31,39 @@ def carlsonRC(x, y): def _rc_xp(xp, x, y): - EPS = 1e-300 diff = y - x + # Branch selection must be RELATIVE: R_C is homogeneous of degree -1/2 + # (DLMF 19.20.3), and an absolute |y-x| < 1e-14 window sent every + # small-scale input down the degenerate x==y branch -- RC(1e-20, 2e-20) + # returned 1/sqrt(x), a 27% error. + scale = xp.maximum(xp.abs(x), xp.abs(y)) + tol = 1e-14 * scale # safe arguments for each branch (avoid div-by-zero when not selected) - x_safe = xp.where(x > EPS, x, xp.full_like(x, 1.0)) - yd_safe = xp.where(diff > EPS, diff, xp.full_like(diff, 1.0)) - yd_safe2 = xp.where(-diff > EPS, -diff, xp.full_like(diff, 1.0)) - y_safe = xp.where(y > EPS, y, xp.full_like(y, 1.0)) + x_safe = xp.where(x > 0, x, xp.full_like(x, 1.0)) + yd_safe = xp.where(diff > 0, diff, xp.full_like(diff, 1.0)) + yd_safe2 = xp.where(-diff > 0, -diff, xp.full_like(diff, 1.0)) + y_safe = xp.where(y > 0, y, xp.full_like(y, 1.0)) rc_gt = xp.arctan(xp.sqrt(xp.clip(diff / x_safe, 0.0, None))) / xp.sqrt(yd_safe) - rc_lt = xp.arctanh(xp.sqrt(xp.clip(-diff / x_safe, 0.0, None))) / xp.sqrt(yd_safe2) + lt_active = (diff < -tol) & (y > 0) & (x > 0) + lt_ratio = xp.where(lt_active, -diff / x_safe, xp.full_like(diff, 0.5)) + # x > y: log((sqrt(x)+sqrt(x-y))/sqrt(y))/sqrt(x-y) -- algebraically + # arctanh(sqrt(1-y/x))/sqrt(x-y), but without the 1 - sqrt(1-eps) + # cancellation that lost 8 digits at RC(3, 1e-10). + lt_y = xp.where(lt_active, y, xp.ones_like(y)) + lt_x = xp.where(lt_active, x, xp.full_like(x, 2.0)) + # ... and as log1p, since log((sx+sxy)/sy) = log1p(((x-y)/(sx+sy) + sxy)/sy): + # the plain log lost 9 digits again when x - y was tiny (RC(1+1e-13, 1)). + sx, sy = xp.sqrt(lt_x), xp.sqrt(lt_y) + sxy = xp.sqrt(xp.clip(lt_x - lt_y, 0.0, None)) + rc_lt = xp.log1p(((lt_x - lt_y) / (sx + sy) + sxy) / sy) / xp.sqrt(yd_safe2) rc_eq = 1.0 / xp.sqrt(x_safe) rc_x0 = (math.pi * 0.5) / xp.sqrt(y_safe) - TOL = 1e-14 - out = xp.where(diff > TOL, rc_gt, xp.where(diff < -TOL, rc_lt, rc_eq)) - out = xp.where(x < EPS, rc_x0, out) - out = xp.where(y < EPS, xp.full_like(out, math.inf), out) + out = xp.where(diff > tol, rc_gt, xp.where(diff < -tol, rc_lt, rc_eq)) + out = xp.where(x == 0, rc_x0, out) + out = xp.where(y == 0, xp.full_like(out, math.inf), out) return out @@ -71,7 +86,13 @@ def carlsonRF(x, y, z): y = xp.asarray(y, dtype=xp.float64) z = xp.asarray(z, dtype=xp.float64) x, y, z = xp.broadcast_arrays(x, y, z) - return _rf_xp(xp, x, y, z) + out = _rf_xp(xp, x, y, z) + # Two zero arguments: the integral diverges (DLMF 19.16.1); the fixed + # duplication count otherwise returns a finite number. + # pure boolean algebra: torch tensors have no .astype, and this must + # stay backend-native (caught on the L4 hardware run) + two0 = ((x == 0) & (y == 0)) | ((x == 0) & (z == 0)) | ((y == 0) & (z == 0)) + return xp.where(two0, xp.full_like(out, math.inf), out) def _rf_xp(xp, x, y, z): @@ -107,7 +128,8 @@ def carlsonRD(x, y, z): y = xp.asarray(y, dtype=xp.float64) z = xp.asarray(z, dtype=xp.float64) x, y, z = xp.broadcast_arrays(x, y, z) - return _rd_xp(xp, x, y, z) + out = _rd_xp(xp, x, y, z) + return xp.where((x == 0) & (y == 0), xp.full_like(out, math.inf), out) # DLMF 19.16.5 def _rd_xp(xp, x, y, z): @@ -156,20 +178,26 @@ def carlsonRJ(x, y, z, p): p = xp.asarray(p, dtype=xp.float64) x, y, z, p = xp.broadcast_arrays(x, y, z, p) - import numpy as _np - if _np.any(_np.asarray(p) <= 0.0): + if is_numpy(xp) and np.any(p <= 0.0): raise ValueError( "carlsonRJ: p must be > 0. For p < 0 the integral is a Cauchy " "principal value (DLMF 19.20.14); use the transformation to " "a q > 0 argument before calling." ) - return _rj_xp(xp, x, y, z, p) + out = _rj_xp(xp, x, y, z, p) + # pure boolean algebra: torch tensors have no .astype, and this must + # stay backend-native (caught on the L4 hardware run) + two0 = ((x == 0) & (y == 0)) | ((x == 0) & (z == 0)) | ((y == 0) & (z == 0)) + return xp.where(two0, xp.full_like(out, math.inf), out) # DLMF 19.16.2 def _rj_xp(xp, x, y, z, p): S = xp.zeros_like(x) fac = xp.ones_like(x) - for _ in range(30): + # 100 duplications: each divides the argument-ratio exponent (base 4) by + # one, so the series is valid for max/min argument ratios up to ~4^94 = 4e56. + # 30 covered only ~1e16 -- RJ(1e-20, 2e-20, 3e-20, 0.5) was 11% off. + for _ in range(100): lam = xp.sqrt(x * y) + xp.sqrt(y * z) + xp.sqrt(z * x) alpha = (p * (xp.sqrt(x) + xp.sqrt(y) + xp.sqrt(z)) + xp.sqrt(x * y * z)) ** 2 beta = p * (p + lam) ** 2 @@ -185,7 +213,7 @@ def _rj_xp(xp, x, y, z, p): Z = (A - z) / A P = -(X + Y + Z) / 2.0 E2 = X*Y + X*Z + Y*Z - 3.0*P**2 - E3 = X*Y*Z + 2.0*E2*P + 3.0*P**3 + E3 = X*Y*Z + 2.0*E2*P + 4.0*P**3 # DLMF 19.36.2 (3P^3 was wrong; masked here by the fixed 100 duplications) E4 = (2.0*X*Y*Z + E2*P + 3.0*P**3) * P E5 = X*Y*Z * P**2 poly = (1.0 - 3.0*E2/14.0 + E3/6.0 + 9.0*E2**2/88.0 diff --git a/python/elliptic/complex_elliptic.py b/python/elliptic/complex_elliptic.py index da6730c..60db7db 100644 --- a/python/elliptic/complex_elliptic.py +++ b/python/elliptic/complex_elliptic.py @@ -8,10 +8,11 @@ """ from __future__ import annotations import numpy as np -from array_api_compat import array_namespace -from .elliptic12 import _elliptic12_numpy -from .ellipj import _ellipj_numpy +from ._xputils import get_xp, is_numpy +from .carlson import _rf_xp, _rd_xp +from .elliptic12 import _elliptic12_xp +from .ellipj import _ellipj_xp def elliptic12i(u, m): @@ -28,76 +29,114 @@ def elliptic12i(u, m): ------- Fi, Ei, Zi : complex arrays """ - u = np.asarray(u, dtype=np.complex128) - m = np.asarray(m, dtype=np.float64) - xp = array_namespace(np.real(u), m) + xp = get_xp(u, m) + u = xp.asarray(u, dtype=xp.complex128) + m = xp.asarray(m, dtype=xp.float64) + u_f, m_f = xp.broadcast_arrays(u, m) - u_bc, m_bc = np.broadcast_arrays(u, m) - orig_shape = u_bc.shape - u_f = u_bc.ravel() - m_f = m_bc.ravel().astype(np.float64) - - if np.any(m_f < 0) or np.any(m_f > 1): + if is_numpy(xp) and np.any((m_f < 0.0) | (m_f > 1.0)): raise ValueError("m must be in [0, 1]") - phi = np.real(u_f) - psi = np.imag(u_f) + phi = xp.real(u_f) + psi = xp.imag(u_f) # Avoid cot(phi) singularity at phi = 0 - phi_s = np.where(np.abs(phi) < np.finfo(float).eps, np.finfo(float).eps, phi) + eps = np.finfo(np.float64).eps + phi_s = xp.where(xp.abs(phi) < eps, xp.full_like(phi, eps), phi) # Roots of X² - b*X - c = 0 (A&S 17.4.11) - cot2 = (np.cos(phi_s) / np.sin(phi_s))**2 - sinh2 = np.sinh(psi)**2 - csc2 = 1.0 / np.sin(phi_s)**2 + cot2 = (xp.cos(phi_s) / xp.sin(phi_s))**2 + sinh2 = xp.sinh(psi)**2 + csc2 = 1.0 / xp.sin(phi_s)**2 b = -(cot2 + m_f * sinh2 * csc2 - 1.0 + m_f) c = -(1.0 - m_f) * cot2 - disc = np.sqrt(np.maximum(b**2 / 4.0 - c, 0.0)) - - # c <= 0, so the roots straddle zero and -b/2 + disc is the non-negative - # one. Near phi = pi/2 that form cancels catastrophically (both terms - # ~ |b|/2 while the root ~ 0), so use the equal -c/(b/2 + disc) when b > 0. - den = np.where(b > 0, b / 2.0 + disc, 1.0) # b > 0 => den >= b/2 > 0 - X = np.where(b > 0, -c / den, -b / 2.0 + disc) - ratio = np.where(b > 0, (1.0 - m_f) / den, # == tan(phi)² · cot(lam)² - (-b / 2.0 + disc) / cot2) - - lam = np.arctan(1.0 / np.sqrt(np.maximum(X, 0.0) + 1e-300)) - # tan(mu)² = (tan(phi)²·cot(lam)² - 1)/m, taken from *ratio* rather than - # from lam: at phi = pi/2 the root X underflows, lam rounds to exactly - # pi/2 and cot(lam) loses every digit of it, collapsing Im to zero. - mu = np.arctan(np.sqrt(np.maximum((ratio - 1.0) / m_f, 0.0))) - - # Account for periodicity - lam = (-1.0)**np.floor(phi / np.pi * 2) * lam + np.pi * np.ceil(phi / np.pi - 0.5 + 1e-14) - mu = np.sign(psi) * np.real(mu) - - F1, E1, _ = _elliptic12_numpy(lam, m_f, np.finfo(np.float64).eps) - F2, E2, _ = _elliptic12_numpy(mu, 1.0 - m_f, np.finfo(np.float64).eps) + # Positive root X1 = cot^2(lambda) of X^2 + bX + c = 0 and tan^2(mu), + # both without cancellation. Writing X1 = cot^2(phi) + Y, Y solves + # Y^2 + B'Y - C' = 0, B' = cot^2 + (1-m) - m sinh^2 csc^2, + # C' = cot^2 * m sinh^2 csc^2 >= 0, + # and A&S 17.4.11's tan^2(mu) = (tan^2 phi cot^2 lambda - 1)/m = Y/(m cot^2) + # collapses to + # tan^2(mu) = 2 sinh^2 csc^2 / (B' + sqrt(B'^2 + 4C')) (B' >= 0) + # = (|B'| + sqrt(B'^2 + 4C')) / (2 m cot^2) (B' < 0) + # -- m cancels analytically in the first form, so m -> 0 (and m = 0 + # exactly) is handled to full precision. The old (ratio - 1)/m lost + # sqrt(eps/m) digits and returned Im F = 0 for psi = 1e-9. + s2c2 = sinh2 * csc2 + Bp = cot2 + (1.0 - m_f) - m_f * s2c2 + Cp = cot2 * m_f * s2c2 + root = xp.sqrt(Bp * Bp + 4.0 * Cp) + pos = Bp >= 0.0 + Y = xp.where(pos, 2.0 * Cp / xp.where(pos, Bp + root, xp.ones_like(Bp)), + 0.5 * (-Bp + root)) + X = cot2 + Y + m_cot = xp.where(pos, xp.ones_like(cot2), m_f * cot2) + tan2mu = xp.where(pos, + 2.0 * s2c2 / xp.where(pos, Bp + root, xp.ones_like(Bp)), + 0.5 * (-Bp + root) / xp.where(pos, xp.ones_like(m_cot), m_cot)) + + lam = xp.arctan(1.0 / xp.sqrt(X + 1e-300)) + mu = xp.arctan(xp.sqrt(tan2mu)) + + # Periodicity: with k = floor(2 phi/pi) the quadrant sign is (-1)^k and + # the period term is pi*ceil(k/2), derived from the SAME k. The previous + # pi*ceil(phi/pi - 0.5 + 1e-14) counted the period from a separately + # rounded quantity, and for phi within 3e-14 below pi/2 it added a period + # the sign term had not crossed: Re F came out 3K instead of K. + kq = xp.floor(phi / np.pi * 2.0) + lam = (-1.0) ** kq * lam + np.pi * xp.ceil(kq / 2.0) + mu = xp.sign(psi) * xp.real(mu) + + F1, E1, _ = _elliptic12_xp(xp, lam, m_f) + F2, E2, _ = _elliptic12_xp(xp, mu, 1.0 - m_f) Fi = F1 + 1j * F2 # E addition formula (A&S 17.4.16) - sl = np.sin(lam); cl = np.cos(lam) - sm = np.sin(mu); cm = np.cos(mu) + sl = xp.sin(lam); cl = xp.cos(lam) + sm = xp.sin(mu); cm = xp.cos(mu) d2l = 1.0 - m_f * sl**2 d2m = 1.0 - (1.0 - m_f) * sm**2 den = cm**2 + m_f * sl**2 * sm**2 - b1 = m_f * sl * cl * sm**2 * np.sqrt(d2l) - b2 = sm * cm * d2l * np.sqrt(d2m) - Ei = (b1 + 1j * b2) / den + E1 + 1j * (-E2 + F2) + b1 = m_f * sl * cl * sm**2 * xp.sqrt(d2l) + b2 = sm * cm * d2l * xp.sqrt(d2m) + # den = 0 only where the small-m series (below) replaces the result; keep + # the division silent instead of raising a RuntimeWarning on 0/0 + den_safe = xp.where(den == 0.0, xp.ones_like(den), den) + Ei = (b1 + 1j * b2) / den_safe + E1 + 1j * (-E2 + F2) # Z = E - (E_complete / K) * F - from scipy.special import ellipk, ellipe as _ellipe - K_m = ellipk(m_f) - E_m = _ellipe(m_f) + # Complete integrals from the exact Carlson forms, not F(double(pi/2)|m): + # cos(double(pi/2)) = 6e-17 is not 0 and K was 5.8e-9 relative short at + # m = 1 - eps/2, which Z inherited (1.8e-11). E = RF - (m/3) RD (DLMF 19.25.1). + zed = xp.zeros_like(m_f); one = xp.ones_like(m_f) + K_m = _rf_xp(xp, zed, 1.0 - m_f, one) + E_m = K_m - m_f / 3.0 * _rd_xp(xp, zed, 1.0 - m_f, one) Zi = Ei - (E_m / K_m) * Fi - Fi = Fi.reshape(orig_shape) - Ei = Ei.reshape(orig_shape) - Zi = Zi.reshape(orig_shape) - return xp.asarray(Fi), xp.asarray(Ei), xp.asarray(Zi) + # Small-m Maclaurin series (through m^2). The A&S 17.4.11 decomposition + # loses ~sqrt(eps/m) digits as m -> 0 (0.2 absolute at m = 1e-16); the + # series is exact there and covers m = 0 itself: + # F = u + m(u/4 - sin2u/8) + m^2(9u/64 - 3sin2u/32 + 3sin4u/256) + O(m^3) + # E = u - m(u/4 - sin2u/8) - m^2(3u/64 - sin2u/32 + sin4u/256) + O(m^3) + # Valid while |m sin^2 u| is small: switch on m*max(1, e^(2|psi|)) < 1e-4, + # where the crossover error is ~2e-12 (measured against 40-digit mpmath). + m_eff = m_f * xp.maximum(xp.ones_like(m_f), xp.exp(2.0 * xp.abs(psi))) + small = m_eff < 1e-4 + s2 = xp.sin(2.0 * u_f) + s4 = xp.sin(4.0 * u_f) + F_ser = (u_f + m_f * (u_f / 4.0 - s2 / 8.0) + + m_f**2 * (9.0 * u_f / 64.0 - 3.0 * s2 / 32.0 + 3.0 * s4 / 256.0)) + E_ser = (u_f - m_f * (u_f / 4.0 - s2 / 8.0) + - m_f**2 * (3.0 * u_f / 64.0 - s2 / 32.0 + s4 / 256.0)) + Z_ser = E_ser - (E_m / K_m) * F_ser + Fi = xp.where(small, F_ser, Fi) + Ei = xp.where(small, E_ser, Ei) + Zi = xp.where(small, Z_ser, Zi) + # u == 0 exactly (incl. -0.0): the cot(phi) nudge above would return eps + z0 = u_f == 0 + Fi = xp.where(z0, u_f, Fi); Ei = xp.where(z0, u_f, Ei); Zi = xp.where(z0, xp.zeros_like(Zi), Zi) + return Fi, Ei, Zi def ellipji(u, m): @@ -117,23 +156,19 @@ def ellipji(u, m): ------- sn, cn, dn : complex arrays """ - u = np.asarray(u, dtype=np.complex128) - m = np.asarray(m, dtype=np.float64) - xp = array_namespace(np.real(u), m) - - u_bc, m_bc = np.broadcast_arrays(u, m) - orig_shape = u_bc.shape - u_f = u_bc.ravel() - m_f = m_bc.ravel().astype(np.float64) + xp = get_xp(u, m) + u_f = xp.asarray(u, dtype=xp.complex128) + m_f = xp.asarray(m, dtype=xp.float64) + u_f, m_f = xp.broadcast_arrays(u_f, m_f) - if np.any(m_f < 0) or np.any(m_f > 1): + if is_numpy(xp) and np.any((m_f < 0.0) | (m_f > 1.0)): raise ValueError("m must be in [0, 1]") - phi = np.real(u_f) - psi = np.imag(u_f) + phi = xp.real(u_f) + psi = xp.imag(u_f) - s, c, d, _ = _ellipj_numpy(phi, m_f) - s1, c1, d1, _ = _ellipj_numpy(psi, 1.0 - m_f) + s, c, d, _ = _ellipj_xp(xp, phi, m_f) + s1, c1, d1, _ = _ellipj_xp(xp, psi, 1.0 - m_f) delta = c1**2 + m_f * s**2 * s1**2 @@ -141,7 +176,4 @@ def ellipji(u, m): cni = (c * c1 - 1j * s * d * s1 * d1) / delta dni = (d * c1 * d1 - 1j * m_f * s * c * s1) / delta - sni = sni.reshape(orig_shape) - cni = cni.reshape(orig_shape) - dni = dni.reshape(orig_shape) - return xp.asarray(sni), xp.asarray(cni), xp.asarray(dni) + return sni, cni, dni diff --git a/python/elliptic/ellipj.py b/python/elliptic/ellipj.py index 62136fc..44cb66e 100644 --- a/python/elliptic/ellipj.py +++ b/python/elliptic/ellipj.py @@ -1,14 +1,20 @@ """Jacobi elliptic functions sn, cn, dn, am — native on any array backend. +Accuracy limit for large arguments: the phase is reduced modulo 2K in +double precision, so the residual carries an absolute uncertainty ~|u|*eps. +Full precision holds for |u| up to ~1e12; by |u| ~ 1e16 the phase is lost +entirely (a bound shared by every double implementation, scipy included). + Algorithm: Arithmetic-Geometric Mean + descending Landen back-substitution (Abramowitz & Stegun §16.4). Fixed 25 AGM iterations, no per-element convergence tracking → fully data-parallel on CUDA / JAX. """ from __future__ import annotations +import math import numpy as np -from ._xputils import get_xp +from ._xputils import get_xp, check_range _AGM_ITERS = 25 @@ -29,48 +35,80 @@ def ellipj(u, m): u = xp.asarray(u, dtype=xp.float64) m = xp.asarray(m, dtype=xp.float64) u, m = xp.broadcast_arrays(u, m) - return _ellipj_xp(xp, u, m) + valid = check_range(xp, m, 0.0, 1.0, 'm') & ~xp.isnan(m) & ~xp.isnan(u) + sn, cn, dn, am = _ellipj_xp(xp, u, m) + nan = xp.full_like(sn, math.nan) + return (xp.where(valid, sn, nan), xp.where(valid, cn, nan), + xp.where(valid, dn, nan), xp.where(valid, am, nan)) def _ellipj_xp(xp, u, m): - # Clamp m away from exact 0/1 so sqrt is defined; edge cases handled below. - m_safe = xp.clip(m, 1e-15, 1.0 - 1e-15) + # Keep every representable interior parameter unchanged. The old global + # clip to [1e-15, 1-1e-15] silently changed valid inputs near both + # endpoints (most visibly m=nextafter(1, 0)). Exact endpoints use a + # harmless interior placeholder here and are replaced below. + interior = (m > 0.0) & (m < 1.0) + m_safe = xp.where(interior, m, xp.full_like(m, 0.5)) a = xp.ones_like(m_safe) b = xp.sqrt(1.0 - m_safe) # Forward AGM: store ratio = (a-b)/(a+b) = c_new/a_new for back-sub ratios = [] + bratios = [] # b_{n+1}/a_{n+1} = 2 sqrt(ab)/(a+b) for _ in range(_AGM_ITERS): ab_sum = a + b ratios.append((a - b) / ab_sum) + bratios.append(2.0 * xp.sqrt(a * b) / ab_sum) b = xp.sqrt(a * b) a = ab_sum * 0.5 - # Starting amplitude: phi_N = 2^N * a_N * u - phin = (2.0 ** _AGM_ITERS) * a * u + # Reduce u before multiplying by 2^N. Without this, the large argument + # enters the Landen recursion directly and loses phase bits; near m=1 the + # error can become O(1) after only a few dozen periods. + K = np.pi / (2.0 * a) + period = xp.floor((u + K) / (2.0 * K)) + u_reduced = u - 2.0 * period * K + + # Starting amplitude on [-K, K]: phi_N = 2^N * a_N * u_reduced + phin = (2.0 ** _AGM_ITERS) * a * u_reduced # Descending Landen back-substitution (all elements, fixed 25 steps) for i in range(_AGM_ITERS - 1, -1, -1): - arg = xp.clip(ratios[i] * xp.sin(phin), -1.0, 1.0) - phin = 0.5 * (xp.arcsin(arg) + phin) - - sn_g = xp.sin(phin) - cn_g = xp.cos(phin) - dn_g = xp.sqrt(xp.clip(1.0 - m_safe * sn_g * sn_g, 0.0, None)) + # arcsin(r sin phi) = atan2(r sin phi, sqrt(cos^2 phi + (b/a)^2 sin^2 phi)) + # using a^2 - c^2 = b^2 for the AGM triple: no arcsin near +/-1, which + # lost ~7 digits when m -> 1 (cn(9.4 | 1-eps/2) was 5e-10 off). + sp = xp.sin(phin); cp = xp.cos(phin) + phin = 0.5 * (xp.arctan2(ratios[i] * sp, + xp.sqrt(cp * cp + (bratios[i] * sp) ** 2)) + phin) + + period_mod2 = period - 2.0 * xp.floor(period * 0.5) + quasi_sign = 1.0 - 2.0 * period_mod2 + sn_g = quasi_sign * xp.sin(phin) + cn_g = quasi_sign * xp.cos(phin) + # The cn form avoids subtracting two nearly equal numbers when m and + # |sn| are both close to one. + dn_g = xp.sqrt(xp.clip((1.0 - m_safe) + m_safe * cn_g * cn_g, 0.0, None)) + am_g = phin + period * np.pi + + # Stable sech avoids overflow in cosh for large non-m=1 elements. Array + # backends evaluate both sides of where, so a nominally unselected cosh + # still emitted warnings/overflowed during ordinary calls. + exp_neg = xp.exp(-xp.abs(u)) + sech_u = 2.0 * exp_neg / (1.0 + exp_neg * exp_neg) # Blend exact m=0 and m=1 results sn = xp.where(m == 0.0, xp.sin(u), xp.where(m == 1.0, xp.tanh(u), sn_g)) cn = xp.where(m == 0.0, xp.cos(u), - xp.where(m == 1.0, 1.0 / xp.cosh(u), cn_g)) + xp.where(m == 1.0, sech_u, cn_g)) dn = xp.where(m == 0.0, xp.ones_like(u), - xp.where(m == 1.0, 1.0 / xp.cosh(u), dn_g)) + xp.where(m == 1.0, sech_u, dn_g)) # am is the *continuous* amplitude from the Landen recursion, not # arcsin(sn): the latter folds it into [-pi/2, pi/2] and so loses the # period count (DLMF 22.16.1: am(u + 2K) = am(u) + pi). am = xp.where(m == 0.0, u, - xp.where(m == 1.0, xp.arcsin(xp.clip(xp.tanh(u), -1.0, 1.0)), phin)) + xp.where(m == 1.0, xp.arcsin(xp.clip(xp.tanh(u), -1.0, 1.0)), am_g)) return sn, cn, dn, am diff --git a/python/elliptic/elliptic12.py b/python/elliptic/elliptic12.py index d9a4db5..e271f8b 100644 --- a/python/elliptic/elliptic12.py +++ b/python/elliptic/elliptic12.py @@ -4,7 +4,7 @@ E(phi, m) = integral_0^phi sqrt(1 - m sin^2 t) dt Z(phi, m) = E(phi, m) - E(m)/K(m) * F(phi, m) [Jacobi Zeta] -Algorithm: Carlson symmetric forms (DLMF 19.25.5-6): +Algorithm: Carlson symmetric forms (DLMF 19.25.5 for F, 19.25.9 for E): F = sin(phi) * RF(cos^2, 1-m sin^2, 1) E = F - m * sin^3(phi)/3 * RD(cos^2, 1-m sin^2, 1) Z = E - E(m)/K(m) * F where K=RF(0,1-m,1), E(m)=K - m/3*RD(0,1-m,1) @@ -17,7 +17,8 @@ import math import numpy as np -from ._xputils import get_xp +from ._xputils import get_xp, check_range, sub_kpi + from .carlson import _rf_xp, _rd_xp, _rf_numpy, _rd_numpy @@ -37,14 +38,18 @@ def elliptic12(u, m): u = xp.asarray(u, dtype=xp.float64) m = xp.asarray(m, dtype=xp.float64) u, m = xp.broadcast_arrays(u, m) - return _elliptic12_xp(xp, u, m) + valid = check_range(xp, m, 0.0, 1.0, 'm') + F, E, Z = _elliptic12_xp(xp, u, m) + nan = xp.full_like(F, math.nan) + return xp.where(valid, F, nan), xp.where(valid, E, nan), xp.where(valid, Z, nan) def _elliptic12_xp(xp, u, m): """Backend-native F, E, Z via Carlson forms. u and m are 1-D xp arrays.""" # Period reduction: F(u+kπ|m) = F(u|m) + 2k·K(m), Z period π k = xp.round(u / math.pi) - u_r = u - k * math.pi # reduced to (-π/2, π/2] + # Cody-Waite split of pi: (u - k*PI_HI) - k*PI_LO keeps the reduction error at eps*|u_r| instead of eps*|u| + u_r = sub_kpi(u, k) # reduced to (-π/2, π/2], error eps*|u_r| # Complete integrals K(m), E(m) via Carlson z0 = xp.zeros_like(m) @@ -55,7 +60,9 @@ def _elliptic12_xp(xp, u, m): s = xp.sin(u_r) c = xp.cos(u_r) - d2 = 1.0 - m * s * s + # (1-m) + m cos^2 avoids the cancellation in 1 - m sin^2 when m -> 1 + # and phi -> pi/2 (F(pi/2-1e-9 | 1-eps/2) was off by 4e-3). + d2 = (1.0 - m) + m * c * c RF = _rf_xp(xp, c * c, d2, xp.ones_like(u_r)) RD = _rd_xp(xp, c * c, d2, xp.ones_like(u_r)) @@ -78,16 +85,26 @@ def _elliptic12_xp(xp, u, m): E = xp.where(m == 0.0, u, E) Z = xp.where(m == 0.0, xp.zeros_like(Z), Z) - # m == 1: F = log(tan(π/4 + u_r/2)), E via sin, Z = sin(u_r) - F_m1 = xp.log(xp.tan(math.pi / 4 + u_r * 0.5)) - um1 = xp.abs(u_r) - Nf = xp.floor((um1 + math.pi * 0.5) / math.pi) + # m == 1: F has its first non-integrable pole at |u|=π/2. Period + # reduction must not hide that crossing (the old code returned F=0 at + # u=π and also under-counted E beyond the first quadrant). + um1 = xp.abs(u) + Nf = xp.floor((um1 + math.pi * 0.5) / math.pi) sgn = xp.where(u >= 0.0, xp.ones_like(u), -xp.ones_like(u)) E_m1 = ((-1.0) ** Nf * xp.sin(um1) + 2.0 * Nf) * sgn - Z_m1 = xp.sin(u_r) # (-1)^Nf * sin(u), Nf=0 for |u_r|<π/2 - - near_pole_m1 = xp.abs(u_r) >= math.pi * 0.5 - 1e-14 - F_m1 = xp.where(near_pole_m1, xp.full_like(F_m1, math.inf) * sgn, F_m1) + Z_m1 = xp.sin(u_r) + + crossed_pole_m1 = um1 >= math.pi * 0.5 + u_m1_safe = xp.where(crossed_pole_m1, xp.zeros_like(u), u) + # asinh(tan u) (the inverse Gudermannian): exact at u = 0, odd, and it does + # not saturate the way atanh(sin u) does when sin u rounds to 1 near pi/2 + # (F(pi/2-1e-9|1) is 21.4, not inf); log(tan(pi/4+u/2)) gave F(0|1) = -1e-16. + F_m1_finite = xp.arcsinh(xp.tan(u_m1_safe)) + F_m1 = xp.where( + crossed_pole_m1, + xp.full_like(F_m1_finite, math.inf) * sgn, + F_m1_finite, + ) F = xp.where(m == 1.0, F_m1, F) E = xp.where(m == 1.0, E_m1, E) Z = xp.where(m == 1.0, Z_m1, Z) diff --git a/python/elliptic/elliptic3.py b/python/elliptic/elliptic3.py index 7bb6638..0a82ebc 100644 --- a/python/elliptic/elliptic3.py +++ b/python/elliptic/elliptic3.py @@ -2,31 +2,16 @@ Pi(u, m, n) = integral_0^u 1 / ((1 - n sin^2 t) sqrt(1 - m sin^2 t)) dt -Algorithm: 10-point Gauss-Legendre quadrature. Pure array-namespace ops — -runs natively on NumPy, PyTorch CUDA, and JAX without any np.asarray conversion. +Algorithm: Carlson symmetric forms (DLMF 19.25.14). Pure array-namespace +operations run natively on NumPy, PyTorch CUDA, and JAX. """ from __future__ import annotations import math +import numpy as np -from ._xputils import get_xp - -# 10-point Gauss-Legendre nodes and weights on [0, 1] as plain Python floats -# (so they broadcast correctly against any backend tensor) -_GL_T = [ - 0.9931285991850949, 0.9639719272779138, - 0.9122344282513259, 0.8391169718222188, - 0.7463319064601508, 0.6360536807265150, - 0.5108670019508271, 0.3737060887154195, - 0.2277858511416451, 0.07652652113349734, -] -_GL_W = [ - 0.01761400713915212, 0.04060142980038694, - 0.06267204833410907, 0.08327674157670475, - 0.10193011981724040, 0.11819453196151840, - 0.13168863844917660, 0.14209610931838200, - 0.14917298647260370, 0.15275338713072580, -] +from ._xputils import get_xp, is_numpy, sub_kpi +from .carlson import _rf_xp, _rj_xp def elliptic3(u, m, n): @@ -34,12 +19,13 @@ def elliptic3(u, m, n): Parameters ---------- - u : array_like Phase in radians, 0 <= u <= pi/2. + u : array_like Phase in radians. m : array_like Parameter, 0 <= m <= 1. n : array_like Characteristic, n <= 1. For n > 1 the integral is a Cauchy principal value (circular case, DLMF 19.7.3) - which 10-point Gauss–Legendre cannot resolve; this - function raises ValueError in that regime. + which this real-valued implementation does not resolve; + NumPy calls raise ValueError when the integration path + crosses that pole. Returns ------- @@ -51,18 +37,20 @@ def elliptic3(u, m, n): n = xp.asarray(n, dtype=xp.float64) u, m, n = xp.broadcast_arrays(u, m, n) - import numpy as _np - n_np = _np.asarray(n) - u_np = _np.asarray(u) - if _np.any(n_np > 1.0): + # Eager NumPy calls can provide a precise domain error. Traced backends + # cannot branch on array values; their invalid elements naturally become + # non-finite through the Carlson expression instead. + if is_numpy(xp) and np.any(n > 1.0): + n_np = np.asarray(n) + u_np = np.asarray(u) # Check whether the singularity sin²θ = 1/n lies in [0, u] - with _np.errstate(invalid='ignore', divide='ignore'): - sing = _np.where(n_np > 1.0, _np.arcsin(_np.sqrt(1.0 / n_np)), _np.inf) - if _np.any((n_np > 1.0) & (u_np >= sing)): + with np.errstate(invalid="ignore", divide="ignore"): + sing = np.where(n_np > 1.0, np.arcsin(np.sqrt(1.0 / n_np)), np.inf) + if np.any((n_np > 1.0) & (np.abs(u_np) >= sing)): raise ValueError( "elliptic3: n > 1 with phase beyond the pole at arcsin(1/sqrt(n)) " - "is a Cauchy principal-value integral (DLMF 19.7.3); not supported " - "by 10-point Gauss–Legendre. Use a transformation (DLMF 19.7.4) " + "is a Cauchy principal-value integral (DLMF 19.7.3); not supported. " + "Use a transformation (DLMF 19.7.4) " "or compute via Carlson R_J with complex arguments." ) @@ -71,50 +59,45 @@ def elliptic3(u, m, n): # Pi(-u) = -Pi(u) # Pi(u+k*pi) = Pi(u) + 2k*Pi(pi/2) # Pi(pi-u) = 2*Pi(pi/2) - Pi(u) - # The 10-point Gauss-Legendre rule below is only accurate on [0, pi/2]; - # larger phases used to be evaluated directly and were silently wrong - # (~1e-6 at u = 6). sign_u = xp.where(u < 0, -xp.ones_like(u), xp.ones_like(u)) ua = xp.abs(u) k_per = xp.floor(ua / math.pi) - r = ua - k_per * math.pi # in [0, pi) + # Cody-Waite split of pi: (u - k*PI_HI) - k*PI_LO keeps the reduction error at eps*|u_r| instead of eps*|u| + r = sub_kpi(ua, k_per) # in [0, pi), error eps*|r| refl = r > math.pi * 0.5 u_red = xp.where(refl, math.pi - r, r) # in [0, pi/2] - reduced = xp.any(k_per > 0) or xp.any(refl) or xp.any(u < 0) + s = xp.sin(u_red) + c = xp.cos(u_red) + s2 = s * s + # (1-m) + m cos^2 and (1-n) + n cos^2: no cancellation near the + # endpoint poles (Pi(pi/2-1e-6 | m, n=1) was off by 3e-5). + d2 = (1.0 - m) + m * c * c + p = (1.0 - n) + n * c * c + one = xp.ones_like(s) - u = u_red - half_u = u * 0.5 - P = xp.zeros_like(u) - for ti, wi in zip(_GL_T, _GL_W): - c0 = half_u * ti - tp = half_u + c0 - tm = half_u - c0 - s2p = xp.sin(tp) ** 2 - s2m = xp.sin(tm) ** 2 - P = P + wi * ( - 1.0 / ((1.0 - n * s2p) * xp.sqrt(xp.clip(1.0 - m * s2p, 0.0, None))) + - 1.0 / ((1.0 - n * s2m) * xp.sqrt(xp.clip(1.0 - m * s2m, 0.0, None))) - ) - Pi = half_u * P + RF = _rf_xp(xp, c * c, d2, one) + RJ = _rj_xp(xp, c * c, d2, one, p) + Pi_red = s * RF + n * s * s2 * RJ / 3.0 + Pi_red = xp.where(s == 0.0, xp.zeros_like(Pi_red), Pi_red) - if reduced: - # Complete integral Pi(pi/2|m,n) by the same rule, then undo the - # reduction: Pi(|u|) = 2k*Pcpl + refl*2*Pcpl ± Pi(u_red), * sign(u) - qtr = math.pi * 0.25 - Pc = xp.zeros_like(u) - for ti, wi in zip(_GL_T, _GL_W): - tp = qtr + qtr * ti - tm = qtr - qtr * ti - s2p = xp.sin(tp) ** 2 - s2m = xp.sin(tm) ** 2 - Pc = Pc + wi * ( - 1.0 / ((1.0 - n * s2p) * xp.sqrt(xp.clip(1.0 - m * s2p, 0.0, None))) + - 1.0 / ((1.0 - n * s2m) * xp.sqrt(xp.clip(1.0 - m * s2m, 0.0, None))) - ) - Pc = qtr * Pc - Pi = sign_u * (2.0 * k_per * Pc + xp.where(refl, 2.0 * Pc, xp.zeros_like(Pc)) - + xp.where(refl, -Pi, Pi)) + # Complete Π is needed to restore reflected/full periods. Replace + # singular complete parameters while evaluating the eager expression so + # unselected branches stay finite on autodiff backends. + complete_singular = (m == 1.0) | (n >= 1.0) + m_complete = xp.where(complete_singular, xp.zeros_like(m), m) + n_complete = xp.where(complete_singular, xp.zeros_like(n), n) + zero = xp.zeros_like(s) + RF_complete = _rf_xp(xp, zero, 1.0 - m_complete, one) + RJ_complete = _rj_xp(xp, zero, 1.0 - m_complete, one, 1.0 - n_complete) + Pi_complete = RF_complete + n_complete * RJ_complete / 3.0 + + Pi_abs = ( + 2.0 * k_per * Pi_complete + + xp.where(refl, 2.0 * Pi_complete - Pi_red, Pi_red) + ) + Pi = sign_u * Pi_abs - # u == pi/2 and (m == 1 or n == 1) → inf - inf_mask = ((u == math.pi * 0.5) & (m == 1.0)) | ((u == math.pi * 0.5) & (n == 1.0)) - return xp.where(inf_mask, xp.full_like(Pi, math.inf), Pi) + # At m=1 or n=1 the path diverges once it reaches the first π/2 pole. + crosses_endpoint_pole = complete_singular & (ua >= math.pi * 0.5) + signed_inf = sign_u * xp.full_like(Pi, math.inf) + return xp.where(crosses_endpoint_pole, signed_inf, Pi) diff --git a/python/elliptic/ellipticBD.py b/python/elliptic/ellipticBD.py index c38b25f..9e9f45b 100644 --- a/python/elliptic/ellipticBD.py +++ b/python/elliptic/ellipticBD.py @@ -14,7 +14,7 @@ import numpy as np from ._xputils import get_xp -from .elliptic12 import _elliptic12_xp +from .carlson import _rf_xp, _rd_xp def ellipticBD(m): @@ -34,17 +34,24 @@ def ellipticBD(m): def _bd_xp(xp, m): - phi = xp.full_like(m, math.pi * 0.5) - K, E, _ = _elliptic12_xp(xp, phi, m) - - # D = (K - E) / m, limit at m=0: π/4 - D = xp.where(m == 0.0, - xp.full_like(m, math.pi * 0.25), - (K - E) / xp.where(m == 0.0, xp.ones_like(m), m)) + zero = xp.zeros_like(m) + one = xp.ones_like(m) + K = _rf_xp(xp, zero, 1.0 - m, one) + D = _rd_xp(xp, zero, 1.0 - m, one) / 3.0 B = K - D - S = xp.where(m == 0.0, - xp.full_like(m, math.pi / 16.0), - (D - B) / xp.where(m == 0.0, xp.ones_like(m), m)) + + # S=(D-B)/m is catastrophically cancelling near m=0. Evaluate its + # convergent binomial/integral series there: + # 1/sqrt(1-m sin²t) = Σ C_k m^k sin^(2k)t. + S_series = xp.zeros_like(m) + for k in range(1, 9): + ck = math.comb(2 * k, k) / (4.0 ** k) + ik = math.pi * math.comb(2 * k, k) / (2.0 * 4.0 ** k) + ik1 = math.pi * math.comb(2 * k + 2, k + 1) / (2.0 * 4.0 ** (k + 1)) + S_series = S_series + ck * (2.0 * ik1 - ik) * m ** (k - 1) + m_safe = xp.where(m == 0.0, xp.ones_like(m), m) + S_direct = (D - B) / m_safe + S = xp.where(xp.abs(m) < 1e-2, S_series, S_direct) return B, D, S diff --git a/python/elliptic/ellipticBDJ.py b/python/elliptic/ellipticBDJ.py index e097db8..190ddca 100644 --- a/python/elliptic/ellipticBDJ.py +++ b/python/elliptic/ellipticBDJ.py @@ -19,7 +19,7 @@ import math -from ._xputils import get_xp +from ._xputils import get_xp, is_numpy, sub_kpi from .carlson import _rf_xp, _rd_xp, _rj_xp @@ -55,11 +55,15 @@ def ellipticBDJ(phi, m, n=None): # D(phi+k*pi|m) = D(phi|m) + 2k*D(m) # J(phi+k*pi,n|m) = J(phi,n|m) + 2k*J(n|m) k = xp.ceil(phi / math.pi - 0.5) - phi = phi - k * math.pi # now in (-pi/2, pi/2] + # Cody-Waite split of pi: (u - k*PI_HI) - k*PI_LO keeps the reduction error at eps*|u_r| instead of eps*|u| + phi = sub_kpi(phi, k) # now in (-pi/2, pi/2], error eps*|phi| s = xp.sin(phi) c = xp.cos(phi) - d2 = 1.0 - m * s * s + # Delta^2 = (1-m) + m cos^2, not 1 - m sin^2: the latter cancels near + # phi = pi/2 as m -> 1 (relative 2.5e-9 at m = 1-1e-8; R_D turned it + # into 3e-10 in D(phi|m) and hence in jacobiEDJ at large u). + d2 = (1.0 - m) + m * c * c s3o3 = s * s * s / 3.0 one = xp.ones_like(phi) @@ -85,11 +89,27 @@ def ellipticBDJ(phi, m, n=None): if compute_J: p = 1.0 - n * s * s - RJ = _rj_xp(xp, c * c, d2, one, p) + # n > 1 with the phase beyond the pole at arcsin(1/sqrt(n)) is a + # Cauchy principal-value integral (DLMF 19.7.3): R_J needs p > 0. + # The private _rj_xp silently returned garbage there (1.147 for + # J(1, 1.5|0.5); the principal value is 0.859). The complete J(n|m) + # is only needed when a period was removed (k != 0); at n = 1 it is + # a pole, and 0 * inf turned J(phi, 1|m) into NaN for |phi| <= pi/2. + bad_inc = p <= 0.0 + bad_cpl = (k != 0.0) & (n >= 1.0) + if is_numpy(xp) and bool(xp.any(bad_inc | bad_cpl)): + raise ValueError( + "ellipticBDJ: n > 1 with phase beyond the pole at arcsin(1/sqrt(n)) " + "(or n >= 1 with |phi| > pi/2) is a Cauchy principal-value integral " + "(DLMF 19.7.3); not supported.") + p_safe = xp.where(bad_inc, one, p) + RJ = _rj_xp(xp, c * c, d2, one, p_safe) J_val = s3o3 * RJ J_val = xp.where(zero, xp.zeros_like(J_val), J_val) - J_cpl = _rj_xp(xp, zed, 1.0 - m, one, 1.0 - n) / 3.0 # J(n|m) - J_val = J_val + 2.0 * k * J_cpl + n_safe = xp.where(bad_cpl | (n == 1.0), xp.zeros_like(n), n) + J_cpl = _rj_xp(xp, zed, 1.0 - m, one, 1.0 - n_safe) / 3.0 # J(n|m) + J_val = J_val + xp.where(k == 0.0, xp.zeros_like(J_val), 2.0 * k * J_cpl) + J_val = xp.where(bad_inc | bad_cpl, xp.full_like(J_val, math.nan), J_val) else: J_val = None diff --git a/python/elliptic/inverse.py b/python/elliptic/inverse.py index fc38de7..d5c6a02 100644 --- a/python/elliptic/inverse.py +++ b/python/elliptic/inverse.py @@ -1,13 +1,16 @@ """Inverse incomplete elliptic integral of the second kind.""" from __future__ import annotations +import math import numpy as np +from ._xputils import get_xp, is_numpy +from .elliptic12 import _elliptic12_xp def inverselliptic2(E_val, m, tol=1e-12): """Inverse of the incomplete elliptic integral of the second kind. - Solves E(phi | m) = E_val for phi using period reduction, Boyd (2012) - initialisation, and a Newton while-loop (issue #12: converges to *tol*). + Solves E(phi | m) = E_val using period reduction and fixed-step Newton + refinement against the library's own Carlson implementation. Period identity: E(phi + pi | m) = E(phi | m) + 2*E(m) Symmetry: E(pi - phi | m) = 2*E(m) - E(phi | m) @@ -20,55 +23,50 @@ def inverselliptic2(E_val, m, tol=1e-12): Returns ------- - phi : ndarray Amplitude in radians such that E(phi | m) ≈ E_val. + phi : array Amplitude in radians such that E(phi | m) ≈ E_val. """ - from scipy.special import ellipe, ellipeinc + xp = get_xp(E_val, m) + E_val = xp.asarray(E_val, dtype=xp.float64) + m = xp.asarray(m, dtype=xp.float64) + E_val, m = xp.broadcast_arrays(E_val, m) - E_val = np.asarray(E_val, dtype=np.float64) - m = np.asarray(m, dtype=np.float64) - - orig_shape = np.broadcast_shapes(E_val.shape, m.shape) - E_flat = np.broadcast_to(E_val, orig_shape).ravel().copy() - m_flat = np.broadcast_to(m, orig_shape).ravel().copy() - - if np.any(m_flat < 0) or np.any(m_flat > 1): + if is_numpy(xp) and np.any((m < 0.0) | (m > 1.0)): raise ValueError("m must be in [0, 1]") - m_flat = np.where(m_flat < np.finfo(float).eps, 0.0, m_flat) # Complete integral E(m); each phi-period of π contributes 2*E1 to E. - E1 = ellipe(m_flat) - two_E1 = 2.0 * np.where(E1 > 0, E1, 1.0) + half_pi = xp.full_like(m, math.pi * 0.5) + _, E1, _ = _elliptic12_xp(xp, half_pi, m) + two_E1 = 2.0 * E1 + + # Oddness first: E(-phi) = -E(phi). Folding a tiny negative z through + # 2*E1 - (z + 2*E1) lost all its digits (rel 1e-7 at z = -1e-9*E1). + sgn = xp.where(E_val < 0.0, -xp.ones_like(E_val), xp.ones_like(E_val)) + E_val = xp.abs(E_val) # Step 1 — strip full periods: phi = phi_base + k*pi - k = np.floor(E_flat / two_E1) - z_red = E_flat - k * two_E1 # in [0, 2*E1) + k = xp.floor(E_val / two_E1) + z_red = E_val - k * two_E1 # in [0, 2*E1) # Step 2 — fold second half-period using E(pi-phi|m) = 2E1 - E(phi|m) - over = z_red > E1 - z_red2 = np.where(over, two_E1 - z_red, z_red) # in [0, E1] - - # Boyd (2012) empirical initialisation for phi in [0, pi/2] - mu = 1.0 - m_flat - zeta = 1.0 - z_red2 / np.where(E1 > 0, E1, 1.0) - r = np.sqrt(zeta**2 + mu**2) - theta = np.arctan2(mu, z_red2 + 1e-300) - phi = np.pi / 2.0 + np.sqrt(r) * (theta - np.pi / 2.0) - phi = np.clip(phi, 0.0, np.pi / 2.0) - - # Newton while-loop until converged (issue #12: fixed 4 iters insufficient) - for _ in range(200): - E_cur = ellipeinc(phi, m_flat) - res = E_cur - z_red2 - if np.max(np.abs(res)) < tol: - break - denom = np.sqrt(np.maximum(1.0 - m_flat * np.sin(phi)**2, 0.0)) - phi = phi - res / np.where(denom < 1e-15, 1e-15, denom) - phi = np.clip(phi, 0.0, np.pi / 2.0) + over = z_red > E1 + z_red2 = xp.where(over, two_E1 - z_red, z_red) # in [0, E1] + + # Monotone linear seed in [0, pi/2]. Fixed iteration count keeps the + # routine JIT-safe; converged elements simply receive zero-sized updates. + phi = xp.clip((z_red2 / E1) * (math.pi * 0.5), 0.0, math.pi * 0.5) + + for _ in range(24): + _, E_cur, _ = _elliptic12_xp(xp, phi, m) + res = E_cur - z_red2 + denom = xp.sqrt(xp.clip(1.0 - m * xp.sin(phi) ** 2, 0.0, None)) + safe_denom = xp.where(denom > tol, denom, xp.ones_like(denom)) + # unconditional step: gating on |res| > tol froze the solver at an + # absolute 1e-12, i.e. only ~3 relative digits for |z| ~ 1e-9 + step = res / safe_denom + phi = xp.clip(phi - step, 0.0, math.pi * 0.5) # Step 3 — undo fold: phi_in_period = pi - phi (if over), else phi - phi = np.where(over, np.pi - phi, phi) # in [0, pi) - - # Step 4 — undo period strips: each strip adds pi to phi - phi = phi + k * np.pi + phi = xp.where(over, math.pi - phi, phi) # in [0, pi) - return phi.reshape(orig_shape) if orig_shape else phi.squeeze() + # Step 4 — undo period strips and the sign + return sgn * (phi + k * math.pi) diff --git a/python/elliptic/jacobi_edj.py b/python/elliptic/jacobi_edj.py index 654ef37..a219226 100644 --- a/python/elliptic/jacobi_edj.py +++ b/python/elliptic/jacobi_edj.py @@ -10,8 +10,9 @@ """ from __future__ import annotations -import numpy as np +from ._xputils import get_xp from .ellipj import ellipj +from .carlson import _rf_xp, _rd_xp, _rj_xp from .ellipticBDJ import ellipticBDJ @@ -32,14 +33,36 @@ def jacobiEDJ(u, m, n=None): Eu, Du : arrays Ju : array or None """ - u_arr = np.asarray(u, dtype=np.float64) - m_arr = np.asarray(m, dtype=np.float64) - u_arr, m_arr = np.broadcast_arrays(u_arr, m_arr) + args = (u, m, n) if n is not None else (u, m) + xp = get_xp(*args) + u_arr = xp.asarray(u, dtype=xp.float64) + m_arr = xp.asarray(m, dtype=xp.float64) + if n is None: + u_arr, m_arr = xp.broadcast_arrays(u_arr, m_arr) + else: + n = xp.asarray(n, dtype=xp.float64) + u_arr, m_arr, n = xp.broadcast_arrays(u_arr, m_arr, n) - _, _, _, phi = ellipj(u_arr, m_arr) - B, D, J = ellipticBDJ(phi, m_arr, n) - - Du = D - Eu = u_arr - m_arr * np.asarray(D) - Ju = J + # Reduce u by the period 2K BEFORE taking the amplitude. am(u) at + # |u| ~ 1e3 carries eps*|am| ~ 5e-14 of rounding, and near phi = pi/2 + # with m -> 1 the map phi -> D(phi|m) is steep (dD/dphi = sin^2/Delta + # ~ 1/sqrt(1-m)): D_u(1520|1-1e-8) was off by 3e-10. As functions of + # u the integrals are perfectly conditioned (dD_u/du = sn^2 <= 1). + zed = xp.zeros_like(m_arr) + one = xp.ones_like(m_arr) + K = _rf_xp(xp, zed, 1.0 - m_arr, one) + k = xp.floor((u_arr + K) / (2.0 * K)) + u_r = u_arr - 2.0 * k * K + _, _, _, phi_r = ellipj(u_r, m_arr) # |phi_r| <= pi/2 + B_r, D_r, J_r = ellipticBDJ(phi_r, m_arr, n) + D_cpl = _rd_xp(xp, zed, 1.0 - m_arr, one) / 3.0 # D(m) + Du = D_r + 2.0 * k * D_cpl + Eu = u_arr - m_arr * Du # error eps*|u|: the conditioning floor + if n is not None: + # J(n|m) only where a period was removed (n = 1 is a pole there) + n_safe = xp.where(k == 0.0, zed, n) + J_cpl = _rj_xp(xp, zed, 1.0 - m_arr, one, 1.0 - n_safe) / 3.0 + Ju = J_r + xp.where(k == 0.0, zed, 2.0 * k * J_cpl) + else: + Ju = None return Eu, Du, Ju diff --git a/python/elliptic/nome.py b/python/elliptic/nome.py index 5672b59..0187d56 100644 --- a/python/elliptic/nome.py +++ b/python/elliptic/nome.py @@ -1,7 +1,9 @@ """Nome q(m) and its inverse m(q).""" from __future__ import annotations +import math import numpy as np -from array_api_compat import array_namespace +from ._xputils import get_xp, check_range, is_numpy +from .theta import _q_from_m_xp def nomeq(m): @@ -17,23 +19,20 @@ def nomeq(m): q : array Nome in [0, 1). """ - from scipy.special import ellipk - m = np.asarray(m, dtype=np.float64) - xp = array_namespace(m) - m_np = np.asarray(m).ravel() - K = ellipk(m_np) - Kp = ellipk(1.0 - m_np) - q = np.exp(-np.pi * Kp / K) - q = q.reshape(np.asarray(m).shape) - return xp.asarray(q) + xp = get_xp(m) + m = xp.asarray(m, dtype=xp.float64) + check_range(xp, m, 0.0, 1.0, 'm') # traced backends already NaN-mask inside + q = _q_from_m_xp(xp, m) + return xp.where(m == 1.0, xp.ones_like(q), q) def inversenomeq(q): """Inverse nome: parameter m from nome q. - Uses ``scipy.optimize.brentq`` to invert ``nomeq``. In double precision the - representable range is roughly q ∈ [0, 0.779]; beyond this, m(q) exceeds - 1 - 2⁻⁵³ and cannot be represented. + Closed form m = (theta2(0,q)/theta3(0,q))^4 (DLMF 20.9.1), exact at every + scale. In double precision the representable range is q ∈ [0, q_max] with + q_max = nomeq(nextafter(1, 0)) ≈ 0.7789; beyond this m(q) exceeds 1 - 2⁻⁵³ + and cannot be represented. Parameters ---------- @@ -45,39 +44,32 @@ def inversenomeq(q): m : array Parameter m = m(q) in [0, 1). """ - import warnings - from scipy.optimize import brentq - from scipy.special import ellipk + xp = get_xp(q) + q = xp.asarray(q, dtype=xp.float64) - q = np.asarray(q, dtype=np.float64) - xp = array_namespace(q) - q_flat = np.asarray(q).ravel() + m_hi_scalar = np.nextafter(1.0, 0.0) + q_max = float(_q_from_m_xp(np, np.asarray(m_hi_scalar))) - if np.any(q_flat < 0) or np.any(q_flat >= 1): - raise ValueError("q must be in [0, 1)") + if is_numpy(xp): + if np.any((q < 0.0) | (q >= 1.0)): + raise ValueError("q must be in [0, 1)") + # Above q_max the true 1-m = m(exp(pi^2/ln q)) ~ 16 exp(-pi^2/ln(1/q)) is + # below eps/2, so the correctly rounded double is exactly 1.0 (the series + # is not converged there; this used to raise, MATLAB returned m > 1). - m_hi = np.nextafter(1.0, 0.0) # largest f64 strictly < 1 - q_max = float(np.exp(-np.pi * ellipk(1.0 - m_hi) / ellipk(m_hi))) - if np.any(q_flat >= q_max): - raise ValueError( - f"inversenomeq: q must be < {q_max:.15f} in double precision " - "(the essential singularity of m(q) at q=1 cannot be resolved in f64)" - ) - if np.any(q_flat > 0.76): - warnings.warn("inversenomeq: accuracy degrades for q > 0.76 (near m=1 singularity)", - RuntimeWarning, stacklevel=2) - - def _nomeq_scalar(m_val): - K = float(ellipk(m_val)) - Kp = float(ellipk(1.0 - m_val)) - return float(np.exp(-np.pi * Kp / K)) - - m_out = np.empty_like(q_flat) - for i, qi in enumerate(q_flat): - if qi == 0.0: - m_out[i] = 0.0 - else: - m_out[i] = brentq(lambda m: _nomeq_scalar(m) - qi, 0.0, m_hi, xtol=1e-14) - - m_out = m_out.reshape(np.asarray(q).shape) - return xp.asarray(m_out) + # Closed form, DLMF 20.9.1: m = (theta2(0,q) / theta3(0,q))^4. + # Exact at every scale -- the previous 64-step bisection in m had an + # absolute resolution floor of 2^-64, so m(1e-30) came back 2.7e-20 + # instead of 1.6e-29 (nine orders of magnitude off). + # theta2(0,q) = 2 q^(1/4) sum q^(n(n+1)), theta3(0,q) = 1 + 2 sum q^(n^2) + # The q^(1/4) factor is kept outside the ratio so tiny q cannot underflow. + valid = (q >= 0.0) & (q < 1.0) + q_safe = xp.where(valid, xp.minimum(q, xp.full_like(q, q_max)), xp.zeros_like(q)) + s2 = xp.ones_like(q_safe) # sum q^(n(n+1)), n >= 0 + s3 = xp.ones_like(q_safe) # theta3 = 1 + 2 sum q^(n^2) + for n in range(1, 31): + s2 = s2 + q_safe ** (n * (n + 1)) + s3 = s3 + 2.0 * q_safe ** (n * n) + result = xp.minimum(16.0 * q_safe * (s2 / s3) ** 4, xp.ones_like(q_safe)) + result = xp.where(q > q_max, xp.ones_like(result), result) + return xp.where(valid, result, xp.full_like(result, math.nan)) diff --git a/python/elliptic/theta.py b/python/elliptic/theta.py index 75f2060..03e7190 100644 --- a/python/elliptic/theta.py +++ b/python/elliptic/theta.py @@ -14,82 +14,115 @@ """ from __future__ import annotations import math -import numpy as np -from array_api_compat import array_namespace +from ._xputils import get_xp +from .carlson import _rf_xp _N_TERMS = 30 # sufficient for |q| ≤ 0.8 (m ≤ ~0.9997) -def _q_from_m(m_np: np.ndarray) -> np.ndarray: - """Nome q(m) — uses scipy K; kept internal to avoid circular import.""" - from scipy.special import ellipk - K = ellipk(m_np) - Kp = ellipk(1.0 - m_np) - return np.exp(-np.pi * Kp / K) +def _q_from_m_xp(xp, m): + """Backend-native nome q(m), with invalid/singular inputs masked.""" + valid = (m >= 0.0) & (m < 1.0) + m_safe = xp.where(valid, m, xp.full_like(m, 0.5)) + zero = xp.zeros_like(m_safe) + one = xp.ones_like(m_safe) + K = _rf_xp(xp, zero, 1.0 - m_safe, one) + Kp = _rf_xp(xp, zero, m_safe, one) + q = xp.exp(-math.pi * Kp / K) + return xp.where(valid, q, xp.full_like(q, math.nan)) # ----------------------------------------------------------------------- # Low-level series (flat 1-D numpy, v in radians, q scalar or array) # ----------------------------------------------------------------------- -def _th1(v: np.ndarray, q: np.ndarray) -> np.ndarray: - """θ₁(v, q).""" - s = np.zeros_like(v) +def _trig_start(xp, v): + """sin/cos of v, 2v once; all higher multiples come from the angle-addition + recurrence. Forming (2n+1)*v as a double product rounds by eps*|k v|, + which cost 1e-12 at v ~ 1e8 and 1e-8 at v ~ 1e11; the recurrence keeps + every term accurate to ~n*eps relative to the exact sin(v), cos(v).""" + s1 = xp.sin(v); c1 = xp.cos(v) + return s1, c1, 2.0 * s1 * c1, 1.0 - 2.0 * s1 * s1 # s1, c1, sin 2v, cos 2v + + +def _th1(xp, v, q): + """θ₁(v, q) = 2 Σ (-1)^n q^((n+1/2)^2) sin((2n+1)v).""" + sk, ck, s2, c2 = _trig_start(xp, v) + s = xp.zeros_like(v) for n in range(_N_TERMS): - s += (-1)**n * q**((n + 0.5)**2) * np.sin((2*n + 1) * v) + s = s + (-1)**n * q**((n + 0.5)**2) * sk + sk, ck = sk * c2 + ck * s2, ck * c2 - sk * s2 return 2.0 * s -def _th2(v: np.ndarray, q: np.ndarray) -> np.ndarray: - """θ₂(v, q).""" - s = np.zeros_like(v) +def _th2(xp, v, q): + """θ₂(v, q) = 2 Σ q^((n+1/2)^2) cos((2n+1)v).""" + sk, ck, s2, c2 = _trig_start(xp, v) + s = xp.zeros_like(v) for n in range(_N_TERMS): - s += q**((n + 0.5)**2) * np.cos((2*n + 1) * v) + s = s + q**((n + 0.5)**2) * ck + sk, ck = sk * c2 + ck * s2, ck * c2 - sk * s2 return 2.0 * s -def _th3(v: np.ndarray, q: np.ndarray) -> np.ndarray: - """θ₃(v, q).""" - s = np.ones_like(v) +def _th3(xp, v, q): + """θ₃(v, q) = 1 + 2 Σ q^(n^2) cos(2nv).""" + _, _, s2, c2 = _trig_start(xp, v) + sk, ck = s2, c2 # sin 2v, cos 2v + s = xp.ones_like(v) for n in range(1, _N_TERMS + 1): - s += 2.0 * q**(n**2) * np.cos(2*n * v) + s = s + 2.0 * q**(n**2) * ck + sk, ck = sk * c2 + ck * s2, ck * c2 - sk * s2 return s -def _th4(v: np.ndarray, q: np.ndarray) -> np.ndarray: - """θ₄(v, q).""" - s = np.ones_like(v) +def _th4(xp, v, q): + """θ₄(v, q) = 1 + 2 Σ (-1)^n q^(n^2) cos(2nv).""" + _, _, s2, c2 = _trig_start(xp, v) + sk, ck = s2, c2 + s = xp.ones_like(v) for n in range(1, _N_TERMS + 1): - s += 2.0 * (-1)**n * q**(n**2) * np.cos(2*n * v) + s = s + 2.0 * (-1)**n * q**(n**2) * ck + sk, ck = sk * c2 + ck * s2, ck * c2 - sk * s2 return s # Derivatives dθⱼ/dv -def _dth1(v: np.ndarray, q: np.ndarray) -> np.ndarray: - s = np.zeros_like(v) +def _dth1(xp, v, q): + sk, ck, s2, c2 = _trig_start(xp, v) + s = xp.zeros_like(v) for n in range(_N_TERMS): - s += (-1)**n * (2*n+1) * q**((n + 0.5)**2) * np.cos((2*n + 1) * v) + s = s + (-1)**n * (2*n+1) * q**((n + 0.5)**2) * ck + sk, ck = sk * c2 + ck * s2, ck * c2 - sk * s2 return 2.0 * s -def _dth2(v: np.ndarray, q: np.ndarray) -> np.ndarray: - s = np.zeros_like(v) +def _dth2(xp, v, q): + sk, ck, s2, c2 = _trig_start(xp, v) + s = xp.zeros_like(v) for n in range(_N_TERMS): - s += -(2*n+1) * q**((n + 0.5)**2) * np.sin((2*n + 1) * v) + s = s - (2*n+1) * q**((n + 0.5)**2) * sk + sk, ck = sk * c2 + ck * s2, ck * c2 - sk * s2 return 2.0 * s -def _dth3(v: np.ndarray, q: np.ndarray) -> np.ndarray: - s = np.zeros_like(v) +def _dth3(xp, v, q): + _, _, s2, c2 = _trig_start(xp, v) + sk, ck = s2, c2 + s = xp.zeros_like(v) for n in range(1, _N_TERMS + 1): - s += -2.0 * 2*n * q**(n**2) * np.sin(2*n * v) + s = s - 4.0 * n * q**(n**2) * sk + sk, ck = sk * c2 + ck * s2, ck * c2 - sk * s2 return s -def _dth4(v: np.ndarray, q: np.ndarray) -> np.ndarray: - s = np.zeros_like(v) +def _dth4(xp, v, q): + _, _, s2, c2 = _trig_start(xp, v) + sk, ck = s2, c2 + s = xp.zeros_like(v) for n in range(1, _N_TERMS + 1): - s += 2.0 * (-1)**n * (-2*n) * q**(n**2) * np.sin(2*n * v) + s = s - 4.0 * n * (-1)**n * q**(n**2) * sk + sk, ck = sk * c2 + ck * s2, ck * c2 - sk * s2 return s @@ -119,42 +152,20 @@ def jacobiThetaEta(u, m): Th, H : arrays Jacobi theta and eta values. """ - from scipy.special import ellipk - - u = np.asarray(u, dtype=np.float64) - m = np.asarray(m, dtype=np.float64) - xp = array_namespace(u, m) + xp = get_xp(u, m) + u = xp.asarray(u, dtype=xp.float64) + m = xp.asarray(m, dtype=xp.float64) u, m = xp.broadcast_arrays(u, m) - orig_shape = np.asarray(u).shape - - u_np = np.asarray(u).ravel() - m_np = np.asarray(m).ravel() - - Th = np.ones_like(u_np) - H = np.zeros_like(u_np) - - # m = 1: undefined - mask1 = m_np >= 1.0 - 1e-14 - Th[mask1] = np.nan - H[mask1] = np.nan - - # m = 0: Th = 1, H = 0 - mask0 = m_np < 1e-14 - # already set by np.ones / np.zeros - - maskN = ~mask0 & ~mask1 - if np.any(maskN): - u_g = u_np[maskN] - m_g = m_np[maskN] - K_g = ellipk(m_g) - q_g = _q_from_m(m_g) - v_g = np.pi * u_g / (2.0 * K_g) # normalised angle argument - Th[maskN] = _th4(v_g, q_g) - H[maskN] = _th1(v_g, q_g) - - Th = Th.reshape(orig_shape) - H = H.reshape(orig_shape) - return xp.asarray(Th), xp.asarray(H) + valid = (m >= 0.0) & (m < 1.0) + m_safe = xp.where(valid, m, xp.full_like(m, 0.5)) + zero = xp.zeros_like(m_safe) + K = _rf_xp(xp, zero, 1.0 - m_safe, xp.ones_like(m_safe)) + q = _q_from_m_xp(xp, m) + v = math.pi * u / (2.0 * K) + Th = _th4(xp, v, q) + H = _th1(xp, v, q) + nan = xp.full_like(Th, math.nan) + return xp.where(valid, Th, nan), xp.where(valid, H, nan) def theta(j, v, m): @@ -180,34 +191,12 @@ def theta(j, v, m): if j not in (1, 2, 3, 4): raise ValueError("j must be 1, 2, 3, or 4") - v = np.asarray(v, dtype=np.float64) - m = np.asarray(m, dtype=np.float64) - xp = array_namespace(v, m) + xp = get_xp(v, m) + v = xp.asarray(v, dtype=xp.float64) + m = xp.asarray(m, dtype=xp.float64) v, m = xp.broadcast_arrays(v, m) - orig_shape = np.asarray(v).shape - - v_np = np.asarray(v).ravel() - m_np = np.asarray(m).ravel() - - Th = np.zeros_like(v_np) - maskN = (m_np >= 1e-14) & (m_np < 1.0 - 1e-14) - - if np.any(maskN): - q_g = _q_from_m(m_np[maskN]) - Th[maskN] = _TH_FNS[j](v_np[maskN], q_g) - - # special cases - mask0 = m_np < 1e-14 - if np.any(mask0): - if j == 1: Th[mask0] = np.sin(v_np[mask0]) # θ₁(v,0) = sin v (leading term) - elif j == 2: Th[mask0] = 0.0 # q→0 all terms vanish except trivially 0 - elif j == 3: Th[mask0] = 1.0 - elif j == 4: Th[mask0] = 1.0 - - mask1 = m_np >= 1.0 - 1e-14 - Th[mask1] = np.nan - - return xp.asarray(Th.reshape(orig_shape)) + q = _q_from_m_xp(xp, m) + return _TH_FNS[j](xp, v, q) def theta_prime(j, v, m): @@ -227,24 +216,9 @@ def theta_prime(j, v, m): if j not in (1, 2, 3, 4): raise ValueError("j must be 1, 2, 3, or 4") - v = np.asarray(v, dtype=np.float64) - m = np.asarray(m, dtype=np.float64) - xp = array_namespace(v, m) + xp = get_xp(v, m) + v = xp.asarray(v, dtype=xp.float64) + m = xp.asarray(m, dtype=xp.float64) v, m = xp.broadcast_arrays(v, m) - orig_shape = np.asarray(v).shape - - v_np = np.asarray(v).ravel() - m_np = np.asarray(m).ravel() - - th_np = np.zeros_like(v_np) - thp_np = np.zeros_like(v_np) - - maskN = (m_np >= 1e-14) & (m_np < 1.0 - 1e-14) - if np.any(maskN): - q_g = _q_from_m(m_np[maskN]) - th_np[maskN] = _TH_FNS[j](v_np[maskN], q_g) - thp_np[maskN] = _DTH_FNS[j](v_np[maskN], q_g) - - th = th_np.reshape(orig_shape) - thp = thp_np.reshape(orig_shape) - return xp.asarray(th), xp.asarray(thp) + q = _q_from_m_xp(xp, m) + return _TH_FNS[j](xp, v, q), _DTH_FNS[j](xp, v, q) diff --git a/python/elliptic/weierstrass.py b/python/elliptic/weierstrass.py index c902c73..0ed52cb 100644 --- a/python/elliptic/weierstrass.py +++ b/python/elliptic/weierstrass.py @@ -11,13 +11,37 @@ import math import numpy as np from ._xputils import get_xp +from .carlson import _rf_xp + + +def _reject_complex_inputs(*values): + for value in values: + dtype = getattr(value, "dtype", None) + if dtype is not None and "complex" in str(dtype).lower(): + raise ValueError("Weierstrass functions currently support real inputs only") + if dtype is None and isinstance(value, complex): + raise ValueError("Weierstrass functions currently support real inputs only") def _broadcast4(z, e1, e2, e3): + _reject_complex_inputs(z, e1, e2, e3) xp = get_xp(z, e1, e2, e3) - z = xp.asarray(z, dtype=xp.float64) - e1 = xp.asarray(e1, dtype=xp.float64) - e2 = xp.asarray(e2, dtype=xp.float64) - e3 = xp.asarray(e3, dtype=xp.float64) + # Python-scalar roots must be materialised on the SAME DEVICE as the + # array inputs: xp.asarray(1.5) is a CPU tensor in torch, and + # broadcast_arrays refuses to mix it with CUDA tensors (found on L4). + args = [z, e1, e2, e3] + ref = next((a for a in args if hasattr(a, 'device') or hasattr(a, 'shape')), None) + def dev(a): + if ref is not None and not hasattr(a, 'shape'): + return xp.full_like(xp.asarray(ref, dtype=xp.float64), float(a)) + return xp.asarray(a, dtype=xp.float64) + z, e1, e2, e3 = (dev(a) for a in args) + # Root ordering e1 >= e2 >= e3 with e1 > e3 (equal neighbours are the + # legitimate m = 0 / m = 1 degenerate lattices). Unsorted roots used to + # fall through as m > 1 and return NaN silently; MATLAB errors there. + from ._xputils import is_numpy + ok = (e1 >= e2) & (e2 >= e3) & (e1 > e3) + if is_numpy(xp) and not bool(xp.all(ok | xp.isnan(e1 + e2 + e3))): + raise ValueError("Weierstrass roots must satisfy e1 >= e2 >= e3 with e1 > e3") z, e1, e2, e3 = xp.broadcast_arrays(z, e1, e2, e3) return xp, z, e1, e2, e3 @@ -35,10 +59,19 @@ def weierstrassP(z, e1, e2, e3): def _weierP_xp(xp, z, e1, e2, e3): from .ellipj import _ellipj_xp m = (e2 - e3) / (e1 - e3) - w = z * xp.sqrt(e1 - e3) + mp = (e1 - e2) / (e1 - e3) # 1-m without cancellation + scale = xp.sqrt(e1 - e3) + K = _rf_xp(xp, xp.zeros_like(m), mp, xp.ones_like(m)) + omega1 = K / scale + period = xp.round(z / (2.0 * omega1)) + z_reduced = z - 2.0 * period * omega1 + w = z_reduced * scale sn, _, _, _ = _ellipj_xp(xp, w, m) sn2 = sn * sn - pole = xp.abs(sn) < 1e-10 + # Pole only at the exact lattice point (DLMF 23.9.2: the Laurent + # expansion makes every nearby representable z a huge FINITE value -- + # P(1e-16) ~ 1e32, not Inf; a tolerance here destroys that data). + pole = z_reduced == 0.0 P = e3 + (e1 - e3) / xp.where(pole, xp.ones_like(sn2), sn2) return xp.where(pole, xp.full_like(P, math.inf), P) @@ -56,14 +89,11 @@ def _weierP_numpy(z, e1, e2, e3): def weierstrassZeta(z, e1, e2, e3): """Weierstrass zeta function (NOT Riemann zeta).""" - _, z, e1, e2, e3 = _broadcast4(z, e1, e2, e3) - orig_shape = z.shape - Z = _weierZ_numpy(np.asarray(z).ravel(), np.asarray(e1).ravel(), - np.asarray(e2).ravel(), np.asarray(e3).ravel()) - return np.asarray(Z.reshape(orig_shape)) + xp, z, e1, e2, e3 = _broadcast4(z, e1, e2, e3) + return _weierZ_xp(xp, z, e1, e2, e3) -def _lattice_theta_numpy(z, e1, e2, e3): +def _lattice_theta_xp(xp, z, e1, e2, e3): """omega1, eta1 and the theta1 series values needed by zeta/sigma. Closed theta forms (DLMF 23.6.8/9/13): no quadrature. Returns @@ -71,41 +101,55 @@ def _lattice_theta_numpy(z, e1, e2, e3): dropped from every series -- only ratios are used downstream, except th1/th1p0 where both drop the same factor. """ - from .elliptic12 import _elliptic12_xp m_param = (e2 - e3) / (e1 - e3) - phi_half = np.full_like(m_param, math.pi / 2) - K, _, _ = _elliptic12_xp(np, phi_half, m_param) - Kp, _, _ = _elliptic12_xp(np, phi_half, 1.0 - m_param) - omega1 = K / np.sqrt(e1 - e3) - q = np.exp(-math.pi * Kp / K) + mp_param = (e1 - e2) / (e1 - e3) # 1-m without cancellation: near + # m -> 1 lattices, 1.0 - m loses the digits omega1 and eta1 depend on + zero = xp.zeros_like(m_param) + one = xp.ones_like(m_param) + K = _rf_xp(xp, zero, mp_param, one) + Kp = _rf_xp(xp, zero, m_param, one) + omega1 = K / xp.sqrt(e1 - e3) + q = xp.exp(-math.pi * Kp / K) v = math.pi * z / (2.0 * omega1) - qmax = float(np.max(q)) if q.size else 0.0 - nT = min(30, max(2, math.ceil(math.sqrt(abs(math.log(np.finfo(float).eps) - / math.log(qmax)))))) if qmax > 0 else 1 - - th1 = np.zeros_like(v); th1p = np.zeros_like(v) - th1p0 = np.zeros_like(v); th1ppp0 = np.zeros_like(v) - for n in range(nT + 1): + th1 = xp.zeros_like(v) + th1p = xp.zeros_like(v) + th1p0 = xp.zeros_like(v) + th1ppp0 = xp.zeros_like(v) + # sin/cos of (2n+1)v by angle-addition from sin v, cos v: the products + # k*v round by eps*|k v| (1e-12 at v ~ 1e8) -- see theta._trig_start + sk, ck = xp.sin(v), xp.cos(v) + s2, c2 = 2.0 * sk * ck, 1.0 - 2.0 * sk * sk + for n in range(31): qq = (-1.0) ** n * q ** ((n + 0.5) ** 2) k = 2 * n + 1 - th1 += qq * np.sin(k * v) - th1p += qq * k * np.cos(k * v) - th1p0 += qq * k - th1ppp0 -= qq * k ** 3 + th1 = th1 + qq * sk + th1p = th1p + qq * k * ck + th1p0 = th1p0 + qq * k + th1ppp0 = th1ppp0 - qq * k ** 3 + sk, ck = sk * c2 + ck * s2, ck * c2 - sk * s2 eta1 = -math.pi ** 2 / (12.0 * omega1) * th1ppp0 / th1p0 return omega1, eta1, th1, th1p, th1p0 -def _weierZ_numpy(z, e1, e2, e3): +def _weierZ_xp(xp, z, e1, e2, e3): # zeta(z) = eta1*z/omega1 + pi/(2*omega1) * theta1'(v)/theta1(v) [DLMF 23.6.13] # Quasi-periodicity zeta(z + 2k*omega1) = zeta(z) + 2k*eta1 is carried # exactly by the formula; no period reduction needed. - omega1, eta1, th1, th1p, _ = _lattice_theta_numpy(z, e1, e2, e3) - with np.errstate(divide='ignore', invalid='ignore'): - Z = eta1 * z / omega1 + math.pi / (2.0 * omega1) * th1p / th1 - Z[th1 == 0.0] = np.inf # lattice points z = 2k*omega1 - return Z + omega1, eta1, th1, th1p, _ = _lattice_theta_xp(xp, z, e1, e2, e3) + period = xp.round(z / (2.0 * omega1)) + z_reduced = z - 2.0 * period * omega1 + # Pole only at the exact lattice point (DLMF 23.9.2: the Laurent + # expansion makes every nearby representable z a huge FINITE value -- + # P(1e-16) ~ 1e32, not Inf; a tolerance here destroys that data). + pole = z_reduced == 0.0 + ratio = th1p / xp.where(pole, xp.ones_like(th1), th1) + Z = eta1 * z / omega1 + math.pi / (2.0 * omega1) * ratio + return xp.where(pole, xp.full_like(Z, math.inf), Z) + + +def _weierZ_numpy(z, e1, e2, e3): + return _weierZ_xp(np, z, e1, e2, e3) # --------------------------------------------------------------------------- @@ -114,20 +158,28 @@ def _weierZ_numpy(z, e1, e2, e3): def weierstrassSigma(z, e1, e2, e3): """Weierstrass sigma function (entire, odd, sigma'(z)/sigma(z) = zeta(z)).""" - _, z, e1, e2, e3 = _broadcast4(z, e1, e2, e3) - orig_shape = z.shape - S = _weierS_numpy(np.asarray(z).ravel(), np.asarray(e1).ravel(), - np.asarray(e2).ravel(), np.asarray(e3).ravel()) - return np.asarray(S.reshape(orig_shape)) + xp, z, e1, e2, e3 = _broadcast4(z, e1, e2, e3) + return _weierS_xp(xp, z, e1, e2, e3) -def _weierS_numpy(z, e1, e2, e3): +def _weierS_xp(xp, z, e1, e2, e3): # sigma(z) = 2*omega1/pi * exp(eta1*z^2/(2*omega1)) * theta1(v)/theta1'(0) # [DLMF 23.6.9]. Entire function: every lattice zero and sign change # comes out of theta1 itself. The previous form integrated log(sigma) # through the zeta pole at 2*omega1 and was wrong beyond it. - omega1, eta1, th1, _, th1p0 = _lattice_theta_numpy(z, e1, e2, e3) - return 2.0 * omega1 / math.pi * np.exp(eta1 * z * z / (2.0 * omega1)) * th1 / th1p0 + omega1, eta1, th1, _, th1p0 = _lattice_theta_xp(xp, z, e1, e2, e3) + return ( + 2.0 + * omega1 + / math.pi + * xp.exp(eta1 * z * z / (2.0 * omega1)) + * th1 + / th1p0 + ) + + +def _weierS_numpy(z, e1, e2, e3): + return _weierS_xp(np, z, e1, e2, e3) # ----------------------------------------------------------------------- @@ -152,9 +204,18 @@ def weierstrassPPrime(z, e1, e2, e3): xp, z, e1, e2, e3 = _broadcast4(z, e1, e2, e3) from .ellipj import _ellipj_xp m = (e2 - e3) / (e1 - e3) - w = z * xp.sqrt(e1 - e3) + mp = (e1 - e2) / (e1 - e3) + root_scale = xp.sqrt(e1 - e3) + K = _rf_xp(xp, xp.zeros_like(m), mp, xp.ones_like(m)) + omega1 = K / root_scale + period = xp.round(z / (2.0 * omega1)) + z_reduced = z - 2.0 * period * omega1 + w = z_reduced * root_scale sn, cn, dn, _ = _ellipj_xp(xp, w, m) scale = -2.0 * (e1 - e3) ** 1.5 - pole = xp.abs(sn) < 1e-10 + # Pole only at the exact lattice point (DLMF 23.9.2: the Laurent + # expansion makes every nearby representable z a huge FINITE value -- + # P(1e-16) ~ 1e32, not Inf; a tolerance here destroys that data). + pole = z_reduced == 0.0 dP = scale * cn * dn / xp.where(pole, xp.ones_like(sn), sn * sn * sn) return xp.where(pole, xp.full_like(dP, math.inf), dP) diff --git a/python/octave-workspace b/python/octave-workspace new file mode 100644 index 0000000..e151ea3 Binary files /dev/null and b/python/octave-workspace differ diff --git a/python/pyproject.toml b/python/pyproject.toml index 07987ae..2545375 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -36,5 +36,5 @@ dev = ["pytest>=7", "scipy>=1.8", "mpmath>=1.3"] packages = ["elliptic"] [tool.pytest.ini_options] -testpaths = ["tests"] -addopts = "-v --tb=short" +testpaths = ["tests", "elliptic"] +addopts = "-v --tb=short --doctest-modules" diff --git a/python/tests/conftest.py b/python/tests/conftest.py index d484357..2402eee 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -11,6 +11,8 @@ pass try: + import jax + jax.config.update("jax_enable_x64", True) import jax.numpy # noqa: F401 BACKENDS.append("jax") except ImportError: diff --git a/python/tests/test_backends.py b/python/tests/test_backends.py new file mode 100644 index 0000000..4513def --- /dev/null +++ b/python/tests/test_backends.py @@ -0,0 +1,90 @@ +"""Backend smoke tests that make the Torch/JAX CI jobs exercise those paths.""" + +import numpy as np +import pytest + +import elliptic + + +def _array(xp, values, *, complex_values=False): + if xp is np: + dtype = np.complex128 if complex_values else np.float64 + return np.asarray(values, dtype=dtype) + if xp.__name__ == "torch": + dtype = xp.complex128 if complex_values else xp.float64 + return xp.tensor(values, dtype=dtype) + dtype = xp.complex128 if complex_values else xp.float64 + return xp.asarray(values, dtype=dtype) + + +def _numpy(value): + if hasattr(value, "detach"): + return value.detach().cpu().numpy() + return np.asarray(value) + + +def test_core_functions_preserve_backend_and_values(xp): + u = _array(xp, [0.2, 0.7, 1.1]) + m = _array(xp, [0.2, 0.5, 0.8]) + + F, E, _ = elliptic.elliptic12(u, m) + sn, cn, dn, _ = elliptic.ellipj(u, m) + Pi = elliptic.elliptic3(u, m, 0.2) + B, D, _ = elliptic.ellipticBD(m) + Eu, Du, _ = elliptic.jacobiEDJ(u, m) + + for value in (F, E, sn, cn, dn, Pi, B, D, Eu, Du): + assert _numpy(value).shape == (3,) + assert np.all(np.isfinite(_numpy(value))) + + np.testing.assert_allclose(_numpy(sn) ** 2 + _numpy(cn) ** 2, 1.0, atol=2e-13) + np.testing.assert_allclose(_numpy(Eu), _numpy(u) - _numpy(m) * _numpy(Du), atol=2e-12) + + +def test_auxiliary_functions_preserve_backend(xp): + v = _array(xp, [0.1, 0.3]) + m = _array(xp, [0.4, 0.7]) + q = elliptic.nomeq(m) + m_back = elliptic.inversenomeq(q) + th = elliptic.theta(3, v, m) + zeta = elliptic.weierstrassZeta(v, 1.0, 0.0, -1.0) + sigma = elliptic.weierstrassSigma(v, 1.0, 0.0, -1.0) + arc = elliptic.arclength_ellipse( + _array(xp, [2.0, 3.0]), + _array(xp, [3.0, 2.0]), + 0.1, + _array(xp, [0.5, 0.7]), + ) + + np.testing.assert_allclose(_numpy(m_back), _numpy(m), atol=2e-13) + for value in (th, zeta, sigma, arc): + assert np.all(np.isfinite(_numpy(value))) + + +def test_complex_functions_preserve_backend(xp): + u = _array(xp, [0.4 + 0.2j, 0.8 - 0.1j], complex_values=True) + m = _array(xp, [0.3, 0.7]) + F, E, _ = elliptic.elliptic12i(u, m) + sn, cn, _ = elliptic.ellipji(u, m) + for value in (F, E, sn, cn): + assert np.all(np.isfinite(_numpy(value))) + + +def test_jax_jit_core_paths(): + jax = pytest.importorskip("jax") + jnp = pytest.importorskip("jax.numpy") + jax.config.update("jax_enable_x64", True) + u = jnp.asarray([0.2, 0.7], dtype=jnp.float64) + + outputs = [ + jax.jit(lambda x: elliptic.elliptic12(x, 0.5)[0])(u), + jax.jit(lambda x: elliptic.elliptic3(x, 0.5, 0.2))(u), + jax.jit(lambda x: elliptic.theta(3, x, 0.5))(u), + ] + # These larger graphs only need a tracing guard here; compiling the full + # fixed-iteration inverse/theta expansions would make this smoke test a + # poor CI citizen. + jax.make_jaxpr(lambda x: elliptic.inverselliptic2(x, 0.5))(u) + jax.make_jaxpr(lambda x: elliptic.weierstrassZeta(x, 1.0, 0.0, -1.0))(u) + jax.make_jaxpr(lambda x: elliptic.arclength_ellipse(2.0, 3.0, 0.0, x))(u) + assert all(np.all(np.isfinite(np.asarray(value))) for value in outputs) diff --git a/python/tests/test_edge_cases.py b/python/tests/test_edge_cases.py index 9bfe1a4..c6582f7 100644 --- a/python/tests/test_edge_cases.py +++ b/python/tests/test_edge_cases.py @@ -442,3 +442,328 @@ def test_theta_prime_at_theta_zeros(self): dpi = _s(elliptic.theta_prime(1, math.pi, m)[1]) assert not math.isnan(dpi) and abs(dpi + d1) < 1e-13 assert not math.isnan(_s(elliptic.theta_prime(2, math.pi / 2, m)[1])) + + +# ===================================================================== +# Q. Adversarial-review round (external Codex + mpmath 1.4.1, dps=40). +# Each test is a counterexample a prior version failed. +# ===================================================================== +class TestAdversarialRound: + def test_elliptic3_negative_amplitude_with_pole(self): + """0*Inf guard: negative phase never crossing the complete-integral pole.""" + assert abs(_s(elliptic.elliptic3(-1.0, 0.5, 1.0)) - (-1.7319915420235269928)) < 1e-13 + assert abs(_s(elliptic.elliptic3(-1.0, 1.0, 0.2)) - (-1.3115010674599590753)) < 1e-13 + assert not math.isnan(_s(elliptic.elliptic3(-0.4, 1.0, 1.0))) + + def test_complex_FE_small_m_series(self): + """A&S 17.4.11 path lost sqrt(eps/m) digits; the m^2 series is exact.""" + assert abs(_s(elliptic.elliptic12i(0.2j, 1e-20)[0]) - 0.2j) < 1e-15 + F, E, _ = elliptic.elliptic12i(math.pi / 2 + 0.2j, 1e-14) + assert abs(_s(F) - (1.5707963267949005462 + 0.20000000000000101344j)) < 1e-13 + assert abs(_s(E) - (1.5707963267948926922 + 0.19999999999999898656j)) < 1e-13 + F, E, _ = elliptic.elliptic12i(math.pi / 2 + 0.2j, 1e-6) + assert abs(_s(F) - (1.5707967194941992113 + 0.20000010134411776594j)) < 5e-12 + assert abs(_s(E) - (1.5707959340957412894 + 0.19999989865593359446j)) < 5e-12 + # both sides of the series threshold vs mpmath (dps=30) + Fa = _s(elliptic.elliptic12i(1.1 + 0.3j, 0.99e-4)[0]) + assert abs(Fa - (1.1000153646885162 + 0.3000120622623928j)) < 1e-12 + Fb = _s(elliptic.elliptic12i(1.1 + 0.3j, 1.01e-4)[0]) + assert abs(Fb - (1.1000156750952820 + 0.3000123059589823j)) < 5e-12 + + def test_weierstrass_near_origin_finite(self): + """DLMF 23.9.2: only the exact lattice point is a pole; z = 1e-16 is + a huge FINITE value (a tolerance here used to return Inf).""" + assert abs(_s(elliptic.weierstrassP(1e-16, 1.0, 0.0, -1.0)) - 1e32) < 1e19 + assert abs(_s(elliptic.weierstrassPPrime(1e-16, 1.0, 0.0, -1.0)) + 2e48) < 1e36 + assert abs(_s(elliptic.weierstrassZeta(1e-16, 1.0, 0.0, -1.0)) - 1e16) < 1e3 + assert math.isinf(_s(elliptic.weierstrassP(0.0, 1.0, 0.0, -1.0))) + + def test_inverse_nome_all_scales(self): + """DLMF 20.9.1 closed form: m = (theta2/theta3)^4, exact at every scale + (the 64-step bisection had a 2^-64 absolute floor: m(1e-30) came back + 2.7e-20).""" + assert abs(_s(elliptic.inversenomeq(1e-30)) - 1.6e-29) < 1e-41 + assert abs(_s(elliptic.inversenomeq(1e-12)) - 1.5999999999872e-11) < 1e-24 + for mv in (1e-8, 0.3, 0.85, 0.999): + assert abs(_s(elliptic.inversenomeq(np.asarray(_s(elliptic.nomeq(mv))))) - mv) \ + < 1e-12 * max(mv, 1e-3), f"roundtrip at m={mv}" + # the computed upper endpoint must be accepted, not rejected + q_max = _s(elliptic.nomeq(np.nextafter(1.0, 0.0))) + assert _s(elliptic.inversenomeq(q_max)) > 0.999 + + def test_carlson_scale_invariance(self): + """DLMF 19.20: RF, RC ~ lambda^-1/2; RD, RJ ~ lambda^-3/2. An absolute + branch tolerance in RC broke this at small scales (27% at 1e-20).""" + x, y, z, p = 1.0, 2.0, 3.0, 4.0 + for lam in (1e-20, 1e20): + for fn, args, power in ( + (elliptic.carlsonRF, (x, y, z), 0.5), + (elliptic.carlsonRC, (x, y), 0.5), + (elliptic.carlsonRD, (x, y, z), 1.5), + (elliptic.carlsonRJ, (x, y, z, p), 1.5), + ): + base = _s(fn(*args)) + scaled = _s(fn(*(lam * a for a in args))) + want = base / lam ** power + assert abs(scaled - want) < 1e-10 * abs(want), f"{fn.__name__} at {lam}" + assert abs(_s(elliptic.carlsonRC(1e-20, 2e-20)) - 7853981633.9744830962) < 1e-4 + + def test_ellipticBD_nondegenerate_anchors(self): + """mpmath: B = (E-(1-m)K)/m, D = (K-E)/m at dps=40.""" + rows = [(0.2, 0.8066808960371526438, 0.85294270257337535705), + (0.7, 0.88437375336868858245, 1.1909893819237805614), + (0.999, 0.99832798626015502386, 3.8428045742901420065)] + for m, B_ref, D_ref in rows: + B, D, _ = elliptic.ellipticBD(m) + assert abs(_s(B) - B_ref) < 1e-14, f"B({m})" + assert abs(_s(D) - D_ref) < 1e-13, f"D({m})" + + def test_reversed_arc_intervals_signed(self): + """Reversal negates the arc for ellipses AND circles alike.""" + assert abs(_s(elliptic.arclength_ellipse(2.0, 3.0, 1.0, 0.1)) + + _s(elliptic.arclength_ellipse(2.0, 3.0, 0.1, 1.0))) < 1e-13 + assert abs(_s(elliptic.arclength_ellipse(2.0, 2.0, 1.0, 0.1)) - (-1.8)) < 1e-13 + + +# ===================================================================== +# R. Second adversarial round — fuzz vs mpmath (dps=40) over parameter +# endpoints, extreme scales, poles and period multiples. Every +# reference was evaluated at the EXACT DOUBLE the library receives: +# near singularities the decimal input and its double rounding differ +# enough to move the answer at 1e-9. scipy reaches machine precision +# on every case below; each was an implementation loss, now fixed. +# ===================================================================== +class TestAdversarialRound2: + M1 = float(np.nextafter(1.0, 0.0)) + + def test_F_near_pole_m_to_1_and_m_equal_1(self): + assert abs(_s(elliptic.elliptic12(math.pi/2 - 1e-9, self.M1)[0]) - 19.65993026560449767) < 1e-12*20 + assert _s(elliptic.elliptic12(0.0, 1.0)[0]) == 0.0 + assert abs(_s(elliptic.elliptic12(1e-16, 1.0)[0]) - 1e-16) < 1e-31 + + def test_third_kind_endpoint_poles(self): + assert abs(_s(elliptic.elliptic3(math.pi/2 - 1e-6, 0.3, 1.0)) - 1195228.2584444625825) < 1e-12*1.2e6 + assert abs(_s(elliptic.elliptic3(math.pi/2 - 1e-6, 1 - 1e-8, 0.9)) - 88.615055050793590585) < 1e-12*90 + + def test_carlson_disparate_scales_tiny_y_near_equal_double_zero(self): + assert abs(_s(elliptic.carlsonRJ(1e-20, 2e-20, 3e-20, 0.5)) - 43616756114.805842986) < 1e-12*4.4e10 + assert abs(_s(elliptic.carlsonRJ(2.0, 3.0, 4.0, 1e-10)) - 7.179193296087372323) < 1e-13*7.2 + assert abs(_s(elliptic.carlsonRC(3.0, 1e-10)) - 7.3643213780616827229) < 1e-13*7.4 + assert abs(_s(elliptic.carlsonRC(1.0000000000001, 1.0)) - 0.99999999999998334665) < 1e-14 + assert math.isinf(_s(elliptic.carlsonRF(0.0, 0.0, 1.0))) + assert math.isinf(_s(elliptic.carlsonRD(0.0, 0.0, 1.0))) + assert math.isinf(_s(elliptic.carlsonRJ(0.0, 0.0, 1.0, 2.0))) + + def test_ellipj_m_to_1(self): + assert abs(_s(elliptic.ellipj(9.375277798108883, self.M1)[1]) - 0.00016958935096417269446) < 1e-13*1.7e-4 + assert abs(_s(elliptic.ellipj(7.0, 1 - 1e-12)[1]) - 0.0018237622775256289351) < 1e-13*1.8e-3 + + def test_inverse_E_tiny_negative_z(self): + for m in (0.0, 0.5, 1 - 1e-8): + z = -1e-9 * float(ellipe(m)) + phi = _s(elliptic.inverselliptic2(z, m)) + assert abs(float(ellipeinc(phi, m)) - z) < 1e-13 * abs(z), f"m={m}" + + def test_complex_F_tiny_psi_and_small_m(self): + assert abs(_s(elliptic.elliptic12i(math.pi/2 + 1e-9j, 0.9)[0]).imag - 3.1622776601683798848e-9) < 1e-12*3.2e-9 + F = _s(elliptic.elliptic12i(math.pi/2 + 1e-9j, self.M1)[0]) + assert abs(F - complex(19.754694640120759063, 0.095049319491958534055)) < 1e-9*20 + F = _s(elliptic.elliptic12i(0.4 + 0.3j, 1e-4)[0]) + assert abs(F - complex(0.39999936996865927219, 0.30000195549059365219)) < 1e-13 + + def test_weierstrass_near_m1_lattice(self): + e1, e2, e3, z = 0.5000001, 0.5, -1.0000001, 13.391953465243201 + assert abs(_s(elliptic.weierstrassP(z, e1, e2, e3)) - 0.51848214450600943279) < 1e-12 + assert abs(_s(elliptic.weierstrassZeta(z, e1, e2, e3)) - -5.4787546526901492279) < 1e-12*5.5 + assert abs(_s(elliptic.weierstrassSigma(z, e1, e2, e3)) - 1.822626274365935705e-13) < 1e-12*1.8e-13 + + +# ===================================================================== +# S. Third adversarial round: dense random fuzz + API abuse +# ===================================================================== +class TestAdversarialRound3: + def test_cody_waite_reduction_point(self): + """u = 5.5*pi + 8e-10, m = 1-1.5e-13 (mpmath at the exact doubles): before + the Cody-Waite split, k*pi rounding cost eps*|u| in the reduced phase, + amplified ~1e5x by dZ/dphi near pi/2 at m -> 1.""" + u, m = 17.27875959554386, 0.99999999999985 + F, E, Z = elliptic.elliptic12(u, m) + assert abs(_s(F) - 177.65640489133312311) < 2e-9 + assert abs(_s(E) - 11.000000000012911122) < 1e-12 + assert abs(_s(Z) - (-0.00012790056416609388015)) < 1e-10 + assert abs(_s(elliptic.elliptic3(u, m, 0.3)) - 248.50046674013002377) < 3e-9 + + def test_domain_and_nan_are_honest(self): + """Out-of-range m raises on numpy; NaN propagates and never becomes a + placeholder value (ellipj(0.3, 1.5) used to return sn(0.3 | 0.5)).""" + for bad in (1.5, -1e-17, np.nextafter(1.0, 2.0)): + with pytest.raises(ValueError): elliptic.ellipj(0.3, bad) + with pytest.raises(ValueError): elliptic.elliptic12(0.3, bad) + with pytest.raises(ValueError): elliptic.nomeq(bad) + assert math.isnan(_s(elliptic.ellipj(0.3, float('nan'))[0])) + assert math.isnan(_s(elliptic.elliptic12(0.3, float('nan'))[0])) + v = elliptic.elliptic12(np.array([0.3, 0.5, 0.7]), np.array([0.2, np.nan, 0.4]))[0] + assert np.isnan(v[1]) and not np.isnan(v[[0, 2]]).any() + + def test_exact_zero_complex_and_RJ_extreme_ratio(self): + # note: the Python literal -0.0+0j already evaluates to 0j; use complex(-0.0, 0.0) + F0 = _s(elliptic.elliptic12i(complex(-0.0, 0.0), 0.5)[0]) + assert F0 == 0 and math.copysign(1.0, F0.real) < 0 # -0.0 preserved + assert _s(elliptic.elliptic12i(0j, 0.5)[0]) == 0 + assert abs(_s(elliptic.carlsonRJ(2.798e-18, 5.954e-24, 9.634e-23, 1.134e21)) - 9.9678905686736778972e-12) < 1e-12 * 1e-11 + + +# ===================================================================== +# T. Theta at a huge argument; Weierstrass root ordering +# ===================================================================== +class TestAdversarialRound4: + def test_theta_huge_argument(self): + """mpmath jtheta at the exact double v = 123456789.123: the products + (2n+1)*v rounded by eps*|k v| before the angle-addition recurrence.""" + v = 123456789.123 + t, tp = elliptic.theta_prime(1, v, 0.4) + assert abs(_s(t) - (0.84585020823346348431)) < 2e-15 and abs(_s(tp) - (0.015114923736622936955)) < 2e-14 + assert abs(_s(elliptic.theta(1, v, 0.4)) - (0.84585020823346348431)) < 2e-15 + t, tp = elliptic.theta_prime(2, v, 0.4) + assert abs(_s(t) - (0.014932290326334898348)) < 2e-15 and abs(_s(tp) - (-0.84241862816186020729)) < 2e-14 + assert abs(_s(elliptic.theta(2, v, 0.4)) - (0.014932290326334898348)) < 2e-15 + t, tp = elliptic.theta_prime(3, v, 0.4) + assert abs(_s(t) - (0.93627542467710214194)) < 2e-15 and abs(_s(tp) - (-0.004519191759268069986)) < 2e-14 + assert abs(_s(elliptic.theta(3, v, 0.4)) - (0.93627542467710214194)) < 2e-15 + t, tp = elliptic.theta_prime(4, v, 0.4) + assert abs(_s(t) - (1.0637286984176921296)) < 2e-15 and abs(_s(tp) - (0.0045203629452149075654)) < 2e-14 + assert abs(_s(elliptic.theta(4, v, 0.4)) - (1.0637286984176921296)) < 2e-15 + + def test_weierstrass_root_order_is_enforced(self): + with pytest.raises(ValueError): elliptic.weierstrassP(0.5, 0.5, 1.0, -1.5) # unsorted + with pytest.raises(ValueError): elliptic.weierstrassZeta(0.5, 1.0, 1.0, 1.0) # e1 == e3 + # equal neighbours are the legitimate degenerate lattices (m = 1 / m = 0) + assert math.isfinite(_s(elliptic.weierstrassP(0.5, 1.0, 1.0, -2.0))) + assert math.isfinite(_s(elliptic.weierstrassP(0.5, 1.0, 0.0, 0.0))) + + +# ===================================================================== +# U. Round 6: cross-port parity sweep at extreme m (anchors: mpmath at the +# exact double inputs). The k*pi reduction now uses a 25-bit split of pi +# (k*float(pi) rounded by eps*|u|), the RJ series E3 coefficient is +# 4 P^3 (DLMF 19.36.2), ellipticBDJ rejects n > 1 beyond the pole and +# handles n = 1, and the Python-only tiny-m / theta-nome paths are pinned. +# ===================================================================== +class TestAdversarialRound6: + def test_tiny_m_band(self): + F, E, _ = elliptic.elliptic12(np.array([1.0, 1.0]), np.array([3e-16, 5e-16])) + assert np.all(np.isfinite(F)) and np.all(np.isfinite(E)) + assert abs(F[0] - 1.0) < 5e-16 and abs(E[0] - 0.99999999999999996) < 5e-16 + assert abs(F[1] - 1.0000000000000001) < 5e-16 and abs(E[1] - 0.99999999999999993) < 5e-16 + + def test_theta_nome_from_exact_m(self): + assert abs(_s(elliptic.theta(1, 34401.9, 1.6e-16)) - 0.00011178415088289534) < 1e-13 * 1.1e-4 + assert abs(_s(elliptic.theta_prime(1, 6577.39, 1.5e-16)[0]) - (-9.8878892558450512e-5)) < 1e-13 * 1e-4 + + def test_E_near_m1_and_large_u_pi_split(self): + F, E, _ = elliptic.elliptic12(-1.65181, 0.99999999999999578) + assert abs(_s(E) - (-1.0032798131910099)) < 5e-14 and abs(_s(F) - (-32.666065762173088)) < 1e-14 * 33 + u, m = 80101.48788857895, 0.9999533239086507 # 25497*pi + 0.3 + F, E, Z = elliptic.elliptic12(u, m) + assert abs(_s(Z) - 0.2477141143165845) < 2e-14 # eps*|u| = 1.8e-11 before the split + assert abs(_s(E) - 51001.284415600044) < 1e-14 * 51001 + assert abs(_s(F) - 324959.38078465716) < 1e-14 * 324959 + _, _, Z = elliptic.elliptic12(1000000.123, 1 - 2**-53) + assert abs(_s(Z) - (-0.220434859492317)) < 2e-14 + assert abs(_s(elliptic.elliptic3(-2.70143, 1 - 2**-53, 0.9723)) - (-1249.3300419938347)) < 1e-14 * 1249 + + def test_RJ_series_and_characteristic_domain(self): + assert abs(_s(elliptic.carlsonRJ(0.1, 0.2, 1, 3.0)) - 1.1311524759367163) < 5e-15 * 1.13 + assert abs(_s(elliptic.carlsonRJ(0.292, 0.646, 1, 1.354)) - 1.2806109121365949) < 5e-15 * 1.28 + assert abs(_s(elliptic.ellipticBDJ(1.0, 0.5, 1.0)[2]) - 0.64877476917835824) < 5e-15 # was NaN (0 * inf) + assert abs(_s(elliptic.ellipticBDJ(0.5, 0.5, 1.5)[2]) - 0.052791966372572887) < 5e-16 + with pytest.raises(ValueError, match="principal"): + elliptic.ellipticBDJ(1.0, 0.5, 1.5) # beyond the pole: returned 1.147 silently + for c, ref in [(-0.5, 0.9560406633267465), (-3.0, 0.66684868942035313), (-100.0, 0.1523863772236308)]: + assert abs(_s(elliptic.elliptic3(1.0, 0.5, c)) - ref) < 5e-16 + assert abs(_s(elliptic.elliptic3(4.0, 0.9, -100.0)) - 0.4921742710224714) < 5e-15 + + def test_bulirsch_cel_is_kc_native(self): + """Bulirsch's algorithm: the old route through m = 1 - kc**2 lost kc + below ~1e-8 (cel1(1e-9) returned 2e6; ln(4/kc) = 22.1). p < 0 is the + Cauchy principal value = Re Pi(1-p | 1-kc^2) (mpmath).""" + assert abs(_s(elliptic.cel1(1e-9)) - (22.109560198066302)) < 1e-15 * 22 + assert abs(_s(elliptic.cel1(1e-300)) - (692.1618222593336)) < 1e-15 * 692 + assert abs(_s(elliptic.cel1(2.0)) - (1.0782578237498216)) < 1e-15 # kc > 1 is m = -3 + assert abs(_s(elliptic.cel(0.5, -0.5, 1.0, 1.0)) - (-1.0782578237498216)) < 1e-15 * 1.1 + assert abs(_s(elliptic.cel(0.7, -5.0, 1.0, 1.0)) - (-0.092277884964284496)) < 1e-15 + assert abs(_s(elliptic.cel(1e-9, 0.3, 1.5, -0.7)) - (-46.045402351061091)) < 1e-15 * 47 + assert _s(elliptic.cel(-0.3, 1.0, 1.0, 1.0)) == _s(elliptic.cel1(0.3)) # depends on kc^2 only + assert math.isinf(_s(elliptic.cel1(0.0))) and _s(elliptic.cel1(0.0)) > 0 + kc = np.array([1e-12, 0.3, 0.9, 2.5]); p = np.array([-0.4, 0.7, -3.0, 1e-3]) + K = np.asarray(elliptic.cel1(kc)) + assert np.all(np.abs(np.asarray(elliptic.cel(kc, p, 1.0, 0.0)) + p * np.asarray(elliptic.cel(kc, p, 0.0, 1.0)) - K) < 1e-14 * np.maximum(1, K)) + + def test_round6c_bdj_delta_jacobiEDJ_complexZ_arclength(self): + """mpmath at exact doubles: Delta^2 = (1-m) + m cos^2 in ellipticBDJ, + jacobiEDJ reduces u before the amplitude, complex Z uses the exact + complete integrals, arclength on mixed arrays.""" + assert abs(_s(elliptic.ellipticBDJ(math.pi/2 - 2e-4, 1 - 1e-8)[1]) - (8.1529993379146678)) < 2e-12 * 9 + Eu, Du, _ = elliptic.jacobiEDJ(1520.3427441800743, 0.999999990458008) + assert abs(_s(Eu) - 143.00000694616405) < 2e-12 and abs(_s(Du) - 1377.3427503765037) < 2e-12 + Z = _s(elliptic.elliptic12i(complex(-1.053, -0.8215), 1 - 2**-53)[2]) + assert abs(Z - complex(-1.1405612347714637, -0.39945642654606886)) < 1e-14 # was 1e-11: K taken at double(pi/2) + v = np.asarray(elliptic.arclength_ellipse(np.array([5, 785.9, 3]), np.array([10, 495.8, 3]), np.array([0, 5.279, 0]), np.array([1, -6.134, 2]))) + assert np.all(np.abs(v - np.array([8.8662512353670695, -7494.1448816975323, 6])) < 1e-14 * np.array([9, 7495, 6])) + + def test_elliptic12i_period_just_below_half_pi(self): + """pi*ceil(phi/pi - 0.5 + 1e-14) added a period for phi within 3e-14 + below pi/2: Re F came out 3K instead of K (mpmath at the exact double).""" + F = _s(elliptic.elliptic12i(complex(1.5707963267948961, 0.5), 1/3)[0]) + assert abs(F - complex(1.7339168852579344, 0.62666316872107993)) < 4e-15 + F = _s(elliptic.elliptic12i(complex(math.pi/2 - 1e-12, -2.0), 0.5)[0]) + assert abs(F.real - 0.3901536583) < 1e-9 # left of the cut, not 2K - ... + + +class TestInputShapes: + """Every function on a 2x3 array, on a flat vector with scalar partners, + and elementwise against scalar calls (the Octave port had four shape + defects of this kind; the NumPy port passes and this pins it).""" + def test_shapes_match_scalar_calls(self): + u = np.linspace(-2.5, 7.3, 6).reshape(2, 3); m = np.array([0.1, 0.5, 0.9, 0.999, 0.3, 0.7]).reshape(2, 3) + n = np.array([0.2, -0.5, 0.9, 0.0, 0.5, 0.3]).reshape(2, 3); q = np.array([0.01, 0.1, 0.3, 0.5, 0.05, 0.2]).reshape(2, 3) + z = np.linspace(0.2, 3.1, 6).reshape(2, 3); zc = u + 1j * np.linspace(-1.5, 1.5, 6).reshape(2, 3) + kc = np.array([0.1, 0.5, 0.9, 2.0, 1e-9, 0.3]).reshape(2, 3); p = np.array([1, 0.5, -0.5, 2, 0.3, 1.]).reshape(2, 3) + a = np.array([1, 0.5, -1, 2, 0.3, 1.]).reshape(2, 3); b = np.array([1, 2, 0.5, -1, 0.7, 0.2]).reshape(2, 3) + cases = [(elliptic.elliptic12, (u, m)), (elliptic.elliptic12i, (zc, m)), (elliptic.ellipj, (u, m)), (elliptic.ellipji, (zc, m)), + (elliptic.elliptic3, (u, m, n)), (elliptic.ellipticBDJ, (u, m, n)), (elliptic.ellipticBD, (m,)), (elliptic.jacobiEDJ, (u, m, n)), + (lambda v, mm: elliptic.theta(1, v, mm), (u, m)), (lambda v, mm: elliptic.theta_prime(3, v, mm), (u, m)), (elliptic.jacobiThetaEta, (u, m)), + (elliptic.nomeq, (m,)), (elliptic.inversenomeq, (q,)), (elliptic.cel, (kc, p, a, b)), (elliptic.cel1, (kc,)), + (elliptic.weierstrassP, (z, 1.5, -0.25, -1.25)), (elliptic.weierstrassZeta, (z, 1.5, -0.25, -1.25)), (elliptic.weierstrassSigma, (z, 1.5, -0.25, -1.25)), + (elliptic.arclength_ellipse, (np.abs(a) + 0.1, np.abs(b) + 0.1, u, z)), (elliptic.inverselliptic2, (z, m)), + (elliptic.carlsonRF, (np.abs(a), np.abs(b) + 0.1, z)), (elliptic.carlsonRJ, (np.abs(a), np.abs(b) + 0.1, z, np.abs(p) + 0.1))] + tup = lambda x: x if isinstance(x, tuple) else (x,) + for fn, args in cases: + outs = [np.asarray(o) for o in tup(fn(*args)) if o is not None] + assert all(o.shape == (2, 3) for o in outs) + for i in range(6): + sa = [(np.asarray(x).ravel()[i] if np.ndim(x) else x) for x in args] + so = [np.asarray(o) for o in tup(fn(*[complex(x) if np.iscomplexobj(x) else float(x) for x in sa])) if o is not None] + for o, s in zip(outs, so): + x, y = o.ravel()[i], s.item() + # 4e-15, not 4e-16: NumPy's SIMD sin/cos on Linux differ from the + # scalar libm path by an ulp, which a batch-vs-scalar comparison sees + assert x == y or (np.isnan(x) and np.isnan(y)) or abs(x - y) <= 4e-15 * max(1, abs(y)) + ca = [np.asarray(args[0]).ravel()] + [(np.asarray(x).ravel()[0] if np.ndim(x) else x) for x in args[1:]] + assert np.asarray(tup(fn(*ca))[0]).shape == (6,) + + +class TestEmptyNaNInf: + def test_cel_propagates_nan_kc(self): + """kc = NaN never became active in the Landen ascent (NaN == NaN is + False) and cel returned the untouched pi/2.""" + assert math.isnan(_s(elliptic.cel(np.nan, 1.0, 1.0, 1.0))) + v = np.asarray(elliptic.cel(np.array([0.3, np.nan, 0.7]), 1.0, 1.0, 1.0)) + assert np.isnan(v[1]) and not np.isnan(v[[0, 2]]).any() + + def test_empty_and_nan_isolation(self): + for fn in (lambda x: elliptic.ellipticBDJ(x, 0.5, 0.3)[0], lambda x: elliptic.cel(x, 1.0, 1.0, 1.0), lambda x: elliptic.weierstrassP(x, 1.5, -0.25, -1.25), + lambda x: elliptic.carlsonRF(x, 0.5, 1.0), lambda x: elliptic.nomeq(x), lambda x: elliptic.elliptic12i(x + 0.2j, 0.5)[0]): + assert np.asarray(fn(np.array([]))).size == 0 + v = np.asarray(fn(np.array([0.3, np.nan, 0.7]))) + assert np.isnan(v[1]) and not np.isnan(v[[0, 2]]).any() diff --git a/python/tests/test_numerical_precision.py b/python/tests/test_numerical_precision.py index 5abb56e..4d81063 100644 --- a/python/tests/test_numerical_precision.py +++ b/python/tests/test_numerical_precision.py @@ -304,10 +304,15 @@ def test_issue_jacobi_scalar_inputs(self): Eu, Du, _ = jacobiEDJ(0.5, 0.5) assert math.isfinite(_f(Eu)) and math.isfinite(_f(Du)) - def test_issue_inversenomeq_clear_error_above_qmax(self): - """inversenomeq formerly raised brentq's cryptic 'f(a) f(b) same sign'.""" + def test_issue_inversenomeq_rounds_to_one_above_qmax(self): + """Above q_max = 0.7789534 the true 1-m is below eps/2, so the correctly + rounded double is exactly 1.0 (formerly a ValueError; MATLAB returned + m > 1 from the unconverged series).""" + assert _f(inversenomeq(0.9)) == 1.0 and _f(inversenomeq(0.999)) == 1.0 + assert _f(inversenomeq(0.78)) == 1.0 + assert 0.0 < 1.0 - _f(inversenomeq(0.7)) < 1e-10 # true 1-m = 1.5e-11 with pytest.raises(ValueError, match="q must be"): - inversenomeq(0.9) + inversenomeq(1.0) def test_issue_inversenomeq_round_trip(self): """Round-trip nomeq → inversenomeq for valid q.""" diff --git a/python/tests/test_regression_followup.py b/python/tests/test_regression_followup.py new file mode 100644 index 0000000..862e70a --- /dev/null +++ b/python/tests/test_regression_followup.py @@ -0,0 +1,135 @@ +"""Regression coverage from the post-0d09740 deep audit.""" + +import builtins +import math + +import mpmath as mp +import numpy as np +import pytest +from scipy import special + +import elliptic + + +def _float(value): + return float(np.asarray(value)) + + +def test_m1_periods_do_not_hide_the_first_kind_pole(): + for phi in [2.0, math.pi, 4.0, 10.0]: + F, E, Z = elliptic.elliptic12(phi, 1.0) + turns = math.floor((abs(phi) + math.pi / 2.0) / math.pi) + expected_E = (-1.0) ** turns * math.sin(abs(phi)) + 2.0 * turns + assert math.isinf(_float(F)) + np.testing.assert_allclose(_float(E), expected_E, atol=1e-14) + + Fn, En, Zn = elliptic.elliptic12(-phi, 1.0) + assert math.isinf(_float(Fn)) and _float(Fn) < 0 + np.testing.assert_allclose(_float(En), -expected_E, atol=1e-14) + np.testing.assert_allclose(_float(Zn), -_float(Z), atol=1e-14) + + +def test_ellipticbd_preserves_small_parameter_limits(): + for m in [0.0, 1e-20, 1e-16, 1e-12, 1e-8]: + B, D, S = elliptic.ellipticBD(m) + np.testing.assert_allclose(_float(B), math.pi / 4.0, atol=2e-8) + np.testing.assert_allclose(_float(D), math.pi / 4.0, atol=2e-8) + np.testing.assert_allclose(_float(S), math.pi / 16.0, atol=2e-8) + + +def test_theta_exact_and_near_endpoint_parameters(): + v = 0.37 + assert _float(elliptic.theta(1, v, 0.0)) == 0.0 + assert _float(elliptic.theta(2, v, 0.0)) == 0.0 + assert _float(elliptic.theta(3, v, 0.0)) == 1.0 + assert _float(elliptic.theta(4, v, 0.0)) == 1.0 + + mp.mp.dps = 50 + for m in [1e-20, 1e-15, np.nextafter(1.0, 0.0)]: + mm = mp.mpf(float(m)) + q = mp.e ** (-mp.pi * mp.ellipk(1 - mm) / mp.ellipk(mm)) + for j in range(1, 5): + expected = float(mp.jtheta(j, v, q)) + np.testing.assert_allclose( + _float(elliptic.theta(j, v, m)), + expected, + rtol=2e-13, + atol=2e-15, + ) + + +def test_elliptic3_near_pole_uses_full_precision_carlson_form(): + phi = np.array([1.2, 1.5, math.pi / 2.0]) + m = np.array([0.8, 0.95, 0.9]) + n = np.array([0.99, 0.999, 0.9999]) + s = np.sin(phi) + c = np.cos(phi) + d2 = 1.0 - m * s**2 + p = 1.0 - n * s**2 + expected = ( + s * special.elliprf(c**2, d2, 1.0) + + n * s**3 / 3.0 * special.elliprj(c**2, d2, 1.0, p) + ) + np.testing.assert_allclose( + elliptic.elliptic3(phi, m, n), expected, rtol=2e-13, atol=2e-13 + ) + + +def test_negative_phase_crossing_third_kind_pole_is_rejected(): + with pytest.raises(ValueError, match="Cauchy principal-value"): + elliptic.elliptic3(-1.0, 0.5, 2.0) + + +def test_large_argument_ellipj_near_endpoint_parameter(): + mp.mp.dps = 70 + u = 1_000_000.123 + m = np.nextafter(1.0, 0.0) + sn, cn, dn, _ = elliptic.ellipj(u, m) + uu = mp.mpf(float(u)) + mm = mp.mpf(float(m)) + refs = [mp.ellipfun(name, uu, mm) for name in ("sn", "cn", "dn")] + for got, expected in zip((sn, cn, dn), refs): + np.testing.assert_allclose(_float(got), float(expected), rtol=2e-8, atol=2e-12) + + +def test_weierstrass_near_pole_is_finite_until_the_actual_lattice_point(): + values = elliptic.weierstrassP(np.array([0.0, 1e-11, 5e-11]), 1.0, 0.0, -1.0) + assert np.isinf(values[0]) + assert np.all(np.isfinite(values[1:])) + np.testing.assert_allclose(values[1:], np.array([1e22, 4e20]), rtol=2e-15) + + with pytest.raises(ValueError, match="real inputs only"): + elliptic.weierstrassP(0.2 + 0.1j, 1.0, 0.0, -1.0) + + +def test_public_runtime_does_not_import_scipy(monkeypatch): + real_import = builtins.__import__ + + def reject_scipy(name, *args, **kwargs): + if name == "scipy" or name.startswith("scipy."): + raise AssertionError(f"unexpected SciPy runtime import: {name}") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", reject_scipy) + + elliptic.theta(1, 0.2, 0.5) + elliptic.theta_prime(1, 0.2, 0.5) + elliptic.jacobiThetaEta(0.2, 0.5) + elliptic.nomeq(0.5) + elliptic.inversenomeq(0.04) + elliptic.inverselliptic2(0.5, 0.5) + elliptic.elliptic12i(0.7 + 0.2j, 0.5) + + +def test_arclength_ellipse_is_vectorized_and_validates_axes(): + arcs = elliptic.arclength_ellipse( + np.array([5.0, 10.0, 3.0]), + np.array([10.0, 5.0, 3.0]), + ) + np.testing.assert_allclose( + arcs, + np.array([48.44224110273839, 48.44224110273839, 6.0 * math.pi]), + rtol=2e-14, + ) + with pytest.raises(ValueError, match="strictly positive"): + elliptic.arclength_ellipse(0.0, 1.0)