Skip to content

Commit 9b3a27c

Browse files
add handle for error documents when raw data reports N/A values
1 parent c2d6f80 commit 9b3a27c

12 files changed

Lines changed: 2693 additions & 5326 deletions

src/allotropy/parsers/unchained_labs_lunatic_stunner/unchained_labs_lunatic_stunner_reader.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,17 @@ class UnchainedLabsLunaticReader:
2020

2121
def __init__(self, named_file_contents: NamedFileContents) -> None:
2222
if named_file_contents.extension == "csv":
23-
data = read_csv(named_file_contents.contents, header=None)
23+
data = read_csv(
24+
named_file_contents.contents,
25+
header=None,
26+
keep_default_na=False,
27+
)
2428
else:
2529
data = read_excel(
26-
named_file_contents.contents, header=None, engine="calamine"
30+
named_file_contents.contents,
31+
header=None,
32+
engine="calamine",
33+
keep_default_na=False,
2734
)
2835

2936
assert_not_empty_df(data, "Unable to parse data from empty dataset.")
@@ -38,7 +45,23 @@ def __init__(self, named_file_contents: NamedFileContents) -> None:
3845
msg = "Unable to find a table header row with 'Sample Name'."
3946
raise AllotropeConversionError(msg)
4047

41-
header_data = data[:table_header_index].dropna(how="all").T.dropna(how="all")
48+
# Clean typical NA-like strings in header only so metadata parsing behaves as before
49+
header_block = data[:table_header_index].copy()
50+
if not header_block.empty:
51+
# Normalize header empties/NA markers to NaN so transpose+dropna collapses to 2 rows (labels+values)
52+
header_block = header_block.astype(object)
53+
previous_downcast_setting = pd.get_option("future.no_silent_downcasting")
54+
try:
55+
new_downcast_setting: bool = True
56+
pd.set_option("future.no_silent_downcasting", new_downcast_setting)
57+
header_block = header_block.replace(
58+
to_replace=[r"^\s*$", r"(?i)^\s*N/?A\s*$"],
59+
value=np.nan,
60+
regex=True,
61+
).infer_objects()
62+
finally:
63+
pd.set_option("future.no_silent_downcasting", previous_downcast_setting)
64+
header_data = header_block.dropna(how="all").T.dropna(how="all")
4265
data = parse_header_row(data[table_header_index:])
4366

4467
# Fix column names in excel sheets with newlines/other whitespace.
@@ -56,7 +79,7 @@ def __init__(self, named_file_contents: NamedFileContents) -> None:
5679
self.header = df_to_series_data(data, index=0)
5780
else:
5881
self.header = df_to_series_data(
59-
parse_header_row(header_data).dropna(axis="columns")
82+
parse_header_row(header_data).dropna(axis="columns", how="all")
6083
)
6184
self.header.series.index = self.header.series.index.astype(str).str.lower()
6285

src/allotropy/parsers/unchained_labs_lunatic_stunner/unchained_labs_lunatic_stunner_structure.py

Lines changed: 94 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,27 @@ def _extract_peak_data(well_plate_data: SeriesData) -> list[dict[str, Any]]:
9090
return peak_data
9191

9292

93+
def _get_calculated_value(series: SeriesData, key: str) -> float | None:
94+
"""Return numeric value for calculated data with special handling.
95+
96+
- If the raw cell value is the string literal "N/A"
97+
return NEGATIVE_ZERO.
98+
- If the cell is empty/NaN, return None so the caller can skip adding the key.
99+
- Otherwise, attempt to convert to float using the standard SeriesData conversion.
100+
"""
101+
# Read as string first to inspect literal content without coercing to float/None
102+
raw_string = series.get(str, key, validate=SeriesData.NOT_NAN)
103+
if raw_string is None:
104+
return None
105+
stripped = str(raw_string).strip()
106+
if stripped == "":
107+
return None
108+
if stripped.upper() == NOT_APPLICABLE:
109+
return NEGATIVE_ZERO
110+
# Otherwise convert using the standard float handling
111+
return series.get(float, key)
112+
113+
93114
def _create_measurement(
94115
well_plate_data: SeriesData,
95116
header: SeriesData,
@@ -137,6 +158,13 @@ def _create_measurement(
137158
detection_type = DEFAULT_DETECTION_TYPE
138159
if application and "B22 & kD" in application:
139160
detection_type = DYNAMIC_LIGHT_SCATTERING_DETECTION_TYPE
161+
# Build calculated data values with special handling for N/A and empty cells
162+
calculated_data_values: dict[str, float] = {}
163+
for item in CALCULATED_DATA_LOOKUP.get(wavelength_column, []):
164+
value = _get_calculated_value(well_plate_data, item["column"])
165+
if value is not None:
166+
calculated_data_values[item["column"]] = value
167+
140168
return Measurement(
141169
type_=MeasurementType.ULTRAVIOLET_ABSORBANCE,
142170
device_type=DEVICE_TYPE,
@@ -188,39 +216,38 @@ def _create_measurement(
188216
else None,
189217
wavelength_identifier=wavelength_column,
190218
calc_docs_custom_info={
191-
**{
192-
item["column"]: well_plate_data.get(float, item["column"])
193-
for item in CALCULATED_DATA_LOOKUP.get(wavelength_column, [])
194-
},
219+
**calculated_data_values,
195220
**{
196221
"b22 linear fit": well_plate_data.get(str, "b22 linear fit"),
197222
"kd linear fit": well_plate_data.get(str, "kd linear fit"),
198223
},
199224
},
200225
measurement_custom_info={
201226
"electronic_absorbance_reference_absorbance": background_absorbance,
202-
**well_plate_data.get_unread(
203-
# Skip already mapped columns from well plate data (repeated in CSV no header cases)
204-
skip={
205-
"time",
206-
"row",
207-
"concentration factor (ng/ul)",
208-
"application",
209-
"concentration (ng/ul)",
210-
"background wvl. (nm)",
211-
"plate id",
212-
"plate position",
213-
"plate type",
214-
"instrument id",
215-
"column",
216-
# Strings to skip since these are already captured as measurements/calculated data
217-
# Skip absorbance measurements with a### (10mm) -- spectral scans
218-
r"^a\d{3} \(10mm\)$",
219-
# a###/a### -- calculated purity values
220-
r"^a\d{3}/a\d{3}$",
221-
# a### -- raw absorbance measurement
222-
r"^a\d{3}$",
223-
},
227+
**_filter_empty_string_values(
228+
well_plate_data.get_unread(
229+
# Skip already mapped columns from well plate data (repeated in CSV no header cases)
230+
skip={
231+
"time",
232+
"row",
233+
"concentration factor (ng/ul)",
234+
"application",
235+
"concentration (ng/ul)",
236+
"background wvl. (nm)",
237+
"plate id",
238+
"plate position",
239+
"plate type",
240+
"instrument id",
241+
"column",
242+
# Strings to skip since these are already captured as measurements/calculated data
243+
# Skip absorbance measurements with a### (10mm) -- spectral scans
244+
r"^a\d{3} \(10mm\)$",
245+
# a###/a### -- calculated purity values
246+
r"^a\d{3}/a\d{3}$",
247+
# a### -- raw absorbance measurement
248+
r"^a\d{3}$",
249+
},
250+
)
224251
),
225252
},
226253
)
@@ -232,8 +259,8 @@ def _get_error_documents(
232259
) -> list[ErrorDocument]:
233260
error_documents = []
234261
for item in CALCULATED_DATA_LOOKUP.get(wavelength_column, []):
235-
value = well_plate_data.get(float, item["column"])
236-
if value is None:
262+
value = _get_calculated_value(well_plate_data, item["column"])
263+
if value == NEGATIVE_ZERO:
237264
error_documents.append(
238265
ErrorDocument(
239266
error=NOT_APPLICABLE,
@@ -283,37 +310,49 @@ def create_metadata(header: SeriesData, file_path: str) -> Metadata:
283310
unc_path=file_path,
284311
asm_file_identifier=asm_file_identifier.name,
285312
data_system_instance_id=NOT_APPLICABLE,
286-
custom_info_doc=header.get_unread(
287-
# Skip already mapped columns from well plate data (repeated in CSV no header cases)
288-
skip={
289-
"time",
290-
"row",
291-
"path length mode",
292-
"sample group",
293-
"pump",
294-
"concentration factor (ng/ul)",
295-
"sample name",
296-
"application",
297-
"concentration (ng/ul)",
298-
"background wvl. (nm)",
299-
"plate id",
300-
"plate position",
301-
# Strings to skip since these are already captured as measurements/calculated data
302-
# Skip absorbance spectrum measurements with a### (10mm)
303-
r"^a\d{3} \(10mm\)$",
304-
# a###/a###
305-
r"^a\d{3}/a\d{3}$",
306-
# a###
307-
r"^a\d{3}$",
308-
# background a###
309-
r"^background \(a\d{3}\)$",
310-
# a### concentration (ng/uL)
311-
r"^a\d{3} concentration \(ng/ul\)$",
312-
},
313+
custom_info_doc=_filter_empty_string_values(
314+
header.get_unread(
315+
# Skip already mapped columns from well plate data (repeated in CSV no header cases)
316+
skip={
317+
"time",
318+
"row",
319+
"path length mode",
320+
"sample group",
321+
"pump",
322+
"concentration factor (ng/ul)",
323+
"sample name",
324+
"application",
325+
"concentration (ng/ul)",
326+
"e1%",
327+
"concentration (mg/ml)",
328+
"background wvl. (nm)",
329+
"plate id",
330+
"plate position",
331+
# Strings to skip since these are already captured as measurements/calculated data
332+
# Skip absorbance spectrum measurements with a### (10mm)
333+
r"^a\d{3} \(10mm\)$",
334+
# a###/a###
335+
r"^a\d{3}/a\d{3}$",
336+
# a###
337+
r"^a\d{3}$",
338+
# background a###
339+
r"^background \(a\d{3}\)$",
340+
# a### concentration (ng/uL)
341+
r"^a\d{3} concentration \(ng/ul\)$",
342+
},
343+
)
313344
),
314345
)
315346

316347

348+
def _filter_empty_string_values(unread: dict[str, Any]) -> dict[str, Any]:
349+
return {
350+
key: value
351+
for key, value in unread.items()
352+
if not (isinstance(value, str) and value.strip() == "")
353+
}
354+
355+
317356
def create_measurement_groups(
318357
header: SeriesData, data: pd.DataFrame
319358
) -> tuple[list[MeasurementGroup], list[CalculatedDocument]]:

0 commit comments

Comments
 (0)