Skip to content

Commit 92efd86

Browse files
authored
perf: Cytiva Biacore T200 Evaluation Module - Added optimizations on decoding logic (#1109)
1 parent f77e5bf commit 92efd86

2 files changed

Lines changed: 109 additions & 118 deletions

File tree

src/allotropy/parsers/cytiva_biacore_t200_evaluation/cytiva_biacore_t200_evaluation_data_creator.py

Lines changed: 47 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,16 @@
4848
from allotropy.parsers.utils.uuids import random_uuid_str
4949
from allotropy.parsers.utils.values import quantity_or_none, try_float_or_none
5050

51+
# Precompiled patterns for faster flow cell id normalization
52+
_FLOWCELL_ID_RE = re.compile(r"\d+")
53+
5154

5255
def _get_sensorgram_datacube(
5356
sensorgram_df: pd.DataFrame, *, cycle: int, flow_cell: str
5457
) -> DataCube:
5558
# Extract all sensorgram data points
56-
time_vals = sensorgram_df["Time (s)"].astype(float).to_list()
57-
resp_vals = sensorgram_df["Sensorgram (RU)"].astype(float).to_list()
59+
time_vals = sensorgram_df["Time (s)"].to_numpy(copy=False)
60+
resp_vals = sensorgram_df["Sensorgram (RU)"].to_numpy(copy=False)
5861
return DataCube(
5962
label=f"Cycle{cycle}_FlowCell{flow_cell}",
6063
structure_dimensions=[
@@ -63,8 +66,8 @@ def _get_sensorgram_datacube(
6366
structure_measures=[
6467
DataCubeComponent(FieldComponentDatatype.double, "resonance", "RU")
6568
],
66-
dimensions=[time_vals],
67-
measures=[resp_vals],
69+
dimensions=[time_vals.tolist()],
70+
measures=[resp_vals.tolist()],
6871
)
6972

7073

@@ -261,10 +264,22 @@ def _normalize_flow_cell_id(value: Any) -> str:
261264
# Don't normalize reference-subtracted flow cell IDs (e.g., "2-1", "3-1", "4-1")
262265
if "-" in s:
263266
return s
264-
# Only normalize pure numeric flow cell IDs
265-
m = re.match(r"\d+", s)
267+
# Fast path for pure digits
268+
if s.isdigit():
269+
return s
270+
# Only normalize pure numeric prefix if present
271+
m = _FLOWCELL_ID_RE.match(s)
266272
return m.group(0) if m else s
267273

274+
# Precompute kinetic analysis mapping from identifiers to flow cell ids (e.g., EvaluationItem2 -> "2")
275+
kinetic_map: dict[str, KineticResult] | None = None
276+
if data.kinetic_analysis and data.kinetic_analysis.results_by_identifier:
277+
kinetic_map = {}
278+
for key, result in data.kinetic_analysis.results_by_identifier.items():
279+
m = _FLOWCELL_ID_RE.search(str(key))
280+
if m:
281+
kinetic_map[m.group(0)] = result
282+
268283
# Process all flow cells (including reference-subtracted ones like "2-1", "3-1", "4-1")
269284
for flow_cell, df_fc in sensorgram_df.groupby("Flow Cell Number"):
270285
fc_id = _normalize_flow_cell_id(flow_cell)
@@ -357,33 +372,8 @@ def _normalize_flow_cell_id(value: Any) -> str:
357372
}
358373
break
359374

360-
# Extract kinetic analysis data for this specific flow cell
361-
# Match EvaluationItem identifier to flow cell identifier
362-
combined_kinetic_data = None
363-
if data.kinetic_analysis and data.kinetic_analysis.results_by_identifier:
364-
# Try to find the specific EvaluationItem for this flow cell
365-
# Flow cell IDs are typically "1", "2", "3", "4"
366-
# EvaluationItem IDs are typically "EvaluationItem1", "EvaluationItem2", etc.
367-
matching_eval_item = None
368-
369-
# First, try direct mapping: flow cell "1" -> "EvaluationItem1"
370-
eval_item_key = f"EvaluationItem{fc_id}"
371-
if eval_item_key in data.kinetic_analysis.results_by_identifier:
372-
matching_eval_item = eval_item_key
373-
else:
374-
# If direct mapping fails, look for any EvaluationItem that might correspond to this flow cell
375-
# This could be enhanced with more sophisticated matching logic if needed
376-
for eval_key in data.kinetic_analysis.results_by_identifier.keys():
377-
if fc_id in eval_key or eval_key.endswith(fc_id):
378-
matching_eval_item = eval_key
379-
break
380-
381-
# Use only the matching EvaluationItem data for this flow cell
382-
if matching_eval_item:
383-
result = data.kinetic_analysis.results_by_identifier[matching_eval_item]
384-
combined_kinetic_data = result
385-
386-
kinetic_data = combined_kinetic_data
375+
# Extract kinetic analysis data using precomputed mapping (if present)
376+
kinetic_data = kinetic_map.get(fc_id) if kinetic_map is not None else None
387377

388378
measurements.append(
389379
Measurement(
@@ -458,37 +448,37 @@ def _normalize_flow_cell_id(value: Any) -> str:
458448

459449

460450
def create_measurement_groups(data: Data) -> list[MeasurementGroup]:
461-
sys = data.system_information
451+
system_info = data.system_information
462452
# Prefer application template timestamp if present in run metadata
463-
if data.run_metadata.timestamp and not sys.measurement_time:
464-
sys = SystemInformation(
465-
application_name=sys.application_name,
466-
application_version=sys.application_version,
467-
user_name=sys.user_name,
468-
system_controller_identifier=sys.system_controller_identifier,
469-
os_type=sys.os_type,
470-
os_version=sys.os_version,
453+
if data.run_metadata.timestamp and not system_info.measurement_time:
454+
system_info = SystemInformation(
455+
application_name=system_info.application_name,
456+
application_version=system_info.application_version,
457+
user_name=system_info.user_name,
458+
system_controller_identifier=system_info.system_controller_identifier,
459+
os_type=system_info.os_type,
460+
os_version=system_info.os_version,
471461
measurement_time=data.run_metadata.timestamp,
472-
unread_application_properties=sys.unread_application_properties,
473-
measurement_aggregate_fields=sys.measurement_aggregate_fields,
462+
unread_application_properties=system_info.unread_application_properties,
463+
measurement_aggregate_fields=system_info.measurement_aggregate_fields,
474464
)
475465
# As a final fallback, look directly in application_template_details.properties
476-
if not sys.measurement_time and data.application_template_details:
466+
if not system_info.measurement_time and data.application_template_details:
477467
props = data.application_template_details.get("properties", {})
478468
ts = props.get("Timestamp")
479469
if ts:
480-
sys = SystemInformation(
481-
application_name=sys.application_name,
482-
application_version=sys.application_version,
483-
user_name=sys.user_name,
484-
system_controller_identifier=sys.system_controller_identifier,
485-
os_type=sys.os_type,
486-
os_version=sys.os_version,
470+
system_info = SystemInformation(
471+
application_name=system_info.application_name,
472+
application_version=system_info.application_version,
473+
user_name=system_info.user_name,
474+
system_controller_identifier=system_info.system_controller_identifier,
475+
os_type=system_info.os_type,
476+
os_version=system_info.os_version,
487477
measurement_time=ts,
488-
unread_application_properties=sys.unread_application_properties,
489-
measurement_aggregate_fields=sys.measurement_aggregate_fields,
478+
unread_application_properties=system_info.unread_application_properties,
479+
measurement_aggregate_fields=system_info.measurement_aggregate_fields,
490480
)
491-
if not sys.measurement_time:
481+
if not system_info.measurement_time:
492482
msg = "Missing measurement time. Expected application_template_details.properties.Timestamp."
493483
raise AllotropeParsingError(msg)
494484
groups: list[MeasurementGroup] = []
@@ -499,7 +489,7 @@ def create_measurement_groups(data: Data) -> list[MeasurementGroup]:
499489
"data collection rate": quantity_or_none(
500490
TQuantityValueHertz, data.run_metadata.data_collection_rate
501491
),
502-
**sys.measurement_aggregate_fields,
492+
**system_info.measurement_aggregate_fields,
503493
}
504494
# Add aggregate-level experimental data identifier for convenience (first measurement's FC)
505495
if measurements:
@@ -519,7 +509,7 @@ def create_measurement_groups(data: Data) -> list[MeasurementGroup]:
519509

520510
groups.append(
521511
MeasurementGroup(
522-
measurement_time=sys.measurement_time,
512+
measurement_time=system_info.measurement_time,
523513
measurements=measurements,
524514
experiment_type=None,
525515
analytical_method_identifier=None,

src/allotropy/parsers/cytiva_biacore_t200_evaluation/cytiva_biacore_t200_evaluation_decoder.py

Lines changed: 62 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from __future__ import annotations
22

3+
import datetime as _dt
4+
import io
35
import re
4-
import struct
56
from typing import Any
67

78
import numpy as np
9+
from numpy.typing import NDArray
810
import olefile as ole
911
import pandas as pd
1012
import xmltodict
@@ -20,8 +22,6 @@
2022

2123
def _convert_datetime(days_str: str) -> str:
2224
# Biacore epoch: 1899-12-30 UTC
23-
import datetime as _dt
24-
2525
days = float(days_str)
2626
start = _dt.datetime(year=1899, month=12, day=30, tzinfo=_dt.timezone.utc)
2727
return (start + _dt.timedelta(days=days)).isoformat()
@@ -234,8 +234,6 @@ def _extract_kinetic_analysis(
234234

235235
# Use the full EvaluationItem identifier as the key
236236
if "EvaluationItem" in path_str:
237-
import re
238-
239237
match = re.search(r"(EvaluationItem\d+)", path_str)
240238
if match:
241239
flow_cell_id = match.group(1) # e.g., "EvaluationItem2"
@@ -377,13 +375,20 @@ def decode_data(named_file_contents: NamedFileContents) -> dict[str, Any]:
377375
with ole.OleFileIO(named_file_contents.get_bytes_stream()) as content:
378376
streams = content.listdir()
379377

380-
sensorgram_df_list: list[pd.DataFrame] = []
378+
# Accumulate arrays for a single DataFrame build at the end
379+
fc_list: list[NDArray[np.object_]] = []
380+
cycle_list: list[NDArray[np.integer[Any]]] = []
381+
curve_list: list[NDArray[np.object_]] = []
382+
window_list: list[NDArray[np.object_]] = []
383+
values_list: list[NDArray[np.floating[Any]]] = []
384+
times_list: list[NDArray[np.floating[Any]]] = []
381385
report_point_by_cycle: dict[str, pd.DataFrame] = {}
382386
dip_data: dict[str, Any] = {}
383387
kinetic_analysis: dict[str, Any] = {}
384388
sample_data: Any = None
385389

386390
flow_cell = None
391+
total_cycles_detected = 0
387392

388393
for stream in streams:
389394
path_str = "/".join(stream)
@@ -427,10 +432,8 @@ def decode_data(named_file_contents: NamedFileContents) -> dict[str, Any]:
427432
continue
428433
if stream == ["RPoint Table"]:
429434
data = content.openstream(stream).read()
430-
lines = data.decode("utf-8").strip().split("\n")
431-
header = lines[0].split("\t")
432-
rows = [line.split("\t") for line in lines[1:] if line]
433-
df = pd.DataFrame([dict(zip(header, r, strict=True)) for r in rows])
435+
436+
df = pd.read_csv(io.BytesIO(data), sep="\t")
434437
report_point_by_cycle = {
435438
grp["Cycle"].iloc[0]: grp for _, grp in df.groupby("Cycle")
436439
}
@@ -501,49 +504,53 @@ def decode_data(named_file_contents: NamedFileContents) -> dict[str, Any]:
501504
if "XYData" in path_str:
502505
# Read all data from the file
503506
raw = content.openstream(stream).read()
504-
xy = list(struct.unpack("f" * (len(raw) // 4), raw))
505-
indexed = xy[3:]
506-
half = int(len(indexed) / 2)
507+
arr = np.frombuffer(raw, dtype="<f4")
508+
indexed = arr[3:]
509+
half = indexed.size // 2
507510
values = indexed[half:]
508511
times = indexed[:half]
509-
length = len(values)
510-
sensorgram_df_list.append(
511-
pd.DataFrame(
512-
{
513-
"Flow Cell Number": [flow_cell or 1] * length,
514-
"Cycle Number": [cycle_number] * length,
515-
"Curve Number": [curve_number] * length,
516-
"Window Number": [window_number] * length,
517-
"Sensorgram (RU)": values,
518-
"Time (s)": times,
519-
}
520-
)
521-
)
512+
length = values.size
513+
fc_list.append(np.full(length, flow_cell or 1, dtype=object))
514+
cycle_list.append(np.full(length, cycle_number, dtype=np.int64))
515+
curve_list.append(np.full(length, curve_number, dtype=object))
516+
window_list.append(np.full(length, window_number, dtype=object))
517+
values_list.append(values)
518+
times_list.append(times)
519+
total_cycles_detected += 1
522520
continue
523521

524522
if "Segment" in path_str:
525523
# Read all data from the file
526524
raw = content.openstream(stream).read()
527-
seg = list(struct.unpack("f" * (len(raw) // 4), raw))
528-
seg_vals = seg[11:]
529-
length = len(seg_vals)
530-
sensorgram_df_list.append(
531-
pd.DataFrame(
532-
{
533-
"Flow Cell Number": [flow_cell or 1] * length,
534-
"Cycle Number": [cycle_number] * length,
535-
"Curve Number": [curve_number] * length,
536-
"Window Number": [window_number] * length,
537-
"Sensorgram (RU)": seg_vals,
538-
}
539-
)
540-
)
541-
542-
combined_df = (
543-
pd.concat(sensorgram_df_list, ignore_index=True)
544-
if sensorgram_df_list
545-
else pd.DataFrame()
546-
)
525+
seg_arr = np.frombuffer(raw, dtype="<f4")
526+
seg_vals = seg_arr[11:]
527+
length = seg_vals.size
528+
fc_list.append(np.full(length, flow_cell or 1, dtype=object))
529+
cycle_list.append(np.full(length, cycle_number, dtype=np.int64))
530+
curve_list.append(np.full(length, curve_number, dtype=object))
531+
window_list.append(np.full(length, window_number, dtype=object))
532+
values_list.append(seg_vals)
533+
times_list.append(np.full(length, np.nan, dtype=np.float64))
534+
# no time aggregation; removed
535+
536+
if values_list:
537+
combined_df = pd.DataFrame(
538+
{
539+
"Flow Cell Number": np.concatenate(fc_list, axis=0),
540+
"Cycle Number": np.concatenate(cycle_list, axis=0),
541+
"Curve Number": np.concatenate(curve_list, axis=0),
542+
"Window Number": np.concatenate(window_list, axis=0),
543+
"Sensorgram (RU)": np.concatenate(values_list, axis=0),
544+
"Time (s)": np.concatenate(times_list, axis=0),
545+
}
546+
)
547+
# Use categorical dtype for string-like columns to save memory and speed up grouping
548+
combined_df["Curve Number"] = combined_df["Curve Number"].astype("category")
549+
combined_df["Window Number"] = combined_df["Window Number"].astype(
550+
"category"
551+
)
552+
else:
553+
combined_df = pd.DataFrame()
547554

548555
if not combined_df.empty:
549556
# Normalize time per flow cell using reference as in control
@@ -564,23 +571,17 @@ def decode_data(named_file_contents: NamedFileContents) -> dict[str, Any]:
564571
g = group.copy()
565572
if "Time (s)" in g.columns:
566573
max_fc = g["Flow Cell Number"].max()
567-
mask = g["Flow Cell Number"] == max_fc
568-
ref_times = g.loc[mask, "Time (s)"]
574+
ref_mask = g["Flow Cell Number"] == max_fc
575+
ref_times = g.loc[ref_mask, "Time (s)"]
569576
if pd.isna(ref_times).any():
570577
g["Time (s)"] = g.groupby("Flow Cell Number").cumcount() + 1
571-
else:
572-
unique_fc = g["Flow Cell Number"].unique()
573-
if len(unique_fc) > 1:
574-
ref = g[mask].reset_index(drop=True)["Time (s)"].values
575-
for fc in unique_fc:
576-
if fc == max_fc:
577-
continue
578-
fc_mask = g["Flow Cell Number"] == fc
579-
idx = g[fc_mask].index
580-
if len(ref) > 0:
581-
g.loc[idx, "Time (s)"] = ref[
582-
np.arange(len(idx)) % len(ref)
583-
]
578+
elif g["Flow Cell Number"].nunique() > 1:
579+
ref = ref_times.reset_index(drop=True).to_numpy()
580+
nonref_mask = ~ref_mask
581+
if ref.size > 0 and nonref_mask.any():
582+
pos = g.groupby("Flow Cell Number").cumcount()
583+
pos_nonref = pos[nonref_mask].to_numpy()
584+
g.loc[nonref_mask, "Time (s)"] = ref[pos_nonref % ref.size]
584585
elif dcr is not None:
585586
g["Time (s)"] = g.groupby("Flow Cell Number").cumcount() * (1 / dcr)
586587
else:

0 commit comments

Comments
 (0)