diff --git a/developer-docs/local-development.md b/developer-docs/local-development.md index a7c2c64..938c061 100644 --- a/developer-docs/local-development.md +++ b/developer-docs/local-development.md @@ -199,7 +199,7 @@ What this gets you out of the box after `make db-reseed`: | --- | --- | --- | | qPCR | `azure_cielo_qpcr_example.csv` | `lambda/tests/fixtures/` | | Gel doc | `azure_600_gel_doc_{example,fluorescence,true_color}.tif` | `lambda/tests/fixtures/` | -| Plate reader (iD3 + iD5) | `spectramax_plate_reader_{endpoint,endpoint_flat,endpoint_sparse,fluorescence,kinetic,well_scan}.xls` | `lambda/tests/fixtures/` | +| Plate reader (iD3 + iD5) | `spectramax_plate_reader_{endpoint,endpoint_flat,endpoint_sparse,fluorescence,kinetic,well_scan}.xls`; Spectrum is `spectrum.xls` (96-well, iD5) and `spectrum_384.xls` (384-well + Endpoint, iD3) | `lambda/tests/fixtures/` | | Other instruments | none — files 404 in the mirror | Stage real bytes via `data-hub-process handler` | The seed cycles every available fixture for an instrument across its seeded runs (so gel-doc screenshots include Chemiluminescence, Fluorescence, and True Color Imaging, not eight copies of the same chemi TIFF). Each run gets one fixture copied to `/test-raw-data-bucket///`, so navigating to `/instruments/azure-cielo-qpcr/runs/Experiment_20260129` shows a real CSV in the file browser, the colony / plate-reader viewers fetch real bytes via `/api/v1/files//download`, and PNG / TIFF / PDF previews on `RunReportSection` render without 404s. Fixture-bearing runs only have the real fixture file — the synthetic CSV siblings other instruments still get are dropped so the UI only shows files that actually exist on disk. diff --git a/lambda/src/data_hub_lambda/spectramax_plate_reader/utils.py b/lambda/src/data_hub_lambda/spectramax_plate_reader/utils.py index 6c95cca..fdd2084 100644 --- a/lambda/src/data_hub_lambda/spectramax_plate_reader/utils.py +++ b/lambda/src/data_hub_lambda/spectramax_plate_reader/utils.py @@ -9,18 +9,25 @@ The raw data section of each plate block is a grid of rows × columns (e.g. 8 × 12 for 96-well, 16 × 24 for 384-well), repeated once per -reading (1 for Endpoint, *N* for Kinetic time-points or Well Scan -positions). Each reading group is followed by an empty separator line. -A summary table (no ``Temperature`` column header) follows the last -group before ``~End``. - -SoftMax may declare more Kinetic readings in the plate header than it -exports (e.g. a 48 h protocol stopped early). The parser emits the -groups that are present and stops at the summary table or ``~End``. +reading (1 for Endpoint, *N* for Kinetic time-points, Well Scan +positions, or Spectrum wavelengths). Each reading group is followed +by an empty separator line. A summary table (no ``Temperature`` +column header) follows the last group before ``~End``. + +Spectrum scans leave the usual wavelength field empty and store the +window at fixed offsets from ``Raw`` (start, end, step). Column 0 of +each reading group is the wavelength in nm, not elapsed time. + +SoftMax may declare more Kinetic or Spectrum readings in the plate +header than it exports (e.g. a 48 h protocol stopped early). The +parser emits the groups that are present and stops at the summary +table or ``~End``. """ from __future__ import annotations +import math import re +from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path from typing import Literal @@ -28,7 +35,7 @@ import pandas as pd MEASUREMENT_MODES = {"Absorbance", "Fluorescence"} -MEASUREMENT_TYPES = {"Endpoint", "Kinetic", "Well Scan"} +MEASUREMENT_TYPES = {"Endpoint", "Kinetic", "Spectrum", "Well Scan"} _COL_PLATE_NAME = 1 _COL_MEASUREMENT_TYPE = 4 @@ -41,6 +48,9 @@ _RAW_SEARCH_START = 6 _OFF_NUM_READINGS = 2 # offset from Raw index +_OFF_SPECTRUM_START = 5 +_OFF_SPECTRUM_END = 6 +_OFF_SPECTRUM_STEP = 7 _OFF_WAVELENGTH = 9 _OFF_NUM_WELLS = 12 @@ -67,14 +77,13 @@ def _at_plate_end(lines: list[str], i: int) -> bool: return i >= len(lines) or lines[i].startswith("~End") -def _kinetic_reading_present(lines: list[str], i: int, num_rows: int) -> bool: - """Return whether ``lines[i:i+num_rows]`` still holds a Kinetic group. - - After the last exported reading, SoftMax writes a summary table or - ``~End``. Neither carries an elapsed-time token, so an overstated - ``num_readings`` in the plate header must not consume those lines as - well data. - """ +def _group_col0_present( + lines: list[str], + i: int, + num_rows: int, + predicate: Callable[[str], bool], +) -> bool: + """Return whether the next grid still looks like a reading group.""" if _at_plate_end(lines, i): return False end = min(i + num_rows, len(lines)) @@ -82,11 +91,48 @@ def _kinetic_reading_present(lines: list[str], i: int, num_rows: int) -> bool: if lines[j].startswith("~End"): return False fields = lines[j].split("\t") - if fields and _ELAPSED_TIME_RE.match(fields[0].strip()): + if fields and predicate(fields[0].strip()): return True return False +def _kinetic_reading_present(lines: list[str], i: int, num_rows: int) -> bool: + """Return whether ``lines[i:i+num_rows]`` still holds a Kinetic group. + + After the last exported reading, SoftMax writes a summary table or + ``~End``. Neither carries an elapsed-time token, so an overstated + ``num_readings`` in the plate header must not consume those lines as + well data. + """ + return _group_col0_present(lines, i, num_rows, lambda col0: bool(_ELAPSED_TIME_RE.match(col0))) + + +def _spectrum_reading_present(lines: list[str], i: int, num_rows: int) -> bool: + """Return whether ``lines[i:i+num_rows]`` still holds a Spectrum group. + + Spectrum groups put the wavelength in column 0. The summary table + does not, so an overstated ``num_readings`` must stop there rather + than raise ``IndexError`` walking into ``~End``. + """ + return _group_col0_present(lines, i, num_rows, lambda col0: _parse_nm(col0) is not None) + + +def _stop_before_missing_reading( + header: _PlateHeader, + lines: list[str], + i: int, + num_rows: int, +) -> bool: + """Return whether the next declared reading is absent from the export.""" + if _at_plate_end(lines, i): + return True + if header.measurement_type == "Kinetic" and not _kinetic_reading_present(lines, i, num_rows): + return True + return header.measurement_type == "Spectrum" and not _spectrum_reading_present( + lines, i, num_rows + ) + + def _parse_well_value(val_str: str) -> float: """Convert a SpectraMax well-cell string to a float. @@ -100,12 +146,37 @@ def _parse_well_value(val_str: str) -> float: return float(val_str) +def _parse_nm(token: str) -> int | None: + """Parse a nanometre token, including SoftMax ``440.0`` floats.""" + stripped = token.strip() + if not stripped: + return None + try: + value = float(stripped) + except ValueError: + return None + if not math.isfinite(value): + return None + return int(value) if value.is_integer() else int(round(value)) + + def _parse_wavelengths(raw: str) -> tuple[int, ...]: """Parse a space-separated wavelength string like ``'750'`` or ``'750 600'``.""" tokens = raw.split() - if not tokens or not all(t.isdigit() for t in tokens): + parsed = [_parse_nm(t) for t in tokens] + if not tokens or any(w is None for w in parsed): raise ValueError(f"Expected space-separated numeric wavelengths, got '{raw}'") - return tuple(int(t) for t in tokens) + return tuple(w for w in parsed if w is not None) + + +def _spectrum_wavelengths(start: int, end: int, step: int) -> tuple[int, ...]: + """Expand a Spectrum scan window into discrete nanometre values.""" + if step <= 0: + raise ValueError(f"Spectrum step must be positive, got {step}") + wavelengths = tuple(range(start, end + 1, step)) + if not wavelengths: + raise ValueError(f"Spectrum window {start}–{end} step {step} is empty") + return wavelengths _WELL_POSITION_RE = re.compile(r"^([A-P])(\d{1,2})$") @@ -121,6 +192,9 @@ class _PlateHeader: num_readings: int wavelength_raw: str num_wells: int + spectrum_start: int | None = None + spectrum_end: int | None = None + spectrum_step: int | None = None def _parse_plate_header(line: str) -> _PlateHeader: @@ -154,16 +228,113 @@ def _parse_plate_header(line: str) -> _PlateHeader: f"have {len(fields) - raw_idx})" ) + measurement_type = fields[_COL_MEASUREMENT_TYPE].strip() + spectrum_start: int | None = None + spectrum_end: int | None = None + spectrum_step: int | None = None + if measurement_type == "Spectrum": + spectrum_start = _parse_nm(fields[raw_idx + _OFF_SPECTRUM_START]) + spectrum_end = _parse_nm(fields[raw_idx + _OFF_SPECTRUM_END]) + spectrum_step = _parse_nm(fields[raw_idx + _OFF_SPECTRUM_STEP]) + return _PlateHeader( plate_name=fields[_COL_PLATE_NAME], - measurement_type=fields[_COL_MEASUREMENT_TYPE].strip(), + measurement_type=measurement_type, measurement_mode=fields[_COL_MEASUREMENT_MODE].strip(), num_readings=int(fields[raw_idx + _OFF_NUM_READINGS]), wavelength_raw=fields[raw_idx + _OFF_WAVELENGTH].strip(), num_wells=int(fields[raw_idx + _OFF_NUM_WELLS]), + spectrum_start=spectrum_start, + spectrum_end=spectrum_end, + spectrum_step=spectrum_step, ) +def _header_wavelengths(header: _PlateHeader) -> tuple[int, ...]: + """Wavelengths declared on a plate header. + + Endpoint / Kinetic / Well Scan store a space-separated list in the + usual wavelength field. Spectrum leaves that field empty and encodes + the scan as start/end/step instead. + """ + if header.wavelength_raw: + return _parse_wavelengths(header.wavelength_raw) + if header.measurement_type == "Spectrum": + if ( + header.spectrum_start is None + or header.spectrum_end is None + or header.spectrum_step is None + ): + raise ValueError( + "Spectrum plate is missing start/end/step wavelengths " + f"(start={header.spectrum_start!r}, end={header.spectrum_end!r}, " + f"step={header.spectrum_step!r})" + ) + return _spectrum_wavelengths( + header.spectrum_start, header.spectrum_end, header.spectrum_step + ) + raise ValueError(f"Expected space-separated numeric wavelengths, got '{header.wavelength_raw}'") + + +def _spectrum_window_label(header: _PlateHeader) -> str | None: + if header.spectrum_start is None or header.spectrum_end is None: + return None + return f"{header.spectrum_start}–{header.spectrum_end}" + + +def _metadata_wavelength_tokens(header: _PlateHeader) -> tuple[str, ...]: + """Compact tokens stored on the run for filters and badges. + + Spectrum windows are kept as ``start–end`` rather than every nm so a + 300–800 scan does not inject hundreds of values into the instrument + filter and the run-detail sidebar. + """ + window = _spectrum_window_label(header) + if header.measurement_type == "Spectrum" and window is not None: + return (window,) + return tuple(str(w) for w in _header_wavelengths(header)) + + +def _allocate_unique_name(preferred: str, seen_names: set[str]) -> str: + """Return ``preferred``, or ``preferred (2)``, ``preferred (3)``, …""" + if preferred not in seen_names: + seen_names.add(preferred) + return preferred + n = 2 + while True: + candidate = f"{preferred} ({n})" + if candidate not in seen_names: + seen_names.add(candidate) + return candidate + n += 1 + + +def _plate_name_suffix(header: _PlateHeader) -> str | None: + window = _spectrum_window_label(header) + if window is not None: + return window + if header.wavelength_raw: + return header.wavelength_raw + return None + + +def _disambiguate_plate_name(header: _PlateHeader, seen_names: set[str]) -> str: + """Keep the first SoftMax plate name as-is; suffix later collisions. + + One export can reuse ``Plate1`` for emission vs excitation sweeps (or + a trailing Endpoint block). Downstream grouping keys on ``plate_name``, + so duplicates would merge unrelated scans. The resolved name is always + recorded so a third block cannot reuse the same suffix. + """ + name = header.plate_name + if name not in seen_names: + seen_names.add(name) + return name + suffix = _plate_name_suffix(header) + preferred = f"{name} ({suffix})" if suffix else name + return _allocate_unique_name(preferred, seen_names) + + _WELL_DATA_COLUMNS = [ "time", "plate_name", @@ -245,10 +416,14 @@ def parse_metadata(file_path: Path) -> dict[str, object]: Returns: A dict with keys `measurement_mode`, `measurement_type`, and - `wavelengths`. Wavelengths are returned as a list of numeric - strings (without the ``nm`` suffix) to mirror the shape used by - other multi-wavelength instruments (e.g. Azure 600 Gel Doc) and - let the UI layer own display formatting. Example:: + `wavelengths`. Type and mode come from the first plate; + wavelengths are the first-seen union across recognised plates so + a file that mixes Spectrum windows (or a trailing Endpoint) is + not truncated to the first block. Later plates with an unknown + type or mode are skipped so a trailing oddity does not fail + ingestion. Endpoint wavelengths stay as numeric strings (header + order, no ``nm`` suffix). Spectrum windows are stored as + ``start–end`` range tokens. Example:: { "measurement_mode": "Absorbance", @@ -257,36 +432,59 @@ def parse_metadata(file_path: Path) -> dict[str, object]: } Raises: - ValueError: If the file contains no `Plate:` header or the header - contains unexpected values. + ValueError: If the file contains no `Plate:` header or the first + header contains unexpected values. """ text = file_path.read_text(encoding="utf-16") + first_header: _PlateHeader | None = None + wavelengths: list[str] = [] + seen_wavelengths: set[str] = set() + for line in text.splitlines(): if not line.startswith("Plate:"): continue header = _parse_plate_header(line) + recognised = ( + header.measurement_mode in MEASUREMENT_MODES + and header.measurement_type in MEASUREMENT_TYPES + ) - if header.measurement_mode not in MEASUREMENT_MODES: - raise ValueError( - f"Unexpected measurement mode '{header.measurement_mode}'; " - f"expected one of {sorted(MEASUREMENT_MODES)}" - ) - if header.measurement_type not in MEASUREMENT_TYPES: - raise ValueError( - f"Unexpected measurement type '{header.measurement_type}'; " - f"expected one of {sorted(MEASUREMENT_TYPES)}" - ) - wavelengths = _parse_wavelengths(header.wavelength_raw) + if first_header is None: + if header.measurement_mode not in MEASUREMENT_MODES: + raise ValueError( + f"Unexpected measurement mode '{header.measurement_mode}'; " + f"expected one of {sorted(MEASUREMENT_MODES)}" + ) + if header.measurement_type not in MEASUREMENT_TYPES: + raise ValueError( + f"Unexpected measurement type '{header.measurement_type}'; " + f"expected one of {sorted(MEASUREMENT_TYPES)}" + ) + first_header = header + elif not recognised: + continue - return { - "measurement_mode": header.measurement_mode, - "measurement_type": header.measurement_type, - "wavelengths": [str(w) for w in wavelengths], - } + try: + tokens = _metadata_wavelength_tokens(header) + except ValueError: + if first_header is header: + raise + continue + for token in tokens: + if token not in seen_wavelengths: + seen_wavelengths.add(token) + wavelengths.append(token) - raise ValueError(f"No 'Plate:' header line found in {file_path}") + if first_header is None: + raise ValueError(f"No 'Plate:' header line found in {file_path}") + + return { + "measurement_mode": first_header.measurement_mode, + "measurement_type": first_header.measurement_type, + "wavelengths": wavelengths, + } def parse_raw_well_data(file_path: Path) -> pd.DataFrame: @@ -305,7 +503,7 @@ def parse_raw_well_data(file_path: Path) -> pd.DataFrame: ============== ======= ========================================== Column Type Notes ============== ======= ========================================== - time str? None for Endpoint reads + time str? None for Endpoint and Spectrum reads plate_name str e.g. "Plate2" well_position str e.g. "A1", "H12" temperature_c float? Celsius; shared across all wells in a @@ -315,7 +513,9 @@ def parse_raw_well_data(file_path: Path) -> pd.DataFrame: such as ``Path?`` or ``Range?``. row_label str e.g. "A" column_label int e.g. 1 - wavelength int? Nanometres; `None` when not reported + wavelength int? Nanometres; `None` when not reported. + Spectrum scans put each group's column-0 + wavelength here (not in ``time``). ============== ======= ========================================== Raises: @@ -325,6 +525,7 @@ def parse_raw_well_data(file_path: Path) -> pd.DataFrame: lines = text.splitlines() records: list[dict[str, object]] = [] + seen_plate_names: set[str] = set() i = 0 while i < len(lines): @@ -333,8 +534,10 @@ def parse_raw_well_data(file_path: Path) -> pd.DataFrame: continue header = _parse_plate_header(lines[i]) + plate_name = _disambiguate_plate_name(header, seen_plate_names) + is_spectrum = header.measurement_type == "Spectrum" try: - wavelengths = _parse_wavelengths(header.wavelength_raw) + wavelengths = _header_wavelengths(header) except ValueError: wavelengths = () @@ -347,20 +550,22 @@ def parse_raw_well_data(file_path: Path) -> pd.DataFrame: i += 2 # Skip plate header + column header row. if layout.format == "flat": - wl = wavelengths[0] if wavelengths else None + header_wl = wavelengths[0] if wavelengths else None for _ in range(header.num_readings): - if _at_plate_end(lines, i): - break - if header.measurement_type == "Kinetic" and not _kinetic_reading_present( - lines, i, 1 - ): + if _stop_before_missing_reading(header, lines, i, 1): break row_fields = lines[i].split("\t") - time_str = row_fields[0].strip() - time_val: str | None = time_str if time_str else None + col0 = row_fields[0].strip() temp_str = row_fields[1].strip() temp_val: float | None = float(temp_str) if temp_str else None + if is_spectrum: + time_val = None + parsed_wl = _parse_nm(col0) + wl = parsed_wl if parsed_wl is not None else header_wl + else: + time_val = col0 if col0 else None + wl = header_wl for col_idx, (row_label, column_label) in enumerate(layout.well_positions): val_str = row_fields[2 + col_idx].strip() @@ -370,7 +575,7 @@ def parse_raw_well_data(file_path: Path) -> pd.DataFrame: records.append( { "time": time_val, - "plate_name": header.plate_name, + "plate_name": plate_name, "well_position": f"{row_label}{column_label}", "temperature_c": temp_val, "value": _parse_well_value(val_str), @@ -395,15 +600,12 @@ def parse_raw_well_data(file_path: Path) -> pd.DataFrame: offsets = layout.group_offsets or (2,) for _ in range(header.num_readings): - if _at_plate_end(lines, i): - break - if header.measurement_type == "Kinetic" and not _kinetic_reading_present( - lines, i, num_rows - ): + if _stop_before_missing_reading(header, lines, i, num_rows): break time_val = None temp_val = None + group_wavelength: int | None = None for row_idx in range(num_rows): row_fields = lines[i].split("\t") @@ -411,15 +613,24 @@ def parse_raw_well_data(file_path: Path) -> pd.DataFrame: # SoftMax Pro writes elapsed time / temperature on the first # populated row of each reading group. When leading rows are # unselected (e.g. edge wells skipped), that is not row A. - if time_val is None and row_fields and row_fields[0].strip(): - time_val = row_fields[0].strip() + # Spectrum stores wavelength in the same column-0 slot. + col0 = row_fields[0].strip() if row_fields else "" + if is_spectrum: + parsed_wl = _parse_nm(col0) + if group_wavelength is None and parsed_wl is not None: + group_wavelength = parsed_wl + elif time_val is None and col0: + time_val = col0 if temp_val is None and len(row_fields) > 1 and row_fields[1].strip(): temp_val = float(row_fields[1].strip()) row_label = _ROW_LABELS[row_idx] for wl_idx, group_start in enumerate(offsets): - wl = wavelengths[wl_idx] if wl_idx < len(wavelengths) else None + if is_spectrum: + wl = group_wavelength + else: + wl = wavelengths[wl_idx] if wl_idx < len(wavelengths) else None for col_idx in range(num_cols): val_str = row_fields[group_start + col_idx].strip() if not val_str: @@ -429,7 +640,7 @@ def parse_raw_well_data(file_path: Path) -> pd.DataFrame: records.append( { "time": time_val, - "plate_name": header.plate_name, + "plate_name": plate_name, "well_position": f"{row_label}{column_label}", "temperature_c": temp_val, "value": _parse_well_value(val_str), diff --git a/lambda/tests/fixtures/spectramax_plate_reader_spectrum.xls b/lambda/tests/fixtures/spectramax_plate_reader_spectrum.xls new file mode 100644 index 0000000..e643e91 Binary files /dev/null and b/lambda/tests/fixtures/spectramax_plate_reader_spectrum.xls differ diff --git a/lambda/tests/fixtures/spectramax_plate_reader_spectrum_384.xls b/lambda/tests/fixtures/spectramax_plate_reader_spectrum_384.xls new file mode 100644 index 0000000..ed34dbe Binary files /dev/null and b/lambda/tests/fixtures/spectramax_plate_reader_spectrum_384.xls differ diff --git a/lambda/tests/spectramax_plate_reader/test_parse_metadata.py b/lambda/tests/spectramax_plate_reader/test_parse_metadata.py index 12cb140..5a8d39d 100644 --- a/lambda/tests/spectramax_plate_reader/test_parse_metadata.py +++ b/lambda/tests/spectramax_plate_reader/test_parse_metadata.py @@ -130,6 +130,22 @@ def test_endpoint_flat(self) -> None: "wavelengths": ["595"], } + def test_spectrum_unions_windows_across_plates(self) -> None: + result = parse_metadata(_FIXTURES_DIR / "spectramax_plate_reader_spectrum.xls") + assert result == { + "measurement_mode": "Fluorescence", + "measurement_type": "Spectrum", + "wavelengths": ["440–450", "430–440", "480–500"], + } + + def test_spectrum_384_unions_windows_and_endpoint(self) -> None: + result = parse_metadata(_FIXTURES_DIR / "spectramax_plate_reader_spectrum_384.xls") + assert result == { + "measurement_mode": "Fluorescence", + "measurement_type": "Spectrum", + "wavelengths": ["540–560", "430–450", "500–520", "595"], + } + # --------------------------------------------------------------------------- # Synthetic happy-path tests @@ -193,8 +209,8 @@ def test_invalid_measurement_mode(self, tmp_path: Path) -> None: parse_metadata(path) def test_invalid_measurement_type(self, tmp_path: Path) -> None: - path = _build_xls(tmp_path, measurement_type="Spectrum") - with pytest.raises(ValueError, match="Unexpected measurement type 'Spectrum'"): + path = _build_xls(tmp_path, measurement_type="Area Scan") + with pytest.raises(ValueError, match="Unexpected measurement type 'Area Scan'"): parse_metadata(path) def test_non_numeric_wavelength(self, tmp_path: Path) -> None: @@ -238,3 +254,54 @@ def test_column_header_with_no_recognizable_labels(self) -> None: """Column header row lacking numeric or well-position labels raises ValueError.""" with pytest.raises(ValueError, match="Could not determine column layout"): _parse_column_layout("\tTemperature\tA\tB") + + def test_trailing_unrecognised_type_does_not_fail_file(self, tmp_path: Path) -> None: + """A later Area Scan block must not fail ingestion of a valid first plate.""" + first = ( + f"{_BOILERPLATE_PREFIX}\tEndpoint\tAbsorbance" + f"{_BOILERPLATE_MIDDLE_RAW}\t595\t1\t12\t96\t1\t4\n{_DATA_ROWS}" + ) + second = ( + f"{_BOILERPLATE_PREFIX}\tArea Scan\tAbsorbance" + f"{_BOILERPLATE_MIDDLE_RAW}\t450\t1\t12\t96\t1\t4\n{_DATA_ROWS}" + ) + path = tmp_path / "mixed.xls" + path.write_text(f"##BLOCKS= 2\n{first}{second}", encoding="utf-16") + assert parse_metadata(path) == { + "measurement_mode": "Absorbance", + "measurement_type": "Endpoint", + "wavelengths": ["595"], + } + + def test_endpoint_then_spectrum_keeps_first_plate_type(self, tmp_path: Path) -> None: + endpoint = ( + f"{_BOILERPLATE_PREFIX}\tEndpoint\tAbsorbance" + f"{_BOILERPLATE_MIDDLE_RAW}\t595\t1\t12\t96\t1\t4\n{_DATA_ROWS}" + ) + spectrum = ( + "Plate:\tPlate1\t1.3\tPlateFormat\tSpectrum\tFluorescence\tFALSE" + "\tRaw\tFALSE\t3\t\t\t440\t450\t5\t\t\t1\t12\t96\n" + f"{_DATA_ROWS}" + ) + path = tmp_path / "endpoint_then_spectrum.xls" + path.write_text(f"##BLOCKS= 2\n{endpoint}{spectrum}", encoding="utf-16") + assert parse_metadata(path) == { + "measurement_mode": "Absorbance", + "measurement_type": "Endpoint", + "wavelengths": ["595", "440–450"], + } + + def test_spectrum_missing_window_raises(self, tmp_path: Path) -> None: + header = ( + "Plate:\tPlate1\t1.3\tPlateFormat\tSpectrum\tAbsorbance" + "\tRaw\tFALSE\t1\t\t\t\t\t\t\t\t1\t12\t96\t1\t4\n" + ) + path = tmp_path / "spectrum_no_window.xls" + path.write_text(f"##BLOCKS= 1\n{header}{_DATA_ROWS}", encoding="utf-16") + with pytest.raises(ValueError, match="missing start/end/step"): + parse_metadata(path) + + def test_float_wavelengths(self, tmp_path: Path) -> None: + path = _build_xls(tmp_path, wavelength="750.0 600.0") + result = parse_metadata(path) + assert result["wavelengths"] == ["750", "600"] diff --git a/lambda/tests/spectramax_plate_reader/test_parse_raw_well_data.py b/lambda/tests/spectramax_plate_reader/test_parse_raw_well_data.py index 0cb44dc..1757829 100644 --- a/lambda/tests/spectramax_plate_reader/test_parse_raw_well_data.py +++ b/lambda/tests/spectramax_plate_reader/test_parse_raw_well_data.py @@ -391,7 +391,282 @@ def test_all_rows_populated(self) -> None: assert set(self.df["row_label"].unique()) == set("ABCDEFGH") -class TestDualWavelength: +class TestSpectrum: + """96-well Spectrum matching iD5 production well occupancy. + + Populated wells are A1–A5 and B1–B10 on a 12-column grid (15 unique + wells). SoftMax reuses ``Plate1``; the later absorbance scan is + suffixed with its window. Values are synthetic; the header layout + matches SoftMax Spectrum (window at Raw+5/6/7, empty wavelength + field, col0 = nm). + """ + + @pytest.fixture(autouse=True) + def _load(self) -> None: + self.df = parse_raw_well_data(_FIXTURES_DIR / "spectramax_plate_reader_spectrum.xls") + + def test_columns(self) -> None: + assert list(self.df.columns) == _EXPECTED_COLUMNS + + def test_shape(self) -> None: + # 4 wells × 3 wl + 5 wells × 3 wl + 10 wells × 3 wl + assert self.df.shape == (57, 8) + + def test_time_is_null(self) -> None: + assert bool(self.df["time"].isna().all()) + + def test_unique_wells_match_id5(self) -> None: + wells = set(self.df["well_position"].unique().tolist()) + expected = {f"A{c}" for c in range(1, 6)} | {f"B{c}" for c in range(1, 11)} + assert wells == expected + + def test_grid_extent_matches_id5(self) -> None: + assert max(self.df["column_label"].unique().tolist()) == 10 + assert set(self.df["row_label"].unique().tolist()) == {"A", "B"} + + def test_plate_names_disambiguated(self) -> None: + assert self.df["plate_name"].unique().tolist() == [ + "Plate1", + "Plate2", + "Plate1 (480–500)", + ] + + def test_first_block_wavelengths(self) -> None: + first = self.df.loc[self.df["plate_name"] == "Plate1"] + assert sorted(first["wavelength"].unique().tolist()) == [440, 445, 450] + assert len(first) == 12 + + def test_second_block_wavelengths(self) -> None: + second = self.df.loc[self.df["plate_name"] == "Plate2"] + assert sorted(second["wavelength"].unique().tolist()) == [430, 435, 440] + assert len(second) == 15 + + def test_duplicate_plate_wells(self) -> None: + third = self.df.loc[self.df["plate_name"] == "Plate1 (480–500)"] + assert set(third["well_position"].unique().tolist()) == {f"B{c}" for c in range(1, 11)} + assert len(third) == 30 + + def test_spectrum_values_from_col0_wavelength(self) -> None: + row = self.df.loc[ + (self.df["plate_name"] == "Plate1") + & (self.df["wavelength"] == 440) + & (self.df["well_position"] == "A1") + ].iloc[0] + assert row["temperature_c"] == pytest.approx(22.7) + assert row["value"] == pytest.approx(1.014) + + def test_wells(self) -> None: + first = self.df.loc[self.df["plate_name"] == "Plate1"] + assert set(first["well_position"].unique().tolist()) == {"A1", "A2", "A3", "A4"} + + +class TestSpectrum384: + """384-well Spectrum + trailing 96-well Endpoint. + + The first Spectrum block fills B1–B24 so the 24-column grid is + actually occupied (wide plate-map layout). Later Spectrum blocks keep + the sparser A1–A5 / B8–B11 occupancy; the Endpoint block is 96-well. + Values are synthetic; the header layout matches SoftMax Spectrum + (window at Raw+5/6/7, empty wavelength field, col0 = nm). + """ + + @pytest.fixture(autouse=True) + def _load(self) -> None: + self.df = parse_raw_well_data(_FIXTURES_DIR / "spectramax_plate_reader_spectrum_384.xls") + + def test_columns(self) -> None: + assert list(self.df.columns) == _EXPECTED_COLUMNS + + def test_shape(self) -> None: + # 24×3 + 5×3 + 4×3 + 24 endpoint + assert self.df.shape == (123, 8) + + def test_time_is_null(self) -> None: + assert bool(self.df["time"].isna().all()) + + def test_unique_wells(self) -> None: + wells = set(self.df["well_position"].unique().tolist()) + expected = {f"A{c}" for c in range(1, 13)} | {f"B{c}" for c in range(1, 25)} + assert wells == expected + + def test_spectrum_extent_is_384_wide(self) -> None: + spectrum = self.df.loc[self.df["plate_name"] != "Plate1 (595)"] + assert max(spectrum["column_label"].unique().tolist()) == 24 + assert set(spectrum["row_label"].unique().tolist()) == {"A", "B"} + + def test_plate_names_disambiguated(self) -> None: + assert self.df["plate_name"].unique().tolist() == [ + "Plate5", + "Plate1", + "Plate1 (500–520)", + "Plate1 (595)", + ] + + def test_first_block_wells(self) -> None: + first = self.df.loc[self.df["plate_name"] == "Plate5"] + assert set(first["well_position"].unique().tolist()) == {f"B{c}" for c in range(1, 25)} + assert len(first) == 72 + + def test_trailing_endpoint(self) -> None: + endpoint = self.df.loc[self.df["plate_name"] == "Plate1 (595)"] + assert len(endpoint) == 24 + assert (endpoint["wavelength"] == 595).all() + assert max(endpoint["column_label"].unique().tolist()) == 12 + assert set(endpoint["well_position"].unique().tolist()) == { + f"{r}{c}" for r in "AB" for c in range(1, 13) + } + + +class TestIncompleteSpectrum: + """SoftMax may declare more Spectrum readings than it exports. + + Emit the groups that are present and stop before the summary / ``~End`` + instead of raising IndexError. + """ + + @pytest.fixture(autouse=True) + def _load(self, tmp_path: Path) -> None: + prefix = "Plate:\tPlate1\t1.3\tPlateFormat" + # Header window is 440–455 / 5 (4 points) and claims 4 readings; + # file only has 2 groups (+ summary). + middle = "\tRaw\tFALSE\t4\t\t\t440\t455\t5\t\t\t1\t2\t4" + header = f"{prefix}\tSpectrum\tAbsorbance{middle}\n" + col_header = "\tTemperature(\xa1C)\t1\t2\t\n" + r0 = "440\t30.0\t0.10\t0.20\t\n" + r1 = "\t\t0.30\t0.40\t\n" + blank = "\n" + r2 = "445\t30.1\t0.11\t0.21\t\n" + r3 = "\t\t0.31\t0.41\t\n" + summary = "\t\t1\t2\t\n\t\t0.10\t0.20\t\n\t\t0.30\t0.40\t\n" + content = f"##BLOCKS= 1\n{header}{col_header}{r0}{r1}{blank}{r2}{r3}{blank}{summary}~End\n" + path = tmp_path / "incomplete_spectrum.xls" + path.write_text(content, encoding="utf-16") + self.df = parse_raw_well_data(path) + + def test_shape_uses_exported_readings_only(self) -> None: + assert self.df.shape == (8, 8) + + def test_wavelengths(self) -> None: + assert self.df["wavelength"].unique().tolist() == [440, 445] + + def test_time_is_null(self) -> None: + assert bool(self.df["time"].isna().all()) + + def test_stops_at_end_without_summary(self, tmp_path: Path) -> None: + prefix = "Plate:\tPlate1\t1.3\tPlateFormat" + middle = "\tRaw\tFALSE\t3\t\t\t440\t450\t5\t\t\t1\t2\t4" + header = f"{prefix}\tSpectrum\tAbsorbance{middle}\n" + col_header = "\tTemperature(\xa1C)\t1\t2\t\n" + r0 = "440\t30.0\t0.10\t0.20\t\n" + r1 = "\t\t0.30\t0.40\t\n" + content = f"##BLOCKS= 1\n{header}{col_header}{r0}{r1}\n~End\n" + path = tmp_path / "incomplete_spectrum_no_summary.xls" + path.write_text(content, encoding="utf-16") + df = parse_raw_well_data(path) + assert df.shape == (4, 8) + assert df["wavelength"].unique().tolist() == [440] + + +class TestDuplicatePlateNames: + """Three blocks sharing a SoftMax plate name must not merge.""" + + def test_third_endpoint_keeps_full_wavelength_suffix(self, tmp_path: Path) -> None: + def block(wavelength: str, value: str) -> str: + prefix = "Plate:\tPlate1\t1.3\tPlateFormat" + middle = "\tRaw\tFALSE\t1\t\t\t\t\t\t1" + header = f"{prefix}\tEndpoint\tAbsorbance{middle}\t{wavelength}\t1\t2\t2\t1\t2\n" + col_header = "\tTemperature(\xa1C)\t1\t2\t\n" + row = f"\t25.0\t{value}\t{value}\t\n" + return f"{header}{col_header}{row}\n~End\n" + + content = ( + "##BLOCKS= 3\n" + + block("595 750", "1.0") + + block("595 800", "2.0") + + block("595 900", "3.0") + ) + path = tmp_path / "dup_endpoint.xls" + path.write_text(content, encoding="utf-16") + df = parse_raw_well_data(path) + assert df["plate_name"].unique().tolist() == [ + "Plate1", + "Plate1 (595 800)", + "Plate1 (595 900)", + ] + assert df.loc[df["plate_name"] == "Plate1", "value"].tolist() == pytest.approx([1.0, 1.0]) + assert df.loc[df["plate_name"] == "Plate1 (595 900)", "value"].tolist() == pytest.approx( + [3.0, 3.0] + ) + + def test_third_identical_spectrum_window_gets_counter(self, tmp_path: Path) -> None: + def block(value: str) -> str: + header = ( + "Plate:\tPlate1\t1.3\tPlateFormat\tSpectrum\tAbsorbance" + "\tRaw\tFALSE\t1\t\t\t440\t440\t1\t\t\t1\t1\t1\n" + ) + col_header = "\tTemperature(\xa1C)\t1\t\n" + row = f"440\t25.0\t{value}\t\n" + return f"{header}{col_header}{row}\n~End\n" + + content = "##BLOCKS= 3\n" + block("1.0") + block("2.0") + block("3.0") + path = tmp_path / "dup_spectrum.xls" + path.write_text(content, encoding="utf-16") + df = parse_raw_well_data(path) + assert df["plate_name"].unique().tolist() == [ + "Plate1", + "Plate1 (440–440)", + "Plate1 (440–440) (2)", + ] + assert df["value"].tolist() == pytest.approx([1.0, 2.0, 3.0]) + + def test_counter_fallback_without_wavelength(self, tmp_path: Path) -> None: + def block(value: str) -> str: + prefix = "Plate:\tP\t1.3\tPlateFormat" + middle = "\tRaw\tFALSE\t1\t\t\t\t\t\t1" + header = f"{prefix}\tEndpoint\tAbsorbance{middle}\t\t1\t1\t1\t1\t1\n" + col_header = "\tTemperature(\xa1C)\t1\t\n" + row = f"\t25.0\t{value}\t\n" + return f"{header}{col_header}{row}\n~End\n" + + content = "##BLOCKS= 3\n" + block("1.0") + block("2.0") + block("3.0") + path = tmp_path / "dup_counter.xls" + path.write_text(content, encoding="utf-16") + df = parse_raw_well_data(path) + assert df["plate_name"].unique().tolist() == ["P", "P (2)", "P (3)"] + + +class TestSpectrumFloatWavelengths: + def test_float_header_and_col0(self, tmp_path: Path) -> None: + header = ( + "Plate:\tPlate1\t1.3\tPlateFormat\tSpectrum\tAbsorbance" + "\tRaw\tFALSE\t1\t\t\t440.0\t440.0\t1.0\t\t\t1\t1\t1\n" + ) + col_header = "\tTemperature(\xa1C)\t1\t\n" + row = "440.0\t25.0\t1.5\t\n" + path = tmp_path / "float_nm.xls" + path.write_text(f"##BLOCKS= 1\n{header}{col_header}{row}\n~End\n", encoding="utf-16") + df = parse_raw_well_data(path) + assert df["wavelength"].tolist() == [440] + assert df["value"].tolist() == pytest.approx([1.5]) + + +class TestSpectrumFlat: + def test_flat_layout_reads_col0_wavelength(self, tmp_path: Path) -> None: + header = ( + "Plate:\tPlate1\t1.3\tPlateFormat\tSpectrum\tAbsorbance" + "\tRaw\tFALSE\t2\t\t\t440\t445\t5\t\t\t1\t2\t2\n" + ) + col_header = "\tTemperature(\xa1C)\tA1\tA2\t\n" + r0 = "440\t25.0\t0.10\t0.20\t\n" + r1 = "445.0\t25.1\t0.11\t0.21\t\n" + path = tmp_path / "spectrum_flat.xls" + path.write_text(f"##BLOCKS= 1\n{header}{col_header}{r0}\n{r1}\n~End\n", encoding="utf-16") + df = parse_raw_well_data(path) + assert df.shape == (4, 8) + assert sorted(df["wavelength"].unique().tolist()) == [440, 445] + assert bool(df["time"].isna().all()) + assert df["well_position"].tolist() == ["A1", "A2", "A1", "A2"] + """Endpoint / Absorbance / dual wavelength (750 + 600), synthetic 2×2 plate.""" @pytest.fixture(autouse=True) diff --git a/web/components/runs/metadata-badges.tsx b/web/components/runs/metadata-badges.tsx index 44dde1a..57b0953 100644 --- a/web/components/runs/metadata-badges.tsx +++ b/web/components/runs/metadata-badges.tsx @@ -63,29 +63,42 @@ export function getMetadataObjectArray( } /** - * Sort a list of wavelength strings (e.g. `"750"`) in ascending numerical - * order. Non-numeric entries are pushed to the end, preserving their - * relative order, so mixed inputs still render predictably. + * Sort a list of wavelength strings (e.g. `"750"` or `"440–450"`) in + * ascending numerical order. Range tokens sort by their start. Other + * non-numeric entries are pushed to the end, preserving their relative + * order, so mixed inputs still render predictably. */ export function sortWavelengths(wavelengths: string[]): string[] { return [...wavelengths].sort((a, b) => { - const na = Number(a); - const nb = Number(b); - const aNum = Number.isFinite(na); - const bNum = Number.isFinite(nb); - if (aNum && bNum) { - return na - nb; + const ka = wavelengthSortKey(a); + const kb = wavelengthSortKey(b); + if (ka !== null && kb !== null) { + return ka - kb; } - if (aNum) { + if (ka !== null) { return -1; } - if (bNum) { + if (kb !== null) { return 1; } return a.localeCompare(b); }); } +const WAVELENGTH_RANGE_RE = /^(\d+(?:\.\d+)?)[–-](\d+(?:\.\d+)?)$/; + +function wavelengthSortKey(value: string): number | null { + const n = Number(value); + if (Number.isFinite(n)) { + return n; + } + const range = WAVELENGTH_RANGE_RE.exec(value); + if (range) { + return Number(range[1]); + } + return null; +} + export function MetadataFieldBadge({ value, colorClass, diff --git a/web/components/runs/plate-map-grid.tsx b/web/components/runs/plate-map-grid.tsx index e5dce1e..22e2573 100644 --- a/web/components/runs/plate-map-grid.tsx +++ b/web/components/runs/plate-map-grid.tsx @@ -353,25 +353,28 @@ function computeGlobalHeatmapRange( return { min, max }; } -interface KineticPlateMapWithTimeSliderProps { +interface PlateMapWithIndexSliderProps { + frameLabels: string[]; frames: PlateWellData[][]; heatmap: boolean; plateName?: string; - timeLabels: string[]; + sliderAxis?: "time" | "wavelength"; wavelength?: string; } /** - * Plate map with a time index slider (for kinetic absorbance series). - * Heatmap scale is global across all frames so colors stay comparable while scrubbing. + * Plate map with an index slider (kinetic time-points or Spectrum + * wavelengths). Heatmap scale is global across all frames so colors stay + * comparable while scrubbing. */ -export function KineticPlateMapWithTimeSlider({ - timeLabels, +export function PlateMapWithIndexSlider({ + frameLabels, frames, heatmap, plateName, wavelength, -}: KineticPlateMapWithTimeSliderProps) { + sliderAxis = "time", +}: PlateMapWithIndexSliderProps) { const [index, setIndex] = useState(0); const maxIdx = Math.max(0, frames.length - 1); const selectedIndex = Math.min(Math.max(0, index), maxIdx); @@ -387,6 +390,10 @@ export function KineticPlateMapWithTimeSlider({ // Thumb centers track from 0–100%; avoid div-by-zero on a single frame. const thumbPercent = maxIdx === 0 ? 0 : (selectedIndex / maxIdx) * 100; + const displayWavelength = + sliderAxis === "wavelength" + ? (frameLabels[selectedIndex] ?? wavelength) + : wavelength; const wide = plateColumnCount(frames[0] ?? []) > COMPACT_PLATE_MAX_COLS; @@ -400,12 +407,12 @@ export function KineticPlateMapWithTimeSlider({ heatmap={heatmap} heatmapRange={heatmapRange} plateName={plateName} - wavelength={wavelength} + wavelength={displayWavelength} /> {frames.length > 1 && (
- {timeLabels[0]} + {frameLabels[0]}
- {timeLabels[selectedIndex] ?? "—"} + {frameLabels[selectedIndex] ?? "—"}
- {timeLabels[maxIdx]} + {frameLabels[maxIdx]}
)} diff --git a/web/components/runs/run-metadata-badges.tsx b/web/components/runs/run-metadata-badges.tsx index f38fbc6..f5109b0 100644 --- a/web/components/runs/run-metadata-badges.tsx +++ b/web/components/runs/run-metadata-badges.tsx @@ -4,6 +4,7 @@ import { getMetadataObjectArray, getMetadataRecord, sortWavelengths, + TruncatedBadges, } from "@/components/runs/metadata-badges"; import { Badge } from "@/components/ui/badge"; import { @@ -109,9 +110,11 @@ export function PlateReaderRunBadges({ - {wavelengths.map((w) => ( - - ))} + )} diff --git a/web/components/runs/variants/plate-reader-run-detail.tsx b/web/components/runs/variants/plate-reader-run-detail.tsx index 98b5558..c860630 100644 --- a/web/components/runs/variants/plate-reader-run-detail.tsx +++ b/web/components/runs/variants/plate-reader-run-detail.tsx @@ -1,8 +1,7 @@ import { DeleteRunDialog } from "@/components/runs/delete-run-dialog"; import { - KineticPlateMapWithTimeSlider, PlateMapGrid, - type PlateWellData, + PlateMapWithIndexSlider, } from "@/components/runs/plate-map-grid"; import { RestoreRunButton } from "@/components/runs/restore-run-button"; import type { RunDetailProps } from "@/components/runs/run-detail"; @@ -14,203 +13,31 @@ import { import { RunSectionHeading } from "@/components/runs/run-section-heading"; import { Card, CardContent } from "@/components/ui/card"; import type { RawWellRow } from "@/lib/api/instrument-runs"; -import { sortTimeKeys } from "@/lib/runs/sort-kinetic-time-keys"; +import { extractPlateMaps } from "@/lib/runs/extract-plate-maps"; /** Measurement types whose well values are numeric and benefit from color-coded heatmaps. */ -const HEATMAP_MEASUREMENT_TYPES = new Set(["Endpoint", "Well Scan", "Kinetic"]); - -/** - * CSV parsing (csv-parse) returns all cell values as strings. The heatmap grid - * relies on `typeof value === "number"` to apply the Plasma colorscale, so we - * coerce numeric-looking strings (e.g. "0.649") to real numbers at the - * PlateWellData boundary. - */ -function coerceNumeric(value: unknown): unknown { - if (typeof value === "number") { - return value; - } - if (typeof value !== "string" || value === "") { - return value; - } - const n = Number(value); - return Number.isFinite(n) ? n : value; -} - -type PlateMapGroup = - | { - mode: "static"; - plateName: string; - wavelength: string; - wells: PlateWellData[]; - } - | { - mode: "kinetic"; - plateName: string; - wavelength: string; - timeLabels: string[]; - frames: PlateWellData[][]; - }; - -/** - * Groups kinetic CSV rows into time-indexed plate map frames, one group per - * unique plate + wavelength combination. Each group becomes either a single - * static plate map (if only one time-point exists) or a kinetic slider with - * one frame per time-point. - */ -function extractKineticPlateMapGroups( - rows: RawWellRow[], - wellKey: "well_position" | "well" -): PlateMapGroup[] { - // First pass: bucket rows by plate + wavelength. - const byPlateWave = new Map(); - for (const row of rows) { - const pw = `${row.plate_name ?? ""}|${row.wavelength ?? ""}`; - const arr = byPlateWave.get(pw) ?? []; - arr.push(row); - byPlateWave.set(pw, arr); - } - - const results: PlateMapGroup[] = []; - for (const [pw, subset] of byPlateWave) { - // Second pass within each plate+wavelength: bucket by time-point. - const byTime = new Map(); - for (const row of subset) { - const tk = String(row.time ?? ""); - const g = byTime.get(tk) ?? []; - g.push(row); - byTime.set(tk, g); - } - - const [plateName = "", wavelength = ""] = pw.split("|"); - - // Only one time-point — degenerate to a static map instead of a slider. - if (byTime.size < 2) { - const flat = [...byTime.values()].flat(); - results.push({ - mode: "static", - plateName, - wavelength, - wells: flat.map((r) => ({ - well: String(r[wellKey]), - value: coerceNumeric(r.value), - })), - }); - continue; - } - - const timeKeysSorted = sortTimeKeys([...byTime.keys()]); - const frames = timeKeysSorted.map((tk) => - (byTime.get(tk) ?? []).map((r) => ({ - well: String(r[wellKey]), - value: coerceNumeric(r.value), - })) - ); - results.push({ - mode: "kinetic", - plateName, - wavelength, - timeLabels: timeKeysSorted, - frames, - }); - } - return results; -} - -/** - * Main entry point: converts flat CSV rows into renderable plate map groups. - * - * Strategy depends on measurement type: - * - Kinetic with multiple time-points → time-slider groups (one per plate+wavelength) - * - Single combination of (plate, wavelength, time) → one unlabelled static map - * - Multiple combinations → separate labelled static maps (e.g. multi-wavelength endpoint) - * - * The CSV may use either "well_position" (SpectraMax) or "well" as the column - * name for well coordinates — we auto-detect from the first row. - */ -function extractPlateMaps( - rows: RawWellRow[], - options: { kinetic: boolean } -): PlateMapGroup[] { - if (rows.length === 0) { - return []; - } - - // Auto-detect the well-address column name across CSV export formats. - const wellKey = - rows[0].well_position === undefined ? "well" : "well_position"; - if (rows[0][wellKey] === undefined) { - return []; - } - - const uniqueTimes = new Set(rows.map((r) => String(r.time ?? ""))); - const hasTimeVariation = uniqueTimes.size > 1; - - if (options.kinetic && hasTimeVariation) { - return extractKineticPlateMapGroups( - rows, - wellKey as "well_position" | "well" - ); - } - - // Check whether all rows belong to the same (plate, wavelength, time) group. - const hasMultiple = - new Set(rows.map((r) => `${r.plate_name}|${r.wavelength}|${r.time}`)).size > - 1; - - if (!hasMultiple) { - return [ - { - mode: "static", - plateName: "", - wavelength: "", - wells: rows.map((r) => ({ - well: String(r[wellKey]), - value: coerceNumeric(r.value), - })), - }, - ]; - } - - // Multiple groups — split by (plate, wavelength, time) and label each one. - const grouped = new Map(); - for (const row of rows) { - const key = `${row.plate_name ?? ""}|${row.wavelength ?? ""}|${row.time ?? ""}`; - const group = grouped.get(key) ?? []; - group.push(row); - grouped.set(key, group); - } - - return Array.from(grouped.entries()).map(([key, group]) => { - const [plate = "", wavelength = "", time = ""] = key.split("|"); - const titleParts: string[] = []; - if (plate) { - titleParts.push(plate); - } - if (time) { - titleParts.push(`t=${time}`); - } - return { - mode: "static" as const, - plateName: titleParts.join(" · "), - wavelength, - wells: group.map((r) => ({ - well: String(r[wellKey]), - value: coerceNumeric(r.value), - })), - }; - }); -} +const HEATMAP_MEASUREMENT_TYPES = new Set([ + "Endpoint", + "Well Scan", + "Kinetic", + "Spectrum", +]); function PlateMapSection({ rows, heatmap, kineticLayout, + spectrumLayout, }: { rows: RawWellRow[]; heatmap: boolean; kineticLayout: boolean; + spectrumLayout: boolean; }) { - const groups = extractPlateMaps(rows, { kinetic: kineticLayout }); + const groups = extractPlateMaps(rows, { + kinetic: kineticLayout, + spectrum: spectrumLayout, + }); if (groups.length === 0) { return null; @@ -224,12 +51,13 @@ function PlateMapSection({
{groups.map((g, i) => g.mode === "kinetic" ? ( - ) : ( @@ -321,6 +149,7 @@ export function PlateReaderRunDetail({ heatmap={heatmap} kineticLayout={measurementType === "Kinetic"} rows={wellData} + spectrumLayout={measurementType === "Spectrum"} /> ); diff --git a/web/lib/db/seed.ts b/web/lib/db/seed.ts index 46907c1..0ed0982 100644 --- a/web/lib/db/seed.ts +++ b/web/lib/db/seed.ts @@ -787,6 +787,16 @@ const SPECTRAMAX_FIXTURE_FILES: readonly FixtureFileSpec[] = [ }, ]; +const SPECTRAMAX_SPECTRUM_96: FixtureFileSpec = { + filename: "spectramax_plate_reader_spectrum.xls", + contentType: "application/vnd.ms-excel", +}; + +const SPECTRAMAX_SPECTRUM_384: FixtureFileSpec = { + filename: "spectramax_plate_reader_spectrum_384.xls", + contentType: "application/vnd.ms-excel", +}; + const GEL_DOC_FIXTURE_FILES: readonly FixtureFileSpec[] = [ { filename: "azure_600_gel_doc_example.tif", @@ -837,9 +847,9 @@ export const INSTRUMENT_FIXTURES: Record = { ], }, "spectramax-id3-plate-reader": { - files: SPECTRAMAX_FIXTURE_FILES, + files: [...SPECTRAMAX_FIXTURE_FILES, SPECTRAMAX_SPECTRUM_384], // Names track the cycled fixture order (endpoint → flat → sparse → - // fluorescence → kinetic → well-scan → endpoint → flat). + // fluorescence → kinetic → well-scan → spectrum → endpoint). runIds: [ "012926_AR_OD750", "012226_DK_OD595_flat", @@ -847,12 +857,12 @@ export const INSTRUMENT_FIXTURES: Record = { "010826_DK_GFP_fluo", "010126_AR_OD595_kinetic", "122525_DK_OD595_wellscan", - "121825_AR_OD750", - "121125_DK_OD595_flat", + "121825_AR_FP_spectrum", + "121125_DK_OD750", ], }, "spectramax-id5-plate-reader": { - files: SPECTRAMAX_FIXTURE_FILES, + files: [...SPECTRAMAX_FIXTURE_FILES, SPECTRAMAX_SPECTRUM_96], // Same fixture cycle as iD3; stems stay distinct per reader. runIds: [ "260721_OD750_AAA", @@ -861,8 +871,8 @@ export const INSTRUMENT_FIXTURES: Record = { "260714_GFP_fluo_DDD", "260710_OD595_kinetic_EEE", "260705_OD595_wellscan_FFF", - "260628_OD750_GGG", - "260620_OD595_flat_HHH", + "260628_FP_spectrum_GGG", + "260620_OD750_HHH", ], }, }; diff --git a/web/lib/instrument-colors.ts b/web/lib/instrument-colors.ts index bf7b8d8..0a979ef 100644 --- a/web/lib/instrument-colors.ts +++ b/web/lib/instrument-colors.ts @@ -69,6 +69,7 @@ export function formatColorMode(value: string): string { export const MEASUREMENT_TYPE_COLORS: Record = { Kinetic: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300", Endpoint: "bg-teal-100 text-teal-600 dark:bg-teal-900 dark:text-teal-400", + Spectrum: "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300", "Well Scan": "bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300", }; diff --git a/web/lib/runs/extract-plate-maps.ts b/web/lib/runs/extract-plate-maps.ts new file mode 100644 index 0000000..c8fb722 --- /dev/null +++ b/web/lib/runs/extract-plate-maps.ts @@ -0,0 +1,270 @@ +import type { PlateWellData } from "@/components/runs/plate-map-grid"; +import type { RawWellRow } from "@/lib/api/instrument-runs"; +import { sortTimeKeys } from "@/lib/runs/sort-kinetic-time-keys"; + +/** + * CSV parsing (csv-parse) returns all cell values as strings. The heatmap grid + * relies on `typeof value === "number"` to apply the Plasma colorscale, so we + * coerce numeric-looking strings (e.g. "0.649") to real numbers at the + * PlateWellData boundary. + */ +export function coerceNumeric(value: unknown): unknown { + if (typeof value === "number") { + return value; + } + if (typeof value !== "string" || value === "") { + return value; + } + const n = Number(value); + return Number.isFinite(n) ? n : value; +} + +export type PlateMapGroup = + | { + mode: "static"; + plateName: string; + wavelength: string; + wells: PlateWellData[]; + } + | { + mode: "kinetic"; + plateName: string; + wavelength: string; + frameLabels: string[]; + frames: PlateWellData[][]; + sliderAxis?: "time" | "wavelength"; + }; + +/** + * Groups kinetic CSV rows into time-indexed plate map frames, one group per + * unique plate + wavelength combination. Each group becomes either a single + * static plate map (if only one time-point exists) or a kinetic slider with + * one frame per time-point. + */ +export function extractKineticPlateMapGroups( + rows: RawWellRow[], + wellKey: "well_position" | "well" +): PlateMapGroup[] { + const byPlateWave = new Map(); + for (const row of rows) { + const pw = `${row.plate_name ?? ""}|${row.wavelength ?? ""}`; + const arr = byPlateWave.get(pw) ?? []; + arr.push(row); + byPlateWave.set(pw, arr); + } + + const results: PlateMapGroup[] = []; + for (const [pw, subset] of byPlateWave) { + const byTime = new Map(); + for (const row of subset) { + const tk = String(row.time ?? ""); + const g = byTime.get(tk) ?? []; + g.push(row); + byTime.set(tk, g); + } + + const [plateName = "", wavelength = ""] = pw.split("|"); + + if (byTime.size < 2) { + const flat = [...byTime.values()].flat(); + results.push({ + mode: "static", + plateName, + wavelength, + wells: flat.map((r) => ({ + well: String(r[wellKey]), + value: coerceNumeric(r.value), + })), + }); + continue; + } + + const timeKeysSorted = sortTimeKeys([...byTime.keys()]); + const frames = timeKeysSorted.map((tk) => + (byTime.get(tk) ?? []).map((r) => ({ + well: String(r[wellKey]), + value: coerceNumeric(r.value), + })) + ); + results.push({ + mode: "kinetic", + plateName, + wavelength, + frameLabels: timeKeysSorted, + frames, + }); + } + return results; +} + +/** + * Groups Spectrum CSV rows into wavelength-indexed plate map frames, one + * group per plate. A trailing Endpoint block in the same file becomes a + * single-frame (static) map. + */ +export function extractSpectrumPlateMapGroups( + rows: RawWellRow[], + wellKey: "well_position" | "well" +): PlateMapGroup[] { + const byPlate = new Map(); + for (const row of rows) { + const plate = String(row.plate_name ?? ""); + const arr = byPlate.get(plate) ?? []; + arr.push(row); + byPlate.set(plate, arr); + } + + const results: PlateMapGroup[] = []; + for (const [plateName, subset] of byPlate) { + const byWavelength = new Map(); + for (const row of subset) { + const wk = String(row.wavelength ?? ""); + const g = byWavelength.get(wk) ?? []; + g.push(row); + byWavelength.set(wk, g); + } + + if (byWavelength.size < 2) { + const flat = [...byWavelength.values()].flat(); + results.push({ + mode: "static", + plateName, + wavelength: [...byWavelength.keys()][0] ?? "", + wells: flat.map((r) => ({ + well: String(r[wellKey]), + value: coerceNumeric(r.value), + })), + }); + continue; + } + + const wavelengthKeys = sortTimeKeys([...byWavelength.keys()]); + const frames = wavelengthKeys.map((wk) => + (byWavelength.get(wk) ?? []).map((r) => ({ + well: String(r[wellKey]), + value: coerceNumeric(r.value), + })) + ); + results.push({ + mode: "kinetic", + plateName, + wavelength: "", + frameLabels: wavelengthKeys, + frames, + sliderAxis: "wavelength", + }); + } + return results; +} + +/** + * True when at least one plate has multiple wavelengths and no row has a + * time value. Used so an Endpoint-first mixed file still gets a wavelength + * slider even though run metadata reports the first plate's type. + */ +export function rowsLookLikeSpectrumScan(rows: RawWellRow[]): boolean { + if (rows.some((r) => String(r.time ?? "") !== "")) { + return false; + } + const byPlate = new Map>(); + for (const row of rows) { + const plate = String(row.plate_name ?? ""); + const set = byPlate.get(plate) ?? new Set(); + const wl = String(row.wavelength ?? ""); + if (wl !== "") { + set.add(wl); + } + byPlate.set(plate, set); + } + return [...byPlate.values()].some((s) => s.size > 1); +} + +/** + * Converts flat CSV rows into renderable plate map groups. + * + * Strategy: + * - Kinetic with multiple time-points → time-slider groups (one per plate+wavelength) + * - Spectrum, or well data that looks like a wavelength scan → wavelength-slider groups + * - Single combination of (plate, wavelength, time) → one unlabelled static map + * - Multiple combinations → separate labelled static maps (e.g. multi-wavelength endpoint) + * + * The CSV may use either "well_position" (SpectraMax) or "well" as the column + * name for well coordinates — we auto-detect from the first row. + */ +export function extractPlateMaps( + rows: RawWellRow[], + options: { kinetic: boolean; spectrum: boolean } +): PlateMapGroup[] { + if (rows.length === 0) { + return []; + } + + const wellKey = + rows[0].well_position === undefined ? "well" : "well_position"; + if (rows[0][wellKey] === undefined) { + return []; + } + + const uniqueTimes = new Set(rows.map((r) => String(r.time ?? ""))); + const hasTimeVariation = uniqueTimes.size > 1; + + if (options.kinetic && hasTimeVariation) { + return extractKineticPlateMapGroups( + rows, + wellKey as "well_position" | "well" + ); + } + + if (options.spectrum || rowsLookLikeSpectrumScan(rows)) { + return extractSpectrumPlateMapGroups( + rows, + wellKey as "well_position" | "well" + ); + } + + const hasMultiple = + new Set(rows.map((r) => `${r.plate_name}|${r.wavelength}|${r.time}`)).size > + 1; + + if (!hasMultiple) { + return [ + { + mode: "static", + plateName: "", + wavelength: "", + wells: rows.map((r) => ({ + well: String(r[wellKey]), + value: coerceNumeric(r.value), + })), + }, + ]; + } + + const grouped = new Map(); + for (const row of rows) { + const key = `${row.plate_name ?? ""}|${row.wavelength ?? ""}|${row.time ?? ""}`; + const group = grouped.get(key) ?? []; + group.push(row); + grouped.set(key, group); + } + + return Array.from(grouped.entries()).map(([key, group]) => { + const [plate = "", wavelength = "", time = ""] = key.split("|"); + const titleParts: string[] = []; + if (plate) { + titleParts.push(plate); + } + if (time) { + titleParts.push(`t=${time}`); + } + return { + mode: "static" as const, + plateName: titleParts.join(" · "), + wavelength, + wells: group.map((r) => ({ + well: String(r[wellKey]), + value: coerceNumeric(r.value), + })), + }; + }); +} diff --git a/web/tests/unit/extract-plate-maps.test.ts b/web/tests/unit/extract-plate-maps.test.ts new file mode 100644 index 0000000..68696e4 --- /dev/null +++ b/web/tests/unit/extract-plate-maps.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { extractPlateMaps } from "@/lib/runs/extract-plate-maps"; + +describe("extractPlateMaps", () => { + it("uses a wavelength slider when metadata is Endpoint but rows are a scan", () => { + const rows = [ + { + plate_name: "Plate1", + well_position: "A1", + wavelength: "595", + value: "0.1", + }, + { + plate_name: "Plate1 (440–450)", + well_position: "A1", + wavelength: "440", + value: "1.0", + }, + { + plate_name: "Plate1 (440–450)", + well_position: "A1", + wavelength: "445", + value: "1.1", + }, + { + plate_name: "Plate1 (440–450)", + well_position: "A1", + wavelength: "450", + value: "1.2", + }, + ]; + + const groups = extractPlateMaps(rows, { kinetic: false, spectrum: false }); + + expect(groups).toHaveLength(2); + expect(groups[0]).toMatchObject({ + mode: "static", + plateName: "Plate1", + wavelength: "595", + }); + expect(groups[1]).toMatchObject({ + mode: "kinetic", + plateName: "Plate1 (440–450)", + sliderAxis: "wavelength", + frameLabels: ["440", "445", "450"], + }); + }); + + it("groups Spectrum plates by wavelength when metadata says Spectrum", () => { + const rows = [ + { + plate_name: "Plate1", + well_position: "A1", + wavelength: "440", + value: "1.0", + }, + { + plate_name: "Plate1", + well_position: "A2", + wavelength: "440", + value: "1.1", + }, + { + plate_name: "Plate1", + well_position: "A1", + wavelength: "445", + value: "1.2", + }, + { + plate_name: "Plate1", + well_position: "A2", + wavelength: "445", + value: "1.3", + }, + ]; + + const groups = extractPlateMaps(rows, { kinetic: false, spectrum: true }); + + expect(groups).toEqual([ + expect.objectContaining({ + mode: "kinetic", + plateName: "Plate1", + sliderAxis: "wavelength", + frameLabels: ["440", "445"], + }), + ]); + expect(groups[0]?.mode === "kinetic" && groups[0].frames).toHaveLength(2); + }); + + it("keeps kinetic time sliders when time varies", () => { + const rows = [ + { + plate_name: "Plate1", + well_position: "A1", + wavelength: "595", + time: "00:00:00", + value: "0.1", + }, + { + plate_name: "Plate1", + well_position: "A1", + wavelength: "595", + time: "00:15:00", + value: "0.2", + }, + ]; + + const groups = extractPlateMaps(rows, { kinetic: true, spectrum: false }); + const group = groups[0]; + expect(group?.mode).toBe("kinetic"); + if (group?.mode !== "kinetic") { + return; + } + expect(group).toMatchObject({ + plateName: "Plate1", + wavelength: "595", + frameLabels: ["00:00:00", "00:15:00"], + }); + expect(group.sliderAxis).toBeUndefined(); + }); +}); diff --git a/web/tests/unit/sort-wavelengths.test.ts b/web/tests/unit/sort-wavelengths.test.ts new file mode 100644 index 0000000..0ab71df --- /dev/null +++ b/web/tests/unit/sort-wavelengths.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { sortWavelengths } from "@/components/runs/metadata-badges"; + +describe("sortWavelengths", () => { + it("sorts numeric wavelengths ascending", () => { + expect(sortWavelengths(["750", "600", "650"])).toEqual([ + "600", + "650", + "750", + ]); + }); + + it("sorts Spectrum range tokens by start nm", () => { + expect(sortWavelengths(["480–500", "430–440", "440–450"])).toEqual([ + "430–440", + "440–450", + "480–500", + ]); + }); + + it("places range tokens among discrete wavelengths by start", () => { + expect(sortWavelengths(["595", "440–450", "400"])).toEqual([ + "400", + "440–450", + "595", + ]); + }); +});