Skip to content

Commit 0fbfa21

Browse files
james-leinasclaude
andauthored
feat: MSD Workbench - Update support for CSV format and Calc Conc CV handling (#1157)
Changes: - Updated msd_workbench_reader.py to detect and handle both CSV formats: - Old format: Plate ID with trailing commas - New format: Plate ID without commas (line 1 = plate ID, line 2 = headers) - Added support for "Calc. Conc. CV" calculated data column - Improved column name handling to support both "Dilution Factor" and "Dilution" - "Calc. Conc. CV" now properly appears in calculated data documents - "Dilution" field now correctly consumed by dilution factor setting Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3aa5c56 commit 0fbfa21

8 files changed

Lines changed: 7793 additions & 2227 deletions

File tree

src/allotropy/calcdocs/msd_workbench/extractor.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,6 @@ def to_element(
3939
"percent_recovery_mean": calc_data_structure.percent_recovery_mean,
4040
"calc_concentration": calc_data_structure.calc_concentration,
4141
"calc_conc_mean": calc_data_structure.calc_conc_mean,
42+
"calc_conc_cv": calc_data_structure.calc_conc_cv,
4243
},
4344
)

src/allotropy/parsers/msd_workbench/calculdated_data_structure.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ class CalculatedDataMeasurementStructure:
1717
percent_recovery_mean: float | None
1818
calc_concentration: float | None
1919
calc_conc_mean: float | None
20+
calc_conc_cv: float | None

src/allotropy/parsers/msd_workbench/msd_workbench_calculated_data_mapping.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class CalculatedDataColumns(Enum):
4444
PERCENT_RECOVERY_MEAN = "% Recovery Mean"
4545
CALC_CONCENTRATION = "Calc. Concentration"
4646
CALC_CONC_MEAN = "Calc. Conc. Mean"
47+
CALC_CONC_CV = "Calc. Conc. CV"
4748

4849

4950
def create_calculated_data_groups(
@@ -84,6 +85,9 @@ def create_calculated_data_groups(
8485
calc_conc_mean=row_series.get(
8586
float, CalculatedDataColumns.CALC_CONC_MEAN.value
8687
),
88+
calc_conc_cv=row_series.get(
89+
float, CalculatedDataColumns.CALC_CONC_CV.value
90+
),
8791
)
8892
)
8993
# we do not need additional data for the calculated data documents
@@ -156,6 +160,12 @@ def create_calculated_data_groups(
156160
view_data=assay_view,
157161
source_configs=(calc_concentration_config,),
158162
)
163+
calc_conc_cv_config = CalculatedDataConfig(
164+
name=CalculatedDataColumns.CALC_CONC_CV.value,
165+
value="calc_conc_cv",
166+
view_data=assay_view,
167+
source_configs=(calc_concentration_config,),
168+
)
159169
configs = CalcDocsConfig(
160170
[
161171
mean_config,
@@ -167,6 +177,7 @@ def create_calculated_data_groups(
167177
percent_recovery_mean_config,
168178
calc_concentration_config,
169179
calc_conc_mean_config,
180+
calc_conc_cv_config,
170181
]
171182
)
172183
calc_docs = [

src/allotropy/parsers/msd_workbench/msd_workbench_reader.py

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,58 @@ def __init__(self, named_file_contents: NamedFileContents) -> None:
1818
sheet_name="Workbench data",
1919
engine="openpyxl",
2020
)
21+
data = data.where(pd.notna(data), None)
22+
data.index = pd.Index(data.index.to_series().ffill())
23+
data.index = data.index.astype(str).str.strip()
24+
data.columns = data.columns.astype(str).str.strip()
25+
first_row = str(data.iloc[0].index[0])
26+
if "Plate" not in first_row:
27+
msg = "Plate ID not found in the first row of the data"
28+
raise AllotropeConversionError(msg)
29+
self.well_plate_id = first_row.split("_")[-1].strip()
30+
# Set the first row as the header
31+
data.columns = pd.Index(data.iloc[0])
32+
data = data[1:].reset_index(drop=True)
2133
else:
22-
data = read_csv(named_file_contents.contents)
23-
data = data.where(pd.notna(data), None)
24-
data.index = pd.Index(data.index.to_series().ffill())
25-
data.index = data.index.astype(str).str.strip()
26-
data.columns = data.columns.astype(str).str.strip()
27-
first_row = str(data.iloc[0].index[0])
28-
if "Plate" not in first_row:
29-
msg = "Plate ID not found in the first row of the data"
30-
raise AllotropeConversionError(msg)
31-
self.well_plate_id = first_row.split("_")[-1].strip()
32-
# Set the first row as the header
33-
data.columns = pd.Index(data.iloc[0])
34-
data = data[1:].reset_index(drop=True)
34+
# First, peek at the first line to check format
35+
named_file_contents.contents.seek(0)
36+
first_line = named_file_contents.contents.readline()
37+
if isinstance(first_line, bytes):
38+
first_line = first_line.decode("utf-8")
39+
named_file_contents.contents.seek(0)
40+
41+
# Check if first line has commas (old format) or not (new format)
42+
if "," in first_line:
43+
# Old format: Plate_ID,,,,,,,,
44+
data = read_csv(named_file_contents.contents)
45+
data = data.where(pd.notna(data), None)
46+
data.index = pd.Index(data.index.to_series().ffill())
47+
data.index = data.index.astype(str).str.strip()
48+
data.columns = data.columns.astype(str).str.strip()
49+
first_row = str(data.iloc[0].index[0])
50+
if "Plate" not in first_row:
51+
msg = "Plate ID not found in the first row of the data"
52+
raise AllotropeConversionError(msg)
53+
self.well_plate_id = first_row.split("_")[-1].strip()
54+
# Set the first row as the header
55+
data.columns = pd.Index(data.iloc[0])
56+
data = data[1:].reset_index(drop=True)
57+
else:
58+
# New format: Plate_ID (no commas on first line)
59+
# Extract plate ID from first line
60+
plate_id_line = first_line.strip()
61+
if "Plate" not in plate_id_line:
62+
msg = "Plate ID not found in the first row of the data"
63+
raise AllotropeConversionError(msg)
64+
self.well_plate_id = plate_id_line.split("_")[-1].strip()
65+
66+
# Now read the CSV starting from the second line (which has headers)
67+
named_file_contents.contents.seek(0)
68+
# Skip the first line
69+
named_file_contents.contents.readline()
70+
# Read rest of file with second line as header
71+
data = read_csv(named_file_contents.contents)
72+
data = data.where(pd.notna(data), None)
73+
data.columns = data.columns.astype(str).str.strip()
74+
3575
self.plate_data = data

src/allotropy/parsers/msd_workbench/msd_workbench_structure.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ def map_measurement(row: SeriesData) -> Measurement:
8484
"detection range": row.get(str, "Detection Range"),
8585
"assay identifier": row.get(str, "Assay"),
8686
}
87+
# Try both "Dilution Factor" and "Dilution" for backwards compatibility
88+
dilution_value = row.get(int, "Dilution Factor")
89+
if dilution_value is None:
90+
dilution_value = row.get(int, "Dilution")
91+
8792
return Measurement(
8893
type_=MeasurementType.LUMINESCENCE,
8994
identifier=random_uuid_str(),
@@ -99,7 +104,7 @@ def map_measurement(row: SeriesData) -> Measurement:
99104
row[str, "Sample"][0].lower()
100105
),
101106
sample_custom_info={
102-
"dilution factor setting": row.get(int, "Dilution Factor"),
107+
"dilution factor setting": dilution_value,
103108
},
104109
measurement_custom_info={
105110
**custom_info,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
Plate_TEST001
2+
Sample,Assay,Well,Spot,Dilution,Concentration,Signal,Adjusted Signal,Mean,Adj. Sig. Mean,CV,% Recovery,% Recovery Mean,Calc. Concentration,Calc. Conc. Mean,Calc. Conc. CV
3+
S001,Test Assay 1.0,A01,1,,100.0,1000.0,1000.0,1010.0,1010.0,1.5,102.5,101.0,102.0,101.0,1.2
4+
S001,Test Assay 1.0,A02,1,,100.0,1020.0,1020.0,1010.0,1010.0,1.5,99.5,101.0,100.0,101.0,1.2
5+
U001,Test Assay 1.0,A03,1,1.0,,2000.0,2000.0,2010.0,2010.0,0.8,,,200.0,201.0,0.6
6+
U001,Test Assay 1.0,A04,1,1.0,,2020.0,2020.0,2010.0,2010.0,0.8,,,202.0,201.0,0.6
7+
U009,Test Assay 1.0,A05,1,1.0,,3000.0,3000.0,3010.0,3010.0,1.2,,,300.0,301.0,1.0
8+
U009,Test Assay 1.0,A06,1,1.0,,3020.0,3020.0,3010.0,3010.0,1.2,,,302.0,301.0,1.0
9+
U017,Test Assay 1.0,A07,1,1.0,,4000.0,4000.0,4010.0,4010.0,0.5,,,400.0,401.0,0.4
10+
U017,Test Assay 1.0,A08,1,1.0,,4020.0,4020.0,4010.0,4010.0,0.5,,,402.0,401.0,0.4
11+
S002,Test Assay 1.0,B01,1,,50.0,500.0,500.0,510.0,510.0,1.8,99.0,98.5,49.5,49.0,1.5
12+
S002,Test Assay 1.0,B02,1,,50.0,520.0,520.0,510.0,510.0,1.8,98.0,98.5,48.5,49.0,1.5
13+
U002,Test Assay 1.0,B03,1,1.0,,1500.0,1500.0,1510.0,1510.0,1.0,,,150.0,151.0,0.9
14+
U002,Test Assay 1.0,B04,1,1.0,,1520.0,1520.0,1510.0,1510.0,1.0,,,152.0,151.0,0.9
15+
U010,Test Assay 1.0,B05,1,1.0,,2500.0,2500.0,2510.0,2510.0,0.6,,,250.0,251.0,0.5
16+
U010,Test Assay 1.0,B06,1,1.0,,2520.0,2520.0,2510.0,2510.0,0.6,,,252.0,251.0,0.5
17+
U018,Test Assay 1.0,B07,1,1.0,,3500.0,3500.0,3510.0,3510.0,0.4,,,350.0,351.0,0.3
18+
U018,Test Assay 1.0,B08,1,1.0,,3520.0,3520.0,3510.0,3510.0,0.4,,,352.0,351.0,0.3
19+
S003,Test Assay 1.0,C01,1,,25.0,250.0,250.0,255.0,255.0,0.1,98.0,98.5,24.5,24.7,0.1
20+
S003,Test Assay 1.0,C02,1,,25.0,260.0,260.0,255.0,255.0,0.1,99.0,98.5,24.9,24.7,0.1
21+
U003,Test Assay 1.0,C03,1,1.0,,1000.0,1000.0,1010.0,1010.0,0.7,,,100.0,101.0,0.6
22+
U003,Test Assay 1.0,C04,1,1.0,,1020.0,1020.0,1010.0,1010.0,0.7,,,102.0,101.0,0.6
23+
U011,Test Assay 1.0,C05,1,1.0,,2000.0,2000.0,2010.0,2010.0,0.9,,,200.0,201.0,0.8
24+
U011,Test Assay 1.0,C06,1,1.0,,2020.0,2020.0,2010.0,2010.0,0.9,,,202.0,201.0,0.8
25+
U019,Test Assay 1.0,C07,1,1.0,,3000.0,3000.0,3010.0,3010.0,0.2,,,300.0,301.0,0.2
26+
U019,Test Assay 1.0,C08,1,1.0,,3020.0,3020.0,3010.0,3010.0,0.2,,,302.0,301.0,0.2
27+
S004,Test Assay 1.0,D01,1,,12.5,125.0,125.0,130.0,130.0,2.5,103.0,100.5,12.9,12.6,3.0
28+
S004,Test Assay 1.0,D02,1,,12.5,135.0,135.0,130.0,130.0,2.5,98.0,100.5,12.3,12.6,3.0
29+
U004,Test Assay 1.0,D03,1,1.0,,500.0,500.0,510.0,510.0,0.3,,,50.0,51.0,0.2
30+
U004,Test Assay 1.0,D04,1,1.0,,520.0,520.0,510.0,510.0,0.3,,,52.0,51.0,0.2
31+
U012,Test Assay 1.0,D05,1,1.0,,1500.0,1500.0,1510.0,1510.0,1.3,,,150.0,151.0,1.2
32+
U012,Test Assay 1.0,D06,1,1.0,,1520.0,1520.0,1510.0,1510.0,1.3,,,152.0,151.0,1.2
33+
U020,Test Assay 1.0,D07,1,1.0,,2500.0,2500.0,2510.0,2510.0,1.6,,,250.0,251.0,2.5
34+
U020,Test Assay 1.0,D08,1,1.0,,2520.0,2520.0,2510.0,2510.0,1.6,,,252.0,251.0,2.5
35+
S005,Test Assay 1.0,E01,1,,6.25,62.5,62.5,65.0,65.0,2.2,98.5,102.0,6.15,6.35,4.5
36+
S005,Test Assay 1.0,E02,1,,6.25,67.5,67.5,65.0,65.0,2.2,105.5,102.0,6.55,6.35,4.5
37+
U005,Test Assay 1.0,E03,1,1.0,,250.0,250.0,255.0,255.0,0.2,,,25.0,25.5,0.1
38+
U005,Test Assay 1.0,E04,1,1.0,,260.0,260.0,255.0,255.0,0.2,,,26.0,25.5,0.1
39+
S006,Test Assay 1.0,F01,1,,3.125,31.25,31.25,32.5,32.5,1.0,103.5,101.0,3.24,3.16,2.8
40+
S006,Test Assay 1.0,F02,1,,3.125,33.75,33.75,32.5,32.5,1.0,98.5,101.0,3.08,3.16,2.8
41+
U006,Test Assay 1.0,F03,1,1.0,,125.0,125.0,127.5,127.5,0.05,,,12.5,12.75,0.04
42+
U006,Test Assay 1.0,F04,1,1.0,,130.0,130.0,127.5,127.5,0.05,,,13.0,12.75,0.04
43+
S007,Test Assay 1.0,G01,1,,1.5625,15.625,15.625,16.25,16.25,4.2,130.0,109.0,2.03,1.88,20.0
44+
S007,Test Assay 1.0,G02,1,,1.5625,16.875,16.875,16.25,16.25,4.2,88.0,109.0,1.73,1.88,20.0
45+
U007,Test Assay 1.0,G03,1,1.0,,62.5,62.5,65.0,65.0,0.2,,,6.25,6.5,0.3
46+
U007,Test Assay 1.0,G04,1,1.0,,67.5,67.5,65.0,65.0,0.2,,,6.75,6.5,0.3
47+
S008,Test Assay 1.0,H01,1,,0.0,7.8,7.8,8.1,8.1,2.0,,NaN,NaN,NaN,NaN
48+
S008,Test Assay 1.0,H02,1,,0.0,8.4,8.4,8.1,8.1,2.0,,NaN,NaN,NaN,NaN
49+
U008,Test Assay 1.0,H03,1,1.0,,31.25,31.25,32.5,32.5,1.5,,,3.125,3.25,2.5
50+
U008,Test Assay 1.0,H04,1,1.0,,33.75,33.75,32.5,32.5,1.5,,,3.375,3.25,2.5

0 commit comments

Comments
 (0)