Skip to content

Commit dc21cd7

Browse files
fix: Recognize Evaluation-Kinetics sheet and Single Cycle Kinetics analyte
The parser failed to find kinetics data (ka, kd, KD, Rmax) when the sheet was named "Evaluation-Kinetics" (no spaces around dash) instead of "Evaluation - Kinetics". Also, for Single Cycle Kinetics experiments the analyte identity is in "Single cycle kinetics 1 Solution" rather than "Analyte 1 Solution", causing key mismatches in the kinetics lookup. Added _find_sheet() and _find_sheets_with_prefix() helpers that normalize dash/space separators before comparing, fixing all sheet name lookups (QC sheets, Evaluation-Kinetics, Evaluation-Trend/Preced). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e3a432a commit dc21cd7

1 file changed

Lines changed: 71 additions & 54 deletions

File tree

src/allotropy/parsers/cytiva_biacore_insight/cytiva_biacore_insight_structure.py

Lines changed: 71 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,33 @@ def _get_regeneration_custom_info(
207207
return regen_info
208208

209209

210+
def _find_sheet(reader_data: dict[str, Any], name: str) -> str | None:
211+
"""Find a sheet by name, tolerating variations in dash/space separators.
212+
213+
Biacore exports use inconsistent separators: "QC - X", "QC-X", "QC- X".
214+
This normalizes by collapsing whitespace around dashes before comparing.
215+
"""
216+
217+
def _normalize(s: str) -> str:
218+
return re.sub(r"\s*-\s*", "-", s).strip()
219+
220+
target = _normalize(name)
221+
for sheet_name in reader_data:
222+
if _normalize(sheet_name) == target:
223+
return sheet_name
224+
return None
225+
226+
227+
def _find_sheets_with_prefix(reader_data: dict[str, Any], prefix: str) -> list[str]:
228+
"""Find all sheets whose names start with prefix after normalizing separators."""
229+
230+
def _normalize(s: str) -> str:
231+
return re.sub(r"\s*-\s*", "-", s).strip()
232+
233+
target = _normalize(prefix)
234+
return [s for s in reader_data if _normalize(s).startswith(target)]
235+
236+
210237
def _get_table_from_dataframe(df: pd.DataFrame, split_on: str) -> pd.DataFrame:
211238
data_table = drop_df_rows_while(df, lambda row: row[0] != split_on)
212239
return parse_header_row(
@@ -422,7 +449,11 @@ def get_data_processing_doc(reader: CytivaBiacoreInsightReader) -> DictType | No
422449
"QC - Capture level",
423450
"QC - Binding to reference",
424451
]
425-
qc_tables = [table for table in table_names if table in reader.data]
452+
qc_tables = [
453+
sheet
454+
for name in table_names
455+
if (sheet := _find_sheet(reader.data, name)) is not None
456+
]
426457
qc_df = None
427458
while qc_df is None and qc_tables:
428459
qc_table = qc_tables.pop(0)
@@ -518,17 +549,20 @@ class EvaluationKinetics:
518549

519550
def __init__(self, kinetics_table: pd.DataFrame) -> None:
520551
def _get_key(row: pd.Series[Any]) -> str:
521-
# Handle both "Channel" and "Flow cell" columns
522552
channel_or_flowcell = row.get("Channel", row.get("Flow cell", ""))
523-
# For affinity data, we might have multiple capture solutions
524-
# Build key with all available capture solutions
525553
capture_solution = row.get("Capture 1 Solution", "")
526-
# Handle None, NaN, or pd.NA values for capture_solution
527554
if capture_solution is None or (
528555
isinstance(capture_solution, float) and np.isnan(capture_solution)
529556
):
530557
capture_solution = ""
531-
analyte_solution = row.get("Analyte 1 Solution", "")
558+
analyte_solution = row.get(
559+
"Single cycle kinetics 1 Solution",
560+
row.get("Analyte 1 Solution", ""),
561+
)
562+
if analyte_solution is None or (
563+
isinstance(analyte_solution, float) and np.isnan(analyte_solution)
564+
):
565+
analyte_solution = ""
532566
return f"{channel_or_flowcell} {capture_solution} {analyte_solution}"
533567

534568
self._data = {}
@@ -831,7 +865,9 @@ def create(
831865
capture_solution = _first_not_null_or_none(
832866
channel_data["Capture 1 Solution"]
833867
)
834-
analyte_solution = first_row_data.get(str, "Analyte 1 Solution")
868+
analyte_solution = first_row_data.get(
869+
str, "Single cycle kinetics 1 Solution"
870+
) or first_row_data.get(str, "Analyte 1 Solution")
835871

836872
# Get calculated concentration from Evaluation sheets if available
837873
# Use the first flow cell from channel_data to look up the concentration
@@ -896,47 +932,38 @@ def create(reader: CytivaBiacoreInsightReader) -> Data:
896932

897933
# OPTIMIZATION: Parse evaluation/affinity data once, not per cycle
898934
evaluation_kinetics = None
899-
if "Evaluation - Kinetics" in reader.data:
935+
kinetics_sheet = _find_sheet(reader.data, "Evaluation - Kinetics")
936+
if kinetics_sheet is not None:
900937
evaluation_kinetics_table = _get_table_from_dataframe(
901-
reader.data["Evaluation - Kinetics"], split_on="Group"
938+
reader.data[kinetics_sheet], split_on="Group"
902939
)
903940
evaluation_kinetics = EvaluationKinetics(evaluation_kinetics_table)
904941
else:
905-
# Look for Affinity sheets (e.g., "Affinity 1", "Affinity 2", etc.)
906942
affinity_sheets = [
907943
sheet for sheet in reader.data.keys() if sheet.startswith("Affinity")
908944
]
909945
if affinity_sheets:
910-
# Use the first Affinity sheet found
911946
affinity_table = _get_table_from_dataframe(
912947
reader.data[affinity_sheets[0]], split_on="Group"
913948
)
914949
evaluation_kinetics = EvaluationKinetics(affinity_table)
915950

916-
# If no kinetics or affinity data found, create empty
917951
if evaluation_kinetics is None:
918952
evaluation_kinetics = EvaluationKinetics(pd.DataFrame())
919953

920954
# OPTIMIZATION: Parse evaluation concentration data once, not per cycle
921-
# Look for Evaluation sheets with concentration data
922955
evaluation_concentration_tables = []
923-
evaluation_sheet_prefixes = [
924-
"Evaluation - Trend_",
925-
"Evaluation - Preced_",
926-
]
927-
for sheet_name in reader.data.keys():
928-
if any(
929-
sheet_name.startswith(prefix) for prefix in evaluation_sheet_prefixes
930-
):
931-
try:
932-
eval_table = _get_table_from_dataframe(
933-
reader.data[sheet_name], split_on="Cycle"
934-
)
935-
evaluation_concentration_tables.append(eval_table)
936-
except (KeyError, ValueError, AssertionError):
937-
# If parsing fails (missing columns, malformed data), skip this sheet
938-
# Evaluation sheets are optional, so we continue without them
939-
continue
956+
eval_conc_sheets = _find_sheets_with_prefix(
957+
reader.data, "Evaluation - Trend_"
958+
) + _find_sheets_with_prefix(reader.data, "Evaluation - Preced_")
959+
for sheet_name in eval_conc_sheets:
960+
try:
961+
eval_table = _get_table_from_dataframe(
962+
reader.data[sheet_name], split_on="Cycle"
963+
)
964+
evaluation_concentration_tables.append(eval_table)
965+
except (KeyError, ValueError, AssertionError):
966+
continue
940967

941968
evaluation_concentration = EvaluationConcentration(
942969
evaluation_concentration_tables
@@ -986,48 +1013,38 @@ def create_measurements_for_cycle(
9861013
cycle_data = report_point_table[report_point_table["Cycle"] == cycle_number]
9871014

9881015
# Handle optional kinetics/affinity data
989-
# Try "Evaluation - Kinetics" first, then look for "Affinity" sheets
9901016
evaluation_kinetics = None
991-
if "Evaluation - Kinetics" in reader.data:
1017+
kinetics_sheet = _find_sheet(reader.data, "Evaluation - Kinetics")
1018+
if kinetics_sheet is not None:
9921019
evaluation_kinetics_table = _get_table_from_dataframe(
993-
reader.data["Evaluation - Kinetics"], split_on="Group"
1020+
reader.data[kinetics_sheet], split_on="Group"
9941021
)
9951022
evaluation_kinetics = EvaluationKinetics(evaluation_kinetics_table)
9961023
else:
997-
# Look for Affinity sheets (e.g., "Affinity 1", "Affinity 2", etc.)
9981024
affinity_sheets = [
9991025
sheet for sheet in reader.data.keys() if sheet.startswith("Affinity")
10001026
]
10011027
if affinity_sheets:
1002-
# Use the first Affinity sheet found
10031028
affinity_table = _get_table_from_dataframe(
10041029
reader.data[affinity_sheets[0]], split_on="Group"
10051030
)
10061031
evaluation_kinetics = EvaluationKinetics(affinity_table)
10071032

1008-
# If no kinetics or affinity data found, create empty
10091033
if evaluation_kinetics is None:
10101034
evaluation_kinetics = EvaluationKinetics(pd.DataFrame())
10111035

1012-
# Parse evaluation concentration data
10131036
evaluation_concentration_tables = []
1014-
evaluation_sheet_prefixes = [
1015-
"Evaluation - Trend_",
1016-
"Evaluation - Preced_",
1017-
]
1018-
for sheet_name in reader.data.keys():
1019-
if any(
1020-
sheet_name.startswith(prefix) for prefix in evaluation_sheet_prefixes
1021-
):
1022-
try:
1023-
eval_table = _get_table_from_dataframe(
1024-
reader.data[sheet_name], split_on="Cycle"
1025-
)
1026-
evaluation_concentration_tables.append(eval_table)
1027-
except (KeyError, ValueError, AssertionError):
1028-
# If parsing fails (missing columns, malformed data), skip this sheet
1029-
# Evaluation sheets are optional, so we continue without them
1030-
continue
1037+
eval_conc_sheets = _find_sheets_with_prefix(
1038+
reader.data, "Evaluation - Trend_"
1039+
) + _find_sheets_with_prefix(reader.data, "Evaluation - Preced_")
1040+
for sheet_name in eval_conc_sheets:
1041+
try:
1042+
eval_table = _get_table_from_dataframe(
1043+
reader.data[sheet_name], split_on="Cycle"
1044+
)
1045+
evaluation_concentration_tables.append(eval_table)
1046+
except (KeyError, ValueError, AssertionError):
1047+
continue
10311048

10321049
evaluation_concentration = EvaluationConcentration(
10331050
evaluation_concentration_tables

0 commit comments

Comments
 (0)