From 3aa97013fe0e67cae51dbfc99fba920218b5b721 Mon Sep 17 00:00:00 2001 From: Wasim Amiri <7220175+wasimxyz@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:10:51 -0700 Subject: [PATCH 1/2] Parse SpectraMax Spectrum wavelength scans instead of failing ingestion. SoftMax Spectrum exports were rejected as an unknown measurement type. Accept them, map the scan axis onto wavelength, and show plate maps with a wavelength slider. Co-authored-by: Cursor --- developer-docs/local-development.md | 2 +- .../spectramax_plate_reader/utils.py | 160 ++++++++++++++---- .../spectramax_plate_reader_spectrum.xls | Bin 0 -> 4034 bytes .../spectramax_plate_reader_spectrum_384.xls | Bin 0 -> 10736 bytes .../test_parse_metadata.py | 35 +++- .../test_parse_raw_well_data.py | 121 +++++++++++++ web/components/runs/plate-map-grid.tsx | 19 ++- .../runs/variants/plate-reader-run-detail.tsx | 87 +++++++++- web/lib/db/seed.ts | 24 ++- web/lib/instrument-colors.ts | 1 + 10 files changed, 401 insertions(+), 48 deletions(-) create mode 100644 lambda/tests/fixtures/spectramax_plate_reader_spectrum.xls create mode 100644 lambda/tests/fixtures/spectramax_plate_reader_spectrum_384.xls diff --git a/developer-docs/local-development.md b/developer-docs/local-development.md index a7c2c64c..938c061c 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 6c95cca3..762d0dff 100644 --- a/lambda/src/data_hub_lambda/spectramax_plate_reader/utils.py +++ b/lambda/src/data_hub_lambda/spectramax_plate_reader/utils.py @@ -9,10 +9,14 @@ 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``. +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 readings in the plate header than it exports (e.g. a 48 h protocol stopped early). The parser emits the @@ -28,7 +32,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 +45,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 @@ -108,6 +115,16 @@ def _parse_wavelengths(raw: str) -> tuple[int, ...]: return tuple(int(t) for t in tokens) +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 +138,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 +174,71 @@ 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": + start_s = fields[raw_idx + _OFF_SPECTRUM_START].strip() + end_s = fields[raw_idx + _OFF_SPECTRUM_END].strip() + step_s = fields[raw_idx + _OFF_SPECTRUM_STEP].strip() + if start_s.isdigit() and end_s.isdigit() and step_s.isdigit(): + spectrum_start = int(start_s) + spectrum_end = int(end_s) + spectrum_step = int(step_s) + 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" + and header.spectrum_start is not None + and header.spectrum_end is not None + and header.spectrum_step is not None + ): + 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 _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. + """ + name = header.plate_name + if name not in seen_names: + seen_names.add(name) + return name + if header.spectrum_start is not None and header.spectrum_end is not None: + return f"{name} ({header.spectrum_start}–{header.spectrum_end})" + if header.wavelength_raw: + return f"{name} ({header.wavelength_raw.split()[0]})" + return f"{name} ({len(seen_names) + 1})" + + _WELL_DATA_COLUMNS = [ "time", "plate_name", @@ -245,15 +320,17 @@ 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 sorted union across every plate so a file + that mixes Spectrum windows (or a trailing Endpoint) is not + truncated to the first block. Wavelengths are numeric strings + (without the ``nm`` suffix) to mirror other multi-wavelength + instruments (e.g. Azure 600 Gel Doc). Example:: { "measurement_mode": "Absorbance", "measurement_type": "Endpoint", - "wavelengths": ["750", "700", "650", "600"], + "wavelengths": ["600", "650", "700", "750"], } Raises: @@ -262,6 +339,9 @@ def parse_metadata(file_path: Path) -> dict[str, object]: """ text = file_path.read_text(encoding="utf-16") + first_header: _PlateHeader | None = None + wavelength_set: set[int] = set() + for line in text.splitlines(): if not line.startswith("Plate:"): continue @@ -278,15 +358,18 @@ def parse_metadata(file_path: Path) -> dict[str, object]: f"Unexpected measurement type '{header.measurement_type}'; " f"expected one of {sorted(MEASUREMENT_TYPES)}" ) - wavelengths = _parse_wavelengths(header.wavelength_raw) + wavelength_set.update(_header_wavelengths(header)) + if first_header is None: + first_header = header - return { - "measurement_mode": header.measurement_mode, - "measurement_type": header.measurement_type, - "wavelengths": [str(w) for w in wavelengths], - } + if first_header is None: + raise ValueError(f"No 'Plate:' header line found in {file_path}") - 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": [str(w) for w in sorted(wavelength_set)], + } def parse_raw_well_data(file_path: Path) -> pd.DataFrame: @@ -305,7 +388,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 +398,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 +410,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 +419,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,7 +435,7 @@ 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 @@ -357,10 +445,15 @@ def parse_raw_well_data(file_path: Path) -> pd.DataFrame: 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 + wl = int(col0) if col0.isdigit() 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 +463,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), @@ -404,6 +497,7 @@ def parse_raw_well_data(file_path: Path) -> pd.DataFrame: 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 +505,23 @@ 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: + if group_wavelength is None and col0.isdigit(): + group_wavelength = int(col0) + 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 +531,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 0000000000000000000000000000000000000000..e643e91e6d668d59eb23e76e890ed86519ecc21f GIT binary patch literal 4034 zcmd^CO>fgc5S?rCD=hT_m$tSOCkAmSAGuJ}igJ%!Y9XXFpp*b7{tJH;cyGqT?lx_u zaERof)!Kf$v$MNzXU8+Ye;vqyJeN~BlNa(@F65bHQp!-yWg(4xkyf6_AVN7%E{Bkb zTW9h~R&uQ)2GH^uMh29Q|Zvgd00j$ z$NC=Yd)&3#dJ3RgDy(}T@1Xr!SFqCctg(t#9?4Ja@G<&1g7VZnLZzk(b%HuYtxmA2*}bnS^(6hi zyAAKsSJ?_SZOPi*mSR+bC)%IeQjEr3Uus$VX;&lcl_z!=cfmImos_Xf?n0kyus(9< z77+N`SMKazxXInN|48S~#s%K~XMgr@vDb-QBIaUzc8Pe%sYE5=;uEV&%P*BYx15S?rCD^}_SE@?O4DdJE*dZDNlpyO%74oO!JK88k(_O>YbVcct4fb$n_5PUp04pM?d^^ zP^&$CcJ*26othF?6WSW;)YrP#z8Y?mB%Hcv1w?K z^Bv{Tx;Ss81mvW>~DL~18es72tsFQu2{>?hK|N#uOABj-)>UouVSUheV8Xgm%y z>hc|HnM*mpV-3Ys0lE0E*HCSEDmxe6Pjx!?PLEASBctIF@M+_! zt>$lYHLm%|RWGfwNr*rT0*km*^8Xf_kCRhL8wFXBA&bvDpDQy1DJAa$F)(EYM zW1{~OtE87P+14HXIZ`GKoWwmBLy?G&T(l?z55`cGh~I z^HksRaGu(qmmT4KkAJ6wgnS(o(#%z2CsMa@BN3!y~o5Xsl};VdoxKi1V7@1mp()#|LtTcrV6= zogoe@#C$D_^Wv%?{wQ_8Ws9*Q(-}j>^GJS$zZ2cXl5Ku((O(u>73beJmW0t{PfrV? z#GT71h4q5X8F*3}b-hIT^Vz@6JUEiEOzV~AhvQxJ6LOf2ryx=Hu(gffIZfTou!qxn zejKAOPvoQa@DZXK3q&wZ@MJ{e2r-9@mn_idV+17Mj^vvR@h-$v4mBQegj2#XQo|%a zBPNIFU5vSVhO%C11mhT~Bc!gus{-+oBl!Z`bM;orE*P?_|8 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": ["430", "435", "440", "445", "450", "480", "490", "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": [ + "430", + "440", + "450", + "500", + "510", + "520", + "540", + "550", + "560", + "595", + ], + } + # --------------------------------------------------------------------------- # Synthetic happy-path tests @@ -193,8 +220,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: @@ -208,7 +235,7 @@ def test_dual_wavelength(self, tmp_path: Path) -> None: assert result == { "measurement_mode": "Absorbance", "measurement_type": "Endpoint", - "wavelengths": ["750", "600"], + "wavelengths": ["600", "750"], } def test_four_wavelengths(self, tmp_path: Path) -> None: @@ -217,7 +244,7 @@ def test_four_wavelengths(self, tmp_path: Path) -> None: assert result == { "measurement_mode": "Absorbance", "measurement_type": "Endpoint", - "wavelengths": ["750", "700", "650", "600"], + "wavelengths": ["600", "650", "700", "750"], } def test_missing_plate_header(self, tmp_path: Path) -> None: 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 0cb44dcd..9d231b1a 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,6 +391,127 @@ def test_all_rows_populated(self) -> None: assert set(self.df["row_label"].unique()) == set("ABCDEFGH") +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. + """ + + @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, matching iD3 occupancy. + + Spectrum blocks use a 24-column grid with B1–B11 / A1–A5 / B8–B11; + the Endpoint block fills A1–A12 and B1–B12 (24 unique wells overall). + """ + + @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: + # 11×3 + 5×3 + 4×3 + 24 endpoint + assert self.df.shape == (84, 8) + + def test_time_is_null(self) -> None: + assert bool(self.df["time"].isna().all()) + + def test_unique_wells_match_id3(self) -> None: + wells = set(self.df["well_position"].unique().tolist()) + expected = {f"{r}{c}" for r in "AB" for c in range(1, 13)} + assert wells == expected + + def test_spectrum_extent_matches_id3(self) -> None: + spectrum = self.df.loc[self.df["plate_name"] != "Plate1 (595)"] + assert max(spectrum["column_label"].unique().tolist()) == 11 + 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, 12)} + assert len(first) == 33 + + 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 TestDualWavelength: """Endpoint / Absorbance / dual wavelength (750 + 600), synthetic 2×2 plate.""" diff --git a/web/components/runs/plate-map-grid.tsx b/web/components/runs/plate-map-grid.tsx index e5dce1e2..9fba0ed1 100644 --- a/web/components/runs/plate-map-grid.tsx +++ b/web/components/runs/plate-map-grid.tsx @@ -357,13 +357,15 @@ interface KineticPlateMapWithTimeSliderProps { frames: PlateWellData[][]; heatmap: boolean; plateName?: string; + sliderAxis?: "time" | "wavelength"; timeLabels: string[]; 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, @@ -371,6 +373,7 @@ export function KineticPlateMapWithTimeSlider({ heatmap, plateName, wavelength, + sliderAxis = "time", }: KineticPlateMapWithTimeSliderProps) { const [index, setIndex] = useState(0); const maxIdx = Math.max(0, frames.length - 1); @@ -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" + ? (timeLabels[selectedIndex] ?? wavelength) + : wavelength; const wide = plateColumnCount(frames[0] ?? []) > COMPACT_PLATE_MAX_COLS; @@ -400,7 +407,7 @@ export function KineticPlateMapWithTimeSlider({ heatmap={heatmap} heatmapRange={heatmapRange} plateName={plateName} - wavelength={wavelength} + wavelength={displayWavelength} /> {frames.length > 1 && (
@@ -416,7 +423,11 @@ export function KineticPlateMapWithTimeSlider({ {timeLabels[selectedIndex] ?? "—"}
(); + 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: "", + timeLabels: wavelengthKeys, + frames, + sliderAxis: "wavelength", + }); + } + 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) + * - Spectrum → wavelength-slider groups (one per plate) * - Single combination of (plate, wavelength, time) → one unlabelled static map * - Multiple combinations → separate labelled static maps (e.g. multi-wavelength endpoint) * @@ -129,7 +196,7 @@ function extractKineticPlateMapGroups( */ function extractPlateMaps( rows: RawWellRow[], - options: { kinetic: boolean } + options: { kinetic: boolean; spectrum: boolean } ): PlateMapGroup[] { if (rows.length === 0) { return []; @@ -142,6 +209,13 @@ function extractPlateMaps( return []; } + if (options.spectrum) { + return extractSpectrumPlateMapGroups( + rows, + wellKey as "well_position" | "well" + ); + } + const uniqueTimes = new Set(rows.map((r) => String(r.time ?? ""))); const hasTimeVariation = uniqueTimes.size > 1; @@ -205,12 +279,17 @@ 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; @@ -229,6 +308,7 @@ function PlateMapSection({ heatmap={heatmap} key={i} plateName={g.plateName} + sliderAxis={g.sliderAxis} timeLabels={g.timeLabels} wavelength={g.wavelength} /> @@ -321,6 +401,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 46907c10..0ed09829 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 bf7b8d80..0a979efb 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", }; From 231b32c6aec93854a13444d5ba5733cc7074bd31 Mon Sep 17 00:00:00 2001 From: Wasim Amiri <7220175+wasimxyz@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:36:37 -0700 Subject: [PATCH 2/2] Harden SpectraMax Spectrum parsing so duplicate plates and mixed files stay distinct. Plate-name suffixes are now unique, incomplete Spectrum reads no longer crash, and wavelength sliders follow the well data instead of the first plate's type. Co-authored-by: Cursor --- .../spectramax_plate_reader/utils.py | 243 +++++++++++----- .../spectramax_plate_reader_spectrum_384.xls | Bin 10736 -> 11204 bytes .../test_parse_metadata.py | 70 ++++- .../test_parse_raw_well_data.py | 180 +++++++++++- web/components/runs/metadata-badges.tsx | 35 ++- web/components/runs/plate-map-grid.tsx | 18 +- web/components/runs/run-metadata-badges.tsx | 9 +- .../runs/variants/plate-reader-run-detail.tsx | 260 +---------------- web/lib/runs/extract-plate-maps.ts | 270 ++++++++++++++++++ web/tests/unit/extract-plate-maps.test.ts | 121 ++++++++ web/tests/unit/sort-wavelengths.test.ts | 28 ++ 11 files changed, 860 insertions(+), 374 deletions(-) create mode 100644 web/lib/runs/extract-plate-maps.ts create mode 100644 web/tests/unit/extract-plate-maps.test.ts create mode 100644 web/tests/unit/sort-wavelengths.test.ts 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 762d0dff..fdd20849 100644 --- a/lambda/src/data_hub_lambda/spectramax_plate_reader/utils.py +++ b/lambda/src/data_hub_lambda/spectramax_plate_reader/utils.py @@ -18,13 +18,16 @@ 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 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``. +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 @@ -74,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)) @@ -89,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. @@ -107,12 +146,27 @@ 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, ...]: @@ -179,13 +233,9 @@ def _parse_plate_header(line: str) -> _PlateHeader: spectrum_end: int | None = None spectrum_step: int | None = None if measurement_type == "Spectrum": - start_s = fields[raw_idx + _OFF_SPECTRUM_START].strip() - end_s = fields[raw_idx + _OFF_SPECTRUM_END].strip() - step_s = fields[raw_idx + _OFF_SPECTRUM_STEP].strip() - if start_s.isdigit() and end_s.isdigit() and step_s.isdigit(): - spectrum_start = int(start_s) - spectrum_end = int(end_s) - spectrum_step = int(step_s) + 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], @@ -209,34 +259,80 @@ def _header_wavelengths(header: _PlateHeader) -> tuple[int, ...]: """ if header.wavelength_raw: return _parse_wavelengths(header.wavelength_raw) - if ( - header.measurement_type == "Spectrum" - and header.spectrum_start is not None - and header.spectrum_end is not None - and header.spectrum_step is not None - ): + 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. + 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 - if header.spectrum_start is not None and header.spectrum_end is not None: - return f"{name} ({header.spectrum_start}–{header.spectrum_end})" - if header.wavelength_raw: - return f"{name} ({header.wavelength_raw.split()[0]})" - return f"{name} ({len(seen_names) + 1})" + suffix = _plate_name_suffix(header) + preferred = f"{name} ({suffix})" if suffix else name + return _allocate_unique_name(preferred, seen_names) _WELL_DATA_COLUMNS = [ @@ -321,46 +417,65 @@ def parse_metadata(file_path: Path) -> dict[str, object]: Returns: A dict with keys `measurement_mode`, `measurement_type`, and `wavelengths`. Type and mode come from the first plate; - wavelengths are the sorted union across every plate so a file - that mixes Spectrum windows (or a trailing Endpoint) is not - truncated to the first block. Wavelengths are numeric strings - (without the ``nm`` suffix) to mirror other multi-wavelength - instruments (e.g. Azure 600 Gel Doc). Example:: + 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", "measurement_type": "Endpoint", - "wavelengths": ["600", "650", "700", "750"], + "wavelengths": ["750", "700", "650", "600"], } 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 - wavelength_set: set[int] = set() + 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)}" - ) - wavelength_set.update(_header_wavelengths(header)) 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 + + 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) if first_header is None: raise ValueError(f"No 'Plate:' header line found in {file_path}") @@ -368,7 +483,7 @@ def parse_metadata(file_path: Path) -> dict[str, object]: return { "measurement_mode": first_header.measurement_mode, "measurement_type": first_header.measurement_type, - "wavelengths": [str(w) for w in sorted(wavelength_set)], + "wavelengths": wavelengths, } @@ -437,11 +552,7 @@ def parse_raw_well_data(file_path: Path) -> pd.DataFrame: if layout.format == "flat": 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") @@ -450,7 +561,8 @@ def parse_raw_well_data(file_path: Path) -> pd.DataFrame: temp_val: float | None = float(temp_str) if temp_str else None if is_spectrum: time_val = None - wl = int(col0) if col0.isdigit() else header_wl + 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 @@ -488,11 +600,7 @@ 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 @@ -508,8 +616,9 @@ def parse_raw_well_data(file_path: Path) -> pd.DataFrame: # Spectrum stores wavelength in the same column-0 slot. col0 = row_fields[0].strip() if row_fields else "" if is_spectrum: - if group_wavelength is None and col0.isdigit(): - group_wavelength = int(col0) + 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(): diff --git a/lambda/tests/fixtures/spectramax_plate_reader_spectrum_384.xls b/lambda/tests/fixtures/spectramax_plate_reader_spectrum_384.xls index 9fc09588c9b4e7cc0e47c01183d3175fe7677d83..ed34dbef112b54940c3fe9e52f042e0339af699b 100644 GIT binary patch delta 948 zcmb`_J&M9W7{>9Ju~Ia>jbOJnOEQ_6WDX%LSO{Bs15c1cOy?;C`(+C&?;v;rv0oN^ zNAvMM2!dcC_=6hr&-9ArQi`+^yBI*Opr-mog+}K@L=aN{|1i_^O@H_z-pT@kHM>;JhYKUh$sXc}g$!KFS{vmin6w~AuEsM<`Bo;AkzQ8KQ2 None: assert result == { "measurement_mode": "Fluorescence", "measurement_type": "Spectrum", - "wavelengths": ["430", "435", "440", "445", "450", "480", "490", "500"], + "wavelengths": ["440–450", "430–440", "480–500"], } def test_spectrum_384_unions_windows_and_endpoint(self) -> None: @@ -143,18 +143,7 @@ def test_spectrum_384_unions_windows_and_endpoint(self) -> None: assert result == { "measurement_mode": "Fluorescence", "measurement_type": "Spectrum", - "wavelengths": [ - "430", - "440", - "450", - "500", - "510", - "520", - "540", - "550", - "560", - "595", - ], + "wavelengths": ["540–560", "430–450", "500–520", "595"], } @@ -235,7 +224,7 @@ def test_dual_wavelength(self, tmp_path: Path) -> None: assert result == { "measurement_mode": "Absorbance", "measurement_type": "Endpoint", - "wavelengths": ["600", "750"], + "wavelengths": ["750", "600"], } def test_four_wavelengths(self, tmp_path: Path) -> None: @@ -244,7 +233,7 @@ def test_four_wavelengths(self, tmp_path: Path) -> None: assert result == { "measurement_mode": "Absorbance", "measurement_type": "Endpoint", - "wavelengths": ["600", "650", "700", "750"], + "wavelengths": ["750", "700", "650", "600"], } def test_missing_plate_header(self, tmp_path: Path) -> None: @@ -265,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 9d231b1a..17578294 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 @@ -396,7 +396,9 @@ class TestSpectrum: 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. + 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) @@ -459,10 +461,13 @@ def test_wells(self) -> None: class TestSpectrum384: - """384-well Spectrum + trailing 96-well Endpoint, matching iD3 occupancy. + """384-well Spectrum + trailing 96-well Endpoint. - Spectrum blocks use a 24-column grid with B1–B11 / A1–A5 / B8–B11; - the Endpoint block fills A1–A12 and B1–B12 (24 unique wells overall). + 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) @@ -473,20 +478,20 @@ def test_columns(self) -> None: assert list(self.df.columns) == _EXPECTED_COLUMNS def test_shape(self) -> None: - # 11×3 + 5×3 + 4×3 + 24 endpoint - assert self.df.shape == (84, 8) + # 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_match_id3(self) -> None: + def test_unique_wells(self) -> None: wells = set(self.df["well_position"].unique().tolist()) - expected = {f"{r}{c}" for r in "AB" for c in range(1, 13)} + 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_matches_id3(self) -> None: + 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()) == 11 + assert max(spectrum["column_label"].unique().tolist()) == 24 assert set(spectrum["row_label"].unique().tolist()) == {"A", "B"} def test_plate_names_disambiguated(self) -> None: @@ -499,8 +504,8 @@ def test_plate_names_disambiguated(self) -> None: 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, 12)} - assert len(first) == 33 + 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)"] @@ -512,7 +517,156 @@ def test_trailing_endpoint(self) -> None: } -class TestDualWavelength: +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 44dde1ac..57b0953d 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 9fba0ed1..22e25739 100644 --- a/web/components/runs/plate-map-grid.tsx +++ b/web/components/runs/plate-map-grid.tsx @@ -353,12 +353,12 @@ function computeGlobalHeatmapRange( return { min, max }; } -interface KineticPlateMapWithTimeSliderProps { +interface PlateMapWithIndexSliderProps { + frameLabels: string[]; frames: PlateWellData[][]; heatmap: boolean; plateName?: string; sliderAxis?: "time" | "wavelength"; - timeLabels: string[]; wavelength?: string; } @@ -367,14 +367,14 @@ interface KineticPlateMapWithTimeSliderProps { * 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, sliderAxis = "time", -}: KineticPlateMapWithTimeSliderProps) { +}: PlateMapWithIndexSliderProps) { const [index, setIndex] = useState(0); const maxIdx = Math.max(0, frames.length - 1); const selectedIndex = Math.min(Math.max(0, index), maxIdx); @@ -392,7 +392,7 @@ export function KineticPlateMapWithTimeSlider({ const thumbPercent = maxIdx === 0 ? 0 : (selectedIndex / maxIdx) * 100; const displayWavelength = sliderAxis === "wavelength" - ? (timeLabels[selectedIndex] ?? wavelength) + ? (frameLabels[selectedIndex] ?? wavelength) : wavelength; const wide = plateColumnCount(frames[0] ?? []) > COMPACT_PLATE_MAX_COLS; @@ -412,7 +412,7 @@ export function KineticPlateMapWithTimeSlider({ {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 f38fbc6a..f5109b05 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 766e2b84..c8606300 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,7 +13,7 @@ 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([ @@ -24,257 +23,6 @@ const HEATMAP_MEASUREMENT_TYPES = new Set([ "Spectrum", ]); -/** - * 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[][]; - 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. - */ -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; -} - -/** - * 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. - */ -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: "", - timeLabels: wavelengthKeys, - frames, - sliderAxis: "wavelength", - }); - } - 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) - * - Spectrum → wavelength-slider groups (one per plate) - * - 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; spectrum: 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 []; - } - - if (options.spectrum) { - return extractSpectrumPlateMapGroups( - rows, - wellKey as "well_position" | "well" - ); - } - - 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), - })), - }; - }); -} - function PlateMapSection({ rows, heatmap, @@ -303,13 +51,13 @@ function PlateMapSection({
{groups.map((g, i) => g.mode === "kinetic" ? ( - ) : ( diff --git a/web/lib/runs/extract-plate-maps.ts b/web/lib/runs/extract-plate-maps.ts new file mode 100644 index 00000000..c8fb7227 --- /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 00000000..68696e4e --- /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 00000000..0ab71dfd --- /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", + ]); + }); +});