Skip to content

Commit df24d50

Browse files
feat: Migrate ctl_immunospot to use SeriesData.get_unread (#1076)
Co-authored-by: Nathan Stender <nathan.stender@benchling.com>
1 parent 72c9166 commit df24d50

8 files changed

Lines changed: 7466 additions & 684 deletions

File tree

src/allotropy/allotrope/converter.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from collections.abc import Callable, Mapping, Sequence
55
from dataclasses import asdict, field, fields, is_dataclass, make_dataclass, MISSING
66
from enum import Enum
7+
import keyword
78
from types import GenericAlias, UnionType
89
from typing import (
910
Any,
@@ -164,6 +165,8 @@ def add_custom_information_document(
164165

165166
def _convert_model_key_to_dict_key(key: str) -> str:
166167
key = SPECIAL_KEYS.get(key, key)
168+
if key.startswith("_KW"):
169+
key = key[3:]
167170
if key.startswith("___") and key[3].isdigit():
168171
key = key[3:]
169172
for dict_val, model_val in DICT_KEY_TO_MODEL_KEY_REPLACEMENTS.items():
@@ -173,6 +176,8 @@ def _convert_model_key_to_dict_key(key: str) -> str:
173176

174177
def _convert_dict_to_model_key(key: str) -> str:
175178
key = SPECIAL_KEYS_INVERSE.get(key, key)
179+
if keyword.iskeyword(key):
180+
key = f"_KW{key}"
176181
if key[0].isdigit():
177182
key = f"___{key}"
178183
for dict_val, model_val in DICT_KEY_TO_MODEL_KEY_REPLACEMENTS.items():

src/allotropy/parsers/ctl_immunospot/ctl_immunospot_reader.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,6 @@ def _read_plate_data_7_0_38(self, reader: CsvReader) -> list[pd.DataFrame]:
202202
return plates
203203

204204
def _read_header(self, reader: CsvReader) -> SeriesData:
205-
lines = [line.strip() for line in reader.pop_until_empty(EMPTY_STR_OR_CSV_LINE)]
206-
207205
def fix_line(line: str) -> str:
208206
# Add missing key for file path line.
209207
if line.endswith(".txt") or line.endswith(".xls"):
@@ -213,13 +211,47 @@ def fix_line(line: str) -> str:
213211
line = f"Review Date: {date_str}"
214212
return line.strip()
215213

216-
lines = [fix_line(line) for raw_line in lines for line in raw_line.split(";")]
214+
def split_multi_key_line(line: str) -> list[str]:
215+
"""Split lines that contain multiple key-value pairs into separate lines."""
216+
line = line.strip(' "') # Clean up quotes and whitespace
217+
218+
# Case 1: Parenthetical format: (Auto Areas: Estimated, Manual Areas: Normalized)
219+
if line.startswith("(") and line.endswith(")") and "," in line:
220+
return [pair.strip() for pair in line[1:-1].split(",")]
221+
222+
# Case 2: Tab-separated format: Assay: 1\t\t\t\t\t\tEdge Compensation Level: 1.0
223+
if "\t" in line and line.count(":") > 1:
224+
return [
225+
part.strip()
226+
for part in line.split("\t")
227+
if part.strip() and ":" in part
228+
]
229+
230+
return [line]
231+
232+
def process_lines(raw_lines: list[str]) -> list[str]:
233+
"""Process a list of raw lines and return split/fixed lines."""
234+
processed = []
235+
for raw_line in raw_lines:
236+
for line in raw_line.split(";"):
237+
fixed_line = fix_line(line)
238+
# Only process lines that have colons (key: value format)
239+
if ":" in fixed_line:
240+
processed.extend(split_multi_key_line(fixed_line))
241+
return processed
242+
243+
# Process initial header lines
244+
lines = [line.strip() for line in reader.pop_until_empty(EMPTY_STR_OR_CSV_LINE)]
245+
all_lines = process_lines(lines)
246+
247+
# Process additional header lines if they exist
217248
reader.drop_empty(EMPTY_STR_OR_CSV_LINE)
218249
if ":" in (reader.get() or ""):
219-
lines.extend(list(reader.pop_until_empty(EMPTY_STR_OR_CSV_LINE)))
250+
additional_lines = list(reader.pop_until_empty(EMPTY_STR_OR_CSV_LINE))
251+
all_lines.extend(process_lines(additional_lines))
220252

221253
df = read_csv(
222-
StringIO("\n".join(lines)),
254+
StringIO("\n".join(all_lines)),
223255
sep=r"^([^:]+):\s+",
224256
header=None,
225257
engine="python",

src/allotropy/parsers/ctl_immunospot/ctl_immunospot_structure.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,18 @@ def _create_measurement(
3333
well_plate_identifier: str,
3434
plate_data: dict[str, pd.DataFrame],
3535
histograms: dict[str, tuple[list[float], list[float]]],
36+
header_data: dict[str, float | str | None],
3637
) -> Measurement:
3738
location_identifier = f"{well_row}{well_col}"
39+
data_processing_document = {
40+
"Min. SpotSize": header_data.pop("Min. SpotSize", None),
41+
"Max. SpotSize": header_data.pop("Max. SpotSize", None),
42+
"Spot Separation": header_data.pop("Spot Separation", None),
43+
}
44+
has_data = any(
45+
value is not None and str(value).strip() != ""
46+
for value in data_processing_document.values()
47+
)
3848
return Measurement(
3949
type_=MeasurementType.OPTICAL_IMAGING,
4050
device_type=constants.DEVICE_TYPE,
@@ -45,6 +55,7 @@ def _create_measurement(
4555
detection_type=constants.DETECTION_TYPE,
4656
processed_data=ProcessedData(
4757
identifier=random_uuid_str(),
58+
data_processing_document=data_processing_document if has_data else None,
4859
features=[
4960
ImageFeature(
5061
identifier=random_uuid_str(),
@@ -77,6 +88,7 @@ def _create_measurement(
7788
]
7889
if histograms and location_identifier in histograms
7990
else None,
91+
device_control_custom_info=header_data,
8092
)
8193

8294

@@ -94,16 +106,28 @@ def create_measurement_groups(
94106
round_to_nearest_well_count(first_plate.size),
95107
f"Unable to determine valid plate count from dataframe of size: {first_plate.size}",
96108
)
109+
measurement_time = header[str, ("Counted", "Review Date")]
110+
analyst = header[str, "Authenticated user"]
111+
custom_info = {
112+
"Assay": header.get(str, "Assay"),
113+
}
114+
header_data = header.get_unread()
97115
return [
98116
MeasurementGroup(
99117
plate_well_count=plate_well_count,
100-
measurement_time=header[str, ("Counted", "Review Date")],
101-
analyst=header[str, "Authenticated user"],
118+
measurement_time=measurement_time,
119+
analyst=analyst,
102120
measurements=[
103121
_create_measurement(
104-
row, col, well_plate_identifier, plate_data, histograms
122+
row,
123+
col,
124+
well_plate_identifier,
125+
plate_data,
126+
histograms,
127+
header_data.copy(),
105128
)
106129
],
130+
custom_info=custom_info,
107131
)
108132
for row in first_plate.index
109133
for col in first_plate.columns

src/allotropy/parsers/utils/pandas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ def _key_matches(self, match_key: str, key: str) -> bool:
279279
# "++" can cause re.compile to fail. Since it is never a valid regex expression
280280
# it is safe to escape it to prevent the error.
281281
match_key = match_key.replace("++", r"\+\+")
282+
282283
return bool(re.fullmatch(match_key, key))
283284

284285
def _get_matching_keys(self, key_or_keys: str | set[str]) -> set[str]:

0 commit comments

Comments
 (0)