Repository navigation
WFS-in-the-loop: ADU calibration, input-referred read noise, faster gain stage, closed-loop guide (2.6.0) - #12
Merged
Conversation
…guide (2.6.0) - getframes.Calibration (and Camera.calibration): mean master dark built from seeded dark frames on the camera's device and precision; to_electrons inverts (ADU - dark) * gain_e_per_adu / em_gain, the configured nonlinearity and an optional unit-mean flat; to_photons divides by the scalar QE expose applies, a given QE, the qe_curve at a wavelength or its effective value over a band; saturated() flags clipped pixels. NumPy and CuPy frames; binned frames. - Camera.effective_read_noise_e: read, reset and avalanche noise referred to the input, with the excess-noise-factor SNR documented. - CPU gain stage samples only lit pixels when they are a minority: same draws, same order, same generator state, so seeded frames stay bit-identical; 5-14% faster photon-starved EMCCD/eAPD frames (benchmarks/bench_small_frames.py, small-frame-results.md). - Guide "A WFS camera in a closed loop": exposure vs frame period, calibration accuracy per detector class, where calibrated-flux offsets come from (the prototype's 1-2% deficit was per-frame scatter, not a signal-chain bias), recommended presets, keeping frames cheap. - Version 2.6.0, CHANGELOG dated 2026-10-10. Closes #10 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhyUAKxoAf5ymTWJx5WiiF
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #10
Summary
gf.Calibration(andCamera.calibration(...)):Calibration.from_camera(cam, exposure, n_darks=64, seed=...)averages seeded, exposure-matched dark frames into a mean master dark on the camera's device and in its precision.to_electrons(frame)computesL^-1[(ADU - dark) * gain_e_per_adu / em_gain] / flat. It inverts the configured nonlinearity by default: closed form for the single-parameter model, Newton for the polynomial one.to_photons(frame)divides by one of: the scalar QEexposeapplies (the default),quantum_efficiency=, theqe_curveatwavelength_nm=, oreffective_qeover abandpass=.saturated(frame)flags pixels at the input-well, output-register or ADC ceiling.Camera.effective_read_noise_e(binning=, binning_mode=)sums read, kTC/reset and avalanche-scaled noise in quadrature and divides by the EM/avalanche gain. For a per-pixel sCMOS map it uses the quadrature mean of the map. The docstring and guide give the excess-noise-factor SNRS / sqrt(F²(S+D) + σ_eff²). OCAM2K: 0.36 e-.Generator.gammareturns 0 for a zero shape without consuming a draw, but it still visits every such pixel. The CPU gain stage now gathers only the lit pixels when they are a minority. The draws, their order and the generator state afterwards are unchanged.docs/guides/wfs-closed-loop.md, in the nav): exposure vs. frame period, ADU calibration and its accuracy per detector class, input-referred noise and ENF, recommended EMCCD/qCMOS/sCMOS/eAPD/CCD presets with effective read noise, and how to keep frames cheap.benchmarks/bench_small_frames.pyplusbenchmarks/small-frame-results.{md,json}. Version 2.6.0, CHANGELOG dated 2026-10-10. I bumped__about__.pybecause the task asked for it; AGENTS.md normally leaves the version to the release step.Evidence
Calibration accuracy
tests/test_wfs_calibration.pyuses a 64x64 Shack-Hartmann spot pattern with 1000 photons per spot, averaged over 150 frames, with a 64-frame mean dark. Mean calibrated flux vs. incident photons:andor_ocam2k,andor_ixon_ultra_888(EMCCD),hamamatsu_orca_quest_2(qCMOS),generic_scmos(sCMOS),generic_eapd,leonardo_saphira(eAPD),generic_ccd,generic_cmos.first_light_imaging_cred_one, with a flat and a per-frame background from unlit pixels.Further tests cover:
Cause of the 1–2% flux offset
It is not a deterministic bias in the signal chain. I reran the prototype's own
SHMakewfscamera path: 288x288, nph=100 per subaperture, 64-frame mean dark,/em_gain/QE.Every stage of the chain is linear in expectation: Poisson, Gamma mean
nG, zero-mean read noise, and rounding with ≥0.5 ADU of read noise. The guide lists the deterministic offsets a pipeline can introduce, andCalibrationhandles each one:from_cameraalways averages.wavelength_nm=orbandpass=.generic_scmos. It is inverted by default.saturated()flags these pixels.flat=.Found along the way
nuvu_hnu_240/nuvu_hnu_128_omegasaturate at one electron. Theirfull_well_eof 500/800 e- also caps the amplified charge, becauseoutput_full_well_eis unset. Calibrated flux comes out at 3.4% of the truth. The Nüvü datasheet gives no well depth, so I did not invent a value. Opened Nüvü EMCCD presets saturate at one input electron (full_well_e doubles as the post-gain ceiling) #11; the guide carries a warning, and the tests useandor_ixon_ultra_888instead.Benchmark (before/after)
Setup: 256x256 spot frames, 1 ms exposure, seeded,
include_truth=False, one pinned Ampere Neoverse-N1 core (cfl-test-bench, NumPy 2.5.3), process CPU time.In-process A/B (old and new gain stage alternated, best of 15 blocks):
workspace=/out=it is 5.05 ms, 1.28x vs. the 2.5.0 default call.Reading this honestly:
exposewas about 5 ms.device="gpu".Quality gate
ruff check .✔,ruff format --check .✔,mypy✔ (strict, 28 files).pytest -m "not gpu": 616 passed.mkdocs build --strict✔. The guide's code blocks were executed.pytest -m gpu, Quadro P620): 24 passed, including the two new calibration tests (device frames, CPU/GPU statistical parity, host-calibration-on-device-frame). The pre-existingtest_gpu_supports_full_detector_artifact_chain_and_sensor_familieshits OOM in the full GPU run because the shared 2 GB card has ~1.75 GB in use by other processes. It passes when run alone on both this branch and main.🤖 Generated with Claude Code
https://claude.ai/code/session_01NhyUAKxoAf5ymTWJx5WiiF