jwspecfit — least-squares fitting
jwspecfit is the least-squares engine: it fits resolution-aware,
bin-averaged Gaussian profiles to all observable emission lines
simultaneously, and returns a FitResult with fluxes, SNRs, centroids,
widths, and equivalent widths.
Note
For science-quality uncertainties the authors recommend the Bayesian MCMC
fitter, jwspecmcmc. Use this least-squares engine for quick
looks, initial guesses, and fast BIC model selection.
Loading spectra
import jwspecfit
spec = jwspecfit.read_fits("spectrum.fits", z=6.0)
read_fits accepts both the JWST SPEC1D BinTable convention
(default) and arbitrary 1-D FITS files: it auto-detects column names
(wave/wavelength/lam/lambda/loglam, flux, err/sigma/ivar),
reads units from the TUNIT keywords (μm/Å/nm, μJy/mJy/Jy,
erg/s/cm²/Å) and falls through to image HDUs with WCS keywords
(CRVAL1, CDELT1, CRPIX1, CTYPE1, CUNIT1, BUNIT).
Override the auto-detection with hdu=, wave_col=, flux_col=,
err_col= if needed.
See the quickstart guide for the full list of
readers (read_fits, read_dict, read_npz).
Quick interactive view
To pop a spectrum open in plotly without first running a fit, call
plot_spectrum_interactive directly on a path or a Spectrum:
fig = jwspecfit.plot_spectrum_interactive("spectrum.fits", z=6.0)
fig = jwspecfit.plot_spectrum_interactive("stack.npz", rest_frame=True)
fig = jwspecfit.plot_spectrum_interactive(spec, flux_unit="flam")
fig.show()
The plot has zoom/pan, hover read-outs, and a ±1σ band whenever an error array is available.
Fitting
result = jwspecfit.fit_lines(spec, z=6.0)
Common options:
result = jwspecfit.fit_lines(
spec, z=6.0,
lines=["OIII_5007", "OIII_4959", "HBETA"], # restrict line list
wave_range_A=(35000, 50000), # wavelength window
wave_windows_A=[(13000, 17000), (35000, 52000)], # multi-window
n_boot=500, # bootstrap iterations
mode="auto", # broad-Balmer mode
niv_doublet_ratio=None, # fix NIV ratio
ciii_doublet_ratio=None, # fix CIII] ratio
)
Inspecting results
Each LineResult contains:
Field |
Description |
|---|---|
|
Integrated line flux (erg / s / cm²) |
|
1σ uncertainty from bootstrap |
|
Signal-to-noise ratio |
|
Rest-frame equivalent width (Å) |
|
Peak amplitude |
|
Observed centroid wavelength (Å) |
|
Gaussian sigma (Å, incl. instrumental broadening) |
Upper limits for undetected lines:
ul = result.flux_upper_limit("OIII_4363", n_sigma=3.0)
uls = result.flux_upper_limits(n_sigma=3.0)
Broad Balmer components
Four nested models are tested and selected via BIC when mode="auto":
Narrow only.
Narrow + intermediate broad (FWHM ~ 500–2000 km s⁻¹).
Narrow + very broad (FWHM ~ 2000–5000 km s⁻¹).
Narrow + both broad components.
result = jwspecfit.fit_with_broad(spec, z=2.0, n_boot_bic=100)
print(result.selected_model) # "narrow" | "broad1" | "broad2" | "both"
UV and absorption lines
Lines prefixed with abs_ are fit as negative Gaussians (low-ionisation
ISM absorption, DLA flanks):
uv_lines = [
"NV_1", "NV_2", "CIV_1", "CIV_2", "HEII_1640",
"CIII]_1907", "CIII]",
"abs_SiII1260", "abs_CII1334", "abs_SiIV1394", "abs_SiIV1403",
]
result = jwspecfit.fit_lines(spec, z=6.0, lines=uv_lines)
DLA fitting
jwspecfit.fit_NHI performs a joint Bayesian fit of the local DLA
column density and the IGM damping wing, following the methodology
of Pollock et al. (2026) Eq. 4:
The posterior over (log_F0, β_UV, log_NHI) (and optionally x_HI,
plus a Lyα emission profile) is sampled with dynesty nested
sampling. The Voigt profile is exact (Faddeeva); the IGM term uses
the Miralda-Escudé (1998) closed form integrated from igm_z_min
(default 5.3, Bosman+22) up to the source redshift.
Install the optional dependency first:
pip install jwspecfit[dla] # installs dynesty
Standard call
import jwspecfit
spec = jwspecfit.read_npz("stack_zgt6_Muv19_21_prism.npz", z=0.0, R=400.0)
result = jwspecfit.fit_NHI(
spec.wave_A, spec.flux_ujy, spec.err_ujy,
z=0.0, # 0 for rest-frame stacks
R=spec.R, # LSF convolution
fit_x_HI=False, # set True for individual z>6 spectra
n_live=400, seed=42,
)
print(result.summary())
result.plot()
The defaults match Pollock+26: priors log_NHI ∈ [18, 24],
x_HI ∈ [0, 1], β_UV ∈ [-4, 0], log_F0 auto-centred ±3 dex
around the data; fit window 1216–3000 Å rest-frame; emission lines
masked with mask_width_A=20 Å; Lyα emission optionally added
inside the exp(−τ) factor.
Useful options
Argument |
Purpose |
|---|---|
|
Sample IGM neutral fraction jointly (Pollock+26 z > 6 mode) |
|
Lower-z bound of the IGM integration (default 5.3) |
|
Voigt Doppler parameter (default 30 km s⁻¹; insensitive at log N_HI > 20.3) |
|
Override the uniform prior bounds |
|
Default |
|
Default (1216, 3000) Å rest-frame |
|
Extra rest-frame regions to ignore |
|
Pre-correct the spectrum for foreground attenuation |
|
Joint or fixed Lyα emission profile |
|
dynesty controls |
The returned DLAResult carries posterior samples for every free
parameter (result.samples is a dict of arrays), median ± 16/84 %
percentiles, log-evidence, and a 95th-percentile upper limit on
log_NHI (result.log_NHI_upper95, result.is_upper_limit=True)
for spectra where the wing is unconstrained — Pollock+26 do exactly
this for 21 of their 48 sources.
D_Lyα equivalent-width statistic (Heintz+25)
When the N_HI–β degeneracy bites at low spectral resolution, the more robust diagnostic is the rest-frame equivalent width of the Lyα absorption integrated 1180–1350 Å (Heintz+25, Eq. 1). It is β-prior–independent to first order and directly comparable across the high-z DLA literature.
DLya = jwspecfit.compute_D_Lya(spec.wave_A, spec.flux_ujy, spec.err_ujy, z=0.0)
print(f"D_Lyα = {DLya['D_Lya']:.1f} ± {DLya['D_Lya_err']:.1f} Å")
# Interpretation (Heintz+25 §3.1):
# D_Lyα < 0 → Lyα emitter
# ~0 to 35 Å → weak / no absorption
# 35 to 50 Å → IGM-dominated regime
# > 50 Å → DLA-dominated (log N_HI ≳ 21.5)
Caveats at PRISM resolution
The N_HI–β posterior covariance is r ≈ −0.96 at PRISM R~100
(Keating+25), so log N_HI shifts by ~1 dex when the β prior is
tightened from [-4, 0] to a star-forming-galaxy range like
[-3, -1.5]. For a stack at z > 6 with PRISM data, report D_Lyα
as the primary statistic and treat any specific log N_HI as a
prior-conditioned posterior. Robust column densities require
medium-resolution gratings (G140M / G235M, R ≳ 1000).
Plotting
fig = jwspecfit.plot_fit(result, save_path="fit.pdf")
fig = jwspecfit.plot_fit_interactive(result) # plotly
See the Plotting & visualisation page for the full set of static and interactive helpers, line markers, and the 2-D + 1-D viewer.
Save / load
jwspecfit.save_result(result, "fit.npz")
loaded = jwspecfit.load_result("fit.npz")
jwspecfit.export_lines_txt(result, "lines.txt")
Why resolution-aware?
NIRSpec prism resolving power R varies from ~30 at 1 μm to ~300 at 5 μm,
so narrow emission lines are often sub-pixel in the blue and
super-pixel in the red. Evaluating a Gaussian at pixel centres
biases the recovered flux systematically downward for under-sampled
lines. jwspecfit computes the analytic integral of each Gaussian
across each pixel’s bin edges using the error function, eliminating this
bias.