Skip to content

Commit 05cc0d3

Browse files
fix: Map missing fields in Chromeleon parser for adapter migration (#1207)
## Summary - Fixes crash when `device_information` is `null` in raw JSON (Glycans sequence) - Maps `analyst` and `submitter` per LC document from injection-level fields - Maps `injection name` → `sample document.written name` - Uses `signal name` as `device type` instead of hardcoded "HPLC" - Uses `sampler model number` as `asset management identifier` when available - Merges non-null custom variables into injection custom info ## Context The Chromeleon adapter is migrating from producing ASM directly to producing a raw JSON dump parsed by the allotropy `BENCHLING_CHROMELEON` parser. The raw JSON contains all the data but several critical fields were not being mapped into ASM output. This PR closes all mapping gaps so the new pipeline produces equivalent output to the old adapter. ## Test plan - [x] All 8 production sequences from output.zip parse successfully (including previously-crashing Glycans) - [x] No data loss in existing test output (verified via DeepDiff) - [x] New test cases added: null device_information + custom variables + non-UV signals, and multi-signal + valid sampler model - [x] Other parsers using same schema mapper (cytiva_unicorn, benchling_empower, agilent_openlab_cds) still pass - [x] Vendor discovery tests pass (313 tests) - [x] Lint passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4739417 commit 05cc0d3

9 files changed

Lines changed: 7064 additions & 6049 deletions

File tree

src/allotropy/allotrope/schema_mappers/adm/liquid_chromatography/benchling/_2023/_09/liquid_chromatography.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@ class Measurement:
244244
@dataclass(frozen=True)
245245
class MeasurementGroup:
246246
measurements: list[Measurement]
247+
analyst: str | None = None
248+
submitter: str | None = None
247249
fractions: list[Fraction] | None = None
248250
logs: list[Log] | None = None
249251
measurement_aggregate_custom_info: dict[str, Any] | None = None
@@ -317,7 +319,8 @@ def _get_technique_document(
317319
self, group: MeasurementGroup, metadata: Metadata
318320
) -> LiquidChromatographyDocumentItem:
319321
return LiquidChromatographyDocumentItem(
320-
analyst=metadata.analyst,
322+
analyst=group.analyst or metadata.analyst,
323+
submitter=group.submitter,
321324
measurement_aggregate_document=add_custom_information_document(
322325
MeasurementAggregateDocument(
323326
measurement_document=[

src/allotropy/parsers/benchling_chromeleon/benchling_chromeleon_reader.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ class BenchlingChromeleonReader:
1414
def __init__(self, named_file_contents: NamedFileContents) -> None:
1515
contents: dict[str, Any] = json.load(named_file_contents.contents)
1616
self.sequence: dict[str, Any] = contents.get("sequence", {})
17-
self.device_information: dict[str, Any] = contents.get("device information", {})
17+
self.device_information: dict[str, Any] = (
18+
contents.get("device information") or {}
19+
)
1820
self.injections: list[dict[str, Any]] = assert_not_none(
1921
contents.get("injections"), "injections"
2022
)

src/allotropy/parsers/benchling_chromeleon/benchling_chromeleon_structure.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@
2828

2929

3030
def _create_device_documents(
31-
device_information: dict[str, Any]
31+
device_information: dict[str, Any] | None,
3232
) -> list[DeviceDocument] | None:
33+
if not device_information:
34+
return None
3335
return [
3436
DeviceDocument(
3537
device_type="pump",
@@ -51,12 +53,15 @@ def create_metadata(
5153
first_injection: dict[str, Any],
5254
sequence: dict[str, Any],
5355
file_path: str,
54-
device_information: dict[str, Any],
56+
device_information: dict[str, Any] | None,
5557
) -> Metadata:
58+
asset_management_identifier = (
59+
(device_information.get("sampler model number") if device_information else None)
60+
or first_injection.get("precondition system instrument name")
61+
or NOT_APPLICABLE
62+
)
5663
return Metadata(
57-
asset_management_identifier=first_injection.get(
58-
"precondition system instrument name", NOT_APPLICABLE
59-
),
64+
asset_management_identifier=asset_management_identifier,
6065
software_name=constants.SOFTWARE_NAME,
6166
file_name=Path(file_path).name,
6267
unc_path=file_path,
@@ -223,6 +228,7 @@ def _create_measurements(injection: dict[str, Any]) -> list[Measurement] | None:
223228
measurement_identifier=random_uuid_str(),
224229
description=injection.get("description"),
225230
sample_identifier=injection["sample identifier"],
231+
written_name=injection.get("injection name"),
226232
location_identifier=injection.get("location identifier"),
227233
well_location_identifier=injection.get("custom variables", {}).get("Well"),
228234
observation=injection.get("custom variables", {}).get("Observation"),
@@ -239,7 +245,7 @@ def _create_measurements(injection: dict[str, Any]) -> list[Measurement] | None:
239245
},
240246
device_control_docs=[
241247
DeviceControlDoc(
242-
device_type=constants.DEVICE_TYPE,
248+
device_type=signal.get("signal name", constants.DEVICE_TYPE),
243249
detection_type=signal.get("detection type"),
244250
detector_offset_setting=val
245251
if (val := signal.get("detector offset setting")) != "unknown"
@@ -271,6 +277,11 @@ def _create_measurements(injection: dict[str, Any]) -> list[Measurement] | None:
271277
"creation user name": injection.get("creation user name"),
272278
"injection program": injection.get("injection program"),
273279
"injection method": injection.get("injection method"),
280+
**{
281+
k: v
282+
for k, v in injection.get("custom variables", {}).items()
283+
if v is not None
284+
},
274285
},
275286
chromatography_serial_num=NOT_APPLICABLE,
276287
chromatogram_data_cube=_get_chromatogram(signal) if signal else None,
@@ -284,7 +295,11 @@ def create_measurement_groups(
284295
injections: list[dict[str, Any]]
285296
) -> list[MeasurementGroup]:
286297
return [
287-
MeasurementGroup(measurements=measurements)
288-
for sample_injections in injections
289-
if (measurements := _create_measurements(sample_injections)) is not None
298+
MeasurementGroup(
299+
measurements=measurements,
300+
analyst=injection.get("last update user name"),
301+
submitter=injection.get("creation user name"),
302+
)
303+
for injection in injections
304+
if (measurements := _create_measurements(injection)) is not None
290305
]

tests/parsers/benchling_chromeleon/benchling_chromeleon_structure_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ def test_create_metadata(mock_data: dict[str, Any]) -> None:
117117
)
118118

119119
assert isinstance(metadata, Metadata)
120-
assert metadata.asset_management_identifier == "Instrument123"
120+
assert metadata.asset_management_identifier == "WPS-3000RS"
121121
assert metadata.software_name == constants.SOFTWARE_NAME
122122
assert metadata.file_name == "test_file.json"
123123
assert metadata.unc_path == "/data/test_file.json"
@@ -173,7 +173,7 @@ def test_create_measurement_groups(mock_data: dict[str, Any]) -> None:
173173

174174
device_control_doc = measurement.device_control_docs[0]
175175
assert device_control_doc is not None
176-
assert device_control_doc.device_type == constants.DEVICE_TYPE
176+
assert device_control_doc.device_type == "UV_VIS_1"
177177
assert device_control_doc.detection_type == "single channel"
178178
assert device_control_doc.electronic_absorbance_reference_bandwidth_setting == 1.0
179179
assert device_control_doc.electronic_absorbance_reference_wavelength_setting == 0.0
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
{
2+
"device information": {
3+
"pump model number": "VF-P10-A",
4+
"uv model number": "VF-D40-A",
5+
"sampler model number": "VF-A10-A"
6+
},
7+
"sequence": {
8+
"sequence creation time": "2026-02-13T10:00:00+01:00",
9+
"sequence directory": "ChromeleonLocal",
10+
"sequence name": "Test_MultiSignal",
11+
"sequence update operator": "operator1",
12+
"sequence update time": "2026-02-14T15:00:00+01:00",
13+
"custom formulas": []
14+
},
15+
"injections": [
16+
{
17+
"injection number": 1,
18+
"injection name": "MQW",
19+
"injection identifier": "99999999-8888-7777-6666-555555555555",
20+
"injection time": "2026-02-14T21:52:50+01:00",
21+
"injection position": "B:A1",
22+
"injection status": "Finished",
23+
"injection type": "Unknown",
24+
"description": "",
25+
"sample identifier": "",
26+
"location identifier": "B:A1",
27+
"last update user name": "Instrument Controller",
28+
"creation user name": "PD01",
29+
"detection type": "single channel",
30+
"injection volume setting": 4.0,
31+
"injection volume setting unit": "uL",
32+
"custom variables": {
33+
"ColumnValvePosition": 1.0
34+
},
35+
"signals": [
36+
{
37+
"signal name": "UV_VIS_1",
38+
"detection type": "single channel",
39+
"detector sampling rate setting": "unknown",
40+
"detector offset setting": "unknown",
41+
"bandwidth setting": 4.0,
42+
"wavelength setting": 220.0,
43+
"reference bandwidth setting": null,
44+
"reference wavelength setting": null,
45+
"chromatogram": {
46+
"x": [
47+
0.0,
48+
0.5,
49+
1.0,
50+
1.5,
51+
2.0
52+
],
53+
"y": [
54+
0.0,
55+
0.5,
56+
1.0,
57+
0.5,
58+
0.0
59+
]
60+
},
61+
"peaks": []
62+
},
63+
{
64+
"signal name": "UV_VIS_2",
65+
"detection type": "single channel",
66+
"detector sampling rate setting": "unknown",
67+
"detector offset setting": "unknown",
68+
"bandwidth setting": 4.0,
69+
"wavelength setting": 280.0,
70+
"reference bandwidth setting": null,
71+
"reference wavelength setting": null,
72+
"chromatogram": {
73+
"x": [
74+
0.0,
75+
0.5,
76+
1.0,
77+
1.5,
78+
2.0
79+
],
80+
"y": [
81+
0.0,
82+
0.3,
83+
0.8,
84+
0.3,
85+
0.0
86+
]
87+
},
88+
"peaks": [
89+
{
90+
"identifier": "1",
91+
"name": "Peak1",
92+
"start time": 0.4,
93+
"end time": 1.6,
94+
"retention time": 1.0,
95+
"area": 8000.0,
96+
"height": 800.0,
97+
"relative peak area": 100.0,
98+
"relative peak height": 100.0,
99+
"peak width at half height": 0.6,
100+
"peak width at 5 % of height": null,
101+
"peak width at 10 % of height": null,
102+
"peak width at baseline": null,
103+
"amount": null,
104+
"relative retention time": null,
105+
"capacity factor": null,
106+
"chromatographic peak resolution": null,
107+
"number of theoretical plates by peak width at half height": null,
108+
"asymmetry factor measured at 5 % height": null,
109+
"start value baseline": null,
110+
"stop value baseline": null,
111+
"peak right width at 10 % of height": null,
112+
"peak left width at 10 % of height": null,
113+
"group area": null,
114+
"rel ce area total": null,
115+
"chromatographic peak resolution (USP)": null,
116+
"asymmetry aia": null
117+
}
118+
]
119+
}
120+
]
121+
}
122+
]
123+
}

0 commit comments

Comments
 (0)