diff --git a/docs/SciencePipeline.md b/docs/SciencePipeline.md index 6f2e1c53..7c0ac6e7 100644 --- a/docs/SciencePipeline.md +++ b/docs/SciencePipeline.md @@ -73,7 +73,7 @@ Three things characterise this design and make it auditable: | `tcl` | `(N,)` int64 \| None | TelemetryIngestStage (or replay source) | Laser trigger counter | | `abs_frame_ids` | `(N,)` int64 | FrameClassificationStage | Monotonic unwrapped frame counter | | `frame_type` | `(N,)` ` bool: batch_estimator=LinearInterpolation(), pedestals=pedestals, realtime_history_size=realtime_dark_history_size, + dark_interval=dark_interval, ), ShotNoiseCorrectionStage(pedestals=pedestals, camera_gain_map=CAMERA_GAIN_MAP), diff --git a/omotion/pipeline/stages/dark.py b/omotion/pipeline/stages/dark.py index 18b358de..34a1fbae 100644 --- a/omotion/pipeline/stages/dark.py +++ b/omotion/pipeline/stages/dark.py @@ -17,7 +17,10 @@ import numpy as np -from ..batch import DarkIntegrityWarning, FrameBatch, IntervalClosed, TerminalDarkResult +from ..batch import ( + DarkIntegrityWarning, FrameBatch, IntervalClosed, MissedDarkWarning, + TerminalDarkResult, +) from ..pedestal import SensorPedestals @@ -33,6 +36,22 @@ _TERMINAL_FSYNC_ABS_OFFSET = 0 +def scheduled_darks_between(left_abs: int, right_abs: int, + dark_interval: int) -> list[int]: + """Scheduled dark abs_ids strictly between two interval boundaries. + + After the first dark (discard_count + 1), the classifier types a frame + dark when (abs_id - 1) % dark_interval == 0 (classify._is_dark). An + interval's left edge is always a dark, so it sits at or after the first + dark and that rule alone covers everything inside the interval. A + non-empty result means those darks never arrived (issue #175). + """ + if dark_interval <= 0: + return [] + first = left_abs + 1 + (-left_abs) % dark_interval + return list(range(first, right_abs, dark_interval)) + + @dataclass(frozen=True) class DarkObservation: """One dark-frame measurement for one camera.""" @@ -402,8 +421,12 @@ def __init__(self, *, batch_estimator: LinearInterpolation, pedestals: Optional[SensorPedestals] = None, realtime_history_size: int = 4, - integrity_max_above_pedestal: float = 5.0): + integrity_max_above_pedestal: float = 5.0, + dark_interval: int = 600): self._realtime = realtime_estimator + # Same value the classifier schedules darks with; used only to spot + # an interval that spans a missed dark (_flag_missed_darks). + self._dark_interval = int(dark_interval) self._batch = batch_estimator self._pedestals = pedestals or SensorPedestals(left=64.0, right=64.0) self._history = DarkHistory(max_darks=realtime_history_size) @@ -438,10 +461,52 @@ def _emit_interval( quadratic stencil. """ side, cam_id = key + self._flag_missed_darks(key, interval, events) corrected = self._batch.correct_interval(interval, side=side, cam_id=cam_id) corrected.left_t = interval.left.obs.t events.append(IntervalClosed(corrected_batch=corrected)) + def _flag_missed_darks( + self, + key: "tuple[str, int]", + interval: "Interval", + events: list, + ) -> None: + """Detect and flag an interval that spans a missed scheduled dark. + + A dark goes missing when its frame is dropped, the camera drops out, + or the frame is rejected as stale. Its row then never reaches this + stage typed "dark" (a gap-fill row is typed "light" and carries NaN + stats), so the interval stays open until the next dark that does + arrive. The baseline is then interpolated across the wider span. + The interval is kept, not discarded: its light frames are flagged + "wide_interval" and a MissedDarkWarning is emitted. Synthetic + nan_filled rows keep their own flag, which consumers use to + recognise them as placeholders. + """ + missed = scheduled_darks_between( + interval.left_abs, interval.right_abs, self._dark_interval, + ) + if not missed: + return + side, cam_id = key + logger.warning( + "missed scheduled dark: side=%s cam_id=%d expected abs_id %s; " + "dark correction interpolated across [%d, %d] (%d frames instead " + "of <= %d); flagging its frames wide_interval", + side, cam_id, ", ".join(str(a) for a in missed), + interval.left_abs, interval.right_abs, + interval.right_abs - interval.left_abs, self._dark_interval, + ) + events.append(MissedDarkWarning( + side=side, cam_id=cam_id, + left_abs=interval.left_abs, right_abs=interval.right_abs, + missed_abs_ids=tuple(missed), + )) + for lf in interval.light_frames: + if lf.quality != "nan_filled": + lf.quality = "wide_interval" + def process(self, batch: FrameBatch) -> FrameBatch: n = batch.frame_ids.shape[0] baseline_rt = np.full((n, 2, 8), np.nan, dtype=np.float32) diff --git a/omotion/pipeline/stages/dark_frame_hold.py b/omotion/pipeline/stages/dark_frame_hold.py index 5eadec69..58aae45c 100644 --- a/omotion/pipeline/stages/dark_frame_hold.py +++ b/omotion/pipeline/stages/dark_frame_hold.py @@ -171,6 +171,13 @@ def _interp(attr: str) -> float: temp_c = _interp("temp_c") if right1.temp_c is not None else None + # The dark row is built from its neighbours, so a neighbour from an + # interval that spans a missed dark (#175) makes it just as suspect. + quality = "ok" + if any(f is not None and f.quality == "wide_interval" + for f in (left2, left1, right1, right2)): + quality = "wide_interval" + return EnrichedCorrectedFrame( abs_frame_id=d_prev_abs, t=d_prev_t, @@ -181,7 +188,7 @@ def _interp(attr: str) -> float: contrast=_interp("contrast"), bfi=_interp("bfi"), bvi=_interp("bvi"), - quality="ok", + quality=quality, temp_c=temp_c, ) diff --git a/omotion/pipeline/stages/side_avg.py b/omotion/pipeline/stages/side_avg.py index 4b6e8965..39e3f29a 100644 --- a/omotion/pipeline/stages/side_avg.py +++ b/omotion/pipeline/stages/side_avg.py @@ -35,8 +35,11 @@ _SIDE_INT_TO_STR = ("left", "right") # Higher rank = worse quality; the side average inherits the worst quality -# of any camera that contributed to it. Mirrors sinks._QUALITY_RANK. -_QUALITY_RANK = {"ok": 0, "ts_corrected": 1, "nan_filled": 2} +# of any camera that contributed to it. "wide_interval" (#175) ranks worst: +# a nan_filled camera is NaN and drops out of the average on its own, but a +# wide-interval camera contributes finite values built on a stretched dark +# baseline, so it is the one flag that can bias the average unnoticed. +_QUALITY_RANK = {"ok": 0, "ts_corrected": 1, "nan_filled": 2, "wide_interval": 3} def _mask_to_cam_indices(mask: int) -> np.ndarray: diff --git a/tests/test_pipeline/test_corrected_side_avg_stage.py b/tests/test_pipeline/test_corrected_side_avg_stage.py index e3881ee3..01e4fd50 100644 --- a/tests/test_pipeline/test_corrected_side_avg_stage.py +++ b/tests/test_pipeline/test_corrected_side_avg_stage.py @@ -227,3 +227,17 @@ def test_reset_clears_pending_window(): flush = _batch() stage.on_scan_stop(flush) assert _avg_frames(flush) == [] + + + +def test_wide_interval_outranks_nan_filled(): + """A wide-interval camera contributes finite but biased values, so its + flag must survive alongside a nan_filled camera (issue #175).""" + stage = _stage() + filled = _ef(12, 5.0, "left", 0, 2.0, 20.0) + filled.quality = "nan_filled" + wide = _ef(12, 5.0, "left", 1, 6.0, 60.0) + wide.quality = "wide_interval" + b = _batch([_interval(10, 20, [filled]), _interval(10, 20, [wide])]) + stage.process(b) + assert _avg_frames(b)[0].quality == "wide_interval" diff --git a/tests/test_pipeline/test_dark_correction_stage.py b/tests/test_pipeline/test_dark_correction_stage.py index 24878eb6..595207bb 100644 --- a/tests/test_pipeline/test_dark_correction_stage.py +++ b/tests/test_pipeline/test_dark_correction_stage.py @@ -717,3 +717,115 @@ def test_corrected_frame_quality_defaults_to_ok(): mean=36.0, std=4.8, contrast=0.13, bfi=5.0, bvi=5.0, ) assert ef.quality == "ok" + + +# ── Missed scheduled darks (issue #175) ──────────────────────────────────── + +from omotion.pipeline.batch import MissedDarkWarning +from omotion.pipeline.stages.dark import scheduled_darks_between + + +@pytest.mark.parametrize("left, right, expected", [ + (10, 601, []), # first interval: discard+1 -> first scheduled dark + (601, 1201, []), # ordinary interval + (10, 1201, [601]), # first scheduled dark after discard missed + (601, 1801, [1201]), + (601, 2401, [1201, 1801]), + (601, 1500, [1201]), # terminal boundary off the schedule + (601, 1150, []), # terminal boundary before the next dark + (10, 605, [601]), # terminal just after a missed first dark +]) +def test_scheduled_darks_between(left, right, expected): + assert scheduled_darks_between(left, right, 600) == expected + + +def test_scheduled_darks_between_disabled_without_interval(): + assert scheduled_darks_between(601, 5000, 0) == [] + + +def _missed_dark_batch(): + """dark@601, lights, a nan_filled placeholder where the dark at 1201 + should have been, more lights, dark@1801.""" + abs_ids = [601, 602, 1200, 1201, 1202, 1800, 1801] + types = ["dark", "light", "light", "light", "light", "light", "dark"] + u1 = [65.0, 500.0, 505.0, np.nan, 510.0, 515.0, 66.0] + sd = [3.0, 20.0, 20.0, np.nan, 21.0, 21.0, 3.0] + n = len(abs_ids) + mean = np.array(u1, dtype=np.float32).reshape(n, 1, 1) * np.ones((1, 2, 8), dtype=np.float32) + std = np.array(sd, dtype=np.float32).reshape(n, 1, 1) * np.ones((1, 2, 8), dtype=np.float32) + batch = _batch(n, types, abs_ids, mean_raw=mean, std_raw=std) + batch.quality = np.array( + ["ok", "ok", "ok", "nan_filled", "ok", "ts_corrected", "ok"], dtype=" None: from omotion.pipeline import ( Pipeline, Stage, FrameBatch, BatchEvent, IntervalClosed, LiveEmit, - DarkIntegrityWarning, StencilFallback, TerminalDarkResult, + DarkIntegrityWarning, MissedDarkWarning, StencilFallback, TerminalDarkResult, PipelineError, TimestampMisalignmentWindow, ScanRunner, CriticalSinkError, Source, LiveUsbSource, CsvReplaySource, @@ -21,7 +21,7 @@ def test_public_api_symbols_importable() -> None: ) for sym in ( Pipeline, Stage, FrameBatch, BatchEvent, IntervalClosed, LiveEmit, - DarkIntegrityWarning, StencilFallback, TerminalDarkResult, + DarkIntegrityWarning, MissedDarkWarning, StencilFallback, TerminalDarkResult, PipelineError, TimestampMisalignmentWindow, ScanRunner, CriticalSinkError, Source, LiveUsbSource, CsvReplaySource, @@ -39,7 +39,7 @@ def test_public_api_all_list_complete() -> None: expected = { "FrameBatch", "BatchEvent", "IntervalClosed", "LiveEmit", - "DarkIntegrityWarning", "StencilFallback", "TerminalDarkResult", + "DarkIntegrityWarning", "MissedDarkWarning", "StencilFallback", "TerminalDarkResult", "PipelineError", "TimestampMisalignmentWindow", "Pipeline", "Stage", "Tee", "ScanRunner", "CriticalSinkError",