Skip to content

Integrate the charge-diffusion correction by Gauss-Legendre quadrature - #217

Open
roytsmart wants to merge 2 commits into
mainfrom
feature/vmr-quadrature
Open

Integrate the charge-diffusion correction by Gauss-Legendre quadrature#217
roytsmart wants to merge 2 commits into
mainfrom
feature/vmr-quadrature

Conversation

@roytsmart

Copy link
Copy Markdown
Collaborator

The charge-diffusion average in vmr_signal was evaluated with a 1001-node midpoint rule. This replaces it with 64 Gauss-Legendre nodes in a better variable: three orders of magnitude more accurate with fifteen times fewer evaluations.

Why there is no closed form

The obstruction is the coincidence probability $\mathcal{P}$, which contains $\operatorname{erf}(1/2\sigma)$ with $\sigma \propto \sqrt{z_f - z}$. Squaring it to cover the two axes gives a product of two error functions integrated against the exponential absorption profile. Substituting $x = 1/2\sigma$ reduces everything to three families, and sympy's verdict is:

integrand result
$x^{-3}e^{c/x^2}$, $x^{-5}e^{c/x^2}$ closed form
$x^{-3}e^{-x^2}e^{c/x^2}$ NonElementaryIntegral
$\operatorname{erf}(x),x^{-3}e^{c/x^2}$ unevaluated
$\operatorname{erf}(x)^2x^{-3}e^{c/x^2}$ unevaluated
control: $\operatorname{erf}(x)^2e^{-x^2}$ closed form, $\sqrt{\pi}\operatorname{erf}(x)^3/6$

The control row matters: sympy handles $\operatorname{erf}^2$ integrals fine in general, so the obstruction is specifically its combination with the reciprocal-argument exponential. Integrating over the separation instead yields an incomplete Bessel integral, which is no better and is not in scipy.

Why the old scheme was slow to converge

$\sigma(z) \propto \sqrt{z_f - z}$ puts a square-root branch point at the edge of the field-free region, so the midpoint rule converges only algebraically. Over 1–10000 Å for the e2v CCD97 it reached only 2.2e-4 with 1001 nodes.

The substitution

Writing the average over the cumulative absorption probability $s$ keeps nodes where photons are actually absorbed — necessary, since $\alpha z_f$ ranges over 0.02 to 1271 across that wavelength range for one sensor. Then $s = 1-r^2$ removes the branch point, since the diffusion width becomes linear in $r$ there. In $r$,

$$\alpha z = -\log!\big(e^{-\alpha z_f} + (1 - e^{-\alpha z_f}),r^2\big)$$

evaluated in this form rather than through $s$ so it stays finite once $e^{-\alpha z_f}$ underflows, which happens beyond optical depth ~745 — well inside the range a real sensor spans. The interval is split where the implant ends and $\eta$ saturates, and Gauss-Legendre is applied to each part.

Accuracy

Against a 400-node reference, over 1–10000 Å:

nodes/segment total max rel. error
8 16 4.1e-05
16 32 2.8e-06
32 64 1.6e-07
48 96 2.7e-08

versus 2.2e-04 from the previous 1001 midpoints. Convergence was also checked directly against optical depths from 0.02 to 3000, so the node count is not tuned to a single sensor.

A rejected alternative

Substituting $t = \sqrt{1-z/z_f}$ in depth is spectrally accurate at low optical depth (1e-13 with 48 nodes on a toy case) but needs nodes scaling as $\sqrt{\alpha z_f}$, and errs by 9e-3 with 16 nodes at $\alpha z_f = 500$ — while real sensors reach 1271. The CDF-based version is uniformly robust because it adapts to the exponential.

Checks

199 tests pass, including the existing Monte Carlo comparison. New test_vmr_signal_quadrature asserts the default node count is converged to 1e-5 against an 8× denser rule across the full wavelength range. The no-diffusion path is bit-identical. black, ruff, and the docs build are clean, and the new Notes text was verified to parse into the doctree with no stray markup.

🤖 Generated with Claude Code

https://claude.ai/code/session_0192abyvBf2Zw5rq5CQ62JFb

The average in the charge-diffusion term of `vmr_signal` has no closed
form. The obstruction is the coincidence probability, which contains
erf(1 / 2 sigma) with sigma proportional to sqrt(z_f - z); squaring it to
cover the two axes of the sensor gives a product of two error functions
integrated against the exponential absorption profile. Sympy returns
NonElementaryIntegral for the underlying antiderivative, while happily
evaluating the same integrand without the reciprocal-argument
exponential, so the obstruction is that combination specifically. Routing
the calculation through the separation instead gives an incomplete Bessel
integral, which is no better and is not in scipy.

It was previously evaluated with a 1001-node midpoint rule in the
cumulative absorption probability. That converges only algebraically,
because sigma(z) puts a square-root branch point at the edge of the
field-free region, and it reached only 2.2e-4 across 1 to 10000
angstroms for the e2v CCD97.

Substituting s = 1 - r^2 removes that branch point while keeping the
nodes distributed by where photons are actually absorbed, which matters
because the optical depth of the field-free region spans 0.02 to 1271
over that wavelength range for the same sensor. The interval is split
where the implant ends and the differential CCE saturates, and
Gauss-Legendre is applied to each part. The optical depth is evaluated as
-log(exp(-a z_f) + (1 - exp(-a z_f)) r^2) rather than through s, so it
stays finite once exp(-a z_f) underflows, which it does beyond an optical
depth of about 745.

With 32 nodes per part the worst error over the same wavelength range is
1.6e-7, against 2.2e-4 from 1001 midpoints: three orders of magnitude
more accurate with fifteen times fewer evaluations. Convergence was
checked separately against optical depths from 0.02 to 3000 to confirm
the node count is not tuned to one sensor.

A plain sqrt substitution in depth was tried first and rejected: it is
spectrally accurate at low optical depth but needs nodes scaling as
sqrt(alpha z_f), and fails at 9e-3 with 16 nodes where a real sensor
reaches 1271.

Results are unchanged to within the quadrature error, and the
no-diffusion path is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0192abyvBf2Zw5rq5CQ62JFb
@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (472fcd1) to head (d3bace9).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #217   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          121       121           
  Lines         7424      7445   +21     
=========================================
+ Hits          7424      7445   +21     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

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

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

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

The split exists only because the differential CCE of Stern 1994 is
piecewise linear and therefore kinks where the implant ends. A smooth
profile would need no split and half as many nodes, so the split reads
like an accident worth optimizing away. It is not: the piecewise-linear
form is kept deliberately, so the fitted implant thickness and
back-surface CCE stay comparable with the values published by Stern and
by Boerner et al. Note that no choice of CCE would produce a closed form
anyway, since setting eta = 1 still leaves the erf-squared obstruction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0192abyvBf2Zw5rq5CQ62JFb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant