diff --git a/src/allotropy/parsers/cytiva_biacore_insight/cytiva_biacore_insight_structure.py b/src/allotropy/parsers/cytiva_biacore_insight/cytiva_biacore_insight_structure.py index 195d22f6a..8e2685869 100644 --- a/src/allotropy/parsers/cytiva_biacore_insight/cytiva_biacore_insight_structure.py +++ b/src/allotropy/parsers/cytiva_biacore_insight/cytiva_biacore_insight_structure.py @@ -346,6 +346,78 @@ def get_data( return self._data.get(f"{channel} {capture_solution} {analyte_solution}", empty) +class EvaluationConcentration: + """Stores calculated concentration data from Evaluation sheets. + + Evaluation sheets (e.g., "Evaluation - Trend_Active_Stab") contain both + nominal concentrations and instrument-calculated concentrations based on + calibration curves. This class provides lookup by cycle, flow cell, and solution. + """ + + _data: dict[str, float] + + def __init__(self, evaluation_tables: list[pd.DataFrame]) -> None: + """Parse evaluation tables and build concentration lookup. + + Args: + evaluation_tables: List of DataFrames from Evaluation sheets, + each containing concentration data + """ + self._data = {} + + for table in evaluation_tables: + if table.empty or len(table.columns) == 0: + continue + + # Check if this table has the calculated concentration column + if "Calculated conc. (µg/ml)" not in table.columns: + continue + + # Check if we have the necessary columns for keying + required_cols = ["Cycle", "Flow cell", "Solution"] + if not all(col in table.columns for col in required_cols): + continue + + # Build lookup dictionary: key = "cycle flowcell solution" + for _, row in table.iterrows(): + row_data = SeriesData(row) + cycle = row_data.get(int, "Cycle") + flow_cell = row_data.get(str, "Flow cell") + solution = row_data.get(str, "Solution") + calc_conc = row_data.get(float, "Calculated conc. (µg/ml)") + + # Skip rows with missing key data or None calculated concentration + if cycle is None or flow_cell is None or solution is None: + continue + if calc_conc is None: + continue + + # Build key: "cycle flowcell solution" + key = f"{cycle} {flow_cell} {solution}" + self._data[key] = calc_conc + + def get_calculated_concentration( + self, + cycle: int, + flow_cell: str | int, + solution: str | None, + ) -> float | None: + """Get calculated concentration for a given cycle, flow cell, and solution. + + Args: + cycle: Cycle number + flow_cell: Flow cell identifier (int or string like "2-1") + solution: Solution name (e.g., "Calib 1", "Sample 1") + + Returns: + Calculated concentration in µg/ml, or None if not found + """ + if solution is None: + return None + key = f"{cycle} {flow_cell} {solution}" + return self._data.get(key) + + @dataclass(frozen=True) class ReportPointData: identifier: str @@ -395,6 +467,7 @@ def create( channel_data: pd.DataFrame, metadata: BiacoreInsightMetadata, evaluation_kinetics: EvaluationKinetics, + evaluation_concentration: EvaluationConcentration, grouping_column: str = "Channel", ) -> MeasurementData: identifier = random_uuid_str() @@ -489,6 +562,17 @@ def create( ) analyte_solution = first_row_data.get(str, "Analyte 1 Solution") + # Get calculated concentration from Evaluation sheets if available + # Use the first flow cell from channel_data to look up the concentration + first_flow_cell = str(channel_data["Flow cell"].iloc[0]) + calculated_concentration = ( + evaluation_concentration.get_calculated_concentration( + cycle=cycle_number, + flow_cell=first_flow_cell, + solution=analyte_solution, + ) + ) + return MeasurementData( identifier=identifier, sample_identifier=f"Run{run}_Cycle{cycle_number}", @@ -534,6 +618,10 @@ def create( ), ) ), + "Analyte 1 Calculated Concentration": quantity_or_none( + TQuantityValueMicrogramPerMilliliter, + calculated_concentration, + ), "Analyte 1 Molecular weight": quantity_or_none( TQuantityValueDalton, first_row_data.get(float, "Analyte 1 Molecular weight (Da)"), @@ -589,6 +677,31 @@ def create(reader: CytivaBiacoreInsightReader) -> Data: if evaluation_kinetics is None: evaluation_kinetics = EvaluationKinetics(pd.DataFrame()) + # OPTIMIZATION: Parse evaluation concentration data once, not per cycle + # Look for Evaluation sheets with concentration data + evaluation_concentration_tables = [] + evaluation_sheet_prefixes = [ + "Evaluation - Trend_", + "Evaluation - Preced_", + ] + for sheet_name in reader.data.keys(): + if any( + sheet_name.startswith(prefix) for prefix in evaluation_sheet_prefixes + ): + try: + eval_table = _get_table_from_dataframe( + reader.data[sheet_name], split_on="Cycle" + ) + evaluation_concentration_tables.append(eval_table) + except (KeyError, ValueError, AssertionError): + # If parsing fails (missing columns, malformed data), skip this sheet + # Evaluation sheets are optional, so we continue without them + continue + + evaluation_concentration = EvaluationConcentration( + evaluation_concentration_tables + ) + # OPTIMIZATION: Group all data by cycle once using pandas groupby # This is much faster than filtering per cycle cycles_dict = {} @@ -601,7 +714,11 @@ def create(reader: CytivaBiacoreInsightReader) -> Data: cycle_int = int(float(str(cycle_number))) cycles_dict[cycle_int] = [ MeasurementData.create( - channel_data, metadata, evaluation_kinetics, grouping_column + channel_data, + metadata, + evaluation_kinetics, + evaluation_concentration, + grouping_column, ) for _, channel_data in cycle_group.groupby(grouping_column) ] @@ -652,12 +769,40 @@ def create_measurements_for_cycle( if evaluation_kinetics is None: evaluation_kinetics = EvaluationKinetics(pd.DataFrame()) + # Parse evaluation concentration data + evaluation_concentration_tables = [] + evaluation_sheet_prefixes = [ + "Evaluation - Trend_", + "Evaluation - Preced_", + ] + for sheet_name in reader.data.keys(): + if any( + sheet_name.startswith(prefix) for prefix in evaluation_sheet_prefixes + ): + try: + eval_table = _get_table_from_dataframe( + reader.data[sheet_name], split_on="Cycle" + ) + evaluation_concentration_tables.append(eval_table) + except (KeyError, ValueError, AssertionError): + # If parsing fails (missing columns, malformed data), skip this sheet + # Evaluation sheets are optional, so we continue without them + continue + + evaluation_concentration = EvaluationConcentration( + evaluation_concentration_tables + ) + # Determine grouping column: use "Channel" if available, otherwise "Flow cell" grouping_column = "Channel" if "Channel" in cycle_data.columns else "Flow cell" return [ MeasurementData.create( - channel_data, metadata, evaluation_kinetics, grouping_column + channel_data, + metadata, + evaluation_kinetics, + evaluation_concentration, + grouping_column, ) for _, channel_data in cycle_data.groupby(grouping_column) ] diff --git a/tests/parsers/cytiva_biacore_insight/testdata/Concentration Analysis.json b/tests/parsers/cytiva_biacore_insight/testdata/Concentration Analysis.json index 107bb6b2d..7a46b4cde 100644 --- a/tests/parsers/cytiva_biacore_insight/testdata/Concentration Analysis.json +++ b/tests/parsers/cytiva_biacore_insight/testdata/Concentration Analysis.json @@ -9932,6 +9932,10 @@ "Analyte 1 Concentration": { "value": 0.215, "unit": "ug/mL" + }, + "Analyte 1 Calculated Concentration": { + "value": 0.2221081, + "unit": "ug/mL" } } }, @@ -10177,6 +10181,10 @@ "Analyte 1 Concentration": { "value": 0.215, "unit": "ug/mL" + }, + "Analyte 1 Calculated Concentration": { + "value": 0.223146185, + "unit": "ug/mL" } } }, @@ -11685,6 +11693,10 @@ "Analyte 1 Concentration": { "value": 0.215, "unit": "ug/mL" + }, + "Analyte 1 Calculated Concentration": { + "value": 0.22221145, + "unit": "ug/mL" } } }, @@ -11930,6 +11942,10 @@ "Analyte 1 Concentration": { "value": 0.215, "unit": "ug/mL" + }, + "Analyte 1 Calculated Concentration": { + "value": 0.223151684, + "unit": "ug/mL" } } }, @@ -13438,6 +13454,10 @@ "Analyte 1 Concentration": { "value": 0.322, "unit": "ug/mL" + }, + "Analyte 1 Calculated Concentration": { + "value": 0.328146726, + "unit": "ug/mL" } } }, @@ -13683,6 +13703,10 @@ "Analyte 1 Concentration": { "value": 0.322, "unit": "ug/mL" + }, + "Analyte 1 Calculated Concentration": { + "value": 0.329638571, + "unit": "ug/mL" } } }, @@ -15191,6 +15215,10 @@ "Analyte 1 Concentration": { "value": 0.322, "unit": "ug/mL" + }, + "Analyte 1 Calculated Concentration": { + "value": 0.327615142, + "unit": "ug/mL" } } }, @@ -15436,6 +15464,10 @@ "Analyte 1 Concentration": { "value": 0.322, "unit": "ug/mL" + }, + "Analyte 1 Calculated Concentration": { + "value": 0.330206424, + "unit": "ug/mL" } } }, @@ -38823,7 +38855,7 @@ "file name": "Name", "UNC path": "Root/", "ASM converter name": "allotropy_cytiva_biacore_insight", - "ASM converter version": "0.1.113", + "ASM converter version": "0.1.117", "software name": "Biacore Insight Evaluation", "software version": "6.0.7.1750" },