Skip to content

Update of rates and addition of Photchem class - #48

Draft
jeonggyukim wants to merge 112 commits into
masterfrom
photchem-full
Draft

Update of rates and addition of Photchem class#48
jeonggyukim wants to merge 112 commits into
masterfrom
photchem-full

Conversation

@jeonggyukim

Copy link
Copy Markdown
Owner

Photochemistry

  • Use updated Badnell recombination rate coefficients.
  • Add collisional ionization, charge transfer rates.
  • Add PhotChem class. Calculates SED-averaged cross sections, mean photon energy, etc.. For a given radial density, temperature profile of ionized gas, evolve a set of ion abundances (SpeciesSet) to chemical equilibrium.

TODO

  • Include charge transfer rates properly in the equilibrium calculation.
  • Add ion cooling rates or line emissivity data (pyneb or CMacIonize or Cloudy?).
  • (maybe) test equilibrium temperature using heating/cooling rates.

photchem-full was opened 2025-02-25 (PR #48) and had not been
updated since `Add PhotChem class` (58c6405). master moved 131
commits forward in the interim, including:

- numpy 2 migration (numpy>=2, yt>=4.4) + removed-API fixes
- SB99 dataset refactor + Zari+22 SFH support
- ISRF API rewrite
- find_files.py / load_sim.py docstring + behavior cleanups
- README rewrites; conda/miniforge install docs
- Various docs / autodoc improvements

Dry-run on pyathena-ct-mvp showed zero conflicts (photchem-full's
new files under data/microphysics/ + pyathena/microphysics/ touch
disjoint paths from master's changes). Confirmed by re-running this
merge with --no-commit -- same clean auto-merge here.

The sibling sub-PR #67 (pyathena-ct-mvp) will rebase on this state
before merging.
Static method `get_ct_ion_HII_OI_Draine11` was misnamed
`get_ct_ion_OI_HII_Draine11` at the dispatch site (line 183), causing
`AttributeError` when O I charge-exchange ionization rate is requested.
Symptom: `get_ct_ion_rate(8, 8, T)` crashes.

Caught by new charge-exchange rate test
`test_ct_rate_balance.py::test_ct_ion_rate_finite_nonneg[8-8-O I]`.
Pin the current behavior of `ChargeTransferRate.get_ct_rec_rate` and
`get_ct_ion_rate` before downstream code wires charge-exchange into
the abundance sweep in `evolve_one_species`. Tests document:

- API conventions (which (Z, N) labels reactant vs product per method);
- Sign of the Boltzmann factor in `get_ct_ion_rate` via the N I
  endothermic pair (ct_ion must grow with T faster than ct_rec);
- Near-resonance behavior for O+H (similar T-growth, Draine 2011
  per-J-level detailed balance at high T);
- Order-of-magnitude reference at T = 1e4 K;
- Known-quirk pinning: S I CT-ion is a constant ~1e-14 placeholder in
  the Cloudy data table (`a=1e-5, b=c=d=dE=0`) -- documented so future
  rate upgrades flag this site.
- High-q fallback behavior (q > 3 in `get_ct_ion_rate` returns 0; in
  `get_ct_rec_rate` returns Dalgarno generic estimate `1.92e-9*(q+1)`).

These tests are the regression anchor for the charge-exchange
completeness fixes described in
`tigris-notes/docs-claude/tigris-ncr/pyathena_ct_fixes_plan.md`
(MVP items 1-3); the wiring of CT into the abundance sweep follows in
later commits on this branch.

Pytest infrastructure (empty `tests/__init__.py` + this test file) is
new to pyathena -- run with `pytest pyathena/microphysics/tests/`.
Relocate `pyathena/microphysics/tests/` -> `tests/microphysics/` so
new test suites (io, fields, obs, ...) can drop into sibling subdirs
without nesting inside the package. Adds:

- `tests/README.md` documenting layout, run commands, and the
  convention for adding new tests + diagnostic plots.
- `tests/conftest.py` providing:
    * `figures_dir` fixture -> `tests/figures/` (created on demand).
    * `--no-figures` pytest CLI option to skip plot generation.
    * `save_figures` fixture (True unless `--no-figures` passed).
    * `ion_color(Z, q)` helper + `ion_colors` fixture matching the
      per-ion color convention in
      `pyathena/microphysics/photchem.py:_set_colors` (H=Greys,
      He=Purples, C=Blues, N=Oranges, O=Greens, S=Reds; intensity
      by ionization stage). Tests that produce diagnostic plots
      use this so the same ion gets the same color across every
      figure in the suite and in notebooks that use
      `PhotChem.plt_rate_coeffs` / `plt_sed_sigma_pi`.
- `tests/figures/.gitignore` excluding all PNG/PDF output.

Also enriches the existing CT-rate balance test docstrings with a
provenance note clarifying that Draine 2011 and the Cloudy O+H
polynomial both fit Stancil et al. 1999 (+ Barragan et al. 2006),
so their close agreement is expected rather than independent
cross-validation. Cloudy poly is only valid for T > 200 K (see
`cloudy/source/atmdat_char_tran.cpp:103-134` reference comment).
Three new test files under `tests/microphysics/`, mirroring the
existing `test_ct_rate_balance.py` style: smoke tests for finiteness
and sign, reference-value spot-checks against published numbers,
shape / monotonicity probes, and a diagnostic-plot test per file.

`test_photx_sigma.py` (Verner+96 photoionization cross sections):

- Threshold zero-cutoff, finite + positive above threshold for an
  ion catalog covering H, He, C, N, O, S.
- Reference values: H I sigma at 13.6 eV ~ 6.3e-18, He I at 24.6
  eV ~ 7.4e-18, He II at 54.4 eV ~ 1.6e-18 (Draine 2011 Tables
  13.1-13.3; rtol 10-20%).
- High-E decay shape (~E^-3 to E^-3.5 hydrogenic asymptote).
- Round-trip `get_Eth` consistency with the sigma=0 boundary.
- Diagnostic plot: sigma_pi(E) for the full catalog, per-ion
  colored by the `photchem.py` convention.

`test_rec_rate.py` (Badnell radiative + dielectronic recomb):

- Pins the (Z, N) = initial-ion convention used by `get_rr_rate`,
  `get_dr_rate`, `get_rec_rate`. The initial ion captures the
  electron: H II rec to H I is queried as (1, 0); He II rec is
  (2, 1); O III rec is (8, 6).
- H II Case B at T = 1e4 K = 2.59e-13 cm^3/s (Draine 2011 Eq.
  14.6). Case A > Case B verified.
- DR temperature shape: O III DR rises from T=1e3 to T=1e5 K
  (Badnell 2006a Fig. 1).
- Consistency: total = RR + DR exactly for non-H ions.
- Diagnostic plot: RR (left panel) and DR (right panel) vs T for
  HII / HeII / HeIII / CII / NII / OII / OIII.

`test_ci_rate.py` (Voronov 1997 collisional ionization):

- (Z, N) = REACTANT-ion convention pinned.
- Endothermic Boltzmann sign verified (rate grows with T for H I,
  He I, O I).
- Cold cutoff: rate is exactly 0 at T = 100 K (the U > 80 branch).
- H I CI at T = 2e5 K matches Voronov fit to 10%.
- Isoelectronic ordering: O I -> O II -> O III ionization rate
  decreases with charge at fixed T = 1e5 K.
- Diagnostic plot: beta_CI(T) for the followed-ion set.

All plot tests use the shared `ion_colors` fixture from
`tests/conftest.py` and the LaTeX `\,` thin-space convention for
labels. Plot generation is on by default; pass `pytest --no-figures`
to skip.

121 tests + 5 skipped (DR not defined for Z=1 or N=0; 3 plot tests
skipped under `--no-figures`).
Replace the legend-on-the-side layout with inline rotated labels
via `pyathena.plt_tools.line_annotation.line_annotate`, matched to
each line's slope and underlined with a white-stroke patheffect so
they stay legible over neighbours. Same-color-per-ion is preserved
(via the `ion_colors` fixture), so visual identification across
figures stays consistent.

Additional polish:

* `photx_sigma_overview.png`: clamp xlim to [3, 1e3] eV and ylim to
  [1e-20, 1e-16] cm^2 so the most relevant range (HII + soft X-ray)
  fills the panel. Add minor+major log grids.
* `rec_rate_overview.png`: per-ion annotation x-coordinate chosen
  per-line to avoid overlapping labels in the dense
  Badnell-RR-decline / DR-peak regions. Grids added.
* `ci_rate_overview.png`: same pattern -- per-line annotation x in
  the steep-rise portion. Grids added.
* `ct_OH_rec_comparison.png`: keep the side legend (only 3 source
  lines + Draine per-J decomposition, dense overlap at low T makes
  inline labels overlap badly). Add grid.

No assertion changes; 124 tests + 2 skipped continue to pass.
Round of figure refinements based on visual review:

* `rec_rate_overview.png`:
    - Add S II, S III recombination -- completes the H/He/C/N/O/S
      coverage (S is in the 10-followed-coolant set and a key
      observational tracer; missing-from-figure was an omission).
    - Split y-labels: left panel `\alpha_{rr}`, right panel
      `\alpha_{dr}`.
    - Tighten ylim top to 2e-10 (was 1e-9; the upper decade was
      empty space).

* `ci_rate_overview.png`:
    - Extend the ion catalog to high charge states (C IV/VI,
      N IV/VII, O V/VIII, S VII/XII) since the T-range goes up to
      1e8 K and at those temperatures the CIE balance is dominated
      by highly-ionized species.
    - `ion_color` now accepts an optional `num_ions` arg in
      `conftest.py` so the per-ion color gradient spans
      neutral->fully-stripped (Z+1) per element when high-q stages
      appear. Low-q stages keep the photchem.py default.
    - ylim top -> 1e-6 (was 1e-7) to show the full plateau region
      at high T.

* `photx_sigma_overview.png`:
    - Add S III (Eth=34.83 eV) since S III is in the followed-ion
      set and a major observational tracer.
    - Hand-tune each ion's inline-label x-position to avoid label
      overlaps near the H I / O I (Eth ~ 13.6) and He I / C II
      (Eth ~ 24) clusters.

* `ct_OH_rec_comparison.png`:
    - ylim bottom raised 1e-12 -> 3e-11 to focus on the HII-region
      regime where all three sources are within factor of 2.

* Whitespace normalization: collapse all "  " (multiple spaces) after
  commas and colons to single space across all four test files,
  matching the surrounding pyathena code style.

127 tests + 2 skipped pass.
pyathena tests + microphysics regression suite + O I CT dispatch fix
`get_ct_rec_rate(Z, N, T)` now treats (Z, N) as the REACTANT ion
(the one that gains an electron from H I and recombines), matching
the convention already used by `get_ct_ion_rate`, `get_ci_rate`,
and `RecRate.get_rec_rate`. Previously (Z, N) labelled the product,
which was inconsistent with every other rate function in the
microphysics module and confusing in the abundance-sweep loop.

Examples in the new convention:
  - O+ + H I -> O + H II is queried as get_ct_rec_rate(8, 7, T).
    Previously this same physical rate was get_ct_rec_rate(8, 8, T).
  - Fully-neutral reactants (q = 0) now return 0 (cannot recombine
    further) instead of raising or returning a stale rate.
  - q > 4 falls back to the Dalgarno-like generic estimate
    `1.92e-9 * q`, identical in numerical value to the previous
    `(q_product + 1)` multiplier (since q_reactant = q_product + 1).

Internal data lookup keeps the original product-indexed table layout
(matches the Cloudy ctrecombdata.dat row order); the API translates
by adding 1 to N before calling `_get_index2`.

Also fixed a bug in `photchem.py:plt_rate_coeffs`: the ionize-loop
axis was plotting `get_ct_rec_rate` instead of `get_ct_ion_rate` for
the CT-ionization curves. The new code calls the correct function;
under the reactant-indexed API the per-ion (Z, N) values stored in
`Ion` work directly for both calls.
…production

Test changes for the get_ct_rec_rate API refactor:
- Split the ion catalog into CT_ION_CATALOG (neutral reactant, queries
  CT-ionization) and CT_REC_CATALOG (singly-ionized reactant, queries
  CT-recombination). Previously a single catalog was shared, which only
  worked under the inconsistent old convention.
- Per-test (Z, N) values shifted where they query CT-recombination
  (NI ct_rec now (7, 6) for N+ reactant; OII ct_rec (8, 7); etc.).
- Updated Dalgarno-high-q pin to `1.92e-9 * q_reactant`.
- Updated module-level API docstring to describe the new convention.

New diagnostic figure: ct_O_equilibrium_Draine_fig14_5.png

Two-panel plot:
- Left panel reproduces the CORRECTED Draine 2011 Figure 14.5, which
  plots the steady-state ratio
    [n(O+)/n(O0)] / [n(H+)/n(H0)]
  versus temperature in the low-density and high-density limits.
  The version in the printed 2011 Princeton book is numerically
  incorrect (acknowledged on Draine's book errata page,
  https://www.astro.princeton.edu/~draine/book/errata_p1.pdf,
  noted 2011.05.18 by E. B. Jenkins). The corrected figure on the
  errata has y in [0, 1] with both curves asymptoting to about 8/9;
  this test matches the corrected version.
- Right panel computes the self-consistent x_HII and x_OII versus
  density in neutral gas at fixed chi_FUV = 1, xi_CR = 2e-16 s^-1,
  no photoionization. Uses pyathena `get_xHII` with grain-assisted
  recombination, then closes the oxygen balance with cosmic-ray
  ionization (zeta_O = 2.7 * xi_CR per Draine 2011 sec 13.7),
  CT-ion, CT-rec, and RR + DR. Two temperatures (50 K and 200 K)
  are shown to illustrate the temperature dependence in the diffuse
  / cold neutral medium regime.
Bug in the realistic-equilibrium right panel of
ct_O_equilibrium_Draine_fig14_5.png: the O+ source rate used
`get_ct_ion_rate(8, 8, T)`, which returns the J-summed total
`k0r + k1r + k2r`. This is the high-density (LTE-populated J levels)
expression. In the diffuse / cold neutral medium (n_H << n_crit
for [OI] 63 um), O0 sits in J=2 only, so only the J=2 channel of
CT-ionization fires.

Mixing the J-sum CT-ion rate with the J-sum CT-rec rate (which is
correctly the full sink, since all final J products radiate back
down to J=2) artificially boosted x_OII by a factor of about 3-4
above what the CT-equilibrium ratio K(T) predicts. The right panel
then showed x_OII > x_HII at low T, inconsistent with K(T) < 1 in
the left panel.

Fix: call `get_ct_ion_HII_OI_Draine11(T, sum=False)` to get the
per-J components and use only the J=2 entry (k0i, equivalent to
Draine's k0r). Sink remains the J-summed `get_ct_rec_rate(8, 7, T)`.
After the fix, x_OII / x_HII matches K(T) from the left panel to
within a couple percent (0.011 at T = 50 K, 0.31 at T = 200 K).
Also drops the buggy explanatory title text from the left panel.
…P populations

The CT-ionization rate of O0 depends on the fine-structure level
populations of O 3P (J = 2, 1, 0). In the low-density limit
(n_H << n_crit) all O0 sits in the J = 2 ground state and only the
k0r CT-ion channel fires. In the high-density LTE limit
(n_H >> n_crit) the J levels thermalize and the effective CT-ion
rate is the J-summed weighted Boltzmann average.

Add two helpers in tests/microphysics/test_ct_rate_balance.py:

- `_n_crit_OI(T, x_e)` -- Draine 2011 Eqs. 14.33-14.34, the
  critical density for the [OI] 63 um J=1 -> J=2 fine-structure
  transition. Sets the boundary between the two regimes.

- `_f_OI_J(T, n_H_over_n_crit)` -- partial-LTE populations of the
  J = 2, 1, 0 levels, parametrized by n_H / n_crit. Returns
  (1, 0, 0) at low density and the full Boltzmann fractions at high
  density. Uses an effective two-level interpolation factor
  beta = (n_H/n_crit) / (1 + n_H/n_crit).

Wire these into both the inline right-panel computation in
test_plot_oxygen_CT_equilibrium and the helper `_K_realistic_OH`
used elsewhere. Also extends the right-panel T grid from {50, 200}
K to {50, 200, 1000, 8000} K to cover the CNM through WIM regimes.

At n_H <~ 1e3 cm^-3 in the test plot range the interpolation makes
no visible difference (beta is small), but the smooth handling will
be needed in the multi-ion sweep itself where the chemistry traverses
n_H >~ 1e4 cm^-3 regions.
The previous commit added a `beta = n_H / (n_H + n_crit)` two-level
interpolation between low-density (J=2 only) and high-density LTE
limits for the O 3P fine-structure populations, with n_crit from
Draine 2011 Eqs. 14.33-14.34. This was a hack to side-step
implementing the full level-population solve.

Pyathena already has the exact solve in `cool.get_OI_lev(nH, T, xe,
xHI, xH2)` at `cool.py:1128`, which integrates the 3-level
statistical equilibrium with H I, H2 (para + ortho), and electron
collision rates from Draine 2011 Appendix F Table F.6. Returns the
J=2 / J=1 / J=0 population fractions directly.

Switch both the inline right-panel computation and the helper
`_K_realistic_OH` to call `get_OI_lev`. Delete the now-unused
`_n_crit_OI` and `_f_OI_J` interpolation helpers. The CT-ion rate
becomes

    k_CT_ion_eff = f_J=2 * k0i + f_J=1 * k1i + f_J=0 * k2i

with f_J read from the proper 3-level solve. Behavior at the
test plot densities (n_H <~ 1e3 cm^-3, n_H << n_crit) is
indistinguishable from the previous beta interpolation, but the
new path is correct across the full density range and uses the
real collision rates rather than a two-level approximation.
In the realistic-equilibrium right panel of
ct_O_equilibrium_Draine_fig14_5.png the code used
`zeta_O = 2.7 * xi_CR` (later 2.84) and attributed the factor to
"Draine 2011 sec 13.7" / "Indriolo & McCall 2012". Both citations
were incorrect. Indriolo+12 does not use a 2.84 factor; Indriolo+15
ApJ 800 40 routes the O+ source through the H+ + O charge-transfer
near-resonance rather than direct CR ionization of O, so the
per-atom zeta_O factor is rarely the dominant term in observational
inversions.

Where the ~2-3 factor does come from:
- Draine 2011 ch. 13.2 covers Auger ionization theory but does not
  tabulate per-element zeta values.
- Tielens 2005, Physics+Chem of the ISM, sec 3.4.2 lists per-element
  CR ionization rates approximately 2-3x zeta_H for O.
- Glassgold et al. 2012, ApJ 756 157 has the detailed Auger cascade
  calculation.

In the test's parameter range (T = 50-200 K, n_H = 1-1e3 cm^-3) the
direct CR-ion source term `zeta_O * n_O0` is about 4 orders of
magnitude smaller than the CT-ion source `n_HII * k_CT_ion * n_O0`,
so the precise factor barely matters. The code now documents this
honestly and keeps 2.84 as a labeled placeholder.

Also rename `num` / `den` to `source_per_OI` / `sink_per_OII` for
clarity, since they are the per-O0 source rate and the per-O+ sink
rate in the steady-state ratio `n(O+)/n(O0) = source / sink`.
Items 3 + 4 of the pyathena_ct_fixes_plan MVP. Add per-ion CT
contributions to drate / crate inside `evolve_one_species`, plus the
cross-element H I / H II coupling that accumulates the sum-over-
metals CT fluxes for the two H walks.

New private helper `_ct_rate_safe(kind, Z, N, T)` wraps
`ChargeTransferRate.get_ct_{ion,rec}_rate` so that ions with no
tabulated CT data (e.g., H itself, high-q metals beyond the
Kingdon-Ferland table) return 0 instead of raising. Cleaner than
sprinkling try/except into the sweep.

New pre-pass `_compute_metal_CT_fluxes(T)` builds four arrays:
HI_drate, HII_crate, HI_crate, HII_drate. These are the
sum-over-metals contributions per cell that the two H walks need.
Pre-computed once per timestep at the start-of-step state, in the
same spirit as the existing implicit-Euler treatment of each
species in isolation.

Inside `evolve_one_species`:

- Hydrogen (`element == 'H'`): the H I and H II walks read the
  appropriate pair of pre-computed arrays from the `metal_CT` tuple.
- Metals (`element not in ('H', 'He')`): the four per-ion CT terms
  are added directly. CT-rec when this ion has q >= 1 (it can lose
  one charge). CT-ion of this ion when it is in the ionize branch.
  Creation from X^(q-1) by CT-ion (recomb branch) and from X^(q+1)
  by CT-rec (ionize branch).
- Helium: skipped both ways (no tabulated CT data; H-He self-CT is
  small enough to ignore at the MVP level).

`evolve_all` now invokes the pre-pass once per timestep after
`calc_radiation_field`, then threads `metal_CT` into each
`evolve_one_species` call.

The MVP test suite (124 tests / 6 skipped under --no-figures)
continues to pass. The 1D OII<->HII resonance relaxation test and
the HIIWind setup will be added separately as items 5a and 5b.
Item 5a of pyathena_ct_fixes_plan MVP:
`tests/microphysics/test_OII_HII_resonance.py` is a small regression
test of the new CT wiring in `PhotChem`. It avoids the full PhotChem
instantiation (which needs an SB99 SED file) by building a minimal
stand-in object that carries only the attributes the CT methods
read: `den`, `ions`, `ct`, `rc`, `ci`, and a stub `Fphot`. Two
checks:

1. `test_metal_CT_fluxes_signs` -- verifies the four sum-over-metals
   arrays returned by `_compute_metal_CT_fluxes` against analytic
   expressions for a 1-zone partially ionized cell with O at 50/50.

2. `test_OII_HII_resonance_lock` -- mass-conserving implicit Euler
   on the (O0, O+) sub-system at T = 1e4 K, n_H = 100 cm^-3
   (low-density limit so only the J=2 channel of CT-ion fires).
   Confirms x_OII relaxes to the analytic CT-equilibrium value, and
   pins K_low(T=1e4) ~ 8/9 (Draine 2011 Figure 14.5 corrected).

The test surfaced a subtle point about pyathena's CT API: the
J-summed rate `get_ct_ion_rate(8, 8, T)` returns the UNWEIGHTED sum
k0i + k1i + k2i, which is not the physically right rate at either
density limit. For the low-density regime it overcounts by ~4x;
for high-density LTE it needs a Boltzmann-weighted (5, 3, 1)
combination. The CT wiring in `evolve_one_species` uses this same
unweighted sum and so currently does NOT include the
J-population-weighted correction; this will need a follow-up that
ties into `cool.get_OI_lev` (and equivalent solves for N, S, ...
neutral metals which are not yet in pyathena).

Also drops the direct CR ionization source term `zeta_O * n_O0`
from the realistic-equilibrium right panel of
`test_plot_oxygen_CT_equilibrium`. I had cited multiple sources
(Indriolo+12, Tielens 2005 sec 3.4.2) for a `zeta_O ~ 2.84 zeta_p`
factor; I was unable to verify any of these citations against the
actual papers, so the unverifiable factor is dropped rather than
carry a number I cannot back up. The term is ~1e4 times smaller
than the CT-ion source in this parameter range anyway, so the
figure is unchanged at plotting resolution.
Pyathena's `ct_rate.get_ct_ion_rate(Z=8, N=8, T)` dispatches to
`get_ct_ion_HII_OI_Draine11(T)` which returns the UNWEIGHTED
J-sum `k0i + k1i + k2i` of the three O 3P fine-structure channels.
The unweighted sum is not the physically correct per-O0 rate at any
density (overcounts by ~4x in either low-density or LTE limit).

For the chemistry sweep in PhotChem (which targets HII regions
operating well below n_crit ~ 2e4 cm^-3 for [OI] 63 um), the
low-density limit applies, so the correct per-O0 CT-ion rate uses
only the J = 2 ground-state channel k0i. Implement this in
`_ct_rate_safe`: when (kind='ion', Z=8, N=8), call
`get_ct_ion_HII_OI_Draine11(T, sum=False)` and return only the
first component.

Other neutrals (C0 / N0 / S0 / Mg0 / Si0) use scalar single-channel
fits (Kingdon-Ferland generic or `get_ct_ion_MgI_HII`,
`get_ct_ion_SiI_HII`), so there is no per-J data to mishandle for
them and no special-case is needed.

Update `test_metal_CT_fluxes_signs` to expect the J = 2 rate
instead of the unweighted sum for the O0 leg.

The proper upgrade -- weight by exact J populations from
`cool.get_OI_lev` -- is deferred to a follow-up branch (see
pyathena_ct_fixes_plan.md item 9 in tigris-notes). Impact in
HII-region ionization state is small (the J=2 channel dominates
either way at HII temperatures), so the low-density-limit fix is
adequate for the MVP. PDR / CNM chemistry would feel the difference
more, but pyathena's PhotChem is not the intended workhorse there.
Add docstring + inline comments explaining the two indexing schemes
in play for the OI 3P system:

- Level index (0/1/2) used by `cool.coolOI`, `cool.get_OI_lev`, and
  most modern radiation-transfer codes: numbered by EXCITATION
  ENERGY starting from ground.
- Channel index (0/1/2) used by Draine 2011 sec 14.7 for the per-J
  CT-rec / CT-ion rates k0r, k1r, k2r and k0i, k1i, k2i: labeled by
  the destination J of the produced O atom.

For OI 3P these happen to agree numerically (Hund's 3rd rule
inverts the J ordering since 2p^4 is more than half-filled, making
J=2 the ground state). For other ions with normal Hund ordering
(e.g., CI 3P) the two conventions would disagree. The added
comments + docstrings flag this so future readers don't get
confused.

Also expand the `get_ct_ion_HII_OI_Draine11` docstring to warn
that the `sum=True` return value (unweighted k0i+k1i+k2i) is NOT a
physically valid per-O0 rate at any density (overcounts by ~4x in
either low-density or LTE limit). For correct per-O0 CT-ion rate,
call with sum=False and weight by the J=2/1/0 populations from
`cool.get_OI_lev`.
New top-level location for per-ion metal-line cooling work that
will support the multi-ion sweep + the R-type ionization-front
benchmark vs Cloudy. Existing `pyathena/microphysics/` code is
untouched -- this is purely additive.

Layout:
  pyathena/photchem/
    __init__.py         -- re-exports solve_5level_steady_state +
                           cooling_from_populations
    n_level.py          -- 5-level statistical equilibrium solver
    coolants/           -- empty for now; will house per-ion cooling
                           functions (OIII, NII, SII, NI, SI, SIII)
    data/               -- empty for now; offline-built CHIANTI fit
                           coefficients per ion

The 5-level truncation is standard for nebular HII-region coolants:
np^2 ions (CI, NII, OIII, SIII), np^3 ions (NI, OII, SII), and
np^4 ions (OI, SI) all share a 5-level ground configuration. Higher
CHIANTI levels (typically 2s 2p^3 / 2p^4 / Rydberg) sit above
60000 K = exp(-6) ~ 0.002 Boltzmann factor at HII T -- negligible
contribution to cooling and to ionization-relevant J populations.
Two-level ions (CII 2P) are handled separately.

`solve_5level_steady_state(A, E, g, T, n_coll_times_q)` builds the
5x5 rate-balance matrix per cell (spontaneous decays + collisional
de-excitation downward; detailed-balance Boltzmann upward) and
solves with the closure sum_i f_i = 1. The colliders are pre-summed
by the caller into the `n_coll_times_q[i, j]` matrix; this keeps the
solver agnostic about the collider mix (HI, e-, H2, etc.).

Smoke-tested in two limits at T = 1e5 K:
- Zero collisions: f = (1, 0, 0, 0, 0) (all in ground).
- Collisions >> A: f matches the LTE Boltzmann distribution to
  ~5e-7 relative error.

Next: an offline CHIANTI-v11.0.2 table builder under data/ that
extracts (level energies, A coefficients, effective collision
strengths Y(T) on a T grid) for each ion and saves per-ion .npz
files. The runtime coolant code then reads only the .npz, so
ChiantiPy is a build-time-only dependency.
`pyathena/photchem/data/build_chianti_tables.py` is the offline
extractor. Reads CHIANTI v11.0.2 via ChiantiPy, takes the lowest
N levels for each followed coolant ion (5 for the np^2 / np^3 /
np^4 ions; 2 for CII), and writes a per-ion ASCII text file with
four sections: LEVELS, A_COEFFS, T_GRID, UPSILON_E.

CLI:
  python -m pyathena.photchem.data.build_chianti_tables \
      [--T-min 1e3] [--T-max 1e6] [--N-T 60] [--ions OIII NII ...]

Defaults to a 60-point log T grid from 1e3 to 1e6 K, covering the
HII-region target plus PDR / CNM (~1e3 K) and a margin to early
SN-shock onset (~1e5-1e6 K) for non-equilibrium conditions.

ASCII format chosen for:
- `cat` / git-diff inspection,
- C++ readability from tigris-NCR with no extra library
  (`std::ifstream` + section-keyword loop),
- consistency with the existing `tigress_coolftn_ncr.txt` precedent.

Initial build commits the 10 followed-coolant tables in
`pyathena/photchem/data/`: CI, CII, NI, NII, OI, OII, OIII, SI,
SII, SIII. ChiantiPy is a build-time-only dependency; the runtime
coolant modules will parse the .txt directly.

Two ChiantiPy v0.16 quirks documented in the build script:
- CHIANTI v11 uses ecm = -1.0 for unmeasured experimental level
  energies; we fall back to ecmth (theoretical) when ecm <= 0.
- Elvlc['conf'] is an internal integer index (always 0), NOT the
  configuration string. The configuration string lives in
  Elvlc['term']; the spin multiplicity 2S+1 is in Elvlc['spin'],
  not Elvlc['mult'] (which is the level degeneracy 2J+1).

Wgfa / Scups convention: lvl1 = lower level, lvl2 = upper level.
Splups is the v10 name for what v11 calls Scups; the script
handles either.

Companion `read_ascii()` parser is included in the same module
for convenience; the runtime coolant files will import it.
jeonggyukim and others added 16 commits June 14, 2026 02:04
When a channel cannot use analytic d_out (e.g. CIE-table-driven
cooling with no precomputed slope column), the substep-loop
bootstrap fallback is 1-point forward FD at dT_rel = 1e-3.

Decision lineage and trade-offs:

- Analytic d_out where the chain rule is tractable (Phase 4d-a, b1,
  ...): exact, free, bit-stable with the C++ port.
- 1-point forward FD at dT_rel = 1e-3 as the bootstrap: 1 extra
  evaluate per cell per substep, rel error < 1% on the worst
  channels (Lya / HRecomb), < 1e-9 on smooth channels (Dust /
  FreeFree). Adequate for the substep loop's stiffness-damping role.
- Avoid central 2-point FD in production: 2 extra evals per cell
  buys 50x precision the damping role does not need.
- Avoid the historical NCR dT_rel = 2e-2 (set at
  `tigris-ncr/src/photchem/photchem.hpp:224`, dlntemp_ = 0.02):
  same one-extra-eval cost at 1e-3 gives an order-of-magnitude
  better accuracy. The 2e-2 plateau is fine for the original C++
  runs but the modern Python rewrite gets a free precision upgrade.
- Avoid per-channel optimum dT_rel: the optimum sits on the
  roundoff cliff. Platform-dependent (Apple silicon vs Intel FMA
  fusion shifts it), numpy-version dependent, function-shape
  dependent. Risks NaN under any perturbation. Conservative 1e-3
  sits in the truncation-dominated plateau where error is platform-
  independent and predictable.

Calibration test (tests/chemistry/test_fd_calibration.py):

- Sweeps dT_rel over logspace(-10, -1, 19) for both 2-point central
  and 1-point forward FD on 3 representative channels:
    Lya         steep Boltzmann exp(-T_excite/T)
    HRecomb     steep power law alpha_B ~ T^-0.8
    Dust        smooth sqrt(T) * (T - T_dust)
- Asserts the conservative defaults (1e-3 central, 1e-1 forward)
  give rel error below the target AND sit to the right of the
  per-curve optimum (i.e. in the truncation regime).
- 6 parametrized cases (3 channels x 2 FD methods). Plain pytest
  runtime under 2 s; left in CI without a slow marker.

Empirical numbers from the sweep:

  Channel    Method        dT_rel=1e-3   dT_rel=2e-2   Optimum
  Lya        central 2-pt  2.3e-5        2.3e-3        3e-6 (5e-10)
  Lya        forward 1-pt  6.5e-3        6.7e-2        3e-8 (3e-7)
  HRecomb    central 2-pt  3.2e-6        3.2e-4        1e-5 (1.7e-9)
  HRecomb    forward 1-pt  3.1e-3        3.0e-2        1e-7 (5e-7)
  Dust       central 2-pt  ~1e-12        ~5e-10        ~1e-7
  Dust       forward 1-pt  ~1e-9         ~5e-7         ~1e-7

Documentation:

- The CoolingChannel ABC docstring (pyathena/chemistry/cooling/base.py)
  now spells out the FD bootstrap convention inline so any future
  channel author sees it at the point of use.
- Memory: feedback_fd_bootstrap_convention.md (Claude-visible).
- This commit adds the calibration test + the inline ABC comment +
  the CHANGES log entry.

Validation: 535 passed, 4 skipped (up from 529 / 4; +6 net new
calibration cases, no regressions).
Two more channels gain a non-zero d_out via the project's
documented 1-point forward FD bootstrap at dT_rel = 1e-3 rather
than analytic. Both share the WD01 charge-parameter chain x =
1.7 * chi * sqrt(T) / (n_e * phi) + 50, whose analytic chain rule
on the surrounding Gamma / Lambda formulas is mechanically
tractable but ~40 ops through quotient rules on 4-term
denominators and gives no meaningful accuracy gain for the substep
damping role.

Refactor pattern (per channel):

- Lambda / Gamma computation moves into a private _compute_X(state,
  out) helper that reads state.T directly.
- evaluate(state, out, d_out) calls _compute_X once for the value.
  When d_out is supplied, evaluate snapshots state.T into the
  T_orig scratch, scales state.T by (1 + 1e-3), calls _compute_X
  a second time into the out_tp scratch, restores state.T, and
  assembles d_out = mu * (out_tp - out) / (T_orig * 1e-3).

Each channel gains two new scratch slots:
heating:photoelectric:T_orig, heating:photoelectric:out_tp,
cooling:grain_rec:T_orig, cooling:grain_rec:out_tp.

Tests
(tests/chemistry/test_phase4d_analytic_derivatives.py):

- test_photoelectric_heating_d_out_matches_FD_bootstrap and
  test_grain_recombination_d_out_matches_FD_bootstrap compare each
  channel's own forward-FD d_out against the test harness's 5-pt
  central FD reference. Tolerance rtol = 5e-2 because forward FD
  has O(dT) truncation around 3-5% in the steepest regimes; this
  is the calibration-sweep empirical worst case.

Long-term direction (Phase 7):

- Mini-RAMSES uses precomputed cool_prime / heat_prime derivative
  columns alongside cool / heat tables; cubic Hermite at query
  returns Lambda and dLambda/dT in one pass with C^1 continuity.
  See cooling_module.f90:516-534. This is the right end state for
  tabulated cooling channels (CII/OI/CI/OII at Phase 4d-e + the
  Phase 7 metal-line tiers); the FD bootstrap path here is the
  interim while pyathena.chemistry.tables doesn't yet build
  derivative columns.

Phase 4d running total after this commit: 9 channels with a
non-zero d_out path -- 7 analytic (CR / H2Diss / Dust / FreeFreeH /
Lya / HICollIon / HRecomb) and 2 FD bootstrap (PE / GrainRec). 12
remaining channels still write d_out[:] = 0.

Validation: 537 passed, 4 skipped (up from 535 / 4; +2 net new
analytic-derivative parity tests, no regressions).
Nebular, HISmith21, H2CollDiss, H2FormationHeating, H2PumpHeating
all gain a non-zero d_out via the project's 1-point forward FD
bootstrap at dT_rel = 1e-3. Same refactor pattern as the Phase 4d-b2
PE / GrainRec batch: Lambda / Gamma body moves into a private
_compute_lambda / _compute_gamma helper that reads state.T; the
public evaluate calls the helper once for the value, then for d_out
snapshots state.T, scales by (1 + 1e-3), calls the helper again,
restores state.T, and assembles
    d_out = mu * (out_tp - out) / (T_orig * 1e-3).

Two new scratch slots per channel: <ns>:T_orig, <ns>:out_tp.

Why FD bootstrap and not analytic for these five:

- All five run through composite chain rules:
    Nebular        -- 6-term Horner polynomial * Boltzmann *
                      Hummer density-reduction factor
    HISmith21      -- 4 Upsilon series each piecewise in T6
    H2CollDiss     -- log-log interpolated rate coefficients in a
                      gated temperature window
    H2Form, H2Pump -- HM79 ncrit chain through saturation densities
                      and temperature-dependent grain rates
  Each analytic derivative is mechanically tractable but ~40-80 ops
  through quotient / product rules with no meaningful accuracy gain
  for the substep damping role.
- None of these channels are tabulatable as Lambda(T) alone (they
  depend on additional combinations of xe, n_H, xHI, xH2, Z_g);
  Phase 7 tabulated Hermite path (mini-RAMSES style cool_prime
  columns) does NOT cover them, so the FD bootstrap stays the
  long-term production path.

Tests (tests/chemistry/test_phase4d_analytic_derivatives.py):

- One test per channel comparing the channel's own forward-FD d_out
  against the 5-pt central FD reference at rtol = 5e-2 (the
  bootstrap O(dT) truncation worst-case from the calibration sweep).
  Per-channel subnormal-tail masks; Nebular's mask is at 1e-26
  because the f_red density-reduction factor flips sign in the cold
  tail and the comparison becomes precision noise there.

Phase 4d running total after this commit: 14 channels with non-zero
d_out -- 7 analytic and 7 FD bootstrap. 7 remaining: H2Moseley21,
H2Gong17 (4d-d) and CII / OI / CI / OII (4d-e).

Validation: 542 passed, 4 skipped (up from 537 / 4; +5 net new
FD-bootstrap parity tests, no regressions).
- H2Moseley21Cooling and H2Gong17Cooling gain FD-bootstrap d_out
  (forward FD at dT_rel = 1e-3). Lambda computation moves into a
  private `_compute_lambda(state, out)`; evaluate calls it twice.
  Two new scratch slots per channel (T_orig, out_tp).
- pyathena.chemistry.equilibrium new module porting get_xHII,
  get_xCII, coeff_kcoll_H, coeff_alpha_rr_H, coeff_alpha_gr_H from
  pyathena.microphysics into the chemistry-rewrite namespace, plus
  eq_xHII_xe(T, nH, xH2, xi_CR, G_PE, G_CI, ...) wrapper that
  solves jointly for (xHII, xCII, xe) via damped fixed-point
  iteration on xe = xHII(xe) + xCII(xe). H conservation
  xHI = 1 - 2*xH2 - xHII enforced on return.
- test_phase4d_analytic_derivatives: _build_state gains an
  H-conservation assert; new _build_state_eq helper derives
  (xHII, xCII, xe, xHI) from eq_xHII_xe. Prior CR / H2Dissociation /
  H2 formation / pump / colldiss tests get conservation-correct
  triplets (xe ~ 1.6e-4 from xCII in molecular regime). Two new
  tests for Phase 4d-d at chemical equilibrium across the
  molecular-regime grid T = logspace(1, 4, 30) x nH = logspace(0,
  6, 14).

Phase 4d running total: 16/20 channels (7 analytic, 9 FD
bootstrap). 4 remaining (4d-e): CII, OI, CI, OII level-pop.

Validation: tests/chemistry/ 404 passed, 2 skipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Add `state.u_rad: Dict[band, ndarray]` as the primary radiation
  representation (energy density, cgs). Bands: PE, LW, FUV (alias),
  EUV. `chi_for(band)` returns the Draine-normalised dimensionless
  field; ISRF reference densities (`U_RAD_PE_ISRF_CGS = 7.613e-14`,
  `U_RAD_LW_ISRF_CGS = 1.335e-14`) match
  tigris-ncr/src/photchem/photchem.hpp.
- Add `state.zeta_pi: Dict[species, ndarray]` (photoionisation) and
  `state.zeta_diss: Dict[species, ndarray]` (photodissociation),
  replacing the prior `state.xi_ph_HI` / `state.xi_diss_H2` flat
  attributes. Species without a key default to zero; arrays may be
  scalar or `(ncell,)`.
- `xi_CR` (cosmic-ray ionisation) keeps its `xi_*` naming -- the
  `zeta_*` / `xi_*` split now matches PDR / HII literature
  convention used in the rest of the chemistry rewrite.
- networks/base.py: NetworkBase gains `closure_species` (tuple of
  species names solved algebraically from conservation rather than
  as Newton variables) and `slow_species` (per-network default
  empty). `seed_equilibrium(state)` is the new hook for analytic /
  fixed-point initialisation.
- networks/ncr3.py: NCRNetwork3 declares `closure_species =
  ('HI',)`; HI is derived from H conservation
  `xHI = 1 - 2 xH2 - xHII` in `closure(state)`. `seed_equilibrium`
  wraps `equilibrium_seed.eq_xHII_xe` to fill `xHII / xCII / xe / xHI`.
  Rate accessors switch from `state.xi_ph_HI` / `xi_diss_H2` to
  `state.zeta_pi['HI']` / `state.zeta_diss['H2']` via a new
  `_get_rate(state, dict_attr, species)` helper that returns the
  scalar / array value or 0 when missing.
- solvers/_stubs.py: RadiationStub builds the dict-layout
  `state.zeta_pi` / `state.zeta_diss` (and populates
  `state.u_rad['PE']` / `['LW']`) instead of the flat attributes.
- tests/chemistry/{test_driver_smoke, test_ncr3_network,
  test_phase4d_analytic_derivatives, test_solvers_explicit_subcycling}:
  fixtures migrate to the dict layout; assertions unchanged.
Reuse `_coeff_kcoll_H` / `_coeff_alpha_rr_H` / `_coeff_alpha_gr_H`
from `networks/ncr3.py` instead of duplicating the formulas. The
network is the single source of truth for the rate coefficients;
the equilibrium seed and the time-dependent integrator now agree
by construction. File renamed from `equilibrium.py` to
`equilibrium_seed.py` (rename landed in the prior commit) to
reflect its restricted role: produce an initial guess for the
Newton solver in `equilibrium_solver_v2`, not solve the full
equilibrium itself.
Preserve the first-pass equilibrium-solver experiments before
replacing them. This module hosts three candidate routines:
GaussSeidel (cheap but diverges in the cold molecular regime where
C and D self-couple through xe), a naive linear-space Newton (over-
shoots the cold-molecular fixed point by 4 decades), and a damped
variant. Kept as reference for the redesign rationale; the v2 module
in the next commit supersedes this file.
Replace the v1 Gauss-Seidel / linear-space Newton prototypes (deleted
in this commit) with a Newton-Raphson solver that operates in log10
space (`y_i = log10(x_i)`) on the evolved-non-closure species. Key
ingredients:

- Closure species (e.g. `xHI` for NCR3) are removed from the Newton
  variable set and recomputed from `network.closure(state)` after
  each accepted step. F is `C - D * x` restricted to the Newton
  variables.
- Log-space variables linearise the multi-decade transition from
  atomic to molecular regimes; the cold-molecular cell that needed a
  4-decade jump in linear xH2 now needs a 4-unit jump in log xH2,
  which Newton handles in O(10) iterations.
- Armijo backtracking line search guards against overshoot. A
  per-step trust region cap `max_log_step = 2.0` prevents single
  steps from displacing y by more than 100x.
- `numpy.linalg.lstsq` fallback handles singular Jacobians (the
  HII region's H2 row is identically zero when no H2 is present).

Returns a `SolveResult` with per-cell `converged` mask and inf-norm
residual; the regression test in the next commit calls
`max_iter = 60`, `tol = 1e-6` and reaches all 5 ISM-strip cells.
Time-marching alternative to ExplicitSubcyclingSolver for solving
`dx/dt = f(x)` to t_target. Verbatim port of OrdinaryDiffEq.jl's
`Rosenbrock23ConstantCache + perform_step!`:

- Constants `d = 1/(2+sqrt 2)`, `c32 = 6+sqrt 2`. Stage 1-2 build the
  order-2 propagated solution, stage 3 produces the order-3 embedded
  estimate `utilde = (h/6) (k1 - 2 k2 + k3)`.
- W = J - I/(h d) is LU-decomposed once per step via
  numpy.linalg.solve per cell, then reused for 3 RHS vectors.
- Mixed-tolerance norm `sc = atol + rtol * max(|y|, |y_new|)`,
  inf-norm over species per cell. Step accepted when err_norm <= 1
  or h <= h_min. Step-size update `factor = 0.9 * err_norm^(-1/3)`
  clipped to [0.1, 5]; exponent is `1/(p+1)` for order p = 2.
- Per-cell independent h and t -- vectorisation comes from
  evaluating f / J as `(nvar, ncell)` and looping the per-cell
  solve, not from sharing a strip-wide dt. Done cells are masked
  so they neither advance nor reject.
- Optional `y_lo` / `y_hi` clips after each accepted step for
  positivity / physical bounds.

_rosenbrock_chemistry_adapter.make_chemistry_f_jac wraps a Network +
ChemState into the (f, jac) closures the integrator needs. f
evaluates `C - D * x` on the Newton variables; jac uses one-sided
forward FD with `eps_J * max(|y|, floor_scale)` and reuses the
analytic-vs-FD switching the v2 equilibrium solver already does.
- test_equilibrium_solver: regression test. Builds a 5-cell ISM
  strip (cold molecular nH=1e3 and nH=1e6, CNM, WNM, HII region)
  via `_build_strip`, seeds with `NCRNetwork3.seed_equilibrium`,
  and asserts `solve_equilibrium(max_iter=60, tol=1e-6)` converges
  on every cell. `_CELLS` is shared with the figure module.
- test_equilibrium_solver_convergence_figure: per-cell time-marching
  comparison. Each cell is integrated independently by
  Rosenbrock23 and by ExplicitSubcyclingSolver (BE-marched, log-
  spaced sample times). The 2 x 5 figure plots species fractions
  (top) and signed relative imbalance `(C - D x) / (C + D x)`
  (bottom) per Newton species, with Rosenbrock solid and BE
  dashed, colour-coded `xHI = C2`, `xHII = C3`, `xH2 = C0`. Writes
  `tests/figures/equilibrium_solver_convergence.png` (regenerated
  by the test; not tracked).
- Per-cell `tlim_yr` reflects the dominant relaxation timescale of
  the dominant evolved species (HII recomb in the HII / WNM /
  CNM cells, H2 formation in the cold molecular ones); the HII
  region tlim was bumped from 1e4 to 5e4 to clear the ~1.2e4 yr
  recomb timescale, and WNM trimmed from 1e8 to 5e7.
Below ~700 K all four collisional dissociation rate coefficients
(`k9l`, `k9h`, `k10l`, `k10h`) underflow to 0 from `exp(-43900/T)`
and similar, giving `log10(0) = -inf` runtime warnings. The result
is discarded by the `T > temp_coll` mask on the next line, so the
warnings are spurious; wrap the rate composition in
`np.errstate(divide='ignore', invalid='ignore')` to suppress them
locally without perturbing the numbers.
…aptive substep

Port `UpdateChemistry` from `tigris-ncr/src/photchem/ncr_solver.hpp:513-571`
and the post-step adaptive control from Zier+ 2024 (AREPO-RT, MNRAS
533, 268), Section 4.1.1.

- Replace the three independent per-row implicit-Euler updates on
  (HI, HII, H2) with a 2x2 Cramer joint solve for (xH2, xHII) that
  substitutes `x_HI = 1 - 2 x_H2 - x_HII` into the C terms
  algebraically, followed by RAMSES-style clipping (renorm if
  `x_HII + 2 x_H2 > 1`, asymmetric clip branched on
  `x_H2 < 0.25`) and closure `x_HI = 1 - 2 x_H2 - x_HII`. The old
  per-row form did not preserve hydrogen conservation when the
  three species saw each other through stale entry values; the
  Cramer form is conservation-respecting by construction.
- ChemistryConfig gains `f_chem_cap` (default 0.1) and
  `f_chem_target` (default 0.05) for the post-step adaptive
  controller. The metric is `f = max(|delta temp_mu|/temp_mu,
  |delta x_HI|, |delta x_HII|, |delta(2 x_H2)|)` -- relative on
  the substep-invariant `temp_mu = T / mu` (chemistry inside one
  substep changes mu without doing work; `T` would otherwise
  appear to drift while `temp_mu` is constant), absolute on the H
  species (since `x_HI + x_HII + 2 x_H2 = 1`, each term sits in
  `[0, 1]` and an absolute cap of 0.1 means "no more than 10 per
  cent of the H budget moves between species per substep" --
  relative on x would over-constrain dt for trace species).
- Substep flow: snapshot `T`, `temp_mu_old`, `(xHI, xHII, xH2)` at
  entry; cap dt at `min(_estimate_dt_sub, _dt_sub_next,
  dt_remaining)` where `_dt_sub_next` is the forward-controller
  carry-over from the previous substep. Try T update + chemistry
  at that dt; if observed `f > f_chem_cap`, restore the snapshot,
  halve dt, retry (existing T-cap path inside `_attempt_temp_mu_step`
  is unchanged). On accept update `_dt_sub_next = dt *
  min(2.0, f_chem_target / f)` (Kannan+ 2019 / Zier+ 2024 2x
  growth cap).
- Add 11 ncell scratch buffers: `solver:cramer_a..f`,
  `solver:cramer_det`, `solver:xHI_pre`, `solver:xHII_pre`,
  `solver:xH2_pre`, `solver:f_denom`.
- Rewrite the hand-rolled-BE regression test against the new
  Cramer expression: same idea (compare against an analytic
  closed-form at a tiny dt) but the closed form is now the 2x2
  Cramer solution, not the per-row BE formula.

Validation: `tests/chemistry/test_solvers_explicit_subcycling.py` all 8
pass.
- pyathena/chemistry/solvers/rosenbrock.py: `integrate_rosenbrock23`
  step-size bounds (`h_init`, `h_min`, `h_max`) default to fractions
  of `t_target` rather than absolute cgs-second scalars
  (`1e-3`, `1e-12`, `1e-20` previously). Defaults are
  `h_init = 1e-6 t_target`, `h_min = 1e-12 t_target`, `h_max =
  t_target` (natural ceiling: no single step covers more than one
  full window). Signature changes the three knobs from `float` to
  `Optional[float]` so explicit callers behave the same.
- Drop `pyathena/chemistry/equilibrium_solver_v2.py`: the
  Newton-Armijo equilibrium solver is removed since the production
  path now reaches equilibrium by time-marching the BE substep
  (Cramer + Zier-style adaptive control); no root-finder is needed.
- Drop `tests/chemistry/test_equilibrium_solver.py`: the Newton
  regression test went with the solver; the test had been holding
  the shared `_CELLS` / `_build_strip` fixtures only.
- Rename `tests/chemistry/test_equilibrium_solver_convergence_figure.py`
  to `tests/chemistry/test_chem_solver_comparison.py` and pull the
  fixtures inline. The `convergence_figure` suffix referred to the
  Newton convergence test, which no longer exists. The renamed file
  hosts the time-marching Rosenbrock-vs-BE-vs-BE-reference figure.
Rebuild the time-marching comparison around the new BE substep
(Cramer joint solve + Zier-style forward controller). Three solvers
share a common dt scale and the same `_CELLS` strip:

- Rosenbrock23 (`rtol = 1e-2`, `atol = 1e-12`) -- adaptive reference
  for embedded local-error control.
- backward-Euler with the 10 percent rule (`f_chem_cap = 0.1`,
  `f_chem_target = 0.05`) -- production setting.
- BE reference with the 0.1 percent rule (`f_chem_cap = 0.001`),
  seeded with `dt_init_frac = _DT_INIT_FRAC` so the early transient
  is resolved before the forward controller takes over -- treated
  as ground truth.

The earlier file had three "smoke" tests asserting only
`res.shape[0] >= 2`, which a solver returning all-NaN would pass.
Replace them with real regression checks:

- `test_state_finite_bounded_and_H_conserved`: every recorded
  trajectory point is finite, every species fraction sits in
  `[0, 1]`, and `x_HI + x_HII + 2 x_H2 == 1` to round-off. Catches
  NaN explosions, broken positivity clipping, and silent hydrogen
  leaks in the Cramer joint solve.
- `test_solvers_agree_at_tlim`: at `t = tlim`, Rosenbrock and BE
  10 percent both agree with BE reference on `(x_HI, x_HII,
  2 x_H2)` within solver-appropriate tolerances (atol = 1e-2 for
  Rosenbrock, atol = 0.05 for BE). This is the main regression
  check -- catches Cramer or forward-controller drift, Rosenbrock
  atol / y_lo misconfiguration, or any future solver replacement
  that quietly converges to a different fixed point.

Other changes:

- BE is driven one substep at a time via `solver._do_one_substep`
  so the recorded trajectory reflects the solver's actual adaptive
  cadence; the old `solver.step(dt_call)` loop conflated sample
  cadence with substep count.
- BE 10 percent shows as markers (no line) on the figure -- its
  substep cadence is meaningful to display per-step. Rosenbrock
  shows as solid line with dots, BE reference as a thin solid
  line as the trusted curve.
- `_CELLS` gains `xHI_init`; `_build_strip` derives `x_HII` from
  hydrogen conservation. The HII region initial state now starts
  near its equilibrium (xHI ~ 1e-4) so the figure does not waste
  the budget on the initial ionisation ramp.
- Initial dt fraction `_DT_INIT_FRAC = 1e-5` is a module-level
  constant shared by Rosenbrock `h_min`, BE-ref `dt_init_frac`,
  and the figure xlim so the three solvers and the plot start
  from the same dt scale.
- Step count + wall-time summary printed to stdout (with `-s`) and
  written to `tests/figures/chem_solver_comparison_summary.txt`.
- ncr3 collisional-dissociation `log10(0)` warning silenced (was
  noise; result is masked out by the `T > temp_coll` gate).
- `_SEC_PER_YR` derived from `astropy.units` so the value stays
  consistent with the rest of the codebase.
Print the step-count / wall-time table to stdout only (visible
with `pytest -s`); the same numbers are annotated on the figure,
so the text file was redundant.
Comment thread pyathena/microphysics/photchem.py Outdated
jeonggyukim and others added 5 commits July 21, 2026 11:10
Zero a band's mean photoionization cross section only when the whole band lies below the ionization threshold energy (band minimum wavelength past the threshold), instead of comparing the energy-weighted mean wavelength. The mean test wrongly zeroed bands that straddle the threshold, discarding real ionizing photons; the effect grows with band width.
Reviewer on PR #48 found the one-line boolean-mask assignment hard
to read. Extract the mask into bins_below_thres and state the
edge-vs-threshold comparison in a comment. No behavior change.

Refs #48
The four level-population channels gain a temperature derivative via
the forward finite-difference bootstrap used by the other 4d
channels: the Lambda computation moves into _compute_lambda(state,
out), evaluate calls it at T and T*(1+1e-3), and the difference
quotient is multiplied by mu_at_entry. Two new scratch slots per
channel (T_orig, out_tp). An analytic derivative would have to chain
through the steady-state 2-/3-level solve.

Tests compare against a 5-point central reference at rtol 5e-2 on
the broad (T, nH) grid. New _slope_mask helper keeps cells whose
log-slope |d ln Lambda / d ln T| exceeds 0.03 instead of applying an
absolute |d_out| floor: the collisional d_out amplitude scales with
nH, so an absolute floor masks entire low-density rows, while the
log-slope cut drops only the dLambda/dT sign-change cells where the
forward-vs-central curvature term dominates. CII and CI additionally
mask a 0.02-dex neighborhood of their piecewise collision-partner
boundaries (T = 500 K and T = 1000 K).

Phase 4d complete: 20 of 20 channels have a d_out path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Compute recombination rate of the ion with one more charge
n_p1 = den[idx+1]
rc_rate_p1 = n_p1*ne*self.rc.get_rec_rate(Z, N-1, T)
ci_rate = ne*self.ci.get_ci_rate(Z, N, T)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is missing a factor of n no?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confusing, but it's not. See:

# Note that all destruction rates divided by n (hence in units of [1/time])
# while creation rates have [1/time/volume]
drate = rc_rate + ci_rate + pi_rate + ct_drate
crate = rc_rate_p1 + ci_rate_m1 + pi_rate_m1 + ct_crate
n_after = (n + crate*dt)/(1 + drate*dt)


The convention (noted at L714-715) is that destruction rates are per-ion [1/time] while creation rates are volumetric [1/time/volume]. ci_rate is destruction of the current ion (X^q + e -> X^(q+1)), so it's nek only — the density factor comes in through the implicit-Euler denominator at L718, n_after = (n + cratedt)/(1 + drate*dt), which multiplies drate by n_after.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, I was going to re-write this a bit in order to use scipy solve_ivp and make things a little more explicitly conservative by constructing the time derivatives as a matrix of rates which are then multiplied by densities. It seems like you've set things up in this file to work with the HII wind problem though, so should I potentially put that somewhere else?

@jeonggyukim jeonggyukim Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would not restructure the ODE solver inside photchem.py. Backward-Euler is the most robust, inexpensive choice here (although only first-order accurate), and I think it will work well for our use case (NCR hydrogen + multi-ion chemistry).

Perhaps the right place is here:
https://github.com/jeonggyukim/pyathena/tree/photchem-full/pyathena/chemistry/solvers
where I have been testing a Rosenbrock implicit solver. In the future, it would be great if we can extend this to more complicated chemistry networks (e.g. GOW17) that may require more accurate, conservative ODE solver.

jeonggyukim and others added 7 commits July 30, 2026 17:07
Adds tests/chemistry/perf/, skipped unless `pytest --bench`. The
`--bench` flag is declared in tests/conftest.py because pytest only
calls pytest_addoption on initial conftests; a perf-local declaration
would leave the option unknown when the suite runs as
`pytest tests/chemistry`.

The result the phase turns on is not one the plan asked for. The
solver takes a strip-MIN dt_sub, so every cell walks the substep count
of the stiffest cell in its strip. A 256-cell strip spanning five ISM
regimes charges 37,888 cell-substeps against an ideal of 11,161
(3.39x). Unsorted splitting recovers nothing, because a random chunk
almost always contains the stiffest regime. Sorting by stiffness and
splitting into four gives 15,232 (1.36x), saturating near 1.1x by
eight chunks. The unit is cell-substeps rather than seconds, so the
counts transfer to the C++ port unchanged.

The plan's own SIMD criteria could not be evaluated with a Python
proxy. The strip-size sweep measures NumPy per-call dispatch, not
vectorisation: 256 cells cost 1.07x the wall time of one cell at equal
substep count. The branch-free versus branched numba ratio came out at
exactly the flop ratio (0.50x), which cannot separate "neither
vectorises" from "both vectorise equally".

Benchmark outputs under results/ are git-ignored, following the
tests/figures/ precedent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CoolingChannels.update summed the per-hydrogen channel outputs and
handed them to a solver expecting a volumetric rate, so solver:net_cool
was short one factor of n_H. Channels emit n_H * Lambda_NCR under the
per-H^2 convention of tables/chianti_v11/build_cool.py, where the
volumetric rate is n_H^2 * Lambda_NCR.

Because channel output scales as n_H and inv_heat_cap_per_temp_mu =
(gamma-1)/(n_H mu_hyd k_B) scales as 1/n_H, the two cancelled exactly
and d(temp_mu)/dt came out independent of density. The solver was
correct only at n_H = 1 cm^-3: cooling ran 1/n_H too fast in diffuse
gas and n_H too slow in dense gas. Measured over four decades at fixed
T and composition, the pre-fix rate was constant to five significant
figures where it must be linear in n_H.

The C++ applies the same multiplication in the solver rather than the
channel (tigris-ncr/src/photchem/ncr_solver.hpp:263,
var.cool_rate = var.nH * cool * ...).

Per-regime effect over a 1 Myr step, substep count before to after:
hot (n_H 1e-2) 350 to 1, HII (1e1) 42 to 142, CNM (5e1) 27 to 40, cold
molecular (1e3) 1 to 37. Gas at 1e6 K fell to 1.07e3 K and now falls
to 9.51e5 K.

test_aggregator_update_matches_channel_sum asserted the old contract
and had to change; it codified the defect. New
test_cooling_rate_scales_linearly_with_density asserts d(temp_mu)/dt
is proportional to n_H across 1e-2 to 1e2 at rtol 1e-12. It runs
against a stub channel with a density-independent Lambda_NCR, not the
production set: level-population cooling saturates above its critical
density, and photoelectric heating and grain recombination depend on
psi = G sqrt(T)/n_e, so the production channels carry a genuine
density dependence of about 18 percent over that range.

Note the defect is invisible at n_H = 1, so a fixture pinned there
would not catch it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ChargeTransferRate accepts the table interpolation modes; the
constructor no longer raises NotImplementedError for them. This was the
last of the three rate modules without a table path. Grid and encoding
match ci_rate and rec_rate exactly (2000 points, 1 K to 1e9 K, shared
log-T axis), so one temperature index can drive a lookup across all
three.

Two tables, one column per (Z, N) reactant: 114 ionization columns (92
identically zero) and 110 recombination. Columns are filled by calling
the analytic getters, so the four hand-fitted ions and the per-ion
Tmin/Tmax clamping are captured without the builder knowing about them.
Temperature-independent charge branches stay outside the table.

Recombination uses a clamp-aware path. Tabulating the analytic form
directly cost 8.9e-2 worst-case relative error, entirely from the clamp
kink: the analytic path clips T and then evaluates, leaving a slope
discontinuity at each window edge, and interpolating across the
straddling bin is what loses the accuracy. Refining the grid does not
help (8.9e-2, 7.4e-3, 1.0e-2, 7.5e-4 across _TAB_N 2000 to 16000,
non-monotonic, depending on whether a node lands near each kink).
Recombination columns are therefore built from the unclamped fit with
the clamp applied to T at lookup, which moves the kink out of the
interpolant and keeps the clamp exact.

Accuracy where the rate exceeds 1e-4 of each ion's peak:

  mode     ionization  recombination
  LogLog   7.5e-3      2.8e-4
  Nqt2     1.1e-2      9.9e-3
  Nqt1     1.5e-2      1.1e-2

Recombination under LogLog now holds the same rtol 1e-3 bar as
ci_rate. Ionization keeps the clamped-form path at 7.5e-3: unclamping
it would also need exp(-dE * 1e4 / T) at lookup, and a transcendental
in the lookup works against tabulating. Only 8 of 120 rows carry a
nonzero dE_rate1.

The significance mask matters for C II, whose recombination fit has a
root at 5397 K just below its Tmin2 of 5500 K. At the clamped floor it
evaluates to 0.2 percent of its leading term, so relative error there
is meaningless: unmasked it reads 3.3e-2 against 2.9e-3 inside the
fit's own window.

Exact remains the default, so the rtol 1e-12 parity test against
microphysics is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TemperatureTable holds one copy of the build-and-look-up code that
ci_rate, rec_rate and ct_rate each grew separately, so cooling channels
can tabulate their temperature-only factors without a fourth. It uses
the same grid as the three rate modules (2000 points, 1 K to 1e9 K),
which is what lets one log-T index drive a lookup across every
tabulated quantity in a cell.

lookup(T, out, scratch) is allocation-free, which is the property that
made this worth extracting: the substep loop is allocation-free by
design (assert_no_alloc), so a lookup that allocated seven intermediate
arrays per call could not be used there. Every step writes through
out=, and np.take replaces fancy indexing.

It also carries the clamp-aware pattern from the charge-transfer work.
Pass an unclamped fn plus its (Tmin, Tmax) window and lookup clips T
into it, keeping the slope discontinuity out of the interpolant.
test_clamp_beats_tabulating_the_clamped_form pins that as a measured
comparison rather than a claim in a comment.

Accuracy on the free-free temperature factor: LogLog 4.8e-7, Nqt2
8.4e-4, Nqt1 1.1e-3.

test_grid_endpoints_are_reproduced caught a real defect while this was
being written. Clipping the float index to TAB_N - 2 before the floor
discards the top node, since frac can then never reach 1, so the last
grid point returned its neighbour's value.

No channel uses this yet; wiring follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The helper as first committed only offered lookup(), which recomputed
the axis position for every table. That does not deliver the benefit
the module docstring claimed for it.

Finding a cell's position costs a transcendental; interpolating at a
known position does not. So tabulation only pays if a cell resolves the
position once and interpolates many times. Lyman alpha is the case that
exposed it: its two temperature-only factors replace three analytic
transcendentals, but two independent LogLog lookups cost two
transcendentals each. Four to replace three is a loss. One locate plus
two interp calls costs two.

locate(T, idx, frac) and interp(idx, frac, out, lo, hi) are now
separate; lookup is the two back to back and remains the only option
for a clamped table, whose clip changes T and so cannot share a
position.

Both stay allocation-free: interp reaches the upper node through a
stored view onto _tab[1:] rather than forming idx + 1, and locate
truncates with np.copyto(casting='unsafe') rather than astype.

Scratch order is now (idx, frac, lo, hi).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds CoolingChannel.T_FADE = (T_full, T_off) and ramps it in the
aggregator with the C2 Hermite smootherstep. This is the first
mechanism in the cooling path able to express that a channel does not
apply at a given temperature; until now CoolingChannels.update called
every channel's evaluate unconditionally at every temperature.

The derivative uses the product rule, dL_eff/dT = w dL/dT + L dw/dT.
Scaling d_out alone would drop the term carrying the switching itself
and leave d_net_cool_d_temp_mu wrong exactly inside the ramp. C2 rather
than C1 because the solver differentiates the cooling function.

Fixes NaN in solver:net_cool above 1.13e7 K. The C I three-level solve
overflows there and NaN * 0 is still NaN, so a channel whose abundance
was 1e-20 poisoned the whole cooling rate. Channels wholly outside
their window are now skipped rather than evaluated, and where the
weight is zero the result is assigned zero rather than multiplied by
it.

Fades applied to the three dust-coupled channels over the C++ hot-gas
window, on the thermal-sputtering argument, and to the four
fine-structure channels a decade above where CIE puts their ion
fraction below 1e-4, as margin against underionization.

At nH = 1e-2, T = 1e6 K with metals zero, net Lambda_e goes from
3.70e-23 to 2.46e-24 against a metal-free CIE reference of 5.00e-24;
the remaining factor of two is helium, which the network does not
track.

The NaN was found by the new CIE consistency test on its first run.
With photoionization and cosmic rays off the network relaxes to
collisional ionization equilibrium, so its cooling is directly
comparable against an independent CIE table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fade figure showed the test channel's invented (1e4, 1e5) window,
which is not a value anything uses. It now plots every production
T_FADE on one axis so the windows can be checked against where each ion
or grain population is expected to survive, keeping the machinery
verification on the test channel below it.

Two things the figure makes visible. The derivative panel shows the
analytic curve tracking the finite difference through the ramp,
including the excursion to -0.95 that is the L dw/dT term; scaling
d_out alone would leave it flat at +1 there. And the dust window
(2e4, 3.5e4) is 0.24 dex against a full decade for the fine-structure
channels, so its dw/dT is about sixty times larger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants