Skip to content

Commit 9065ed9

Browse files
authored
feat: Unchained Labs Lunnatic & Stunner - refactor measurements to use data cubes for wavelength spectrums (#1062)
1 parent eb16cee commit 9065ed9

4 files changed

Lines changed: 396 additions & 15387 deletions

File tree

src/allotropy/calcdocs/unchained_labs_lunatic_stunner/extractor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ def to_element(cls, measurement: Measurement) -> Element:
1717
uuid=measurement.identifier,
1818
data={
1919
"uuid": measurement.identifier,
20-
"wavelength id": measurement.wavelength_identifier,
21-
"absorbance": measurement.absorbance,
20+
"wavelength id": "NA",
21+
"absorbance": 0.0,
2222
"detection type": measurement.detection_type,
2323
**custom_info,
2424
},

src/allotropy/parsers/unchained_labs_lunatic_stunner/unchained_labs_lunatic_stunner_structure.py

Lines changed: 199 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
from __future__ import annotations
22

3+
from decimal import Decimal
34
from pathlib import Path
45
import re
56
from typing import Any
67

78
import pandas as pd
89

10+
from allotropy.allotrope.models.shared.definitions.definitions import (
11+
FieldComponentDatatype,
12+
)
13+
from allotropy.allotrope.models.shared.definitions.units import (
14+
MilliAbsorbanceUnit,
15+
Nanometer,
16+
)
917
from allotropy.allotrope.schema_mappers.adm.plate_reader.rec._2025._03.plate_reader import (
1018
ErrorDocument,
1119
Measurement,
@@ -14,6 +22,7 @@
1422
Metadata,
1523
ProcessedDataDocument,
1624
)
25+
from allotropy.allotrope.schema_mappers.data_cube import DataCube, DataCubeComponent
1726
from allotropy.exceptions import AllotropeConversionError
1827
from allotropy.parsers.constants import NEGATIVE_ZERO, NOT_APPLICABLE
1928
from allotropy.parsers.unchained_labs_lunatic_stunner.constants import (
@@ -121,20 +130,28 @@ def _is_literal_not_applicable(series: SeriesData, key: str) -> bool:
121130
def _create_measurement(
122131
well_plate_data: SeriesData,
123132
header: SeriesData,
124-
wavelength_column: str,
133+
wavelength_columns: list[str],
125134
) -> Measurement:
126-
if wavelength_column not in well_plate_data.series:
127-
msg = NO_MEASUREMENT_IN_PLATE_ERROR_MSG.format(wavelength_column)
128-
raise AllotropeConversionError(msg)
129-
130-
wavelength_match = WAVELENGTH_COLUMNS_RE.match(wavelength_column)
131-
if not wavelength_match:
132-
raise AllotropeConversionError(INCORRECT_WAVELENGTH_COLUMN_FORMAT_ERROR_MSG)
133-
if len(wavelength_match.groups()) > 1:
134-
wavelength, path_length = wavelength_match.groups()
135-
else:
136-
wavelength = wavelength_match.groups()[0]
137-
path_length = None
135+
absorbance_errors: list[ErrorDocument] = []
136+
for wavelength_column in wavelength_columns:
137+
if wavelength_column not in well_plate_data.series:
138+
msg = NO_MEASUREMENT_IN_PLATE_ERROR_MSG.format(wavelength_column)
139+
raise AllotropeConversionError(msg)
140+
141+
wavelength_match = WAVELENGTH_COLUMNS_RE.match(wavelength_column)
142+
if not wavelength_match:
143+
raise AllotropeConversionError(INCORRECT_WAVELENGTH_COLUMN_FORMAT_ERROR_MSG)
144+
145+
absorbance_is_na = _is_literal_not_applicable(
146+
well_plate_data, wavelength_column
147+
)
148+
if absorbance_is_na:
149+
absorbance_errors.append(
150+
ErrorDocument(
151+
error=NOT_APPLICABLE,
152+
error_feature=f"{DEFAULT_DETECTION_TYPE.lower()}",
153+
)
154+
)
138155

139156
background_wavelength = well_plate_data.get(float, "background wvl. (nm)")
140157
background_absorbance = None
@@ -147,8 +164,7 @@ def _create_measurement(
147164
peak_data = _extract_peak_data(well_plate_data)
148165

149166
error_documents: list[ErrorDocument] = []
150-
absorbance = well_plate_data.get(float, wavelength_column)
151-
absorbance_is_na = _is_literal_not_applicable(well_plate_data, wavelength_column)
167+
152168
concentration_factor = well_plate_data.get(float, "concentration factor (ng/ul)")
153169
application = header.get(str, "application")
154170
analytical_method_identifier = (
@@ -158,41 +174,46 @@ def _create_measurement(
158174
detection_type = DEFAULT_DETECTION_TYPE
159175
if application and "B22 & kD" in application:
160176
detection_type = DYNAMIC_LIGHT_SCATTERING_DETECTION_TYPE
161-
# Build calculated data values with special handling for N/A and empty cells
162-
calculated_data_values: dict[str, float] = {}
163-
for item in CALCULATED_DATA_LOOKUP.get(wavelength_column, []):
164-
value, is_na = _get_calculated_value_and_is_na(well_plate_data, item["column"])
165-
# Only create error docs when the cell is the literal "N/A"
166-
if is_na:
167-
error_documents.append(
168-
ErrorDocument(
169-
error=NOT_APPLICABLE,
170-
error_feature=item["name"],
171-
)
172-
)
173-
# Skip missing cells entirely; include numeric values
174-
if value is not None:
175-
calculated_data_values[item["column"]] = value
176-
177-
# Append absorbance error last to preserve historical ordering (calculated-data errors first)
178-
if absorbance_is_na:
179-
error_documents.append(
180-
ErrorDocument(
181-
error=NOT_APPLICABLE,
182-
error_feature=DEFAULT_DETECTION_TYPE.lower(),
183-
)
177+
178+
calculated_data_values, calculated_data_errors = _get_calculated_data_values(
179+
well_plate_data, wavelength_columns
180+
)
181+
182+
error_documents.extend(calculated_data_errors)
183+
error_documents.extend(absorbance_errors)
184+
185+
# Initialize optional variables to None; set if applicable
186+
spectrum_data_cube: DataCube | None = None
187+
absorbance_value: float | None = None
188+
detector_wavelength_setting: float | None = None
189+
wavelength_identifier: str | None = None
190+
191+
if len(wavelength_columns) > 1:
192+
spectrum_data_cube = _get_spectrum_data_cube(
193+
well_plate_data, wavelength_columns
184194
)
195+
elif len(wavelength_columns) == 1:
196+
wavelength_match = WAVELENGTH_COLUMNS_RE.match(wavelength_columns[0])
197+
wavelength, _ = wavelength_match.groups() if wavelength_match else ("", "")
198+
absorbance_tmp = well_plate_data.get(float, wavelength_columns[0])
199+
absorbance_value = (
200+
absorbance_tmp if absorbance_tmp is not None else NEGATIVE_ZERO
201+
)
202+
detector_wavelength_setting = float(wavelength) if wavelength else None
203+
wavelength_identifier = wavelength_columns[0]
185204

186205
return Measurement(
187-
type_=MeasurementType.ULTRAVIOLET_ABSORBANCE,
206+
type_=MeasurementType.ULTRAVIOLET_ABSORBANCE_CUBE_SPECTRUM
207+
if len(wavelength_columns) > 1
208+
else MeasurementType.ULTRAVIOLET_ABSORBANCE,
188209
device_type=DEVICE_TYPE,
189210
detection_type=detection_type,
190211
identifier=measurement_identifier,
191212
analytical_method_identifier=analytical_method_identifier,
192213
experimental_data_identifier=experimental_data_identifier,
193-
detector_wavelength_setting=float(wavelength),
214+
detector_wavelength_setting=detector_wavelength_setting,
194215
electronic_absorbance_reference_wavelength_setting=background_wavelength,
195-
absorbance=absorbance if absorbance is not None else NEGATIVE_ZERO,
216+
absorbance=absorbance_value,
196217
sample_identifier=well_plate_data[str, "sample name"],
197218
location_identifier=well_plate_data[str, "plate position"],
198219
well_plate_identifier=well_plate_data.get(str, "plate id"),
@@ -202,7 +223,6 @@ def _create_measurement(
202223
integration_time=well_plate_data.get(float, "acquisition time (s)"),
203224
compartment_temperature=well_plate_data.get(float, "temperature (°c)"),
204225
sample_custom_info={
205-
"path length": float(path_length) if path_length is not None else None,
206226
"plate type": header.get(str, "plate type")
207227
or well_plate_data.get(str, "plate type"),
208228
"nr of plates": header.get(str, "nr of plates"),
@@ -232,7 +252,6 @@ def _create_measurement(
232252
)
233253
if concentration_factor is not None or peak_data
234254
else None,
235-
wavelength_identifier=wavelength_column,
236255
calc_docs_custom_info={
237256
**calculated_data_values,
238257
**{
@@ -268,9 +287,145 @@ def _create_measurement(
268287
)
269288
),
270289
},
290+
spectrum_data_cube=spectrum_data_cube,
291+
wavelength_identifier=wavelength_identifier,
292+
)
293+
294+
295+
def _get_spectrum_data_cube(
296+
well_plate_data: SeriesData, wavelength_columns: list[str]
297+
) -> DataCube:
298+
wavelengths, absorbances = _get_wavelengths_and_absorbance(
299+
well_plate_data, wavelength_columns
300+
)
301+
302+
return DataCube(
303+
label="absorbance-spectrum",
304+
structure_dimensions=[
305+
DataCubeComponent(
306+
concept="wavelength",
307+
type_=FieldComponentDatatype.double,
308+
unit=Nanometer.unit,
309+
)
310+
],
311+
structure_measures=[
312+
DataCubeComponent(
313+
concept="absorbance",
314+
type_=FieldComponentDatatype.double,
315+
unit=MilliAbsorbanceUnit.unit,
316+
)
317+
],
318+
dimensions=[wavelengths],
319+
measures=[absorbances],
271320
)
272321

273322

323+
def _get_wavelengths_and_absorbance(
324+
well_plate_data: SeriesData, wavelength_columns: list[str]
325+
) -> tuple[list[float], list[float]]:
326+
wavelength_to_absorbance: dict[float, Decimal | None] = {}
327+
for wavelength_column in wavelength_columns:
328+
match = WAVELENGTH_COLUMNS_RE.match(wavelength_column)
329+
if not match:
330+
raise AllotropeConversionError(INCORRECT_WAVELENGTH_COLUMN_FORMAT_ERROR_MSG)
331+
wavelength_str, _ = match.groups()
332+
wavelength = float(wavelength_str)
333+
# Read as string to preserve precision; fallback to float; allow None
334+
raw_str = well_plate_data.get(
335+
str, wavelength_column, validate=SeriesData.NOT_NAN
336+
)
337+
absorbance: Decimal | None
338+
if raw_str is None or str(raw_str).strip() == "":
339+
absorbance = None
340+
else:
341+
absorbance = Decimal(str(raw_str).strip())
342+
343+
if wavelength in wavelength_to_absorbance:
344+
previous_absorbance = wavelength_to_absorbance[wavelength]
345+
if absorbance is None or previous_absorbance is None:
346+
continue
347+
else:
348+
wavelength_to_absorbance[
349+
wavelength
350+
] = _get_absorbance_with_highest_precision(
351+
previous_absorbance, absorbance
352+
)
353+
354+
else:
355+
wavelength_to_absorbance[wavelength] = absorbance
356+
357+
sorted_wavelengths = sorted(wavelength_to_absorbance.keys())
358+
359+
absorbances = []
360+
for w in sorted_wavelengths:
361+
absorbance = wavelength_to_absorbance[w]
362+
if absorbance is None:
363+
absorbances.append(float(NEGATIVE_ZERO))
364+
else:
365+
absorbances.append(float(absorbance))
366+
367+
return sorted_wavelengths, absorbances
368+
369+
370+
def _get_absorbance_with_highest_precision(
371+
absorbance1: Decimal, absorbance2: Decimal
372+
) -> Decimal:
373+
def _int_exponent(d: Decimal) -> int:
374+
exp = d.as_tuple().exponent
375+
if not isinstance(exp, int):
376+
msg = "Invalid absorbance value (NaN/Inf) not allowed for precision comparison."
377+
raise AllotropeConversionError(msg)
378+
return exp
379+
380+
exp1 = _int_exponent(absorbance1)
381+
exp2 = _int_exponent(absorbance2)
382+
383+
# Determine which has higher precision (more decimal places => smaller exponent),
384+
# for example, 1.56 has an exponent of -2, while 1.562 has an exponent of -3.
385+
if exp1 < exp2:
386+
high_precision = absorbance1
387+
low_precision, low_precision_exp = absorbance2, exp2
388+
else:
389+
high_precision = absorbance2
390+
low_precision, low_precision_exp = absorbance1, exp1
391+
392+
# Round both to the lower precision and ensure they match
393+
quant = Decimal(1).scaleb(low_precision_exp) # e.g., 1E-2 for two decimal places
394+
if high_precision.quantize(quant) != low_precision.quantize(quant):
395+
msg = (
396+
f"Conflicting absorbance values at same wavelength: {absorbance1} vs {absorbance2} "
397+
f"when rounded to {abs(low_precision_exp)} decimal places."
398+
)
399+
raise AllotropeConversionError(msg)
400+
401+
# Return the value with the highest precision
402+
return high_precision
403+
404+
405+
def _get_calculated_data_values(
406+
well_plate_data: SeriesData, wavelength_columns: list[str]
407+
) -> tuple[dict[str, float], list[ErrorDocument]]:
408+
calculated_data_values: dict[str, float] = {}
409+
error_documents: list[ErrorDocument] = []
410+
for wavelength_column in wavelength_columns:
411+
for item in CALCULATED_DATA_LOOKUP.get(wavelength_column, []):
412+
value, is_na = _get_calculated_value_and_is_na(
413+
well_plate_data, item["column"]
414+
)
415+
# Only create error docs when the cell is the literal "N/A"
416+
if is_na:
417+
error_documents.append(
418+
ErrorDocument(
419+
error=NOT_APPLICABLE,
420+
error_feature=item["name"],
421+
)
422+
)
423+
# Skip missing cells entirely; include numeric values
424+
if value is not None:
425+
calculated_data_values[item["column"]] = value
426+
return calculated_data_values, error_documents
427+
428+
274429
def _create_measurement_group(
275430
data: SeriesData,
276431
wavelength_columns: list[str],
@@ -287,10 +442,7 @@ def _create_measurement_group(
287442
measurement_time=assert_not_none(timestamp, msg=NO_DATE_OR_TIME_ERROR_MSG),
288443
analyst=header.get(str, "test performed by"),
289444
plate_well_count=96,
290-
measurements=[
291-
_create_measurement(data, header, wavelength_column)
292-
for wavelength_column in wavelength_columns
293-
],
445+
measurements=[_create_measurement(data, header, wavelength_columns)],
294446
)
295447

296448

0 commit comments

Comments
 (0)