Skip to content

Commit fd5cff0

Browse files
fix: Molecular Devices SoftMax Pro - account for partial plate reads (#1013)
Co-authored-by: james-leinas <157071641+james-leinas@users.noreply.github.com>
1 parent ca83037 commit fd5cff0

11 files changed

Lines changed: 39147 additions & 31 deletions

src/allotropy/parsers/moldev_softmax_pro/softmax_pro_data_creator.py

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,19 @@
1111
)
1212
from allotropy.allotrope.models.shared.definitions.units import UNITLESS
1313
from allotropy.allotrope.schema_mappers.adm.plate_reader.rec._2025._03.plate_reader import (
14+
ErrorDocument,
1415
Measurement,
1516
MeasurementGroup,
1617
MeasurementType,
1718
Metadata,
1819
)
1920
from allotropy.allotrope.schema_mappers.data_cube import DataCube, DataCubeComponent
2021
from allotropy.exceptions import AllotropeConversionError
21-
from allotropy.parsers.constants import DEFAULT_EPOCH_TIMESTAMP, NOT_APPLICABLE
22+
from allotropy.parsers.constants import (
23+
DEFAULT_EPOCH_TIMESTAMP,
24+
NEGATIVE_ZERO,
25+
NOT_APPLICABLE,
26+
)
2227
from allotropy.parsers.moldev_softmax_pro.constants import DEVICE_TYPE
2328
from allotropy.parsers.moldev_softmax_pro.softmax_pro_structure import (
2429
DataElement,
@@ -74,15 +79,23 @@ def _get_data_cube(
7479
)
7580
],
7681
dimensions=[data_element.elapsed_time],
77-
measures=[data_element.kinetic_measures],
82+
measures=[
83+
[
84+
value if value is not None else NEGATIVE_ZERO
85+
for value in data_element.kinetic_measures
86+
]
87+
],
7888
)
7989

8090

8191
def _get_spectrum_data_cube(
8292
plate_block: PlateBlock, data_elements: list[DataElement]
83-
) -> DataCube:
93+
) -> DataCube | None:
8494
wavelengths = [data_element.wavelength for data_element in data_elements]
8595
values = [data_element.value for data_element in data_elements]
96+
if all(value is None for value in values):
97+
# Ignore the wells completely from the ASM if all values are None
98+
return None
8699

87100
return DataCube(
88101
label=f"{plate_block.header.concept}-spectrum",
@@ -99,20 +112,36 @@ def _get_spectrum_data_cube(
99112
)
100113
],
101114
dimensions=[wavelengths],
102-
measures=[values],
115+
measures=[[value if value is not None else NEGATIVE_ZERO for value in values]],
103116
)
104117

105118

106119
def _create_spectrum_measurement(
107120
plate_block: PlateBlock, data_elements: list[DataElement]
108-
) -> Measurement:
121+
) -> Measurement | None:
109122
measurement_type = plate_block.measurement_type
110123
first_data_element = data_elements[0]
124+
spectrum_data_cube = _get_spectrum_data_cube(plate_block, data_elements)
125+
if not spectrum_data_cube:
126+
return None
127+
128+
# Collect error documents and update error_feature to include wavelength with unit
129+
error_documents = []
130+
for data_element in data_elements:
131+
for error_doc in data_element.error_document:
132+
if error_doc.error_feature == plate_block.header.read_mode:
133+
updated_error_doc = ErrorDocument(
134+
error=error_doc.error, error_feature=f"{data_element.wavelength}nm"
135+
)
136+
error_documents.append(updated_error_doc)
137+
else:
138+
# Keep original error_feature for non-spectrum data cube errors
139+
error_documents.append(error_doc)
111140

112141
return Measurement(
113142
type_=measurement_type,
114143
identifier=first_data_element.uuid,
115-
spectrum_data_cube=_get_spectrum_data_cube(plate_block, data_elements),
144+
spectrum_data_cube=spectrum_data_cube,
116145
compartment_temperature=first_data_element.temperature or None,
117146
location_identifier=first_data_element.position,
118147
well_plate_identifier=plate_block.header.name,
@@ -136,7 +165,7 @@ def _create_spectrum_measurement(
136165
total_measurement_time_setting=plate_block.header.read_time,
137166
read_interval_setting=plate_block.header.read_interval,
138167
number_of_scans_setting=plate_block.header.kinetic_points,
139-
error_document=first_data_element.error_document,
168+
error_document=error_documents,
140169
measurement_custom_info=first_data_element.custom_info,
141170
)
142171

@@ -154,24 +183,39 @@ def _create_measurements(plate_block: PlateBlock, position: str) -> list[Measure
154183
MeasurementType.EMISSION_LUMINESCENCE_CUBE_SPECTRUM,
155184
MeasurementType.EXCITATION_LUMINESCENCE_CUBE_SPECTRUM,
156185
):
157-
return [_create_spectrum_measurement(plate_block, data_elements)]
186+
measurement = _create_spectrum_measurement(plate_block, data_elements)
187+
if not measurement:
188+
return []
189+
return [measurement]
158190

159191
return [
160192
Measurement(
161193
type_=measurement_type,
162194
identifier=data_element.uuid,
163195
absorbance=(
164-
data_element.value
196+
(
197+
data_element.value
198+
if data_element.value is not None
199+
else NEGATIVE_ZERO
200+
)
165201
if measurement_type == MeasurementType.ULTRAVIOLET_ABSORBANCE
166202
else None
167203
),
168204
fluorescence=(
169-
data_element.value
205+
(
206+
data_element.value
207+
if data_element.value is not None
208+
else NEGATIVE_ZERO
209+
)
170210
if measurement_type == MeasurementType.FLUORESCENCE
171211
else None
172212
),
173213
luminescence=(
174-
data_element.value
214+
(
215+
data_element.value
216+
if data_element.value is not None
217+
else NEGATIVE_ZERO
218+
)
175219
if measurement_type == MeasurementType.LUMINESCENCE
176220
else None
177221
),

src/allotropy/parsers/moldev_softmax_pro/softmax_pro_structure.py

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ class DataElement:
373373
temperature: float | None
374374
wavelength: float
375375
position: str
376-
value: float
376+
value: float | None
377377
error_document: list[ErrorDocument]
378378
custom_info: dict[str, str] = field(default_factory=dict)
379379
elapsed_time: list[float] = field(default_factory=list)
@@ -410,21 +410,19 @@ def create(
410410
for row_idx, *row_data in df_data.itertuples()
411411
for col, raw_value in zip(df_data.columns, row_data, strict=True)
412412
}
413-
414413
data_elements = {}
415414
for position, raw_value in data.items():
416415
value = try_non_nan_float_or_none(raw_value)
417416
if value is None and elapsed_time is not None:
418417
msg = f"Missing kinetic measurement for well position {position} at {elapsed_time}s."
419418
raise AllotropeConversionError(msg)
420-
421419
data_elements[str(position)] = DataElement(
422420
uuid=random_uuid_str(),
423421
plate=header.name,
424422
temperature=temperature,
425423
wavelength=wavelength,
426424
position=str(position),
427-
value=NEGATIVE_ZERO if value is None else value,
425+
value=value,
428426
error_document=(
429427
[ErrorDocument(str(raw_value), header.read_mode)]
430428
if value is None
@@ -475,6 +473,13 @@ def get_measurement_section(
475473
reader.lines_as_df(lines=lines, sep="\t"),
476474
msg="unable to find data from plate block.",
477475
)
476+
477+
# Truncate columns Series to match the actual number of data columns
478+
if len(columns) >= data.shape[1]:
479+
columns = columns.iloc[: data.shape[1]]
480+
elif len(columns) < data.shape[1]:
481+
data = data.loc[:, : len(columns) - 1]
482+
478483
set_columns(data, columns)
479484
return data
480485

@@ -559,7 +564,7 @@ def _update_kinetic_data(
559564

560565
@dataclass(frozen=True)
561566
class SpectrumRawPlateData(RawData):
562-
maximum_wavelength_signal: dict[str, float]
567+
maximum_wavelength_signal: dict[str, float | None]
563568

564569
@staticmethod
565570
def create(reader: CsvReader, header: PlateHeader) -> SpectrumRawPlateData:
@@ -594,9 +599,7 @@ def create(reader: CsvReader, header: PlateHeader) -> SpectrumRawPlateData:
594599
reader, columns, rows
595600
).iloc[:, 2:]
596601
signal_data = {
597-
f"{num_to_chars(row_idx)}{col}": try_float(
598-
str(raw_value), "wavelength signal"
599-
)
602+
f"{num_to_chars(row_idx)}{col}": try_float_or_none(str(raw_value))
600603
for row_idx, *row_data in max_wavelength_signal_data.itertuples()
601604
for col, raw_value in zip(
602605
max_wavelength_signal_data.columns, row_data, strict=True
@@ -665,8 +668,14 @@ def create(
665668
)
666669

667670
def iter_data_elements(self, position: str) -> Iterator[DataElement]:
668-
for wavelength_data in self.raw_data.wavelength_data:
669-
yield wavelength_data.data_elements[position]
671+
for plate_wavelength_data in self.raw_data.wavelength_data:
672+
yield plate_wavelength_data.data_elements[position]
673+
674+
def position_exists(self, position: str) -> bool:
675+
for plate_wavelength_data in self.raw_data.wavelength_data:
676+
if position in plate_wavelength_data.data_elements:
677+
return True
678+
return False
670679

671680

672681
@dataclass(frozen=True)
@@ -693,7 +702,7 @@ def create(
693702
temperature=temperature,
694703
wavelength=wavelength,
695704
position=str(position),
696-
value=NEGATIVE_ZERO if value is None else value,
705+
value=value,
697706
error_document=error_document,
698707
)
699708

@@ -799,10 +808,19 @@ def create(
799808
)
800809

801810
def iter_data_elements(self, position: str) -> Iterator[DataElement]:
802-
for wavelength_data in self.raw_data.wavelength_data:
803-
for measurement_data in wavelength_data.measurement_data:
811+
for time_wavelength_data in self.raw_data.wavelength_data:
812+
for measurement_data in time_wavelength_data.measurement_data:
804813
yield measurement_data.data_elements[position]
805814

815+
def position_exists(self, position: str) -> bool:
816+
for time_wavelength_data in self.raw_data.wavelength_data:
817+
if any(
818+
position in measurement.data_elements
819+
for measurement in time_wavelength_data.measurement_data
820+
):
821+
return True
822+
return False
823+
806824

807825
@dataclass(frozen=True)
808826
class PlateBlock(ABC, Block):
@@ -915,7 +933,9 @@ def iter_wells(self) -> Iterator[str]:
915933
cols, rows = NUM_WELLS_TO_PLATE_DIMENSIONS[self.header.num_wells]
916934
for row in range(rows):
917935
for col in range(1, cols + 1):
918-
yield f"{num_to_chars(row)}{col}"
936+
position = f"{num_to_chars(row)}{col}"
937+
if self.block_data.position_exists(position):
938+
yield position
919939

920940
def iter_data_elements(self, position: str | list[str]) -> Iterator[DataElement]:
921941
position = [position] if isinstance(position, str) else position
@@ -1279,12 +1299,26 @@ def create(reader: CsvReader) -> StructureData:
12791299
# experiment, there is no way at the moment to link the group data with a plate.
12801300
continue
12811301
plate_block = block_list.plate_blocks[group_data_element.plate]
1302+
is_spectrum = (
1303+
plate_block.header.read_type == ReadType.SPECTRUM.value
1304+
)
1305+
processed_positions = set()
1306+
12821307
for data_element in plate_block.iter_data_elements(
12831308
group_data_element.positions
12841309
):
12851310
data_element.group_id = group_block.group_data.name
12861311
data_element.sample_id = group_data_element.sample
1287-
data_element.error_document += group_data_element.errors
1312+
1313+
# For spectrum measurements, only add errors to the first DataElement per well
1314+
if (
1315+
not is_spectrum
1316+
or data_element.position not in processed_positions
1317+
):
1318+
data_element.error_document += group_data_element.errors
1319+
if is_spectrum:
1320+
processed_positions.add(data_element.position)
1321+
12881322
data_element.custom_info.update(group_data_element.custom_info)
12891323

12901324
return StructureData(

tests/parsers/moldev_softmax_pro/testdata/MD_SMP_absorbance_endpoint_partial_plate_example02.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20337,7 +20337,7 @@
2033720337
"UNC path": "tests/parsers/moldev_softmax_pro/testdata/MD_SMP_absorbance_endpoint_partial_plate_example02.txt",
2033820338
"file name": "MD_SMP_absorbance_endpoint_partial_plate_example02.txt",
2033920339
"ASM converter name": "allotropy_molecular_devices_softmax_pro",
20340-
"ASM converter version": "0.1.92",
20340+
"ASM converter version": "0.1.93",
2034120341
"software name": "SoftMax Pro"
2034220342
},
2034320343
"device system document": {

tests/parsers/moldev_softmax_pro/testdata/MD_SMP_absorbance_endpoint_partial_plate_example05.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15965,7 +15965,7 @@
1596515965
"UNC path": "tests/parsers/moldev_softmax_pro/testdata/MD_SMP_absorbance_endpoint_partial_plate_example05.txt",
1596615966
"file name": "MD_SMP_absorbance_endpoint_partial_plate_example05.txt",
1596715967
"ASM converter name": "allotropy_molecular_devices_softmax_pro",
15968-
"ASM converter version": "0.1.92",
15968+
"ASM converter version": "0.1.93",
1596915969
"software name": "SoftMax Pro"
1597015970
},
1597115971
"device system document": {

tests/parsers/moldev_softmax_pro/testdata/MD_SMP_fluorescence_endpoint_partial_plate_example02.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18557,7 +18557,7 @@
1855718557
"UNC path": "tests/parsers/moldev_softmax_pro/testdata/MD_SMP_fluorescence_endpoint_partial_plate_example02.txt",
1855818558
"file name": "MD_SMP_fluorescence_endpoint_partial_plate_example02.txt",
1855918559
"ASM converter name": "allotropy_molecular_devices_softmax_pro",
18560-
"ASM converter version": "0.1.92",
18560+
"ASM converter version": "0.1.93",
1856118561
"software name": "SoftMax Pro"
1856218562
},
1856318563
"device system document": {

tests/parsers/moldev_softmax_pro/testdata/MD_SMP_luminescence_endpoint_partial_plate_example02.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17117,7 +17117,7 @@
1711717117
"UNC path": "tests/parsers/moldev_softmax_pro/testdata/MD_SMP_luminescence_endpoint_partial_plate_example02.txt",
1711817118
"file name": "MD_SMP_luminescence_endpoint_partial_plate_example02.txt",
1711917119
"ASM converter name": "allotropy_molecular_devices_softmax_pro",
17120-
"ASM converter version": "0.1.92",
17120+
"ASM converter version": "0.1.93",
1712117121
"software name": "SoftMax Pro"
1712217122
},
1712317123
"device system document": {

0 commit comments

Comments
 (0)