Skip to content

Commit 0849e03

Browse files
feat: Beckman Coulter Biomek Liquid Handler - add support for log headerless files (#1078)
1 parent df24d50 commit 0849e03

4 files changed

Lines changed: 953 additions & 14 deletions

File tree

Lines changed: 82 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import re
2+
from typing import ClassVar
3+
14
import pandas as pd
25

36
from allotropy.named_file_contents import NamedFileContents
@@ -11,18 +14,85 @@ class BeckmanCoulterBiomekReader:
1114
header: SeriesData
1215
data: pd.DataFrame
1316

17+
# Fixed column names based on headerless files
18+
FIXED_COLUMN_NAMES: ClassVar[list[str]] = [
19+
"Time Stamp",
20+
"Pod",
21+
"Transfer Step",
22+
"Deck Position",
23+
"Labware Name",
24+
"Labware Barcode",
25+
"Well Index",
26+
"Sample Name",
27+
"Probe",
28+
"Amount",
29+
"Liquid Handling Technique",
30+
]
31+
32+
def _looks_like_data_line(self, line: str) -> bool:
33+
"""
34+
Check if a line looks like data rather than metadata.
35+
Data lines should have a date/time pattern as the first field.
36+
"""
37+
parts = line.split(",")
38+
if len(parts) < 3:
39+
return False
40+
41+
first_part = parts[0].strip()
42+
# Check if first part looks like a timestamp (contains numbers and slashes or dashes)
43+
# Examples: "08/15/2025 10:59:55", "9/4/24 14:15"
44+
timestamp_pattern = r"^\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\s+\d{1,2}:\d{2}"
45+
return bool(re.match(timestamp_pattern, first_part))
46+
1447
def __init__(self, named_file_contents: NamedFileContents) -> None:
1548
reader = CsvReader.create(named_file_contents)
1649

17-
header_lines = list(reader.pop_until(".*Well Index.*"))
18-
header_dict = {}
19-
for line in header_lines:
20-
split = line.split(",")[0].split("=", maxsplit=1)
21-
if len(split) != 2:
22-
continue
23-
header_dict[split[0].strip()] = split[1].strip()
24-
25-
self.header = SeriesData(pd.Series(header_dict))
26-
self.data = assert_not_none(
27-
reader.pop_csv_block_as_df(header="infer"), "Cannot parse empty dataset"
28-
)
50+
# Check if this file has a header line by looking for "Well Index"
51+
has_header_line = any("Well Index" in line for line in reader.lines)
52+
53+
if has_header_line:
54+
header_lines = list(reader.pop_until(".*Well Index.*"))
55+
header_dict = {}
56+
for line in header_lines:
57+
split = line.split(",")[0].split("=", maxsplit=1)
58+
if len(split) != 2:
59+
continue
60+
header_dict[split[0].strip()] = split[1].strip()
61+
62+
self.header = SeriesData(pd.Series(header_dict))
63+
self.data = assert_not_none(
64+
reader.pop_csv_block_as_df(header="infer"), "Cannot parse empty dataset"
65+
)
66+
else:
67+
# Handle headerless files
68+
header_lines = []
69+
data_lines = []
70+
71+
for raw_line in reader.lines:
72+
line = raw_line.strip()
73+
if not line:
74+
continue
75+
76+
if self._looks_like_data_line(line):
77+
data_lines.append(line)
78+
else:
79+
header_lines.append(line)
80+
81+
# Parse header metadata
82+
header_dict = {}
83+
for line in header_lines:
84+
split = line.split(",")[0].split("=", maxsplit=1)
85+
if len(split) != 2:
86+
continue
87+
header_dict[split[0].strip()] = split[1].strip()
88+
89+
self.header = SeriesData(pd.Series(header_dict))
90+
91+
# Create a new reader with just the data lines
92+
data_reader = CsvReader(data_lines)
93+
self.data = assert_not_none(
94+
data_reader.pop_csv_block_as_df(
95+
header=None, names=self.FIXED_COLUMN_NAMES
96+
),
97+
"Cannot parse empty dataset",
98+
)

src/allotropy/parsers/beckman_coulter_biomek/beckman_coulter_biomek_structure.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,14 @@ def _create_measurement(
5151
identifier=random_uuid_str(),
5252
measurement_time=dispense_data[str, "Time Stamp"],
5353
sample_identifier=NOT_APPLICABLE,
54-
source_plate=aspiration_data[str, "Labware Barcode"],
54+
source_plate=aspiration_data.get(
55+
str, "Labware Barcode", validate=SeriesData.NOT_NAN
56+
),
5557
source_well=aspiration_data[str, "Well Index"],
5658
source_location=aspiration_data[str, "Deck Position"],
57-
destination_plate=dispense_data[str, "Labware Barcode"],
59+
destination_plate=dispense_data.get(
60+
str, "Labware Barcode", validate=SeriesData.NOT_NAN
61+
),
5862
destination_well=dispense_data[str, "Well Index"],
5963
destination_location=dispense_data[str, "Deck Position"],
6064
aspiration_volume=aspiration_data[float, "Amount"],

0 commit comments

Comments
 (0)