From f4d48440bf0872074b1feca3f4d4d5f7ff397852 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:35:45 +0200 Subject: [PATCH] feat: add a scientific camera simulator tool Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 2 + doc/camera-simulator.md | 39 +++ doc/simulation.md | 2 + .../adapters/desktop.py | 6 + .../adapters/web.py | 6 + src/datalab_camera_characterization/demo.py | 9 +- .../simulator.py | 303 ++++++++++++++++++ tests/unit/test_camera_simulator.py | 132 ++++++++ 9 files changed, 496 insertions(+), 4 deletions(-) create mode 100644 doc/camera-simulator.md create mode 100644 src/datalab_camera_characterization/simulator.py create mode 100644 tests/unit/test_camera_simulator.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1659d37..52725d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,3 +57,4 @@ All notable changes to this project will be documented in this file. - Propose dark and flat frames from their metadata and let DataLab's generic launcher run the recipes, replacing the Desktop dark/flat checklist. - Pair the photon transfer ladder with both the photon transfer and relative-DN methods; example parameter values are now keyed by method. - Document how to set the exposure time and the frame role on your own frames with DataLab's Add metadata dialog (`doc/preparing-frames.md`). +- Add a scientific camera simulator tool, in DataLab Desktop and DataLab-Web: a live view next to the sensor, illumination and acquisition settings, and acquisitions of dark and flat frames tagged for the Camera methods, in single-exposure or exposure-sequence mode (`doc/camera-simulator.md`). diff --git a/README.md b/README.md index fa23190..32acf69 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ truth = result.truth The same parameters and seed produce identical frames and truth maps. See [`doc/simulation.md`](doc/simulation.md) for units, equations, and limitations. +In DataLab Desktop and DataLab-Web, the **Scientific camera simulator** tool shows a live view of this model next to its settings, and acquires dark and flat frames tagged for the Camera methods. See [`doc/camera-simulator.md`](doc/camera-simulator.md). + ## Relative Characterization in DN The headless core validates dark and uniform-illumination frame stacks before diff --git a/doc/camera-simulator.md b/doc/camera-simulator.md new file mode 100644 index 0000000..aa6e8d0 --- /dev/null +++ b/doc/camera-simulator.md @@ -0,0 +1,39 @@ +# Scientific Camera Simulator + +The Camera application includes a simulated scientific camera. It is a DataLab tool: open it from the Camera page of the **Applications** catalog (**Tools** section), or from the **Plugins > Camera & Detector Characterization** menu of the image panel. It works the same way in DataLab Desktop and DataLab-Web. + +The window shows a live view on the left and the camera settings on the right. Every change of a setting refreshes the view. **Live** refreshes it continuously, with new noise at every frame, like the viewer of a real camera. **Acquire** adds the frames to the workspace, in a new group per acquisition; the window stays open for the next acquisition. + +## Settings + +The settings are grouped in four tabs. + +- **Acquisition.** Single exposure, or a sequence of exposure times (in ms, separated by commas). Number of frames per exposure. Shutter open (flat frames) or closed (dark frames). With the shutter open, dark frames can be added at the longest exposure time. +- **Illumination.** Photoelectron rate per pixel, vignetting and dust shadows. +- **Sensor.** Frame size, ADC resolution, conversion gain, read noise, offset, PRNU, DSNU, row and column patterns, defective pixels, and the serial number. +- **Dark current.** Dark current, its non-uniformity, hot pixels and amplifier glow. + +The ADC saturates at its largest code, 2^bits - 1 DN. The live view uses this full range, so the image gets brighter as the exposure grows, and saturated pixels stand out. Below the view, a summary gives the mean and standard deviation of the frame, and the share of saturated pixels. + +The serial number seeds the sensor: PRNU, DSNU, defects and hot pixels stay the same from one acquisition to the next, as on a real camera. Change it to simulate another camera of the same model. Each frame draws new temporal noise. + +One acquisition is limited to 20 megapixels (all frames together), which keeps DataLab-Web within the browser memory. + +## Acquired Frames + +Frames are named like the generated examples, for instance `Dark 136 ms 01` or `Flat 10 ms 02`. Each frame carries the metadata the Camera methods expect: + +- `plugin.org.datalab.camera-characterization.frame_role`: `dark` or `flat`; +- `plugin.org.datalab.camera-characterization.exposure_time_s`: the exposure time, in seconds; +- `plugin.org.datalab.camera-characterization.synthetic` and `.simulation_seed`, which record the simulated origin. + +So an acquisition can be analyzed right away: + +| Acquisition | Method | +| --- | --- | +| Exposure sequence, shutter open, with dark frames (the default sequence) | Photon transfer curve, relative camera characterization | +| Exposure sequence with the shutter closed, for instance `500, 1000, 2000, 4000, 8000` ms (raise the dark current to a few tens of e⁻/s to see it clearly) | Dark current and hot pixels | + +## Limits + +The simulator uses the model described in [`simulation.md`](simulation.md), with the same limitations: no wavelength or quantum efficiency, no charge transfer, temporal drift, nonlinear response or blooming, and no calibrated radiometry. It helps learning the methods and checking a workflow; it does not replace measurements of a real camera. diff --git a/doc/simulation.md b/doc/simulation.md index 291f9b9..e65e9d9 100644 --- a/doc/simulation.md +++ b/doc/simulation.md @@ -5,6 +5,8 @@ static/noiseless ground truth used to generate them. It is intended for testing future relative characterization algorithms, not for claiming a calibrated or EMVA 1288-compliant camera model. +In DataLab, the scientific camera simulator tool drives this model interactively, with a live view and acquisitions tagged for the Camera methods. See [`camera-simulator.md`](camera-simulator.md). + ## Units and Model The conversion gain is expressed in electrons per digital number (`e-/DN`). diff --git a/src/datalab_camera_characterization/adapters/desktop.py b/src/datalab_camera_characterization/adapters/desktop.py index 462afa0..08d4062 100644 --- a/src/datalab_camera_characterization/adapters/desktop.py +++ b/src/datalab_camera_characterization/adapters/desktop.py @@ -20,6 +20,7 @@ __version__, ) from ..demo import DARK_RAMP_DEMO, PHOTON_TRANSFER_DEMO, materialize_generated_example +from ..simulator import CAMERA_SIMULATOR_TOOL, CameraSimulator from ..workflow import ( CAMERA_RECIPES, DARK_CURRENT_RECIPE, @@ -64,6 +65,7 @@ class CameraDetectorCharacterizationPlugin(PluginBase): ) RECIPES = CAMERA_RECIPES EXAMPLES = (CAMERA_QUICKSTART, PHOTON_TRANSFER_DEMO, DARK_RAMP_DEMO) + TOOLS = (CAMERA_SIMULATOR_TOOL,) WELCOME_TILES = ( WelcomeTile( id="application", @@ -86,6 +88,10 @@ def materialize_example(cls, example_id: str) -> PluginExampleData | None: cls.get_example(example_id) return materialize_generated_example(example_id) + def camera_simulator(self) -> CameraSimulator: + """Return a new scientific camera simulator.""" + return CameraSimulator() + def run_relative_dn(self) -> RecipeOutcome | None: """Run the relative-DN characterization on the selected frames.""" return self.start_recipe(RELATIVE_DN_RECIPE.recipe_id) diff --git a/src/datalab_camera_characterization/adapters/web.py b/src/datalab_camera_characterization/adapters/web.py index 8b55b4d..d71eb30 100644 --- a/src/datalab_camera_characterization/adapters/web.py +++ b/src/datalab_camera_characterization/adapters/web.py @@ -17,6 +17,7 @@ from .. import PLUGIN_DESCRIPTION, PLUGIN_ID, PLUGIN_NAME, __version__ from ..demo import DARK_RAMP_DEMO, PHOTON_TRANSFER_DEMO, materialize_generated_example +from ..simulator import CAMERA_SIMULATOR_TOOL, CameraSimulator from ..workflow import ( CAMERA_RECIPES, RELATIVE_DN_RECIPE, @@ -56,10 +57,15 @@ class CameraDetectorCharacterizationWebPlugin(PluginBase): ) RECIPES = CAMERA_RECIPES EXAMPLES = (CAMERA_QUICKSTART, PHOTON_TRANSFER_DEMO, DARK_RAMP_DEMO) + TOOLS = (CAMERA_SIMULATOR_TOOL,) def create_actions(self) -> None: """Application actions are provided by DataLab-Web's generic host.""" + def camera_simulator(self) -> CameraSimulator: + """Return a new scientific camera simulator.""" + return CameraSimulator() + @classmethod def materialize_example(cls, example_id: str) -> PluginExampleData | None: """Generate in-memory examples; the quickstart stays a packaged file.""" diff --git a/src/datalab_camera_characterization/demo.py b/src/datalab_camera_characterization/demo.py index 244f6c4..d01b145 100644 --- a/src/datalab_camera_characterization/demo.py +++ b/src/datalab_camera_characterization/demo.py @@ -94,7 +94,7 @@ def _sensor_parameters(seed: int, **overrides: object) -> CameraSimulationParame return CameraSimulationParameters(**values) -def _series_images( +def series_images( title_prefix: str, parameters: CameraSimulationParameters, role: str, @@ -116,7 +116,7 @@ def build_photon_transfer_campaign() -> PluginExampleData: """Build the deterministic photon transfer demonstration campaign.""" stream = 1 longest = max(PHOTON_TRANSFER_EXPOSURES_S) - images = _series_images( + images = series_images( "Dark", _sensor_parameters( PHOTON_TRANSFER_SEED, @@ -130,7 +130,7 @@ def build_photon_transfer_campaign() -> PluginExampleData: for exposure_time_s in PHOTON_TRANSFER_EXPOSURES_S: stream += 1 images.extend( - _series_images( + series_images( f"Flat {exposure_time_s * 1_000:g} ms", _sensor_parameters( PHOTON_TRANSFER_SEED, @@ -150,7 +150,7 @@ def build_dark_ramp_campaign() -> PluginExampleData: images: list[ImageObj] = [] for stream, exposure_time_s in enumerate(DARK_RAMP_EXPOSURES_S, start=1): images.extend( - _series_images( + series_images( f"Dark {exposure_time_s:g} s", _sensor_parameters( DARK_RAMP_SEED, @@ -198,4 +198,5 @@ def materialize_generated_example(example_id: str) -> PluginExampleData | None: "build_dark_ramp_campaign", "build_photon_transfer_campaign", "materialize_generated_example", + "series_images", ] diff --git a/src/datalab_camera_characterization/simulator.py b/src/datalab_camera_characterization/simulator.py new file mode 100644 index 0000000..5c77dea --- /dev/null +++ b/src/datalab_camera_characterization/simulator.py @@ -0,0 +1,303 @@ +"""Host-neutral scientific camera simulator, shared by the Desktop and Web adapters. + +The simulator behaves like a camera on a test bench: its settings describe one +sensor and its illumination, the live view shows a frame, and each acquisition +adds dark or flat frames tagged with the metadata the Camera methods expect. +""" + +from __future__ import annotations + +import itertools +import math +import re + +import guidata.dataset as gds +import numpy as np +from datalab.plugin_instruments import ( + InstrumentAcquisition, + InstrumentFrame, + PluginInstrument, +) +from datalab.plugin_tools import PluginTool +from datalab.recipes import RecipeObjectType +from sigima.objects import ImageObj, create_image + +from .core import CameraSimulationParameters, simulate_camera_frames +from .demo import series_images + +#: Largest number of pixels created by one acquisition (all frames together) +MAX_ACQUISITION_PIXELS = 20_000_000 +#: Live frames cycle through the first noise streams; acquisitions use the next +#: ones (the simulator spawns as many seeds as the stream number) +LIVE_NOISE_STREAMS = 64 + +CAMERA_SIMULATOR_TOOL = PluginTool( + id="camera-simulator", + title="Scientific camera simulator...", + description="Acquire dark and flat frames from a simulated camera with live view", + instrument="camera_simulator", + object_type=RecipeObjectType.IMAGE, +) + +_MODE = gds.ValueProp("single") +_SHUTTER = gds.ValueProp(True) + + +class CameraSimulatorSettings(gds.DataSet): + """Scientific camera simulator""" + + _tabs = gds.BeginTabGroup("Settings") + + _acquisition = gds.BeginGroup("Acquisition") + mode = gds.ChoiceItem( + "Mode", + (("single", "Single exposure"), ("sequence", "Exposure sequence")), + default="single", + ).set_prop("display", store=_MODE) + exposure_ms = gds.FloatItem( + "Exposure time", default=10.0, min=0.0, max=60_000.0, unit="ms" + ).set_prop("display", active=gds.FuncProp(_MODE, lambda mode: mode == "single")) + exposures_ms = gds.StringItem( + "Exposure times", + default="1, 2, 4, 8, 16, 32, 64, 96, 112, 120, 128, 136", + help="Exposure times in milliseconds, separated by commas", + ).set_prop("display", active=gds.FuncProp(_MODE, lambda mode: mode == "sequence")) + frame_count = gds.IntItem("Frames per exposure", default=2, min=1, max=100) + shutter_open = gds.BoolItem("Shutter open", default=True).set_prop( + "display", store=_SHUTTER + ) + dark_frame_count = gds.IntItem( + "Dark frames", + default=4, + min=0, + max=100, + help="Frames acquired shutter closed at the longest exposure time", + ).set_prop("display", active=_SHUTTER) + _acquisition_end = gds.EndGroup("Acquisition") + + _illumination = gds.BeginGroup("Illumination") + photoelectron_rate = gds.FloatItem( + "Photoelectron rate", + default=60_000.0, + min=0.0, + unit="e⁻/s", + help="Mean photoelectrons collected per pixel and per second", + ).set_prop("display", active=_SHUTTER) + vignetting_percent = gds.FloatItem( + "Vignetting", default=0.0, min=0.0, max=99.0, unit="%" + ) + dust_shadow_count = gds.IntItem("Dust shadows", default=0, min=0, max=50) + dust_shadow_depth_percent = gds.FloatItem( + "Dust-shadow depth", default=20.0, min=0.0, max=99.0, unit="%" + ) + _illumination_end = gds.EndGroup("Illumination") + + _sensor = gds.BeginGroup("Sensor") + width = gds.IntItem("Width", default=128, min=8, max=2048, unit="px") + height = gds.IntItem("Height", default=96, min=8, max=2048, unit="px") + bit_depth = gds.IntItem("ADC resolution", default=12, min=8, max=16, unit="bits") + conversion_gain = gds.FloatItem( + "Conversion gain", default=2.0, min=0.01, max=1_000.0, unit="e⁻/DN" + ) + read_noise = gds.FloatItem( + "Read noise", default=3.0, min=0.0, max=1_000.0, unit="e⁻" + ) + offset = gds.FloatItem("Offset", default=100.0, min=0.0, unit="DN") + prnu_percent = gds.FloatItem("PRNU", default=1.0, min=0.0, max=99.0, unit="%") + dsnu = gds.FloatItem("DSNU", default=1.0, min=0.0, unit="DN") + row_pattern = gds.FloatItem("Row pattern", default=3.0, min=0.0, unit="DN") + column_pattern = gds.FloatItem("Column pattern", default=2.0, min=0.0, unit="DN") + defective_percent = gds.FloatItem( + "Defective pixels", default=0.1, min=0.0, max=100.0, unit="%" + ) + serial_number = gds.IntItem( + "Serial number", + default=20261007, + min=0, + help=( + "Fixes the sensor maps (PRNU, DSNU, defects, hot pixels): change it " + "to simulate another camera of the same model" + ), + ) + _sensor_end = gds.EndGroup("Sensor") + + _dark = gds.BeginGroup("Dark current") + dark_current = gds.FloatItem("Dark current", default=0.2, min=0.0, unit="e⁻/s") + dark_nonuniformity_percent = gds.FloatItem( + "Non-uniformity", default=0.0, min=0.0, max=99.0, unit="%" + ) + hot_pixel_percent = gds.FloatItem( + "Hot pixels", default=0.0, min=0.0, max=100.0, unit="%" + ) + hot_pixel_factor = gds.FloatItem( + "Hot-pixel factor", default=30.0, min=1.0, max=10_000.0 + ) + glow = gds.FloatItem("Amplifier glow", default=0.0, min=0.0, unit="e⁻/s") + _dark_end = gds.EndGroup("Dark current") + + _tabs_end = gds.EndTabGroup("Settings") + + +def parse_exposures_ms(text: str) -> tuple[float, ...]: + """Return the exposure times listed in a text, in milliseconds. + + Raises: + ValueError: if the text does not list positive, finite numbers + """ + try: + values = tuple(float(item) for item in re.split(r"[,;\s]+", text.strip())) + except ValueError: + values = () + if not values or not all(math.isfinite(value) and value > 0 for value in values): + raise ValueError( + "Exposure times must be positive numbers in milliseconds, " + "separated by commas" + ) + return values + + +class CameraSimulator(PluginInstrument): + """Scientific camera simulator. + + The serial number seeds the sensor: every frame of a session comes from + the same pixels, while each frame draws new temporal noise. + """ + + live_interval_ms = 300 + + def __init__(self) -> None: + super().__init__(CameraSimulatorSettings()) + self._acquisition_count = 0 + self._noise_streams = itertools.count(LIVE_NOISE_STREAMS + 1) + self._preview_streams = itertools.cycle(range(1, LIVE_NOISE_STREAMS + 1)) + + @property + def saturation_dn(self) -> int: + """Return the largest value of the ADC.""" + return 2 ** int(self.settings.bit_depth) - 1 + + def sensor_parameters( + self, + exposure_ms: float, + frame_count: int, + shutter_open: bool, + noise_stream: int, + ) -> CameraSimulationParameters: + """Return the simulator parameters of one series of frames.""" + s = self.settings + exposure_time_s = exposure_ms / 1_000.0 + return CameraSimulationParameters( + shape=(int(s.height), int(s.width)), + frame_count=frame_count, + exposure_time_s=exposure_time_s, + signal_electrons=( + float(s.photoelectron_rate) * exposure_time_s if shutter_open else 0.0 + ), + offset_dn=float(s.offset), + conversion_gain_e_per_dn=float(s.conversion_gain), + read_noise_e=float(s.read_noise), + dark_current_e_per_s=float(s.dark_current), + prnu_fraction=float(s.prnu_percent) / 100.0, + dsnu_dn=float(s.dsnu), + row_pattern_dn=float(s.row_pattern), + column_pattern_dn=float(s.column_pattern), + vignetting_fraction=float(s.vignetting_percent) / 100.0, + dust_shadow_count=int(s.dust_shadow_count), + dust_shadow_depth_fraction=float(s.dust_shadow_depth_percent) / 100.0, + saturation_dn=float(self.saturation_dn), + bit_depth=int(s.bit_depth), + defective_pixel_fraction=float(s.defective_percent) / 100.0, + seed=int(s.serial_number), + dark_current_nonuniformity_fraction=( + float(s.dark_nonuniformity_percent) / 100.0 + ), + dark_hot_pixel_fraction=float(s.hot_pixel_percent) / 100.0, + dark_hot_pixel_factor=float(s.hot_pixel_factor), + amplifier_glow_e_per_s=float(s.glow), + noise_stream=noise_stream, + ) + + def exposures_ms(self) -> tuple[float, ...]: + """Return the exposure times of the next acquisition.""" + if self.settings.mode == "sequence": + return parse_exposures_ms(self.settings.exposures_ms) + return (float(self.settings.exposure_ms),) + + def preview(self) -> InstrumentFrame: + """Return one frame at the exposure time of single mode. + + In sequence mode, the frame is taken at the longest exposure time, + where saturation is most likely. + """ + exposure_ms = max(self.exposures_ms()) + shutter_open = bool(self.settings.shutter_open) + parameters = self.sensor_parameters( + exposure_ms, 1, shutter_open, next(self._preview_streams) + ) + frame = simulate_camera_frames(parameters).frames_dn[0] + role = "Flat" if shutter_open else "Dark" + image = create_image(f"{role} {exposure_ms:g} ms", frame.copy()) + saturated = 100.0 * np.count_nonzero(frame >= self.saturation_dn) / frame.size + summary = ( + f"{role} frame, {exposure_ms:g} ms: mean {np.mean(frame):.1f} DN, " + f"standard deviation {np.std(frame):.1f} DN, " + f"{saturated:.2f} % saturated pixels" + ) + return InstrumentFrame((image,), summary, (0.0, float(self.saturation_dn))) + + def acquire(self) -> InstrumentAcquisition: + """Acquire the frames of single or sequence mode, with dark frames.""" + settings = self.settings + exposures = self.exposures_ms() + shutter_open = bool(settings.shutter_open) + frame_count = int(settings.frame_count) + dark_count = int(settings.dark_frame_count) if shutter_open else 0 + total_frames = frame_count * len(exposures) + dark_count + pixels = total_frames * int(settings.width) * int(settings.height) + if pixels > MAX_ACQUISITION_PIXELS: + raise ValueError( + f"This acquisition would create {pixels / 1e6:.1f} megapixels " + f"(limit: {MAX_ACQUISITION_PIXELS / 1e6:g}): reduce the frame size " + "or the number of frames" + ) + images: list[ImageObj] = [] + if dark_count: + longest = max(exposures) + images.extend( + series_images( + f"Dark {longest:g} ms", + self.sensor_parameters( + longest, dark_count, False, next(self._noise_streams) + ), + "dark", + ) + ) + role = "flat" if shutter_open else "dark" + for exposure_ms in exposures: + images.extend( + series_images( + f"{role.capitalize()} {exposure_ms:g} ms", + self.sensor_parameters( + exposure_ms, + frame_count, + shutter_open, + next(self._noise_streams), + ), + role, + ) + ) + self._acquisition_count += 1 + return InstrumentAcquisition( + f"Camera SN {settings.serial_number} - acquisition " + f"{self._acquisition_count:03d}", + images, + ) + + +__all__ = [ + "CAMERA_SIMULATOR_TOOL", + "MAX_ACQUISITION_PIXELS", + "CameraSimulator", + "CameraSimulatorSettings", + "parse_exposures_ms", +] diff --git a/tests/unit/test_camera_simulator.py b/tests/unit/test_camera_simulator.py new file mode 100644 index 0000000..88b12f6 --- /dev/null +++ b/tests/unit/test_camera_simulator.py @@ -0,0 +1,132 @@ +"""Tests of the scientific camera simulator instrument.""" + +from __future__ import annotations + +import numpy as np +import pytest +from datalab.plugin_instruments import InstrumentAcquisition, InstrumentFrame +from datalab.recipe_binding import ( + RecipeReadinessStatus, + assess_recipe_inputs, + create_recipe_parameters, +) +from datalab.recipes import RecipeExecutionContext + +from datalab_camera_characterization.simulator import ( + MAX_ACQUISITION_PIXELS, + CameraSimulator, + parse_exposures_ms, +) +from datalab_camera_characterization.workflow import ( + DARK_CURRENT_RECIPE, + EXPOSURE_TIME_METADATA_KEY, + FRAME_ROLE_METADATA_KEY, + PHOTON_TRANSFER_RECIPE, + RELATIVE_DN_RECIPE, + PhotonTransferRecipeParameters, +) + + +def _ready(recipe, objects) -> bool: + """Return True if a recipe can run on objects with its default parameters.""" + parameters = create_recipe_parameters(recipe, {}) + readiness = assess_recipe_inputs(recipe, objects, parameters) + return readiness.status is RecipeReadinessStatus.READY + + +def test_live_frame_draws_new_noise_on_the_same_sensor() -> None: + """Live frames show one exposure with the ADC range and summary levels.""" + camera = CameraSimulator() + first = camera.preview() + second = camera.preview() + + assert isinstance(first, InstrumentFrame) + (image,) = first.objects + assert image.data.shape == (96, 128) + assert image.title == "Flat 10 ms" + assert first.value_range == (0.0, 4095.0) + assert first.summary.startswith("Flat frame, 10 ms: mean ") + assert not np.array_equal(image.data, second.objects[0].data) + + camera.settings.shutter_open = False + camera.settings.mode = "sequence" + camera.settings.exposures_ms = "5, 50" + dark = camera.preview() + assert dark.objects[0].title == "Dark 50 ms" + assert dark.summary.startswith("Dark frame, 50 ms") + + +def test_single_acquisition_adds_dark_and_flat_frames() -> None: + """One exposure gives flat frames, plus dark frames at the same exposure.""" + camera = CameraSimulator() + acquisition = camera.acquire() + + assert isinstance(acquisition, InstrumentAcquisition) + assert acquisition.group_title == "Camera SN 20261007 - acquisition 001" + assert [image.title for image in acquisition.objects] == [ + "Dark 10 ms 01", + "Dark 10 ms 02", + "Dark 10 ms 03", + "Dark 10 ms 04", + "Flat 10 ms 01", + "Flat 10 ms 02", + ] + assert [ + image.metadata[FRAME_ROLE_METADATA_KEY] for image in acquisition.objects + ] == ["dark"] * 4 + ["flat"] * 2 + assert { + image.metadata[EXPOSURE_TIME_METADATA_KEY] for image in acquisition.objects + } == {0.01} + assert camera.acquire().group_title.endswith("acquisition 002") + + +def test_photon_transfer_sequence_is_ready_for_the_camera_methods() -> None: + """A flat exposure ladder with darks suits the PTC and relative-DN methods.""" + camera = CameraSimulator() + camera.settings.mode = "sequence" + images = camera.acquire().objects + + assert len(images) == 4 + 2 * 12 + for recipe in (PHOTON_TRANSFER_RECIPE, RELATIVE_DN_RECIPE): + assert _ready(recipe, images), recipe.recipe_id + + outcome = PHOTON_TRANSFER_RECIPE.run( + assess_recipe_inputs( + PHOTON_TRANSFER_RECIPE, images, PhotonTransferRecipeParameters() + ).bindings, + PhotonTransferRecipeParameters(), + RecipeExecutionContext(), + ) + (metrics,) = outcome.results + rows = {row[0]: row for row in metrics.value.data} + assert rows["Conversion gain"][1] == pytest.approx(2.0, rel=0.1) + + +def test_dark_ramp_sequence_is_ready_for_the_dark_current_method() -> None: + """With the shutter closed, an exposure sequence is a dark ramp.""" + camera = CameraSimulator() + camera.settings.mode = "sequence" + camera.settings.shutter_open = False + camera.settings.exposures_ms = "500, 1000, 2000, 4000, 8000" + camera.settings.frame_count = 4 + camera.settings.dark_current = 40.0 + images = camera.acquire().objects + + assert len(images) == 20 + assert {image.metadata[FRAME_ROLE_METADATA_KEY] for image in images} == {"dark"} + assert _ready(DARK_CURRENT_RECIPE, images) + + +def test_invalid_settings_are_reported() -> None: + """Unreadable exposure lists and oversized acquisitions are rejected.""" + assert parse_exposures_ms("1; 2 4,8") == (1.0, 2.0, 4.0, 8.0) + for text in ("", "1, a", "1, -2", "nan"): + with pytest.raises(ValueError, match="positive numbers"): + parse_exposures_ms(text) + + camera = CameraSimulator() + camera.settings.width = camera.settings.height = 2048 + camera.settings.frame_count = 100 + with pytest.raises(ValueError, match="megapixels"): + camera.acquire() + assert 2048 * 2048 * 104 > MAX_ACQUISITION_PIXELS