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 06e962f5..608fb7e4 100644 --- a/lambda/src/data_hub_lambda/spectramax_plate_reader/utils.py +++ b/lambda/src/data_hub_lambda/spectramax_plate_reader/utils.py @@ -183,17 +183,20 @@ def _parse_column_layout(col_header_line: str) -> _ColumnLayout: raise ValueError("Could not determine column layout from header row") -def parse_metadata(file_path: Path) -> dict[str, str]: +def parse_metadata(file_path: Path) -> dict[str, object]: """Extract measurement metadata from a SpectraMax `.xls` file. Returns: A dict with keys `measurement_mode`, `measurement_type`, and - `wavelength`. Example:: + `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:: { "measurement_mode": "Absorbance", "measurement_type": "Endpoint", - "wavelength": "750 nm", + "wavelengths": ["750", "700", "650", "600"], } Raises: @@ -223,7 +226,7 @@ def parse_metadata(file_path: Path) -> dict[str, str]: return { "measurement_mode": header.measurement_mode, "measurement_type": header.measurement_type, - "wavelength": ", ".join(f"{w} nm" for w in wavelengths), + "wavelengths": [str(w) for w in wavelengths], } raise ValueError(f"No 'Plate:' header line found in {file_path}") diff --git a/lambda/tests/integration/test_lambda_api.py b/lambda/tests/integration/test_lambda_api.py index 7d0305c3..1ab80cb3 100644 --- a/lambda/tests/integration/test_lambda_api.py +++ b/lambda/tests/integration/test_lambda_api.py @@ -111,7 +111,7 @@ class TestSpectraMaxHappyPath: { "measurement_mode": "Absorbance", "measurement_type": "Endpoint", - "wavelength": "750 nm", + "wavelengths": ["750"], }, id="endpoint", ), @@ -121,7 +121,7 @@ class TestSpectraMaxHappyPath: { "measurement_mode": "Absorbance", "measurement_type": "Well Scan", - "wavelength": "595 nm", + "wavelengths": ["595"], }, id="well-scan", ), @@ -131,7 +131,7 @@ class TestSpectraMaxHappyPath: { "measurement_mode": "Absorbance", "measurement_type": "Kinetic", - "wavelength": "595 nm", + "wavelengths": ["595"], }, id="kinetic", ), @@ -147,7 +147,7 @@ def test_xls_completes_with_processed_csv( mock_s3_upload: MagicMock, fixture_file: str, run_id: str, - expected_metadata: dict[str, str], + expected_metadata: dict[str, object], ) -> None: filename = f"{run_id}.xls" s3_key = f"spectramax-id3-plate-reader/{run_id}/{filename}" diff --git a/lambda/tests/spectramax_plate_reader/test_parse_metadata.py b/lambda/tests/spectramax_plate_reader/test_parse_metadata.py index b90bfa2d..02464cb3 100644 --- a/lambda/tests/spectramax_plate_reader/test_parse_metadata.py +++ b/lambda/tests/spectramax_plate_reader/test_parse_metadata.py @@ -77,7 +77,7 @@ def test_endpoint_absorbance(self) -> None: assert result == { "measurement_mode": "Absorbance", "measurement_type": "Endpoint", - "wavelength": "750 nm", + "wavelengths": ["750"], } def test_well_scan_absorbance(self) -> None: @@ -85,7 +85,7 @@ def test_well_scan_absorbance(self) -> None: assert result == { "measurement_mode": "Absorbance", "measurement_type": "Well Scan", - "wavelength": "595 nm", + "wavelengths": ["595"], } def test_endpoint_fluorescence(self) -> None: @@ -93,7 +93,7 @@ def test_endpoint_fluorescence(self) -> None: assert result == { "measurement_mode": "Fluorescence", "measurement_type": "Endpoint", - "wavelength": "512 nm", + "wavelengths": ["512"], } def test_endpoint_sparse_absorbance(self) -> None: @@ -101,7 +101,7 @@ def test_endpoint_sparse_absorbance(self) -> None: assert result == { "measurement_mode": "Absorbance", "measurement_type": "Endpoint", - "wavelength": "600 nm", + "wavelengths": ["600"], } def test_kinetic_absorbance(self) -> None: @@ -109,7 +109,7 @@ def test_kinetic_absorbance(self) -> None: assert result == { "measurement_mode": "Absorbance", "measurement_type": "Kinetic", - "wavelength": "595 nm", + "wavelengths": ["595"], } def test_endpoint_flat(self) -> None: @@ -117,7 +117,7 @@ def test_endpoint_flat(self) -> None: assert result == { "measurement_mode": "Absorbance", "measurement_type": "Endpoint", - "wavelength": "595 nm", + "wavelengths": ["595"], } @@ -138,7 +138,7 @@ def test_absorbance_endpoint(self, tmp_path: Path) -> None: assert result == { "measurement_mode": "Absorbance", "measurement_type": "Endpoint", - "wavelength": "750 nm", + "wavelengths": ["750"], } def test_fluorescence_kinetic(self, tmp_path: Path) -> None: @@ -152,7 +152,7 @@ def test_fluorescence_kinetic(self, tmp_path: Path) -> None: assert result == { "measurement_mode": "Fluorescence", "measurement_type": "Kinetic", - "wavelength": "488 nm", + "wavelengths": ["488"], } def test_reduced_anchor(self, tmp_path: Path) -> None: @@ -167,7 +167,7 @@ def test_reduced_anchor(self, tmp_path: Path) -> None: assert result == { "measurement_mode": "Absorbance", "measurement_type": "Endpoint", - "wavelength": "600 nm", + "wavelengths": ["600"], } @@ -198,7 +198,16 @@ def test_dual_wavelength(self, tmp_path: Path) -> None: assert result == { "measurement_mode": "Absorbance", "measurement_type": "Endpoint", - "wavelength": "750 nm, 600 nm", + "wavelengths": ["750", "600"], + } + + def test_four_wavelengths(self, tmp_path: Path) -> None: + path = _build_xls(tmp_path, wavelength="750 700 650 600") + result = parse_metadata(path) + assert result == { + "measurement_mode": "Absorbance", + "measurement_type": "Endpoint", + "wavelengths": ["750", "700", "650", "600"], } def test_missing_plate_header(self, tmp_path: Path) -> None: diff --git a/web-app/components/instruments/runs-table/gel-doc-runs-table.tsx b/web-app/components/instruments/runs-table/gel-doc-runs-table.tsx index 5ab1bb66..c0ba214c 100644 --- a/web-app/components/instruments/runs-table/gel-doc-runs-table.tsx +++ b/web-app/components/instruments/runs-table/gel-doc-runs-table.tsx @@ -21,10 +21,11 @@ import type { RunRow } from "."; import { ClickableRow } from "./clickable-row"; import { FilterableColumnHeader } from "./filterable-column-header"; import { - MetadataArrayBadges, MetadataFieldBadge, + TruncatedBadges, getMetadataArray, getMetadataField, + sortWavelengths, } from "./metadata-utils"; import { RanByCell } from "./ran-by-cell"; import { RunSelectAllCheckbox, RunSelectCheckbox } from "./run-select-checkbox"; @@ -46,6 +47,7 @@ export function GelDocRunsTable({ getMetadataArray(row.metadata, "wavelengths") ); const wavelengthColors = buildWavelengthColorMap(allWavelengths); + const sortedWavelengthOptions = sortWavelengths(filterOptions.wavelengths); const runRefs: RunRef[] = data.map((row) => ({ id: row.id, instrumentId: row.instrument_id, @@ -80,7 +82,7 @@ export function GelDocRunsTable({ @@ -105,7 +107,9 @@ export function GelDocRunsTable({ const isDeleted = row.deleted_at !== null; const captureType = getMetadataField(row.metadata, "capture_type"); const imagingMode = getMetadataField(row.metadata, "imaging_mode"); - const wavelengths = getMetadataArray(row.metadata, "wavelengths"); + const wavelengths = sortWavelengths( + getMetadataArray(row.metadata, "wavelengths") + ); const wavelengthColorLabels = getMetadataArray( row.metadata, "colors" @@ -166,15 +170,17 @@ export function GelDocRunsTable({ /> - - diff --git a/web-app/components/instruments/runs-table/metadata-utils.tsx b/web-app/components/instruments/runs-table/metadata-utils.tsx index 2533fa43..8119136a 100644 --- a/web-app/components/instruments/runs-table/metadata-utils.tsx +++ b/web-app/components/instruments/runs-table/metadata-utils.tsx @@ -1,6 +1,8 @@ export { MetadataArrayBadges, MetadataFieldBadge, + TruncatedBadges, getMetadataArray, getMetadataField, + sortWavelengths, } from "@/components/runs/metadata-badges"; diff --git a/web-app/components/instruments/runs-table/plate-reader-runs-table.tsx b/web-app/components/instruments/runs-table/plate-reader-runs-table.tsx index 80737b3c..2b87d3bd 100644 --- a/web-app/components/instruments/runs-table/plate-reader-runs-table.tsx +++ b/web-app/components/instruments/runs-table/plate-reader-runs-table.tsx @@ -19,7 +19,13 @@ import { cn, formatBytes } from "@/lib/utils"; import type { RunRow } from "."; import { ClickableRow } from "./clickable-row"; import { FilterableColumnHeader } from "./filterable-column-header"; -import { MetadataFieldBadge, getMetadataField } from "./metadata-utils"; +import { + MetadataFieldBadge, + TruncatedBadges, + getMetadataArray, + getMetadataField, + sortWavelengths, +} from "./metadata-utils"; import { RanByCell } from "./ran-by-cell"; import { RunSelectAllCheckbox, RunSelectCheckbox } from "./run-select-checkbox"; import type { RunRef } from "./run-selection-provider"; @@ -37,6 +43,7 @@ export function PlateReaderRunsTable({ ranByOptions: { value: string; label: string }[]; }) { const wavelengthColors = buildWavelengthColorMap(filterOptions.wavelengths); + const sortedWavelengthOptions = sortWavelengths(filterOptions.wavelengths); const runRefs: RunRef[] = data.map((row) => ({ id: row.id, instrumentId: row.instrument_id, @@ -55,9 +62,9 @@ export function PlateReaderRunsTable({ Total Size @@ -87,7 +94,9 @@ export function PlateReaderRunsTable({ {data.map((row) => { const isDeleted = row.deleted_at !== null; - const wavelength = getMetadataField(row.metadata, "wavelength"); + const wavelengths = sortWavelengths( + getMetadataArray(row.metadata, "wavelengths") + ); const mode = getMetadataField(row.metadata, "measurement_mode"); const type = getMetadataField(row.metadata, "measurement_type"); return ( @@ -130,11 +139,10 @@ export function PlateReaderRunsTable({ {formatBytes(row.total_size_bytes)} - diff --git a/web-app/components/runs/metadata-badges.tsx b/web-app/components/runs/metadata-badges.tsx index 26f04f82..4d105185 100644 --- a/web-app/components/runs/metadata-badges.tsx +++ b/web-app/components/runs/metadata-badges.tsx @@ -1,4 +1,9 @@ import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; export function getMetadataField( @@ -18,6 +23,24 @@ export function getMetadataArray(metadata: unknown, key: string): string[] { return []; } +/** + * 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. + */ +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; + if (aNum) return -1; + if (bNum) return 1; + return a.localeCompare(b); + }); +} + export function MetadataFieldBadge({ value, colorClass, @@ -33,17 +56,17 @@ export function MetadataFieldBadge({ ); } -export function MetadataArrayBadges({ +function BadgeRow({ values, colorMap, + className, }: { values: string[]; colorMap?: Record; + className?: string; }) { - if (values.length === 0) - return ; return ( -
+
{values.map((v) => ( ); } + +export function MetadataArrayBadges({ + values, + colorMap, +}: { + values: string[]; + colorMap?: Record; +}) { + if (values.length === 0) + return ; + return ; +} + +/** + * Renders up to `maxVisible` badges followed by a `+N` overflow badge when + * the array exceeds the limit. Hovering the cell surfaces a tooltip listing + * every badge on a single row (no wrapping) so the collapsed values remain + * discoverable. When the array fits within `maxVisible` no tooltip is shown. + */ +export function TruncatedBadges({ + values, + colorMap, + maxVisible = 2, +}: { + values: string[]; + colorMap?: Record; + maxVisible?: number; +}) { + if (values.length === 0) + return ; + + if (values.length <= maxVisible) { + return ; + } + + const visible = values.slice(0, maxVisible); + const hiddenCount = values.length - maxVisible; + + return ( + + +
+ {visible.map((v) => ( + + {v} + + ))} + + +{hiddenCount} + +
+
+ + + +
+ ); +} diff --git a/web-app/components/runs/run-metadata-badges.tsx b/web-app/components/runs/run-metadata-badges.tsx index 2ab75552..4ced7c50 100644 --- a/web-app/components/runs/run-metadata-badges.tsx +++ b/web-app/components/runs/run-metadata-badges.tsx @@ -13,6 +13,7 @@ import { cn } from "@/lib/utils"; import { getMetadataArray, getMetadataField, + sortWavelengths, } from "@/components/runs/metadata-badges"; // --------------------------------------------------------------------------- @@ -54,7 +55,7 @@ function ColorBadge({ export function hasPlateReaderMetadata(metadata: Record) { return Boolean( - getMetadataField(metadata, "wavelength") || + getMetadataArray(metadata, "wavelengths").length || getMetadataField(metadata, "measurement_mode") || getMetadataField(metadata, "measurement_type") ); @@ -65,11 +66,15 @@ export function PlateReaderRunBadges({ }: { metadata: Record; }) { - const wavelength = getMetadataField(metadata, "wavelength"); + const wavelengths = sortWavelengths( + getMetadataArray(metadata, "wavelengths") + ); const mode = getMetadataField(metadata, "measurement_mode"); const type = getMetadataField(metadata, "measurement_type"); - if (!wavelength && !mode && !type) return null; + if (!wavelengths.length && !mode && !type) return null; + + const wavelengthColors = buildWavelengthColorMap(wavelengths); return ( <> @@ -83,9 +88,13 @@ export function PlateReaderRunBadges({ )} - {wavelength && ( - - + {wavelengths.length > 0 && ( + + {wavelengths.map((w) => ( + + ))} )} @@ -112,7 +121,9 @@ export function GelDocRunBadges({ }) { const captureType = getMetadataField(metadata, "capture_type"); const imagingMode = getMetadataField(metadata, "imaging_mode"); - const wavelengths = getMetadataArray(metadata, "wavelengths"); + const wavelengths = sortWavelengths( + getMetadataArray(metadata, "wavelengths") + ); const colors = getMetadataArray(metadata, "colors"); if (!captureType && !imagingMode && !wavelengths.length && !colors.length) diff --git a/web-app/lib/api/instrument-runs.ts b/web-app/lib/api/instrument-runs.ts index 40f2ec3d..75d6b5b2 100644 --- a/web-app/lib/api/instrument-runs.ts +++ b/web-app/lib/api/instrument-runs.ts @@ -208,7 +208,7 @@ export async function buildRunListQuery(filters: RunListFilters) { // Plate-reader metadata column filters (leverages the GIN index). if (filters.wavelength) { conditions.push( - sql`${instrumentRuns.metadata}->>'wavelength' = ${filters.wavelength}` + sql`${instrumentRuns.metadata}->'wavelengths' @> ${JSON.stringify([filters.wavelength])}::jsonb` ); } if (filters.measurementMode) { @@ -441,7 +441,6 @@ export type PlateReaderFilterOptions = { }; const ALLOWED_METADATA_KEYS = new Set([ - "wavelength", "measurement_mode", "measurement_type", "capture_type", @@ -478,7 +477,7 @@ export async function getPlateReaderFilterOptions( instrumentId: string ): Promise { const [wavelengths, measurementModes, measurementTypes] = await Promise.all([ - distinctMetadataValues(instrumentId, "wavelength"), + distinctMetadataArrayValues(instrumentId, "wavelengths"), distinctMetadataValues(instrumentId, "measurement_mode"), distinctMetadataValues(instrumentId, "measurement_type"), ]);