11from __future__ import annotations
22
3+ from decimal import Decimal
34from pathlib import Path
45import re
56from typing import Any
67
78import 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+ )
917from allotropy .allotrope .schema_mappers .adm .plate_reader .rec ._2025 ._03 .plate_reader import (
1018 ErrorDocument ,
1119 Measurement ,
1422 Metadata ,
1523 ProcessedDataDocument ,
1624)
25+ from allotropy .allotrope .schema_mappers .data_cube import DataCube , DataCubeComponent
1726from allotropy .exceptions import AllotropeConversionError
1827from allotropy .parsers .constants import NEGATIVE_ZERO , NOT_APPLICABLE
1928from allotropy .parsers .unchained_labs_lunatic_stunner .constants import (
@@ -121,20 +130,28 @@ def _is_literal_not_applicable(series: SeriesData, key: str) -> bool:
121130def _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+
274429def _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