Skip to content

Commit 43701d6

Browse files
feat: Unchained Labs Lunatic & Stunner - Add support for Stunner files (#1054)
* Migrate Unchained Labs Lunatic adapter to plate reader 05/03 schema * Add support for Stunner files * Rename adapter to "Unchained Labs Lunatic & Stunner" * Add support for error documents for fields containing "N/A" value, report calculated data as -0.0 for errored fields.
1 parent 90d6ab2 commit 43701d6

45 files changed

Lines changed: 15087 additions & 5033 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

SUPPORTED_INSTRUMENT_SOFTWARE.adoc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ The parsers follow maturation levels of: Recommended, Candidate Release, Working
4747
|Revvity Kaleido|Recommended|BENCHLING/2023/09
4848
|Tecan Magellan|Recommended|REC/2024/06
4949
|Thermo Fisher Scientific SkanIt|Recommended|REC/2025/03
50-
|Unchained Labs Lunatic|Recommended|REC/2024/06
50+
|Unchained Labs Lunatic & Stunner|Recommended|REC/2025/03
5151
.3+|Solution Analyzer|Beckman Coulter PharmSpec|Recommended|REC/2024/09
5252
|NovaBio Flex2|Recommended|BENCHLING/2024/09
5353
|Roche Cedex BioHT|Recommended|REC/2024/09

docs/parser_requirements/unchained_labs_lunatic/images/image1.png renamed to docs/parser_requirements/unchained_labs_lunatic_stunner/images/image1.png

File renamed without changes.

docs/parser_requirements/unchained_labs_lunatic/images/image2.png renamed to docs/parser_requirements/unchained_labs_lunatic_stunner/images/image2.png

File renamed without changes.

docs/parser_requirements/unchained_labs_lunatic/unchained_labs_lunatic_req.html renamed to docs/parser_requirements/unchained_labs_lunatic_stunner/unchained_labs_lunatic_req.html

File renamed without changes.

src/allotropy/allotrope/converter.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@
104104
"~": "_TILDE_",
105105
"?": "_QMARK_",
106106
"^": "_CARET_",
107+
"=": "_EQUALS_",
108+
"@": "_AT_",
107109
# NOTE: this MUST be at the end, or it will break other key replacements.
108110
" ": "_",
109111
}

src/allotropy/allotrope/schema_mappers/adm/plate_reader/rec/_2025/_03/plate_reader.py

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
MeasurementAggregateDocument,
1818
MeasurementDocument,
1919
Model,
20+
PeakItem,
21+
PeakList,
2022
PlateReaderAggregateDocument,
2123
PlateReaderDocumentItem,
2224
ProcessedDataAggregateDocument,
@@ -27,6 +29,7 @@
2729
from allotropy.allotrope.models.shared.components.plate_reader import SampleRoleType
2830
from allotropy.allotrope.models.shared.definitions.custom import (
2931
TQuantityValueDegreeCelsius,
32+
TQuantityValueKiloDalton,
3033
TQuantityValueMilliAbsorbanceUnit,
3134
TQuantityValueMillimeter,
3235
TQuantityValueMilliSecond,
@@ -120,6 +123,7 @@ class ErrorDocument:
120123
class ProcessedDataDocument:
121124
identifier: str | None = None
122125
concentration_factor: float | None = None
126+
peak_list_custom_info: list[dict[str, Any]] | None = None
123127

124128

125129
@dataclass
@@ -164,6 +168,7 @@ class Measurement:
164168
wavelength_identifier: str | None = None
165169
analytical_method_identifier: str | None = None
166170
experimental_data_identifier: str | None = None
171+
integration_time: float | None = None
167172

168173
auto_focus_enabled_setting: bool | None = None
169174
exposure_duration_setting: float | None = None
@@ -323,6 +328,9 @@ def _get_ultraviolet_absorbance_measurement_document(
323328
TQuantityValueNanometer,
324329
measurement.electronic_absorbance_reference_wavelength_setting,
325330
),
331+
integration_time=quantity_or_none(
332+
TQuantityValueSecondTime, measurement.integration_time
333+
),
326334
device_type=measurement.device_type,
327335
detection_type=measurement.detection_type,
328336
detector_wavelength_setting=quantity_or_none(
@@ -386,12 +394,30 @@ def _get_ultraviolet_absorbance_measurement_document(
386394
processed_data_document=[
387395
ProcessedDataDocumentItem(
388396
processed_data_identifier=measurement.processed_data_document.identifier,
389-
data_processing_document={
390-
"concentration factor": quantity_or_none(
391-
TQuantityValueNanogramPerMicroliter,
392-
measurement.processed_data_document.concentration_factor,
393-
)
394-
},
397+
data_processing_document=(
398+
{
399+
"concentration factor": quantity_or_none(
400+
TQuantityValueNanogramPerMicroliter,
401+
measurement.processed_data_document.concentration_factor,
402+
)
403+
}
404+
if measurement.processed_data_document.concentration_factor
405+
is not None
406+
else None
407+
),
408+
peak_list=PeakList(
409+
peak=[
410+
add_custom_information_document(
411+
PeakItem(),
412+
self._process_peak_custom_info(peak_custom_info),
413+
)
414+
for peak_custom_info in (
415+
measurement.processed_data_document.peak_list_custom_info
416+
)
417+
]
418+
)
419+
if measurement.processed_data_document.peak_list_custom_info
420+
else None,
395421
)
396422
]
397423
)
@@ -834,3 +860,25 @@ def _get_error_aggregate_document(
834860
if error_documents
835861
else None
836862
)
863+
864+
def _process_peak_custom_info(
865+
self, peak_custom_info: dict[str, Any]
866+
) -> dict[str, Any]:
867+
"""Process peak custom info by applying proper units to measured values."""
868+
processed_info: dict[str, Any] = {}
869+
870+
for key, value in peak_custom_info.items():
871+
if key == "peak mean diameter":
872+
processed_info[key] = quantity_or_none(TQuantityValueNanometer, value)
873+
elif key == "peak mode diameter":
874+
processed_info[key] = quantity_or_none(TQuantityValueNanometer, value)
875+
elif key == "peak est. MW":
876+
processed_info[key] = quantity_or_none(TQuantityValueKiloDalton, value)
877+
elif key == "peak intensity":
878+
processed_info[key] = quantity_or_none(TQuantityValuePercent, value)
879+
elif key == "peak mass":
880+
processed_info[key] = quantity_or_none(TQuantityValuePercent, value)
881+
else:
882+
processed_info[key] = value
883+
884+
return processed_info

src/allotropy/calcdocs/config.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ class CalculatedDataConfig:
3030
source_configs: tuple[CalculatedDataConfig | MeasurementConfig, ...]
3131
unit: str | None = None
3232
description: str | None = None
33+
# Optional key to pull a dynamic description from the element data
34+
description_value_key: str | None = None
3335
required: bool = False
3436

3537
def iter_data_sources(
@@ -71,13 +73,23 @@ def _get_calc_doc_inner(
7173
if not data_sources:
7274
return None
7375

76+
if self.name == "B22 goodness of fit":
77+
pass
7478
return CalculatedDocument(
7579
uuid=random_uuid_str(),
7680
name=self.name,
7781
value=value,
7882
data_sources=data_sources,
7983
unit=self.unit,
80-
description=self.description,
84+
description=(
85+
self.description
86+
if self.description is not None
87+
else (
88+
elements[0].get_str_or_none(self.description_value_key)
89+
if self.description_value_key
90+
else None
91+
)
92+
),
8193
)
8294

8395
def get_cache_key(self, keys: Keys) -> str:

src/allotropy/calcdocs/unchained_labs_lunatic/extractor.py renamed to src/allotropy/calcdocs/unchained_labs_lunatic_stunner/extractor.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from allotropy.allotrope.schema_mappers.adm.plate_reader.rec._2024._06.plate_reader import (
1+
from allotropy.allotrope.schema_mappers.adm.plate_reader.rec._2025._03.plate_reader import (
22
Measurement,
33
)
44
from allotropy.calcdocs.extractor import Element, Extractor
@@ -19,6 +19,7 @@ def to_element(cls, measurement: Measurement) -> Element:
1919
"uuid": measurement.identifier,
2020
"wavelength id": measurement.wavelength_identifier,
2121
"absorbance": measurement.absorbance,
22+
"detection type": measurement.detection_type,
2223
**custom_info,
2324
},
2425
)

src/allotropy/calcdocs/unchained_labs_lunatic/views.py renamed to src/allotropy/calcdocs/unchained_labs_lunatic_stunner/views.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,15 @@ def sort_elements(self, elements: list[Element]) -> dict[str, list[Element]]:
1616
if uuid := element.get_str("uuid"):
1717
items[uuid].append(element)
1818
return dict(items)
19+
20+
21+
class DetectionTypeView(View):
22+
def __init__(self, sub_view: View | None = None):
23+
super().__init__(name="detection_type", sub_view=sub_view)
24+
25+
def sort_elements(self, elements: list[Element]) -> dict[str, list[Element]]:
26+
items = defaultdict(list)
27+
for element in elements:
28+
if detection_type := element.get_str("detection type"):
29+
items[detection_type].append(element)
30+
return dict(items)

src/allotropy/parser_factory.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,8 @@
119119
ThermoFisherVisionliteParser,
120120
)
121121
from allotropy.parsers.thermo_skanit.thermo_skanit_parser import ThermoSkanItParser
122-
from allotropy.parsers.unchained_labs_lunatic.unchained_labs_lunatic_parser import (
123-
UnchainedLabsLunaticParser,
122+
from allotropy.parsers.unchained_labs_lunatic_stunner.unchained_labs_lunatic_stunner_parser import (
123+
UnchainedLabsLunaticStunnerParser,
124124
)
125125
from allotropy.parsers.utils.timestamp_parser import TimestampParser
126126
from allotropy.parsers.vendor_parser import VendorParser
@@ -273,7 +273,7 @@ def get_parser(
273273
Vendor.THERMO_FISHER_QUBIT_FLEX: ThermoFisherQubitFlexParser,
274274
Vendor.THERMO_FISHER_VISIONLITE: ThermoFisherVisionliteParser,
275275
Vendor.THERMO_SKANIT: ThermoSkanItParser,
276-
Vendor.UNCHAINED_LABS_LUNATIC: UnchainedLabsLunaticParser,
276+
Vendor.UNCHAINED_LABS_LUNATIC: UnchainedLabsLunaticStunnerParser,
277277
}
278278

279279

0 commit comments

Comments
 (0)