lazuli-exo: model, tables and caveats
Reference material moved out of the README. The README stays short; this file keeps the physics, the table pipeline and the known approximations.
Noise tables: 2026-10-11 regeneration and provenance
The 14 noise tables were rebuilt in one pass on main with slicersim 1.3.4
and the narrow field pinned, picking up #33 (saturation at the last read),
#34 (one PHOENIX SED for both paths), #37 (pixel-averaged SED, one reset
frame per ramp) and #44 (solar-metallicity grid, log g to 5.5). Against the
2026-10-04 tables the single-transit errors rose by 2-30 % at the median
(shorter ramps and the reset overhead), most at the bright hosts; HD 209458 b
and HAT-P-11 b now desaturate fully, HD 189733 b keeps 324 saturated pixels
at the (2,1,0) floor with the 0.7 s subframe and still carries the warning.
Every table’s meta records package_version, git_commit (of the code
that built it), slicersim_version, instrument_config_sha256 and
phoenix_grid_sha256 (#4), and index.json (schema 4) holds a sha256 per
table file. make_noise_tables.py refuses nothing but marks the commit
-dirty when generator code is uncommitted.
Bring your own spectrum
simulate(star, spectrum=(wl_um, depth)) bypasses the parametric atmosphere
and runs the rest of the pipeline on a user transit depth (R_p/R_s)^2: the
PHOENIX star, live slicersim noise, stellar contamination and the LSF /
photon-weighted binning are identical to the parametric path (feeding the
parametric model’s own native-grid output back in reproduces its binned
depths to 0 ppm). Only teff_K, logg, feh, mag, t14_s are required;
R_p_RJ/R_s_RSun set flat_depth when given, else it is the median
depth. The spectrum must be increasing in wavelength and reach into the
first and last bin; give it a few pixels of margin beyond 0.40-1.70 um,
because the LSF convolution reaches past the band and the interpolation
holds the edge value there (a spectrum cut exactly at the band edge moves
the last bin by ~100 ppm for WASP-39 b). This is the intended route to an
accurate atmosphere; a POSEIDON backend inside the package is not planned,
because POSEIDON and slicersim do not share an environment.
Parametric model
lazuli_exo.simulate(params) builds a spectrum for any transiting planet from a
shared POSEIDON-derived opacity grid (src/lazuli_exo/data/tables/atmo/zeta_grid.npz) and a
PHOENIX stellar grid (src/lazuli_exo/data/tables/star/phoenix_grid.npz), with slicersim
computing the noise live (needs slicersim and sncosmo at call time;
import lazuli_exo itself stays numpy-only):
import lazuli_exo
p = lazuli_exo.presets.showcase("WASP-39 b", "cloudy") # or build the dict by hand
p.update(mu=None, log_P_cloud_bar=-2.0)
obs = lazuli_exo.simulate(p, R=100, n_transits=5)
obs.detect()
Knobs: T_eq_K, R_p_RJ, M_p_MJ, R_s_RSun, log_met (-1..3), c_to_o (0.3..1.0), mu (None = equilibrium-chemistry value), log_P_cloud_bar, log_a_haze, haze_slope, teff_K, logg, feh, mag, band (J/H/Ks/V), mag_system ("vega" default, or "ab"), t14_s. R_p is the band-median (0.40-1.70 um) transit
radius, which replaces the P_ref convention for this path. The physics is
zeta(lambda) = -ln P_photo / bar (tabulated: T, [M/H], C/O; two fiducials)
zeta_p = zeta + 0.5 ln[(R_p H) / (R_p,fid H_fid)] (blended between the fiducials)
+ 1.5 s [ln(r(lambda)/R_p) - ln(r_0(lambda)/R_fid)] (local slant column, iterated)
tau(z) = exp((zeta_p - s gamma_E - z)/s) + exp(zeta_haze + 1.5 ln(r/R_p) - z)
zeta_eff = zeta_d + int_0^inf [1 - exp(-tau(zeta_d + u))] du
R(lambda) = R_p / (1 - (H / R_p) (zeta_eff - median zeta_eff)), depth = (R / R_s)^2
(zeta_p is an effective-radius level, s gamma_E above the gas tau_chord = 1
level for an exponential tau profile with slant-column exponent s read off
the two fiducials; zeta_haze = -ln P_haze is the haze tau_chord = 1 level
with exponent 1; the integral is the thin-atmosphere effective radius of the
summed profile over an opaque deck at zeta_d = -ln P_cloud, which for pure
gas is zeta_d + s Ein(exp((zeta_p - zeta_d)/s - gamma_E)); the last line is
the exact isothermal hydrostatic solution with g ~ 1/r^2; H = k T_eq /
(mu m_H g) with g at R_p). The chord column at wavelength lambda is
sqrt(2 pi r H(r)) ~ r^1.5 at that wavelength’s own radius r(lambda), not at
R_p, so the gas level gains 1.5 s ln(r/R_p) less the same term for the
fiducial’s own radius r_0(lambda) (already in the grid) and the haze level
gains 1.5 ln(r/R_p); r is the effective radius of the previous iterate and
the fixed point converges to 1e-4 in ln r in 2-4 steps (contraction
~1.5 s H/R_p; info["column_iterations"], info["column_correction_max_H"]).
This matters for puffy planets (H/R_p >= 0.015; a 1 R_J, g = 1.7 m/s^2,
1000 K planet goes from 0.93 to 0.98 of the brute-force amplitude); the
residual 0.1-0.2 H at H/R_p = 0.033 is the second-order sphericity term
(the chord sampling r(z) across the tau profile, not just at r_eff), which
is not modelled. zeta is stored at the model’s native
R = 10,000 so that a cloud deck clips individual line cores, as in POSEIDON,
and on two fiducial planets (1 R_J at g = 10 m/s^2; 0.3 R_J at g = 30 m/s^2) so that the
pressure dependence of the line cores is captured to first order.
lazuli_exo.observe(planet, scenario) runs the same model at the frozen
registry values (presets.showcase) and combines it with the precomputed
noise table, so the showcase and parametric paths share one model and one
radius convention (issue #3). The 14 x 5 POSEIDON spectra in
precompute/tables/models/ are validation data only: isothermal, equilibrium
chemistry, gray deck, power-law haze (MacMad17 form, anchored at 0.35 um).
Validation is precompute/validate_zeta_grid.py (run by
tests/test_validation_report.py against the committed reference sets; it
fails, never skips, if they are missing). Each of the 70 showcase cases is
binned to R = 100 over 0.40-1.70 um with the constant offset removed and
gated against that planet’s single-transit per-bin sigma: worst bin < 1 sigma
(G2), error-spectrum template S/N sqrt(sum (res/sigma)^2) < 3 (G5),
template S/N ratio model/table within 10 % (G3), and for the featureless
flat scenario a model amplitude < 10 ppm. A second set,
precompute/tables/holdouts/zeta_holdouts.npz, holds direct POSEIDON zeta
and depths at 15 off-node points (T = 650/1100/2450 K midpoints, 567/736/
1166 K, [M/H] 1.75, C/O 0.9, x = +0.63) so that grid interpolation itself is
gated, not only the node values every showcase planet sits on. Expected
failures are listed in WAIVED with the gate they waive and the issue that
removes them (#35 grid regeneration); a waiver
whose gate passes is itself an error. Note the convention difference: the
POSEIDON tables put the archive radius at 10 bar, the parametric path puts
it at the transit photosphere, ~10 scale heights higher; the validation
therefore feeds the model each table’s own band-median radius. The transit
depth baseline k = R_p/R_s is taken from the registry radii (R_p_RJ,
R_s_RSun) on both paths and recorded as meta["k_source"]. The registry
freezes one radius ratio per planet (issue #7): k is the archive
pl_ratror (fallback pl_radj/st_rad, see k_source), R_p_RJ is derived
as k R_s, and depth is k^2 by construction; the archive pl_radj is
kept as the informational R_p_archive_RJ (WASP-39 b: 1.279 vs 1.331 R_J
derived, a 7.7 % depth difference). T_eq_K is the archive pl_eqt as
frozen, not recomputed, because the POSEIDON validation tables were built at
those temperatures; T_eq_conv_K = T_eff sqrt(R_s / 2a) (A = 0, full
redistribution) is stored alongside, and four planets deviate from it by
more than 4 %: GJ 3470 b (0.87), HAT-P-11 b (1.04), WASP-107 b (0.955) and
K2-18 b (1.06, via the registry a/R* of 83.83 rather than its T_eq).
Stellar SED. star.phoenix_sed(teff_K, logg, feh) interpolates the
pysynphot PHOENIX phoenixm00 folder (2000-12000 K, log g 3.5-5.5, R = 1000
storage, trilinear in log F / log T_eff). It is solar metallicity by
construction: the non-solar pysynphot folders are a different, low-resolution
(R ~ 741) product with missing log g columns and cool-star spectra wrong by
1-3 mag in V-J, and Gaia XP prefers the solar node for every metal-rich FGK
host (issue #34). The feh argument is accepted for API stability but any
non-zero value is clamped to 0 and reported in info["clamped"]["feh"]; T_eff
below 2000 K and log g outside 3.5-5.5 clamp to the edge node the same way
(info["clamped"]["teff"/"logg"]), T_eff above 12000 K raises. log g 5.5 is
only tabulated below 5100 K and log g 5.0 below 10200 K; hotter nodes repeat
the nearest available column (meta["filled_from_nearest_logg"]).
precompute/make_noise_tables.py builds the noise tables from this same SED,
so observe() and simulate() share one stellar spectrum (the committed
noise tables were regenerated this way on 2026-10-11, issue #34); the POSEIDON
star tables star/<slug>.npz are validation data only.
Spectrograph field. depth_error rescales the slicersim target to the
narrow field by default (spectrograph_mode="narrow") instead of relying on
slicersim’s default: from slicersim 1.3.3 a freshly built LazuliTarget is
left un-rescaled, with half the narrow-field throughput, which made the live
path 1.4x noisier than the committed tables (built in the narrow field with
slicersim 1.0.0). Pass spectrograph_mode="wide" for the wide field.
Magnitudes. slicersim normalizes in AB; archive J magnitudes are Vega.
lazuli_exo.slicer.depth_error converts (J: +0.90 mag via sncosmo) and reports
the offset in info. The committed noise tables were regenerated with this
conversion on 2026-10-04 and are 1.4-1.5x more conservative than before for
the eleven hosts slicersim reported as unsaturated. That report was wrong for
12 of the 14 hosts (saturation was tested at the (n-1)-group signal, see
“Known approximations”); the committed tables still carry it until the
regeneration tracked in #31, which will also apply the pixel-averaged SED and
the ramp reset overhead below (median depth_err x1.09-1.13 for GJ 1214 b,
TRAPPIST-1 e and WASP-39 b, x1.6-1.9 at 0.45 um; x1.14-1.30 for the three
bright hosts at the 0.7 s subframe).
Instrument response (binning, GH #5). A noise-table channel is one
detector pixel column (525 px, 0.400-1.699 um); slicersim’s LSF is a Gaussian
of sigma = 1.15 px, constant in pixel space (resolution element 2.3 px,
config/lazuli_cbe.toml dispersion_resolution), so the physical resolving
power is R_instr = lambda / (2.3 dlambda_px) = 101 (0.99 um) to 369
(0.40 um), median 156 (binning.R_instr). binning.instrument_response
puts the R=10,000 model and the stellar photon flux on a fine grid uniform in
pixel coordinate (100 points/px), convolves F (1 - d) and F with the LSF,
integrates per pixel and returns 1 - sum_in/sum_out per channel; the
within-pixel photon weight is the R=10,000 star table on the observe() path
(uniform if the table is missing) and the R=1000 phoenix_sed on the
simulate() path (coarser; ~76 ppm for GJ 1214 b at R=100 per the review).
The constant-R bin edges are snapped to channel edges so every bin is an
integer group of pixels, and the per-channel depths are combined with the
same 1/sigma^2 weights whose uncertainty bin_sigma returns, so depth
and depth_err describe the same estimator (this removes the 0.87-1.18
depth_err sawtooth at R=100). Before this, the unconvolved, unweighted mean
overshot the Na/K cores by 2-4 sigma per bin at R >= 100 (HD 189733 b R=200
3.7 sigma, WASP-39 b R=100 3.0 sigma); against the review’s full forward
model the residual is now <= 0.1 sigma. Requesting R > R_instr.min() over
the binned range emits a UserWarning (not an error): the bins are then
narrower than a resolution element and their depths are correlated by the
LSF, which is fine for oversampled display but not for a statistical
forecast. meta["instrument_response"] records lsf_sigma_px,
resolution_px, R_instr_min/max, photon_weighting and edges_snapped;
the LSF constants are read from the noise-table meta when present
(lsf_sigma_px, resolution_px, to be written on the next table
regeneration, #31) and default to 1.15 / 2.3 otherwise.
Instrument setup. By default the readout is slicersim’s and is shortened
(groups, then frames halved, then the 0.7 s rapid subframe tframe unless
the caller fixed it) until no detector pixel exceeds the full well at the
last read of the ramp; the metadata records readout_mode = "auto",
default_nmd, nmd_used and tframe_s. Before slicersim sees the SED it
is box-averaged over each instrument pixel (slicer.pixel_average_sed),
because slicersim point-samples the spectrum at the pixel centres. A caller that
shows this result next to its own single-exposure ETC can instead pass the
same observation: depth_error(..., nmd=(N, M, D), tframe=, spectrograph_mode=, guiding_sigma=, zodi_ab_mag=) (also as keywords of simulate). A given nmd
is used as is and its saturation is reported, not removed
(readout_mode = "fixed", fully_desaturated, nsaturated_detpx). The
native-noise cache keys on the setup, so the knobs are free to vary per call.
Live instrument-noise cache
simulate() reuses up to 32 native slicersim results per process and caches
Vega-to-AB zero-point offsets for up to 32 bands. Native-result cache keys
include the exact stellar wavelength/flux arrays, converted AB magnitude,
band, instrument, and slicersim version/target implementation. Changing the
atmosphere, output resolution, duration, or transit count reuses the native
result; duration and transit-count scaling and metadata are recomputed for
each call. Different SED contents (including a different stellar grid) miss
the cache. Returned arrays and metadata can be edited without affecting it.
The cache assumes instrument/calibration files and registered bandpasses
stay fixed while the process runs. After changing them, call
lazuli_exo.slicer.clear_noise_cache() or restart the process. For an
independent reference calculation, call slicer.depth_error(..., use_cache=False); this bypasses both cache lookup and storage. The cache
is bounded and process-local, not persistent or shared across web workers.
Two-stage precompute
inputs (POSEIDON opacities + fastchem grid)
|
v poseidon conda env
atmo/zeta_grid.npz star/phoenix_grid.npz star/<planet>.npz
| |
| v py313 env + slicersim
| noise/<planet>.npz
v
runtime (src/lazuli_exo/) = numpy + these tables, nothing heavier
precompute/tables/models/<planet>__<scenario>.npz POSEIDON validation data
Grids/star tables and noise tables are built in separate steps, in separate
conda environments, because POSEIDON and slicersim never need to be
installed together. src/lazuli_exo/data/tables/ (~34 MB) is committed to
the repo and ships in the wheel as package data; the runtime only ever reads
it. The per-planet POSEIDON spectra (14 MB) live in precompute/tables/ and
are read only by the validation script and notebook 06.
Regenerating the tables
# 1. POSEIDON validation tables (-> precompute/tables/models) + star tables
# (poseidon env; needs the opacity/chemistry inputs)
POSEIDON_input_data=$HOME/inputs/ PYSYN_CDBS=$HOME/inputs/stellar_grids \
~/anaconda3/envs/poseidon/bin/python precompute/make_model_tables.py [planet ...]
# 2. flat (featureless) validation tables (analytic, py313 env)
~/anaconda3/envs/py313/bin/python precompute/make_flat_tables.py
# 3. noise tables (py313 env; needs slicersim)
~/anaconda3/envs/py313/bin/python precompute/make_noise_tables.py
# 4. parametric grids (zeta: poseidon env, ~14 min, 6 GB; PHOENIX: poseidon env, ~1 min)
POSEIDON_input_data=$HOME/inputs/ ~/anaconda3/envs/poseidon/bin/python precompute/make_zeta_grid.py
PYSYN_CDBS=$HOME/inputs/stellar_grids PYTHONPATH=src python precompute/make_phoenix_grid.py # numpy + astropy only
~/anaconda3/envs/py313/bin/python precompute/validate_zeta_grid.py # 14 x 5 vs POSEIDON tables
# 5. limb-darkening tables (py313 env; needs exotic-ld, network on first run;
# PHOENIX files cache to $EXOTIC_LD_DATA, default ~/inputs/exotic_ld_data)
~/anaconda3/envs/py313/bin/python precompute/make_ld_tables.py
Each script calls tables.rebuild_index() when it finishes, so index.json
always reflects what’s actually on disk.
Table schema
Under a tables directory:
index.json # version, planets (slug, has_noise, has_ld), grids
star/<planet-slug>.npz # wavelength_um, f_lambda, meta
noise/<planet-slug>.npz # wavelength_um, depth_err_1transit, meta
ld/<planet-slug>.npz # wavelength_um, u1, u2, meta
atmo/zeta_grid.npz, star/phoenix_grid.npz
Scenarios are not table files: list_scenarios() returns presets.SCENARIOS.
index.json carries a schema version integer; lazuli_exo.tables refuses to
load a mismatched version with a clear error. observe() resolves the
tables directory as: an explicit tables_dir= argument, else the
LAZULI_EXO_TABLES environment variable, else the data/tables/ shipped inside the installed
repo/package.
Known approximations
Isothermal equilibrium chemistry: every scenario uses a single T = T_eq isotherm, not a real P-T profile.
Light-curve shape (
lazuli_exo.lightcurve): quadratically limb-darkened transit (numpy-only numerical disk integration, validated against the analytic uniform-disk and small-planet limits) with geometry from the frozen NEA registry: z(t) is the exact sky-projected separation of the Keplerian orbit (orbit.projected_separation, Kepler’s equation by Newton iteration, mid-transit at inferior conjunction so z(0) = b with the eccentricity factor; agrees with jaxoplanet to < 0.02 ppm in flux for every registry planet). The registryt14_sis the measured archive duration and sets the noise and the window, but the geometric duration from (P, a/R*, k, i, e, w) can differ (HAT-P-11 b: 7184 s vs 8480 s), so the orbit’s clock is scaled byt14_geom / t14_sand the model’s first-to-fourth-contact span equalst14_s(meta["t14_geom_s"],meta["time_stretch"]). LD coefficients come from per-planet tables (src/lazuli_exo/data/tables/ld/) computed byprecompute/make_ld_tables.pywith exotic-ld (PHOENIX grid, trilinear interpolation, 48 wavelength chunks, fit over mu >= 0.1); each chunk is refitted under the Kipping (2013) constraints (u1 >= 0, u1 + u2 <= 1, u1 + 2 u2 >= 0) so the limb intensity is never negative, with the affected chunks listed inmeta["constrained_chunks"](only TRAPPIST-1 e, at 0.41-0.46 um and 0.74 um). TRAPPIST-1 e’s (2566 K) LD is uncertain at the 0.1-0.4 level in u1 (it depends on the fit’s mu_min and the grid vertex), and a 0.1 error in u1 biases a fitted white-light k^2 by ~150 ppm = 6 sigma_band, so an empirical/JWST LD prior is recommended for that target. Planets without a registry entry or LD table fall back to a b = 0 trapezoid;meta["shape"]records which. Per-point noise is derived from the band’s depth precision via sigma_pt = sigma_band * sqrt(t14 / 2c), consistent with the noise tables’ equal out-of-transit baseline assumption; a depth + baseline fit to the light curve recovers sigma_band to ~10 % depending on the template (0.83-1.07x measured), and to 1/sqrt(2) with the baseline held fixed.Stellar SED is solar metallicity, PHOENIX only: see “Stellar SED” above. For M-dwarf hosts PHOENIX is 15-25 % too bright at 0.45 um and up to 2x in the 0.60 um TiO bins against Gaia XP, so blue-end noise for GJ 1214 b, LHS 1140 b, GJ 3470 b and TRAPPIST-1 e is optimistic by ~10-25 % in sigma (review 2026-10-10, F4).
Cold-planet temperature floor: the fastchem equilibrium-chemistry grid has a hard floor at 300 K. K2-18 b (T_eq ~ 284 K), LHS 1140 b (~226 K), and TRAPPIST-1 e (~250 K) are clamped to 300 K for both the isotherm and the chemistry lookup; this is recorded per-table as
T_clampedinmeta.“hazy” is deck_haze, not haze-alone: POSEIDON’s
cloud_type="haze"alone is a no-op for a 1D model (it silently zeroes the cloudy-sector fraction, so the haze opacity is computed but never mixed into the spectrum, confirmed by an empirical A/B check). Thehazyscenario instead usescloud_model="MacMad17",cloud_type="deck_haze", with the deck’s own opaque cloud pinned at the pressure grid floor (100 bar) so it never actually shadows the atmosphere: only the haze term contributes opacity.Template S/N, not detection significance:
detect()returns a matched-template amplitude S/N against the best-fit flat line under a diagonal covariance (template_snr_diagonal): there is no defined null/alternative likelihood ratio, nuisance model, covariance, or false-positive calibration. Per-band values (band_snr, masks inlazuli_exo.detect.BAND_MASKS) restrict the same statistic to fixed wavelength masks; they mix every species in the mask plus haze/cloud/ continuum structure, and bands overlap (e.g. H2O and CH4 near 1.15 um), so they are not molecule detections. The rigorous upgrade path is a likelihood ratio against per-molecule leave-one-out model tables, which slots into the existing table schema without API changes.sqrt(N) SNR scaling:
depth_errscales as1/sqrt(n_transits)from a single precomputed single-transit uncertainty; there’s no per-transit systematics or red-noise floor.Equal out-of-transit baseline: the noise model assumes an out-of-transit baseline exposure equal to the in-transit one, i.e.
sigma_depth = sqrt(1/SNR_in^2 + 1/SNR_out^2)withSNR_in == SNR_out, not an optimized or telescope-time-constrained baseline.Stellar contamination (transit light source effect): off by default, i.e. forecasts for M/K hosts assume an immaculate photosphere unless
f_spot/f_facare set; with f_spot >= 0.03 the stellar signal is >= 1 sigma per bin for every M/K showcase host and >= 10 % of the H2O amplitude for M dwarfs.star.contaminationimplements Rackham et al. 2018 (AJ 853, 122) eq. 2,eps = 1 / (1 - f_spot (1 - F_spot/F_phot) - f_fac (1 - F_fac/F_phot)), with the PHOENIX surface fluxes fromstar.phoenix_sedat the host log g / [Fe/H]; defaults T_spot = 0.86 T_eff (T_eff < 4000 K, Rackham 2018) or 0.80 T_eff (Rackham 2019, FGK), T_fac = T_eff + 100 K.simulate()andobserve()multiply the native-resolution depth by eps before binning (meta["atmosphere"]["tlse"]records the temperatures, fractions and the eps range over 0.40-1.70 um);flat_depthstays the geometric (R_p/R_s)^2, so the observed flat depth is eps-weighted. Literature covering fractions are instar.TLSE_PRESETS(data, not defaults). Contamination amplitude depth x (eps - 1), peak-to-peak 0.4-1.7 um, R=100, clear scenario, 1 transit (2026-10-10 review):host
f_spot
ptp (ppm)
TRAPPIST-1 e
0.03 / 0.39
103 / 2393
GJ 1214 b
0.03 / 0.12
283 / 1274
LHS 1140 b
0.03 / 0.12
108 / 490
K2-18 b
0.03 / 0.10
50 / 184
GJ 3470 b
0.03 / 0.10
108 / 393
WASP-107 b
0.03 / 0.08
337 / 956
HAT-P-11 b
0.03 / 0.08
60 / 172
HD 189733 b
0.03 / 0.08
450 / 1283
Saturation is tested at the last read, not by slicersim’s count: slicersim 1.0.0 flags a pixel on
rate * (n-1)(m+d) * tframe, the slope signal between group means, while the last read of the ramp accumulatesrate * nframes * tframe, so every (2, m, 0) ramp is tested at half its real charge (#33, upstream #6).slicer._nsat_lastreadrecomputes the count at the last read from slicersim’s own projected cube (same pixel rate: cube / anamorphose x QE + effective dark) and drives the readout ladder andfully_desaturated; slicersim’s own count is kept asnsaturated_detpx_slicersim. The committed tables predate this fix.Residual detector saturation on bright hosts: for HD 189733 b the ramp still saturates ~320 px at the (2, 1, 0) floor with the 0.7 s subframe (HD 209458 b and HAT-P-11 b are clean there). The residual
nsaturated_detpxis recorded in the noise table’smetarather than silently ignored, andobserve()raises aUserWarningfor such hosts: their uncertainties are unvalidated (saturation can bias extraction weights, nonlinearity, and wavelength dependence in either direction), so treat their forecasts as indicative only until an unsaturated readout strategy (e.g. the wide field) or an injection/recovery validation exists. Whether a 0.7 s subframe exists for the full IFS field is a Pearl question.Ramp reset overhead: slicersim’s observing time is
nframes * tframe * nrampswith no dead time between ramps.depth_errorassumes one reset frame per MACC ramp (n_reset_frames=1, JWST-style accounting), so the wall clock entering thesqrt(t14 / t_obs)scaling is(nframes + 1) * tframe * nramps(info["t_obs_s"]; slicersim’s value ist_int_s): x1.22 ondepth_errfor (2, 1, 0), x1.06 for (2, 4, 0), x1.03 for (2, 8, 0). Pearl should confirm the H4RG MACC reset accounting; setn_reset_frames=0to reproduce slicersim’s zero-overhead timing.SED pixel averaging: slicersim samples the input SED at the pixel centres (
np.interp). With the R=10,000 star tables (~28 samples per pixel) that is one sample of a spectrum with structure inside the pixel: the per-channel signal of an M dwarf was off by ~14 % rms and the R=100 binned sigma by up to 33 % (#37).depth_errornow box-averages the SED over each pixel first (exact for the piecewise-linear SED; a SED coarser than a pixel is unchanged), so slicersim’s centre sample is the pixel mean and its LSF is applied once.
Deployment notes
The runtime ships with its lookup tables and no heavy dependencies (numpy,
plus matplotlib as an optional extra), so a future Django frontend can be
deployed on Heroku importing lazuli-exo directly: no POSEIDON, slicersim,
jax, or opacity data needs to reach the web dyno. That app is a separate,
not-yet-started follow-up project.
The tables live at src/lazuli_exo/data/tables and are declared as package
data in pyproject.toml, so an editable install, a repo checkout, a VCS pin
(pip install git+https://...@<sha>) and a built wheel all ship them (~50 MB).
LAZULI_EXO_TABLES overrides the location when a deployment wants the tables
somewhere else.
Atmosphere-grid smoke builds
precompute/make_zeta_grid.py --smoke writes only
atmo/zeta_grid.smoke.npz, with a separate
atmo/zeta_grid.smoke.partial.npz checkpoint. Add --resume to resume that
smoke checkpoint. Production builds retain zeta_grid.npz and
zeta_grid.partial.npz; a smoke run never changes the production grid or
index. Completed grids are serialized to a temporary file beside the
destination and atomically replaced, so a failed write preserves the last
published grid. A failed build retains its checkpoint for resuming.