Skip to content

Add utu.spectrum.synthesize, the spectral radiance of each line - #14

Merged
roytsmart merged 2 commits into
mainfrom
feature/synthesize
Oct 15, 2026
Merged

roytsmart merged 2 commits into
mainfrom
feature/synthesize

Conversation

@roytsmart

@roytsmart roytsmart commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

This adds utu.spectrum.synthesize, which turns the contribution functions of some lines and an emission measure into the spectral radiance of each line in bins of velocity. It is the first step toward simulated ESIS-II images built from a DEM, so that the esis report (esis-mission/esis#72), the ESIS-II paper and the ctis inversion tests can all get the same scene from installed code.

What it computes

$$L(\lambda) = \frac{1}{4\pi} \sum_T G(T)\,\mathrm{EM}(T)\,\phi(\lambda; T), \qquad \sigma^2 = \frac{k_B T}{m} + \sigma_\mathrm{nt}^2$$
  • Profile: a Gaussian for each line at each temperature. It is centered on the line-of-sight velocity of the plasma (shift), and its width is the thermal motion of the ion added in quadrature to a nonthermal width.
  • Bins: the radiance of each bin is the integral of the profile across it, divided by the width of the bin in wavelength. So a line's radiance is conserved even on coarse grids, as long as the bins span the line. The integral comes from the complementary error function of the tail the bin is in, so it stays precise far into the wings; a difference of erf rounds every bin beyond about 8.5σ to zero.
  • Inputs: contribution is the output of contribution_function(), unchanged. mass (such as fiasco.Ion.mass) sets the thermal width; give it along the line axis to do several ions at once. emission_measure is ∫ n_e n_H dh per temperature bin, the convention of fiasco's G(T).
  • Axes: every axis of the emission measure other than temperature is carried through, such as the two axes of a map. shift and width_nonthermal can vary along any of them, including temperature.
  • Output: a FunctionArray whose inputs are a na.DopplerVectorArray of the bin vertices (wavelength plus rest wavelength, so .velocity works) and whose outputs are in erg cm⁻² s⁻¹ sr⁻¹ Å⁻¹, along the line and velocity axes.
  • Out of scope: instrumental width, line selection, natural and pressure broadening, and opacity.
  • Memory and speed: the result is preallocated and filled one velocity bin and one block of lines at a time. Every intermediate array stays under _size_block (2²⁵ elements), so a map needs little more memory than the result itself. When nothing the profile depends on varies along the line axis, the profile is computed once for all lines.
  • Errors: a ValueError for a missing velocity or temperature axis, a velocity grid that varies with temperature, a temperature that isn't positive, or an argument whose line axis disagrees with contribution's. An argument with one element along the line axis broadcasts, and an empty contribution gives an empty result.

Other changes

  • Tutorial: a new "Line profiles" section broadens O V by 0 and 20 km/s and shows that both profiles sum to the radiance in the table above it.
  • EM convention fix: the tutorial called the emission measure ∫ n_e² dh. fiasco leaves n_H/n_e out of its contribution function, so the emission measure that goes with it, and with lines(), is ∫ n_e n_H dh. The tutorial, lines() and contribution_function() now say so.
  • scipy is listed as a dependency, for erfc. It was already installed through fiasco.

Tests

test_synthesis.py has 23 tests, 22 of which use made-up contribution functions, so they run on every job of the matrix, not only the CHIANTI one. They check that:

  • the type, units and axes are right, and the inputs' velocities are the ones asked for;
  • the bins hold the whole radiance on a fine grid and on a coarse one (8 bins over ±400 km/s);
  • a line at rest is symmetric, and bins from fast to slow give the same result reversed;
  • the centroid moves with shift (−30, 0, +45 km/s);
  • at one temperature the variance is kT/m plus the nonthermal width squared, plus Sheppard's correction for the bins (to 1e-6);
  • the wing beyond 8σ holds erfc(8/√2)/2 of the line (to 1e-6), and every bin out to 25σ is positive;
  • a single line without a line axis gives the same profile as the same line within several;
  • a map whose shift and nonthermal width vary across it and with temperature gives each pixel the result of computing it alone;
  • axis names other than the defaults give the same result;
  • each new ValueError is raised, an empty line axis gives an empty result, and a length-1 mass broadcasts;
  • synthesizing in one-line blocks gives the same map as one block;
  • with the database, the total over the lines of O V equals lines() divided by 4π sr (to 1e-10).

I checked that the tests can fail. Doubling the thermal variance, dropping the 4π, or flipping the sign of the shift each fails two tests, and going back to a difference of erf fails the wing and reversed-bin tests.

Locally: the full suite with --doctest-modules passes (108 tests), the docstring example and every block of the tutorial run, and black and ruff pass.

The second commit fixes the 13 findings of an xhigh code review of the first.

🤖 Generated with Claude Code

https://claude.ai/code/session_017xqpht97bF4FWsACAA4wQa

Spread each line of `contribution_function()` into a Gaussian in bins of
velocity around its rest wavelength. Its width is the thermal motion of the
ion added in quadrature to a nonthermal width, and its center is the
line-of-sight velocity of the plasma. The plasma at each temperature has a
profile of its own, and the profiles are summed over temperature with the
emission measure. Every other axis of the emission measure, such as the two
axes of a map, is carried through, and the shift and the nonthermal width
can vary along any of them.

The radiance of each bin is the integral of the profile across it, so the
radiance of a line is conserved on coarse grids. The result's inputs are a
`DopplerVectorArray` of the bin vertices.

The tutorial gains a section on line profiles, and now gives the emission
measure as that of n_e n_H, which is what fiasco's contribution function
goes with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xqpht97bF4FWsACAA4wQa
@read-the-docs-community

read-the-docs-community Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

@codecov

codecov Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (e269f91) to head (89f51a4).

Additional details and impacted files
@@            Coverage Diff             @@
##              main       #14    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files           17        19     +2     
  Lines         1055      1285   +230     
==========================================
+ Hits          1055      1285   +230     

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

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

- Raise a ValueError when `velocity` lacks `axis_velocity` or varies with
  temperature, when `temperature` or `contribution` lacks the axis of
  temperature, when a temperature is not positive, and when an argument
  varies along the axis of lines differently than `contribution` does. A
  velocity axis of the wrong name used to give a result of NaN.
- Broadcast arguments with one element along the axis of lines, and return
  an empty result for a `contribution` with no lines, instead of raising.
- Take the fraction of a profile in a bin from the complementary error
  function of the tail the bin is in. The difference of error functions
  rounded every bin beyond about 8.5 standard deviations to zero.
- Fill a preallocated result one bin of velocity and one block of lines at
  a time, keeping every intermediate array under `_size_block` elements,
  and compute the profile once for all lines when nothing it depends on
  varies along the axis of lines.
- Take the width of a bin in wavelength from the width of the bin in
  velocity, rather than as the difference of two Doppler-shifted
  wavelengths.
- Say in the docs that the emission measure is per bin of temperature, not
  a DEM per unit log T, and document the n_e n_H convention in `lines()`
  and `contribution_function()` as well.
- Test axis names other than the defaults, every new error, an empty and a
  broadcast axis of lines, reversed bins, the far wings, nonthermal widths
  which vary over a map, and synthesis in several blocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xqpht97bF4FWsACAA4wQa
@roytsmart
roytsmart merged commit e0f889e into main Oct 15, 2026
13 checks passed
@roytsmart
roytsmart deleted the feature/synthesize branch October 15, 2026 21:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant