Skip to content

Commit c122d25

Browse files
feat: Reduce Cytiva Biacore T200 Control memory usage with cycle streaming (#1164)
## Summary Implements cycle streaming for Cytiva Biacore T200 Control parser to reduce memory usage by 19.6%. ## Changes - Implement `decode_data_streaming()` generator that yields cycles one at a time - Process cycles individually instead of concatenating all DataFrames at once - Maintain backward compatibility with `decode_data()` wrapper function ## Memory Impact - **Before:** 134.24 MB peak (84.6x amplification on 1.59 MB file) - **After:** 107.87 MB peak (68.0x amplification) - **Reduction:** 26.37 MB saved (19.6% improvement) Measurements taken with tracemalloc on `Fig.2c_NC_prongs hybridization.blr` (1.59 MB test file), consistent across 3 runs. ## Testing - All existing tests pass with no changes to golden files - Type safety verified with mypy - Backward compatible - `decode_data()` wrapper maintains existing API ## Context This addresses memory amplification concerns identified in the parser audit. The T200 Control parser showed 133x average amplification with memory_profiler (agent-reported). Accurate measurements with tracemalloc show 84.6x on main, reduced to 68.0x with streaming. Related to memory optimization work on other Cytiva Biacore parsers.
1 parent aa04d15 commit c122d25

1 file changed

Lines changed: 126 additions & 92 deletions

File tree

src/allotropy/parsers/cytiva_biacore_t200_control/cytiva_biacore_t200_control_decoder.py

Lines changed: 126 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -362,19 +362,30 @@ def get_r_point_data(r_point_data: str) -> list[dict[str, Any]]:
362362
return r_data_list
363363

364364

365-
def decode_data(named_file_contents: NamedFileContents) -> dict[str, Any]:
365+
def decode_data_streaming(named_file_contents: NamedFileContents) -> Any: # Generator
366366
"""
367-
Decodes the proprietary file into a structured dict
367+
Decodes the proprietary file into a structured dict, streaming cycle data.
368368
:param named_file_contents: The named file contents containing the input file details
369-
:return: structured dictionary of decoded data
369+
:return: Iterator yielding metadata first, then cycle data dicts
370370
"""
371371
intermediate_json: dict[str, Any] = {}
372372
content = ole.OleFileIO(named_file_contents.get_bytes_stream())
373373
streams = content.listdir()
374-
sensorgram_df_list = []
375-
report_point_data = {}
374+
375+
# Group streams by cycle number
376+
cycle_streams: dict[int, list[Any]] = {}
377+
report_point_df = None
376378

377379
for stream in streams:
380+
# Check if this is a cycle-specific stream first
381+
if cycle_match := cycle_pattern.search(stream[0]):
382+
cycle_number = int(cycle_match.group(1))
383+
if cycle_number not in cycle_streams:
384+
cycle_streams[cycle_number] = []
385+
cycle_streams[cycle_number].append(stream)
386+
continue
387+
388+
# Process metadata streams
378389
stream_content = content.openstream(stream).read()
379390
if stream == ["Environment"]:
380391
intermediate_json["system_information"] = extract_stream_data(
@@ -384,7 +395,6 @@ def decode_data(named_file_contents: NamedFileContents) -> dict[str, Any]:
384395
intermediate_json["chip"] = extract_stream_data(
385396
stream_content.decode("utf-8")
386397
)
387-
388398
elif stream == ["AppData", "Dip"]:
389399
intermediate_json["dip"] = get_dip_data(stream_content.decode("utf-8"))
390400
elif stream == ["AppData", "ApplicationTemplate"]:
@@ -396,76 +406,11 @@ def decode_data(named_file_contents: NamedFileContents) -> dict[str, Any]:
396406
intermediate_json["sample_data"] = sample_data
397407
elif stream == ["RPoint Table"]:
398408
r_point_data = get_r_point_data(stream_content.decode("utf-8"))
399-
r_point_dataframe = pd.DataFrame(r_point_data)
400-
report_point_data = {
401-
group["Cycle"].iloc[0]: group
402-
for _, group in r_point_dataframe.groupby("Cycle")
403-
}
409+
report_point_df = pd.DataFrame(r_point_data)
404410

405-
if not (cycle_match := cycle_pattern.search(stream[0])):
406-
continue
407-
cycle_number = int(cycle_match.group(1))
408-
409-
if (curve_match := curve_pattern.search(str(stream))) and (
410-
window_match := window_pattern.search(str(stream))
411-
):
412-
curve_number = curve_match.group(1)
413-
window_number = window_match.group(1)
414-
415-
# get the flow cell number
416-
if "Labels" in str(stream):
417-
label_list = []
418-
if len(stream_content) != 0:
419-
decoded_stream_content = stream_content.decode("utf-8")
420-
for line in decoded_stream_content.strip().split("\n"):
421-
label_list.append(line)
422-
if "Fc" in line:
423-
flow_cell = label_list[0].split("=")[1]
424-
425-
# gets the xy data as data frame
426-
elif "XYData" in str(stream):
427-
xy_data_list = list(
428-
struct.unpack("f" * (len(stream_content) // 4), stream_content)
429-
)
430-
indexed_xy_data_list = xy_data_list[3:]
431-
length_of_xydata = len(xy_data_list[3:])
432-
xy_result_list = indexed_xy_data_list[int(length_of_xydata / 2) :]
433-
time_values = indexed_xy_data_list[: int(length_of_xydata / 2)]
434-
length_of_xy = len(xy_result_list)
435-
436-
xy_data_frame = pd.DataFrame(
437-
{
438-
"Flow Cell Number": [flow_cell] * length_of_xy,
439-
"Cycle Number": [cycle_number] * length_of_xy,
440-
"Curve Number": [curve_number] * length_of_xy,
441-
"Window Number": [window_number] * length_of_xy,
442-
"Sensorgram (RU)": xy_result_list,
443-
"Time (s)": time_values,
444-
}
445-
)
446-
447-
sensorgram_df_list.append(xy_data_frame)
448-
449-
# gets the segment data
450-
elif "Segment" in str(stream):
451-
segment_data_list = list(
452-
struct.unpack("f" * (len(stream_content) // 4), stream_content)
453-
)
454-
length_of_segment = len(segment_data_list[11:])
455-
segment_data_frame = pd.DataFrame(
456-
{
457-
"Flow Cell Number": [flow_cell] * length_of_segment,
458-
"Cycle Number": [cycle_number] * length_of_segment,
459-
"Curve Number": [curve_number] * length_of_segment,
460-
"Window Number": [window_number] * length_of_segment,
461-
"Sensorgram (RU)": segment_data_list[11:],
462-
}
463-
)
464-
sensorgram_df_list.append(segment_data_frame)
465-
466-
combined_sensorgram_df = pd.concat(sensorgram_df_list, ignore_index=True)
467-
468-
sensorgram_data = {}
411+
# Get total cycles and data collection rate
412+
total_cycles = max(cycle_streams.keys()) if cycle_streams else 0
413+
intermediate_json["total_cycles"] = total_cycles
469414

470415
data_collection_rate = None
471416
if intermediate_json.get("application_template_details", {}).get(
@@ -475,10 +420,82 @@ def decode_data(named_file_contents: NamedFileContents) -> dict[str, Any]:
475420
"DataCollectionRate"
476421
]["value"]
477422

478-
grouped_df = combined_sensorgram_df.groupby("Cycle Number")
479-
for cycle_num, cycle_group in grouped_df:
480-
group = cycle_group.copy()
423+
# Yield metadata first
424+
yield intermediate_json
425+
426+
# Process cycles one at a time
427+
for cycle_num in sorted(cycle_streams.keys()):
428+
sensorgram_df_list = []
429+
flow_cell = "1" # default
481430

431+
for stream in cycle_streams[cycle_num]:
432+
stream_content = content.openstream(stream).read()
433+
434+
if (curve_match := curve_pattern.search(str(stream))) and (
435+
window_match := window_pattern.search(str(stream))
436+
):
437+
curve_number = curve_match.group(1)
438+
window_number = window_match.group(1)
439+
440+
# get the flow cell number
441+
if "Labels" in str(stream):
442+
label_list = []
443+
if len(stream_content) != 0:
444+
decoded_stream_content = stream_content.decode("utf-8")
445+
for line in decoded_stream_content.strip().split("\n"):
446+
label_list.append(line)
447+
if "Fc" in line:
448+
flow_cell = label_list[0].split("=")[1]
449+
450+
# gets the xy data as data frame
451+
elif "XYData" in str(stream):
452+
xy_data_list = list(
453+
struct.unpack("f" * (len(stream_content) // 4), stream_content)
454+
)
455+
indexed_xy_data_list = xy_data_list[3:]
456+
length_of_xydata = len(xy_data_list[3:])
457+
xy_result_list = indexed_xy_data_list[int(length_of_xydata / 2) :]
458+
time_values = indexed_xy_data_list[: int(length_of_xydata / 2)]
459+
length_of_xy = len(xy_result_list)
460+
461+
xy_data_frame = pd.DataFrame(
462+
{
463+
"Flow Cell Number": [flow_cell] * length_of_xy,
464+
"Cycle Number": [cycle_num] * length_of_xy,
465+
"Curve Number": [curve_number] * length_of_xy,
466+
"Window Number": [window_number] * length_of_xy,
467+
"Sensorgram (RU)": xy_result_list,
468+
"Time (s)": time_values,
469+
}
470+
)
471+
472+
sensorgram_df_list.append(xy_data_frame)
473+
474+
# gets the segment data
475+
elif "Segment" in str(stream):
476+
segment_data_list = list(
477+
struct.unpack("f" * (len(stream_content) // 4), stream_content)
478+
)
479+
length_of_segment = len(segment_data_list[11:])
480+
segment_data_frame = pd.DataFrame(
481+
{
482+
"Flow Cell Number": [flow_cell] * length_of_segment,
483+
"Cycle Number": [cycle_num] * length_of_segment,
484+
"Curve Number": [curve_number] * length_of_segment,
485+
"Window Number": [window_number] * length_of_segment,
486+
"Sensorgram (RU)": segment_data_list[11:],
487+
}
488+
)
489+
sensorgram_df_list.append(segment_data_frame)
490+
491+
# Process this cycle's sensorgram data
492+
if not sensorgram_df_list:
493+
continue
494+
495+
combined_sensorgram_df = pd.concat(sensorgram_df_list, ignore_index=True)
496+
group = combined_sensorgram_df.copy()
497+
498+
# Time normalization logic
482499
if "Time (s)" in group.columns:
483500
max_flow_cell = group["Flow Cell Number"].max()
484501
max_flow_cell_mask = group["Flow Cell Number"] == max_flow_cell
@@ -495,9 +512,9 @@ def decode_data(named_file_contents: NamedFileContents) -> dict[str, Any]:
495512
)
496513
reference_times = max_flow_cell_data["Time (s)"].values
497514

498-
for flow_cell in unique_flow_cells:
499-
if flow_cell != max_flow_cell:
500-
flow_cell_mask = group["Flow Cell Number"] == flow_cell
515+
for fc in unique_flow_cells:
516+
if fc != max_flow_cell:
517+
flow_cell_mask = group["Flow Cell Number"] == fc
501518
flow_cell_indices = group[flow_cell_mask].index
502519

503520
num_points = len(flow_cell_indices)
@@ -516,7 +533,7 @@ def decode_data(named_file_contents: NamedFileContents) -> dict[str, Any]:
516533
else:
517534
group["Time (s)"] = group.groupby("Flow Cell Number").cumcount() + 1
518535

519-
sensorgram_data[str(cycle_num)] = group[
536+
sensorgram_data = group[
520537
[
521538
"Flow Cell Number",
522539
"Cycle Number",
@@ -527,16 +544,33 @@ def decode_data(named_file_contents: NamedFileContents) -> dict[str, Any]:
527544
]
528545
]
529546

530-
# In some cases the "RPoint Table" stream may not have data for the first cycle,
531-
# but we get streams with sensorgram data (XYData or Segment streams) for all the cycles.
532-
intermediate_json["cycle_data"] = [
533-
{
534-
"cycle_number": cycle,
535-
"report_point_data": report_point_data.get(cycle),
536-
"sensorgram_data": sensorgram_df,
547+
# Get report point data for this cycle
548+
report_point_data = None
549+
if report_point_df is not None:
550+
cycle_report = report_point_df[report_point_df["Cycle"] == str(cycle_num)]
551+
if not cycle_report.empty:
552+
report_point_data = cycle_report
553+
554+
# Yield this cycle's data
555+
yield {
556+
"cycle_number": str(cycle_num),
557+
"report_point_data": report_point_data,
558+
"sensorgram_data": sensorgram_data,
537559
}
538-
for cycle, sensorgram_df in sensorgram_data.items()
539-
]
540-
intermediate_json["total_cycles"] = cycle_number
560+
561+
562+
def decode_data(named_file_contents: NamedFileContents) -> dict[str, Any]:
563+
"""
564+
Decodes the proprietary file into a structured dict
565+
:param named_file_contents: The named file contents containing the input file details
566+
:return: structured dictionary of decoded data
567+
"""
568+
# Use streaming decoder but collect all data
569+
stream_gen = decode_data_streaming(named_file_contents)
570+
intermediate_json: dict[str, Any] = next(stream_gen) # Get metadata
571+
572+
# Collect all cycle data
573+
cycle_data = list(stream_gen)
574+
intermediate_json["cycle_data"] = cycle_data
541575

542576
return intermediate_json

0 commit comments

Comments
 (0)