From 201bef413871bb20b438d07723e4a59c615be91b Mon Sep 17 00:00:00 2001 From: Wasim Sandhu Date: Thu, 23 Apr 2026 16:06:07 -0700 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20Add=20Hina=20microscope=20(`hina-mi?= =?UTF-8?q?croscope`)=20ND2=20=E2=86=92=20JPG=20processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts uploaded .nd2 files into composite RGB JPG overlays using arcadia-microscopy-tools. Per-channel intensities are percentile stretched (1st–99th) and blended via `overlay_channels`; Z-stacks collapse to a max-intensity projection, and T/P axes fall back to the first index. Run-level metadata (sizes, channels, dimensions) is parsed once per run via a best-effort `if run.metadata` gate after `ensure_run`, so the first file to arrive wins while every file still produces its own JPG. Also adds the `hina` subcommand to `data-hub-process`, an S3 trigger for `hina-microscope/*.nd2`, and unit tests covering metadata serialization, axis reduction, percentile rescale, and the single-shot metadata gate. --- docs/lambda.md | 3 + infra/template.yaml | 9 + lambda/pyproject.toml | 1 + lambda/src/data_hub_lambda/cli.py | 40 ++ lambda/src/data_hub_lambda/handler.py | 7 + .../hina_microscope/__init__.py | 3 + .../hina_microscope/image_processing.py | 159 ++++++++ .../hina_microscope/parse_metadata.py | 40 ++ .../hina_microscope/process_file.py | 100 +++++ .../hina_microscope/test_image_processing.py | 96 +++++ .../hina_microscope/test_parse_metadata.py | 161 ++++++++ .../hina_microscope/test_process_file.py | 190 ++++++++++ lambda/tests/integration/conftest.py | 1 + .../shared/src/data_hub_shared/constants.py | 1 + packages/shared/src/data_hub_shared/enums.py | 1 + uv.lock | 354 ++++++++++++++---- 16 files changed, 1098 insertions(+), 68 deletions(-) create mode 100644 lambda/src/data_hub_lambda/hina_microscope/__init__.py create mode 100644 lambda/src/data_hub_lambda/hina_microscope/image_processing.py create mode 100644 lambda/src/data_hub_lambda/hina_microscope/parse_metadata.py create mode 100644 lambda/src/data_hub_lambda/hina_microscope/process_file.py create mode 100644 lambda/tests/hina_microscope/test_image_processing.py create mode 100644 lambda/tests/hina_microscope/test_parse_metadata.py create mode 100644 lambda/tests/hina_microscope/test_process_file.py diff --git a/docs/lambda.md b/docs/lambda.md index af034564..338bae22 100644 --- a/docs/lambda.md +++ b/docs/lambda.md @@ -33,6 +33,7 @@ When a file fails processing (or needs to be re-run), users can trigger reproces | Akta FPLC | `akta_fplc` | `akta-fplc` | | Azure 600 Gel Doc | `azure_600_gel_doc` | `azure-600-gel-doc` | | Azure Cielo qPCR | `azure_cielo_qpcr` | `azure-cielo-qpcr` | +| Hina Microscope | `hina_microscope` | `hina-microscope` | | SpectraMax iD3 Plate Reader | `spectramax_plate_reader` | `spectramax-id3-plate-reader` | | SpectraMax iD5 Plate Reader | `spectramax_plate_reader` | `spectramax-id5-plate-reader` | @@ -67,6 +68,7 @@ Available commands: | Command | Description | | --- | --- | | `gel-doc` | Process an Azure 600 Gel Doc TIFF (contrast-enhanced PNG + metadata) | +| `hina` | Convert a Hina microscope ND2 file to a JPG overlay + metadata | | `qpcr` | Parse dye channels from an Azure Cielo qPCR Cq Values CSV | | `spectramax` | Parse metadata and raw well data from a SpectraMax `.xls` export | | `tapestation` | Extract the tape type from a TapeStation CSV filename | @@ -102,6 +104,7 @@ The Lambda function depends on a scientific Python stack: - `matplotlib` — plotting - `scikit-image` — image processing - `tifffile` — TIFF file reading +- `arcadia-microscopy-tools` — ND2 reading, channel handling, and multi-channel compositing for the Hina microscope - `pydantic` — data validation - `requests` — HTTP client for the Data Hub API - `aws-lambda-typing` — type stubs for Lambda events/context diff --git a/infra/template.yaml b/infra/template.yaml index 784c8f63..a0fd3cff 100644 --- a/infra/template.yaml +++ b/infra/template.yaml @@ -107,6 +107,15 @@ Resources: # with `+` — this matches keys ending in `_Cq Values.csv`. Value: _Cq+Values.csv Function: !GetAtt DataHubFunction.Arn + - Event: s3:ObjectCreated:* + Filter: + S3Key: + Rules: + - Name: prefix + Value: hina-microscope/ + - Name: suffix + Value: .nd2 + Function: !GetAtt DataHubFunction.Arn - Event: s3:ObjectCreated:* Filter: S3Key: diff --git a/lambda/pyproject.toml b/lambda/pyproject.toml index 8a05bda6..7b148b26 100644 --- a/lambda/pyproject.toml +++ b/lambda/pyproject.toml @@ -4,6 +4,7 @@ version = "0.2.0" requires-python = ">=3.12" dependencies = [ "data-hub-shared", + "arcadia-microscopy-tools>=0.3.2", "aws-lambda-typing>=2.20.0", "click>=8.1", "matplotlib>=3.8", diff --git a/lambda/src/data_hub_lambda/cli.py b/lambda/src/data_hub_lambda/cli.py index bfb77d0b..528d8e3b 100644 --- a/lambda/src/data_hub_lambda/cli.py +++ b/lambda/src/data_hub_lambda/cli.py @@ -51,6 +51,46 @@ def gel_doc(file: Path, output_dir: Path | None) -> None: click.echo(json.dumps(metadata, indent=2)) +# --------------------------------------------------------------------------- +# Hina microscope (Nikon ND2) +# --------------------------------------------------------------------------- + + +@cli.command("hina") +@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option( + "--output-dir", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help="Directory for the exported JPG (default: same directory as FILE).", +) +def hina(file: Path, output_dir: Path | None) -> None: + """Convert a Hina microscope ND2 file to a JPG overlay. + + Loads the ND2 via `arcadia-microscopy-tools`, produces a composite + JPG overlay (per-channel percentile-stretched intensities blended onto + a brightfield/zero background using each channel's native color), and + prints the parsed run-level metadata. + """ + from data_hub_lambda.hina_microscope.image_processing import ND2Processor + from data_hub_lambda.hina_microscope.parse_metadata import parse_metadata + + processor = ND2Processor(file) + processor.load() + jpg_path = processor.export_jpg() + + if output_dir is not None: + output_dir.mkdir(parents=True, exist_ok=True) + dest = output_dir / jpg_path.name + shutil.move(str(jpg_path), str(dest)) + jpg_path = dest + + click.echo(f"Exported JPG: {jpg_path}") + + metadata = parse_metadata(processor.image) + click.echo(json.dumps(metadata, indent=2)) + + # --------------------------------------------------------------------------- # Azure Cielo qPCR # --------------------------------------------------------------------------- diff --git a/lambda/src/data_hub_lambda/handler.py b/lambda/src/data_hub_lambda/handler.py index f6bbe957..a5e866c4 100644 --- a/lambda/src/data_hub_lambda/handler.py +++ b/lambda/src/data_hub_lambda/handler.py @@ -17,6 +17,7 @@ akta_fplc, azure_600_gel_doc, azure_cielo_qpcr, + hina_microscope, spectramax_plate_reader, ) from data_hub_shared import slack @@ -215,6 +216,12 @@ def lambda_handler(event: dict[str, Any], context: Context) -> dict[str, Any] | filename=event_info.filename, ) + elif instrument_id == Instrument.HINA_MICROSCOPE.value: + result_url = hina_microscope.process_file( + run_id=event_info.run_id, + filename=event_info.filename, + ) + elif instrument_id in ( Instrument.SPECTRAMAX_ID3_PLATE_READER.value, Instrument.SPECTRAMAX_ID5_PLATE_READER.value, diff --git a/lambda/src/data_hub_lambda/hina_microscope/__init__.py b/lambda/src/data_hub_lambda/hina_microscope/__init__.py new file mode 100644 index 00000000..a4427588 --- /dev/null +++ b/lambda/src/data_hub_lambda/hina_microscope/__init__.py @@ -0,0 +1,3 @@ +from data_hub_lambda.hina_microscope.process_file import ( + process_file, # noqa: F401 +) diff --git a/lambda/src/data_hub_lambda/hina_microscope/image_processing.py b/lambda/src/data_hub_lambda/hina_microscope/image_processing.py new file mode 100644 index 00000000..4ff17f53 --- /dev/null +++ b/lambda/src/data_hub_lambda/hina_microscope/image_processing.py @@ -0,0 +1,159 @@ +from __future__ import annotations +import logging +from pathlib import Path + +import numpy as np +import skimage as ski +from arcadia_microscopy_tools import MicroscopyImage +from arcadia_microscopy_tools.blending import overlay_channels +from arcadia_microscopy_tools.channels import BRIGHTFIELD, Channel +from numpy.typing import NDArray +from PIL import Image + +logger = logging.getLogger(__name__) + +ND2_SUFFIXES = (".nd2",) + +# Percentile range for per-channel contrast stretching before overlay. +# 1st-99th percentile clips hot pixels / rare noise peaks while keeping the +# bulk of the dynamic range visible. +CONTRAST_PERCENTILES: tuple[float, float] = (1.0, 99.0) + +# JPEG quality for the exported composite. +JPEG_QUALITY = 90 + + +class ND2Processor: + """Convert a Nikon ND2 file into a per-run JPG preview. + + The pipeline uses `arcadia_microscopy_tools.MicroscopyImage` to load the + ND2, reduces each channel down to a single 2D frame (max-projection over + Z, first index over T / P), percentile-stretches intensities, and then + composites the channels into an RGB overlay using each channel's native + fluorophore color via `overlay_channels`. + """ + + def __init__(self, path: Path) -> None: + self.path = path + self._image: MicroscopyImage | None = None + + def load(self) -> None: + if not self.path.exists(): + raise FileNotFoundError(f"ND2 file not found: {self.path}") + if self.path.suffix.lower() not in ND2_SUFFIXES: + raise ValueError(f"Expected ND2 file (.nd2), got: {self.path.suffix}") + + self._image = MicroscopyImage.from_nd2_path(self.path) + + @property + def image(self) -> MicroscopyImage: + if self._image is None: + raise RuntimeError("Call load() first.") + return self._image + + def export_jpg(self) -> Path: + """Render the composite overlay and write it as a JPG next to the source.""" + rgb = self._render_rgb() + rgb_uint8 = (np.clip(rgb, 0.0, 1.0) * 255).astype(np.uint8) + + jpg_path = self.path.parent / f"{self.path.stem}.jpg" + Image.fromarray(rgb_uint8, mode="RGB").save(jpg_path, format="JPEG", quality=JPEG_QUALITY) + return jpg_path + + def _render_rgb(self) -> NDArray[np.float64]: + """Produce the RGB overlay for the loaded image.""" + per_channel_2d: dict[Channel, NDArray[np.float64]] = {} + for channel in self.image.channels: + intensities = self.image.get_intensities_from_channel(channel) + reduced = self._reduce_to_2d(intensities, self._non_channel_axes()) + per_channel_2d[channel] = _rescale_percentile(reduced, CONTRAST_PERCENTILES) + + background = self._pick_background(per_channel_2d) + + # Fluorescence channels are overlaid on top of the grayscale background. + # Skip the background channel (if it was picked from the image) so it + # isn't blended onto itself. + overlay_inputs = { + ch: arr for ch, arr in per_channel_2d.items() if ch.name != BRIGHTFIELD.name + } + if not overlay_inputs: + # Single-channel brightfield (or equivalent): return the background + # as an RGB image so the caller still gets a valid overlay. + return ski.color.gray2rgb(background) + + return overlay_channels(background, overlay_inputs) + + def _non_channel_axes(self) -> list[str]: + """Ordered axis labels for the per-channel array (C dropped).""" + return [axis for axis in self.image.sizes.keys() if axis != "C"] + + @staticmethod + def _reduce_to_2d( + intensities: NDArray, # type: ignore[type-arg] + axis_labels: list[str], + ) -> NDArray[np.float64]: + """Collapse leading axes down to a (Y, X) frame. + + Z axes are max-projected; T and P axes fall back to the first index. + Any unknown leading axis is also reduced by taking the first index, + with a warning logged. + """ + arr = intensities + labels = list(axis_labels) + while len(labels) > 2: + label = labels[0] + if label == "Z": + arr = arr.max(axis=0) + elif label in ("T", "P"): + logger.info( + "Reducing axis %s (size %d) by taking first index only.", + label, + arr.shape[0], + ) + arr = arr[0] + else: + logger.warning( + "Unknown leading axis %s (size %d); taking first index.", + label, + arr.shape[0], + ) + arr = arr[0] + labels = labels[1:] + return arr + + @staticmethod + def _pick_background( + per_channel: dict[Channel, NDArray[np.float64]], + ) -> NDArray[np.float64]: + """Pick a grayscale [0, 1] background for the overlay. + + Prefers an existing BRIGHTFIELD channel (gives a natural context + image); falls back to zeros with the same 2D shape as the first + channel so fluorescence alone still renders correctly. + """ + for channel, arr in per_channel.items(): + if channel.name == BRIGHTFIELD.name: + return arr + + first = next(iter(per_channel.values())) + return np.zeros_like(first, dtype=np.float64) + + +def _rescale_percentile( + intensities: NDArray, # type: ignore[type-arg] + percentiles: tuple[float, float], +) -> NDArray[np.float64]: + """Percentile-based contrast stretching into [0, 1].""" + if intensities.size == 0: + return np.zeros_like(intensities, dtype=np.float64) + + lo, hi = np.percentile(intensities, percentiles) + if lo == hi: + return np.zeros_like(intensities, dtype=np.float64) + + rescaled = ski.exposure.rescale_intensity( + intensities, + in_range=(lo, hi), # type: ignore[arg-type] + out_range=(0.0, 1.0), # type: ignore[arg-type] + ) + return rescaled.astype(np.float64) diff --git a/lambda/src/data_hub_lambda/hina_microscope/parse_metadata.py b/lambda/src/data_hub_lambda/hina_microscope/parse_metadata.py new file mode 100644 index 00000000..a2fa506b --- /dev/null +++ b/lambda/src/data_hub_lambda/hina_microscope/parse_metadata.py @@ -0,0 +1,40 @@ +from __future__ import annotations +from typing import Any + +from arcadia_microscopy_tools import MicroscopyImage +from arcadia_microscopy_tools.channels import Channel +from arcadia_microscopy_tools.metadata_structures import DimensionFlags + + +def parse_metadata(image: MicroscopyImage) -> dict[str, Any]: + """Extract run-level metadata from a loaded Nikon ND2 image. + + Returns a JSON-serializable dict with three keys: + + - `sizes`: the full dimension map, e.g. `{"C": 4, "Y": 256, "X": 256}`. + - `channels`: a list of `{name, excitation_nm, emission_nm, color}` dicts. + - `dimensions`: a list of `DimensionFlags` member names set on the image. + + The function operates on the already-loaded `MicroscopyImage` so the + caller does not need to re-open the ND2 file for the metadata step. + """ + return { + "sizes": dict(image.sizes), + "channels": [_channel_to_dict(channel) for channel in image.channels], + "dimensions": _dimension_names(image.dimensions), + } + + +def _channel_to_dict(channel: Channel) -> dict[str, Any]: + color = channel.color.hex_code if channel.color is not None else None + return { + "name": channel.name, + "excitation_nm": channel.excitation_nm, + "emission_nm": channel.emission_nm, + "color": color, + } + + +def _dimension_names(dimensions: DimensionFlags) -> list[str]: + """Serialize a `DimensionFlags` IntFlag as a list of member names.""" + return [flag.name for flag in DimensionFlags if flag in dimensions and flag.name] diff --git a/lambda/src/data_hub_lambda/hina_microscope/process_file.py b/lambda/src/data_hub_lambda/hina_microscope/process_file.py new file mode 100644 index 00000000..37cf2fec --- /dev/null +++ b/lambda/src/data_hub_lambda/hina_microscope/process_file.py @@ -0,0 +1,100 @@ +from __future__ import annotations +import logging + +from data_hub_lambda.api_client import get_client +from data_hub_lambda.constants import DATA_HUB_WEB_URL +from data_hub_lambda.hina_microscope.image_processing import ND2Processor +from data_hub_lambda.hina_microscope.parse_metadata import parse_metadata +from data_hub_shared import s3_utils +from data_hub_shared.config import config +from data_hub_shared.enums import Instrument + +logger = logging.getLogger(__name__) + +INSTRUMENT_ID = Instrument.HINA_MICROSCOPE.value + + +def process_file(run_id: str, filename: str) -> str: + """Process a single Hina microscope ND2 file through the Data Hub API. + + Downloads the raw ND2, runs it through the image processing pipeline to + produce a composite JPG overlay, uploads the JPG to the processed bucket, + and registers both files via the API. Run-level metadata (sizes, + channels, dimensions) is parsed and stored once per run — the first file + to arrive wins. Subsequent files in the same run still get a JPG but + skip the metadata step. + + Args: + run_id: The run ID (grouping key for files in a single imaging session). + filename: The original filename (e.g. `well_A1_xy01.nd2`). + + Returns: + The web app URL for the instrument run. + """ + logger.info("Processing Hina microscope file: %s (run: %s)", filename, run_id) + + client = get_client() + s3_bucket = config.AWS_S3_RAW_DATA_BUCKET + s3_key = f"{INSTRUMENT_ID}/{run_id}/{filename}" + + run = client.ensure_run(INSTRUMENT_ID, run_id) + + file_record = client.create_file( + instrument_id=INSTRUMENT_ID, + run_id=run_id, + s3_bucket=s3_bucket or "", + s3_key=s3_key, + filename=filename, + ) + file_id = file_record.id + + try: + client.update_file(file_id, status="processing") + + raw_data_dir = config.LOCAL_RAW_DATA_DIRPATH / INSTRUMENT_ID / run_id + local_file_path = raw_data_dir / filename + s3_utils.download_file(f"s3://{s3_bucket}/{s3_key}", local_file_path) + logger.info("Downloaded %s to %s", filename, local_file_path) + + processor = ND2Processor(local_file_path) + processor.load() + jpg_file_path = processor.export_jpg() + + processed_bucket = config.AWS_S3_PROCESSED_DATA_BUCKET + jpg_s3_key = f"{INSTRUMENT_ID}/{run_id}/{jpg_file_path.name}" + s3_utils.upload_file(jpg_file_path, f"s3://{processed_bucket}/{jpg_s3_key}") + logger.info("Uploaded processed image to s3://%s/%s", processed_bucket, jpg_s3_key) + + processed_file = client.create_file( + instrument_id=INSTRUMENT_ID, + run_id=run_id, + s3_bucket=processed_bucket or "", + s3_key=jpg_s3_key, + filename=jpg_file_path.name, + category="processed", + ) + client.update_file( + processed_file.id, + size_bytes=jpg_file_path.stat().st_size, + content_type="image/jpeg", + ) + + # Run-level metadata is deterministic for a Hina run (same channels, + # sizes, dimensions across every .nd2 file in the run) so we only + # parse and store it once — on the first file to arrive. + if not run.metadata: + metadata = parse_metadata(processor.image) + client.update_run(INSTRUMENT_ID, run_id, metadata=metadata) + logger.info("Parsed and stored run-level metadata for %s", run_id) + else: + logger.info("Run %s already has metadata; skipping metadata step.", run_id) + + client.update_file(file_id, status="completed") + logger.info("File %s marked as completed.", filename) + + except Exception as e: + logger.error("Error processing file: %s", e) + client.update_file(file_id, status="failed", error_message=str(e)) + raise + + return f"{DATA_HUB_WEB_URL}/instruments/{INSTRUMENT_ID}/runs/{run_id}" diff --git a/lambda/tests/hina_microscope/test_image_processing.py b/lambda/tests/hina_microscope/test_image_processing.py new file mode 100644 index 00000000..800997d8 --- /dev/null +++ b/lambda/tests/hina_microscope/test_image_processing.py @@ -0,0 +1,96 @@ +"""Unit tests for `hina_microscope.image_processing`. + +Covers the deterministic helper functions (`_reduce_to_2d`, +`_rescale_percentile`) without requiring a real ND2 fixture. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from data_hub_lambda.hina_microscope.image_processing import ( + ND2Processor, + _rescale_percentile, +) + + +class TestReduceTo2D: + def test_2d_input_passes_through(self) -> None: + arr = np.arange(16, dtype=np.float64).reshape(4, 4) + + out = ND2Processor._reduce_to_2d(arr, ["Y", "X"]) + + np.testing.assert_array_equal(out, arr) + + def test_z_axis_takes_max_projection(self) -> None: + # Z-stack with a per-plane max at z=2 in every pixel. + stack = np.stack( + [ + np.full((3, 3), 1, dtype=np.float64), + np.full((3, 3), 5, dtype=np.float64), + np.full((3, 3), 9, dtype=np.float64), + ] + ) + + out = ND2Processor._reduce_to_2d(stack, ["Z", "Y", "X"]) + + assert out.shape == (3, 3) + assert np.all(out == 9) + + def test_t_axis_takes_first_frame(self) -> None: + series = np.stack( + [ + np.full((2, 2), 1, dtype=np.float64), + np.full((2, 2), 2, dtype=np.float64), + ] + ) + + out = ND2Processor._reduce_to_2d(series, ["T", "Y", "X"]) + + assert np.all(out == 1) + + def test_p_axis_takes_first_point(self) -> None: + positions = np.stack( + [ + np.full((2, 2), 7, dtype=np.float64), + np.full((2, 2), 8, dtype=np.float64), + ] + ) + + out = ND2Processor._reduce_to_2d(positions, ["P", "Y", "X"]) + + assert np.all(out == 7) + + def test_tz_combined_reduces_in_order(self) -> None: + # shape: (T=2, Z=3, Y=2, X=2). First T is a Z-stack with max=9. + arr = np.arange(24, dtype=np.float64).reshape(2, 3, 2, 2) + + out = ND2Processor._reduce_to_2d(arr, ["T", "Z", "Y", "X"]) + + # T=0 slice is arr[0] (shape 3,2,2). Max over Z=0 axis gives arr[0][2]. + np.testing.assert_array_equal(out, arr[0].max(axis=0)) + + +class TestRescalePercentile: + def test_range_maps_to_0_1(self) -> None: + arr = np.linspace(100, 200, 100, dtype=np.float64) + + out = _rescale_percentile(arr, (1, 99)) + + assert out.min() == pytest.approx(0.0, abs=1e-6) + assert out.max() == pytest.approx(1.0, abs=1e-6) + + def test_constant_array_returns_zeros(self) -> None: + arr = np.full((4, 4), 42.0, dtype=np.float64) + + out = _rescale_percentile(arr, (1, 99)) + + np.testing.assert_array_equal(out, np.zeros_like(arr)) + + def test_empty_array_returns_empty_array(self) -> None: + arr = np.array([], dtype=np.float64) + + out = _rescale_percentile(arr, (1, 99)) + + assert out.shape == (0,) diff --git a/lambda/tests/hina_microscope/test_parse_metadata.py b/lambda/tests/hina_microscope/test_parse_metadata.py new file mode 100644 index 00000000..a287fd1e --- /dev/null +++ b/lambda/tests/hina_microscope/test_parse_metadata.py @@ -0,0 +1,161 @@ +from __future__ import annotations +from types import SimpleNamespace +from typing import cast + +import pytest +from arcadia_microscopy_tools import MicroscopyImage +from arcadia_microscopy_tools.channels import BRIGHTFIELD, DAPI, FITC +from arcadia_microscopy_tools.metadata_structures import DimensionFlags + +from data_hub_lambda.hina_microscope.parse_metadata import ( + _channel_to_dict, + _dimension_names, + parse_metadata, +) + + +def _make_image( + sizes: dict[str, int], + channels: list, + dimensions: DimensionFlags, +) -> MicroscopyImage: + """Build a minimal MicroscopyImage-shaped stub for metadata tests.""" + return cast( + MicroscopyImage, + SimpleNamespace(sizes=sizes, channels=channels, dimensions=dimensions), + ) + + +class TestParseMetadata: + def test_returns_expected_keys(self) -> None: + image = _make_image({"C": 1, "Y": 2, "X": 2}, [DAPI], DimensionFlags(0)) + + result = parse_metadata(image) + + assert set(result.keys()) == {"sizes", "channels", "dimensions"} + + def test_preserves_sizes_mapping(self) -> None: + image = _make_image({"C": 4, "Y": 256, "X": 256}, [DAPI, FITC], DimensionFlags(0)) + + result = parse_metadata(image) + + assert result["sizes"] == {"C": 4, "Y": 256, "X": 256} + + def test_channels_serialized_as_dicts(self) -> None: + image = _make_image( + {"C": 3, "Y": 8, "X": 8}, + [BRIGHTFIELD, DAPI, FITC], + DimensionFlags.MULTICHANNEL, + ) + + result = parse_metadata(image) + + assert result["channels"] == [ + { + "name": "BRIGHTFIELD", + "excitation_nm": None, + "emission_nm": None, + "color": "#ffffff", + }, + { + "name": "DAPI", + "excitation_nm": 405, + "emission_nm": 450, + "color": "#0033ff", + }, + { + "name": "FITC", + "excitation_nm": 488, + "emission_nm": 512, + "color": "#07ff00", + }, + ] + + def test_dimensions_serialized_as_names(self) -> None: + image = _make_image( + {"C": 2, "Z": 5, "Y": 8, "X": 8}, + [DAPI, FITC], + DimensionFlags.MULTICHANNEL | DimensionFlags.Z_STACK, + ) + + result = parse_metadata(image) + + assert sorted(result["dimensions"]) == ["MULTICHANNEL", "Z_STACK"] + + def test_empty_dimensions_serializes_to_empty_list(self) -> None: + image = _make_image({"C": 1, "Y": 8, "X": 8}, [DAPI], DimensionFlags(0)) + + result = parse_metadata(image) + + assert result["dimensions"] == [] + + def test_result_is_json_serializable(self) -> None: + import json + + image = _make_image( + {"C": 2, "T": 10, "Y": 32, "X": 32}, + [DAPI, FITC], + DimensionFlags.MULTICHANNEL | DimensionFlags.TIMELAPSE, + ) + + result = parse_metadata(image) + + # Round-trip through JSON to verify everything is serializable. + round_tripped = json.loads(json.dumps(result)) + assert round_tripped == result + + +class TestChannelToDict: + def test_channel_with_all_fields(self) -> None: + result = _channel_to_dict(DAPI) + assert result == { + "name": "DAPI", + "excitation_nm": 405, + "emission_nm": 450, + "color": "#0033ff", + } + + def test_channel_without_wavelengths(self) -> None: + result = _channel_to_dict(BRIGHTFIELD) + assert result["excitation_nm"] is None + assert result["emission_nm"] is None + assert result["color"] == "#ffffff" + + def test_channel_with_none_color(self) -> None: + from arcadia_microscopy_tools.channels import Channel + + bare = Channel(name="CUSTOM", excitation_nm=500, emission_nm=520, color=None) + + result = _channel_to_dict(bare) + + assert result == { + "name": "CUSTOM", + "excitation_nm": 500, + "emission_nm": 520, + "color": None, + } + + +class TestDimensionNames: + def test_single_flag(self) -> None: + assert _dimension_names(DimensionFlags.MULTICHANNEL) == ["MULTICHANNEL"] + + def test_combined_flags_preserves_all_members(self) -> None: + combined = DimensionFlags.MULTICHANNEL | DimensionFlags.Z_STACK | DimensionFlags.TIMELAPSE + + result = _dimension_names(combined) + + assert set(result) == {"MULTICHANNEL", "Z_STACK", "TIMELAPSE"} + + def test_no_flags(self) -> None: + assert _dimension_names(DimensionFlags(0)) == [] + + +@pytest.fixture(autouse=True) +def _no_warnings_leak(): + """Keep tests quiet regardless of upstream deprecation warnings.""" + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + yield diff --git a/lambda/tests/hina_microscope/test_process_file.py b/lambda/tests/hina_microscope/test_process_file.py new file mode 100644 index 00000000..d2a7d619 --- /dev/null +++ b/lambda/tests/hina_microscope/test_process_file.py @@ -0,0 +1,190 @@ +"""Unit tests for `hina_microscope.process_file`. + +These verify the orchestration logic — specifically that run-level metadata +is parsed and stored only on the first file to arrive in a given run — by +mocking S3 I/O, the API client, the ND2 processor, and the metadata parser. +""" + +from __future__ import annotations +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from data_hub_lambda.models import FileResponse, RunResponse + + +@pytest.fixture(autouse=True) +def _reset_api_client() -> Any: + """Ensure `get_client()` returns a fresh mock per test.""" + import data_hub_lambda.api_client as api_module + + original = api_module._client + api_module._client = None + try: + yield + finally: + api_module._client = original + + +def _run_response(metadata: dict | None = None) -> RunResponse: + return RunResponse( + id="run-uuid", + instrument_id="hina-microscope", + run_id="run-xyz", + source="lambda", + metadata=metadata or {}, + ) + + +def _file_response(file_id: int = 123) -> FileResponse: + return FileResponse( + id=file_id, + instrument_run_id="run-uuid", + filename="sample.nd2", + s3_bucket="raw", + s3_key="hina-microscope/run-xyz/sample.nd2", + category="raw", + status="uploaded", + ) + + +def _build_client_mock(run_metadata: dict | None) -> MagicMock: + client = MagicMock() + client.ensure_run.return_value = _run_response(metadata=run_metadata) + # `create_file` is called twice (raw, processed); give each a distinct id. + client.create_file.side_effect = [ + _file_response(file_id=10), + _file_response(file_id=11), + ] + return client + + +@pytest.fixture +def patched_jpg_path(tmp_path: Path) -> Path: + """A fake JPG output path that actually exists on disk (for stat()).""" + jpg = tmp_path / "sample.jpg" + jpg.write_bytes(b"fake-jpg-bytes") + return jpg + + +@pytest.fixture +def patched_processor(patched_jpg_path: Path) -> MagicMock: + """A stand-in `ND2Processor` whose `export_jpg` returns a real tmp file.""" + processor = MagicMock() + processor.image = MagicMock() + processor.load.return_value = None + processor.export_jpg.return_value = patched_jpg_path + return processor + + +class TestProcessFileRunMetadataGate: + """Verifies the single-shot-metadata behavior of `process_file`.""" + + def test_first_file_parses_metadata_and_updates_run( + self, + patched_processor: MagicMock, + ) -> None: + client = _build_client_mock(run_metadata=None) # empty = first file + + with ( + patch("data_hub_lambda.hina_microscope.process_file.get_client", return_value=client), + patch("data_hub_lambda.hina_microscope.process_file.s3_utils") as s3_mock, + patch( + "data_hub_lambda.hina_microscope.process_file.ND2Processor", + return_value=patched_processor, + ), + patch( + "data_hub_lambda.hina_microscope.process_file.parse_metadata", + return_value={"sizes": {"C": 1}, "channels": [], "dimensions": []}, + ) as parse_mock, + ): + from data_hub_lambda.hina_microscope.process_file import process_file + + process_file(run_id="run-xyz", filename="sample.nd2") + + # First file → metadata is parsed and persisted. + parse_mock.assert_called_once_with(patched_processor.image) + client.update_run.assert_called_once() + _, kwargs = client.update_run.call_args + assert kwargs == { + "metadata": {"sizes": {"C": 1}, "channels": [], "dimensions": []}, + } + + # JPG is generated and uploaded regardless. + patched_processor.export_jpg.assert_called_once() + s3_mock.upload_file.assert_called_once() + + def test_later_file_skips_metadata_but_still_generates_jpg( + self, + patched_processor: MagicMock, + ) -> None: + existing_metadata = { + "sizes": {"C": 2, "Y": 8, "X": 8}, + "channels": [{"name": "DAPI"}], + "dimensions": ["MULTICHANNEL"], + } + client = _build_client_mock(run_metadata=existing_metadata) + + with ( + patch("data_hub_lambda.hina_microscope.process_file.get_client", return_value=client), + patch("data_hub_lambda.hina_microscope.process_file.s3_utils") as s3_mock, + patch( + "data_hub_lambda.hina_microscope.process_file.ND2Processor", + return_value=patched_processor, + ), + patch( + "data_hub_lambda.hina_microscope.process_file.parse_metadata", + ) as parse_mock, + ): + from data_hub_lambda.hina_microscope.process_file import process_file + + process_file(run_id="run-xyz", filename="sample-2.nd2") + + # Second file → metadata step is skipped. + parse_mock.assert_not_called() + client.update_run.assert_not_called() + + # JPG is still generated and uploaded. + patched_processor.export_jpg.assert_called_once() + s3_mock.upload_file.assert_called_once() + + +class TestProcessFileFailure: + """On exception, the raw file status is marked `failed` with an error message.""" + + def test_marks_raw_file_failed_on_exception(self, tmp_path: Path) -> None: + client = _build_client_mock(run_metadata=None) + + failing_processor = MagicMock() + failing_processor.load.side_effect = RuntimeError("boom") + + with ( + patch("data_hub_lambda.hina_microscope.process_file.get_client", return_value=client), + patch("data_hub_lambda.hina_microscope.process_file.s3_utils"), + patch( + "data_hub_lambda.hina_microscope.process_file.ND2Processor", + return_value=failing_processor, + ), + ): + from data_hub_lambda.hina_microscope.process_file import process_file + + with pytest.raises(RuntimeError, match="boom"): + process_file(run_id="run-xyz", filename="broken.nd2") + + # File should be transitioned through processing → failed with the error message. + statuses = [ + call.kwargs.get("status") + for call in client.update_file.call_args_list + if "status" in call.kwargs + ] + assert "processing" in statuses + assert "failed" in statuses + + failed_call = next( + call + for call in client.update_file.call_args_list + if call.kwargs.get("status") == "failed" + ) + assert failed_call.kwargs["error_message"] == "boom" diff --git a/lambda/tests/integration/conftest.py b/lambda/tests/integration/conftest.py index 0cc2371a..d96dd26d 100644 --- a/lambda/tests/integration/conftest.py +++ b/lambda/tests/integration/conftest.py @@ -33,6 +33,7 @@ _INSTRUMENTS: dict[str, str] = { "azure-cielo-qpcr": "Azure Cielo qPCR", "azure-600-gel-doc": "Azure 600 Gel Doc", + "hina-microscope": "Hina Microscope", "spectramax-id3-plate-reader": "SpectraMax iD3 Plate Reader", "spectramax-id5-plate-reader": "SpectraMax iD5 Plate Reader", } diff --git a/packages/shared/src/data_hub_shared/constants.py b/packages/shared/src/data_hub_shared/constants.py index eb48cc5a..9d103394 100644 --- a/packages/shared/src/data_hub_shared/constants.py +++ b/packages/shared/src/data_hub_shared/constants.py @@ -7,6 +7,7 @@ Instrument.AKTA_FPLC.value: "Akta FPLC", Instrument.AZURE_600_GEL_DOC.value: "Azure 600 Gel Doc", Instrument.AZURE_CIELO_QPCR.value: "Azure Cielo qPCR", + Instrument.HINA_MICROSCOPE.value: "Hina Microscope", Instrument.SPECTRAMAX_ID3_PLATE_READER.value: "SpectraMax iD3 Plate Reader", Instrument.SPECTRAMAX_ID5_PLATE_READER.value: "SpectraMax iD5 Plate Reader", } diff --git a/packages/shared/src/data_hub_shared/enums.py b/packages/shared/src/data_hub_shared/enums.py index a2eb91a1..c4e3eea1 100644 --- a/packages/shared/src/data_hub_shared/enums.py +++ b/packages/shared/src/data_hub_shared/enums.py @@ -12,5 +12,6 @@ class Instrument(Enum): AKTA_FPLC = "akta-fplc" AZURE_600_GEL_DOC = "azure-600-gel-doc" AZURE_CIELO_QPCR = "azure-cielo-qpcr" + HINA_MICROSCOPE = "hina-microscope" SPECTRAMAX_ID3_PLATE_READER = "spectramax-id3-plate-reader" SPECTRAMAX_ID5_PLATE_READER = "spectramax-id5-plate-reader" diff --git a/uv.lock b/uv.lock index 80a4b31f..ddeffd5c 100644 --- a/uv.lock +++ b/uv.lock @@ -5,9 +5,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] [manifest] @@ -35,6 +38,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "arcadia-microscopy-tools" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arcadia-pycolor" }, + { name = "colour-science" }, + { name = "liffile" }, + { name = "nd2" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pydantic" }, + { name = "scikit-image" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/59/0be702d252c7bf935a9f4eca83db1435ab85b5da99575943810bdf35ea0f/arcadia_microscopy_tools-0.3.2.tar.gz", hash = "sha256:bf45a847b243aa1c364bc07a0e901dd8006e013dc5bbc2792f3408e9304ed8c9", size = 3326355, upload-time = "2026-03-23T22:50:35.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/1a/d3297a140a1694b9a00707575ec7a8aac6d353fa43a57ca316124efd9839/arcadia_microscopy_tools-0.3.2-py3-none-any.whl", hash = "sha256:324a711d420921bbea9ceffc08f435ff0b91bf9471043c93ed661b0f32eb526b", size = 1863223, upload-time = "2026-03-23T22:50:37.26Z" }, +] + +[[package]] +name = "arcadia-pycolor" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "colorspacious" }, + { name = "kaleido" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "plotly" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/e6/8fd3134f88c81fc820b810271e42c7d31eedbd3c5ebf147a0b283d2c8120/arcadia_pycolor-0.7.2.tar.gz", hash = "sha256:f02fe1b81fe12e0a1fec0a203d339f4e73f38e9153a31d31d9baf12dee9f72d2", size = 39744, upload-time = "2026-03-11T18:35:40.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/d8/2588e193d9650a510f3e34476ad61fa2e7af8a0f9aba58309be1397ad321/arcadia_pycolor-0.7.2-py3-none-any.whl", hash = "sha256:3411f8c4dca2c414c6fcaa72e2bde8669ebb884bdad976e7ff82dc8c6725a3b6", size = 46393, upload-time = "2026-03-11T18:35:39.166Z" }, +] + [[package]] name = "aws-lambda-typing" version = "2.20.0" @@ -44,6 +83,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/3d/4031f5950d65e89136d20a18b5ac985d5f087c29c902f285b6e8063619e4/aws_lambda_typing-2.20.0-py3-none-any.whl", hash = "sha256:1d44264cabfeab5ac38e67ddd0c874e677b2cbbae77a42d0519df470e6bbb49b", size = 35296, upload-time = "2024-04-02T09:43:05.881Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + [[package]] name = "boto3" version = "1.42.80" @@ -175,6 +227,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -184,13 +245,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "colorspacious" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/e4/aa41ae14c5c061205715006c8834496d86ec7500f1edda5981f0f0190cc6/colorspacious-1.1.2.tar.gz", hash = "sha256:5e9072e8cdca889dac445c35c9362a22ccf758e97b00b79ff0d5a7ba3e11b618", size = 688573, upload-time = "2018-04-08T04:27:30.83Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/a1/318b9aeca7b9856410ededa4f52d6f82174d1a41e64bdd70d951e532675a/colorspacious-1.1.2-py2.py3-none-any.whl", hash = "sha256:c78befa603cea5dccb332464e7dd29e96469eebf6cd5133029153d1e69e3fd6f", size = 37735, upload-time = "2018-04-08T04:27:22.143Z" }, +] + +[[package]] +name = "colour-science" +version = "0.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/66/6e2216ffae6f0008118c99d6f425f7591e688671ab99f5636f7f65021395/colour_science-0.4.7.tar.gz", hash = "sha256:b34773dc4dd3f9ba99cca5297fa10e9f532134d994b3a2308dacbe970dfd5079", size = 13283171, upload-time = "2025-12-06T08:15:08.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/9e/ce54e33f383d7e1432be9fb39557477779ccb155c60033c491609936dacf/colour_science-0.4.7-py3-none-any.whl", hash = "sha256:fe2383d86e507fc11a4b5206ff31a7399031e0dce527e22ec06eeb491d45d9a7", size = 9065613, upload-time = "2025-12-06T08:15:14.908Z" }, +] + [[package]] name = "contourpy" version = "1.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -260,11 +344,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "dask" +version = "2026.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "cloudpickle" }, + { name = "fsspec" }, + { name = "packaging" }, + { name = "partd" }, + { name = "pyyaml" }, + { name = "toolz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/2a/5d8cc1579590af86576dde890254440e478c7174b93a02095ecfc2e6ba38/dask-2026.3.0.tar.gz", hash = "sha256:f7d96c8274e8a900d217c1ff6ea8d1bbf0b4c2c21e74a409644498d925eb8f85", size = 11000710, upload-time = "2026-03-18T07:10:14.945Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl", hash = "sha256:be614b9242b0b38288060fb2d7696125946469c98a1c30e174883fd199e0428d", size = 1485630, upload-time = "2026-03-18T07:10:12.832Z" }, +] + +[package.optional-dependencies] +array = [ + { name = "numpy" }, +] + [[package]] name = "data-hub-lambda" version = "0.2.0" source = { editable = "lambda" } dependencies = [ + { name = "arcadia-microscopy-tools" }, { name = "aws-lambda-typing" }, { name = "click" }, { name = "data-hub-shared" }, @@ -278,6 +386,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "arcadia-microscopy-tools", specifier = ">=0.3.2" }, { name = "aws-lambda-typing", specifier = ">=2.20.0" }, { name = "click", specifier = ">=8.1" }, { name = "data-hub-shared", editable = "packages/shared" }, @@ -393,6 +502,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, ] +[[package]] +name = "fsspec" +version = "2026.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/cf/b50ddf667c15276a9ab15a70ef5f257564de271957933ffea49d2cdbcdfb/fsspec-2026.3.0.tar.gz", hash = "sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41", size = 313547, upload-time = "2026-03-27T19:11:14.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" }, +] + [[package]] name = "identify" version = "2.6.18" @@ -416,8 +534,7 @@ name = "imageio" version = "2.37.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "numpy" }, { name = "pillow" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673, upload-time = "2026-03-09T11:31:12.573Z" } @@ -443,6 +560,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] +[[package]] +name = "kaleido" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f7/0ccaa596ec341963adbb4f839774c36d5659e75a0812d946732b927d480e/kaleido-0.2.1-py2.py3-none-macosx_10_11_x86_64.whl", hash = "sha256:ca6f73e7ff00aaebf2843f73f1d3bacde1930ef5041093fe76b83a15785049a7", size = 85153681, upload-time = "2021-03-08T10:27:34.202Z" }, + { url = "https://files.pythonhosted.org/packages/45/8e/4297556be5a07b713bb42dde0f748354de9a6918dee251c0e6bdcda341e7/kaleido-0.2.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:bb9a5d1f710357d5d432ee240ef6658a6d124c3e610935817b4b42da9c787c05", size = 85808197, upload-time = "2021-03-08T10:27:46.561Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b3/a0f0f4faac229b0011d8c4a7ee6da7c2dca0b6fd08039c95920846f23ca4/kaleido-0.2.1-py2.py3-none-manylinux1_x86_64.whl", hash = "sha256:aa21cf1bf1c78f8fa50a9f7d45e1003c387bd3d6fe0a767cfbbf344b95bdc3a8", size = 79902476, upload-time = "2021-03-08T10:27:57.364Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2b/680662678a57afab1685f0c431c2aba7783ce4344f06ec162074d485d469/kaleido-0.2.1-py2.py3-none-manylinux2014_aarch64.whl", hash = "sha256:845819844c8082c9469d9c17e42621fbf85c2b237ef8a86ec8a8527f98b6512a", size = 83711746, upload-time = "2021-03-08T10:28:08.847Z" }, + { url = "https://files.pythonhosted.org/packages/88/89/4b6f8bb3f9ab036fd4ad1cb2d628ab5c81db32ac9aa0641d7b180073ba43/kaleido-0.2.1-py2.py3-none-win32.whl", hash = "sha256:ecc72635860be616c6b7161807a65c0dbd9b90c6437ac96965831e2e24066552", size = 62312480, upload-time = "2021-03-08T10:28:18.204Z" }, + { url = "https://files.pythonhosted.org/packages/f7/9a/0408b02a4bcb3cf8b338a2b074ac7d1b2099e2b092b42473def22f7b625f/kaleido-0.2.1-py2.py3-none-win_amd64.whl", hash = "sha256:4670985f28913c2d063c5734d125ecc28e40810141bdb0a46f15b76c1d45f23c", size = 65945521, upload-time = "2021-03-08T10:28:26.823Z" }, +] + [[package]] name = "kiwisolver" version = "1.5.0" @@ -541,6 +671,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" }, ] +[[package]] +name = "liffile" +version = "2026.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/33/bee0d50308252bd3474350c6c61a2a3a83dcb9fad0ab383653ea3c59f5f8/liffile-2026.4.11.tar.gz", hash = "sha256:bf1056390e0aaf0b172a284cf977d834ba0e21aed18ceeb9be3ba42f8caa8d25", size = 55599, upload-time = "2026-04-11T23:48:55.817Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/5c/1abfe6871eb7868c485b83eb9b3587f6996f26a15af849882da2912a9bee/liffile-2026.4.11-py3-none-any.whl", hash = "sha256:4b9ce66ed200f3592c1e4a491657ebd8c74c5620affb4d9407cb4de834e37f99", size = 36291, upload-time = "2026-04-11T23:48:54.565Z" }, +] + +[[package]] +name = "locket" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/83/97b29fe05cb6ae28d2dbd30b81e2e402a3eed5f460c26e9eaa5895ceacf5/locket-1.0.0.tar.gz", hash = "sha256:5c0d4c052a8bbbf750e056a8e65ccd309086f4f0f18a2eac306a8dfa4112a632", size = 4350, upload-time = "2022-04-20T22:04:44.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl", hash = "sha256:b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3", size = 4398, upload-time = "2022-04-20T22:04:42.23Z" }, +] + [[package]] name = "matplotlib" version = "3.10.3" @@ -550,8 +701,7 @@ dependencies = [ { name = "cycler" }, { name = "fonttools" }, { name = "kiwisolver" }, - { name = "numpy", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "numpy" }, { name = "packaging" }, { name = "pillow" }, { name = "pyparsing" }, @@ -579,6 +729,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/92/9a45c91089c3cf690b5badd4be81e392ff086ccca8a1d4e3a08463d8a966/matplotlib-3.10.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4f23ffe95c5667ef8a2b56eea9b53db7f43910fa4a2d5472ae0f72b64deab4d5", size = 8139044, upload-time = "2025-05-08T19:10:44.551Z" }, ] +[[package]] +name = "narwhals" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/f3/257adc69a71011b4c8cda321b00f02c5bf1980ae38ffd05a58d9632d4de8/narwhals-2.20.0.tar.gz", hash = "sha256:c10994975fa7dc5a68c2cffcddbd5908fc8ebb2d463c5bab085309c0ee1f551e", size = 627848, upload-time = "2026-04-20T12:11:45.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/69/f24d3d1c38ad69e256138b4ec2452a8c7cf66be49dc214771ae99dd4f0a0/narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d", size = 449373, upload-time = "2026-04-20T12:11:43.596Z" }, +] + +[[package]] +name = "nd2" +version = "0.11.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dask", extra = ["array"] }, + { name = "numpy" }, + { name = "ome-types" }, + { name = "pydantic-core", marker = "python_full_version >= '3.13'" }, + { name = "resource-backed-dask-array" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/e7/2bf7dcb2c011aad251d7041ff592c8ceab30fa6d5c40b654434f0e25e5ba/nd2-0.11.3.tar.gz", hash = "sha256:c153fccee87ef3df27accd18cf813502ceb19289de266a61cc29a5346c43fc91", size = 880585, upload-time = "2026-03-27T19:55:35.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/51/4f3af9658395bf5549b424b8620df790ba60513d0c093222a55ec5cbb720/nd2-0.11.3-py3-none-any.whl", hash = "sha256:5cf735dc02bb7b8fb400f657f8c04e73f51c28c1032c644a7746948c13be38f9", size = 102111, upload-time = "2026-03-27T19:55:33.398Z" }, +] + [[package]] name = "networkx" version = "3.6.1" @@ -597,61 +773,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] -[[package]] -name = "numpy" -version = "2.3.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -sdist = { url = "https://files.pythonhosted.org/packages/2e/19/d7c972dfe90a353dbd3efbbe1d14a5951de80c99c9dc1b93cd998d51dc0f/numpy-2.3.1.tar.gz", hash = "sha256:1ec9ae20a4226da374362cca3c62cd753faf2f951440b0e3b98e93c235441d2b", size = 20390372, upload-time = "2025-06-21T12:28:33.469Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/56/71ad5022e2f63cfe0ca93559403d0edef14aea70a841d640bd13cdba578e/numpy-2.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2959d8f268f3d8ee402b04a9ec4bb7604555aeacf78b360dc4ec27f1d508177d", size = 20896664, upload-time = "2025-06-21T12:15:30.845Z" }, - { url = "https://files.pythonhosted.org/packages/25/65/2db52ba049813670f7f987cc5db6dac9be7cd95e923cc6832b3d32d87cef/numpy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:762e0c0c6b56bdedfef9a8e1d4538556438288c4276901ea008ae44091954e29", size = 14131078, upload-time = "2025-06-21T12:15:52.23Z" }, - { url = "https://files.pythonhosted.org/packages/57/dd/28fa3c17b0e751047ac928c1e1b6990238faad76e9b147e585b573d9d1bd/numpy-2.3.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:867ef172a0976aaa1f1d1b63cf2090de8b636a7674607d514505fb7276ab08fc", size = 5112554, upload-time = "2025-06-21T12:16:01.434Z" }, - { url = "https://files.pythonhosted.org/packages/c9/fc/84ea0cba8e760c4644b708b6819d91784c290288c27aca916115e3311d17/numpy-2.3.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:4e602e1b8682c2b833af89ba641ad4176053aaa50f5cacda1a27004352dde943", size = 6646560, upload-time = "2025-06-21T12:16:11.895Z" }, - { url = "https://files.pythonhosted.org/packages/61/b2/512b0c2ddec985ad1e496b0bd853eeb572315c0f07cd6997473ced8f15e2/numpy-2.3.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8e333040d069eba1652fb08962ec5b76af7f2c7bce1df7e1418c8055cf776f25", size = 14260638, upload-time = "2025-06-21T12:16:32.611Z" }, - { url = "https://files.pythonhosted.org/packages/6e/45/c51cb248e679a6c6ab14b7a8e3ead3f4a3fe7425fc7a6f98b3f147bec532/numpy-2.3.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:e7cbf5a5eafd8d230a3ce356d892512185230e4781a361229bd902ff403bc660", size = 16632729, upload-time = "2025-06-21T12:16:57.439Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ff/feb4be2e5c09a3da161b412019caf47183099cbea1132fd98061808c2df2/numpy-2.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1b8f26d1086835f442286c1d9b64bb3974b0b1e41bb105358fd07d20872952", size = 15565330, upload-time = "2025-06-21T12:17:20.638Z" }, - { url = "https://files.pythonhosted.org/packages/bc/6d/ceafe87587101e9ab0d370e4f6e5f3f3a85b9a697f2318738e5e7e176ce3/numpy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ee8340cb48c9b7a5899d1149eece41ca535513a9698098edbade2a8e7a84da77", size = 18361734, upload-time = "2025-06-21T12:17:47.938Z" }, - { url = "https://files.pythonhosted.org/packages/2b/19/0fb49a3ea088be691f040c9bf1817e4669a339d6e98579f91859b902c636/numpy-2.3.1-cp312-cp312-win32.whl", hash = "sha256:e772dda20a6002ef7061713dc1e2585bc1b534e7909b2030b5a46dae8ff077ab", size = 6320411, upload-time = "2025-06-21T12:17:58.475Z" }, - { url = "https://files.pythonhosted.org/packages/b1/3e/e28f4c1dd9e042eb57a3eb652f200225e311b608632bc727ae378623d4f8/numpy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cfecc7822543abdea6de08758091da655ea2210b8ffa1faf116b940693d3df76", size = 12734973, upload-time = "2025-06-21T12:18:17.601Z" }, - { url = "https://files.pythonhosted.org/packages/04/a8/8a5e9079dc722acf53522b8f8842e79541ea81835e9b5483388701421073/numpy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:7be91b2239af2658653c5bb6f1b8bccafaf08226a258caf78ce44710a0160d30", size = 10191491, upload-time = "2025-06-21T12:18:33.585Z" }, - { url = "https://files.pythonhosted.org/packages/d4/bd/35ad97006d8abff8631293f8ea6adf07b0108ce6fec68da3c3fcca1197f2/numpy-2.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25a1992b0a3fdcdaec9f552ef10d8103186f5397ab45e2d25f8ac51b1a6b97e8", size = 20889381, upload-time = "2025-06-21T12:19:04.103Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4f/df5923874d8095b6062495b39729178eef4a922119cee32a12ee1bd4664c/numpy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7dea630156d39b02a63c18f508f85010230409db5b2927ba59c8ba4ab3e8272e", size = 14152726, upload-time = "2025-06-21T12:19:25.599Z" }, - { url = "https://files.pythonhosted.org/packages/8c/0f/a1f269b125806212a876f7efb049b06c6f8772cf0121139f97774cd95626/numpy-2.3.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bada6058dd886061f10ea15f230ccf7dfff40572e99fef440a4a857c8728c9c0", size = 5105145, upload-time = "2025-06-21T12:19:34.782Z" }, - { url = "https://files.pythonhosted.org/packages/6d/63/a7f7fd5f375b0361682f6ffbf686787e82b7bbd561268e4f30afad2bb3c0/numpy-2.3.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:a894f3816eb17b29e4783e5873f92faf55b710c2519e5c351767c51f79d8526d", size = 6639409, upload-time = "2025-06-21T12:19:45.228Z" }, - { url = "https://files.pythonhosted.org/packages/bf/0d/1854a4121af895aab383f4aa233748f1df4671ef331d898e32426756a8a6/numpy-2.3.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:18703df6c4a4fee55fd3d6e5a253d01c5d33a295409b03fda0c86b3ca2ff41a1", size = 14257630, upload-time = "2025-06-21T12:20:06.544Z" }, - { url = "https://files.pythonhosted.org/packages/50/30/af1b277b443f2fb08acf1c55ce9d68ee540043f158630d62cef012750f9f/numpy-2.3.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5902660491bd7a48b2ec16c23ccb9124b8abfd9583c5fdfa123fe6b421e03de1", size = 16627546, upload-time = "2025-06-21T12:20:31.002Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ec/3b68220c277e463095342d254c61be8144c31208db18d3fd8ef02712bcd6/numpy-2.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:36890eb9e9d2081137bd78d29050ba63b8dab95dff7912eadf1185e80074b2a0", size = 15562538, upload-time = "2025-06-21T12:20:54.322Z" }, - { url = "https://files.pythonhosted.org/packages/77/2b/4014f2bcc4404484021c74d4c5ee8eb3de7e3f7ac75f06672f8dcf85140a/numpy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a780033466159c2270531e2b8ac063704592a0bc62ec4a1b991c7c40705eb0e8", size = 18360327, upload-time = "2025-06-21T12:21:21.053Z" }, - { url = "https://files.pythonhosted.org/packages/40/8d/2ddd6c9b30fcf920837b8672f6c65590c7d92e43084c25fc65edc22e93ca/numpy-2.3.1-cp313-cp313-win32.whl", hash = "sha256:39bff12c076812595c3a306f22bfe49919c5513aa1e0e70fac756a0be7c2a2b8", size = 6312330, upload-time = "2025-06-21T12:25:07.447Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c8/beaba449925988d415efccb45bf977ff8327a02f655090627318f6398c7b/numpy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d5ee6eec45f08ce507a6570e06f2f879b374a552087a4179ea7838edbcbfa42", size = 12731565, upload-time = "2025-06-21T12:25:26.444Z" }, - { url = "https://files.pythonhosted.org/packages/0b/c3/5c0c575d7ec78c1126998071f58facfc124006635da75b090805e642c62e/numpy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c4d9e0a8368db90f93bd192bfa771ace63137c3488d198ee21dfb8e7771916e", size = 10190262, upload-time = "2025-06-21T12:25:42.196Z" }, - { url = "https://files.pythonhosted.org/packages/ea/19/a029cd335cf72f79d2644dcfc22d90f09caa86265cbbde3b5702ccef6890/numpy-2.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:b0b5397374f32ec0649dd98c652a1798192042e715df918c20672c62fb52d4b8", size = 20987593, upload-time = "2025-06-21T12:21:51.664Z" }, - { url = "https://files.pythonhosted.org/packages/25/91/8ea8894406209107d9ce19b66314194675d31761fe2cb3c84fe2eeae2f37/numpy-2.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c5bdf2015ccfcee8253fb8be695516ac4457c743473a43290fd36eba6a1777eb", size = 14300523, upload-time = "2025-06-21T12:22:13.583Z" }, - { url = "https://files.pythonhosted.org/packages/a6/7f/06187b0066eefc9e7ce77d5f2ddb4e314a55220ad62dd0bfc9f2c44bac14/numpy-2.3.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d70f20df7f08b90a2062c1f07737dd340adccf2068d0f1b9b3d56e2038979fee", size = 5227993, upload-time = "2025-06-21T12:22:22.53Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ec/a926c293c605fa75e9cfb09f1e4840098ed46d2edaa6e2152ee35dc01ed3/numpy-2.3.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:2fb86b7e58f9ac50e1e9dd1290154107e47d1eef23a0ae9145ded06ea606f992", size = 6736652, upload-time = "2025-06-21T12:22:33.629Z" }, - { url = "https://files.pythonhosted.org/packages/e3/62/d68e52fb6fde5586650d4c0ce0b05ff3a48ad4df4ffd1b8866479d1d671d/numpy-2.3.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:23ab05b2d241f76cb883ce8b9a93a680752fbfcbd51c50eff0b88b979e471d8c", size = 14331561, upload-time = "2025-06-21T12:22:55.056Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ec/b74d3f2430960044bdad6900d9f5edc2dc0fb8bf5a0be0f65287bf2cbe27/numpy-2.3.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ce2ce9e5de4703a673e705183f64fd5da5bf36e7beddcb63a25ee2286e71ca48", size = 16693349, upload-time = "2025-06-21T12:23:20.53Z" }, - { url = "https://files.pythonhosted.org/packages/0d/15/def96774b9d7eb198ddadfcbd20281b20ebb510580419197e225f5c55c3e/numpy-2.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c4913079974eeb5c16ccfd2b1f09354b8fed7e0d6f2cab933104a09a6419b1ee", size = 15642053, upload-time = "2025-06-21T12:23:43.697Z" }, - { url = "https://files.pythonhosted.org/packages/2b/57/c3203974762a759540c6ae71d0ea2341c1fa41d84e4971a8e76d7141678a/numpy-2.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:010ce9b4f00d5c036053ca684c77441f2f2c934fd23bee058b4d6f196efd8280", size = 18434184, upload-time = "2025-06-21T12:24:10.708Z" }, - { url = "https://files.pythonhosted.org/packages/22/8a/ccdf201457ed8ac6245187850aff4ca56a79edbea4829f4e9f14d46fa9a5/numpy-2.3.1-cp313-cp313t-win32.whl", hash = "sha256:6269b9edfe32912584ec496d91b00b6d34282ca1d07eb10e82dfc780907d6c2e", size = 6440678, upload-time = "2025-06-21T12:24:21.596Z" }, - { url = "https://files.pythonhosted.org/packages/f1/7e/7f431d8bd8eb7e03d79294aed238b1b0b174b3148570d03a8a8a8f6a0da9/numpy-2.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:2a809637460e88a113e186e87f228d74ae2852a2e0c44de275263376f17b5bdc", size = 12870697, upload-time = "2025-06-21T12:24:40.644Z" }, - { url = "https://files.pythonhosted.org/packages/d4/ca/af82bf0fad4c3e573c6930ed743b5308492ff19917c7caaf2f9b6f9e2e98/numpy-2.3.1-cp313-cp313t-win_arm64.whl", hash = "sha256:eccb9a159db9aed60800187bc47a6d3451553f0e1b08b068d8b277ddfbb9b244", size = 10260376, upload-time = "2025-06-21T12:24:56.884Z" }, -] - [[package]] name = "numpy" version = "2.4.4" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, @@ -709,6 +834,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, ] +[[package]] +name = "ome-types" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "pydantic-core", marker = "python_full_version >= '3.13'" }, + { name = "pydantic-extra-types" }, + { name = "xsdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/4c/d252c1619c733eec9b4d2d21fe369fd21a2594954b396bf4352edea1e272/ome_types-0.6.3.tar.gz", hash = "sha256:eef4138cda5edfdcb2a44cfb90b714a59ead1b69e4c5ce5f9892ad397ccaaa68", size = 121784, upload-time = "2025-11-26T00:28:24.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/6a/1000cad1700ab0af4d1b1d0a9c23c34badddb4f547c008bde2a6c61968f1/ome_types-0.6.3-py3-none-any.whl", hash = "sha256:ce9753ff351bbc534ee5c5038d3cf60b1e4c13d69ad2e6b5a5b75de2a52521a5", size = 245802, upload-time = "2025-11-26T00:28:22.853Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -723,8 +863,7 @@ name = "pandas" version = "3.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "numpy" }, { name = "python-dateutil" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] @@ -771,6 +910,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" }, ] +[[package]] +name = "partd" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "locket" }, + { name = "toolz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/3a/3f06f34820a31257ddcabdfafc2672c5816be79c7e353b02c1f318daa7d4/partd-1.4.2.tar.gz", hash = "sha256:d022c33afbdc8405c226621b015e8067888173d85f7f5ecebb3cafed9a20f02c", size = 21029, upload-time = "2024-05-06T19:51:41.945Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.2-py3-none-any.whl", hash = "sha256:978e4ac767ec4ba5b86c6eaa52e5a2a3bc748a2ca839e8cc798f1cc6ce6efb0f", size = 18905, upload-time = "2024-05-06T19:51:39.271Z" }, +] + [[package]] name = "pillow" version = "12.2.0" @@ -849,6 +1001,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, ] +[[package]] +name = "plotly" +version = "6.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/7f/0f100df1172aadf88a929a9dbb902656b0880ba4b960fe5224867159d8f4/plotly-6.7.0.tar.gz", hash = "sha256:45eea0ff27e2a23ccd62776f77eb43aa1ca03df4192b76036e380bb479b892c6", size = 6911286, upload-time = "2026-04-09T20:36:45.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl", hash = "sha256:ac8aca1c25c663a59b5b9140a549264a5badde2e057d79b8c772ae2920e32ff0", size = 9898444, upload-time = "2026-04-09T20:36:39.812Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1001,6 +1166,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] +[[package]] +name = "pydantic-extra-types" +version = "2.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -1159,6 +1337,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] +[[package]] +name = "resource-backed-dask-array" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dask", extra = ["array"] }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/80/b8952048ae1772d33b95dbf7d7107cf364c037cc229a2690fc8fa9ee8e48/resource_backed_dask_array-0.1.0.tar.gz", hash = "sha256:8fabcccf5c7e29059b5badd6786dd7675a258a203c58babf10077d9c90ada54f", size = 10300, upload-time = "2022-02-18T02:10:06.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/b5/852f619e53fa7fb70d8915fcae66632df3958cac7e926c4ac38458958674/resource_backed_dask_array-0.1.0-py2.py3-none-any.whl", hash = "sha256:ec457fa72d81f0340a67ea6557a5a5919323a11cccc978a950df29fa69fe5679", size = 8044, upload-time = "2022-02-18T02:10:05.559Z" }, +] + [[package]] name = "ruff" version = "0.15.8" @@ -1204,8 +1395,7 @@ dependencies = [ { name = "imageio" }, { name = "lazy-loader" }, { name = "networkx" }, - { name = "numpy", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "numpy" }, { name = "packaging" }, { name = "pillow" }, { name = "scipy" }, @@ -1260,8 +1450,7 @@ name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -1326,19 +1515,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + [[package]] name = "tifffile" version = "2026.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1a/e4/e804505f87627cd8cdae9c010c47c4485fd8c1ce31a7dd0ab7fcc4707377/tifffile-2026.3.3-py3-none-any.whl", hash = "sha256:e8be15c94273113d31ecb7aa3a39822189dd11c4967e3cc88c178f1ad2fd1170", size = 243960, upload-time = "2026-03-03T19:14:35.808Z" }, ] +[[package]] +name = "toolz" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1416,3 +1622,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] + +[[package]] +name = "xsdata" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/c9/71e9e8eac669091fd434ed494d806c8cc37614aecb34ce4c62c283f99abf/xsdata-26.2.tar.gz", hash = "sha256:c631af71aaa75734f8ce92a08fcf8389d905dee2aab0b5032c9032e9071009a6", size = 349690, upload-time = "2026-02-15T16:13:31.274Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/92/f0edcbc2f895ecea14a68e492b24c157625e251279a94b172a6b263290e7/xsdata-26.2-py3-none-any.whl", hash = "sha256:85a591a4405d903416afbd4a917e8dda8ea44641a3e66d72134bc2a31b3c16b0", size = 235561, upload-time = "2026-02-15T16:13:29.614Z" }, +] From a7b775432a3bf5af4952698c1f6667a177e2510f Mon Sep 17 00:00:00 2001 From: Wasim Sandhu Date: Thu, 23 Apr 2026 16:27:59 -0700 Subject: [PATCH 2/7] feat: Add instrument runs table and image carousel for Hina microscope Introduces the Hina microscope as a first-class instrument variant in the web app, mirroring the existing plate reader / gel doc / qPCR pattern. - Add `hina_microscope` to `instrument_type` pg enum (migration 0008) and the edit-instrument dialog's type picker - New `HinaRunsTable` with Channels / Dimensions / Sizes columns surfacing the metadata the Lambda writes from parse_metadata.py (channel name + hex swatch, dimension flags, formatted size string) - New `HinaMicroscopeRunDetail` variant whose Report section is an image carousel (shadcn/ui + embla) over the processed JPGs produced per ND2 file - Shared helpers `getMetadataRecord` and `getMetadataObjectArray` to safely read the nested `sizes` dict and `channels` array-of-dicts --- .../instruments/edit-instrument-dialog.tsx | 1 + .../runs-table/hina-runs-table.tsx | 173 +++ .../instruments/runs-table/index.tsx | 10 + .../instruments/runs-table/metadata-utils.tsx | 2 + .../components/runs/hina-report-section.tsx | 144 ++ web-app/components/runs/metadata-badges.tsx | 25 + .../components/runs/run-metadata-badges.tsx | 141 ++ .../variants/hina-microscope-run-detail.tsx | 60 + web-app/components/runs/variants/index.tsx | 3 + web-app/components/ui/carousel.tsx | 242 +++ .../drizzle/0008_chilly_princess_powerful.sql | 1 + web-app/drizzle/meta/0008_snapshot.json | 1362 +++++++++++++++++ web-app/drizzle/meta/_journal.json | 7 + web-app/lib/db/schema.ts | 1 + web-app/package-lock.json | 29 + web-app/package.json | 1 + 16 files changed, 2202 insertions(+) create mode 100644 web-app/components/instruments/runs-table/hina-runs-table.tsx create mode 100644 web-app/components/runs/hina-report-section.tsx create mode 100644 web-app/components/runs/variants/hina-microscope-run-detail.tsx create mode 100644 web-app/components/ui/carousel.tsx create mode 100644 web-app/drizzle/0008_chilly_princess_powerful.sql create mode 100644 web-app/drizzle/meta/0008_snapshot.json diff --git a/web-app/components/instruments/edit-instrument-dialog.tsx b/web-app/components/instruments/edit-instrument-dialog.tsx index 75f9f1ef..ad3ef57b 100644 --- a/web-app/components/instruments/edit-instrument-dialog.tsx +++ b/web-app/components/instruments/edit-instrument-dialog.tsx @@ -31,6 +31,7 @@ const TYPE_LABELS: Record = { gel_doc: "Gel Doc", qpcr: "qPCR", tape_station: "TapeStation", + hina_microscope: "Hina Microscope", }; const INSTRUMENT_TYPE_OPTIONS = VALID_INSTRUMENT_TYPES.map((value) => ({ diff --git a/web-app/components/instruments/runs-table/hina-runs-table.tsx b/web-app/components/instruments/runs-table/hina-runs-table.tsx new file mode 100644 index 00000000..7950c94a --- /dev/null +++ b/web-app/components/instruments/runs-table/hina-runs-table.tsx @@ -0,0 +1,173 @@ +import { RelativeTime } from "@/components/dashboard/relative-time"; +import { + extractHinaChannels, + formatHinaSizes, +} from "@/components/runs/run-metadata-badges"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { runRowToRef } from "@/lib/runs/row-actions"; +import { cn, formatBytes } from "@/lib/utils"; + +import type { RunsTableProps } from "."; +import { ClickableRow } from "./clickable-row"; +import { FilterableColumnHeader } from "./filterable-column-header"; +import { + MetadataArrayBadges, + getMetadataArray, + getMetadataRecord, +} from "./metadata-utils"; +import { RanByCell } from "./ran-by-cell"; +import { RawFileColumnHeader } from "./raw-file-column-header"; +import { RunIdLabel } from "./run-id-label"; +import { RunRowActions } from "./run-row-actions"; +import { RunSelectAllCheckbox, RunSelectCheckbox } from "./run-select-checkbox"; +import type { RunRef } from "./run-selection-provider"; +import { RunStatusIcon } from "./run-status-icon"; + +export function HinaRunsTable({ + data, + instrumentId, + ranByOptions, +}: RunsTableProps) { + const runRefs: RunRef[] = data.map(runRowToRef); + + return ( + + + + + + + Run ID + + + + + + + Channels + Dimensions + Sizes + + + + Created + + Actions + + + + + {data.map((row) => { + const isDeleted = row.deleted_at !== null; + const channels = extractHinaChannels(row.metadata); + const dimensions = getMetadataArray(row.metadata, "dimensions"); + const sizes = getMetadataRecord(row.metadata, "sizes"); + const sizesLabel = sizes ? formatHinaSizes(sizes) : ""; + return ( + + + + + +
+ + + {isDeleted && ( + + deleted + + )} +
+
+ + {row.file_count} + + + {formatBytes(row.total_size_bytes)} + + + {channels.length === 0 ? ( + + ) : ( +
+ {channels.map((c) => { + const style = c.color + ? { borderColor: c.color, color: c.color } + : undefined; + return ( + + {c.color && ( + + ); + })} +
+ )} +
+ + + + + {sizesLabel ? ( + + {sizesLabel} + + ) : ( + + )} + + + + + + + + + + +
+ ); + })} +
+
+ ); +} diff --git a/web-app/components/instruments/runs-table/index.tsx b/web-app/components/instruments/runs-table/index.tsx index 6f2f7953..0c51790d 100644 --- a/web-app/components/instruments/runs-table/index.tsx +++ b/web-app/components/instruments/runs-table/index.tsx @@ -9,6 +9,7 @@ import { SearchX } from "lucide-react"; import { DefaultRunsTable } from "./default-runs-table"; import { GelDocRunsTable } from "./gel-doc-runs-table"; +import { HinaRunsTable } from "./hina-runs-table"; import { PlateReaderRunsTable } from "./plate-reader-runs-table"; import { QpcrRunsTable } from "./qpcr-runs-table"; import { RunsTableFooter } from "./runs-table-footer"; @@ -98,6 +99,15 @@ export function InstrumentRunsTable({ /> ); break; + case "hina_microscope": + table = ( + + ); + break; default: table = ( + files + .filter( + (f) => + f.category === "processed" && f.deletedAt === null && isImageFile(f) + ) + .sort(sortByFilename), + [files] + ); + + const [api, setApi] = useState(null); + const [currentIndex, setCurrentIndex] = useState(0); + + useEffect(() => { + if (!api) return; + // Subscribe to Embla's own "select" and "reInit" events — no synchronous + // state sync needed on mount since Embla defaults to snap 0 which matches + // our initial state. `reInit` covers the case where the carousel recalcs + // after images load and potentially lands on a different snap. + const onSelect = () => setCurrentIndex(api.selectedScrollSnap()); + api.on("select", onSelect); + api.on("reInit", onSelect); + return () => { + api.off("select", onSelect); + api.off("reInit", onSelect); + }; + }, [api]); + + if (processedImages.length === 0) { + return ( +
+

Report Data

+ + +

+ No report data has been generated for this run. +

+
+
+
+ ); + } + + const currentFile = + processedImages[Math.min(currentIndex, processedImages.length - 1)]; + const currentDownloadUrl = `/api/v1/files/${currentFile.id}/download`; + + return ( +
+

+ Report Data{" "} + + {processedImages.length} image(s) + +

+ + +
+
+

+ {currentFile.filename} +

+ + {currentIndex + 1} / {processedImages.length} + +
+ +
+ + + {processedImages.map((file, i) => { + const url = `/api/v1/files/${file.id}/download`; + return ( + +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {file.filename} +
+
+ ); + })} +
+ {processedImages.length > 1 && ( + <> + + + + )} +
+
+
+
+ ); +} diff --git a/web-app/components/runs/metadata-badges.tsx b/web-app/components/runs/metadata-badges.tsx index 4d105185..e02d0ec2 100644 --- a/web-app/components/runs/metadata-badges.tsx +++ b/web-app/components/runs/metadata-badges.tsx @@ -23,6 +23,31 @@ export function getMetadataArray(metadata: unknown, key: string): string[] { return []; } +export function getMetadataRecord( + metadata: unknown, + key: string +): Record | null { + if (!metadata || typeof metadata !== "object") return null; + const value = (metadata as Record)[key]; + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + return null; +} + +export function getMetadataObjectArray( + metadata: unknown, + key: string +): Record[] { + if (!metadata || typeof metadata !== "object") return []; + const value = (metadata as Record)[key]; + if (!Array.isArray(value)) return []; + return value.filter( + (v): v is Record => + v !== null && typeof v === "object" && !Array.isArray(v) + ); +} + /** * Sort a list of wavelength strings (e.g. `"750"`) in ascending numerical * order. Non-numeric entries are pushed to the end, preserving their diff --git a/web-app/components/runs/run-metadata-badges.tsx b/web-app/components/runs/run-metadata-badges.tsx index 4ced7c50..5b7478c1 100644 --- a/web-app/components/runs/run-metadata-badges.tsx +++ b/web-app/components/runs/run-metadata-badges.tsx @@ -13,6 +13,8 @@ import { cn } from "@/lib/utils"; import { getMetadataArray, getMetadataField, + getMetadataObjectArray, + getMetadataRecord, sortWavelengths, } from "@/components/runs/metadata-badges"; @@ -221,6 +223,145 @@ export function TapeStationRunBadges({ ); } +// --------------------------------------------------------------------------- +// Hina microscope +// --------------------------------------------------------------------------- + +export type HinaChannel = { + name: string; + color: string | null; +}; + +// Preferred dimension order for the `sizes` badge. ND2 files commonly include +// C (channels), Z (z-slices), T (time), Y (height), X (width); we render them +// in this order when present and append any unrecognised keys at the end so +// unknown dimensions still surface rather than being silently dropped. +const SIZE_DIMENSION_ORDER = ["T", "Z", "C", "Y", "X"]; + +export function extractHinaChannels(metadata: unknown): HinaChannel[] { + const raw = getMetadataObjectArray(metadata, "channels"); + return raw + .map((c) => { + const name = c.name != null ? String(c.name) : ""; + const color = typeof c.color === "string" ? c.color : null; + return { name, color }; + }) + .filter((c) => c.name.length > 0); +} + +export function formatHinaSizes(sizes: Record): string { + const entries = Object.entries(sizes).filter( + ([, v]) => typeof v === "number" || typeof v === "string" + ); + if (entries.length === 0) return ""; + + const known = SIZE_DIMENSION_ORDER.filter((k) => + entries.some(([ek]) => ek === k) + ); + const unknown = entries + .map(([k]) => k) + .filter((k) => !SIZE_DIMENSION_ORDER.includes(k)) + .sort(); + const orderedKeys = [...known, ...unknown]; + + const sizeMap = Object.fromEntries(entries) as Record< + string, + number | string + >; + + // Separate spatial dimensions (Y x X) into a compact "HxW" group so the + // badge reads like "C=4 · 256x256" rather than "C=4 · Y=256 · X=256". + const spatial: string[] = []; + if (sizeMap.Y != null) spatial.push(String(sizeMap.Y)); + if (sizeMap.X != null) spatial.push(String(sizeMap.X)); + + const scalar = orderedKeys + .filter((k) => k !== "X" && k !== "Y") + .map((k) => `${k}=${sizeMap[k]}`); + + const parts = [...scalar]; + if (spatial.length > 0) parts.push(spatial.join("\u00d7")); + return parts.join(" \u00b7 "); +} + +export function hasHinaMetadata(metadata: Record) { + return Boolean( + extractHinaChannels(metadata).length || + getMetadataArray(metadata, "dimensions").length || + (getMetadataRecord(metadata, "sizes") && + Object.keys(getMetadataRecord(metadata, "sizes") ?? {}).length > 0) + ); +} + +// Inline-styled badge using an arbitrary hex color for the border + text. +// Used for Hina channels since channel color is run-specific and can't be +// baked into a static Tailwind palette. +function ChannelBadge({ name, color }: HinaChannel) { + const style = color ? { borderColor: color, color } : undefined; + return ( + + {color && ( + + ); +} + +export function HinaChannelBadges({ channels }: { channels: HinaChannel[] }) { + if (channels.length === 0) + return ; + return ( +
+ {channels.map((c) => ( + + ))} +
+ ); +} + +export function HinaRunBadges({ + metadata, +}: { + metadata: Record; +}) { + const channels = extractHinaChannels(metadata); + const dimensions = getMetadataArray(metadata, "dimensions"); + const sizes = getMetadataRecord(metadata, "sizes"); + const sizesLabel = sizes ? formatHinaSizes(sizes) : ""; + + if (channels.length === 0 && dimensions.length === 0 && !sizesLabel) + return null; + + return ( + <> + {channels.length > 0 && ( + + {channels.map((c) => ( + + ))} + + )} + {dimensions.length > 0 && ( + + {dimensions.map((d) => ( + + ))} + + )} + {sizesLabel && ( + + + + )} + + ); +} + // --------------------------------------------------------------------------- // Default / generic — each key gets a row with outline badge value(s) // --------------------------------------------------------------------------- diff --git a/web-app/components/runs/variants/hina-microscope-run-detail.tsx b/web-app/components/runs/variants/hina-microscope-run-detail.tsx new file mode 100644 index 00000000..f04f9e9d --- /dev/null +++ b/web-app/components/runs/variants/hina-microscope-run-detail.tsx @@ -0,0 +1,60 @@ +import { DeleteRunDialog } from "@/components/runs/delete-run-dialog"; +import { HinaReportSection } from "@/components/runs/hina-report-section"; +import { RestoreRunButton } from "@/components/runs/restore-run-button"; +import type { RunDetailProps } from "@/components/runs/run-detail"; +import { RunDetail } from "@/components/runs/run-detail"; +import { + HinaRunBadges, + hasHinaMetadata, +} from "@/components/runs/run-metadata-badges"; + +export function HinaMicroscopeRunDetail({ + run, + files, + instrumentId, + runId, + attributionsSlot, +}: RunDetailProps) { + const isDeleted = run.deletedAt !== null; + const canRestore = isDeleted && run.filesPurgedAt === null; + const activeFileCount = files.filter((f) => f.deletedAt === null).length; + const hasProcessedFiles = + files.filter((f) => f.category === "processed" && f.deletedAt === null) + .length > 0; + + return ( + <> + + {!isDeleted && ( + + )} + {canRestore && ( + + )} + + + + {hasHinaMetadata(run.metadata as Record) && ( + + } /> + + )} + + + + + + + + ); +} diff --git a/web-app/components/runs/variants/index.tsx b/web-app/components/runs/variants/index.tsx index 77990cb3..b2b78a27 100644 --- a/web-app/components/runs/variants/index.tsx +++ b/web-app/components/runs/variants/index.tsx @@ -2,6 +2,7 @@ import type { RunDetailProps } from "@/components/runs/run-detail"; import { DefaultRunDetail } from "./default-run-detail"; import { GelDocRunDetail } from "./gel-doc-run-detail"; +import { HinaMicroscopeRunDetail } from "./hina-microscope-run-detail"; import { PlateReaderRunDetail } from "./plate-reader-run-detail"; import { QpcrRunDetail } from "./qpcr-run-detail"; import { TapeStationRunDetail } from "./tape-station-run-detail"; @@ -18,6 +19,8 @@ export function RunDetailVariant(props: RunDetailVariantProps) { return ; case "tape_station": return ; + case "hina_microscope": + return ; default: return ; } diff --git a/web-app/components/ui/carousel.tsx b/web-app/components/ui/carousel.tsx new file mode 100644 index 00000000..12316ca0 --- /dev/null +++ b/web-app/components/ui/carousel.tsx @@ -0,0 +1,242 @@ +"use client"; + +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; + +type CarouselApi = UseEmblaCarouselType[1]; +type UseCarouselParameters = Parameters; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; + +type CarouselProps = { + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: "horizontal" | "vertical"; + setApi?: (api: CarouselApi) => void; +}; + +type CarouselContextProps = { + carouselRef: ReturnType[0]; + api: ReturnType[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; +} & CarouselProps; + +const CarouselContext = React.createContext(null); + +function useCarousel() { + const context = React.useContext(CarouselContext); + + if (!context) { + throw new Error("useCarousel must be used within a "); + } + + return context; +} + +function Carousel({ + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props +}: React.ComponentProps<"div"> & CarouselProps) { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) return; + setCanScrollPrev(api.canScrollPrev()); + setCanScrollNext(api.canScrollNext()); + }, []); + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev(); + }, [api]); + + const scrollNext = React.useCallback(() => { + api?.scrollNext(); + }, [api]); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + scrollPrev(); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + scrollNext(); + } + }, + [scrollPrev, scrollNext] + ); + + React.useEffect(() => { + if (!api || !setApi) return; + setApi(api); + }, [api, setApi]); + + React.useEffect(() => { + if (!api) return; + onSelect(api); + api.on("reInit", onSelect); + api.on("select", onSelect); + + return () => { + api?.off("select", onSelect); + }; + }, [api, onSelect]); + + return ( + +
+ {children} +
+
+ ); +} + +function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { + const { carouselRef, orientation } = useCarousel(); + + return ( +
+
+
+ ); +} + +function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { + const { orientation } = useCarousel(); + + return ( +
+ ); +} + +function CarouselPrevious({ + className, + variant = "outline", + size = "icon-sm", + ...props +}: React.ComponentProps) { + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + + return ( + + ); +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon-sm", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel(); + + return ( + + ); +} + +export { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, + useCarousel, + type CarouselApi, +}; diff --git a/web-app/drizzle/0008_chilly_princess_powerful.sql b/web-app/drizzle/0008_chilly_princess_powerful.sql new file mode 100644 index 00000000..a373cea5 --- /dev/null +++ b/web-app/drizzle/0008_chilly_princess_powerful.sql @@ -0,0 +1 @@ +ALTER TYPE "public"."instrument_type" ADD VALUE 'hina_microscope'; \ No newline at end of file diff --git a/web-app/drizzle/meta/0008_snapshot.json b/web-app/drizzle/meta/0008_snapshot.json new file mode 100644 index 00000000..76833cf3 --- /dev/null +++ b/web-app/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1362 @@ +{ + "id": "c2b6ce60-ef03-4243-b22b-989c7dc2e1bd", + "prevId": "5ed571bb-c4c1-4f0b-a8fa-7f1a16e2c57b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_accounts_user_id": { + "name": "idx_accounts_user_id", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "instrument_run_id": { + "name": "instrument_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "relative_path": { + "name": "relative_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "s3_bucket": { + "name": "s3_bucket", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "s3_key": { + "name": "s3_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "file_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'raw'" + }, + "status": { + "name": "status", + "type": "file_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'detected'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "upload_requested_at": { + "name": "upload_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_files_instrument_run_id_relative_path": { + "name": "uq_files_instrument_run_id_relative_path", + "columns": [ + { + "expression": "instrument_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relative_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"files\".\"relative_path\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_files_s3_key": { + "name": "uq_files_s3_key", + "columns": [ + { + "expression": "s3_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"files\".\"s3_key\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_files_instrument_run_id": { + "name": "idx_files_instrument_run_id", + "columns": [ + { + "expression": "instrument_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_files_status_instrument_run_id": { + "name": "idx_files_status_instrument_run_id", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instrument_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_files_active": { + "name": "idx_files_active", + "columns": [ + { + "expression": "instrument_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"files\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_files_upload_queue": { + "name": "idx_files_upload_queue", + "columns": [ + { + "expression": "upload_requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"files\".\"upload_requested_at\" is not null and \"files\".\"uploaded_at\" is null and \"files\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_files_metadata_gin": { + "name": "idx_files_metadata_gin", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "files_instrument_run_id_instrument_runs_id_fk": { + "name": "files_instrument_run_id_instrument_runs_id_fk", + "tableFrom": "files", + "tableTo": "instrument_runs", + "columnsFrom": [ + "instrument_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instrument_runs": { + "name": "instrument_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "instrument_id": { + "name": "instrument_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "instrument_run_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'lambda'" + }, + "watcher_id": { + "name": "watcher_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "files_purged_at": { + "name": "files_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_instrument_runs_instrument_id_created_at": { + "name": "idx_instrument_runs_instrument_id_created_at", + "columns": [ + { + "expression": "instrument_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_instrument_runs_active": { + "name": "idx_instrument_runs_active", + "columns": [ + { + "expression": "instrument_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"instrument_runs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_instrument_runs_metadata_gin": { + "name": "idx_instrument_runs_metadata_gin", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "instrument_runs_instrument_id_instruments_id_fk": { + "name": "instrument_runs_instrument_id_instruments_id_fk", + "tableFrom": "instrument_runs", + "tableTo": "instruments", + "columnsFrom": [ + "instrument_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "instrument_runs_watcher_id_watchers_id_fk": { + "name": "instrument_runs_watcher_id_watchers_id_fk", + "tableFrom": "instrument_runs", + "tableTo": "watchers", + "columnsFrom": [ + "watcher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_instrument_runs_instrument_id_run_id": { + "name": "uq_instrument_runs_instrument_id_run_id", + "nullsNotDistinct": false, + "columns": [ + "instrument_id", + "run_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instruments": { + "name": "instruments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "instrument_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "instrument_type": { + "name": "instrument_type", + "type": "instrument_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'generic'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personal_access_tokens": { + "name": "personal_access_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_personal_access_tokens_user_id": { + "name": "idx_personal_access_tokens_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personal_access_tokens_user_id_user_id_fk": { + "name": "personal_access_tokens_user_id_user_id_fk", + "tableFrom": "personal_access_tokens", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "personal_access_tokens_token_hash_unique": { + "name": "personal_access_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.run_attributions": { + "name": "run_attributions", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_run_attributions_run_id": { + "name": "idx_run_attributions_run_id", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_run_attributions_user_id": { + "name": "idx_run_attributions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "run_attributions_run_id_instrument_runs_id_fk": { + "name": "run_attributions_run_id_instrument_runs_id_fk", + "tableFrom": "run_attributions", + "tableTo": "instrument_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "run_attributions_user_id_user_id_fk": { + "name": "run_attributions_user_id_user_id_fk", + "tableFrom": "run_attributions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "run_attributions_run_id_user_id_pk": { + "name": "run_attributions_run_id_user_id_pk", + "columns": [ + "run_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watcher_events": { + "name": "watcher_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "watcher_id": { + "name": "watcher_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "watcher_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_watcher_events_watcher_id_timestamp": { + "name": "idx_watcher_events_watcher_id_timestamp", + "columns": [ + { + "expression": "watcher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_watcher_events_watcher_id_event_type": { + "name": "idx_watcher_events_watcher_id_event_type", + "columns": [ + { + "expression": "watcher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watcher_events_watcher_id_watchers_id_fk": { + "name": "watcher_events_watcher_id_watchers_id_fk", + "tableFrom": "watcher_events", + "tableTo": "watchers", + "columnsFrom": [ + "watcher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watcher_heartbeats": { + "name": "watcher_heartbeats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "watcher_id": { + "name": "watcher_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "upload_mode": { + "name": "upload_mode", + "type": "upload_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "files_uploaded_since_last": { + "name": "files_uploaded_since_last", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "runs_reported_since_last": { + "name": "runs_reported_since_last", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "errors_since_last": { + "name": "errors_since_last", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "uptime_seconds": { + "name": "uptime_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_watcher_heartbeats_watcher_id_timestamp": { + "name": "idx_watcher_heartbeats_watcher_id_timestamp", + "columns": [ + { + "expression": "watcher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watcher_heartbeats_watcher_id_watchers_id_fk": { + "name": "watcher_heartbeats_watcher_id_watchers_id_fk", + "tableFrom": "watcher_heartbeats", + "tableTo": "watchers", + "columnsFrom": [ + "watcher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watchers": { + "name": "watchers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "instrument_id": { + "name": "instrument_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "os_info": { + "name": "os_info", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config_checksum": { + "name": "config_checksum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config_yaml": { + "name": "config_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "watcher_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'registered'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_watchers_instrument_id": { + "name": "idx_watchers_instrument_id", + "columns": [ + { + "expression": "instrument_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"watchers\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watchers_instrument_id_instruments_id_fk": { + "name": "watchers_instrument_id_instruments_id_fk", + "tableFrom": "watchers", + "tableTo": "instruments", + "columnsFrom": [ + "instrument_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.file_category": { + "name": "file_category", + "schema": "public", + "values": [ + "raw", + "processed" + ] + }, + "public.file_status": { + "name": "file_status", + "schema": "public", + "values": [ + "detected", + "upload_requested", + "uploaded", + "processing", + "completed", + "failed" + ] + }, + "public.instrument_run_source": { + "name": "instrument_run_source", + "schema": "public", + "values": [ + "lambda", + "watcher" + ] + }, + "public.instrument_status": { + "name": "instrument_status", + "schema": "public", + "values": [ + "pending", + "active", + "inactive" + ] + }, + "public.instrument_type": { + "name": "instrument_type", + "schema": "public", + "values": [ + "generic", + "plate_reader", + "gel_doc", + "qpcr", + "tape_station", + "hina_microscope" + ] + }, + "public.upload_mode": { + "name": "upload_mode", + "schema": "public", + "values": [ + "auto", + "manual" + ] + }, + "public.watcher_event_type": { + "name": "watcher_event_type", + "schema": "public", + "values": [ + "watcher_started", + "watcher_stopped", + "file_uploaded", + "upload_failed", + "run_reported", + "config_synced", + "error" + ] + }, + "public.watcher_status": { + "name": "watcher_status", + "schema": "public", + "values": [ + "registered", + "watching", + "stopped" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/web-app/drizzle/meta/_journal.json b/web-app/drizzle/meta/_journal.json index 2fe2e63e..18af47d4 100644 --- a/web-app/drizzle/meta/_journal.json +++ b/web-app/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1776804297734, "tag": "0007_dazzling_hercules", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1776986418967, + "tag": "0008_chilly_princess_powerful", + "breakpoints": true } ] } \ No newline at end of file diff --git a/web-app/lib/db/schema.ts b/web-app/lib/db/schema.ts index 3f4cc30e..61644494 100644 --- a/web-app/lib/db/schema.ts +++ b/web-app/lib/db/schema.ts @@ -51,6 +51,7 @@ export const instrumentTypeEnum = pgEnum("instrument_type", [ "gel_doc", "qpcr", "tape_station", + "hina_microscope", ]); export const VALID_INSTRUMENT_TYPES = instrumentTypeEnum.enumValues; diff --git a/web-app/package-lock.json b/web-app/package-lock.json index 3d132ef6..9672843d 100644 --- a/web-app/package-lock.json +++ b/web-app/package-lock.json @@ -21,6 +21,7 @@ "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "drizzle-orm": "^0.45.2", + "embla-carousel-react": "^8.6.0", "lucide-react": "^1.7.0", "mcp-handler": "^1.1.0", "next": "^16.2.4", @@ -9677,6 +9678,34 @@ "integrity": "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==", "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", diff --git a/web-app/package.json b/web-app/package.json index e958e682..b337e97f 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -36,6 +36,7 @@ "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "drizzle-orm": "^0.45.2", + "embla-carousel-react": "^8.6.0", "lucide-react": "^1.7.0", "mcp-handler": "^1.1.0", "next": "^16.2.4", From 0fef702dc702147bf5717c8ddd1cce95667a0b1c Mon Sep 17 00:00:00 2001 From: Wasim Sandhu Date: Thu, 23 Apr 2026 16:42:54 -0700 Subject: [PATCH 3/7] Lambda: Bump ephemeral storage, memory, and timeout for large ND2 files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hina microscope uploads can be 3-4 GB, which immediately exhausts the previous 512 MB /tmp and 1 GB RAM limits (decoded ND2 arrays are even larger than the compressed file). Raise EphemeralStorage to 10 GB, MemorySize to 10 GB, and Timeout to 900 s — all Lambda hard maxes. Also add a pre-cleanup call at the top of `lambda_handler`: the existing `finally: _cleanup_tmp()` doesn't run when the runtime SIGKILLs the process (OOM / timeout), so a warm container can start a new invocation with stale downloads still occupying /tmp. --- infra/template.yaml | 12 +++++++++--- lambda/src/data_hub_lambda/handler.py | 5 +++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/infra/template.yaml b/infra/template.yaml index a0fd3cff..eca1dac8 100644 --- a/infra/template.yaml +++ b/infra/template.yaml @@ -52,8 +52,12 @@ Parameters: Globals: Function: - Timeout: 300 - MemorySize: 1024 + # Large files (e.g. 3-4 GB Hina microscope ND2s) force us near Lambda's + # hard limits: 10 GB RAM to hold the decoded image array, 900 s to + # download + process, and 10 GB of /tmp (set per-function below) to + # land the raw file plus the generated JPG. + Timeout: 900 + MemorySize: 10240 Resources: # ---- S3 buckets ---- @@ -159,7 +163,9 @@ Resources: PackageType: Image ImageUri: !Ref EcrImageUri EphemeralStorage: - Size: 512 + # 10 GB (the Lambda max) — large Hina ND2 uploads need room for the + # raw download, the decoded working copy, and the generated JPG. + Size: 10240 Role: !GetAtt LambdaExecutionRole.Arn FunctionUrlConfig: AuthType: NONE diff --git a/lambda/src/data_hub_lambda/handler.py b/lambda/src/data_hub_lambda/handler.py index a5e866c4..d4551d02 100644 --- a/lambda/src/data_hub_lambda/handler.py +++ b/lambda/src/data_hub_lambda/handler.py @@ -189,6 +189,11 @@ def lambda_handler(event: dict[str, Any], context: Context) -> dict[str, Any] | logger.info("Run ID: '%s'", run_id) instrument_name = INSTRUMENT_ID_TO_NAME_MAP[instrument_id] + # Pre-cleanup: if the previous invocation on this warm container was + # SIGKILL'd (e.g. OOM), the `finally` block below didn't run and stale + # downloads may still be sitting in /tmp. Wipe them before we start. + _cleanup_tmp() + try: logger.info("Processing file %s...", event_info.filename) From 865b437663f62781b6eaa34fa6f25e19bdfacc1b Mon Sep 17 00:00:00 2001 From: Wasim Sandhu Date: Thu, 23 Apr 2026 16:49:28 -0700 Subject: [PATCH 4/7] fix: Update Drizzle config --- web-app/drizzle.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web-app/drizzle.config.ts b/web-app/drizzle.config.ts index 8408f240..5f90ddda 100644 --- a/web-app/drizzle.config.ts +++ b/web-app/drizzle.config.ts @@ -7,4 +7,6 @@ export default defineConfig({ dbCredentials: { url: process.env.DATABASE_URL!, }, + schemaFilter: ["public"], + tablesFilter: ["!pg_stat_statements", "!pg_stat_statements_info"], }); From c76bbc9d5b96da67cd1815404266f0de33679e93 Mon Sep 17 00:00:00 2001 From: Wasim Sandhu Date: Fri, 24 Apr 2026 11:53:27 -0700 Subject: [PATCH 5/7] feat(runs): disable upload actions when watcher is offline Requesting an upload transitions a file to `upload_requested`, which only resolves if a watcher is online to push the file to S3. When no watcher is heartbeating, the UI now disables both per-row and bulk Upload buttons and shows a tooltip explaining why, preventing files from getting stuck in `upload_requested` with no agent to action them. The run detail page fetches the instrument (cached) alongside run files and threads an `isWatcherOnline` flag through `RunDetailProps` and each variant into `RunFilesSection` and `RunFilesTable`. Made-with: Cursor --- .../[instrumentId]/runs/[runId]/page.tsx | 10 ++- web-app/components/runs/run-detail.ts | 4 ++ web-app/components/runs/run-files-section.tsx | 62 ++++++++++++++----- web-app/components/runs/run-files-table.tsx | 50 ++++++++++++--- .../runs/variants/default-run-detail.tsx | 2 + .../runs/variants/gel-doc-run-detail.tsx | 2 + .../variants/hina-microscope-run-detail.tsx | 2 + .../runs/variants/plate-reader-run-detail.tsx | 2 + .../runs/variants/qpcr-run-detail.tsx | 2 + .../runs/variants/tape-station-run-detail.tsx | 2 + 10 files changed, 113 insertions(+), 25 deletions(-) diff --git a/web-app/app/instruments/[instrumentId]/runs/[runId]/page.tsx b/web-app/app/instruments/[instrumentId]/runs/[runId]/page.tsx index f171e635..8cd7c7a7 100644 --- a/web-app/app/instruments/[instrumentId]/runs/[runId]/page.tsx +++ b/web-app/app/instruments/[instrumentId]/runs/[runId]/page.tsx @@ -5,6 +5,7 @@ import { getRunFiles, lookupRunByNaturalKey, } from "@/lib/api/instrument-runs"; +import { getInstrumentById } from "@/lib/api/instruments"; import { auth } from "@/lib/auth"; import { notFound, redirect } from "next/navigation"; import type { Metadata } from "next/types"; @@ -31,8 +32,14 @@ export default async function RunDetailPage({ params }: Props) { const run = await lookupRunByNaturalKey(instrumentId, runId); if (!run) notFound(); - const runFiles = await getRunFiles(run.id); + const [runFiles, instrument] = await Promise.all([ + getRunFiles(run.id), + getInstrumentById(instrumentId), + ]); const wellData = await getProcessedCsvData(runFiles); + // Gate client-side upload actions on watcher availability — a queued + // upload request is a no-op if no agent is around to action it. + const isWatcherOnline = (instrument?.watchersOnline ?? 0) > 0; return (
@@ -42,6 +49,7 @@ export default async function RunDetailPage({ params }: Props) { wellData={wellData} instrumentId={instrumentId} runId={runId} + isWatcherOnline={isWatcherOnline} attributionsSlot={
- + {isWatcherOnline ? ( + + ) : ( + + + {/* Wrapping span keeps the tooltip reachable while the + underlying button is disabled. */} + + + + + + {WATCHER_OFFLINE_UPLOAD_TOOLTIP} + + + )} + {isWatcherOnline ? ( + + ) : ( + + + {/* Wrapping span keeps the tooltip reachable while + the underlying button is disabled. */} + + + + + + {WATCHER_OFFLINE_UPLOAD_TOOLTIP} + + + )}
); } diff --git a/web-app/components/instruments/runs-table/filterable-column-header.tsx b/web-app/components/instruments/runs-table/filterable-column-header.tsx index 5e09e3d4..558245d4 100644 --- a/web-app/components/instruments/runs-table/filterable-column-header.tsx +++ b/web-app/components/instruments/runs-table/filterable-column-header.tsx @@ -14,20 +14,25 @@ import { instrumentDetailSearchParams } from "@/lib/search-params"; import { cn } from "@/lib/utils"; import { ChevronsUpDown, ListFilter } from "lucide-react"; import { useQueryStates } from "nuqs"; +import type { inferParserType } from "nuqs/server"; -type FilterParamKey = - | "wavelength" - | "measurement_mode" - | "measurement_type" - | "capture_type" - | "imaging_mode" - | "gel_wavelength" - | "gel_color" - | "dye_channel" - | "hina_channel" - | "hina_dimension" - | "hina_size" - | "ran_by"; +type InstrumentDetailFilters = inferParserType< + typeof instrumentDetailSearchParams +>; + +// Filter-column params are the nullable-string entries in the nuqs map +// (`parseAsString` without a default). Keys whose parser carries a default +// — page/per_page/search/include_deleted — aren't column filters and get +// excluded automatically here. Adding a new `parseAsString` filter to +// `instrumentDetailSearchParams` makes it a valid `paramKey` with no +// hand-maintenance needed at this boundary. +export type FilterParamKey = { + [K in keyof InstrumentDetailFilters]: null extends InstrumentDetailFilters[K] + ? InstrumentDetailFilters[K] extends string | null + ? K + : never + : never; +}[keyof InstrumentDetailFilters]; // Options accept either plain strings (value == label) or { value, label } // pairs for cases where the URL-stable value and the display label differ diff --git a/web-app/components/instruments/runs-table/hina-runs-table.tsx b/web-app/components/instruments/runs-table/hina-runs-table.tsx index ed1b7966..9fb73b50 100644 --- a/web-app/components/instruments/runs-table/hina-runs-table.tsx +++ b/web-app/components/instruments/runs-table/hina-runs-table.tsx @@ -2,7 +2,7 @@ import { RelativeTime } from "@/components/dashboard/relative-time"; import { extractHinaChannels, formatHinaSizes, - getHinaChannelBadgeStyle, + HinaChannelBadges, } from "@/components/runs/run-metadata-badges"; import { Badge } from "@/components/ui/badge"; import { @@ -21,9 +21,9 @@ import type { RunRow } from "."; import { ClickableRow } from "./clickable-row"; import { FilterableColumnHeader } from "./filterable-column-header"; import { - MetadataArrayBadges, getMetadataArray, getMetadataRecord, + MetadataArrayBadges, } from "./metadata-utils"; import { RanByCell } from "./ran-by-cell"; import { RawFileColumnHeader } from "./raw-file-column-header"; @@ -136,32 +136,7 @@ export function HinaRunsTable({ {formatBytes(row.total_size_bytes)} - {channels.length === 0 ? ( - - ) : ( -
- {channels.map((c) => { - const { badge, dot } = getHinaChannelBadgeStyle(c.color); - return ( - - {c.color && ( - - ); - })} -
- )} +
diff --git a/web-app/components/instruments/runs-table/index.tsx b/web-app/components/instruments/runs-table/index.tsx index 41e3343f..6e30fa45 100644 --- a/web-app/components/instruments/runs-table/index.tsx +++ b/web-app/components/instruments/runs-table/index.tsx @@ -1,23 +1,11 @@ -import type { - GelDocFilterOptions, - HinaFilterOptions, - PlateReaderFilterOptions, - QpcrFilterOptions, - RunListRow, -} from "@/lib/api/instrument-runs"; -import type { InstrumentType } from "@/lib/db/schema"; +import type { RunListRow } from "@/lib/api/instrument-runs"; import { SearchX } from "lucide-react"; -import { DefaultRunsTable } from "./default-runs-table"; -import { GelDocRunsTable } from "./gel-doc-runs-table"; -import { HinaRunsTable } from "./hina-runs-table"; -import { PlateReaderRunsTable } from "./plate-reader-runs-table"; -import { QpcrRunsTable } from "./qpcr-runs-table"; import { RunsTableFooter } from "./runs-table-footer"; // Re-export under the historical name so imports like // `import type { RunRow } from "@/components/instruments/runs-table"` -// keep working. The type itself is now derived server-side. +// keep working. The type itself is derived server-side. export type RunRow = RunListRow; export type RanByOption = { value: string; label: string }; @@ -28,36 +16,40 @@ export type RunsTableProps = { ranByOptions: RanByOption[]; }; -export function InstrumentRunsTable({ - data, - instrumentId, - instrumentType, +/** + * Thin wrapper around the per-instrument table variants. + * + * Previously this component owned a big `switch (instrumentType)` with one + * optional `filterOptions` prop per instrument type (plus a non-null assertion + * at every call site). That scaled poorly — every new instrument forced a new + * optional prop here and a new `!` in the caller. + * + * The shell is now purely structural: the empty-state card when there are no + * rows, otherwise the bordered frame + footer around whichever table variant + * the caller chose to render as `children`. Picking the variant is the + * caller's job, which lets each page compose the right discriminated + * filter-options narrowing without routing them through this shell. + */ +export function InstrumentRunsTableShell({ + isEmpty, hasFilters, - filterOptions, - gelDocFilterOptions, - qpcrFilterOptions, - hinaFilterOptions, - ranByOptions, + shownCount, totalCount, pendingUploadCount, unattributedCount, ranByYouCount, + children, }: { - data: RunRow[]; - instrumentId: string; - instrumentType: InstrumentType; + isEmpty: boolean; hasFilters: boolean; - filterOptions?: PlateReaderFilterOptions; - gelDocFilterOptions?: GelDocFilterOptions; - qpcrFilterOptions?: QpcrFilterOptions; - hinaFilterOptions?: HinaFilterOptions; - ranByOptions: RanByOption[]; + shownCount: number; totalCount: number; pendingUploadCount: number; unattributedCount: number; ranByYouCount: number; + children: React.ReactNode; }) { - if (data.length === 0) { + if (isEmpty) { return (
@@ -70,63 +62,11 @@ export function InstrumentRunsTable({ ); } - let table; - switch (instrumentType) { - case "plate_reader": - table = ( - - ); - break; - case "gel_doc": - table = ( - - ); - break; - case "qpcr": - table = ( - - ); - break; - case "hina_microscope": - table = ( - - ); - break; - default: - table = ( - - ); - } - return (
- {table} + {children}
- {isWatcherOnline ? ( - - ) : ( - - - {/* Wrapping span keeps the tooltip reachable while the - underlying button is disabled. */} - - - - - - {WATCHER_OFFLINE_UPLOAD_TOOLTIP} - - - )} + + {isPending ? ( + + ) : ( + + )} + Upload {selectedDetectedIds.length} + + + + + Reprocess file? + + {file.filename} will be sent + to the Lambda function for reprocessing. Any existing report data + for this file will be cleared. + + + + Cancel + onReprocess(file.id)}> + Reprocess + + + + + ); +} + +function UploadDismissActions({ + file, + isPending, + onUpload, + onDismiss, +}: { + file: RunFile; + isPending: boolean; + onUpload: (id: number) => void; + onDismiss: (id: number) => void; +}) { + return ( + <> + onUpload(file.id)} + disabled={isPending} + > + + Upload + + + + + + + + Dismiss file? + + {file.filename} will be + soft-deleted. The watcher will skip it on future scans. + + + + Cancel + onDismiss(file.id)}> + Dismiss + + + + + + ); +} + +function canReprocess(file: RunFile): boolean { + return ( + file.deletedAt === null && + REPROCESSABLE_STATUSES.has(file.status) && + file.s3Key !== null + ); +} + +// --------------------------------------------------------------------------- +// Read-only variant: no selection column, no upload/dismiss. Reprocessing is +// still allowed for completed/failed files so operators can recover report +// data without restoring the run. +// --------------------------------------------------------------------------- + +export type ReadOnlyRunFilesTableProps = { + files: RunFile[]; + isPending: boolean; + onReprocess: (id: number) => void; +}; + +export function ReadOnlyRunFilesTable({ + files, + isPending, + onReprocess, +}: ReadOnlyRunFilesTableProps) { + return ( + + + + + + + + + {files.map((file) => { + const isDismissed = file.deletedAt !== null; + return ( + + + + {canReprocess(file) && ( +
+ +
+ )} +
+
+ ); + })} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Editable variant: adds a selection column (for bulk upload/dismiss flows) +// and per-row upload/dismiss buttons on detected files. Falls back to the +// reprocess action for completed/failed files, matching the read-only table. +// --------------------------------------------------------------------------- + export type RunFilesTableSelection = { selectedIds: Set; visibleSelectableIds: Set; @@ -140,32 +369,23 @@ export type RunFilesTableSelection = { onToggleAll: () => void; }; -export type RunFilesTableProps = { +export type EditableRunFilesTableProps = { files: RunFile[]; - isDeleted: boolean; isPending: boolean; - // When false, per-row "Upload" actions are disabled so users can't queue - // a transition to `upload_requested` that no agent would pick up. - isWatcherOnline: boolean; selection: RunFilesTableSelection; onUpload: (id: number) => void; onDismiss: (id: number) => void; onReprocess: (id: number) => void; }; -const WATCHER_OFFLINE_UPLOAD_TOOLTIP = - "Watcher is offline. Bring the watcher online before requesting uploads — otherwise nothing will transfer this file to S3."; - -export function RunFilesTable({ +export function EditableRunFilesTable({ files, - isDeleted, isPending, - isWatcherOnline, selection, onUpload, onDismiss, onReprocess, -}: RunFilesTableProps) { +}: EditableRunFilesTableProps) { const { selectedIds, visibleSelectableIds, @@ -176,7 +396,7 @@ export function RunFilesTable({ onToggleAll, } = selection; - const showSelectionColumn = !isDeleted && visibleSelectableIds.size > 0; + const showSelectionColumn = visibleSelectableIds.size > 0; return ( @@ -196,35 +416,17 @@ export function RunFilesTable({ /> )} - - File name - - - Type - - - Size - - - Created - - - Status - + {files.map((file) => { const isDismissed = file.deletedAt !== null; - const isSelectable = - !isDeleted && file.status === "detected" && !isDismissed; + const isSelectable = file.status === "detected" && !isDismissed; const isSelected = selectedIds.has(file.id); - const showRowActions = - !isDeleted && - !isDismissed && - file.status === "detected" && - !hasBulkSelection; + const showUploadDismiss = + !isDismissed && file.status === "detected" && !hasBulkSelection; return ( )} - - - {file.filename} - {DOWNLOADABLE_STATUSES.has(file.status) && ( - - - - )} - - - - - {file.category} - - - - {formatBytes(file.sizeBytes)} - - - {file.createdAt ? formatDateTime(file.createdAt) : "—"} - - - - + - {showRowActions ? ( + {showUploadDismiss ? (
- {isWatcherOnline ? ( - - ) : ( - - - {/* Wrapping span keeps the tooltip reachable while - the underlying button is disabled. */} - - - - - - {WATCHER_OFFLINE_UPLOAD_TOOLTIP} - - - )} - - - - - - - Dismiss file? - - - {file.filename} - {" "} - will be soft-deleted. The watcher will skip it on - future scans. - - - - Cancel - onDismiss(file.id)}> - Dismiss - - - - +
) : ( - !isDismissed && - REPROCESSABLE_STATUSES.has(file.status) && - file.s3Key !== null && ( + canReprocess(file) && (
- - - - - - - Reprocess file? - - - {file.filename} - {" "} - will be sent to the Lambda function for - reprocessing. Any existing report data for this - file will be cleared. - - - - Cancel - onReprocess(file.id)} - > - Reprocess - - - - +
) )} diff --git a/web-app/components/runs/run-metadata-badges.tsx b/web-app/components/runs/run-metadata-badges.tsx index 690cd8dd..bb894b06 100644 --- a/web-app/components/runs/run-metadata-badges.tsx +++ b/web-app/components/runs/run-metadata-badges.tsx @@ -279,10 +279,12 @@ function relativeLuminance({ return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b); } -// Channel colors come straight from instrument metadata, so any value is -// possible — including white, which disappears against the light theme's -// white surface. For near-white colors we blend with `--foreground` so the -// border/text remain visible in light mode while staying vivid in dark mode. +// Channel colors come straight from instrument metadata. To keep channel +// badges visually consistent with the other metadata badges, we always use +// the default outline border and carry the channel's identity on the dot and +// text color alone. Near-white colors are blended with `--foreground` so the +// text remains readable on light surfaces while staying vivid on dark ones, +// and the dot is ringed with `--border` so a white swatch is still visible. const NEAR_WHITE_LUMINANCE = 0.85; export type ChannelBadgeStyle = { @@ -297,21 +299,15 @@ export function getHinaChannelBadgeStyle( const rgb = parseColorToRgb(color); const isNearWhite = rgb !== null && relativeLuminance(rgb) > NEAR_WHITE_LUMINANCE; - if (isNearWhite) { - const adapted = `color-mix(in oklab, ${color}, var(--foreground) 45%)`; - return { - badge: { borderColor: adapted, color: adapted }, - // Keep the true channel color on the dot so the swatch still reads as - // "white", but ring it with the theme border so it stays visible. - dot: { - backgroundColor: color, - boxShadow: "inset 0 0 0 1px var(--border)", - }, - }; - } + const textColor = isNearWhite ? "text-foreground" : color; return { - badge: { borderColor: color, color }, - dot: { backgroundColor: color }, + badge: { color: textColor }, + dot: isNearWhite + ? { + backgroundColor: color, + boxShadow: "inset 0 0 0 1px var(--border)", + } + : { backgroundColor: color }, }; } @@ -397,7 +393,7 @@ function ChannelBadge({ name, color }: HinaChannel) { export function HinaChannelBadges({ channels }: { channels: HinaChannel[] }) { if (channels.length === 0) - return ; + return ; return (
{channels.map((c) => ( diff --git a/web-app/components/runs/variants/default-run-detail.tsx b/web-app/components/runs/variants/default-run-detail.tsx index d97ab1e3..0c4b32c7 100644 --- a/web-app/components/runs/variants/default-run-detail.tsx +++ b/web-app/components/runs/variants/default-run-detail.tsx @@ -12,7 +12,6 @@ export function DefaultRunDetail({ files, instrumentId, runId, - isWatcherOnline, attributionsSlot, }: RunDetailProps) { const isDeleted = run.deletedAt !== null; @@ -51,7 +50,6 @@ export function DefaultRunDetail({ instrumentId={instrumentId} runId={runId} isDeleted={isDeleted} - isWatcherOnline={isWatcherOnline} /> diff --git a/web-app/components/runs/variants/gel-doc-run-detail.tsx b/web-app/components/runs/variants/gel-doc-run-detail.tsx index 8243fab5..a0828c01 100644 --- a/web-app/components/runs/variants/gel-doc-run-detail.tsx +++ b/web-app/components/runs/variants/gel-doc-run-detail.tsx @@ -12,7 +12,6 @@ export function GelDocRunDetail({ files, instrumentId, runId, - isWatcherOnline, attributionsSlot, }: RunDetailProps) { const isDeleted = run.deletedAt !== null; @@ -51,7 +50,6 @@ export function GelDocRunDetail({ instrumentId={instrumentId} runId={runId} isDeleted={isDeleted} - isWatcherOnline={isWatcherOnline} /> diff --git a/web-app/components/runs/variants/hina-microscope-run-detail.tsx b/web-app/components/runs/variants/hina-microscope-run-detail.tsx index 0f877a25..f04f9e9d 100644 --- a/web-app/components/runs/variants/hina-microscope-run-detail.tsx +++ b/web-app/components/runs/variants/hina-microscope-run-detail.tsx @@ -13,7 +13,6 @@ export function HinaMicroscopeRunDetail({ files, instrumentId, runId, - isWatcherOnline, attributionsSlot, }: RunDetailProps) { const isDeleted = run.deletedAt !== null; @@ -50,7 +49,6 @@ export function HinaMicroscopeRunDetail({ instrumentId={instrumentId} runId={runId} isDeleted={isDeleted} - isWatcherOnline={isWatcherOnline} /> diff --git a/web-app/components/runs/variants/plate-reader-run-detail.tsx b/web-app/components/runs/variants/plate-reader-run-detail.tsx index fec5ca3d..eff9bf14 100644 --- a/web-app/components/runs/variants/plate-reader-run-detail.tsx +++ b/web-app/components/runs/variants/plate-reader-run-detail.tsx @@ -263,7 +263,6 @@ export function PlateReaderRunDetail({ wellData, instrumentId, runId, - isWatcherOnline, attributionsSlot, }: RunDetailProps) { const isDeleted = run.deletedAt !== null; @@ -314,7 +313,6 @@ export function PlateReaderRunDetail({ instrumentId={instrumentId} runId={runId} isDeleted={isDeleted} - isWatcherOnline={isWatcherOnline} /> diff --git a/web-app/components/runs/variants/qpcr-run-detail.tsx b/web-app/components/runs/variants/qpcr-run-detail.tsx index b45ec187..63ee1d8d 100644 --- a/web-app/components/runs/variants/qpcr-run-detail.tsx +++ b/web-app/components/runs/variants/qpcr-run-detail.tsx @@ -12,7 +12,6 @@ export function QpcrRunDetail({ files, instrumentId, runId, - isWatcherOnline, attributionsSlot, }: RunDetailProps) { const isDeleted = run.deletedAt !== null; @@ -49,7 +48,6 @@ export function QpcrRunDetail({ instrumentId={instrumentId} runId={runId} isDeleted={isDeleted} - isWatcherOnline={isWatcherOnline} /> diff --git a/web-app/components/runs/variants/tape-station-run-detail.tsx b/web-app/components/runs/variants/tape-station-run-detail.tsx index da2e063f..40fba943 100644 --- a/web-app/components/runs/variants/tape-station-run-detail.tsx +++ b/web-app/components/runs/variants/tape-station-run-detail.tsx @@ -12,7 +12,6 @@ export function TapeStationRunDetail({ files, instrumentId, runId, - isWatcherOnline, attributionsSlot, }: RunDetailProps) { const isDeleted = run.deletedAt !== null; @@ -51,7 +50,6 @@ export function TapeStationRunDetail({ instrumentId={instrumentId} runId={runId} isDeleted={isDeleted} - isWatcherOnline={isWatcherOnline} /> diff --git a/web-app/components/runs/watcher-gated-upload-button.tsx b/web-app/components/runs/watcher-gated-upload-button.tsx new file mode 100644 index 00000000..192f96da --- /dev/null +++ b/web-app/components/runs/watcher-gated-upload-button.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type * as React from "react"; + +import { useWatcherStatus } from "./watcher-status-provider"; + +const DEFAULT_OFFLINE_TOOLTIP = + "Watcher is offline. Bring the watcher online before requesting uploads — otherwise nothing will transfer to S3."; + +/** + * Renders an upload-style `Button` that is fully interactive when the + * instrument's watcher is online, and a disabled button wrapped in an + * explanatory tooltip when it is offline. + * + * Watcher status is read from the nearest `WatcherStatusProvider`, so + * callers don't need to thread `isWatcherOnline` down through the tree. + * + * The offline path intentionally wraps the disabled button in a + * `tabIndex={0}` span so the tooltip remains reachable via hover and + * keyboard focus even though the underlying ` + ); + } + + return ( + + + + + + + + {offlineTooltip} + + + ); +} diff --git a/web-app/components/runs/watcher-status-provider.tsx b/web-app/components/runs/watcher-status-provider.tsx new file mode 100644 index 00000000..753a84d5 --- /dev/null +++ b/web-app/components/runs/watcher-status-provider.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { createContext, use, type ReactNode } from "react"; + +/** + * Describes whether the instrument's watcher is actively heartbeating. + * + * Downstream upload-style controls read this to decide between the + * enabled button and the disabled-with-tooltip variant (see + * `watcher-gated-upload-button.tsx`). Centralizing the flag here keeps + * the run-detail variants free of `isWatcherOnline` prop drilling. + */ +export type WatcherStatus = { + isWatcherOnline: boolean; +}; + +const WatcherStatusContext = createContext(null); + +export function WatcherStatusProvider({ + isWatcherOnline, + children, +}: { + isWatcherOnline: boolean; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useWatcherStatus(): WatcherStatus { + const context = use(WatcherStatusContext); + if (!context) { + throw new Error( + "useWatcherStatus must be used within a " + ); + } + return context; +} diff --git a/web-app/components/ui/carousel.tsx b/web-app/components/ui/carousel.tsx index 12316ca0..cf58d601 100644 --- a/web-app/components/ui/carousel.tsx +++ b/web-app/components/ui/carousel.tsx @@ -33,7 +33,7 @@ type CarouselContextProps = { const CarouselContext = React.createContext(null); function useCarousel() { - const context = React.useContext(CarouselContext); + const context = React.use(CarouselContext); if (!context) { throw new Error("useCarousel must be used within a "); diff --git a/web-app/lib/api/instrument-runs.ts b/web-app/lib/api/instrument-runs.ts index aaff8a1f..bf9ccb82 100644 --- a/web-app/lib/api/instrument-runs.ts +++ b/web-app/lib/api/instrument-runs.ts @@ -2,6 +2,7 @@ import { parse } from "csv-parse/sync"; import { formatHinaSizes } from "@/components/runs/run-metadata-badges"; import { db } from "@/lib/db"; +import type { InstrumentType } from "@/lib/db/schema"; import { files, instrumentRuns, @@ -667,6 +668,52 @@ export async function getHinaFilterOptions( return { channels, dimensions, sizes }; } +// --------------------------------------------------------------------------- +// Dispatcher: fetch whichever per-instrument filter options apply to this +// instrument type. The discriminated return shape lets the caller narrow to +// the correct variant component without non-null assertions, and TS +// exhaustiveness-checks the switch so a newly added `InstrumentType` won't +// compile until it's handled here. +// --------------------------------------------------------------------------- + +export type InstrumentFilterOptionsByType = + | { kind: "plate_reader"; options: PlateReaderFilterOptions } + | { kind: "gel_doc"; options: GelDocFilterOptions } + | { kind: "qpcr"; options: QpcrFilterOptions } + | { kind: "hina_microscope"; options: HinaFilterOptions } + | { kind: "default" }; + +export async function getInstrumentFilterOptions( + instrumentType: InstrumentType, + instrumentId: string +): Promise { + switch (instrumentType) { + case "plate_reader": + return { + kind: "plate_reader", + options: await getPlateReaderFilterOptions(instrumentId), + }; + case "gel_doc": + return { + kind: "gel_doc", + options: await getGelDocFilterOptions(instrumentId), + }; + case "qpcr": + return { + kind: "qpcr", + options: await getQpcrFilterOptions(instrumentId), + }; + case "hina_microscope": + return { + kind: "hina_microscope", + options: await getHinaFilterOptions(instrumentId), + }; + case "generic": + case "tape_station": + return { kind: "default" }; + } +} + // --------------------------------------------------------------------------- // Distinct users who have attributed any (non-deleted) run for this // instrument — used to populate the "Ran By" column filter dropdown.