Skip to content

Commit f01c47d

Browse files
feat: Add Vi-Cell XR single-sample report format support (#1222)
## Summary - Adds support for the Vi-CELL XR 2.04 single-sample "report" export format (xlsx) - This format uses a key-value layout (one sample per file) rather than the tabular multi-sample format already supported - Detection is based on row 4 containing "Sample ID" as a label in column A (report format) vs as part of a column header row (tabular format) - All measurement data, settings, and metadata are correctly mapped to the same ASM cell-counting schema ## Test plan - [x] New test file added (`Beckman_Vi-Cell-XR_report_format.xlsx`) with expected JSON output - [x] All 13 Vi-Cell XR parser tests pass (existing + new) - [x] Report format detection does not false-positive on any existing tabular test files - [x] Discover vendor tests pass (sniff correctly identifies the new format) - [x] Lint passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8fa531c commit f01c47d

3 files changed

Lines changed: 226 additions & 6 deletions

File tree

src/allotropy/parsers/beckman_vi_cell_xr/vi_cell_xr_reader.py

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
from dataclasses import dataclass
44
from io import StringIO
55
import re
6-
from typing import Any
6+
from typing import Any, ClassVar
77

8+
import openpyxl
89
import pandas as pd
910

1011
from allotropy.named_file_contents import NamedFileContents
@@ -33,12 +34,34 @@ class ViCellData:
3334
version: XrVersion
3435

3536

37+
def _is_report_format(named_file_contents: NamedFileContents) -> bool:
38+
"""Detect the single-sample report format by checking for key-value layout."""
39+
if named_file_contents.extension not in ("xls", "xlsx"):
40+
return False
41+
try:
42+
wb = openpyxl.load_workbook(
43+
named_file_contents.get_bytes_stream(), read_only=True, data_only=True
44+
)
45+
ws = wb[wb.sheetnames[0]]
46+
rows = list(ws.iter_rows(min_row=4, max_row=4, values_only=True))
47+
wb.close()
48+
if rows and rows[0][0] == "Sample ID":
49+
return True
50+
except Exception:
51+
return False
52+
return False
53+
54+
3655
def create_reader_data(named_file_contents: NamedFileContents) -> ViCellData:
37-
reader: ViCellXRReader | ViCellXRTXTReader = (
38-
ViCellXRTXTReader(named_file_contents)
39-
if named_file_contents.extension == "txt"
40-
else ViCellXRReader(named_file_contents)
41-
)
56+
if named_file_contents.extension == "txt":
57+
reader: ViCellXRReader | ViCellXRTXTReader | ViCellXRReportReader = (
58+
ViCellXRTXTReader(named_file_contents)
59+
)
60+
elif _is_report_format(named_file_contents):
61+
named_file_contents.contents.seek(0)
62+
reader = ViCellXRReportReader(named_file_contents)
63+
else:
64+
reader = ViCellXRReader(named_file_contents)
4265
return ViCellData(reader.data, reader.serial_number, reader.version)
4366

4467

@@ -127,6 +150,83 @@ def _get_file_info(self) -> SeriesData:
127150
return SeriesData(info)
128151

129152

153+
class ViCellXRReportReader:
154+
"""Reader for the single-sample report format exported by Vi-CELL XR 2.04."""
155+
156+
data: list[SeriesData]
157+
serial_number: str | None
158+
version: XrVersion
159+
160+
RESULTS_FIELDS: ClassVar[dict[str, int]] = {
161+
"Total cells": 9,
162+
"Viable cells": 10,
163+
"Viability (%)": 11,
164+
"Total cells/ml (x10^6)": 12,
165+
"Viable cells/ml (x10^6)": 13,
166+
"Avg. diam. (microns)": 14,
167+
"Avg. circ.": 15,
168+
"Images": 16,
169+
"Average cells / image": 17,
170+
"Avg. background intensity": 18,
171+
}
172+
173+
SETTINGS_FIELDS: ClassVar[dict[str, int]] = {
174+
"Cell type": 9,
175+
"Minimum diameter (microns)": 10,
176+
"Maximum diameter (microns)": 11,
177+
"Minimum circularity": 12,
178+
"Dilution factor": 13,
179+
"Cell brightness (%)": 14,
180+
"Cell sharpness": 15,
181+
"Viable cell spot brightness (%)": 16,
182+
"Viable cell spot area (%)": 17,
183+
"Decluster degree": 18,
184+
"Aspirate cycles": 19,
185+
"Trypan blue mixing cycles": 20,
186+
}
187+
188+
def __init__(self, named_file_contents: NamedFileContents) -> None:
189+
wb = openpyxl.load_workbook(
190+
named_file_contents.get_bytes_stream(), read_only=True, data_only=True
191+
)
192+
ws = wb[wb.sheetnames[0]]
193+
self.rows = list(ws.iter_rows(values_only=True))
194+
wb.close()
195+
196+
self.version = _get_file_version(str(self.rows[0][0]))
197+
self.serial_number = None
198+
self.data = self._read_data()
199+
200+
def _read_data(self) -> list[SeriesData]:
201+
data: dict[str, Any] = {}
202+
203+
data["Sample ID"] = self.rows[3][2]
204+
data["File name"] = self.rows[4][2]
205+
data[DATE_HEADER] = self.rows[5][2]
206+
comment = self.rows[6][2] if len(self.rows) > 6 else None
207+
if comment:
208+
data["Comment"] = comment
209+
210+
for field, row_idx in self.RESULTS_FIELDS.items():
211+
if row_idx < len(self.rows):
212+
val = self.rows[row_idx][3]
213+
if val is not None:
214+
data[field] = val
215+
216+
for field, row_idx in self.SETTINGS_FIELDS.items():
217+
if row_idx < len(self.rows):
218+
val = self.rows[row_idx][8]
219+
if val is not None:
220+
data[field] = val
221+
222+
series = pd.Series(data)
223+
series[DATE_HEADER] = pd.to_datetime(
224+
series[DATE_HEADER],
225+
format="%d %b %Y %I:%M:%S %p",
226+
)
227+
return [SeriesData(series)]
228+
229+
130230
class ViCellXRTXTReader:
131231
data: list[SeriesData]
132232
serial_number: str | None
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
{
2+
"$asm.manifest": "http://purl.allotrope.org/manifests/cell-counting/REC/2024/09/cell-counting.manifest",
3+
"cell counting aggregate document": {
4+
"data system document": {
5+
"ASM file identifier": "Beckman_Vi-Cell-XR_report_format.json",
6+
"data system instance identifier": "N/A",
7+
"ASM converter name": "allotropy_beckman_coulter_vi_cell_xr",
8+
"ASM converter version": "0.1.134",
9+
"file name": "Beckman_Vi-Cell-XR_report_format.xlsx",
10+
"software name": "Vi-Cell XR",
11+
"software version": "2.04",
12+
"UNC path": "tests/parsers/beckman_vi_cell_xr/testdata/v2.04/Beckman_Vi-Cell-XR_report_format.xlsx"
13+
},
14+
"device system document": {
15+
"device identifier": "N/A",
16+
"model number": "Vi-Cell XR"
17+
},
18+
"cell counting document": [
19+
{
20+
"measurement aggregate document": {
21+
"measurement document": [
22+
{
23+
"device control aggregate document": {
24+
"device control document": [
25+
{
26+
"detection type": "brightfield",
27+
"device type": "brightfield imager (cell counter)",
28+
"custom information document": {
29+
"Trypan blue mixing cycles": 3.0,
30+
"Aspirate cycles": 1.0
31+
}
32+
}
33+
]
34+
},
35+
"measurement identifier": "BECKMAN_VI_CELL_XR_TEST_ID_0",
36+
"measurement time": "2026-05-11T14:59:27+00:00",
37+
"processed data aggregate document": {
38+
"processed data document": [
39+
{
40+
"data processing document": {
41+
"cell type processing method": "Default",
42+
"cell density dilution factor": {
43+
"value": 1.0,
44+
"unit": "(unitless)"
45+
},
46+
"minimum cell diameter setting": {
47+
"value": 5.0,
48+
"unit": "µm"
49+
},
50+
"maximum cell diameter setting": {
51+
"value": 50.0,
52+
"unit": "µm"
53+
},
54+
"custom information document": {
55+
"Decluster degree": "Medium",
56+
"Viable cell spot area (%)": 5.0,
57+
"Minimum circularity": 0.0,
58+
"Cell brightness (%)": 85.0,
59+
"Viable cell spot brightness (%)": 75.0,
60+
"Cell sharpness": 100.0
61+
}
62+
},
63+
"viability (cell counter)": {
64+
"value": 86.99453735351562,
65+
"unit": "%"
66+
},
67+
"total cell density (cell counter)": {
68+
"value": 0.9703072143554687,
69+
"unit": "10^6 cells/mL"
70+
},
71+
"viable cell density (cell counter)": {
72+
"value": 0.8441272808074951,
73+
"unit": "10^6 cells/mL"
74+
},
75+
"average total cell diameter": {
76+
"value": 16.80272565612793,
77+
"unit": "µm"
78+
},
79+
"total cell count": {
80+
"value": 915,
81+
"unit": "cell"
82+
},
83+
"viable cell count": {
84+
"value": 796,
85+
"unit": "cell"
86+
},
87+
"average total cell circularity": {
88+
"value": 0.6407691074371338,
89+
"unit": "(unitless)"
90+
},
91+
"custom information document": {
92+
"Avg. background intensity": 202.89999389648438,
93+
"Average cells / image": 18.299999237060547
94+
}
95+
}
96+
]
97+
},
98+
"sample document": {
99+
"sample identifier": "7B"
100+
},
101+
"image aggregate document": {
102+
"image document": [
103+
{
104+
"custom information document": {
105+
"Images": 50.0
106+
}
107+
}
108+
]
109+
}
110+
}
111+
],
112+
"custom information document": {
113+
"experimental data identifier": "C:\\ViCELLXR\\Data\\7B.txt"
114+
}
115+
},
116+
"analyst": "Vi-Cell XR"
117+
}
118+
]
119+
}
120+
}
Binary file not shown.

0 commit comments

Comments
 (0)