Computing the Analyzing Power in AZURE2
This chapter is the companion to Polarization Observables in R-Matrix Theory. It describes what was actually built, how each step was tested, what went wrong, and how far the result can be trusted. Everything here is checkable against the source.
What the code already had, and the one thing it did not
Seyler’s Eq. (4) needs five ingredients. AZURE2 had four of them:
the collision matrix \(U\), from the A- or R-matrix inversion;
Coulomb and hard-sphere phases,
EPoint::GetExpCoulombPhaseand the hard-sphere phases stored per channel;the Coulomb amplitude \(C(\theta)\),
EPoint::GetCoulombAmplitude;Clebsch–Gordan and Racah coefficients,
AngCoeff::ClebGordandAngCoeff::Racah;and the whole code is written in the channel-spin representation already, so \(s\) and \(s'\) are available per channel rather than needing to be recoupled out of a \(jj\) scheme.
The missing ingredient was the angular functions. A search of src/ and
include/ turned up exactly one angular function, EPoint::GetLegendreP(L)
— no associated Legendre functions and no spherical harmonics anywhere. As
Polarization Observables in R-Matrix Theory explains, this is not an oversight but a direct
consequence of what the code had been asked to compute: unpolarized cross
sections need only \(\mu = 0\).
GSL was already a dependency and provides gsl_sf_legendre_sphPlm, the
normalized associated Legendre function, so the addition was small
(src/AngCoeff.cpp:37):
complex AngCoeff::SphericalHarmonic(int l, int m, double theta, double phi) {
if (l < 0 || std::abs(m) > l) return complex(0.0, 0.0);
const int am = std::abs(m);
const double norm = gsl_sf_legendre_sphPlm(l, am, std::cos(theta));
complex y = norm * complex(std::cos(am * phi), std::sin(am * phi));
if (m < 0) { y = std::conj(y); if (am % 2) y = -y; }
return y;
}
The negative-\(m\) branch applies
\(Y_l^{-m} = (-1)^m \left(Y_l^{m}\right)^{*}\). GSL’s sphPlm already
carries the Condon–Shortley phase, so it is not applied again here — applying it
twice is a mistake that leaves \(|A_y|\) untouched and flips its sign, which
is precisely the class of error that internal checks cannot catch.
The step that made the rest easy
The most useful thing found during this work was not written; it was already
there. Look at how AMatrixFunc forms its T-matrix element
(src/AMatrixFunc.cpp:408):
complex uphase = coulombPhaseEn * hardSpherePhaseEn
* coulombPhaseEx * hardSpherePhaseEx;
complex umatrix = 2.0 * complex(0.0,1.0) * sqrtPenEn * sqrtPenEx
* this->GetUBilinear(jNum, chNum, chpNum);
complex tphase = coulombPhaseEn * coulombPhaseEn;
if (chNum == chpNum) tmatrix = tphase - uphase * (1.0 + umatrix);
else tmatrix = -uphase * umatrix;
Since \(U = \texttt{uphase}\,(1 + \texttt{umatrix})\) and tphase is
\(e^{2i\omega_l}\), this is
which is the bracket of Eq. (1) with its exponential already applied. No phase has to be reconstructed, no convention has to be guessed, and — most importantly — the polarization code is guaranteed to see the same \(U\) that the cross-section code sees, including every boundary-condition and Brune-parametrization subtlety. Had this not been the case the sensible approach would have been to rebuild \(U\) from scratch, which would have doubled the surface area for disagreement.
The amplitude matrix
Polarization::AmplitudeMatrix (include/PolarizationFunc.h) holds one
complex number per \((s,\nu,s',\nu')\) and is filled by accumulating one
reaction pathway at a time. The constructor enumerates the channel spins
available in the entrance and exit pairs, from \(|j_1 - j_2|\) to
\(j_1 + j_2\), and allocates a slot for every projection pair.
AddPathway is Eq. (1) transcribed:
for (double v = -s; v <= s + 1.e-6; v += 1.0) {
const double cg1 = AngCoeff::ClebGord(s, (double)l, jValue, v, 0.0, v);
if (std::fabs(cg1) < 1.e-12) continue;
for (double vp = -sp; vp <= sp + 1.e-6; vp += 1.0) {
const double mu = v - vp;
if (std::fabs(mu) > lp + 1.e-6) continue;
const double cg2 = AngCoeff::ClebGord(sp, (double)lp, jValue, vp, mu, v);
if (std::fabs(cg2) < 1.e-12) continue;
const complex y = AngCoeff::SphericalHarmonic(lp, (int)std::lround(mu), theta_);
At(s, v, sp, vp) += complex(0.0,1.0) * std::sqrt(2.0*l+1.0)
* cg1 * cg2 * tMatrixElement * y;
}
}
The loops run over half-integers by stepping in units of one from \(-s\), with a tolerance on the upper bound, and comparisons of spins use a tolerance rather than equality — a spin of \(1/2\) is exactly representable but \(\nu - \nu'\) accumulated in floating point is not reliably so.
AddCoulomb adds \(-C(\theta)\) on the diagonal in
\((s,\nu)\), and only when the entrance and exit pairs coincide.
AnalyzingPowerAy evaluates Eq. (2) directly.
Validating it, in the order the checks were applied
The strategy was to test the amplitude matrix against something already trusted before asking it for a new observable, so that a disagreement could only come from the new code.
Gate 1 — the unpolarized cross section, checked as angle independence.
\(M\) must reproduce what GenMatrixFunc::CalculateCrossSection produces
by the Blatt–Biedenharn route. The two differ by kinematic factors that are
tedious to match exactly, so the test used was sharper and needed no bookkeeping:
at fixed energy, the ratio of the two must be constant in angle. Any error in
the coupling order, or in the choice of \(l\) against \(l'\) inside the
spherical harmonic, gives an angle-dependent ratio. The ratio came out constant
to between \(10^{-9}\) and \(10^{-11}\), which is the level of the
accumulated floating-point noise.
This gate is the one that matters. It tests the Clebsch–Gordan ordering, the spherical-harmonic indices, the phase convention and the pathway enumeration all at once, against a code path that has been in production for years.
Gate 2 — the bounds and the zeros. \(|A_y| \le 1\) everywhere; \(A_y \to 0\) at \(\theta \to 0\); \(A_y \to 0\) far below the resonances where scattering is pure Coulomb.
A false negative was encountered here and is worth recording. On first inspection \(A_y\) appeared to be identically zero, and the natural conclusion was that the spin-flip amplitudes were not being built. In fact only low-energy points had been examined, where \(A_y = 0\) is the correct answer. Scanning the full energy range showed the expected structure. A test whose passing value and whose failure value are both zero proves nothing.
Gate 3 — the sign, against data. As Polarization Observables in R-Matrix Theory explains, no
internal check can fix the overall sign. Baumann et al. [Baumann1992] mark
four points in their fig. 4 where \(|A_y|\) reaches unity. Scanning the
tests/13N model over the same window reproduces all four, including the
pattern of three positive extrema and one negative:
Baumann fig. 4 |
computed |
\(\Delta E\) |
|---|---|---|
1.665 MeV, 97°, + |
1.670, 100°, +0.998 |
5 keV |
1.725 MeV, 120°, + |
1.740, 125°, +0.998 |
15 keV |
1.750 MeV, 80°, − |
1.750, 80°, −0.992 |
0 |
1.770 MeV, 145°, + |
1.775, 145°, +0.998 |
5 keV |
The agreement in position is within the precision of reading crosses off a contour plot. The agreement in sign pattern is what fixes the convention.
That this works at all is a non-trivial statement about the model rather than
just the code: the tests/13N evaluation carries a \(3/2^-\) level at
\(E_x = 3.503\) MeV and a \(5/2^+\) at 3.545 MeV, fitted to cross-section
and capture data with no knowledge of any polarization measurement, and
Baumann’s table 1 gives 3.499 and 3.546 MeV from their phase-shift analysis of
\(A_y\). Two independent routes to the same two states.
Making it an observable
Computing \(A_y\) at a point is one thing; letting a user ask for it is another. The observable code 7 was added to the segment machinery, and two defects surfaced that are worth describing because both were silent.
A valid extrapolation line was rejected outright. ExtrapLine reads its
ten fields and then calls getline to look for optional advanced-segment
data. For a plain segment those ten fields are the whole line, so the stream is
already at end-of-file and getline sets failbit. EData::MakePoints
tests rdstate() and returns \(-1\) on it — which aborts the parse of
<segmentsTest> entirely. One plain segment therefore discarded every test
segment in the file, with no message. It had gone unnoticed because the GUI
always writes the trailing field; only a hand-written or generated .azr
trips it. The fix is to read only when something is left.
Target-effect sub-points never learned what they were. A segment carrying
target integration is not evaluated at its own points. EPoint::Calculate
builds sub-points spanning the target thickness, evaluates those, and combines
them into a yield. Sub-points are copied from the parent before the observable
was stamped on it, so they never became analyzing-power points and the
\(A_y\) branch never ran for them. The symptom was that of three declared
\(A_y\) segments, exactly one — the one that happened to have no target
effect — returned an analyzing power, and the other two returned cross sections.
Diagnosing this took longer than it should have because two plausible
explanations had to be eliminated first: the stored points were verified to
carry the flag correctly after MakePoints, and pointer identity showed that
the points being calculated were different objects entirely, with a null parent
EData. Only then did the sub-point mechanism become the obvious candidate.
AddSubPoint now propagates the observable (src/EPoint.cpp:1577).
Averaging a ratio over a target
Fixing the flag exposed the physics point of Eq. (3): the existing integrator computes a yield, and running it on \(A_y\) as though \(A_y\) were a cross section is meaningless.
EPoint::IntegrateTargetEffectForObservable (src/EPoint.cpp:1498) runs the
existing yield integrator twice — once on \(\sigma\), once on the product
\(\sigma A_y\) — and divides:
for (int i=1;i<=n;i++) sigma[i-1] = GetSubPoint(i)->GetFitCrossSection();
IntegrateTargetEffect(configure);
const double denominator = GetFitCrossSection();
for (int i=1;i<=n;i++)
GetSubPoint(i)->SetFitCrossSection(sigma[i-1] *
GetSubPoint(i)->GetAnalyzingPower());
IntegrateTargetEffect(configure);
const double numerator = GetFitCrossSection();
for (int i=1;i<=n;i++) GetSubPoint(i)->SetFitCrossSection(sigma[i-1]);
SetFitCrossSection(std::fabs(denominator) > 0.0 ? numerator/denominator : 0.0);
Reusing the integrator rather than writing a second one is deliberate: the quadrature, the straggling model and the energy-loss treatment are then identical to the cross-section path by construction, and there is only one of them to maintain. The cost is one extra integration per analyzing-power point, which is negligible against the R-matrix evaluation itself.
This also required \(A_y\) to be carried beside the cross section rather than replacing it, since \(\sigma\) is the weight. Only a non-sub-point substitutes \(A_y\) into the reported value, which keeps output files, \(\chi^2\) and plotting free of special cases.
The dilution this produces is dramatic and is a genuine prediction, not a bug.
For the thick gas target in tests/13N the sub-point grid runs from 0.42 to
1.54 MeV; Rutherford scattering makes \(\sigma\) diverge at the low end where
\(A_y \approx 0\), and a resonant \(A_y \approx 0.8\) averages down to
about \(10^{-6}\). Comparisons against thin-target data must use segments
with no target integration.
Warning
<targetInt> assigns effects to segments by index, and
<segmentsTest> shares that numbering with <segmentsData>. Test
segments 1 and 2 therefore silently inherit whatever the data segments 1 and 2
declare. This is not specific to analyzing powers, but it is much more visible
here, because the wrong answer is a factor of \(10^{6}\) rather than a few
percent.
Getting data to test against
Baumann et al. publish no table of \(A_y\). Their results are contour
plots, plus six angular distributions in fig. 1 at
\(E_p = 1.618, 1.658, 1.708, 1.738, 1.758\) and 1.779 MeV. Those six panels
are the only numerically recoverable data in the paper, so they were digitised:
the six files under tests/13N/data/ hold 178 points in all, 24 to 38 per
energy, spanning 1 to 177 degrees. Angles are centre-of-mass, as printed on the
figure axis, which is what observable 7 expects; energies are laboratory.
Two properties of that data matter when reading any fit against it, and are
repeated in tests/13N/README.md so they cannot be lost.
They are samples of the drawn curve, not the measured points. Baumann measured at ten angles between 40 and 160 degrees; the files span 1 to 177 degrees with up to 38 points per energy. What is sampled is the curve of the paper’s phase-shift analysis, which is smooth and defined over the whole angular range. Neighbouring points are therefore strongly correlated, and there are many more of them than there were measurements – so a \(\chi^2\) computed from them counts the same information repeatedly.
The uncertainties are relative, which is the wrong model here. They are 10% of the value with its sign, so 50 of the 178 are negative and the smallest is \(1.6\times10^{-5}\). A point where \(A_y\) passes through zero does not have a vanishing uncertainty. Those near-zero errors dominate everything else. Until they are re-quoted as a roughly constant absolute value, of order the digitisation precision, a fit against this set is not describing the data within its stated errors.
Fitting it
The analyzing-power data live in tests/13N as segments 11–16, one per
energy, beside the capture and scattering data of the same compound nucleus.
Fitting \(A_y\) alone – every level fixed except the excitation energy
and proton width of the two resonances the energy range is sensitive to –
finds both of them (tests/13N/README.md gives the recipe):
parameter |
fitted |
Baumann table 1 |
start |
|---|---|---|---|
\(3/2^-\) \(E_x\) |
3.5079 MeV |
3.499 MeV |
3.5032 MeV |
\(3/2^-\) \(\Gamma_p\) |
41.9 keV |
57 keV |
55.2 keV |
\(5/2^+\) \(E_x\) |
3.5444 MeV |
3.546 MeV |
3.5453 MeV |
\(5/2^+\) \(\Gamma_p\) |
45.8 keV |
50 keV |
49.0 keV |
The excitation energies come out 9 keV and 2 keV from the published values, which is the result worth having: an R-matrix model fitted to \(A_y\) alone lands on the same two states, from the correct side, knowing nothing about the cross section.
The widths do not, and the reason is the uncertainty model rather than the physics. \(\chi^2\) per point is 40.6 – the fit is not describing the data within its stated errors, because the points near \(A_y = 0\) carry uncertainties of order \(10^{-5}\) and outweigh everything else, pulling both widths low.
Differentiating it
The analyzing power has an exact adjoint, which matters because without one a
fit either falls back to numerical derivatives or – worse – uses the wrong
ones. Before this was written, an analyzing-power point fell through
AMatrixFunc::PointAdjoint into the differential-cross-section branch, since
\(A_y\) segments are flagged differential. It silently returned
\(\partial\sigma/\partial p\) where \(\partial A_y/\partial p\) was
wanted.
One structural fact makes the derivative easy. AddPathway is called in a
loop over exactly the (k, m) K-group and M-group indices that
PointAdjoint uses for its T-matrix cotangents tBar, so the forward and
reverse passes are the same loop and no index mapping is needed. And since
\(M\) is linear in \(T\), the coefficient that AddPathway
multiplies \(T\) by is the derivative:
The Coulomb amplitude depends only on energy, so it drops out.
With \(u_i = M_{s'\nu',+1/2}\) and \(d_i = M_{s'\nu',-1/2}\), and \(A_y = N/D\) as in (2), the Wirtinger derivatives are
so that
AmplitudeMatrix::AnalyzingPowerBar returns twice these, matching the
cotangent convention the rest of AMatrixFunc uses – it finally takes
Re(conj(bar) * dz/dp) with no further factor, so the bar must carry the
\(2\) that a real function of a complex variable requires.
PathwayAdjoint then walks the AddPathway loop and contracts. Everything
downstream – T to \(U\), \(U\) to the level matrix, level matrix to
\(E_\lambda\) and \(\gamma\) – is the machinery that already existed.
Verified against central differences on all 71 analyzing-power rows of
tests/13N and all 14 free parameters. Agreement is at \(10^{-9}\) to
\(10^{-10}\) for twelve of them. The two level-energy parameters, whose
derivatives are the largest, show \(4\times10^{-6}\) at a step of
\(10^{-5}\) – and that residue falls as \(h^2\)
(\(1.5\times10^{-5}\), \(1.5\times10^{-7}\), \(1.7\times10^{-9}\)
for \(h = 10^{-4}, 10^{-5}, 10^{-6}\)), which is finite-difference
truncation rather than an error in the adjoint. The capture-channel widths give
identically zero, correctly: they cannot affect elastic scattering.
A point whose \(A_y\) is averaged over a target is deliberately not supported. That quantity is a ratio of two integrals (Eq. (3)), so its derivative needs the quotient rule across sub-points and both integrals differentiated. Rather than approximate it, such a point reports itself unsupported, which makes the whole Jacobian unavailable and returns the fit to numerical derivatives.
Using it
In the GUI, choose Analyzing Power from the Data Type menu when adding a data or test segment. It behaves like a centre-of-mass differential segment: the angle fields are enabled, Vary Norm? is disabled because a normalization factor means nothing for a ratio, and the Plot tab switches the y-axis to linear and to Cross Section when such a segment is drawn, since a logarithmic axis cannot show a quantity that goes negative.
By hand, the observable is code 7. Angles are centre-of-mass and energies laboratory, as for any differential segment:
<segmentsData>
1 1 1 1.5 1.9 40 160 7 1 0 5 0 0.005 0 data/baumann_ay.dat 0 0
</segmentsData>
The data file carries E_lab theta_cm A_y dA_y. From Python:
from pyazr import azure2
with azure2("13N.azr") as azr:
ay = azr.calculate_analyzing_power_rwa(azr.params_rwa)
Because \(A_y\) is reported in place of the cross section, everything downstream — \(\chi^2\), output files, plotting, fitting, MCMC — works without modification.
Two things to keep in mind. A normalization factor is meaningless for a ratio, so
leave varyNorm at 0. And use segments without target integration when
comparing against thin-target data, for the reason given above.
What is not done
Tensor observables need a spin-1 projectile and rank-2 operators. The amplitude matrix carries all channel spins and projections already, so the missing part is the observable side, but it is not written.
Capture channels need Seyler and Weller rather than Seyler.
Analytic derivatives through a target integration. \(A_y\) itself now has an exact adjoint (see below), but a point whose \(A_y\) is averaged over a target is a ratio of two integrals and is not differentiated analytically. Such a point returns unsupported, which makes the whole Jacobian unavailable and falls the fit back to numerical derivatives – coarse, but never wrong.