@@ -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+
150175def _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+
553602def _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
948997def 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