Skip to content

Commit 2445c0c

Browse files
feat: Add Beckman Echo Cherry Pick parser (#1155)
## Summary - Added new parser for Beckman Echo Cherry Pick instrument files - Based on existing Echo Plate Reformat parser with Cherry Pick specific modifications - Includes test data and passes all tests ## Changes - New parser module in `src/allotropy/parsers/beckman_echo_cherry_pick/` - Added Cherry Pick specific fields: - Sample and Control Pick List File Names - Reference ID and Order ID - Destination Well X/Y Offset fields - Updated parser factory to include new parser - Added to SUPPORTED_INSTRUMENT_SOFTWARE documentation - Includes test file with comprehensive test coverage ## Test plan - [x] Tests pass for new Cherry Pick parser - [x] All existing tests continue to pass - [x] Linting and type checking pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.1 <noreply@anthropic.com>
1 parent 0179399 commit 2445c0c

11 files changed

Lines changed: 23739 additions & 1 deletion

SUPPORTED_INSTRUMENT_SOFTWARE.adoc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ The parsers follow maturation levels of: Recommended, Candidate Release, Working
3131
|Benchling Waters Empower Adapter|Recommended|BENCHLING/2023/09
3232
|Cytiva Unicorn|Recommended|BENCHLING/2023/09
3333
|Benchling Thermo Fisher Scientific Chromeleon|Recommended|BENCHLING/2023/09
34-
.2+|Liquid Handler|Beckman Coulter Biomek|Recommended|BENCHLING/2024/11
34+
.3+|Liquid Handler|Beckman Coulter Biomek|Recommended|BENCHLING/2024/11
35+
|Beckman Echo Cherry Pick|Recommended|BENCHLING/2024/11
3536
|Beckman Echo Plate Reformat|Recommended|BENCHLING/2024/11
3637
.3+|Multi Analyte Profiling|Bio-Rad Bio-Plex Manager|Recommended|BENCHLING/2024/09
3738
|Luminex INTELLIFLEX|Recommended|BENCHLING/2024/09

src/allotropy/parser_factory.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
from allotropy.parsers.beckman_coulter_biomek.beckman_coulter_biomek_parser import (
3131
BeckmanCoulterBiomekParser,
3232
)
33+
from allotropy.parsers.beckman_echo_cherry_pick.beckman_echo_cherry_pick_parser import (
34+
BeckmanEchoCherryPickParser,
35+
)
3336
from allotropy.parsers.beckman_echo_plate_reformat.beckman_echo_plate_reformat_parser import (
3437
BeckmanEchoPlateReformatParser,
3538
)
@@ -142,6 +145,7 @@ class Vendor(Enum):
142145
APPBIO_QUANTSTUDIO_DESIGNANDANALYSIS = "APPBIO_QUANTSTUDIO_DESIGNANDANALYSIS"
143146
BENCHLING_EMPOWER = "BENCHLING_EMPOWER"
144147
BECKMAN_COULTER_BIOMEK = "BECKMAN_COULTER_BIOMEK"
148+
BECKMAN_ECHO_CHERRY_PICK = "BECKMAN_ECHO_CHERRY_PICK"
145149
BECKMAN_ECHO_PLATE_REFORMAT = "BECKMAN_ECHO_PLATE_REFORMAT"
146150
BMG_LABTECH_SMART_CONTROL = "BMG_LABTECH_SMART_CONTROL"
147151
BMG_MARS = "BMG_MARS"
@@ -244,6 +248,7 @@ def get_parser(
244248
Vendor.APPBIO_QUANTSTUDIO: AppBioQuantStudioParser,
245249
Vendor.APPBIO_QUANTSTUDIO_DESIGNANDANALYSIS: AppBioQuantStudioDesignandanalysisParser,
246250
Vendor.BECKMAN_COULTER_BIOMEK: BeckmanCoulterBiomekParser,
251+
Vendor.BECKMAN_ECHO_CHERRY_PICK: BeckmanEchoCherryPickParser,
247252
Vendor.BECKMAN_ECHO_PLATE_REFORMAT: BeckmanEchoPlateReformatParser,
248253
Vendor.BECKMAN_PHARMSPEC: PharmSpecParser,
249254
Vendor.BECKMAN_VI_CELL_BLU: ViCellBluParser,

src/allotropy/parsers/beckman_echo_cherry_pick/__init__.py

Whitespace-only changes.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import pandas as pd
2+
3+
from allotropy.allotrope.models.adm.liquid_handler.benchling._2024._11.liquid_handler import (
4+
Model,
5+
)
6+
from allotropy.allotrope.schema_mappers.adm.liquid_handler.benchling._2024._11.liquid_handler import (
7+
Data,
8+
Mapper,
9+
)
10+
from allotropy.named_file_contents import NamedFileContents
11+
from allotropy.parsers.beckman_echo_cherry_pick.beckman_echo_cherry_pick_reader import (
12+
BeckmanEchoCherryPickReader,
13+
)
14+
from allotropy.parsers.beckman_echo_cherry_pick.beckman_echo_cherry_pick_structure import (
15+
create_measurement_groups,
16+
create_metadata,
17+
)
18+
from allotropy.parsers.beckman_echo_cherry_pick.constants import DISPLAY_NAME
19+
from allotropy.parsers.release_state import ReleaseState
20+
from allotropy.parsers.vendor_parser import VendorParser
21+
22+
23+
class BeckmanEchoCherryPickParser(VendorParser[Data, Model]):
24+
DISPLAY_NAME = DISPLAY_NAME
25+
RELEASE_STATE = ReleaseState.RECOMMENDED
26+
SUPPORTED_EXTENSIONS = BeckmanEchoCherryPickReader.SUPPORTED_EXTENSIONS
27+
SCHEMA_MAPPER = Mapper
28+
29+
def create_data(self, named_file_contents: NamedFileContents) -> Data:
30+
reader = BeckmanEchoCherryPickReader(named_file_contents)
31+
measurement_groups = create_measurement_groups(
32+
pd.concat(reader.sections.values(), ignore_index=True), reader.header
33+
)
34+
return Data(
35+
create_metadata(reader.header, named_file_contents.original_file_path),
36+
measurement_groups,
37+
)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from io import StringIO
2+
import re
3+
4+
import numpy as np
5+
import pandas as pd
6+
7+
from allotropy.exceptions import AllotropeConversionError
8+
from allotropy.named_file_contents import NamedFileContents
9+
from allotropy.parsers.lines_reader import (
10+
LinesReader,
11+
)
12+
from allotropy.parsers.utils.pandas import (
13+
df_to_series_data,
14+
read_csv,
15+
SeriesData,
16+
)
17+
from allotropy.parsers.utils.values import assert_not_none
18+
19+
20+
class BeckmanEchoCherryPickReader:
21+
SUPPORTED_EXTENSIONS = "csv"
22+
header: SeriesData
23+
sections: dict[str, pd.DataFrame]
24+
25+
def __init__(self, named_file_contents: NamedFileContents) -> None:
26+
reader = LinesReader.create(named_file_contents)
27+
28+
# read header section, up to a data section delimiter
29+
header_lines = [
30+
line.strip() for line in reader.pop_until(r"^\[.+\]") if line.strip()
31+
]
32+
if not header_lines:
33+
msg = "Cannot parse data from empty header."
34+
raise AllotropeConversionError(msg)
35+
36+
# now read multiple sections, each starting with a section title: '[Section Title]', with header row and multiple data rows.
37+
sections = {}
38+
39+
# we expect to have at least one section, and that the current line of the reader is at the title
40+
# (due to reader.pop_until(r"^\[.+\]") above)
41+
while not reader.is_empty():
42+
match = re.match(
43+
r"^\[(.+)\]",
44+
assert_not_none(reader.pop(), "Unexpected empty section"),
45+
)
46+
if match:
47+
title = str(
48+
assert_not_none(
49+
match, f"Cannot read title section: {reader.get()}"
50+
).groups()[0]
51+
)
52+
data_lines = list(
53+
reader.pop_until_empty()
54+
) # read all lines of section, including header line
55+
reader.drop_empty() # sections are separated by empty lines
56+
57+
sections[title] = read_csv(
58+
StringIO("\n".join(data_lines)), sep=","
59+
).replace(np.nan, None)
60+
else:
61+
break
62+
63+
# read footer section, after last tabular data section
64+
footer_lines = list(reader.pop_until_empty())
65+
header_lines.extend(footer_lines)
66+
67+
# parse header/footer to SeriesData
68+
with StringIO("\n".join(header_lines)) as csv_stream:
69+
raw_data = read_csv(
70+
csv_stream, header=None, sep=",", skipinitialspace=True, index_col=0
71+
)
72+
raw_data.index = raw_data.index.str.replace("*", "")
73+
header = df_to_series_data(raw_data.T.replace(np.nan, None))
74+
75+
self.header = header
76+
self.sections = sections
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
from functools import partial
2+
from pathlib import Path
3+
4+
import pandas as pd
5+
6+
from allotropy.allotrope.schema_mappers.adm.liquid_handler.benchling._2024._11.liquid_handler import (
7+
Error,
8+
Measurement,
9+
MeasurementGroup,
10+
Metadata,
11+
)
12+
from allotropy.exceptions import AllotropeConversionError
13+
from allotropy.parsers.beckman_echo_cherry_pick import constants
14+
from allotropy.parsers.constants import NOT_APPLICABLE
15+
from allotropy.parsers.utils.pandas import map_rows, SeriesData
16+
from allotropy.parsers.utils.uuids import random_uuid_str
17+
18+
19+
def create_metadata(header_footer_data: SeriesData, file_path: str) -> Metadata:
20+
path = Path(file_path)
21+
return Metadata(
22+
file_name=path.name,
23+
asm_file_identifier=path.with_suffix(".json").name,
24+
unc_path=str(path),
25+
data_system_instance_identifier=NOT_APPLICABLE,
26+
device_type=constants.DEVICE_TYPE,
27+
product_manufacturer=constants.PRODUCT_MANUFACTURER,
28+
software_version=header_footer_data[str, "Application Version"],
29+
model_number=header_footer_data.get(str, "Instrument Model"),
30+
equipment_serial_number=header_footer_data.get(str, "Instrument Serial Number"),
31+
software_name=header_footer_data[str, "Application Name"],
32+
device_system_custom_info={
33+
"Instrument Software Version": header_footer_data.get(
34+
str, "Instrument Software Version"
35+
)
36+
},
37+
custom_info=header_footer_data.get_unread(),
38+
)
39+
40+
41+
def _create_measurement(row_data: SeriesData, run_date_time: str | None) -> Measurement:
42+
def convert_echo_nl_to_ul(value: float | None) -> float | None:
43+
return (
44+
(value * constants.CHERRY_PICK_REPORT_VOLUME_CONVERSION_TO_UL)
45+
if value is not None
46+
else None
47+
)
48+
49+
# If the Date Time Point only contains time (no date) combine with Run Date/Time to create measurement time.
50+
# We detect if there is a date in the timestamp by checking for a space, and if there is no space, add the date
51+
# component of
52+
date_time_point = row_data[str, "Date Time Point"]
53+
if " " not in date_time_point.strip():
54+
if not run_date_time or " " not in run_date_time:
55+
msg = "Cannot parse timestamp for measurement, 'Date Time Point' does not contain a date (time only) and there is no valid 'Run Date/Time' in the header to infer date from."
56+
raise AllotropeConversionError(msg)
57+
run_date_time_date = run_date_time.split(" ")
58+
date_time_point = f"{run_date_time_date[0]} {date_time_point}"
59+
60+
return Measurement(
61+
identifier=random_uuid_str(),
62+
measurement_time=date_time_point,
63+
sample_identifier=row_data.get(str, "Sample ID", NOT_APPLICABLE),
64+
source_plate=row_data[str, "Source Plate Barcode"],
65+
source_well=row_data[str, "Source Well"],
66+
source_location=row_data[str, "Source Plate Name"],
67+
destination_plate=row_data[str, "Destination Plate Barcode"],
68+
destination_well=row_data[str, "Destination Well"],
69+
destination_location=row_data[str, "Destination Plate Name"],
70+
aspiration_volume=convert_echo_nl_to_ul(row_data.get(float, "Actual Volume")),
71+
transfer_volume=convert_echo_nl_to_ul(row_data.get(float, "Actual Volume")),
72+
injection_volume_setting=convert_echo_nl_to_ul(
73+
row_data.get(float, "Transfer Volume")
74+
),
75+
device_control_custom_info={
76+
"sample name": row_data.get(str, "Sample Name"),
77+
"destination well x offset": row_data.get(
78+
float, "Destination Well X Offset"
79+
),
80+
"destination well y offset": row_data.get(
81+
float, "Destination Well Y Offset"
82+
),
83+
"current fluid volume": row_data.get(
84+
float, "Current Fluid Volume"
85+
), # This is already in uL, so don't convert to nL
86+
"intended transfer volume": convert_echo_nl_to_ul(
87+
row_data.get(float, "Transfer Volume")
88+
),
89+
"source labware name": row_data.get(str, "Source Plate Type"),
90+
"destination labware name": row_data.get(str, "Destination Plate Type"),
91+
"fluid composition": row_data.get(str, "Fluid Composition"),
92+
"fluid units": row_data.get(str, "Fluid Units"),
93+
"fluid type": row_data.get(str, "Fluid Type"),
94+
"transfer status": row_data.get(str, "Transfer Status", NOT_APPLICABLE),
95+
},
96+
errors=[
97+
Error(
98+
error=row_data[str, "Transfer Status"],
99+
feature=row_data[str, "Transfer Status"].split(": ")[0],
100+
)
101+
]
102+
if row_data.get(str, "Transfer Status")
103+
else [],
104+
custom_info=row_data.get_unread(),
105+
)
106+
107+
108+
def create_measurement_groups(
109+
data: pd.DataFrame, header: SeriesData
110+
) -> list[MeasurementGroup]:
111+
run_date_time = header.get(str, "Run Date/Time")
112+
create_measurement = partial(_create_measurement, run_date_time=run_date_time)
113+
return [
114+
MeasurementGroup(
115+
analyst=header[str, "User Name"],
116+
analytical_method_identifier=header.get(str, "Protocol Name"),
117+
experimental_data_identifier=header.get(str, "Run ID"),
118+
measurements=map_rows(data, create_measurement),
119+
custom_info={
120+
"Run Date/Time": run_date_time,
121+
"Sample Pick List File Name": header.get(
122+
str, "Sample Pick List File Name"
123+
),
124+
"Control Pick List File Name": header.get(
125+
str, "Control Pick List File Name"
126+
),
127+
"Reference ID": header.get(str, "Reference ID"),
128+
"Order ID": header.get(str, "Order ID"),
129+
},
130+
)
131+
]
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
DISPLAY_NAME = "Beckman Echo Cherry Pick"
2+
DEVICE_TYPE = "liquid handler"
3+
PRODUCT_MANUFACTURER = "Beckman Coulter"
4+
SOFTWARE_NAME = "Labcyte Echo Cherry Pick"
5+
6+
TRANSFER_RESULTS_LABEL = "DETAILS"
7+
TRANSFER_ERRORS_LABEL = "EXCEPTIONS"
8+
9+
CHERRY_PICK_REPORT_VOLUME_UNITS = "nL"
10+
CHERRY_PICK_REPORT_VOLUME_CONVERSION_TO_UL = 0.001

tests/parsers/beckman_echo_cherry_pick/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)