Skip to content

Commit 94f69db

Browse files
committed
Added support for single dataset files
1 parent 20f0202 commit 94f69db

7 files changed

Lines changed: 18234 additions & 14 deletions

File tree

src/allotropy/parsers/lines_reader.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ def __init__(self, lines: list[str]) -> None:
4040
def line_exists(self, line: int) -> bool:
4141
return 0 <= line < len(self.lines)
4242

43+
def line_with_pattern_exists(self, pattern: str) -> bool:
44+
return any(search(pattern, line) for line in self.lines)
45+
4346
def current_line_exists(self) -> bool:
4447
return self.line_exists(self.current_line)
4548

src/allotropy/parsers/luminex_xponent/constants.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,53 @@ class StatisticSectionConfig:
6969
"% Recovery": "(unitless)",
7070
"%CV of Replicates": "(unitless)",
7171
}
72+
73+
# Known metric names in the columns of the single dataset results table
74+
SINGLE_DATASET_RESULTS_METRIC_WORDS: set[str] = {
75+
"MEAN",
76+
"MEDIAN",
77+
"COUNT",
78+
"PEAK",
79+
"STDEV",
80+
"STANDARD",
81+
"DEVIATION",
82+
"%CV",
83+
"TRIMMED",
84+
"NET",
85+
"MFI",
86+
"AVERAGE",
87+
"AVG",
88+
"RMS",
89+
"MODE",
90+
"TRMEAN",
91+
"TRIMSTDEV",
92+
"NORMALIZED",
93+
"DILUTION",
94+
"FACTOR",
95+
"SETTING",
96+
"UNITS",
97+
}
98+
99+
# Allowed section names for Luminex Xponent reports. Any other name should raise an error upstream.
100+
ALLOWED_SECTION_NAMES: set[str] = {
101+
"Median",
102+
"Test Result",
103+
"Range",
104+
"Net MFI",
105+
"Count",
106+
"Mean",
107+
"Avg Net MFI",
108+
"Peak",
109+
"Trimmed Peak",
110+
"Trimmed Count",
111+
"Trimmed Std Dev",
112+
"Std Dev",
113+
"% CV",
114+
"Expected Result",
115+
"Units",
116+
"Per Bead Count",
117+
"Dilution Factor",
118+
"Analysis Types",
119+
"Audit Logs",
120+
"Warnings/Errors",
121+
}

src/allotropy/parsers/luminex_xponent/luminex_xponent_reader.py

Lines changed: 308 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from io import StringIO
44
import re
5+
from typing import cast, ClassVar
56

67
import pandas as pd
78

@@ -16,20 +17,316 @@
1617
class LuminexXponentReader:
1718
SUPPORTED_EXTENSIONS = "csv"
1819

19-
header_data: pd.DataFrame
20-
calibration_data: pd.DataFrame
21-
minimum_assay_bead_count_setting: float | None
22-
results_data: dict[str, pd.DataFrame]
23-
2420
def __init__(self, named_file_contents: NamedFileContents) -> None:
25-
reader = CsvReader(read_to_lines(named_file_contents))
21+
self.lines = read_to_lines(named_file_contents)
22+
23+
if LuminexXponentReader._is_single_dataset(self.lines):
24+
(
25+
self.results_data,
26+
self.header_data,
27+
self.calibration_data,
28+
self.minimum_assay_bead_count_setting,
29+
) = SingleDatasetParser.parse(self.lines)
30+
else:
31+
reader = CsvReader(self.lines)
32+
(
33+
self.results_data,
34+
self.header_data,
35+
self.calibration_data,
36+
self.minimum_assay_bead_count_setting,
37+
) = MultipleDatasetParser.parse(reader)
38+
39+
@staticmethod
40+
def _is_single_dataset(lines: list[str]) -> bool:
41+
first_line = lines[0] or ""
42+
return (
43+
"INSTRUMENT TYPE" in first_line
44+
and "WELL LOCATION" in first_line
45+
and "SAMPLE ID" in first_line
46+
)
47+
48+
49+
class SingleDatasetParser:
50+
"""
51+
Adapter to read single dataset CSV exports (all data in a single block)
52+
and yield the same data structures expected by xPONENT-based downstream code.
53+
"""
54+
55+
# Columns that are always present in the single-dataset CSV export and are not analyte columns
56+
FIXED_INPUT_COLUMNS: ClassVar[list[str]] = [
57+
"INSTRUMENT TYPE",
58+
"SERIAL NUMBER",
59+
"SOFTWARE VERSION",
60+
"PLATE NAME",
61+
"PLATE START",
62+
"WELL LOCATION",
63+
"SAMPLE ID",
64+
]
65+
66+
@staticmethod
67+
def is_reporter_format(lines: list[str]) -> bool:
68+
"""Return True if the first line looks like the single-dataset format header."""
69+
first_line = lines[0] if lines else ""
70+
return (
71+
"INSTRUMENT TYPE" in first_line
72+
and "WELL LOCATION" in first_line
73+
and "SAMPLE ID" in first_line
74+
)
75+
76+
@staticmethod
77+
def parse_header(col: str) -> tuple[str, str] | None:
78+
"""Parse a column header into (analyte_label, metric_token).
79+
80+
The column names have an ID followed by the metric. The metric itself is
81+
composed of space-separated words that must all be included in
82+
constants.SINGLE_DATASET_RESULTS_METRIC_WORDS. We therefore split the
83+
header on spaces and find the earliest split index such that the suffix
84+
(metric) is entirely composed of known metric words (case-insensitive),
85+
assigning the prefix to the analyte label.
86+
"""
87+
header = str(col).strip()
88+
parts = [p for p in header.split(" ") if p]
89+
if len(parts) < 2:
90+
return None
91+
92+
# Compare words case-insensitively, without extra cleaning
93+
cleaned = [p.upper() for p in parts]
94+
95+
# Find earliest index where the suffix is all known metric words
96+
split_index: int | None = None
97+
for i in range(1, len(parts)):
98+
suffix = cleaned[i:]
99+
if suffix and all(
100+
s in constants.SINGLE_DATASET_RESULTS_METRIC_WORDS for s in suffix
101+
):
102+
split_index = i
103+
break
104+
105+
if split_index is None:
106+
return None
107+
108+
analyte_part = " ".join(parts[:split_index]).strip()
109+
metric_part = " ".join(parts[split_index:]).strip()
110+
if not analyte_part or not metric_part:
111+
return None
112+
return analyte_part, metric_part
113+
114+
@staticmethod
115+
def section_name_from_metric(metric_token: str) -> str:
116+
"""Map raw metric tokens to xPONENT-style section names."""
117+
token_upper = metric_token.upper().strip()
118+
if token_upper.endswith("AVERAGE MFI"):
119+
return "Avg Net MFI"
120+
# Title-case fallback for all other tokens
121+
return metric_token.title()
122+
123+
@staticmethod
124+
def build_section(
125+
df: pd.DataFrame,
126+
analyte_labels: list[str],
127+
headers_map: dict[tuple[str, str], str],
128+
metric_token: str,
129+
) -> pd.DataFrame | None:
130+
"""Build a results section dataframe for the given metric.
131+
132+
Returns None if no analyte column exists for this metric in the input.
133+
"""
134+
135+
if metric_token.upper().strip() == "UNITS":
136+
return SingleDatasetParser.build_units_section(
137+
df, analyte_labels, headers_map
138+
)
139+
140+
at_least_one_present = False
141+
out = pd.DataFrame()
142+
out["Location"] = [f"1(1,{w})" for w in df["WELL LOCATION"].astype(str)]
143+
out["Sample"] = df["SAMPLE ID"].astype(str)
144+
for analyte in analyte_labels:
145+
src_col = headers_map.get((analyte, metric_token))
146+
if src_col:
147+
at_least_one_present = True
148+
out[analyte] = df[src_col] if src_col in df.columns else ""
149+
150+
if not at_least_one_present:
151+
return None
152+
153+
if metric_token == "COUNT": # noqa: S105
154+
analyte_cols = analyte_labels
155+
# Convert each analyte column to numeric, coercing errors to NaN then filling with 0.0
156+
converted = cast(
157+
pd.DataFrame,
158+
out[analyte_cols].apply(pd.to_numeric, errors="coerce").fillna(0.0),
159+
)
160+
out["Total Events"] = converted.sum(axis=1)
161+
else:
162+
out["Total Events"] = ""
163+
return out.set_index("Location")
26164

27-
self.header_data = self._get_header_data(reader)
28-
self.calibration_data = self._get_calibration_data(reader)
29-
self.minimum_assay_bead_count_setting = (
30-
self._get_minimum_assay_bead_count_setting(reader)
165+
@staticmethod
166+
def build_units_section(
167+
df: pd.DataFrame,
168+
analyte_labels: list[str],
169+
headers_map: dict[tuple[str, str], str],
170+
) -> pd.DataFrame:
171+
"""Build a units section dataframe for the given analyte labels."""
172+
units_list = []
173+
at_least_one_present = False
174+
for analyte in analyte_labels:
175+
src_col = headers_map.get((analyte, "UNITS"))
176+
units_list.append(df[src_col][0] if src_col in df.columns else "")
177+
if src_col:
178+
at_least_one_present = True
179+
180+
if not at_least_one_present:
181+
return pd.DataFrame()
182+
183+
columns = ["Analyte:", *analyte_labels]
184+
units: pd.DataFrame = pd.DataFrame(
185+
[
186+
columns,
187+
["BeadID:", *units_list],
188+
["Units:", *[""] * len(analyte_labels)],
189+
],
190+
index=["Analyte:", "BeadID:", "Units:"],
191+
columns=columns,
192+
)
193+
194+
return units
195+
196+
@staticmethod
197+
def add_mandatory_columns(
198+
results: dict[str, pd.DataFrame],
199+
base_df: pd.DataFrame,
200+
analyte_labels: list[str],
201+
) -> None:
202+
"""Ensure required sections exist (Units, Dilution Factor)."""
203+
if "Dilution Factor" not in results:
204+
results["Dilution Factor"] = pd.DataFrame()
205+
results["Dilution Factor"]["Location"] = [
206+
f"1(1,{w})" for w in base_df["WELL LOCATION"].astype(str)
207+
]
208+
results["Dilution Factor"]["Sample"] = base_df["SAMPLE ID"].astype(str)
209+
results["Dilution Factor"]["Dilution Factor"] = ""
210+
results["Dilution Factor"] = results["Dilution Factor"].set_index(
211+
"Location"
212+
)
213+
if "Units" not in results:
214+
units_df = pd.DataFrame(
215+
[
216+
["Analyte:", *analyte_labels],
217+
["BeadID:"],
218+
["Units:"],
219+
],
220+
index=["Analyte:", "BeadID:", "Units:"],
221+
columns=["Analyte:", *analyte_labels],
222+
)
223+
results["Units"] = units_df
224+
225+
@classmethod
226+
def parse(
227+
cls, lines: list[str]
228+
) -> tuple[dict[str, pd.DataFrame], pd.DataFrame, pd.DataFrame, float | None]:
229+
"""Parse single-dataset CSV and return header, calibration, min beads and results tables."""
230+
df = read_csv(StringIO("\n".join(lines)), header=0)
231+
232+
analyte_labels: list[str] = []
233+
seen_labels: set[str] = set()
234+
headers_map: dict[tuple[str, str], str] = {}
235+
metric_tokens_ordered: list[str] = []
236+
seen_metric_tokens: set[str] = set()
237+
for col in df.columns:
238+
if col in cls.FIXED_INPUT_COLUMNS:
239+
continue
240+
parsed = cls.parse_header(str(col))
241+
if not parsed:
242+
continue
243+
analyte_label, metric = parsed
244+
headers_map[(analyte_label, metric)] = str(col)
245+
if (
246+
metric.upper().strip().endswith("COUNT")
247+
and analyte_label not in seen_labels
248+
):
249+
analyte_labels.append(analyte_label)
250+
seen_labels.add(analyte_label)
251+
if metric not in seen_metric_tokens:
252+
metric_tokens_ordered.append(metric)
253+
seen_metric_tokens.add(metric)
254+
255+
results: dict[str, pd.DataFrame] = {}
256+
257+
for token in metric_tokens_ordered:
258+
section_name = cls.section_name_from_metric(token)
259+
section_df = cls.build_section(df, analyte_labels, headers_map, token)
260+
if section_df is not None:
261+
results[section_name] = section_df
262+
263+
cls.add_mandatory_columns(results, df, analyte_labels)
264+
265+
# Safely extract optional fields from the input DataFrame without mypy complaints
266+
_serial_series = (
267+
df["SERIAL NUMBER"] if "SERIAL NUMBER" in df.columns else pd.Series([""])
268+
)
269+
_serial_value = str(_serial_series.iloc[0]) if len(_serial_series) > 0 else ""
270+
_plate_series = (
271+
df["PLATE START"] if "PLATE START" in df.columns else pd.Series([""])
272+
)
273+
_plate_value = str(_plate_series.iloc[0]) if len(_plate_series) > 0 else ""
274+
275+
header = (
276+
pd.DataFrame(
277+
{
278+
0: [
279+
"Program",
280+
"Build",
281+
"Date",
282+
"SN",
283+
"ProtocolPlate",
284+
"ComputerName",
285+
"BatchStartTime",
286+
],
287+
1: [
288+
"xPONENT",
289+
"Unknown",
290+
"",
291+
_serial_value,
292+
"Name",
293+
"",
294+
_plate_value,
295+
],
296+
2: ["", "", "", "", "", "", ""],
297+
3: ["", "", "", "", "", "", ""],
298+
4: ["", "", "", "", "", "", ""],
299+
5: ["", "", "", "", "", "", ""],
300+
}
301+
)
302+
.set_index(0)
303+
.T
304+
)
305+
306+
calibration = pd.DataFrame()
307+
min_beads: float | None = None
308+
309+
return results, header, calibration, min_beads
310+
311+
312+
class MultipleDatasetParser:
313+
@classmethod
314+
def parse(
315+
cls, reader: CsvReader
316+
) -> tuple[dict[str, pd.DataFrame], pd.DataFrame, pd.DataFrame, float | None]:
317+
318+
header_data = cls._get_header_data(reader)
319+
calibration_data = cls._get_calibration_data(reader)
320+
minimum_assay_bead_count_setting = cls._get_minimum_assay_bead_count_setting(
321+
reader
322+
)
323+
results_data = cls._get_results(reader)
324+
return (
325+
results_data,
326+
header_data,
327+
calibration_data,
328+
minimum_assay_bead_count_setting,
31329
)
32-
self.results_data = LuminexXponentReader._get_results(reader)
33330

34331
@classmethod
35332
def _get_header_data(cls, reader: CsvReader) -> pd.DataFrame:

0 commit comments

Comments
 (0)