Per-exposure-time dark masters, reference-master scale-down only (#832) - #842
Conversation
#832) - Reduction._create_master_darks() groups a night's raw DARK frames by exptime (tolerance-grouped, reusing #831's exptime_grouping) and creates one master per group instead of a single night-wide dark, longest-first. Frame groups with <3 members are skipped individually rather than aborting dark reduction for the whole instrument/binning. FILENAME gains an exptime component (shared by BIAS/SKYFLAT too, since it's one pattern) via a new FilenameFormatter "exptime" function; MasterCalibCreated carries the dark's exptime; ReductionBase's master-frame cache key gains exptime (None for non-DARK). - Pipeline.find_master() gains exptime/exptime_tolerance/exptime_max params: an exptime match ranks ahead of a merely time-close one for DARK, and exptime_max enforces scale-down-only when searching for a reference master. - Calibration implements ADR 0015's matching policy via a new _find_dark_master(): exact match (unscaled) > bias-only below dark_min_exptime > reference master (dark_scale_exptime, scaled down only) > allow_unmatched_dark_scale fallback > strict ValueError, caught the same way as any other missing-master case. _CalibrationCache keys DARK entries by the target exptime searched for (science exptime or the reference), since a science image's own EXPTIME no longer identifies which master it needs. _CCDDataCalibrator.dark_scale threads through to ccd_process() so an exact match isn't rescaled. Scoped to issue #832 only (reduction/pipeline side); #831 (archive/robotic side) already landed on develop via PR #840. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Review — approved with minor changesReviewed the full diff ( Verified
1. Bug — uncaught
|
- Fix uncaught KeyError in Calibration._find_dark_at when a candidate master has no EXPTIME header (a legacy pre-#831 master) -- now correctly treated as "not an exact match" and falls through the rest of the policy instead of crashing. - Add a CHANGELOG.rst entry for #832, per ADR 0015's explicit requirement that the always-scale -> strict-by-default behavior change be called out there; corrected the plan's "configs keep working unchanged" claim to distinguish config validity from calibration behavior. - Ignore FILTER in _CalibrationCache's key for BIAS/DARK (matching Calibration._find_master_in_archive, which already ignores it for these two types) -- previously a near-guaranteed cache miss on every science frame, since a master bias/dark's own FILTER header rarely matches the science frame's. - Reduction._create_master_darks() falls back to one combined, untagged master when every raw dark this night lacks EXPTIME (a fully legacy instrument), instead of silently producing zero masters; still drops only the untagged frames when EXPTIME is missing on some but not all of them. Uncovered and fixed a related LocalArchive bug along the way: pandas coerces a missing EXPTIME (None) to NaN once any other frame's real EXPTIME forces its index column to float64, so "no EXPTIME" frames were silently miscounted as tagged; normalized back to None on read, plus a defensive NaN guard in the new fallback logic itself. - FilenameFormatter's new exptime function renders a missing key as "unknown" instead of raising, so a legacy dark master's filename can still be formatted at all. - Corrected the strict-match error message's tolerance-ceiling wording and had it list the archive's available master exptimes, per ADR 0015. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Re-review of fix commit
|
… allow_unmatched_dark_scale path - _CCDDataCalibrator only reads dark.header["EXPTIME"] when dark_scale is actually True, instead of unconditionally -- a legacy dark master (no EXPTIME at all) used unscaled no longer raises KeyError. - _find_dark_master's branch 4 (allow_unmatched_dark_scale) checks whether the fallback master actually has an EXPTIME before asking to scale it; a legacy master is used unscaled instead, since ccdproc can't compute a scale factor without knowing the master's own exposure time. - _find_dark_master's branch 3 (reference, scale down only) now verifies the reference master's own EXPTIME actually covers the science exptime before using it -- previously a site with only a short master (e.g. 45s) could have it returned as the "reference" for a longer science exposure and scaled UP, the exact direction ADR 0015 forbids. Falls through to the strict error instead. - _available_dark_exptimes (the strict-error message's archive query) is now bounded to max_days_dark (or 30 days) around the science frame's DATE-OBS, instead of scanning the archive's entire DARK history. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…eet-open-items PR #842 landed the reduction/pipeline side of #832 on develop: flip the plan's index entry to implemented, drop #832's row from the open-issues tracker per its own maintenance rule (fix landed, issue stays open pending a main release), remove the now-implemented plan from "Open plans", and update pyobs-pipeline#13's note now that both #831 and #832 prerequisites landed.
- Fix uncaught KeyError in Calibration._find_dark_at when a candidate master has no EXPTIME header (a legacy pre-#831 master) -- now correctly treated as "not an exact match" and falls through the rest of the policy instead of crashing. - Add a CHANGELOG.rst entry for #832, per ADR 0015's explicit requirement that the always-scale -> strict-by-default behavior change be called out there; corrected the plan's "configs keep working unchanged" claim to distinguish config validity from calibration behavior. - Ignore FILTER in _CalibrationCache's key for BIAS/DARK (matching Calibration._find_master_in_archive, which already ignores it for these two types) -- previously a near-guaranteed cache miss on every science frame, since a master bias/dark's own FILTER header rarely matches the science frame's. - Reduction._create_master_darks() falls back to one combined, untagged master when every raw dark this night lacks EXPTIME (a fully legacy instrument), instead of silently producing zero masters; still drops only the untagged frames when EXPTIME is missing on some but not all of them. Uncovered and fixed a related LocalArchive bug along the way: pandas coerces a missing EXPTIME (None) to NaN once any other frame's real EXPTIME forces its index column to float64, so "no EXPTIME" frames were silently miscounted as tagged; normalized back to None on read, plus a defensive NaN guard in the new fallback logic itself. - FilenameFormatter's new exptime function renders a missing key as "unknown" instead of raising, so a legacy dark master's filename can still be formatted at all. - Corrected the strict-match error message's tolerance-ceiling wording and had it list the archive's available master exptimes, per ADR 0015. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Per-exposure-time dark masters, reference-master scale-down only (#832)
…eet-open-items PR #842 landed the reduction/pipeline side of #832 on develop: flip the plan's index entry to implemented, drop #832's row from the open-issues tracker per its own maintenance rule (fix landed, issue stays open pending a main release), remove the now-implemented plan from "Open plans", and update pyobs-pipeline#13's note now that both #831 and #832 prerequisites landed.
Add pyobs-portal#143 (dashboard timeline shows local time, not UTC); drop #831/#832 (dark-exptime matching), already landed on develop via PR #840/#842; clear the stale vfs-token-auth unblock caveat now that pyobs-web-client's own index has caught up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoEVuHBfy1A7M46kKCfYa1
Summary
Reduction._create_master_darks()groups a night's rawDARKframes by exptime (tolerance-grouped, reusing Take morning darks at the exposure times used for science frames during the night #831'spyobs.utils.exptime_grouping) and creates one master per group instead of a single night-wide dark, longest-first. Frame groups with fewer than 3 members are skipped individually with a warning rather than aborting dark reduction for the whole instrument/binning.FILENAMEgains an exptime component ({EXPTIME|exptime}), shared byBIAS/SKYFLATtoo since it's one pattern — via a newFilenameFormatter"exptime"function that renders600.0as600.MasterCalibCreatedcarries the dark'sexptime.ReductionBase's master-frame cache key gainsexptime(Nonefor non-DARK).Pipeline.find_master()gainsexptime/exptime_tolerance/exptime_maxparams: forDARK, an exptime match ranks ahead of a merely time-close non-match, andexptime_maxenforces scale-down-only when searching for a reference master.Calibrationimplements ADR0015-dark-master-strict-exptime-matching-reference-scale-down-only.md's policy via a new_find_dark_master(), checked in order against the science frame'sEXPTIME: (1) exact match withindark_exptime_tolerance→ unscaled; (2) no exact match andEXPTIME < dark_min_exptime→ bias-only, not an error; (3) no exact match, above the minimum, and a reference master (dark_scale_exptime, default 600s) exists → scaled down to the science exptime; (4)allow_unmatched_dark_scale=True→ today's always-scale-whatever's-nearest behavior; (5) otherwise →ValueError, caught the same way as any other missing-master case in__call__._CalibrationCachenow keysDARKentries by the target exptime searched for (science exptime for an exact-match lookup, or the reference exptime), not the science image's ownEXPTIMEheader — a science frame's own exptime no longer identifies which master it needs once scaling is in play._CCDDataCalibratorgains adark_scale: boolparam threaded fromCalibration.__call__, so an exact-match dark isn't rescaled byccdproc.ccd_process().0015was alreadyaccepted(flipped in Take morning darks at the night's science exposure times (#831) #840); this plan (2026-09-01-per-exptime-dark-masters.md) is flippedproposed→implemented, both open questions resolved (relative-only tolerance; hard filename-pattern rename, documented rather than dual-pattern).Scoped to issue #832 only (reduction/pipeline side). #831 (archive/robotic side) already landed on
developvia PR #840.Design notes / judgment calls
_verify_image_headernow also requiresEXPTIME. It was already an implicit requirement of_CCDDataCalibrator(data_exposure=...EXPTIME...); checking it upfront turns a bareKeyErrormid-calibration into the sameValueErrorpath every other missing-header case already takes._CCDDataCalibratorpath.dark: Image | None = Nonewas already handled end-to-end (ccd_process(dark_frame=None, ...)); the plan's open question here is resolved by reuse, not a new branch._CalibrationCacheby derivingexptimefrom the master image's own header — works, but doesn't generalize past this PR. Landed on keying explicitly by the target exptime the caller searched for, passed in by_find_dark_at, which is what actually identifies "which master do I need" once scaling means the returned master's own exptime can legitimately differ from what was asked for.Test plan
pytest tests/(excludingintegration/xmpp): 1758 passed, same 7 pre-existing/unrelatedtests/cli/test_pyobsd.pyfailures asdevelop(local-environmentpyobsexecutable lookup).ruff check .clean.black --checkclean.pyrefly check pyobs/(CI's actual invocation): 0 errors, same baseline suppressed/warning counts asdevelop.tests/utils/pipeline/test_pipeline.py(new —Pipeline.find_master's exptime ranking/exptime_max),tests/utils/pipeline/test_reduction.py(per-exptime dark grouping, under-populated-group skip, no-bias skip),tests/images/processors/misc/test_calibration.py(all 5_find_dark_masterpolicy branches +_CCDDataCalibrator.dark_scale),tests/images/processors/misc/test_calibration_cache.py(exptime keying),tests/utils/test_exptime_grouping.py(newgroup_by_exptime).🤖 Generated with Claude Code