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.
Files changed (102) hide show
  1. syncem/__init__.py +98 -0
  2. syncem/_expm.py +125 -0
  3. syncem/bessel.py +268 -0
  4. syncem/constants.py +23 -0
  5. syncem/conversion.py +50 -0
  6. syncem/cumulants.py +132 -0
  7. syncem/derivatives.py +57 -0
  8. syncem/expansion.py +321 -0
  9. syncem/faraday.py +214 -0
  10. syncem/kirchhoff.py +336 -0
  11. syncem/los_moments.py +132 -0
  12. syncem/magnus.py +57 -0
  13. syncem/model/__init__.py +174 -0
  14. syncem/model/_allowances.py +87 -0
  15. syncem/model/_angular_faint_bounds.py +284 -0
  16. syncem/model/_angular_global_bounds.py +457 -0
  17. syncem/model/_basis_checks.py +195 -0
  18. syncem/model/_basis_core.py +347 -0
  19. syncem/model/_basis_taylor.py +221 -0
  20. syncem/model/_bessel_recurrence.py +200 -0
  21. syncem/model/_bound_helpers.py +44 -0
  22. syncem/model/_budget_terms.py +387 -0
  23. syncem/model/_certified_angular_i00.py +484 -0
  24. syncem/model/_certified_dense_harmonic.py +344 -0
  25. syncem/model/_certified_high_order_basis.py +540 -0
  26. syncem/model/_certified_sparse_harmonic.py +457 -0
  27. syncem/model/_channel_shapes.py +94 -0
  28. syncem/model/_continuum.py +296 -0
  29. syncem/model/_depth_bound.py +103 -0
  30. syncem/model/_direct.py +322 -0
  31. syncem/model/_factor.py +412 -0
  32. syncem/model/_factorisation_bounds.py +106 -0
  33. syncem/model/_harmonic_cells.py +306 -0
  34. syncem/model/_harmonic_ranges.py +140 -0
  35. syncem/model/_harmonic_tail.py +112 -0
  36. syncem/model/_harmonic_taylor.py +409 -0
  37. syncem/model/_interval_angular_geometry.py +208 -0
  38. syncem/model/_kernel_helpers.py +208 -0
  39. syncem/model/_polynomial.py +139 -0
  40. syncem/model/_population.py +291 -0
  41. syncem/model/_prediction.py +125 -0
  42. syncem/model/_project.py +399 -0
  43. syncem/model/_provenance.py +213 -0
  44. syncem/model/_refine.py +99 -0
  45. syncem/model/_registry.py +388 -0
  46. syncem/model/_remainder_probe.py +396 -0
  47. syncem/model/_remainder_route.py +207 -0
  48. syncem/model/_ridge_interval_cells.py +226 -0
  49. syncem/model/_support_check.py +130 -0
  50. syncem/model/_verified_airy.py +265 -0
  51. syncem/model/_verified_bessel.py +174 -0
  52. syncem/model/adapters.py +277 -0
  53. syncem/model/assumptions.py +436 -0
  54. syncem/model/basis.py +438 -0
  55. syncem/model/bounds.py +395 -0
  56. syncem/model/channels.py +435 -0
  57. syncem/model/errors.py +287 -0
  58. syncem/model/fit/__init__.py +68 -0
  59. syncem/model/fit/_bfgs.py +114 -0
  60. syncem/model/fit/_combination_core.py +344 -0
  61. syncem/model/fit/_combination_errors.py +165 -0
  62. syncem/model/fit/_combination_json.py +216 -0
  63. syncem/model/fit/_discrepancy.py +45 -0
  64. syncem/model/fit/_feasibility.py +245 -0
  65. syncem/model/fit/_feasibility_rules.py +173 -0
  66. syncem/model/fit/_identifiability.py +118 -0
  67. syncem/model/fit/_linear_core.py +169 -0
  68. syncem/model/fit/_nodal.py +185 -0
  69. syncem/model/fit/_reduction_checks.py +136 -0
  70. syncem/model/fit/_relations.py +326 -0
  71. syncem/model/fit/_result_build.py +304 -0
  72. syncem/model/fit/_result_core.py +293 -0
  73. syncem/model/fit/_transform.py +204 -0
  74. syncem/model/fit/combination_result.py +369 -0
  75. syncem/model/fit/combinations.py +332 -0
  76. syncem/model/fit/diagnostics.py +277 -0
  77. syncem/model/fit/layout.py +278 -0
  78. syncem/model/fit/linear.py +304 -0
  79. syncem/model/fit/nonlinear.py +391 -0
  80. syncem/model/fit/observation.py +367 -0
  81. syncem/model/fit/reduction.py +334 -0
  82. syncem/model/fit/result.py +215 -0
  83. syncem/model/harmonic.py +695 -0
  84. syncem/model/high_order_basis.py +440 -0
  85. syncem/model/high_order_harmonic.py +948 -0
  86. syncem/model/index.py +388 -0
  87. syncem/model/kernels.py +153 -0
  88. syncem/model/moments.py +363 -0
  89. syncem/model/phase.py +378 -0
  90. syncem/model/predict.py +300 -0
  91. syncem/model/screens.py +172 -0
  92. syncem/rm.py +400 -0
  93. syncem/sed.py +183 -0
  94. syncem/solutions.py +56 -0
  95. syncem/stokes.py +102 -0
  96. syncem/transfer.py +399 -0
  97. syncem/ultrarel.py +48 -0
  98. syncem-0.6.0.dist-info/METADATA +1263 -0
  99. syncem-0.6.0.dist-info/RECORD +102 -0
  100. syncem-0.6.0.dist-info/WHEEL +5 -0
  101. syncem-0.6.0.dist-info/licenses/LICENSE +21 -0
  102. 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)