syncem 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- syncem/__init__.py +98 -0
- syncem/_expm.py +125 -0
- syncem/bessel.py +268 -0
- syncem/constants.py +23 -0
- syncem/conversion.py +50 -0
- syncem/cumulants.py +132 -0
- syncem/derivatives.py +57 -0
- syncem/expansion.py +321 -0
- syncem/faraday.py +214 -0
- syncem/kirchhoff.py +336 -0
- syncem/los_moments.py +132 -0
- syncem/magnus.py +57 -0
- syncem/model/__init__.py +174 -0
- syncem/model/_allowances.py +87 -0
- syncem/model/_angular_faint_bounds.py +284 -0
- syncem/model/_angular_global_bounds.py +457 -0
- syncem/model/_basis_checks.py +195 -0
- syncem/model/_basis_core.py +347 -0
- syncem/model/_basis_taylor.py +221 -0
- syncem/model/_bessel_recurrence.py +200 -0
- syncem/model/_bound_helpers.py +44 -0
- syncem/model/_budget_terms.py +387 -0
- syncem/model/_certified_angular_i00.py +484 -0
- syncem/model/_certified_dense_harmonic.py +344 -0
- syncem/model/_certified_high_order_basis.py +540 -0
- syncem/model/_certified_sparse_harmonic.py +457 -0
- syncem/model/_channel_shapes.py +94 -0
- syncem/model/_continuum.py +296 -0
- syncem/model/_depth_bound.py +103 -0
- syncem/model/_direct.py +322 -0
- syncem/model/_factor.py +412 -0
- syncem/model/_factorisation_bounds.py +106 -0
- syncem/model/_harmonic_cells.py +306 -0
- syncem/model/_harmonic_ranges.py +140 -0
- syncem/model/_harmonic_tail.py +112 -0
- syncem/model/_harmonic_taylor.py +409 -0
- syncem/model/_interval_angular_geometry.py +208 -0
- syncem/model/_kernel_helpers.py +208 -0
- syncem/model/_polynomial.py +139 -0
- syncem/model/_population.py +291 -0
- syncem/model/_prediction.py +125 -0
- syncem/model/_project.py +399 -0
- syncem/model/_provenance.py +213 -0
- syncem/model/_refine.py +99 -0
- syncem/model/_registry.py +388 -0
- syncem/model/_remainder_probe.py +396 -0
- syncem/model/_remainder_route.py +207 -0
- syncem/model/_ridge_interval_cells.py +226 -0
- syncem/model/_support_check.py +130 -0
- syncem/model/_verified_airy.py +265 -0
- syncem/model/_verified_bessel.py +174 -0
- syncem/model/adapters.py +277 -0
- syncem/model/assumptions.py +436 -0
- syncem/model/basis.py +438 -0
- syncem/model/bounds.py +395 -0
- syncem/model/channels.py +435 -0
- syncem/model/errors.py +287 -0
- syncem/model/fit/__init__.py +68 -0
- syncem/model/fit/_bfgs.py +114 -0
- syncem/model/fit/_combination_core.py +344 -0
- syncem/model/fit/_combination_errors.py +165 -0
- syncem/model/fit/_combination_json.py +216 -0
- syncem/model/fit/_discrepancy.py +45 -0
- syncem/model/fit/_feasibility.py +245 -0
- syncem/model/fit/_feasibility_rules.py +173 -0
- syncem/model/fit/_identifiability.py +118 -0
- syncem/model/fit/_linear_core.py +169 -0
- syncem/model/fit/_nodal.py +185 -0
- syncem/model/fit/_reduction_checks.py +136 -0
- syncem/model/fit/_relations.py +326 -0
- syncem/model/fit/_result_build.py +304 -0
- syncem/model/fit/_result_core.py +293 -0
- syncem/model/fit/_transform.py +204 -0
- syncem/model/fit/combination_result.py +369 -0
- syncem/model/fit/combinations.py +332 -0
- syncem/model/fit/diagnostics.py +277 -0
- syncem/model/fit/layout.py +278 -0
- syncem/model/fit/linear.py +304 -0
- syncem/model/fit/nonlinear.py +391 -0
- syncem/model/fit/observation.py +367 -0
- syncem/model/fit/reduction.py +334 -0
- syncem/model/fit/result.py +215 -0
- syncem/model/harmonic.py +695 -0
- syncem/model/high_order_basis.py +440 -0
- syncem/model/high_order_harmonic.py +948 -0
- syncem/model/index.py +388 -0
- syncem/model/kernels.py +153 -0
- syncem/model/moments.py +363 -0
- syncem/model/phase.py +378 -0
- syncem/model/predict.py +300 -0
- syncem/model/screens.py +172 -0
- syncem/rm.py +400 -0
- syncem/sed.py +183 -0
- syncem/solutions.py +56 -0
- syncem/stokes.py +102 -0
- syncem/transfer.py +399 -0
- syncem/ultrarel.py +48 -0
- syncem-0.6.0.dist-info/METADATA +1263 -0
- syncem-0.6.0.dist-info/RECORD +102 -0
- syncem-0.6.0.dist-info/WHEEL +5 -0
- syncem-0.6.0.dist-info/licenses/LICENSE +21 -0
- syncem-0.6.0.dist-info/top_level.txt +1 -0
syncem/__init__.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""syncem: moment expansion of synchrotron emission (JAX + Equinox).
|
|
2
|
+
|
|
3
|
+
Note: importing this package enables ``jax_enable_x64`` (float64) as a global
|
|
4
|
+
side effect, because the Bessel quadrature and the likelihood-style sums need
|
|
5
|
+
float64 for accuracy. Do this once at the top of any driver script, before any
|
|
6
|
+
array is created.
|
|
7
|
+
|
|
8
|
+
Modules
|
|
9
|
+
-------
|
|
10
|
+
bessel : differentiable J_n, J_n', K_nu (integral representations)
|
|
11
|
+
stokes : exact Schott harmonic Stokes (I, Q, V) with correct
|
|
12
|
+
normalisation
|
|
13
|
+
derivatives : derivative spectra dS/dp, d^2S/dp^2 via autodiff
|
|
14
|
+
expansion : eqx.Module for quadratic Taylor averaging (mean/covariance)
|
|
15
|
+
cumulants : finite Bell moment contractions from supplied cumulants
|
|
16
|
+
ultrarel : ultra-relativistic F(x), G(x) fast path
|
|
17
|
+
rm : rotation measure (RM) constants + Burn depolarisation
|
|
18
|
+
faraday : joint emission-depth averages and finite responses
|
|
19
|
+
transfer : Mueller matrix + numerical slab/LOS matrix exponentials
|
|
20
|
+
solutions : analytic limiting solutions (thin/rotation/self-absorbed)
|
|
21
|
+
kirchhoff : emissivity/absorption moment expansion + Kirchhoff closure
|
|
22
|
+
conversion : cold-plasma Faraday rotation/conversion coefficients + moments
|
|
23
|
+
magnus : Magnus expansion (Omega1, Omega2) of the LOS transfer
|
|
24
|
+
los_moments : explicit reduced-units and physical-CGS moment-driven slabs
|
|
25
|
+
sed : frequency-domain SED (spectral index, curvature, abs. emissivity)
|
|
26
|
+
model : finite joint response of channel Stokes spectra and spectral
|
|
27
|
+
fits (subpackage: index, errors, channels, phase, moments,
|
|
28
|
+
bounds, assumptions, kernels, harmonic,
|
|
29
|
+
high_order_harmonic, screens, basis, high_order_basis,
|
|
30
|
+
predict, adapters, fit.observation, fit.linear,
|
|
31
|
+
fit.nonlinear, fit.diagnostics, fit.result, fit.layout,
|
|
32
|
+
fit.reduction, fit.combinations, fit.combination_result);
|
|
33
|
+
import its modules by full path
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
__version__ = "0.6.0"
|
|
39
|
+
|
|
40
|
+
import jax
|
|
41
|
+
|
|
42
|
+
# Enable float64 for accuracy of Bessel quadrature and likelihood-style sums.
|
|
43
|
+
jax.config.update("jax_enable_x64", True)
|
|
44
|
+
|
|
45
|
+
from . import ( # noqa: E402
|
|
46
|
+
bessel,
|
|
47
|
+
conversion,
|
|
48
|
+
cumulants,
|
|
49
|
+
derivatives,
|
|
50
|
+
kirchhoff,
|
|
51
|
+
los_moments,
|
|
52
|
+
magnus,
|
|
53
|
+
expansion,
|
|
54
|
+
rm,
|
|
55
|
+
faraday,
|
|
56
|
+
sed,
|
|
57
|
+
solutions,
|
|
58
|
+
stokes,
|
|
59
|
+
transfer,
|
|
60
|
+
ultrarel,
|
|
61
|
+
) # noqa: E402,F401
|
|
62
|
+
from .stokes import larmor_power, stokes_harmonic # noqa: E402,F401
|
|
63
|
+
from .derivatives import derivative_spectra # noqa: E402,F401
|
|
64
|
+
from .expansion import ( # noqa: E402,F401
|
|
65
|
+
CumulantExpansion,
|
|
66
|
+
QuadraticTaylorExpansion,
|
|
67
|
+
build_expansion,
|
|
68
|
+
mixed_moments,
|
|
69
|
+
)
|
|
70
|
+
from .ultrarel import F, G # noqa: E402,F401
|
|
71
|
+
|
|
72
|
+
__all__ = [
|
|
73
|
+
"__version__",
|
|
74
|
+
"bessel",
|
|
75
|
+
"stokes",
|
|
76
|
+
"derivatives",
|
|
77
|
+
"expansion",
|
|
78
|
+
"cumulants",
|
|
79
|
+
"ultrarel",
|
|
80
|
+
"rm",
|
|
81
|
+
"faraday",
|
|
82
|
+
"transfer",
|
|
83
|
+
"solutions",
|
|
84
|
+
"kirchhoff",
|
|
85
|
+
"conversion",
|
|
86
|
+
"magnus",
|
|
87
|
+
"los_moments",
|
|
88
|
+
"sed",
|
|
89
|
+
"stokes_harmonic",
|
|
90
|
+
"larmor_power",
|
|
91
|
+
"derivative_spectra",
|
|
92
|
+
"CumulantExpansion",
|
|
93
|
+
"QuadraticTaylorExpansion",
|
|
94
|
+
"build_expansion",
|
|
95
|
+
"mixed_moments",
|
|
96
|
+
"F",
|
|
97
|
+
"G",
|
|
98
|
+
]
|
syncem/_expm.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Matrix exponential whose branches survive ``vmap`` under reverse mode.
|
|
2
|
+
|
|
3
|
+
``jax.scipy.linalg.expm`` picks its Pade degree with ``lax.switch`` and each
|
|
4
|
+
squaring with ``lax.cond``, on values of the operand. Under ``vmap`` with a
|
|
5
|
+
batched operand JAX turns both into selects that wrap every operand in
|
|
6
|
+
``stop_gradient``. When the exponential is differentiated inside a
|
|
7
|
+
``custom_jvp`` rule (``transfer._tangent``), those operands include the
|
|
8
|
+
rule's tangents, and ``stop_gradient`` of a tangent cannot be transposed, so
|
|
9
|
+
``grad`` of a sum over ``vmap`` in ``K`` would raise ``NotImplementedError``.
|
|
10
|
+
Here the degree is fixed (Pade 13) and a squaring runs while any member of the
|
|
11
|
+
batch needs it, selected per member.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from functools import partial
|
|
17
|
+
|
|
18
|
+
import jax
|
|
19
|
+
import jax.numpy as jnp
|
|
20
|
+
|
|
21
|
+
# Higham (2005) Pade-13 coefficients b_0..b_13 and theta_13 (float64). The
|
|
22
|
+
# approximant P/Q is unchanged by a common factor, so the coefficients are held
|
|
23
|
+
# at the exact scale 2^-40: with b_0 = 6.5e16, V - U ~ b_0 I and the transpose
|
|
24
|
+
# of the solve divided cotangents by b_0, which flushed float32 reverse-mode
|
|
25
|
+
# derivatives for small sources (tests/test_transfer_scale_and_budget.py).
|
|
26
|
+
# Dividing by b_0 fully overflows rev-over-fwd Hessians near 1e306.
|
|
27
|
+
_PADE13 = tuple(
|
|
28
|
+
b * 2.0**-40
|
|
29
|
+
for b in (64764752532480000.0, 32382376266240000.0, 7771770303897600.0,
|
|
30
|
+
1187353796428800.0, 129060195264000.0, 10559470521600.0,
|
|
31
|
+
670442572800.0, 33522128640.0, 1323241920.0, 40840800.0,
|
|
32
|
+
960960.0, 16380.0, 182.0, 1.0)
|
|
33
|
+
) # fmt: skip
|
|
34
|
+
_THETA13 = 5.371920351148152
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@partial(jax.jit, static_argnames="max_squarings")
|
|
38
|
+
def expm_pade13(A, max_squarings):
|
|
39
|
+
"""``exp(A)`` by Pade 13 on ``A 2^-n``, ``n = max(0, ceil(log2(|A|_1 / theta_13)))``
|
|
40
|
+
(Higham 2005: backward error below the unit roundoff).
|
|
41
|
+
|
|
42
|
+
``jax.scipy.linalg.expm`` takes ``floor`` and so evaluates Pade 13 up to
|
|
43
|
+
``2 theta_13``; for a rotation-dominated ``A`` just below ``2^(m+1) theta_13``
|
|
44
|
+
that costs relative errors from 1e-10 (m = 0) to 1e-1 (m = 31) where
|
|
45
|
+
``ceil`` stays at the conditioning limit (``tests/test_transfer_expm_boundaries.py``).
|
|
46
|
+
Where ``ceil`` exceeds ``max_squarings`` but ``floor`` does not, ``floor``
|
|
47
|
+
is used, so the result is NaN exactly where ``expm``'s is in float64 (in
|
|
48
|
+
float32 ``expm`` used Pade 7, ``theta_7 = 3.93``, and a lower NaN bound).
|
|
49
|
+
Used for the slab value (5x5) and its propagators (8x8, ``propagators``).
|
|
50
|
+
Compiled, as ``expm`` is: op by op, XLA CPU rounded it an ulp differently."""
|
|
51
|
+
norm = jax.lax.stop_gradient(jnp.max(jnp.sum(jnp.abs(A), axis=0)))
|
|
52
|
+
ratio = jnp.log2(norm / _THETA13)
|
|
53
|
+
low, high = jnp.maximum(0, jnp.floor(ratio)), jnp.maximum(0, jnp.ceil(ratio))
|
|
54
|
+
return expm_squared(A, jnp.where(high > max_squarings, low, high), max_squarings)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@partial(jax.jit, static_argnames="max_squarings")
|
|
58
|
+
def expm_squared(A, n, max_squarings):
|
|
59
|
+
"""Pade-13 of ``A / 2^n`` squared ``n`` times (NaN if ``n > max_squarings``).
|
|
60
|
+
A squaring step runs while any member needs it (``any_member``) and is
|
|
61
|
+
applied per member (``batched_only``)."""
|
|
62
|
+
dot = partial(jnp.matmul, precision=jax.lax.Precision.HIGHEST)
|
|
63
|
+
A = A / 2 ** n.astype(A.dtype)
|
|
64
|
+
b, ident = _PADE13, jnp.eye(A.shape[0], dtype=A.dtype)
|
|
65
|
+
A2 = dot(A, A)
|
|
66
|
+
A4 = dot(A2, A2)
|
|
67
|
+
A6 = dot(A4, A2)
|
|
68
|
+
U = dot(A6, b[13] * A6 + b[11] * A4 + b[9] * A2)
|
|
69
|
+
U = dot(A, U + b[7] * A6 + b[5] * A4 + b[3] * A2 + b[1] * ident)
|
|
70
|
+
V = dot(A6, b[12] * A6 + b[10] * A4 + b[8] * A2)
|
|
71
|
+
V = V + b[6] * A6 + b[4] * A4 + b[2] * A2 + b[0] * ident
|
|
72
|
+
R = jnp.linalg.solve(V - U, V + U)
|
|
73
|
+
|
|
74
|
+
def squaring(R, i):
|
|
75
|
+
def square(R):
|
|
76
|
+
return jnp.where(batched_only(i < n), dot(R, R), R)
|
|
77
|
+
|
|
78
|
+
return jax.lax.cond(any_member(i < n), square, lambda R: R, R), None
|
|
79
|
+
|
|
80
|
+
steps = jnp.arange(max_squarings, dtype=n.dtype)
|
|
81
|
+
R = jax.lax.scan(squaring, R, steps)[0]
|
|
82
|
+
return jnp.where(n > max_squarings, jnp.nan, R)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def propagators(K, ds, max_squarings):
|
|
86
|
+
"""``(Phi, G) = (e^{-K ds}, int_0^ds e^{-K t} dt)`` from one 8x8 exponential.
|
|
87
|
+
|
|
88
|
+
The identity block (not ``I ds``) keeps the norm at ``max(|K ds|, 1)``, at
|
|
89
|
+
most that of the slab value's 5x5 exponential (same ``expm_pade13``), so
|
|
90
|
+
they exceed ``max_squarings`` only where the value is refused. There is no
|
|
91
|
+
branch on a member's value, so a ``jax.jvp`` of it inside a ``custom_jvp``
|
|
92
|
+
rule transposes under a batched ``vmap``."""
|
|
93
|
+
A = jnp.zeros((8, 8), dtype=K.dtype)
|
|
94
|
+
A = A.at[:4, :4].set(-K * ds).at[:4, 4:].set(jnp.eye(4, dtype=K.dtype))
|
|
95
|
+
E = expm_pade13(A, max_squarings)
|
|
96
|
+
return E[:4, :4], E[:4, 4:] * ds
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@jax.custom_batching.custom_vmap
|
|
100
|
+
def any_member(flag):
|
|
101
|
+
"""``flag``; under ``vmap`` its ``any`` over the batch, unbatched, so that a
|
|
102
|
+
``lax.cond`` on it stays a branch instead of evaluating both sides. The rule
|
|
103
|
+
re-enters ``any_member``, so an enclosing ``vmap`` reduces its axis too (a
|
|
104
|
+
plain ``jnp.any`` stayed batched there, and reverse mode through nested
|
|
105
|
+
``vmap`` hit ``stop_gradient`` in the select that replaced the ``cond``)."""
|
|
106
|
+
return flag
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@any_member.def_vmap
|
|
110
|
+
def _any_member_vmap(axis_size, in_batched, flag):
|
|
111
|
+
return any_member(jnp.any(flag, axis=0) if in_batched[0] else flag), False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@jax.custom_batching.custom_vmap
|
|
115
|
+
def batched_only(flag):
|
|
116
|
+
"""``True``; under ``vmap`` with a batched ``flag``, ``flag``. In a branch
|
|
117
|
+
taken on ``any_member(flag)`` it selects the members per step, and folds
|
|
118
|
+
to nothing when unbatched (a ``where`` on ``flag`` cost 1.4x in reverse).
|
|
119
|
+
Unbatched at one level, it re-enters itself for an enclosing ``vmap``."""
|
|
120
|
+
return jnp.ones_like(flag)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@batched_only.def_vmap
|
|
124
|
+
def _batched_only_vmap(axis_size, in_batched, flag):
|
|
125
|
+
return (flag if in_batched[0] else batched_only(flag)), in_batched[0]
|
syncem/bessel.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Differentiable integer J and modified K functions with explicit quadrature.
|
|
2
|
+
|
|
3
|
+
Integer J uses a periodic trapezoidal rule, with an exactly equivalent complex
|
|
4
|
+
contour shift for ``0 < |x| < n`` to resolve exponentially small values without
|
|
5
|
+
subtracting order-one oscillations. The contour is held fixed during autodiff:
|
|
6
|
+
the integral is invariant under that shift, so this differentiates the same
|
|
7
|
+
analytic function rather than the arbitrary contour-selection algorithm.
|
|
8
|
+
|
|
9
|
+
For a concrete order the default grid resolves ``|x| <= n`` (the harmonic domain).
|
|
10
|
+
For a traced order the default is 2048 nodes; choose a larger static ``n_nodes``
|
|
11
|
+
when needed. Calls outside ``|n| + |x| <= n_nodes/2`` return NaN, rather than silently
|
|
12
|
+
aliasing unresolved oscillations. This conservative resolution guard is not a
|
|
13
|
+
roundoff or quadrature-error certificate. Compare increased resolution and an
|
|
14
|
+
independent reference for a new domain, including the required derivatives.
|
|
15
|
+
|
|
16
|
+
K uses one continuous cosh integral; there is no asymptotic switch at x=0.01.
|
|
17
|
+
Its finite upper limit has the explicit bound returned by ``bessel_kn_tail_bound``.
|
|
18
|
+
The quadrature error is separate. Finite independent tests cover orders 0, 2/3,
|
|
19
|
+
5/3 over x=1e-12..100 and integer J through n=3200; they do not establish uniform
|
|
20
|
+
accuracy outside those cases. Order, node count and tail cutoff are static;
|
|
21
|
+
positive arguments are differentiated. SciPy is used only in independent tests.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from functools import lru_cache
|
|
27
|
+
import math
|
|
28
|
+
import numbers
|
|
29
|
+
|
|
30
|
+
import jax
|
|
31
|
+
import jax.numpy as jnp
|
|
32
|
+
import numpy as np
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@lru_cache(maxsize=20)
|
|
36
|
+
def _periodic_grid(n_nodes):
|
|
37
|
+
if n_nodes < 16:
|
|
38
|
+
raise ValueError("n_nodes must be at least 16")
|
|
39
|
+
t = 2.0 * np.pi * np.arange(n_nodes) / n_nodes
|
|
40
|
+
return np.sin(t), np.cos(t), t
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@lru_cache(maxsize=8)
|
|
44
|
+
def _legendre_grid(n_nodes):
|
|
45
|
+
if n_nodes < 16:
|
|
46
|
+
raise ValueError("n_nodes must be at least 16")
|
|
47
|
+
return np.polynomial.legendre.leggauss(n_nodes)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _j_resolution(n, x, n_nodes):
|
|
51
|
+
if n_nodes is not None:
|
|
52
|
+
return int(n_nodes)
|
|
53
|
+
# No host materialisation of traced or device arrays in an online graph.
|
|
54
|
+
if isinstance(n, (numbers.Real, np.ndarray, list, tuple)):
|
|
55
|
+
extent = float(np.max(np.abs(np.asarray(n))))
|
|
56
|
+
if isinstance(x, (numbers.Real, np.ndarray, list, tuple)):
|
|
57
|
+
extent = max(extent, float(np.max(np.abs(np.asarray(x)))))
|
|
58
|
+
count = 2 ** math.ceil(math.log2(max(128, 4 * extent + 64)))
|
|
59
|
+
if count > 65536:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
"automatic J quadrature exceeds 65536 nodes; choose a validated static n_nodes or a suitable reference approximation"
|
|
62
|
+
)
|
|
63
|
+
return count
|
|
64
|
+
return 2048
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def bessel_jn_and_prime(n, x, *, n_nodes=None):
|
|
68
|
+
"""Return integer-order (J_n(x), J_n'(x)); broadcast n and x.
|
|
69
|
+
|
|
70
|
+
``n >= 0`` must be integral. For JIT with a varying order, ``n_nodes`` is
|
|
71
|
+
a static resolution choice; unresolved inputs are NaN. At x=0 the analytic
|
|
72
|
+
function values are supplied; use a nonzero argument for autodiff.
|
|
73
|
+
"""
|
|
74
|
+
count = _j_resolution(n, x, n_nodes)
|
|
75
|
+
sin_t, cos_t, t = _periodic_grid(count)
|
|
76
|
+
n, x = jnp.broadcast_arrays(jnp.asarray(n), jnp.asarray(x))
|
|
77
|
+
ax = jnp.abs(x)
|
|
78
|
+
safe_x = jnp.where(ax > 0, ax, 1.0)
|
|
79
|
+
shifted = (n > safe_x) & (n > 0)
|
|
80
|
+
# Fix the contour while differentiating. Scaled sinh/cosh avoid overflow
|
|
81
|
+
# when the shift is large but x*sinh(a) and x*cosh(a) remain modest.
|
|
82
|
+
d = jax.lax.stop_gradient(jnp.sqrt(jnp.maximum(n**2 - safe_x**2, 0.0)))
|
|
83
|
+
shift = jax.lax.stop_gradient(
|
|
84
|
+
jnp.where(shifted, jnp.log(jnp.maximum(n + d, 1.0)) - jnp.log(safe_x), 0.0)
|
|
85
|
+
)
|
|
86
|
+
reference_x = jax.lax.stop_gradient(safe_x)
|
|
87
|
+
ratio = safe_x / reference_x
|
|
88
|
+
xsinh = jnp.where(shifted, d * ratio, 0.0)
|
|
89
|
+
xcosh = jnp.where(shifted, n * ratio, safe_x)
|
|
90
|
+
phase = n[..., None] * t - xcosh[..., None] * sin_t
|
|
91
|
+
envelope = jnp.exp(xsinh[..., None] * (cos_t - 1.0))
|
|
92
|
+
pref = jnp.exp(xsinh - n * shift)
|
|
93
|
+
value = pref * jnp.mean(envelope * jnp.cos(phase), axis=-1)
|
|
94
|
+
prime = (
|
|
95
|
+
pref
|
|
96
|
+
* jnp.mean(
|
|
97
|
+
envelope
|
|
98
|
+
* (
|
|
99
|
+
xsinh[..., None] * cos_t * jnp.cos(phase)
|
|
100
|
+
+ xcosh[..., None] * sin_t * jnp.sin(phase)
|
|
101
|
+
),
|
|
102
|
+
axis=-1,
|
|
103
|
+
)
|
|
104
|
+
/ safe_x
|
|
105
|
+
)
|
|
106
|
+
parity = jnp.where(n % 2 == 0, 1.0, -1.0)
|
|
107
|
+
value = jnp.where(x < 0, parity * value, value)
|
|
108
|
+
prime = jnp.where(x < 0, -parity * prime, prime)
|
|
109
|
+
value = jnp.where(ax == 0, jnp.where(n == 0, 1.0, 0.0), value)
|
|
110
|
+
prime = jnp.where(ax == 0, jnp.where(n == 1, 0.5, 0.0), prime)
|
|
111
|
+
valid = (n >= 0) & (n == jnp.floor(n)) & (n + ax <= count / 2)
|
|
112
|
+
return jnp.where(valid, value, jnp.nan), jnp.where(valid, prime, jnp.nan)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def bessel_jn(n, x, *, n_nodes=None):
|
|
116
|
+
"""Integer J_n; see ``bessel_jn_and_prime`` for the resolution contract."""
|
|
117
|
+
return bessel_jn_and_prime(n, x, n_nodes=n_nodes)[0]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def bessel_jn_prime(n, x, *, n_nodes=None):
|
|
121
|
+
"""Argument derivative of integer J_n, evaluated on the same contour."""
|
|
122
|
+
return bessel_jn_and_prime(n, x, n_nodes=n_nodes)[1]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def bessel_jn_neighbours(n, x, *, n_nodes=None):
|
|
126
|
+
"""``(J_{n-1}(x), J_{n+1}(x), J_n'(x))`` from one quadrature; broadcast n and x.
|
|
127
|
+
|
|
128
|
+
Dimensionless. ``n >= 1`` must be integral (traced floats allowed, as in
|
|
129
|
+
``bessel_jn_and_prime``). One periodic trapezoidal rule on the contour
|
|
130
|
+
``t + i a`` chosen for order ``n`` (``cosh a = n/|x|`` when ``n > |x|``,
|
|
131
|
+
else ``a = 0``) serves all three functions: with ``phi = n t - |x| cosh(a)
|
|
132
|
+
sin t`` and ``E = exp(|x| sinh(a) cos t)``, the order-``k`` integrand is
|
|
133
|
+
``exp(-k a) E e^{i (phi + (k - n) t)}``, so ``J_{n+-1}`` use
|
|
134
|
+
``cos(phi -+ t) = cos(phi) cos t +- sin(phi) sin t`` and ``J_n'`` uses the
|
|
135
|
+
argument derivative of the order-``n`` integrand, all from one set of
|
|
136
|
+
``cos(phi)``, ``sin(phi)`` and ``E`` (three transcendental evaluations per
|
|
137
|
+
node instead of nine). The shift identity is exact for every integer
|
|
138
|
+
order (the integrand is entire and ``2 pi``-periodic), so no fallback to
|
|
139
|
+
separate contours is needed; the precision of the neighbours on the
|
|
140
|
+
order-``n`` contour is checked, not bounded, against SciPy over the
|
|
141
|
+
harmonic resolution classes ``n <= 1008``, ``x`` from ``1e-8`` through
|
|
142
|
+
the transition ``x ~ n`` to the resolution edge (relative differences
|
|
143
|
+
below ``1e-12`` in ``tests/model/test_memory_numerics.py``).
|
|
144
|
+
|
|
145
|
+
Resolution guard: ``n + 1 + |x| <= n_nodes/2`` (the default count
|
|
146
|
+
resolves ``|x| <= n + 1`` for a concrete order and is 2048 for a traced
|
|
147
|
+
one); unresolved inputs, ``n < 1`` and non-integral orders are NaN. This
|
|
148
|
+
guard is not an accuracy certificate. The contour is held fixed under
|
|
149
|
+
autodiff (see the module docstring); at ``x = 0`` the analytic values are
|
|
150
|
+
returned with a zero derivative. ``bessel_jn_and_prime`` is unchanged.
|
|
151
|
+
|
|
152
|
+
Autodiff accuracy: ``jax.jacfwd``/``jax.grad`` through this quadrature
|
|
153
|
+
lose accuracy at ``|x| << n``. On the shifted contour the neighbour
|
|
154
|
+
integrands carry ``exp(-+a)`` with ``a = arccosh(n/|x|)``, and roundoff
|
|
155
|
+
grows by about ``(2n/|x|)^k`` at derivative order ``k``. At ``n = 1``,
|
|
156
|
+
``x = 1e-6`` (default count, 128 nodes) the third derivatives of
|
|
157
|
+
``J_0`` and ``J_1'`` come out as -83 and 128 against SciPy's 3.8e-7
|
|
158
|
+
and 3.1e-7; the second derivative of ``J_0`` is off by 1.6e-4. For
|
|
159
|
+
derivatives use the order recurrence ``J_n' = (J_{n-1} - J_{n+1})/2``
|
|
160
|
+
on the same contour (``syncem.model._bessel_recurrence``:
|
|
161
|
+
``neighbours_recurrence``, ``derivative_coefficients``), which
|
|
162
|
+
``HarmonicKernel(derivatives="analytic")`` uses; at the same point it
|
|
163
|
+
agrees with SciPy to 1e-15 through the third derivative
|
|
164
|
+
(``tests/test_bessel_autodiff_accuracy.py``; checked, not bounded).
|
|
165
|
+
"""
|
|
166
|
+
top = n
|
|
167
|
+
if n_nodes is None and isinstance(n, (numbers.Real, np.ndarray, list, tuple)):
|
|
168
|
+
top = np.abs(np.asarray(n, dtype=float)) + 1.0
|
|
169
|
+
count = _j_resolution(top, x, n_nodes)
|
|
170
|
+
sin_t, cos_t, t = _periodic_grid(count)
|
|
171
|
+
n, x = jnp.broadcast_arrays(jnp.asarray(n), jnp.asarray(x))
|
|
172
|
+
ax = jnp.abs(x)
|
|
173
|
+
safe_x = jnp.where(ax > 0, ax, 1.0)
|
|
174
|
+
shifted = (n > safe_x) & (n > 0)
|
|
175
|
+
d = jax.lax.stop_gradient(jnp.sqrt(jnp.maximum(n**2 - safe_x**2, 0.0)))
|
|
176
|
+
shift = jax.lax.stop_gradient(
|
|
177
|
+
jnp.where(shifted, jnp.log(jnp.maximum(n + d, 1.0)) - jnp.log(safe_x), 0.0)
|
|
178
|
+
)
|
|
179
|
+
ratio = safe_x / jax.lax.stop_gradient(safe_x)
|
|
180
|
+
xsinh = jnp.where(shifted, d * ratio, 0.0)
|
|
181
|
+
xcosh = jnp.where(shifted, n * ratio, safe_x)
|
|
182
|
+
phase = n[..., None] * t - xcosh[..., None] * sin_t
|
|
183
|
+
envelope = jnp.exp(xsinh[..., None] * (cos_t - 1.0))
|
|
184
|
+
c, s = envelope * jnp.cos(phase), envelope * jnp.sin(phase)
|
|
185
|
+
lower = jnp.exp(xsinh - (n - 1.0) * shift) * jnp.mean(
|
|
186
|
+
c * cos_t + s * sin_t, axis=-1
|
|
187
|
+
)
|
|
188
|
+
upper = jnp.exp(xsinh - (n + 1.0) * shift) * jnp.mean(
|
|
189
|
+
c * cos_t - s * sin_t, axis=-1
|
|
190
|
+
)
|
|
191
|
+
prime = (
|
|
192
|
+
jnp.exp(xsinh - n * shift)
|
|
193
|
+
* jnp.mean(xsinh[..., None] * cos_t * c + xcosh[..., None] * sin_t * s, axis=-1)
|
|
194
|
+
/ safe_x
|
|
195
|
+
)
|
|
196
|
+
# J_{n+-1}(-x) = (-1)^(n+-1) J_{n+-1}(x) and J_n'(-x) = (-1)^(n+1) J_n'(x).
|
|
197
|
+
flip = jnp.where(x < 0, jnp.where(n % 2 == 0, -1.0, 1.0), 1.0)
|
|
198
|
+
lower = jnp.where(ax == 0, jnp.where(n == 1, 1.0, 0.0), flip * lower)
|
|
199
|
+
upper = jnp.where(ax == 0, 0.0, flip * upper)
|
|
200
|
+
prime = jnp.where(ax == 0, jnp.where(n == 1, 0.5, 0.0), flip * prime)
|
|
201
|
+
valid = (n >= 1) & (n == jnp.floor(n)) & (n + 1 + ax <= count / 2)
|
|
202
|
+
return tuple(jnp.where(valid, v, jnp.nan) for v in (lower, upper, prime))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _log_cosh(x):
|
|
206
|
+
magnitude = jnp.abs(x)
|
|
207
|
+
return magnitude + jnp.log1p(jnp.exp(-2.0 * magnitude)) - jnp.log(2.0)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _modified_tmax(nu, x, tail_cutoff):
|
|
211
|
+
cutoff = tail_cutoff + 2 * abs(nu)
|
|
212
|
+
# arccosh(1+cutoff/x), expressed without overflowing cutoff/x at tiny x.
|
|
213
|
+
return jnp.log(x + cutoff + jnp.sqrt(cutoff * (cutoff + 2 * x))) - jnp.log(x)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _modified_integral(nu, x, *, divide_cosh=False, n_nodes=128, tail_cutoff=50.0):
|
|
217
|
+
"""Integral exp(-x cosh t) cosh(nu t) [ / cosh(t)] dt."""
|
|
218
|
+
nu = float(nu)
|
|
219
|
+
if not np.isfinite(nu) or tail_cutoff <= 0:
|
|
220
|
+
raise ValueError("nu must be finite and tail_cutoff must be positive")
|
|
221
|
+
nodes, weights = _legendre_grid(int(n_nodes))
|
|
222
|
+
x = jnp.asarray(x)
|
|
223
|
+
safe_x = jnp.where(x > 0, x, 1.0)
|
|
224
|
+
tmax = _modified_tmax(nu, safe_x, tail_cutoff)
|
|
225
|
+
t = 0.5 * tmax[..., None] * (nodes + 1.0)
|
|
226
|
+
log_cosh_t = _log_cosh(t)
|
|
227
|
+
log_integrand = -jnp.exp(jnp.log(safe_x)[..., None] + log_cosh_t) + _log_cosh(
|
|
228
|
+
nu * t
|
|
229
|
+
)
|
|
230
|
+
if divide_cosh:
|
|
231
|
+
log_integrand = log_integrand - log_cosh_t
|
|
232
|
+
integrand = jnp.exp(log_integrand)
|
|
233
|
+
result = 0.5 * tmax * jnp.sum(weights * integrand, axis=-1)
|
|
234
|
+
return jnp.where(x > 0, result, jnp.where(x == 0, jnp.inf, jnp.nan))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _modified_tail_bound(nu, x, *, divide_cosh=False, tail_cutoff=50.0):
|
|
238
|
+
"""Analytic upper bound for the omitted positive-t integral, x > 0.
|
|
239
|
+
|
|
240
|
+
``cosh(t) >= exp(t)/2`` and ``cosh(nu t) <= exp(|nu| t)`` reduce the tail to an
|
|
241
|
+
incomplete Gamma integral. Bounding log(u/z)<= (u-z)/z gives the elementary
|
|
242
|
+
bound below. It excludes quadrature and floating-point error.
|
|
243
|
+
"""
|
|
244
|
+
nu = abs(float(nu))
|
|
245
|
+
x = jnp.asarray(x)
|
|
246
|
+
tmax = _modified_tmax(nu, x, tail_cutoff)
|
|
247
|
+
z = 0.5 * jnp.exp(jnp.log(x) + tmax)
|
|
248
|
+
power = nu - int(divide_cosh)
|
|
249
|
+
log_pref = power * jnp.log(2.0 / x) + int(divide_cosh) * jnp.log(2.0)
|
|
250
|
+
slope = max(power - 1.0, 0.0) / z
|
|
251
|
+
bound = jnp.exp(log_pref - z + (power - 1) * jnp.log(z)) / (1.0 - slope)
|
|
252
|
+
return jnp.where((x > 0) & (slope < 1), bound, jnp.inf)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def bessel_kn(nu, x, *, n_nodes=128, tail_cutoff=50.0):
|
|
256
|
+
"""K_nu(x), x > 0; nonnegative static nu is sufficient for current uses.
|
|
257
|
+
|
|
258
|
+
A continuous cosh integral with a parameter-dependent upper limit is used.
|
|
259
|
+
K_nu(0)=infinity and negative x returns NaN. Node count and tail_cutoff must
|
|
260
|
+
be static under JIT. Derivatives inherit both quadrature and finite-tail
|
|
261
|
+
errors, and must be checked separately from function values.
|
|
262
|
+
"""
|
|
263
|
+
return _modified_integral(nu, x, n_nodes=n_nodes, tail_cutoff=tail_cutoff)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def bessel_kn_tail_bound(nu, x, *, tail_cutoff=50.0):
|
|
267
|
+
"""Upper bound on K's omitted integral, excluding discretisation error."""
|
|
268
|
+
return _modified_tail_bound(nu, x, tail_cutoff=tail_cutoff)
|
syncem/constants.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Shared Gaussian-cgs constants adopted by the manuscript reference calculation.
|
|
2
|
+
|
|
3
|
+
Electron mass is the CODATA2022 recommended value, converted from kg to g;
|
|
4
|
+
elementary charge uses the manuscript's conventional Coulomb-to-statcoulomb
|
|
5
|
+
conversion of the SI elementary charge. The charge denotes a positive
|
|
6
|
+
magnitude (electron charge is -E_ESU). Values are centralised so emission and
|
|
7
|
+
propagation cannot silently use different normalisations.
|
|
8
|
+
|
|
9
|
+
Sources: https://physics.nist.gov/cuu/pdf/wall_2022.pdf and
|
|
10
|
+
https://www.nist.gov/pml/special-publication-330/sp-330-section-2 .
|
|
11
|
+
Mass uncertainty and any convention/conversion uncertainty are not randomised
|
|
12
|
+
by this module; precision budgets requiring them should propagate them as
|
|
13
|
+
shared physical-constant inputs rather than independent per-module errors.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
E_ESU = 4.803204712570263e-10 # charge magnitude [statC]
|
|
17
|
+
M_E = 9.1093837139e-28 # electron mass [g]
|
|
18
|
+
C_CGS = 2.99792458e10 # speed of light [cm/s]
|
|
19
|
+
# SI speed of light [m/s]; only for the Faraday phase 2 (c/nu)^2 varphi with the
|
|
20
|
+
# wavelength in metres (syncem.model.phase), never for the emission kernels.
|
|
21
|
+
C_SI_M = 2.99792458e8
|
|
22
|
+
|
|
23
|
+
__all__ = ["E_ESU", "M_E", "C_CGS", "C_SI_M"]
|
syncem/conversion.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Leading cold-plasma coefficients in CGS and the paper's Stokes convention.
|
|
2
|
+
|
|
3
|
+
Inputs: frequency Hz, thermal density cm^-3, magnetic field Gauss. The
|
|
4
|
+
``mueller_*`` functions return signed Stokes rates cm^-1 for dS/ds=eps-K S,
|
|
5
|
+
Q=parallel-minus-perpendicular to projected B, and V=-2 Im(E_parallel E_perp*).
|
|
6
|
+
In that natural basis conversion is rQ (U--V mixing); rU=0. At sky azimuth
|
|
7
|
+
phi its components are rQ*cos(2phi), rQ*sin(2phi).
|
|
8
|
+
|
|
9
|
+
``rotation_coefficient`` retains the position-angle rate for compatibility;
|
|
10
|
+
``conversion_coefficient`` returns rQ itself. There is no additional factor
|
|
11
|
+
of two on the latter. These leading high-frequency cold-dielectric terms
|
|
12
|
+
require plasma and cyclotron frequencies small compared with nu. Hot and
|
|
13
|
+
non-thermal corrections are distribution dependent and are not bounded here.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from .rm import E_ESU, M_E, C_CGS
|
|
21
|
+
|
|
22
|
+
# rho_V = e^3 n_e B_par / (2 pi m_e^2 c^2 nu^2)
|
|
23
|
+
C_ROT = E_ESU**3 / (2.0 * np.pi * M_E**2 * C_CGS**2)
|
|
24
|
+
# rho_Q = e^4 n_e B_perp^2 / (4 pi^2 m_e^3 c^3 nu^3)
|
|
25
|
+
C_CONV = E_ESU**4 / (4.0 * np.pi**2 * M_E**3 * C_CGS**3)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def rotation_coefficient(nu, n_e, B_par):
|
|
29
|
+
"""Physical Faraday rotation rate rho_V [rad/cm] (position angle)."""
|
|
30
|
+
return C_ROT * n_e * B_par / nu**2
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def conversion_coefficient(nu, n_e, B_perp):
|
|
34
|
+
"""Natural-basis Stokes conversion rate rQ [cm^-1], signed."""
|
|
35
|
+
return -C_CONV * n_e * B_perp**2 / nu**3
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def conversion_rotation_ratio(nu, B_perp, B_par):
|
|
39
|
+
"""Signed rQ/rV, independent of n_e; undefined when B_par=0."""
|
|
40
|
+
return -(E_ESU * B_perp**2) / (4.0 * np.pi * M_E * C_CGS * nu * B_par)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def mueller_rotation(nu, n_e, B_par):
|
|
44
|
+
"""Stokes-space rotation coefficient rV = 2 rho_V for syncem.transfer."""
|
|
45
|
+
return 2.0 * rotation_coefficient(nu, n_e, B_par)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def mueller_conversion(nu, n_e, B_perp):
|
|
49
|
+
"""Natural-basis Stokes conversion coefficient rQ for syncem.transfer."""
|
|
50
|
+
return conversion_coefficient(nu, n_e, B_perp)
|