Skip to content

Commit e95fc0e

Browse files
feat: Flowjo Improve performance (#1067)
Improve processing time to convert to ASM. Key improvements: * Built a per-sample keyword map once and reused it for all lookups, eliminating repeated Keywords XML scans. * Precomputed root-level, cytometer, and SampleList fields once outside the sample loop and merged them, avoiding per-sample attribute re-reads. * Replaced repeated keyword extraction functions with set-based filters over the keyword map (O(1) lookups, iterate smaller of keys vs map). * Used the keyword map for common fields (measurement time, analyst, well/plate IDs, written name) to avoid extra traversals. --------- Co-authored-by: Nathan Stender <nathan.stender@benchling.com>
1 parent 8b3f401 commit e95fc0e

1 file changed

Lines changed: 181 additions & 85 deletions

File tree

src/allotropy/parsers/flowjo/flowjo_structure.py

Lines changed: 181 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,31 @@ def _extract_general_custom_keywords(
147147
)
148148

149149

150+
def _extract_device_control_keywords_from_map(
151+
mapping: dict[str, str]
152+
) -> dict[str, str]:
153+
"""Filter device control keywords using a pre-built mapping."""
154+
return {
155+
name: value for name, value in mapping.items() if _is_device_control_field(name)
156+
}
157+
158+
159+
def _extract_general_custom_keywords_from_map(
160+
mapping: dict[str, str]
161+
) -> dict[str, str]:
162+
"""Filter general custom keywords using a pre-built mapping."""
163+
result: dict[str, str] = {}
164+
for name, value in mapping.items():
165+
if (
166+
name not in ALL_STRUCTURED_KEYWORDS
167+
and name not in ["FJ FCS VERSION", "curGroup"]
168+
and not _is_device_control_field(name)
169+
and value.strip() != ""
170+
):
171+
result[name] = value
172+
return result
173+
174+
150175
def _is_device_control_field(keyword: str) -> bool:
151176
"""
152177
Check if a keyword belongs to device control document.
@@ -550,6 +575,30 @@ def _get_keyword_value_by_name_from_sample(
550575
return found_value
551576

552577

578+
def _build_keyword_map(
579+
sample: StrictXmlElement,
580+
) -> tuple[dict[str, str], StrictXmlElement | None]:
581+
"""
582+
Build a name->value map from the sample's Keywords block once.
583+
584+
Returns the map and the Keywords element (if present).
585+
"""
586+
keywords = sample.find_or_none("Keywords")
587+
if keywords is None:
588+
return {}, None
589+
590+
mapping: dict[str, str] = {}
591+
keyword_elements = keywords.findall("Keyword")
592+
for kw in keyword_elements:
593+
kw_name = kw.get_attr_or_none("name")
594+
kw_value = kw.get_attr_or_none("value")
595+
if kw_name is not None and kw_value is not None and kw_value.strip() != "":
596+
mapping[kw_name] = kw_value.strip()
597+
kw.mark_all_as_read()
598+
keywords.mark_all_as_read()
599+
return mapping, keywords
600+
601+
553602
def _create_compensation_matrix_groups(
554603
transform_matrix_element: StrictXmlElement,
555604
) -> list[CompensationMatrixGroup] | None:
@@ -946,13 +995,80 @@ def process_population(population: StrictXmlElement | None) -> None:
946995

947996

948997
def create_measurement_groups(root_element: StrictXmlElement) -> list[MeasurementGroup]:
998+
949999
sample_list = root_element.find_or_none("SampleList")
9501000
if sample_list is None:
9511001
msg = "No SampleList element found in XML file."
9521002
raise AllotropeParsingError(msg)
9531003

1004+
# Precompute root-level fields (same for all samples)
1005+
root_element_fields = ["modDate", "name", "clientTimestamp", "homepage"]
1006+
pre_root_measurement_fields: dict[str, str] = {}
1007+
for field in root_element_fields:
1008+
value = root_element.get_attr_or_none(field)
1009+
if value is not None and value.strip() != "":
1010+
pre_root_measurement_fields[field] = value.strip()
1011+
1012+
root_data_processing_fields = [
1013+
"linFromKW",
1014+
"logFromKW",
1015+
"linMax",
1016+
"logMax",
1017+
"useFCS3",
1018+
"useGain",
1019+
"linearRescale",
1020+
"logMin",
1021+
"linMin",
1022+
"extraNegs",
1023+
"logRescale",
1024+
]
1025+
pre_root_processing_fields: dict[str, str] = {}
1026+
for field in root_data_processing_fields:
1027+
value = root_element.get_attr_or_none(field)
1028+
if value is not None and value.strip() != "":
1029+
pre_root_processing_fields[field] = value.strip()
1030+
1031+
# Precompute cytometer-based fields (if present) once
1032+
cytometer = root_element.recursive_find_or_none(["Cytometers", "Cytometer"])
1033+
pre_cytometer_measurement_fields: dict[str, str] = {}
1034+
pre_cytometer_processing_fields: dict[str, str] = {}
1035+
if cytometer:
1036+
for field in root_element_fields:
1037+
value = cytometer.get_attr_or_none(field)
1038+
if value is not None and value.strip() != "":
1039+
pre_cytometer_measurement_fields[field] = value.strip()
1040+
1041+
for field in root_data_processing_fields:
1042+
value = cytometer.get_attr_or_none(field)
1043+
if value is not None and value.strip() != "":
1044+
pre_cytometer_processing_fields[field] = value.strip()
1045+
1046+
cytometer.mark_read(
1047+
{
1048+
"cyt",
1049+
"widthBasis",
1050+
"useTransform",
1051+
"icon",
1052+
"manufacturer",
1053+
"serialnumber",
1054+
"transformType",
1055+
}
1056+
)
1057+
1058+
# Precompute sample_list measurement fields (if any)
1059+
pre_sample_list_measurement_fields: dict[str, str] = {}
1060+
for field in root_element_fields:
1061+
value = sample_list.get_attr_or_none(field)
1062+
if value is not None and value.strip() != "":
1063+
pre_sample_list_measurement_fields[field] = value.strip()
1064+
9541065
samples = sample_list.findall("Sample")
9551066

1067+
# Precompute keyword sets for fast membership tests
1068+
measurement_kw_set = set(MEASUREMENT_DOCUMENT_KEYWORDS)
1069+
sample_kw_set = set(SAMPLE_DOCUMENT_KEYWORDS)
1070+
processed_kw_set = set(PROCESSED_DATA_KEYWORDS)
1071+
9561072
result = []
9571073
for sample in samples:
9581074
sample_node = sample.find("SampleNode")
@@ -1008,36 +1124,36 @@ def create_measurement_groups(root_element: StrictXmlElement) -> list[Measuremen
10081124
}
10091125
all_custom_info.update(non_none_sample_node_unread)
10101126

1011-
# Extract specific keyword values for measurement-level fields
1127+
# Build keyword map once per sample for faster repeated access
1128+
keyword_map, keywords_element = _build_keyword_map(sample)
1129+
1130+
# Helper uses the prebuilt map first, falling back to XML lookup if needed
10121131
def get_keyword_value(
1013-
name: str, current_sample: StrictXmlElement = sample
1132+
name: str,
1133+
_map: dict[str, str] = keyword_map,
1134+
_sample: StrictXmlElement = sample,
10141135
) -> str | None:
1015-
return _get_keyword_value_by_name_from_sample(current_sample, name)
1136+
value = _map.get(name)
1137+
if value is not None:
1138+
return value
1139+
return _get_keyword_value_by_name_from_sample(_sample, name)
10161140

10171141
# Extract measurement-level metadata fields
10181142
measurement_custom_info = {}
1019-
for keyword in MEASUREMENT_DOCUMENT_KEYWORDS:
1020-
value = get_keyword_value(keyword)
1021-
if value is not None and value.strip() != "":
1022-
measurement_custom_info[keyword] = value
1143+
if len(keyword_map) <= len(measurement_kw_set):
1144+
for name, value in keyword_map.items():
1145+
if name in measurement_kw_set:
1146+
measurement_custom_info[name] = value
1147+
else:
1148+
for name in measurement_kw_set:
1149+
if (value := keyword_map.get(name)) is not None:
1150+
measurement_custom_info[name] = value
10231151

1024-
# Also extract root element fields for measurement document
1025-
root_element_fields = ["modDate", "name", "clientTimestamp", "homepage"]
1026-
for field in root_element_fields:
1027-
root_value = root_element.get_attr_or_none(field)
1028-
if root_value is not None and root_value.strip() != "":
1029-
measurement_custom_info[field] = root_value.strip()
1030-
1031-
# Check sample_list element for measurement document fields
1032-
if sample_list:
1033-
for field in root_element_fields:
1034-
sample_list_value = sample_list.get_attr_or_none(field)
1035-
if (
1036-
sample_list_value is not None
1037-
and sample_list_value.strip() != ""
1038-
and field not in measurement_custom_info
1039-
):
1040-
measurement_custom_info[field] = sample_list_value.strip()
1152+
for field, value in pre_root_measurement_fields.items():
1153+
measurement_custom_info.setdefault(field, value)
1154+
1155+
for field, value in pre_sample_list_measurement_fields.items():
1156+
measurement_custom_info.setdefault(field, value)
10411157

10421158
# Check current sample element for measurement document fields
10431159
for field in root_element_fields:
@@ -1051,17 +1167,25 @@ def get_keyword_value(
10511167

10521168
# Extract sample-level metadata fields
10531169
sample_custom_info = {}
1054-
for keyword in SAMPLE_DOCUMENT_KEYWORDS:
1055-
value = get_keyword_value(keyword)
1056-
if value is not None and value.strip() != "":
1057-
sample_custom_info[keyword] = value
1170+
if len(keyword_map) <= len(sample_kw_set):
1171+
for name, value in keyword_map.items():
1172+
if name in sample_kw_set:
1173+
sample_custom_info[name] = value
1174+
else:
1175+
for name in sample_kw_set:
1176+
if (value := keyword_map.get(name)) is not None:
1177+
sample_custom_info[name] = value
10581178

10591179
# Extract data processing document-level metadata fields
10601180
data_processing_custom_info = {}
1061-
for keyword in PROCESSED_DATA_KEYWORDS:
1062-
value = get_keyword_value(keyword)
1063-
if value is not None and value.strip() != "":
1064-
data_processing_custom_info[keyword] = value
1181+
if len(keyword_map) <= len(processed_kw_set):
1182+
for name, value in keyword_map.items():
1183+
if name in processed_kw_set:
1184+
data_processing_custom_info[name] = value
1185+
else:
1186+
for name in processed_kw_set:
1187+
if (value := keyword_map.get(name)) is not None:
1188+
data_processing_custom_info[name] = value
10651189

10661190
# Also extract root element fields for data processing document
10671191
root_data_processing_fields = [
@@ -1078,73 +1202,37 @@ def get_keyword_value(
10781202
"logRescale",
10791203
]
10801204

1081-
# Check root element
1082-
for field in root_data_processing_fields:
1083-
root_value = root_element.get_attr_or_none(field)
1084-
if root_value is not None and root_value.strip() != "":
1085-
data_processing_custom_info[field] = root_value.strip()
1086-
1087-
# Check cytometer element
1088-
cytometer = root_element.recursive_find_or_none(["Cytometers", "Cytometer"])
1089-
if cytometer:
1090-
# Extract measurement document fields from cytometer first
1091-
for field in root_element_fields:
1092-
cytometer_value = cytometer.get_attr_or_none(field)
1093-
if (
1094-
cytometer_value is not None
1095-
and cytometer_value.strip() != ""
1096-
and field not in measurement_custom_info
1097-
):
1098-
measurement_custom_info[field] = cytometer_value.strip()
1099-
1100-
# Then extract data processing fields
1101-
for field in root_data_processing_fields:
1102-
cytometer_value = cytometer.get_attr_or_none(field)
1103-
if (
1104-
cytometer_value is not None
1105-
and cytometer_value.strip() != ""
1106-
and field not in data_processing_custom_info
1107-
):
1108-
data_processing_custom_info[field] = cytometer_value.strip()
1109-
cytometer.mark_read(
1110-
{
1111-
"cyt",
1112-
"widthBasis",
1113-
"useTransform",
1114-
"icon",
1115-
"manufacturer",
1116-
"serialnumber",
1117-
"transformType",
1118-
}
1119-
)
1205+
for field, value in pre_root_processing_fields.items():
1206+
data_processing_custom_info.setdefault(field, value)
11201207

1121-
# Extract device control-level metadata fields (parameter, laser, and CST fields)
1122-
keywords = sample.find_or_none("Keywords")
1123-
device_control_custom_info = _extract_device_control_keywords(keywords)
1208+
for field, value in pre_cytometer_measurement_fields.items():
1209+
measurement_custom_info.setdefault(field, value)
1210+
for field, value in pre_cytometer_processing_fields.items():
1211+
data_processing_custom_info.setdefault(field, value)
1212+
1213+
device_control_custom_info = _extract_device_control_keywords_from_map(
1214+
keyword_map
1215+
)
11241216

1125-
# Get all remaining keywords for general custom info
1126-
keywords_custom_info = _extract_general_custom_keywords(keywords)
1217+
keywords_custom_info = _extract_general_custom_keywords_from_map(keyword_map)
11271218

11281219
if keywords_custom_info:
11291220
all_custom_info.update(keywords_custom_info)
11301221

11311222
measurement_group = MeasurementGroup(
11321223
experimental_data_identifier=experimental_data_identifier,
1133-
measurement_time=_get_measurement_time(sample),
1134-
analyst=_get_keyword_value_by_name_from_sample(sample, "$OP"),
1224+
measurement_time=_get_measurement_time_from_map(keyword_map)
1225+
or _get_measurement_time(sample),
1226+
analyst=get_keyword_value("$OP"),
11351227
compensation_matrix_groups=compensation_matrix_groups,
11361228
custom_info=all_custom_info if all_custom_info else None,
11371229
measurements=[
11381230
Measurement(
11391231
measurement_identifier=random_uuid_str(),
11401232
sample_identifier=sample_node.get_attr("sampleID"),
1141-
location_identifier=_get_keyword_value_by_name_from_sample(
1142-
sample, "WELL ID"
1143-
),
1144-
well_plate_identifier=_get_keyword_value_by_name_from_sample(
1145-
sample, "PLATE ID"
1146-
),
1147-
written_name=_get_keyword_value_by_name_from_sample(sample, "$SRC"),
1233+
location_identifier=get_keyword_value("WELL ID"),
1234+
well_plate_identifier=get_keyword_value("PLATE ID"),
1235+
written_name=get_keyword_value("$SRC"),
11481236
device_type=constants.DEVICE_TYPE,
11491237
method_version=root_element.get_attr_or_none("version"),
11501238
data_processing_time=root_element.get_attr_or_none("modDate"),
@@ -1177,3 +1265,11 @@ def _get_measurement_time(sample: StrictXmlElement) -> str | None:
11771265
if date is None or etim is None:
11781266
return None
11791267
return f"{date} {etim}"
1268+
1269+
1270+
def _get_measurement_time_from_map(mapping: dict[str, str]) -> str | None:
1271+
date = mapping.get("$DATE")
1272+
etim = mapping.get("$ETIM")
1273+
if not date or not etim:
1274+
return None
1275+
return f"{date} {etim}"

0 commit comments

Comments
 (0)