Skip to content

Commit 680806e

Browse files
feat: Luminex xPONENT - get unread data (#1059)
Co-authored-by: Nathan Stender <nathan.stender@benchling.com>
1 parent a6ddd00 commit 680806e

10 files changed

Lines changed: 2270 additions & 203 deletions

File tree

src/allotropy/allotrope/schema_mappers/adm/multi_analyte_profiling/benchling/_2024/_09/multi_analyte_profiling.py

Lines changed: 61 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from dataclasses import dataclass
2+
from typing import Any
23

4+
from allotropy.allotrope.converter import add_custom_information_document
35
from allotropy.allotrope.models.adm.multi_analyte_profiling.benchling._2024._09.multi_analyte_profiling import (
46
AnalyteAggregateDocument,
57
AnalyteDocumentItem,
@@ -101,6 +103,11 @@ class Measurement:
101103
minimum_assay_bead_count_setting: float | None = None
102104
detector_gain_setting: str | None = None
103105

106+
# custom
107+
measurement_custom_info: dict[str, Any] | None = None
108+
sample_custom_info: dict[str, Any] | None = None
109+
device_control_custom_info: dict[str, Any] | None = None
110+
104111

105112
@dataclass(frozen=True)
106113
class MeasurementGroup:
@@ -112,6 +119,7 @@ class MeasurementGroup:
112119
method_version: str | None = None
113120
container_type: str | None = None
114121
experiment_type: str | None = None
122+
custom_info: dict[str, Any] | None = None
115123

116124

117125
@dataclass(frozen=True)
@@ -136,6 +144,7 @@ class Metadata:
136144
product_manufacturer: str | None = None
137145

138146
calibrations: list[Calibration] | None = None
147+
custom_info: dict[str, Any] | None = None
139148

140149

141150
@dataclass(frozen=True)
@@ -151,14 +160,17 @@ class Mapper(SchemaMapper[Data, Model]):
151160
def map_model(self, data: Data) -> Model:
152161
return Model(
153162
multi_analyte_profiling_aggregate_document=MultiAnalyteProfilingAggregateDocument(
154-
device_system_document=DeviceSystemDocument(
155-
model_number=data.metadata.model_number,
156-
product_manufacturer=data.metadata.product_manufacturer,
157-
firmware_version=data.metadata.firmware_version,
158-
equipment_serial_number=data.metadata.equipment_serial_number,
159-
calibration_aggregate_document=self._get_calibration_aggregate_document(
160-
data.metadata.calibrations
163+
device_system_document=add_custom_information_document(
164+
DeviceSystemDocument(
165+
model_number=data.metadata.model_number,
166+
product_manufacturer=data.metadata.product_manufacturer,
167+
firmware_version=data.metadata.firmware_version,
168+
equipment_serial_number=data.metadata.equipment_serial_number,
169+
calibration_aggregate_document=self._get_calibration_aggregate_document(
170+
data.metadata.calibrations
171+
),
161172
),
173+
custom_info_doc=data.metadata.custom_info,
162174
),
163175
data_system_document=DataSystemDocument(
164176
data_system_instance_identifier=data.metadata.data_system_instance_identifier,
@@ -186,44 +198,55 @@ def _get_technique_document(
186198
) -> MultiAnalyteProfilingDocumentItem:
187199
return MultiAnalyteProfilingDocumentItem(
188200
analyst=measurement_group.analyst,
189-
measurement_aggregate_document=MeasurementAggregateDocument(
190-
experiment_type=measurement_group.experiment_type,
191-
analytical_method_identifier=measurement_group.analytical_method_identifier,
192-
method_version=measurement_group.method_version,
193-
experimental_data_identifier=measurement_group.experimental_data_identifier,
194-
container_type=measurement_group.container_type,
195-
plate_well_count=quantity_or_none(
196-
TQuantityValueNumber, measurement_group.plate_well_count
201+
measurement_aggregate_document=add_custom_information_document(
202+
MeasurementAggregateDocument(
203+
experiment_type=measurement_group.experiment_type,
204+
analytical_method_identifier=measurement_group.analytical_method_identifier,
205+
method_version=measurement_group.method_version,
206+
experimental_data_identifier=measurement_group.experimental_data_identifier,
207+
container_type=measurement_group.container_type,
208+
plate_well_count=quantity_or_none(
209+
TQuantityValueNumber, measurement_group.plate_well_count
210+
),
211+
measurement_document=[
212+
self._get_measurement_document(measurement, metadata)
213+
for measurement in measurement_group.measurements
214+
],
197215
),
198-
measurement_document=[
199-
self._get_measurement_document(measurement, metadata)
200-
for measurement in measurement_group.measurements
201-
],
216+
custom_info_doc=measurement_group.custom_info,
202217
),
203218
)
204219

205220
def _get_measurement_document(
206221
self, measurement: Measurement, metadata: Metadata
207222
) -> MeasurementDocumentItem:
208-
return MeasurementDocumentItem(
223+
measurement_document = MeasurementDocumentItem(
209224
measurement_identifier=measurement.identifier,
210225
measurement_time=self.get_date_time(measurement.measurement_time),
211-
sample_document=self._get_sample_document(measurement),
226+
sample_document=add_custom_information_document(
227+
self._get_sample_document(measurement),
228+
custom_info_doc=measurement.sample_custom_info,
229+
),
212230
device_control_aggregate_document=DeviceControlAggregateDocument(
213231
device_control_document=[
214-
DeviceControlDocumentItem(
215-
device_type=metadata.device_type,
216-
sample_volume_setting=quantity_or_none(
217-
TQuantityValueMicroliter, measurement.sample_volume_setting
218-
),
219-
dilution_factor_setting=quantity_or_none(
220-
TQuantityValueUnitless, measurement.dilution_factor_setting
221-
),
222-
detector_gain_setting=measurement.detector_gain_setting,
223-
minimum_assay_bead_count_threshold_setting=quantity_or_none(
224-
TQuantityValueNumber,
225-
measurement.minimum_assay_bead_count_setting,
232+
add_custom_information_document(
233+
DeviceControlDocumentItem(
234+
device_type=metadata.device_type,
235+
sample_volume_setting=quantity_or_none(
236+
TQuantityValueMicroliter,
237+
measurement.sample_volume_setting,
238+
),
239+
dilution_factor_setting=quantity_or_none(
240+
TQuantityValueUnitless,
241+
measurement.dilution_factor_setting,
242+
),
243+
detector_gain_setting=measurement.detector_gain_setting,
244+
minimum_assay_bead_count_threshold_setting=quantity_or_none(
245+
TQuantityValueNumber,
246+
measurement.minimum_assay_bead_count_setting,
247+
),
226248
),
249+
custom_info_doc=measurement.device_control_custom_info,
227250
)
228251
]
229252
),
@@ -275,6 +298,11 @@ def _get_measurement_document(
275298
),
276299
)
277300

301+
return add_custom_information_document(
302+
measurement_document,
303+
custom_info_doc=measurement.measurement_custom_info,
304+
)
305+
278306
def _get_sample_document(self, measurement: Measurement) -> SampleDocument:
279307
return SampleDocument(
280308
sample_identifier=measurement.sample_identifier,

src/allotropy/parsers/luminex_xponent/luminex_xponent_structure.py

Lines changed: 86 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -64,35 +64,49 @@ class Header:
6464
detector_gain_setting: str | None
6565
minimum_assay_bead_count_setting: float | None
6666
analyst: str | None
67+
custom_info: dict[str, Any]
6768

6869
@classmethod
6970
def create(
70-
cls, header_data: pd.DataFrame, minimum_assay_bead_count_setting: float | None
71+
cls,
72+
header_data: pd.DataFrame,
73+
header_row: SeriesData,
74+
minimum_assay_bead_count_setting: float | None,
7175
) -> Header:
72-
info_row = SeriesData(header_data.iloc[0])
73-
raw_datetime = info_row[str, "BatchStartTime"]
74-
sample_volume = info_row.get(str, ["SampleVolume", "MaxSampleUptakeVolume"])
76+
sample_volume = header_row.get(str, ["SampleVolume", "MaxSampleUptakeVolume"])
77+
78+
header_row.mark_read({"Program", "ProtocolPlate", "Date"})
7579

7680
return Header(
7781
model_number=cls._get_model_number(header_data),
78-
software_version=info_row.get(str, "Build"),
79-
equipment_serial_number=info_row.get(str, "SN"),
80-
analytical_method_identifier=info_row.get(str, "ProtocolName"),
81-
method_version=info_row.get(str, "ProtocolVersion"),
82-
experimental_data_identifier=info_row.get(str, "Batch"),
82+
software_version=header_row.get(str, "Build"),
83+
equipment_serial_number=header_row.get(str, "SN"),
84+
analytical_method_identifier=header_row.get(str, "ProtocolName"),
85+
method_version=header_row.get(str, "ProtocolVersion"),
86+
experimental_data_identifier=header_row.get(str, "Batch"),
8387
sample_volume_setting=(
8488
try_float(sample_volume.split()[0], "sample volume setting")
8589
if sample_volume
8690
else None
8791
),
8892
plate_well_count=cls._get_plate_well_count(header_data),
89-
measurement_time=raw_datetime,
90-
detector_gain_setting=info_row.get(
93+
measurement_time=header_row[str, "BatchStartTime"],
94+
detector_gain_setting=header_row.get(
9195
str, ["ProtocolReporterGain", "ProtocolOperatingMode"]
9296
),
93-
data_system_instance_identifier=info_row[str, "ComputerName"],
97+
data_system_instance_identifier=header_row[str, "ComputerName"],
9498
minimum_assay_bead_count_setting=minimum_assay_bead_count_setting,
95-
analyst=info_row.get(str, "Operator"),
99+
analyst=header_row.get(str, "Operator"),
100+
custom_info={
101+
"Country Code": header_row.get(str, "Country Code"),
102+
"ProtocolDevelopingCompany": header_row.get(
103+
str, "ProtocolDevelopingCompany"
104+
),
105+
"Version": header_row.get(str, "Version"),
106+
# Used for the measurement groups
107+
"BatchStopTime": header_row.get(str, "BatchStopTime"),
108+
"ProtocolDescription": header_row.get(str, "ProtocolDescription"),
109+
},
96110
)
97111

98112
@classmethod
@@ -138,7 +152,10 @@ def create_calibration(calibration_data: SeriesData) -> Calibration:
138152
msg = f"Expected at least two columns on the calibration line, got:\n{calibration_data.series}."
139153
raise AllotropeConversionError(msg)
140154

155+
# Read the calibration data using SeriesData methods
156+
calibration_name = calibration_data.series.iloc[0]
141157
calibration_info = str(calibration_data.series.iloc[1]).strip()
158+
calibration_data.get_unread()
142159

143160
# Check if the calibration info starts with a known status value
144161
status_values = ["Passed", "Failed", "Calibrated", "Verified"]
@@ -164,7 +181,7 @@ def create_calibration(calibration_data: SeriesData) -> Calibration:
164181
raise AllotropeConversionError(msg)
165182

166183
return Calibration(
167-
name=calibration_data.series.iloc[0].replace("Last", "").strip(),
184+
name=calibration_name.replace("Last", "").strip(),
168185
time=time,
169186
report=report,
170187
)
@@ -180,13 +197,17 @@ class Measurement:
180197
analytes: list[Analyte]
181198
calculated_data: list[CalculatedDocument]
182199
errors: list[Error] | None = None
200+
measurement_custom_info: dict[str, Any] | None = None
201+
sample_custom_info: dict[str, Any] | None = None
202+
device_control_custom_info: dict[str, Any] | None = None
183203

184204
@classmethod
185205
def create(
186206
cls,
187207
results_data: dict[str, pd.DataFrame],
188208
count_data: SeriesData,
189209
bead_ids_data: SeriesData,
210+
header_row: SeriesData,
190211
) -> Measurement:
191212
location = str(count_data.series.name)
192213
dilution_factor_data = results_data["Dilution Factor"]
@@ -201,9 +222,12 @@ def create(
201222
metadata_keys = ["Sample", "Total Events"]
202223

203224
well_location, location_id = cls._get_location_details(location)
204-
dilution_factor_setting = SeriesData(dilution_factor_data.loc[location]).get(
225+
dilution_factor_series = SeriesData(dilution_factor_data.loc[location])
226+
dilution_factor_setting = dilution_factor_series.get(
205227
float, "Dilution Factor", NEGATIVE_ZERO, validate=SeriesData.NOT_NAN
206228
)
229+
# Capture unread dilution factor data to prevent warnings
230+
dilution_factor_series.get_unread()
207231
errors: list[Error] = []
208232
data_errors = cls._get_errors(errors_data, well_location) or []
209233
for data_error in data_errors:
@@ -289,6 +313,20 @@ def get_statistic_dimensions(analyte: str) -> list[StatisticDimension]:
289313
)
290314
)
291315

316+
device_control_custom_info = {
317+
"ProtocolHeater": header_row.get(str, "ProtocolHeater"),
318+
"DDGate": header_row.get(str, "DDGate"),
319+
"SampleTimeout": header_row.get(str, "SampleTimeout"),
320+
"ProtocolAnalysis": header_row.get(str, "ProtocolAnalysis"),
321+
"ProtocolMicrosphere": header_row.get(str, "ProtocolMicrosphere"),
322+
"PlateReadDirection": header_row.get(str, "PlateReadDirection"),
323+
}
324+
sample_custom_info = {
325+
"BatchDescription": header_row.get(str, "BatchDescription"),
326+
"PanelName": header_row.get(str, "PanelName"),
327+
"BeadType": header_row.get(str, "BeadType"),
328+
}
329+
292330
return Measurement(
293331
identifier=measurement_identifier,
294332
sample_identifier=count_data[str, "Sample"],
@@ -298,6 +336,9 @@ def get_statistic_dimensions(analyte: str) -> list[StatisticDimension]:
298336
analytes=analytes,
299337
errors=errors,
300338
calculated_data=calculated_data,
339+
device_control_custom_info=device_control_custom_info,
340+
sample_custom_info=sample_custom_info,
341+
measurement_custom_info=count_data.get_unread() | header_row.get_unread(),
301342
)
302343

303344
@classmethod
@@ -316,17 +357,24 @@ def _get_errors(
316357
) -> list[str] | None:
317358
if errors_data is None or well_location not in errors_data.index:
318359
return None
319-
return map_rows(
320-
errors_data.loc[[well_location]], lambda data: data[str, "Message"]
321-
)
360+
361+
def extract_message(data: SeriesData) -> str:
362+
message = data[str, "Message"]
363+
# Capture unread error data to prevent warnings
364+
data.get_unread()
365+
return message
366+
367+
return map_rows(errors_data.loc[[well_location]], extract_message)
322368

323369

324370
@dataclass(frozen=True)
325371
class MeasurementList:
326372
measurements: list[Measurement]
327373

328374
@classmethod
329-
def create(cls, results_data: dict[str, pd.DataFrame]) -> MeasurementList:
375+
def create(
376+
cls, results_data: dict[str, pd.DataFrame], header_row: SeriesData
377+
) -> MeasurementList:
330378
if missing_sections := [
331379
section for section in REQUIRED_SECTIONS if section not in results_data
332380
]:
@@ -352,6 +400,7 @@ def create_measurement(count_data: SeriesData) -> Measurement:
352400
results_data=results_data,
353401
count_data=count_data,
354402
bead_ids_data=bead_ids_data,
403+
header_row=header_row,
355404
)
356405

357406
return MeasurementList(map_rows(results_data["Count"], create_measurement))
@@ -365,12 +414,14 @@ class Data:
365414

366415
@classmethod
367416
def create(cls, reader: LuminexXponentReader) -> Data:
417+
header_row = SeriesData(reader.header_data.iloc[0])
418+
368419
return Data(
369420
header=Header.create(
370-
reader.header_data, reader.minimum_assay_bead_count_setting
421+
reader.header_data, header_row, reader.minimum_assay_bead_count_setting
371422
),
372423
calibrations=map_rows(reader.calibration_data, create_calibration),
373-
measurement_list=MeasurementList.create(reader.results_data),
424+
measurement_list=MeasurementList.create(reader.results_data, header_row),
374425
)
375426

376427

@@ -389,6 +440,7 @@ def create_metadata(
389440
software_name=DEFAULT_SOFTWARE_NAME,
390441
software_version=header.software_version,
391442
device_type=DEFAULT_DEVICE_TYPE,
443+
custom_info=header.custom_info,
392444
)
393445

394446

@@ -416,11 +468,24 @@ def create_measurement_groups(
416468
assay_bead_count=measurement.assay_bead_count,
417469
analytes=measurement.analytes,
418470
errors=measurement.errors if measurement.errors else None,
471+
measurement_custom_info=measurement.measurement_custom_info,
472+
sample_custom_info=measurement.sample_custom_info,
473+
device_control_custom_info=measurement.device_control_custom_info,
419474
)
420475
],
476+
custom_info={
477+
"BatchStopTime": header.custom_info.get("BatchStopTime", None),
478+
"ProtocolDescription": header.custom_info.get(
479+
"ProtocolDescription", None
480+
),
481+
},
421482
)
422483
for measurement in measurements
423484
]
485+
# Remove the header custom info that was used to create the measurement groups.
486+
header.custom_info.pop("BatchStopTime", None)
487+
header.custom_info.pop("ProtocolDescription", None)
488+
424489
calculated_documents = list(
425490
itertools.chain(*[measurement.calculated_data for measurement in measurements])
426491
)

0 commit comments

Comments
 (0)