Skip to content

Commit b0ebd01

Browse files
authored
Merge branch 'main' into felipenarv/qiacuity_dpcr_get_unread
2 parents 9d7cf55 + 167a1eb commit b0ebd01

10 files changed

Lines changed: 16248 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this packages will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.1.108] - 2025-10-23
9+
10+
### Added
11+
12+
- BMG Labtech MARS - Support for CSVs generated by Mars V3.40 R2 (#1105)
13+
814
## [0.1.107] - 2025-10-09
915

1016
### Added

src/allotropy/__about__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.1.107"
1+
__version__ = "0.1.108"

src/allotropy/allotrope/schema_mappers/adm/plate_reader/rec/_2025/_03/plate_reader.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,8 @@ class Metadata:
220220
equipment_serial_number: str | None = None
221221
product_manufacturer: str | None = None
222222
file_name: str | None = None
223-
custom_info_doc: dict[str, Any] | None = None
223+
metadata_custom_info: dict[str, Any] | None = None
224+
device_control_custom_info: dict[str, Any] | None = None
224225

225226

226227
@dataclass(frozen=True)
@@ -253,7 +254,7 @@ def map_model(self, data: Data) -> Model:
253254
ASM_converter_name=self.converter_name,
254255
ASM_converter_version=ASM_CONVERTER_VERSION,
255256
),
256-
data.metadata.custom_info_doc,
257+
data.metadata.metadata_custom_info,
257258
),
258259
plate_reader_document=[
259260
self._get_technique_document(measurement_group)

src/allotropy/parsers/bmg_mars/bmg_mars_reader.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import csv
12
from io import StringIO
23

34
import pandas as pd
@@ -16,6 +17,15 @@ class BmgMarsReader:
1617
header_content: str
1718

1819
def __init__(self, named_file_contents: NamedFileContents) -> None:
20+
try:
21+
self._parse_file(named_file_contents)
22+
except pd.errors.ParserError:
23+
# Fix malformed CSV files with uneven columns and retry
24+
named_file_contents = self._normalize_csv_columns(named_file_contents)
25+
self._parse_file(named_file_contents)
26+
27+
def _parse_file(self, named_file_contents: NamedFileContents) -> None:
28+
"""Parse the BMG MARS file and populate header and data attributes."""
1929
reader = CsvReader(read_to_lines(named_file_contents))
2030
lines = list(reader.pop_until_inclusive("^,?Raw Data"))
2131
# Store the header contents so we can parse some values that don't have key/value
@@ -28,7 +38,6 @@ def __init__(self, named_file_contents: NamedFileContents) -> None:
2838
axis="index"
2939
)
3040
new = df["value"].str.split(": ", expand=True, n=1)
31-
3241
# Handle the case where no ": " delimiter is found, resulting in a DataFrame with only one column
3342
if new.shape[1] < 2:
3443
msg = "Unable to parse header data: no key-value pairs found with expected format."
@@ -41,3 +50,67 @@ def __init__(self, named_file_contents: NamedFileContents) -> None:
4150
reader.pop_csv_block_as_df(header=0, index_col=0),
4251
msg="Unable to parse dataset from file.",
4352
)
53+
54+
def _normalize_csv_columns(
55+
self, named_file_contents: NamedFileContents
56+
) -> NamedFileContents:
57+
"""Fix malformed CSV files with uneven number of columns per row.
58+
59+
BMG MARS software <= v4.0 can produce CSV files where some rows have
60+
more or fewer columns than others. This method normalizes all rows
61+
to have the same number of columns by padding with empty strings,
62+
allowing the csv reader to parse the file correctly.
63+
64+
Args:
65+
named_file_contents: The named file contents to fix
66+
67+
Returns:
68+
Fixed NamedFileContents with consistent column counts
69+
"""
70+
lines = read_to_lines(named_file_contents)
71+
72+
if not lines:
73+
return named_file_contents
74+
75+
# Parse all rows and find the maximum number of columns
76+
csv_rows = []
77+
max_columns = 0
78+
79+
for line in lines:
80+
if line.strip():
81+
reader = csv.reader([line])
82+
try:
83+
row = next(reader)
84+
csv_rows.append(row)
85+
max_columns = max(max_columns, len(row))
86+
except (csv.Error, StopIteration):
87+
# If parsing fails, keep the original line
88+
csv_rows.append([line])
89+
max_columns = max(max_columns, 1)
90+
else:
91+
pass # skip empty lines
92+
93+
# Normalize all rows to have the same number of columns
94+
normalized_rows = []
95+
for row in csv_rows:
96+
normalized_row = row.copy()
97+
if len(normalized_row) < max_columns:
98+
# Pad with empty strings
99+
normalized_row.extend([""] * (max_columns - len(normalized_row)))
100+
elif len(normalized_row) > max_columns:
101+
# Truncate if somehow longer (shouldn't happen with max calculation)
102+
normalized_row = normalized_row[:max_columns]
103+
normalized_rows.append(normalized_row)
104+
105+
# Convert back to CSV string
106+
output = StringIO()
107+
writer = csv.writer(output)
108+
writer.writerows(normalized_rows)
109+
output.seek(0)
110+
111+
# Create a new NamedFileContents with the fixed content
112+
return NamedFileContents(
113+
contents=output,
114+
original_file_path=named_file_contents.original_file_path,
115+
encoding="utf-8",
116+
)

src/allotropy/parsers/thermo_skanit/thermo_skanit_structure.py

Lines changed: 106 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,22 +80,66 @@ def create_metadata(
8080
)
8181
general_info_data = ThermoSkanItMetadata._get_general_info_data(general_info_df)
8282
path = Path(file_path)
83+
84+
device_identifier = str(instrument_info_data.pop("device_identifier"))
85+
model_number = str(instrument_info_data.pop("model_number"))
86+
87+
equipment_serial_number = None
88+
if "equipment_serial_number" in instrument_info_data:
89+
equipment_serial_number = str(
90+
instrument_info_data.pop("equipment_serial_number")
91+
)
92+
93+
software_name = None
94+
if general_info_data.get("software_name") is not None:
95+
software_name = str(general_info_data.pop("software_name"))
96+
else:
97+
general_info_data.pop("software_name", None)
98+
99+
software_version = None
100+
if general_info_data.get("software_version") is not None:
101+
software_version = str(general_info_data.pop("software_version"))
102+
else:
103+
general_info_data.pop("software_version", None)
104+
105+
# Merge custom_info dicts, ensuring we only unpack dict types
106+
instrument_custom_info = instrument_info_data.get("custom_info", {})
107+
device_control_custom_info_raw = instrument_info_data.get(
108+
"device_control_custom_info"
109+
)
110+
general_custom_info = general_info_data.get("custom_info", {})
111+
112+
custom_info: dict[str, Any] = {}
113+
if isinstance(instrument_custom_info, dict):
114+
custom_info.update(instrument_custom_info)
115+
if isinstance(general_custom_info, dict):
116+
custom_info.update(general_custom_info)
117+
118+
# Ensure device_control_custom_info is the correct type
119+
device_control_custom_info = (
120+
device_control_custom_info_raw
121+
if isinstance(device_control_custom_info_raw, dict)
122+
else None
123+
)
124+
83125
return Metadata(
84126
asm_file_identifier=path.with_suffix(".json").name,
85127
data_system_instance_id=NOT_APPLICABLE,
86128
file_name=path.name,
87129
unc_path=file_path,
88-
device_identifier=instrument_info_data["device_identifier"],
89-
model_number=instrument_info_data["model_number"],
90-
equipment_serial_number=instrument_info_data.get("equipment_serial_number"),
91-
software_name=general_info_data["software_name"],
92-
software_version=general_info_data["software_version"],
130+
device_identifier=device_identifier,
131+
model_number=model_number,
132+
equipment_serial_number=equipment_serial_number,
133+
software_name=software_name,
134+
software_version=software_version,
135+
metadata_custom_info=custom_info,
136+
device_control_custom_info=device_control_custom_info,
93137
)
94138

95139
@staticmethod
96140
def _get_general_info_data(
97141
general_info_df: pd.DataFrame | None,
98-
) -> dict[str, str | None]:
142+
) -> dict[str, str | None | dict[str, str]]:
99143
if general_info_df is None:
100144
return dict.fromkeys(GENERAL_INFO_KEYS, None)
101145

@@ -106,15 +150,21 @@ def _get_general_info_data(
106150
software_name, software_version_txt = software_info.split(",", 1)
107151
match = re.search(r"\d+(?:\.\d+)*", software_version_txt)
108152
software_version = match.group() if match else None
153+
unread_data = general_info_data.get_unread(skip={"Filter", "Position"})
154+
# Filter out "nan" keys from custom info and ensure string values
155+
custom_info = {
156+
k: str(v) for k, v in unread_data.items() if k != "nan" and v is not None
157+
}
109158
return {
110159
"software_name": software_name,
111160
"software_version": software_version,
161+
"custom_info": custom_info,
112162
}
113163

114164
@staticmethod
115165
def _get_instrument_data(
116166
instrument_info_df: pd.DataFrame | None,
117-
) -> dict[str, str]:
167+
) -> dict[str, str | None | dict[str, str] | dict[str, float | str | None]]:
118168
if instrument_info_df is None:
119169
return {
120170
"device_identifier": NOT_APPLICABLE,
@@ -139,11 +189,45 @@ def _get_instrument_data(
139189
"model_number": ("Name", NOT_APPLICABLE),
140190
"equipment_serial_number": ("Serial number", None),
141191
}
142-
return {
192+
# Get the raw data first
193+
raw_data = {
143194
key: value
144195
for key, (label, default) in lookups.items()
145196
if (value := instrument_info_data.get(str, label, default)) is not None
146197
}
198+
device_control_custom_info = instrument_info_data.get_custom_keys(
199+
{
200+
"Optical response compensation",
201+
"Plate adapter number",
202+
"Plate adapter name",
203+
}
204+
)
205+
unread_data = instrument_info_data.get_unread(
206+
skip={
207+
"Wavelength",
208+
"Incubator",
209+
"Date and time of definition",
210+
"Top optics",
211+
"Bottom optics",
212+
"Dispenser 1",
213+
"Dispenser 2",
214+
"Module's name",
215+
"Module's serial number",
216+
"Instrument modules",
217+
"Gas control",
218+
"Bandwidth",
219+
"Position",
220+
"Filter",
221+
}
222+
)
223+
custom_info = {
224+
k: str(v) for k, v in unread_data.items() if k != "nan" and v is not None
225+
}
226+
return {
227+
**raw_data,
228+
"custom_info": custom_info,
229+
"device_control_custom_info": device_control_custom_info,
230+
}
147231

148232

149233
@dataclass
@@ -158,6 +242,7 @@ def create(
158242
well_plate_identifier: str | None,
159243
error_documents: list[ErrorDocument] | None = None,
160244
experimental_data_identifier: str | None = None,
245+
device_control_custom_info: dict[str, Any] | None = None,
161246
) -> Measurement:
162247
measurement_type_str = MEASUREMENT_TYPES[type_]
163248
error_docs = error_documents or []
@@ -184,6 +269,7 @@ def create(
184269
else None,
185270
experimental_data_identifier=experimental_data_identifier,
186271
error_document=error_docs if error_docs else None,
272+
device_control_custom_info=device_control_custom_info,
187273
)
188274

189275
@staticmethod
@@ -202,14 +288,19 @@ def create(
202288
sheet_df: pd.DataFrame,
203289
type_: MeasurementType,
204290
session_info_df: pd.DataFrame | None,
291+
device_control_custom_info: dict[str, Any] | None = None,
205292
) -> list[MeasurementGroup]:
206293
plates = ThermoSkanItMeasurementGroups.identify_data_and_sample_dfs(sheet_df)
207294

208295
session_name = exec_time = None
296+
custom_info = {}
209297
if session_info_df is not None:
210298
session_info_data = df_to_series_data(parse_header_row(session_info_df.T))
211299
session_name = session_info_data.get(str, "Session name")
212300
exec_time = session_info_data.get(str, "Execution time")
301+
unread_data = session_info_data.get_unread(skip={"Executed with"})
302+
# Filter out "nan" keys from custom info
303+
custom_info = {k: v for k, v in unread_data.items() if k != "nan"}
213304

214305
if not exec_time:
215306
exec_time = sheet_df.iloc[1].iloc[0]
@@ -273,6 +364,7 @@ def create(
273364
experimental_data_identifier=session_name.replace(".skax", "")
274365
if session_name
275366
else None,
367+
device_control_custom_info=device_control_custom_info,
276368
)
277369

278370
if well_key not in well_measurements:
@@ -288,6 +380,7 @@ def create(
288380
measurements=measurements,
289381
plate_well_count=plate_well_counts[plate_id],
290382
measurement_time=exec_time,
383+
custom_info=custom_info,
291384
)
292385
)
293386

@@ -458,10 +551,15 @@ def create(sheet_data: dict[str, pd.DataFrame], file_path: str) -> Data:
458551
general_info_df=clean_data.get("General information"),
459552
file_path=file_path,
460553
)
554+
555+
# Extract device control custom info from metadata
556+
device_control_custom_info = metadata.device_control_custom_info
557+
461558
measurement_groups = ThermoSkanItMeasurementGroups.create(
462559
sheet_df=measurement_df,
463560
session_info_df=clean_data.get("Session information"),
464561
type_=_type,
562+
device_control_custom_info=device_control_custom_info,
465563
)
466564

467565
return Data(

src/allotropy/parsers/unchained_labs_lunatic_stunner/unchained_labs_lunatic_stunner_structure.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,7 @@ def create_metadata(header: SeriesData, file_path: str) -> Metadata:
463463
unc_path=file_path,
464464
asm_file_identifier=asm_file_identifier.name,
465465
data_system_instance_id=NOT_APPLICABLE,
466-
custom_info_doc=_filter_empty_string_values(
466+
metadata_custom_info=_filter_empty_string_values(
467467
header.get_unread(
468468
# Skip already mapped columns from well plate data (repeated in CSV no header cases)
469469
skip={
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
User: USER,Path: C:\Program Files (x86)\BMG\PHERAstar\User\Data,Test run no.: 4
2+
Test name: Transcreener ADP2 FI,Date: 29/02/2016,Time: 14:34:46
3+
4+
ID1: black 384w small volume,ID2: 20 ul
5+
Fluorescence (FI)
6+
7+
Raw Data (580/620)
8+
9+
,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24
10+
A,25104,25203,24886,24098,24466,26571,24056,24156,24130,24089,21634,21969,22160,23823,23019,22230,22992,23852,23657,24361
11+
B,33863,33227,34758,33603,34186,34318,34196,35279,35608,37342,35468,34056,34031,33235,33242,33304,35286,34482,34378,33774
12+
C,45480,42551,41891,44773,43821,45338,43328,42901,44137,46425,39341,39162,39791,41566,41265,43889,40461,41195,40761,40262
13+
D,50970,50862,50279,50797,51063,50202,50808,50966,50097,49497,50325,48821,48954,49208,49181,49016,49404,49271,48856,47830
14+
E,53997,52824,53844,50679,55121,54280,53994,54413,54396,53971,53444,50748,53143,53916,53624,53156,53531,53493,53216,53154
15+
F,55774,56191,56164,56462,55583,56253,56300,55464,56719,54927,56253,54441,58358,56279,55870,55886,55465,55897,55737,53798
16+
G,57378,57180,57681,58158,57949,57397,57546,57895,58077,57617,57827,57072,58385,58021,58624,57891,57835,58321,58046,55748
17+
H,59660,58847,59844,60655,61534,60652,60090,60958,59683,59631,59656,58141,59493,59990,59664,59956,59928,60272,59780,58810
18+
I,63183,57522,58573,60240,60618,60777,61149,61504,61682,60194,61840,61912,61256,63237,61643,60385,60806,60566,60231,58425
19+
J,62375,61292,62851,65025,64750,65573,58147,64039,63962,64279,64763,60451,64021,64272,65055,63228,61028,61503,61899,60751
20+
K,63255,63635,63048,63799,65845,66218,65161,63773,64427,62842,65316,64136,65885,65606,65812,66198,62934,63653,64950,62926
21+
L,66220,62882,65007,64797,65385,66359,66227,67120,65709,64827,65253,61630,66214,65540,65774,67329,65779,65519,65672,63404
22+
M,63792,64859,66378,66579,67777,67381,67099,67971,66080,64760,67145,65628,66682,66075,66363,65642,67384,65322,63597,64895
23+
N,64497,64888,64056,65284,66138,66751,66896,66044,66878,62160,64748,64183,65883,65153,66413,63789,63507,62883,63665,63882
24+
O,68426,60666,65656,66138,63289,107,103,98,100,94,,59
25+
P,63471,62852,62886,63555,63035,79,90,100,102,103,,62

0 commit comments

Comments
 (0)