From 5eed401d0c3edb7913af7dfe59dbe0a51e1249e1 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Wed, 3 Dec 2025 16:51:00 +0100 Subject: [PATCH 01/70] starting work on mesh extraction --- energyml-utils/.gitignore | 1 + .../example/epc_stream_keep_open_example.py | 210 +++++ energyml-utils/example/main_test_3D.py | 63 ++ .../example/storage_interface_example.py | 229 +++++ .../src/energyml/utils/data/datasets_io.py | 11 + .../src/energyml/utils/data/helper.py | 216 +++-- .../src/energyml/utils/data/mesh.py | 24 +- .../src/energyml/utils/epc_stream.py | 93 +- .../src/energyml/utils/exception.py | 7 + .../src/energyml/utils/introspection.py | 4 +- .../src/energyml/utils/storage_interface.py | 820 ++++++++++++++++++ energyml-utils/tests/test_xml.py | 1 + 12 files changed, 1587 insertions(+), 92 deletions(-) create mode 100644 energyml-utils/example/epc_stream_keep_open_example.py create mode 100644 energyml-utils/example/main_test_3D.py create mode 100644 energyml-utils/example/storage_interface_example.py create mode 100644 energyml-utils/src/energyml/utils/storage_interface.py diff --git a/energyml-utils/.gitignore b/energyml-utils/.gitignore index 38a850f..07739c5 100644 --- a/energyml-utils/.gitignore +++ b/energyml-utils/.gitignore @@ -44,6 +44,7 @@ sample/ gen*/ manip* *.epc +*.h5 *.off *.obj *.log diff --git a/energyml-utils/example/epc_stream_keep_open_example.py b/energyml-utils/example/epc_stream_keep_open_example.py new file mode 100644 index 0000000..61e7d09 --- /dev/null +++ b/energyml-utils/example/epc_stream_keep_open_example.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python +# Copyright (c) 2023-2024 Geosiris. +# SPDX-License-Identifier: Apache-2.0 +""" +Example demonstrating the keep_open feature of EpcStreamReader. + +This example shows how using keep_open=True improves performance when +performing multiple operations on an EPC file by keeping the ZIP file +open instead of reopening it for each operation. +""" + +import time +from pathlib import Path +import sys +from pathlib import Path + +# Add src directory to path +src_path = Path(__file__).parent.parent / "src" +sys.path.insert(0, str(src_path)) + +from energyml.utils.epc_stream import EpcStreamReader + + +def benchmark_without_keep_open(epc_path: str, num_operations: int = 10): + """Benchmark reading objects without keep_open.""" + print(f"\nBenchmark WITHOUT keep_open ({num_operations} operations):") + print("=" * 60) + + start = time.time() + + # Create reader without keep_open + with EpcStreamReader(epc_path, keep_open=False, cache_size=5) as reader: + metadata_list = reader.list_object_metadata() + + if not metadata_list: + print(" No objects in EPC file") + return 0 + + # Perform multiple read operations + for i in range(min(num_operations, len(metadata_list))): + meta = metadata_list[i % len(metadata_list)] + if meta.identifier: + _ = reader.get_object_by_identifier(meta.identifier) + if i == 0: + print(f" First object: {meta.object_type}") + + elapsed = time.time() - start + print(f" Time: {elapsed:.4f}s") + print(f" Avg per operation: {elapsed / num_operations:.4f}s") + + return elapsed + + +def benchmark_with_keep_open(epc_path: str, num_operations: int = 10): + """Benchmark reading objects with keep_open.""" + print(f"\nBenchmark WITH keep_open ({num_operations} operations):") + print("=" * 60) + + start = time.time() + + # Create reader with keep_open + with EpcStreamReader(epc_path, keep_open=True, cache_size=5) as reader: + metadata_list = reader.list_object_metadata() + + if not metadata_list: + print(" No objects in EPC file") + return 0 + + # Perform multiple read operations + for i in range(min(num_operations, len(metadata_list))): + meta = metadata_list[i % len(metadata_list)] + if meta.identifier: + _ = reader.get_object_by_identifier(meta.identifier) + if i == 0: + print(f" First object: {meta.object_type}") + + elapsed = time.time() - start + print(f" Time: {elapsed:.4f}s") + print(f" Avg per operation: {elapsed / num_operations:.4f}s") + + return elapsed + + +def demonstrate_file_modification_with_keep_open(epc_path: str): + """Demonstrate that modifications work correctly with keep_open.""" + print("\nDemonstrating file modifications with keep_open:") + print("=" * 60) + + with EpcStreamReader(epc_path, keep_open=True) as reader: + metadata_list = reader.list_object_metadata() + original_count = len(metadata_list) + print(f" Original object count: {original_count}") + + if metadata_list: + # Get first object + first_obj = reader.get_object_by_identifier(metadata_list[0].identifier) + print(f" Retrieved object: {metadata_list[0].object_type}") + + # Update the object (re-add it) + identifier = reader.update_object(first_obj) + print(f" Updated object: {identifier}") + + # Verify we can still read it after update + updated_obj = reader.get_object_by_identifier(identifier) + assert updated_obj is not None, "Failed to read object after update" + print(" ✓ Object successfully read after update") + + # Verify object count is the same + new_metadata_list = reader.list_object_metadata() + new_count = len(new_metadata_list) + print(f" New object count: {new_count}") + + if new_count == original_count: + print(" ✓ Object count unchanged (correct)") + else: + print(f" ✗ Object count changed: {original_count} -> {new_count}") + + +def demonstrate_proper_cleanup(): + """Demonstrate that persistent ZIP file is properly closed.""" + print("\nDemonstrating proper cleanup:") + print("=" * 60) + + temp_path = "temp_test.epc" + + try: + # Create a temporary EPC file + reader = EpcStreamReader(temp_path, keep_open=True) + print(" Created EpcStreamReader with keep_open=True") + + # Manually close + reader.close() + print(" ✓ Manually closed reader") + + # Create another reader and let it go out of scope + reader2 = EpcStreamReader(temp_path, keep_open=True) + print(" Created second EpcStreamReader") + del reader2 + print(" ✓ Reader deleted (automatic cleanup via __del__)") + + # Create reader in context manager + with EpcStreamReader(temp_path, keep_open=True) as _: + print(" Created third EpcStreamReader in context manager") + print(" ✓ Context manager exited (automatic cleanup)") + + finally: + # Clean up temp file + if Path(temp_path).exists(): + Path(temp_path).unlink() + + +def main(): + """Run all examples.""" + print("EpcStreamReader keep_open Feature Demonstration") + print("=" * 60) + + # You'll need to provide a valid EPC file path + epc_path = "wip/epc_test.epc" + + if not Path(epc_path).exists(): + print(f"\nError: EPC file not found: {epc_path}") + print("Please provide a valid EPC file path in the script.") + print("\nRunning cleanup demonstration only:") + demonstrate_proper_cleanup() + return + + try: + # Run benchmarks + num_ops = 20 + + time_without = benchmark_without_keep_open(epc_path, num_ops) + time_with = benchmark_with_keep_open(epc_path, num_ops) + + # Show comparison + print("\n" + "=" * 60) + print("Performance Comparison:") + print("=" * 60) + if time_with > 0 and time_without > 0: + speedup = time_without / time_with + improvement = ((time_without - time_with) / time_without) * 100 + print(f" Speedup: {speedup:.2f}x") + print(f" Improvement: {improvement:.1f}%") + + if speedup > 1.1: + print("\n ✓ keep_open=True significantly improves performance!") + elif speedup > 1.0: + print("\n ✓ keep_open=True slightly improves performance") + else: + print("\n Note: For this workload, the difference is minimal") + print(" (cache effects or small file)") + + # Demonstrate modifications + demonstrate_file_modification_with_keep_open(epc_path) + + # Demonstrate cleanup + demonstrate_proper_cleanup() + + print("\n" + "=" * 60) + print("All demonstrations completed successfully!") + print("=" * 60) + + except Exception as e: + print(f"\nError: {e}") + import traceback + + traceback.print_exc() + + +if __name__ == "__main__": + main() diff --git a/energyml-utils/example/main_test_3D.py b/energyml-utils/example/main_test_3D.py new file mode 100644 index 0000000..13d49d6 --- /dev/null +++ b/energyml-utils/example/main_test_3D.py @@ -0,0 +1,63 @@ +# Copyright (c) 2023-2024 Geosiris. +# SPDX-License-Identifier: Apache-2.0 +import os +import re +import datetime +from pathlib import Path +import traceback + +from energyml.utils.data.mesh import export_obj, read_mesh_object +from energyml.utils.epc_stream import EpcStreamReader +from energyml.utils.exception import NotSupportedError + + +def export_all_representation(epc_path: str, output_dir: str, regex_type_filter: str = None): + + epc = EpcStreamReader(epc_path, keep_open=True) + dt = datetime.datetime.now().strftime("%Hh%M_%d-%m-%Y") + not_supported_types = set() + for mdata in epc.list_object_metadata(): + if "Representation" in mdata.object_type and ( + regex_type_filter is None or re.search(regex_type_filter, mdata.object_type, flags=re.IGNORECASE) + ): + logging.info(f"Exporting representation: {mdata.object_type} ({mdata.uuid})") + energyml_obj = epc.get_object_by_uuid(mdata.uuid)[0] + try: + mesh_list = read_mesh_object( + energyml_object=energyml_obj, + workspace=epc, + use_crs_displacement=True, + ) + + os.makedirs(output_dir, exist_ok=True) + + path = Path(output_dir) / f"{dt}-{mdata.object_type}{mdata.uuid}_mesh.obj" + with path.open("wb") as f: + export_obj( + mesh_list=mesh_list, + out=f, + ) + logging.info(f" ✓ Exported to {path.name}") + except NotSupportedError: + # print(f" ✗ Not supported: {e}") + not_supported_types.add(mdata.object_type) + except Exception: + traceback.print_exc() + + logging.info("Export completed.") + if not_supported_types: + logging.info("Not supported representation types encountered:") + for t in not_supported_types: + logging.info(f" - {t}") + + +# $env:PYTHONPATH="D:\Geosiris\Github\energyml\energyml-python\energyml-utils\src"; poetry run python example/main_test_3D.py +if __name__ == "__main__": + import logging + + logging.basicConfig(level=logging.DEBUG) + epc_file = "rc/epc/testingPackageCpp.epc" + # epc_file = "rc/epc/Volve_Horizons_and_Faults_Depth_originEQN.epc" + output_directory = Path("exported_meshes") / Path(epc_file).name.replace(".epc", "_3D_export") + # export_all_representation(epc_file, output_directory) + export_all_representation(epc_file, output_directory, regex_type_filter="Grid2D") diff --git a/energyml-utils/example/storage_interface_example.py b/energyml-utils/example/storage_interface_example.py new file mode 100644 index 0000000..54326dc --- /dev/null +++ b/energyml-utils/example/storage_interface_example.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python +# Copyright (c) 2023-2024 Geosiris. +# SPDX-License-Identifier: Apache-2.0 +""" +Example usage of the unified storage interface. + +This example demonstrates how to use the EnergymlStorageInterface to work +with EPC files in both regular and streaming modes, using the same API. +""" +import sys +from pathlib import Path + +# Add src directory to path +src_path = Path(__file__).parent.parent / "src" +sys.path.insert(0, str(src_path)) + +from energyml.utils.storage_interface import create_storage + + +def example_regular_epc(): + """Example using regular EPC storage (loads everything into memory)""" + print("=" * 60) + print("Example 1: Regular EPC Storage") + print("=" * 60) + + # Create storage from file path + with create_storage("example.epc", stream_mode=False) as storage: + # List all objects + print("\nListing all objects:") + for metadata in storage.list_objects(): + print(f" - {metadata.title} ({metadata.object_type})") + print(f" UUID: {metadata.uuid}") + print(f" URI: {metadata.uri}") + + # Get a specific object + objects = storage.list_objects() + if objects: + first_obj = storage.get_object(objects[0].identifier) + print(f"\nRetrieved object: {objects[0].title}") + + # Try to read an array + try: + array = storage.read_array(first_obj, "values/0") + if array is not None: + print(f" Array shape: {array.shape}") + print(f" Array dtype: {array.dtype}") + except Exception as e: + print(f" No array data or error: {e}") + + +def example_streaming_epc(): + """Example using streaming EPC storage (memory efficient for large files)""" + print("\n" + "=" * 60) + print("Example 2: Streaming EPC Storage (Memory Efficient)") + print("=" * 60) + + # Create storage in streaming mode + with create_storage("example.epc", stream_mode=True, cache_size=50) as storage: + # List all objects (metadata loaded, objects loaded on-demand) + print("\nListing all objects (lazy loading):") + all_metadata = storage.list_objects() + print(f"Total objects: {len(all_metadata)}") + + for metadata in all_metadata[:5]: # Show first 5 + print(f" - {metadata.title} ({metadata.object_type})") + + if len(all_metadata) > 5: + print(f" ... and {len(all_metadata) - 5} more") + + # Get statistics (only available in streaming mode) + if hasattr(storage, "get_statistics"): + stats = storage.get_statistics() + print("\nCache statistics:") + print(f" Total objects: {stats['total_objects']}") + print(f" Loaded objects: {stats['loaded_objects']}") + print(f" Cache hits: {stats['cache_hits']}") + print(f" Cache misses: {stats['cache_misses']}") + if stats["cache_hit_ratio"] is not None: + print(f" Cache hit ratio: {stats['cache_hit_ratio']:.2%}") + + +def example_filtering(): + """Example of filtering objects by type""" + print("\n" + "=" * 60) + print("Example 3: Filtering Objects by Type") + print("=" * 60) + + with create_storage("example.epc") as storage: + # List only specific object types + print("\nListing Grid2dRepresentation objects:") + grid_objects = storage.list_objects(object_type="resqml20.obj_Grid2dRepresentation") + + for metadata in grid_objects: + print(f" - {metadata.title}") + print(f" UUID: {metadata.uuid}") + + +def example_crud_operations(): + """Example of Create, Read, Update, Delete operations""" + print("\n" + "=" * 60) + print("Example 4: CRUD Operations") + print("=" * 60) + + with create_storage("example.epc") as storage: + # Get an object + objects = storage.list_objects() + if not objects: + print("No objects in EPC file") + return + + print(f"\nTotal objects: {len(objects)}") + + # Read + first_metadata = objects[0] + obj = storage.get_object(first_metadata.identifier) + print(f"\nRead object: {first_metadata.title}") + print(f"Object type: {type(obj).__name__}") + + # Get by UUID (may return multiple versions) + uuid_objects = storage.get_object_by_uuid(first_metadata.uuid) + print(f"Objects with UUID {first_metadata.uuid}: {len(uuid_objects)}") + + # Update would be: modify obj, then storage.put_object(obj) + # Delete would be: storage.delete_object(identifier) + + # Save changes (if using EPCStorage) + if hasattr(storage, "save"): + # storage.save("modified_example.epc") + print("\nChanges can be saved with storage.save()") + + +def example_array_operations(): + """Example of working with data arrays""" + print("\n" + "=" * 60) + print("Example 5: Array Operations") + print("=" * 60) + + with create_storage("example.epc") as storage: + objects = storage.list_objects() + + for metadata in objects: + obj = storage.get_object(metadata.identifier) + + # Try to get array metadata + try: + array_meta = storage.get_array_metadata(obj, "values/0") + if array_meta: + print(f"\nObject: {metadata.title}") + print(f" Array path: {array_meta.path_in_resource}") + print(f" Array type: {array_meta.array_type}") + print(f" Dimensions: {array_meta.dimensions}") + print(f" Total elements: {array_meta.size}") + + # Read the array + array = storage.read_array(obj, "values/0") + if array is not None: + print(f" Actual shape: {array.shape}") + print(f" Min/Max: {array.min():.2f} / {array.max():.2f}") + + break # Only show first array + except Exception: + continue + + +def example_comparison(): + """Compare regular vs streaming mode""" + print("\n" + "=" * 60) + print("Example 6: Regular vs Streaming Comparison") + print("=" * 60) + + import time + + file_path = "example.epc" + + # Regular mode + start = time.time() + with create_storage(file_path, stream_mode=False) as storage: + metadata_list = storage.list_objects() + regular_count = len(metadata_list) + regular_time = time.time() - start + + # Streaming mode + start = time.time() + with create_storage(file_path, stream_mode=True, cache_size=10) as storage: + metadata_list = storage.list_objects() + stream_count = len(metadata_list) + stream_time = time.time() - start + + print("\nRegular mode:") + print(f" Objects: {regular_count}") + print(f" Load time: {regular_time:.4f}s") + + print("\nStreaming mode:") + print(f" Objects: {stream_count}") + print(f" Load time: {stream_time:.4f}s") + + if stream_time < regular_time: + speedup = regular_time / stream_time + print(f"\nStreaming mode is {speedup:.2f}x faster for metadata loading!") + else: + print("\nRegular mode was faster (small file, overhead dominates)") + + +if __name__ == "__main__": + print("Unified Storage Interface Examples") + print("=" * 60) + print("\nNote: These examples require an 'example.epc' file to exist.") + print("Modify the file paths as needed for your environment.\n") + + try: + example_regular_epc() + example_streaming_epc() + example_filtering() + example_crud_operations() + example_array_operations() + example_comparison() + + print("\n" + "=" * 60) + print("All examples completed successfully!") + print("=" * 60) + + except FileNotFoundError as e: + print(f"\nError: {e}") + print("Please provide a valid EPC file path.") + except Exception as e: + print(f"\nError running examples: {e}") + import traceback + + traceback.print_exc() diff --git a/energyml-utils/src/energyml/utils/data/datasets_io.py b/energyml-utils/src/energyml/utils/data/datasets_io.py index 3325eeb..5213029 100644 --- a/energyml-utils/src/energyml/utils/data/datasets_io.py +++ b/energyml-utils/src/energyml/utils/data/datasets_io.py @@ -145,6 +145,17 @@ def extract_h5_datasets( ) -> None: raise MissingExtraInstallation(extra_name="hdf5") + class HDF5FileWriter: + + def write_array( + self, + target: Union[str, BytesIO, bytes], + array: Union[list, np.ndarray], + path_in_external_file: str, + dtype: Optional[np.dtype] = None, + ): + raise MissingExtraInstallation(extra_name="hdf5") + # APACHE PARQUET if __PARQUET_MODULE_EXISTS__: diff --git a/energyml-utils/src/energyml/utils/data/helper.py b/energyml-utils/src/energyml/utils/data/helper.py index febba46..556ef25 100644 --- a/energyml-utils/src/energyml/utils/data/helper.py +++ b/energyml-utils/src/energyml/utils/data/helper.py @@ -183,23 +183,56 @@ def prod_n_tab(val: Union[float, int, str], tab: List[Union[float, int, str]]): :param tab: :return: """ - return list(map(lambda x: x * val, tab)) + if val is None: + return [None] * len(tab) + + # Convert to numpy array for vectorized operations, handling None values + arr = np.array(tab, dtype=object) + # Create mask for non-None values + mask = arr != None # noqa: E711 + # Create result array filled with None + result = np.full(len(tab), None, dtype=object) + # Multiply only non-None values + result[mask] = arr[mask].astype(float) * val + return result.tolist() def sum_lists(l1: List, l2: List): """ - Sums 2 lists values. + Sums 2 lists values, preserving None values. Example: [1,1,1] and [2,2,3,6] gives : [3,3,4,6] + [1,None,3] and [2,2,3] gives : [3,None,6] :param l1: :param l2: :return: """ - return [l1[i] + l2[i] for i in range(min(len(l1), len(l2)))] + max(l1, l2, key=len)[ - min(len(l1), len(l2)) : # noqa: E203 - ] + min_len = min(len(l1), len(l2)) + + # Convert to numpy arrays for vectorized operations + arr1 = np.array(l1[:min_len], dtype=object) + arr2 = np.array(l2[:min_len], dtype=object) + + # Create result array + result = np.full(min_len, None, dtype=object) + + # Find indices where both values are not None + mask = (arr1 != None) & (arr2 != None) # noqa: E711 + + # Sum only where both are not None + if np.any(mask): + result[mask] = arr1[mask].astype(float) + arr2[mask].astype(float) + + # Convert back to list and append remaining elements from longer list + result_list = result.tolist() + if len(l1) > min_len: + result_list.extend(l1[min_len:]) + elif len(l2) > min_len: + result_list.extend(l2[min_len:]) + + return result_list def get_crs_obj( @@ -294,7 +327,7 @@ def read_external_array( root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, workspace: Optional[EnergymlWorkspace] = None, - sub_indices: List[int] = None, + sub_indices: Optional[List[int]] = None, ) -> Union[List[Any], np.ndarray]: """ Read an external array (BooleanExternalArray, BooleanHdf5Array, DoubleHdf5Array, IntegerHdf5Array, StringExternalArray ...) @@ -333,10 +366,11 @@ def read_external_array( ) if sub_indices is not None and len(sub_indices) > 0: - res = [] - for idx in sub_indices: - res.append(array[idx]) - array = res + if isinstance(array, np.ndarray): + array = array[sub_indices] + else: + # Fallback for non-numpy arrays + array = [array[idx] for idx in sub_indices] return array @@ -358,8 +392,8 @@ def read_array( root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, workspace: Optional[EnergymlWorkspace] = None, - sub_indices: List[int] = None, -) -> List[Any]: + sub_indices: Optional[List[int]] = None, +) -> Union[List[Any], np.ndarray]: """ Read an array and return a list. The array is read depending on its type. see. :py:func:`energyml.utils.data.helper.get_supported_array` :param energyml_array: @@ -424,8 +458,8 @@ def read_xml_array( root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, workspace: Optional[EnergymlWorkspace] = None, - sub_indices: List[int] = None, -) -> List[Any]: + sub_indices: Optional[List[int]] = None, +) -> Union[List[Any], np.ndarray]: """ Read a xml array ( BooleanXmlArray, FloatingPointXmlArray, IntegerXmlArray, StringXmlArray ...) :param energyml_array: @@ -439,10 +473,11 @@ def read_xml_array( # count = get_object_attribute_no_verif(energyml_array, "count_per_value") if sub_indices is not None and len(sub_indices) > 0: - res = [] - for idx in sub_indices: - res.append(values[idx]) - values = res + if isinstance(values, np.ndarray): + values = values[sub_indices] + elif isinstance(values, list): + # Use list comprehension for efficiency + values = [values[idx] for idx in sub_indices] return values @@ -451,7 +486,7 @@ def read_jagged_array( root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, workspace: Optional[EnergymlWorkspace] = None, - sub_indices: List[int] = None, + sub_indices: Optional[List[int]] = None, ) -> List[Any]: """ Read a jagged array @@ -475,17 +510,13 @@ def read_jagged_array( workspace=workspace, ) - array = [] - previous = 0 - for cl in cumulative_length: - array.append(elements[previous:cl]) - previous = cl + # Use list comprehension for better performance + array = [ + elements[cumulative_length[i - 1] if i > 0 else 0 : cumulative_length[i]] for i in range(len(cumulative_length)) + ] if sub_indices is not None and len(sub_indices) > 0: - res = [] - for idx in sub_indices: - res.append(array[idx]) - array = res + array = [array[idx] for idx in sub_indices] return array @@ -494,7 +525,7 @@ def read_int_double_lattice_array( root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, workspace: Optional[EnergymlWorkspace] = None, - sub_indices: List[int] = None, + sub_indices: Optional[List[int]] = None, ): """ Read DoubleLatticeArray or IntegerLatticeArray. @@ -525,7 +556,7 @@ def read_point3d_zvalue_array( root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, workspace: Optional[EnergymlWorkspace] = None, - sub_indices: List[int] = None, + sub_indices: Optional[List[int]] = None, ): """ Read a Point3D2ValueArray @@ -556,15 +587,26 @@ def read_point3d_zvalue_array( ) ) - count = 0 + # Use NumPy for vectorized operation if possible + error_logged = False - for i in range(len(sup_geom_array)): - try: - sup_geom_array[i][2] = zvalues_array[i] - except Exception as e: - if count == 0: - logging.error(e, f": {i} is out of bound of {len(zvalues_array)}") - count = count + 1 + if isinstance(sup_geom_array, np.ndarray) and isinstance(zvalues_array, np.ndarray): + # Vectorized assignment for NumPy arrays + min_len = min(len(sup_geom_array), len(zvalues_array)) + if min_len < len(sup_geom_array): + logging.warning( + f"Z-values array ({len(zvalues_array)}) is shorter than geometry array ({len(sup_geom_array)}), only updating first {min_len} values" + ) + sup_geom_array[:min_len, 2] = zvalues_array[:min_len] + else: + # Fallback for list-based arrays + for i in range(len(sup_geom_array)): + try: + sup_geom_array[i][2] = zvalues_array[i] + except (IndexError, TypeError) as e: + if not error_logged: + logging.error(f"{type(e).__name__}: index {i} is out of bound of {len(zvalues_array)}") + error_logged = True return sup_geom_array @@ -574,7 +616,7 @@ def read_point3d_from_representation_lattice_array( root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, workspace: Optional[EnergymlWorkspace] = None, - sub_indices: List[int] = None, + sub_indices: Optional[List[int]] = None, ): """ Read a Point3DFromRepresentationLatticeArray. @@ -617,14 +659,14 @@ def read_grid2d_patch( grid2d: Optional[Any] = None, path_in_root: Optional[str] = None, workspace: Optional[EnergymlWorkspace] = None, - sub_indices: List[int] = None, -) -> List: + sub_indices: Optional[List[int]] = None, +) -> Union[List, np.ndarray]: points_path, points_obj = search_attribute_matching_name_with_path(patch, "Geometry.Points")[0] return read_array( energyml_array=points_obj, root_obj=grid2d, - path_in_root=path_in_root + "." + points_path, + path_in_root=path_in_root + "." + points_path if path_in_root else points_path, workspace=workspace, sub_indices=sub_indices, ) @@ -635,7 +677,7 @@ def read_point3d_lattice_array( root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, workspace: Optional[EnergymlWorkspace] = None, - sub_indices: List[int] = None, + sub_indices: Optional[List[int]] = None, ) -> List: """ Read a Point3DLatticeArray. @@ -712,40 +754,74 @@ def read_point3d_lattice_array( slowest_size = crs_sa_count[0] fastest_size = crs_fa_count[0] - for i in range(slowest_size): - for j in range(fastest_size): - previous_value = origin - # to avoid a sum of the parts of the array at each iteration, I take the previous value in the same line - # number i and add the fastest_table[j] value - - if j > 0: - if i > 0: - line_idx = i * fastest_size # numero de ligne - previous_value = result[line_idx + j - 1] - else: - previous_value = result[j - 1] - if zincreasing_downward: - result.append(sum_lists(previous_value, slowest_table[i - 1])) - else: - result.append(sum_lists(previous_value, fastest_table[j - 1])) - else: - if i > 0: - prev_line_idx = (i - 1) * fastest_size # numero de ligne precedent - previous_value = result[prev_line_idx] - if zincreasing_downward: - result.append(sum_lists(previous_value, fastest_table[j - 1])) + # Vectorized approach using NumPy for massive performance improvement + try: + # Convert tables to NumPy arrays + origin_arr = np.array(origin, dtype=float) + slowest_arr = np.array(slowest_table, dtype=float) # shape: (slowest_size, 3) + fastest_arr = np.array(fastest_table, dtype=float) # shape: (fastest_size, 3) + + # Compute cumulative sums + slowest_cumsum = np.cumsum(slowest_arr, axis=0) # cumulative offset along slowest axis + fastest_cumsum = np.cumsum(fastest_arr, axis=0) # cumulative offset along fastest axis + + # Create meshgrid indices + i_indices, j_indices = np.meshgrid(np.arange(slowest_size), np.arange(fastest_size), indexing="ij") + + # Initialize result array + result_arr = np.zeros((slowest_size, fastest_size, 3), dtype=float) + result_arr[:, :, :] = origin_arr # broadcast origin to all positions + + # Add offsets based on zincreasing_downward + if zincreasing_downward: + # Add slowest offsets where i > 0 + result_arr[1:, :, :] += slowest_cumsum[:-1, np.newaxis, :] + # Add fastest offsets where j > 0 + result_arr[:, 1:, :] += fastest_cumsum[np.newaxis, :-1, :] + else: + # Add fastest offsets where j > 0 + result_arr[:, 1:, :] += fastest_cumsum[np.newaxis, :-1, :] + # Add slowest offsets where i > 0 + result_arr[1:, :, :] += slowest_cumsum[:-1, np.newaxis, :] + + # Flatten to list of points + result = result_arr.reshape(-1, 3).tolist() + + except (ValueError, TypeError) as e: + # Fallback to original implementation if NumPy conversion fails + logging.warning(f"NumPy vectorization failed ({e}), falling back to iterative approach") + for i in range(slowest_size): + for j in range(fastest_size): + previous_value = origin + + if j > 0: + if i > 0: + line_idx = i * fastest_size + previous_value = result[line_idx + j - 1] else: + previous_value = result[j - 1] + if zincreasing_downward: result.append(sum_lists(previous_value, slowest_table[i - 1])) + else: + result.append(sum_lists(previous_value, fastest_table[j - 1])) else: - result.append(previous_value) + if i > 0: + prev_line_idx = (i - 1) * fastest_size + previous_value = result[prev_line_idx] + if zincreasing_downward: + result.append(sum_lists(previous_value, fastest_table[j - 1])) + else: + result.append(sum_lists(previous_value, slowest_table[i - 1])) + else: + result.append(previous_value) else: raise Exception(f"{type(energyml_array)} read with an offset of length {len(offset)} is not supported") if sub_indices is not None and len(sub_indices) > 0: - res = [] - for idx in sub_indices: - res.append(result[idx]) - result = res + if isinstance(result, np.ndarray): + result = result[sub_indices].tolist() + else: + result = [result[idx] for idx in sub_indices] return result diff --git a/energyml-utils/src/energyml/utils/data/mesh.py b/energyml-utils/src/energyml/utils/data/mesh.py index 3ee9409..0a59a2d 100644 --- a/energyml-utils/src/energyml/utils/data/mesh.py +++ b/energyml-utils/src/energyml/utils/data/mesh.py @@ -23,7 +23,7 @@ ) from ..epc import Epc, get_obj_identifier, gen_energyml_object_path from ..epc_stream import EpcStreamReader -from ..exception import ObjectNotFoundNotError +from ..exception import NotSupportedError, ObjectNotFoundNotError from ..introspection import ( search_attribute_matching_name, search_attribute_matching_name_with_path, @@ -145,7 +145,7 @@ def crs_displacement(points: List[Point], crs_obj: Any) -> Tuple[List[Point], Po if crs_point_offset != [0, 0, 0]: for p in points: for xyz in range(len(p)): - p[xyz] = p[xyz] + crs_point_offset[xyz] + p[xyz] = (p[xyz] + crs_point_offset[xyz]) if p[xyz] is not None else None if zincreasing_downward and len(p) >= 3: p[2] = -p[2] @@ -196,6 +196,7 @@ def read_mesh_object( reader_func = get_mesh_reader_function(array_type_name) if reader_func is not None: + # logging.info(f"using function {reader_func} to read type {array_type_name}") surfaces: List[AbstractMesh] = reader_func( energyml_object=energyml_object, workspace=workspace, sub_indices=sub_indices ) @@ -204,12 +205,18 @@ def read_mesh_object( crs_displacement(s.point_list, s.crs_object) return surfaces else: - logging.error(f"Type {array_type_name} is not supported: function read_{snake_case(array_type_name)} not found") - raise Exception( - f"Type {array_type_name} is not supported\n\t{energyml_object}: \n\tfunction read_{snake_case(array_type_name)} not found" + # logging.error(f"Type {array_type_name} is not supported: function read_{snake_case(array_type_name)} not found") + raise NotSupportedError( + f"Type {array_type_name} is not supported\n\tfunction read_{snake_case(array_type_name)} not found" ) +def read_ijk_grid_representation( + energyml_object: Any, workspace: EnergymlWorkspace, sub_indices: List[int] = None +) -> List[Any]: + raise NotSupportedError("IJKGrid representation reading is not supported yet.") + + def read_point_representation( energyml_object: Any, workspace: EnergymlWorkspace, sub_indices: List[int] = None ) -> List[PointSetMesh]: @@ -659,7 +666,7 @@ def read_sub_representation( supporting_rep = workspace.get_object_by_identifier(supporting_rep_identifier) total_size = 0 - all_indices = [] + all_indices = None for patch_path, patch_indices in search_attribute_matching_name_with_path( obj=energyml_object, name_rgx="SubRepresentationPatch.\\d+.ElementIndices.\\d+.Indices", @@ -690,7 +697,7 @@ def read_sub_representation( else: total_size = total_size + len(array) - all_indices = all_indices + array + all_indices = all_indices + array if all_indices is not None else array meshes = read_mesh_object( energyml_object=supporting_rep, workspace=workspace, @@ -1263,7 +1270,8 @@ def export_obj(mesh_list: List[AbstractMesh], out: BytesIO, obj_name: Optional[s point_offset = 0 for m in mesh_list: - out.write(f"g {m.identifier}\n\n".encode("utf-8")) + mesh_id = getattr(m, "identifier", None) or getattr(m, "uuid", "mesh") + out.write(f"g {mesh_id}\n\n".encode("utf-8")) _export_obj_elt( off_point_part=out, off_face_part=out, diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index 721f9d6..93158e0 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -110,6 +110,7 @@ def __init__( preload_metadata: bool = True, export_version: EpcExportVersion = EpcExportVersion.CLASSIC, force_h5_path: Optional[str] = None, + keep_open: bool = False, ): """ Initialize the EPC stream reader. @@ -121,11 +122,13 @@ def __init__( preload_metadata: Whether to preload all object metadata export_version: EPC packaging version (CLASSIC or EXPANDED) force_h5_path: Optional forced HDF5 file path for external resources. If set, all arrays will be read/written from/to this path. + keep_open: If True, keeps the ZIP file open for better performance with multiple operations. File is closed only when instance is deleted or close() is called. """ self.epc_file_path = Path(epc_file_path) self.cache_size = cache_size self.validate_on_load = validate_on_load self.force_h5_path = force_h5_path + self.keep_open = keep_open is_new_file = False @@ -145,7 +148,7 @@ def __init__( with zipfile.ZipFile(self.epc_file_path, "r") as zf: content_types_path = get_epc_content_type_path() if content_types_path not in zf.namelist(): - logging.info(f"EPC file is missing required structure. Initializing empty EPC file.") + logging.info("EPC file is missing required structure. Initializing empty EPC file.") self._create_empty_epc() is_new_file = True except Exception as e: @@ -166,6 +169,7 @@ def __init__( # File handle management self._zip_file: Optional[zipfile.ZipFile] = None + self._persistent_zip: Optional[zipfile.ZipFile] = None # Used when keep_open=True # EPC export version detection self.export_version: EpcExportVersion = export_version or EpcExportVersion.CLASSIC # Default @@ -179,6 +183,10 @@ def __init__( # Detect EPC version after loading metadata self.export_version = self._detect_epc_version() + # Open persistent ZIP file if keep_open is enabled + if self.keep_open and not is_new_file: + self._persistent_zip = zipfile.ZipFile(self.epc_file_path, "r") + def _create_empty_epc(self) -> None: """Create an empty EPC file structure.""" # Ensure directory exists @@ -218,14 +226,22 @@ def _load_metadata(self) -> None: @contextmanager def _get_zip_file(self) -> Iterator[zipfile.ZipFile]: - """Context manager for ZIP file access with proper resource management.""" - zf = None - try: - zf = zipfile.ZipFile(self.epc_file_path, "r") - yield zf - finally: - if zf is not None: - zf.close() + """Context manager for ZIP file access with proper resource management. + + If keep_open is True, uses the persistent connection. Otherwise opens a new one. + """ + if self.keep_open and self._persistent_zip is not None: + # Use persistent connection, don't close it + yield self._persistent_zip + else: + # Open and close per request + zf = None + try: + zf = zipfile.ZipFile(self.epc_file_path, "r") + yield zf + finally: + if zf is not None: + zf.close() def _read_content_types(self, zf: zipfile.ZipFile) -> Types: """Read and parse [Content_Types].xml file.""" @@ -772,6 +788,43 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): """Context manager exit with cleanup.""" self.clear_cache() + self.close() + + def __del__(self): + """Destructor to ensure persistent ZIP file is closed.""" + try: + self.close() + except Exception: + pass # Ignore errors during cleanup + + def close(self) -> None: + """Close the persistent ZIP file if it's open, recomputing rels first.""" + # Recompute all relationships before closing to ensure consistency + try: + self.rebuild_all_rels(clean_first=True) + except Exception as e: + logging.warning(f"Error rebuilding rels on close: {e}") + + if self._persistent_zip is not None: + try: + self._persistent_zip.close() + except Exception as e: + logging.debug(f"Error closing persistent ZIP file: {e}") + finally: + self._persistent_zip = None + + def _reopen_persistent_zip(self) -> None: + """Reopen persistent ZIP file after modifications to reflect changes. + + This is called after any operation that modifies the EPC file to ensure + that subsequent reads see the updated content. + """ + if self.keep_open and self._persistent_zip is not None: + try: + self._persistent_zip.close() + except Exception: + pass + self._persistent_zip = zipfile.ZipFile(self.epc_file_path, "r") def add_object(self, obj: Any, file_path: Optional[str] = None, replace_if_exists: bool = True) -> str: """ @@ -913,7 +966,15 @@ def remove_object(self, identifier: Union[str, Uri]) -> bool: raise RuntimeError(f"Failed to remove object from EPC: {e}") def _remove_single_object(self, identifier: str) -> bool: - """Remove a single object by its full identifier.""" + """ + Remove a single object by its full identifier. + The rels files of other objects referencing this object are NOT updated. You must update them manually (or close the epc, the rels are regenerated on epc close). + Args: + identifier: The full identifier (uuid.version) of the object to remove + Returns: + True if the object was successfully removed, False otherwise + + """ try: if identifier not in self._metadata: return False @@ -1038,6 +1099,7 @@ def add_rels_for_object(self, identifier: Union[str, Uri, Any], relationships: L target_zip, ) shutil.move(temp_path, self.epc_file_path) + self._reopen_persistent_zip() def _compute_object_rels(self, obj: Any, obj_identifier: str) -> List[Relationship]: """ @@ -1304,9 +1366,13 @@ def _add_object_to_file(self, obj: Any, metadata: EpcObjectMetadata) -> None: # Update .rels files by merging with existing ones read from source updated_rels_paths = self._update_rels_files(obj, metadata, source_zip, target_zip) - # Copy all existing files except [Content_Types].xml and rels we'll update + # Copy all existing files except [Content_Types].xml, the object file, and rels we already updated for item in source_zip.infolist(): - if item.filename == get_epc_content_type_path() or item.filename in updated_rels_paths: + if ( + item.filename == get_epc_content_type_path() + or item.filename == metadata.file_path + or item.filename in updated_rels_paths + ): continue data = source_zip.read(item.filename) target_zip.writestr(item, data) @@ -1317,6 +1383,7 @@ def _add_object_to_file(self, obj: Any, metadata: EpcObjectMetadata) -> None: # Replace original file with updated version shutil.move(temp_path, self.epc_file_path) + self._reopen_persistent_zip() except Exception as e: # Clean up temp file on error @@ -1352,6 +1419,7 @@ def _remove_object_from_file(self, metadata: EpcObjectMetadata) -> None: # Replace original file with updated version shutil.move(temp_path, self.epc_file_path) + self._reopen_persistent_zip() except Exception: # Clean up temp file on error @@ -1686,6 +1754,7 @@ def rebuild_all_rels(self, clean_first: bool = True) -> Dict[str, int]: # Replace original file shutil.move(temp_path, self.epc_file_path) + self._reopen_persistent_zip() logging.info( f"Rebuilt .rels files: processed {stats['objects_processed']} objects, " diff --git a/energyml-utils/src/energyml/utils/exception.py b/energyml-utils/src/energyml/utils/exception.py index 87e128c..fac041f 100644 --- a/energyml-utils/src/energyml/utils/exception.py +++ b/energyml-utils/src/energyml/utils/exception.py @@ -39,3 +39,10 @@ def __init__(self, t: Optional[str] = None): class UnparsableFile(Exception): def __init__(self, t: Optional[str] = None): super().__init__("File is not parsable for an EPC file. Please use RawFile class for non energyml files.") + + +class NotSupportedError(Exception): + """Exception for not supported features""" + + def __init__(self, msg): + super().__init__(msg) diff --git a/energyml-utils/src/energyml/utils/introspection.py b/energyml-utils/src/energyml/utils/introspection.py index e764eba..1230d32 100644 --- a/energyml-utils/src/energyml/utils/introspection.py +++ b/energyml-utils/src/energyml/utils/introspection.py @@ -292,8 +292,8 @@ def import_related_module(energyml_module_name: str) -> None: for m in related: try: import_module(m) - except Exception: - pass + except Exception as e: + logging.debug(f"Could not import related module {m}: {e}") # logging.error(e) diff --git a/energyml-utils/src/energyml/utils/storage_interface.py b/energyml-utils/src/energyml/utils/storage_interface.py new file mode 100644 index 0000000..77ececb --- /dev/null +++ b/energyml-utils/src/energyml/utils/storage_interface.py @@ -0,0 +1,820 @@ +# Copyright (c) 2023-2024 Geosiris. +# SPDX-License-Identifier: Apache-2.0 +""" +Unified Storage Interface Module + +This module provides a unified interface for reading and writing energyml objects and arrays, +abstracting away whether the data comes from an ETP server, a local EPC file, or an EPC stream reader. + +The storage interface enables applications to work with energyml data without knowing the +underlying storage mechanism, making it easy to switch between server-based and file-based +workflows. + +Key Components: +- EnergymlStorageInterface: Abstract base class defining the storage interface +- ETPStorage: Implementation for ETP server-based storage (requires py-etp-client) +- EPCStorage: Implementation for local EPC file-based storage +- EPCStreamStorage: Implementation for streaming EPC file-based storage +- ResourceMetadata: Dataclass for object metadata (similar to ETP Resource) +- DataArrayMetadata: Dataclass for array metadata +- create_storage: Factory function for creating storage instances + +Example Usage: + ```python + from energyml.utils.storage_interface import create_storage + + # Use with EPC file + storage = create_storage("my_data.epc") + + # Same API for all implementations! + obj = storage.get_object("uuid.version") + metadata_list = storage.list_objects() + array = storage.read_array(obj, "values/0") + storage.put_object(new_obj) + storage.close() + ``` +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Union, Tuple +import logging + +import numpy as np + +from energyml.utils.uri import Uri, parse_uri +from energyml.utils.introspection import ( + get_obj_identifier, + get_obj_uuid, + get_obj_version, + get_obj_type, + get_content_type_from_class, + epoch_to_date, + epoch, +) +from energyml.utils.constants import content_type_to_qualified_type + + +@dataclass +class ResourceMetadata: + """ + Metadata for an energyml object, similar to ETP Resource. + + This class provides a unified representation of object metadata across + different storage backends (EPC, EPC Stream, ETP). + """ + + uri: str + """URI of the resource (ETP-style uri or identifier)""" + + uuid: str + """Object UUID""" + + title: str + """Object title/name from citation""" + + object_type: str + """Qualified type (e.g., 'resqml20.obj_TriangulatedSetRepresentation')""" + + content_type: str + """Content type (e.g., 'application/x-resqml+xml;version=2.0;type=obj_TriangulatedSetRepresentation')""" + + version: Optional[str] = None + """Object version (optional)""" + + dataspace: Optional[str] = None + """Dataspace name (primarily for ETP)""" + + created: Optional[datetime] = None + """Creation timestamp""" + + last_changed: Optional[datetime] = None + """Last modification timestamp""" + + source_count: Optional[int] = None + """Number of source relationships (objects this references)""" + + target_count: Optional[int] = None + """Number of target relationships (objects referencing this)""" + + custom_data: Dict[str, Any] = field(default_factory=dict) + """Additional custom metadata""" + + @property + def identifier(self) -> str: + """Get object identifier (uuid.version or uuid if no version)""" + if self.version: + return f"{self.uuid}.{self.version}" + return self.uuid + + +@dataclass +class DataArrayMetadata: + """ + Metadata for a data array in an energyml object. + + This provides information about arrays stored in HDF5 or other external storage, + similar to ETP DataArrayMetadata. + """ + + path_in_resource: str + """Path to the array within the HDF5 file""" + + array_type: str + """Data type of the array (e.g., 'double', 'int', 'string')""" + + dimensions: List[int] + """Array dimensions/shape""" + + custom_data: Dict[str, Any] = field(default_factory=dict) + """Additional custom metadata""" + + @property + def size(self) -> int: + """Total number of elements in the array""" + result = 1 + for dim in self.dimensions: + result *= dim + return result + + @property + def ndim(self) -> int: + """Number of dimensions""" + return len(self.dimensions) + + +class EnergymlStorageInterface(ABC): + """ + Abstract base class for energyml data storage operations. + + This interface defines a common API for interacting with energyml objects and arrays, + regardless of whether they are stored on an ETP server, in a local EPC file, or in + a streaming EPC reader. + + All implementations must provide methods for: + - Getting, putting, and deleting energyml objects + - Reading and writing data arrays + - Getting array metadata + - Listing available objects with metadata + - Transaction support (where applicable) + - Closing the storage connection + """ + + @abstractmethod + def get_object(self, identifier: Union[str, Uri]) -> Optional[Any]: + """ + Retrieve an object by its identifier (UUID or UUID.version). + + Args: + identifier: Object identifier (UUID or UUID.version) or ETP URI + + Returns: + The deserialized energyml object, or None if not found + """ + pass + + @abstractmethod + def get_object_by_uuid(self, uuid: str) -> List[Any]: + """ + Retrieve all objects with the given UUID (all versions). + + Args: + uuid: Object UUID + + Returns: + List of objects with this UUID (may be empty) + """ + pass + + @abstractmethod + def put_object(self, obj: Any, dataspace: Optional[str] = None) -> Optional[str]: + """ + Store an energyml object. + + Args: + obj: The energyml object to store + dataspace: Optional dataspace name (primarily for ETP) + + Returns: + The identifier of the stored object (UUID.version or UUID), or None on error + """ + pass + + @abstractmethod + def delete_object(self, identifier: Union[str, Uri]) -> bool: + """ + Delete an object by its identifier. + + Args: + identifier: Object identifier (UUID or UUID.version) or ETP URI + + Returns: + True if successfully deleted, False otherwise + """ + pass + + @abstractmethod + def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Optional[np.ndarray]: + """ + Read a data array from external storage (HDF5). + + Args: + proxy: The object identifier/URI or the object itself that references the array + path_in_external: Path within the HDF5 file (e.g., 'values/0') + + Returns: + The data array as a numpy array, or None if not found + """ + pass + + @abstractmethod + def write_array( + self, + proxy: Union[str, Uri, Any], + path_in_external: str, + array: np.ndarray, + ) -> bool: + """ + Write a data array to external storage (HDF5). + + Args: + proxy: The object identifier/URI or the object itself that references the array + path_in_external: Path within the HDF5 file (e.g., 'values/0') + array: The numpy array to write + + Returns: + True if successfully written, False otherwise + """ + pass + + @abstractmethod + def get_array_metadata( + self, proxy: Union[str, Uri, Any], path_in_external: Optional[str] = None + ) -> Union[DataArrayMetadata, List[DataArrayMetadata], None]: + """ + Get metadata for data array(s). + + Args: + proxy: The object identifier/URI or the object itself that references the array + path_in_external: Optional specific path. If None, returns all array metadata for the object + + Returns: + DataArrayMetadata if path specified, List[DataArrayMetadata] if no path, + or None if not found + """ + pass + + @abstractmethod + def list_objects( + self, dataspace: Optional[str] = None, object_type: Optional[str] = None + ) -> List[ResourceMetadata]: + """ + List all objects with their metadata. + + Args: + dataspace: Optional dataspace filter (primarily for ETP) + object_type: Optional type filter (qualified type, e.g., 'resqml20.obj_Grid2dRepresentation') + + Returns: + List of ResourceMetadata for all matching objects + """ + pass + + @abstractmethod + def close(self) -> None: + """ + Close the storage connection and release resources. + """ + pass + + # Transaction support (optional, may raise NotImplementedError) + + def start_transaction(self) -> bool: + """ + Start a transaction (if supported). + + Returns: + True if transaction started, False if not supported + """ + raise NotImplementedError("Transactions not supported by this storage backend") + + def commit_transaction(self) -> Tuple[bool, Optional[str]]: + """ + Commit the current transaction (if supported). + + Returns: + Tuple of (success, transaction_uuid) + """ + raise NotImplementedError("Transactions not supported by this storage backend") + + def rollback_transaction(self) -> bool: + """ + Rollback the current transaction (if supported). + + Returns: + True if rolled back successfully + """ + raise NotImplementedError("Transactions not supported by this storage backend") + + # Additional utility methods + + def get_object_dependencies(self, identifier: Union[str, Uri]) -> List[str]: + """ + Get list of object identifiers that this object depends on (references). + + Args: + identifier: Object identifier + + Returns: + List of identifiers of objects this object references + """ + raise NotImplementedError("Dependency tracking not implemented by this storage backend") + + def __enter__(self): + """Context manager entry""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit""" + self.close() + + +class EPCStorage(EnergymlStorageInterface): + """ + EPC file-based storage implementation. + + This implementation uses an Epc object to interact with energyml data stored in + a local EPC file. Arrays are stored in associated HDF5 external files. + + Args: + epc: An initialized Epc instance + """ + + def __init__(self, epc: "Epc"): # noqa: F821 + """ + Initialize EPC storage with an Epc instance. + + Args: + epc: An Epc instance + """ + from energyml.utils.epc import Epc + + if not isinstance(epc, Epc): + raise TypeError(f"Expected Epc instance, got {type(epc)}") + + self.epc = epc + self._closed = False + + def get_object(self, identifier: Union[str, Uri]) -> Optional[Any]: + """Retrieve an object by identifier from EPC.""" + if self._closed: + raise RuntimeError("Storage is closed") + + # Convert URI to identifier if needed + if isinstance(identifier, Uri): + identifier = f"{identifier.uuid}.{identifier.version}" if identifier.version else identifier.uuid + elif isinstance(identifier, str) and identifier.startswith("eml://"): + parsed = parse_uri(identifier) + identifier = f"{parsed.uuid}.{parsed.version}" if parsed.version else parsed.uuid + + return self.epc.get_object_by_identifier(identifier) + + def get_object_by_uuid(self, uuid: str) -> List[Any]: + """Retrieve all objects with the given UUID.""" + if self._closed: + raise RuntimeError("Storage is closed") + + result = self.epc.get_object_by_uuid(uuid) + return result if isinstance(result, list) else [result] if result else [] + + def put_object(self, obj: Any, dataspace: Optional[str] = None) -> Optional[str]: + """Store an object in EPC.""" + if self._closed: + raise RuntimeError("Storage is closed") + + try: + # Check if object already exists + identifier = get_obj_identifier(obj) + existing = self.epc.get_object_by_identifier(identifier) + + if existing: + # Update existing object + self.epc.energyml_objects.remove(existing) + + # Add new object + self.epc.energyml_objects.append(obj) + return identifier + except Exception as e: + logging.error(f"Failed to put object: {e}") + return None + + def delete_object(self, identifier: Union[str, Uri]) -> bool: + """Delete an object from EPC.""" + if self._closed: + raise RuntimeError("Storage is closed") + + try: + obj = self.get_object(identifier) + if obj and obj in self.epc.energyml_objects: + self.epc.energyml_objects.remove(obj) + return True + return False + except Exception as e: + logging.error(f"Failed to delete object: {e}") + return False + + def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Optional[np.ndarray]: + """Read array from HDF5 file associated with EPC.""" + if self._closed: + raise RuntimeError("Storage is closed") + + # Get object if proxy is identifier + if isinstance(proxy, (str, Uri)): + proxy = self.get_object(proxy) + if proxy is None: + return None + + return self.epc.read_array(proxy, path_in_external) + + def write_array( + self, + proxy: Union[str, Uri, Any], + path_in_external: str, + array: np.ndarray, + ) -> bool: + """Write array to HDF5 file associated with EPC.""" + if self._closed: + raise RuntimeError("Storage is closed") + + # Get object if proxy is identifier + if isinstance(proxy, (str, Uri)): + proxy = self.get_object(proxy) + if proxy is None: + return False + + return self.epc.write_array(proxy, path_in_external, array) + + def get_array_metadata( + self, proxy: Union[str, Uri, Any], path_in_external: Optional[str] = None + ) -> Union[DataArrayMetadata, List[DataArrayMetadata], None]: + """Get array metadata (limited support for EPC).""" + if self._closed: + raise RuntimeError("Storage is closed") + + # EPC doesn't have native array metadata support + # We can try to read the array and infer metadata + try: + if path_in_external: + array = self.read_array(proxy, path_in_external) + if array is not None: + return DataArrayMetadata( + path_in_resource=path_in_external, + array_type=str(array.dtype), + dimensions=list(array.shape), + ) + else: + # Would need to scan all possible paths - not practical + logging.warning("EPC does not support listing all arrays without specific path") + return [] + except Exception as e: + logging.error(f"Failed to get array metadata: {e}") + + return None + + def list_objects( + self, dataspace: Optional[str] = None, object_type: Optional[str] = None + ) -> List[ResourceMetadata]: + """List all objects with metadata.""" + if self._closed: + raise RuntimeError("Storage is closed") + + results = [] + for obj in self.epc.energyml_objects: + try: + uuid = get_obj_uuid(obj) + version = get_obj_version(obj) + obj_type = get_obj_type(obj) + content_type = get_content_type_from_class(obj.__class__) + + # Apply type filter + if object_type and obj_type != object_type: + continue + + # Get title from citation + title = "Unknown" + if hasattr(obj, "citation") and obj.citation: + if hasattr(obj.citation, "title"): + title = obj.citation.title + + # Build URI + uri = f"eml:///{uuid}" + if version: + uri += f".{version}" + + metadata = ResourceMetadata( + uri=uri, + uuid=uuid, + version=version, + title=title, + object_type=obj_type, + content_type=content_type, + ) + + results.append(metadata) + except Exception as e: + logging.warning(f"Failed to get metadata for object: {e}") + continue + + return results + + def close(self) -> None: + """Close the EPC storage.""" + self._closed = True + + def save(self, file_path: Optional[str] = None) -> None: + """ + Save the EPC to disk. + + Args: + file_path: Optional path to save to. If None, uses epc.epc_file_path + """ + if self._closed: + raise RuntimeError("Storage is closed") + + self.epc.export_file(file_path) + + +class EPCStreamStorage(EnergymlStorageInterface): + """ + Memory-efficient EPC stream-based storage implementation. + + This implementation uses EpcStreamReader for lazy loading and caching, + making it ideal for handling very large EPC files with thousands of objects. + + Features: + - Lazy loading: Objects loaded only when accessed + - Smart caching: LRU cache with configurable size + - Memory monitoring: Track memory usage and cache efficiency + - Same interface as EPCStorage for seamless switching + + Args: + stream_reader: An EpcStreamReader instance + """ + + def __init__(self, stream_reader: "EpcStreamReader"): # noqa: F821 + """ + Initialize EPC stream storage with an EpcStreamReader instance. + + Args: + stream_reader: An EpcStreamReader instance + """ + from energyml.utils.epc_stream import EpcStreamReader + + if not isinstance(stream_reader, EpcStreamReader): + raise TypeError(f"Expected EpcStreamReader instance, got {type(stream_reader)}") + + self.stream_reader = stream_reader + self._closed = False + + def get_object(self, identifier: Union[str, Uri]) -> Optional[Any]: + """Retrieve an object by identifier from EPC stream.""" + if self._closed: + raise RuntimeError("Storage is closed") + + # Convert URI to identifier if needed + if isinstance(identifier, Uri): + identifier = f"{identifier.uuid}.{identifier.version}" if identifier.version else identifier.uuid + elif isinstance(identifier, str) and identifier.startswith("eml://"): + parsed = parse_uri(identifier) + identifier = f"{parsed.uuid}.{parsed.version}" if parsed.version else parsed.uuid + + return self.stream_reader.get_object_by_identifier(identifier) + + def get_object_by_uuid(self, uuid: str) -> List[Any]: + """Retrieve all objects with the given UUID.""" + if self._closed: + raise RuntimeError("Storage is closed") + + return self.stream_reader.get_object_by_uuid(uuid) + + def put_object(self, obj: Any, dataspace: Optional[str] = None) -> Optional[str]: + """Store an object in EPC stream.""" + if self._closed: + raise RuntimeError("Storage is closed") + + try: + return self.stream_reader.add_object(obj, replace_if_exists=True) + except Exception as e: + logging.error(f"Failed to put object: {e}") + return None + + def delete_object(self, identifier: Union[str, Uri]) -> bool: + """Delete an object from EPC stream.""" + if self._closed: + raise RuntimeError("Storage is closed") + + try: + return self.stream_reader.remove_object(identifier) + except Exception as e: + logging.error(f"Failed to delete object: {e}") + return False + + def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Optional[np.ndarray]: + """Read array from HDF5 file associated with EPC stream.""" + if self._closed: + raise RuntimeError("Storage is closed") + + return self.stream_reader.read_array(proxy, path_in_external) + + def write_array( + self, + proxy: Union[str, Uri, Any], + path_in_external: str, + array: np.ndarray, + ) -> bool: + """Write array to HDF5 file associated with EPC stream.""" + if self._closed: + raise RuntimeError("Storage is closed") + + return self.stream_reader.write_array(proxy, path_in_external, array) + + def get_array_metadata( + self, proxy: Union[str, Uri, Any], path_in_external: Optional[str] = None + ) -> Union[DataArrayMetadata, List[DataArrayMetadata], None]: + """Get array metadata (limited support for EPC Stream).""" + if self._closed: + raise RuntimeError("Storage is closed") + + # EPC Stream doesn't have native array metadata support + # We can try to read the array and infer metadata + try: + if path_in_external: + array = self.read_array(proxy, path_in_external) + if array is not None: + return DataArrayMetadata( + path_in_resource=path_in_external, + array_type=str(array.dtype), + dimensions=list(array.shape), + ) + else: + # Would need to scan all possible paths - not practical + logging.warning("EPC Stream does not support listing all arrays without specific path") + return [] + except Exception as e: + logging.error(f"Failed to get array metadata: {e}") + + return None + + def list_objects( + self, dataspace: Optional[str] = None, object_type: Optional[str] = None + ) -> List[ResourceMetadata]: + """List all objects with metadata.""" + if self._closed: + raise RuntimeError("Storage is closed") + + results = [] + metadata_list = self.stream_reader.list_object_metadata(object_type) + + for meta in metadata_list: + try: + # Load object to get title + obj = self.stream_reader.get_object_by_identifier(meta.identifier) + title = "Unknown" + if obj and hasattr(obj, "citation") and obj.citation: + if hasattr(obj.citation, "title"): + title = obj.citation.title + + # Build URI + uri = f"eml:///{meta.uuid}" + if meta.version: + uri += f".{meta.version}" + + resource = ResourceMetadata( + uri=uri, + uuid=meta.uuid, + version=meta.version, + title=title, + object_type=meta.object_type, + content_type=meta.content_type, + ) + + results.append(resource) + except Exception as e: + logging.warning(f"Failed to get metadata for {meta.identifier}: {e}") + continue + + return results + + def close(self) -> None: + """Close the EPC stream storage.""" + self._closed = True + + def get_statistics(self) -> Dict[str, Any]: + """ + Get streaming statistics. + + Returns: + Dictionary with cache statistics and performance metrics + """ + if self._closed: + raise RuntimeError("Storage is closed") + + stats = self.stream_reader.get_statistics() + return { + "total_objects": stats.total_objects, + "loaded_objects": stats.loaded_objects, + "cache_hits": stats.cache_hits, + "cache_misses": stats.cache_misses, + "cache_hit_ratio": stats.cache_hit_ratio, + "bytes_read": stats.bytes_read, + } + + +def create_storage( + source: Union[str, Path, "Epc", "EpcStreamReader"], # noqa: F821 + stream_mode: bool = False, + cache_size: int = 100, + **kwargs, +) -> EnergymlStorageInterface: + """ + Factory function to create an appropriate storage interface from various sources. + + This convenience function automatically determines the correct storage implementation + based on the type of source provided. + + Args: + source: Can be: + - Epc instance: Creates EPCStorage + - EpcStreamReader instance: Creates EPCStreamStorage + - str/Path (file path): Loads EPC file and creates EPCStorage or EPCStreamStorage + stream_mode: If True and source is a file path, creates EPCStreamStorage for memory efficiency + cache_size: Cache size for stream mode (default: 100) + **kwargs: Additional arguments passed to EpcStreamReader if in stream mode + + Returns: + An EnergymlStorageInterface implementation (EPCStorage or EPCStreamStorage) + + Raises: + ValueError: If the source type is not supported + FileNotFoundError: If file path does not exist + + Example: + ```python + # From EPC instance + from energyml.utils.epc import Epc + epc = Epc() + storage = create_storage(epc) + + # From EPC stream reader + from energyml.utils.epc_stream import EpcStreamReader + stream_reader = EpcStreamReader("large_file.epc", cache_size=50) + storage = create_storage(stream_reader) + + # From file path (regular mode) + storage = create_storage("path/to/file.epc") + + # From file path (streaming mode for large files) + storage = create_storage("path/to/large_file.epc", stream_mode=True, cache_size=50) + ``` + """ + from energyml.utils.epc import Epc + from energyml.utils.epc_stream import EpcStreamReader + + if isinstance(source, Epc): + return EPCStorage(source) + + elif isinstance(source, EpcStreamReader): + return EPCStreamStorage(source) + + elif isinstance(source, (str, Path)): + file_path = Path(source) + if not file_path.exists(): + raise FileNotFoundError(f"EPC file not found: {file_path}") + + if stream_mode: + # Create streaming reader for memory efficiency + stream_reader = EpcStreamReader(file_path, cache_size=cache_size, **kwargs) + return EPCStreamStorage(stream_reader) + else: + # Load full EPC into memory + from energyml.utils.epc import read_energyml_epc_file + + epc = read_energyml_epc_file(str(file_path)) + return EPCStorage(epc) + + else: + raise ValueError( + f"Unsupported source type: {type(source)}. " "Expected Epc, EpcStreamReader, or file path (str/Path)" + ) + + +__all__ = [ + "EnergymlStorageInterface", + "EPCStorage", + "EPCStreamStorage", + "ResourceMetadata", + "DataArrayMetadata", + "create_storage", +] diff --git a/energyml-utils/tests/test_xml.py b/energyml-utils/tests/test_xml.py index 4c454af..4bf1f67 100644 --- a/energyml-utils/tests/test_xml.py +++ b/energyml-utils/tests/test_xml.py @@ -3,6 +3,7 @@ import logging +from scripts.optimized_constants import parse_qualified_type from src.energyml.utils.xml import * CT_20 = "application/x-resqml+xml;version=2.0;type=obj_TriangulatedSetRepresentation" From c4598bd0338a1549dd6b8bba1a61042d306d48c4 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Thu, 4 Dec 2025 01:06:54 +0100 Subject: [PATCH 02/70] read of wellboreFrameRepresentation --- energyml-utils/example/main_test_3D.py | 10 +- .../src/energyml/utils/data/helper.py | 70 +++++--- .../src/energyml/utils/data/mesh.py | 157 +++++++++++++++++- .../src/energyml/utils/introspection.py | 7 +- 4 files changed, 212 insertions(+), 32 deletions(-) diff --git a/energyml-utils/example/main_test_3D.py b/energyml-utils/example/main_test_3D.py index 13d49d6..f39b209 100644 --- a/energyml-utils/example/main_test_3D.py +++ b/energyml-utils/example/main_test_3D.py @@ -18,7 +18,9 @@ def export_all_representation(epc_path: str, output_dir: str, regex_type_filter: not_supported_types = set() for mdata in epc.list_object_metadata(): if "Representation" in mdata.object_type and ( - regex_type_filter is None or re.search(regex_type_filter, mdata.object_type, flags=re.IGNORECASE) + regex_type_filter is None + or len(regex_type_filter) == 0 + or re.search(regex_type_filter, mdata.object_type, flags=re.IGNORECASE) ): logging.info(f"Exporting representation: {mdata.object_type} ({mdata.uuid})") energyml_obj = epc.get_object_by_uuid(mdata.uuid)[0] @@ -56,8 +58,10 @@ def export_all_representation(epc_path: str, output_dir: str, regex_type_filter: import logging logging.basicConfig(level=logging.DEBUG) - epc_file = "rc/epc/testingPackageCpp.epc" + epc_file = "rc/epc/output-val.epc" + # epc_file = "rc/epc/testingPackageCpp.epc" # epc_file = "rc/epc/Volve_Horizons_and_Faults_Depth_originEQN.epc" output_directory = Path("exported_meshes") / Path(epc_file).name.replace(".epc", "_3D_export") # export_all_representation(epc_file, output_directory) - export_all_representation(epc_file, output_directory, regex_type_filter="Grid2D") + # export_all_representation(epc_file, output_directory, regex_type_filter="Wellbore") + export_all_representation(epc_file, str(output_directory), regex_type_filter="") diff --git a/energyml-utils/src/energyml/utils/data/helper.py b/energyml-utils/src/energyml/utils/data/helper.py index 556ef25..cf21892 100644 --- a/energyml-utils/src/energyml/utils/data/helper.py +++ b/energyml-utils/src/energyml/utils/data/helper.py @@ -86,20 +86,29 @@ def is_z_reversed(crs: Optional[Any]) -> bool: """ reverse_z_values = False if crs is not None: - # resqml 201 - zincreasing_downward = search_attribute_matching_name(crs, "ZIncreasingDownward") - if len(zincreasing_downward) > 0: - reverse_z_values = zincreasing_downward[0] - - # resqml >= 22 - vert_axis = search_attribute_matching_name(crs, "VerticalAxis.Direction") - if len(vert_axis) > 0: - vert_axis_str = str(vert_axis[0]) - if "." in vert_axis_str: - vert_axis_str = vert_axis_str.split(".")[-1] - - reverse_z_values = vert_axis_str.lower() == "down" - + if "VerticalCrs" in type(crs).__name__: + vert_axis = search_attribute_matching_name(crs, "Direction") + if len(vert_axis) > 0: + vert_axis_str = str(vert_axis[0]) + if "." in vert_axis_str: + vert_axis_str = vert_axis_str.split(".")[-1] + + reverse_z_values = vert_axis_str.lower() == "down" + else: + # resqml 201 + zincreasing_downward = search_attribute_matching_name(crs, "ZIncreasingDownward") + if len(zincreasing_downward) > 0: + reverse_z_values = zincreasing_downward[0] + + # resqml >= 22 + vert_axis = search_attribute_matching_name(crs, "VerticalAxis.Direction") + if len(vert_axis) > 0: + vert_axis_str = str(vert_axis[0]) + if "." in vert_axis_str: + vert_axis_str = vert_axis_str.split(".")[-1] + + reverse_z_values = vert_axis_str.lower() == "down" + logging.debug(f"is_z_reversed: {reverse_z_values}") return reverse_z_values @@ -185,15 +194,18 @@ def prod_n_tab(val: Union[float, int, str], tab: List[Union[float, int, str]]): """ if val is None: return [None] * len(tab) - + logging.debug(f"Multiplying list by {val}: {tab}") # Convert to numpy array for vectorized operations, handling None values arr = np.array(tab, dtype=object) + logging.debug(f"arr: {arr}") # Create mask for non-None values mask = arr != None # noqa: E711 # Create result array filled with None result = np.full(len(tab), None, dtype=object) + logging.debug(f"result before multiplication: {result}") # Multiply only non-None values result[mask] = arr[mask].astype(float) * val + logging.debug(f"result after multiplication: {result}") return result.tolist() @@ -536,19 +548,25 @@ def read_int_double_lattice_array( :param sub_indices: :return: """ - # start_value = get_object_attribute_no_verif(energyml_array, "start_value") + start_value = get_object_attribute_no_verif(energyml_array, "start_value") offset = get_object_attribute_no_verif(energyml_array, "offset") - # result = [] + result = [] + + if len(offset) == 1: + # 1D lattice array: offset is a single DoubleConstantArray or IntegerConstantArray + offset_obj = offset[0] + + # Get the offset value and count from the ConstantArray + offset_value = get_object_attribute_no_verif(offset_obj, "value") + count = get_object_attribute_no_verif(offset_obj, "count") - # if len(offset) == 1: - # pass - # elif len(offset) == 2: - # pass - # else: - raise Exception(f"{type(energyml_array)} read with an offset of length {len(offset)} is not supported") + # Generate the 1D array: start_value + i * offset_value for i in range(count) + result = [start_value + i * offset_value for i in range(count)] + else: + raise Exception(f"{type(energyml_array)} read with an offset of length {len(offset)} is not supported") - # return result + return result def read_point3d_zvalue_array( @@ -737,6 +755,10 @@ def read_point3d_lattice_array( slowest_size = len(slowest_table) fastest_size = len(fastest_table) + logging.debug(f"slowest vector: {slowest_vec}, spacing: {slowest_spacing}, size: {slowest_size}") + logging.debug(f"fastest vector: {fastest_vec}, spacing: {fastest_spacing}, size: {fastest_size}") + logging.debug(f"origin: {origin}, zincreasing_downward: {zincreasing_downward}") + if len(crs_sa_count) > 0 and len(crs_fa_count) > 0: if (crs_sa_count[0] == fastest_size and crs_fa_count[0] == slowest_size) or ( crs_sa_count[0] == fastest_size - 1 and crs_fa_count[0] == slowest_size - 1 diff --git a/energyml-utils/src/energyml/utils/data/mesh.py b/energyml-utils/src/energyml/utils/data/mesh.py index 0a59a2d..2b5daf0 100644 --- a/energyml-utils/src/energyml/utils/data/mesh.py +++ b/energyml-utils/src/energyml/utils/data/mesh.py @@ -29,6 +29,7 @@ search_attribute_matching_name_with_path, snake_case, get_object_attribute, + get_object_attribute_rgx, ) _FILE_HEADER: bytes = b"# file exported by energyml-utils python module (Geosiris)\n" @@ -200,8 +201,12 @@ def read_mesh_object( surfaces: List[AbstractMesh] = reader_func( energyml_object=energyml_object, workspace=workspace, sub_indices=sub_indices ) - if use_crs_displacement: + if ( + use_crs_displacement and "wellbore" not in array_type_name.lower() + ): # WellboreFrameRep has allready the displacement applied + # TODO: the displacement should be done in each reader function to manage specific cases for s in surfaces: + print("CRS : ", s.crs_object.uuid if s.crs_object is not None else "None") crs_displacement(s.point_list, s.crs_object) return surfaces else: @@ -399,6 +404,8 @@ def gen_surface_grid_geometry( path_in_root=patch_path, workspace=workspace, ) + logging.debug(f"Total points read: {len(points)}") + logging.debug(f"Sample points: {points[0:5]}") fa_count = search_attribute_matching_name(patch, "FastestAxisCount") if fa_count is None: @@ -437,7 +444,7 @@ def gen_surface_grid_geometry( sa_count = sa_count + 1 fa_count = fa_count + 1 - # logging.debug(f"sa_count {sa_count} fa_count {fa_count} : {sa_count*fa_count} - {len(points)} ") + logging.debug(f"sa_count {sa_count} fa_count {fa_count} : {sa_count*fa_count} - {len(points)} ") for sa in range(sa_count - 1): for fa in range(fa_count - 1): @@ -654,6 +661,152 @@ def read_triangulated_set_representation( return meshes +def read_wellbore_frame_representation( + energyml_object: Any, workspace: EnergymlWorkspace, sub_indices: List[int] = None +) -> List[PolylineSetMesh]: + """ + Read a WellboreFrameRepresentation and construct a polyline mesh from the trajectory. + + :param energyml_object: The WellboreFrameRepresentation object + :param workspace: The EnergymlWorkspace to access related objects + :param sub_indices: Optional list of indices to filter specific nodes + :return: List containing a single PolylineSetMesh representing the wellbore + """ + meshes = [] + + try: + # Read measured depths (NodeMd) + md_array = [] + try: + node_md_path, node_md_obj = search_attribute_matching_name_with_path(energyml_object, "NodeMd")[0] + md_array = read_array( + energyml_array=node_md_obj, + root_obj=energyml_object, + path_in_root=node_md_path, + workspace=workspace, + ) + if not isinstance(md_array, list): + md_array = md_array.tolist() if hasattr(md_array, "tolist") else list(md_array) + except (IndexError, AttributeError) as e: + logging.warning(f"Could not read NodeMd from wellbore frame: {e}") + return meshes + + # Get trajectory reference + trajectory_dor = search_attribute_matching_name(obj=energyml_object, name_rgx="Trajectory")[0] + trajectory_identifier = get_obj_identifier(trajectory_dor) + trajectory_obj = workspace.get_object_by_identifier(trajectory_identifier) + + if trajectory_obj is None: + logging.error(f"Trajectory {trajectory_identifier} not found") + return meshes + + # CRS + crs = None + + # Get reference point (wellhead location) - try different attribute paths for different versions + head_x, head_y, head_z = 0.0, 0.0, 0.0 + z_is_up = True # Default assumption + + try: + # Try to get MdDatum (RESQML 2.0.1) or MdInterval.Datum (RESQML 2.2+) + md_datum_dor = None + try: + md_datum_dor = search_attribute_matching_name(obj=trajectory_obj, name_rgx=r"MdDatum")[0] + except IndexError: + try: + md_datum_dor = search_attribute_matching_name(obj=trajectory_obj, name_rgx=r"MdInterval.Datum")[0] + except IndexError: + pass + + if md_datum_dor is not None: + md_datum_identifier = get_obj_identifier(md_datum_dor) + md_datum_obj = workspace.get_object_by_identifier(md_datum_identifier) + + if md_datum_obj is not None: + # Try to get coordinates from ReferencePointInACrs + try: + head_x = get_object_attribute_rgx(md_datum_obj, r"HorizontalCoordinates.Coordinate1") or 0.0 + head_y = get_object_attribute_rgx(md_datum_obj, r"HorizontalCoordinates.Coordinate2") or 0.0 + head_z = get_object_attribute_rgx(md_datum_obj, "VerticalCoordinate") or 0.0 + + # Get vertical CRS to determine z direction + try: + vcrs_dor = search_attribute_matching_name(obj=md_datum_obj, name_rgx="VerticalCrs")[0] + vcrs_identifier = get_obj_identifier(vcrs_dor) + vcrs_obj = workspace.get_object_by_identifier(vcrs_identifier) + + if vcrs_obj is not None: + z_is_up = not is_z_reversed(vcrs_obj) + except (IndexError, AttributeError): + pass + except AttributeError: + pass + # Get CRS from trajectory geometry if available + try: + geometry_paths = search_attribute_matching_name_with_path(md_datum_obj, r"VerticalCrs") + if len(geometry_paths) > 0: + crs_dor_path, crs_dor = geometry_paths[0] + crs_identifier = get_obj_identifier(crs_dor) + crs = workspace.get_object_by_identifier(crs_identifier) + except Exception as e: + logging.debug(f"Could not get CRS from trajectory: {e}") + except Exception as e: + logging.debug(f"Could not get reference point from trajectory: {e}") + + # Build wellbore path points - simple vertical projection from measured depths + # Note: This is a simplified representation. For accurate 3D trajectory, + # you would need to interpolate along the trajectory's control points. + points = [] + line_indices = [] + + for i, md in enumerate(md_array): + # Create point at (head_x, head_y, head_z +/- md) + # Apply z direction based on CRS + z_offset = md if z_is_up else -md + points.append([head_x, head_y, head_z + z_offset]) + + # Connect consecutive points + if i > 0: + line_indices.append([i - 1, i]) + + # Apply sub_indices filter if provided + if sub_indices is not None and len(sub_indices) > 0: + filtered_points = [] + filtered_indices = [] + index_map = {} + + for new_idx, old_idx in enumerate(sub_indices): + if 0 <= old_idx < len(points): + filtered_points.append(points[old_idx]) + index_map[old_idx] = new_idx + + for line in line_indices: + if line[0] in index_map and line[1] in index_map: + filtered_indices.append([index_map[line[0]], index_map[line[1]]]) + + points = filtered_points + line_indices = filtered_indices + + if len(points) > 0: + meshes.append( + PolylineSetMesh( + identifier=f"{get_obj_identifier(energyml_object)}_wellbore", + energyml_object=energyml_object, + crs_object=crs, + point_list=points, + line_indices=line_indices, + ) + ) + + except Exception as e: + logging.error(f"Failed to read wellbore frame representation: {e}") + import traceback + + traceback.print_exc() + + return meshes + + def read_sub_representation( energyml_object: Any, workspace: EnergymlWorkspace, diff --git a/energyml-utils/src/energyml/utils/introspection.py b/energyml-utils/src/energyml/utils/introspection.py index 1230d32..0ca5951 100644 --- a/energyml-utils/src/energyml/utils/introspection.py +++ b/energyml-utils/src/energyml/utils/introspection.py @@ -587,9 +587,10 @@ def get_object_attribute_no_verif(obj: Any, attr_name: str, default: Optional[An else: raise AttributeError(obj, name=attr_name) else: - return ( - getattr(obj, attr_name) or default - ) # we did not used the "default" of getattr to keep raising AttributeError + res = getattr(obj, attr_name) + if res is None: # we did not used the "default" of getattr to keep raising AttributeError + return default + return res def get_object_attribute_rgx(obj: Any, attr_dot_path_rgx: str) -> Any: From ec2a8b949d67255316f0dd1b25ee2f51703ad454 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Thu, 4 Dec 2025 10:26:14 +0100 Subject: [PATCH 03/70] -- --- energyml-utils/example/main_test_3D.py | 6 +++--- energyml-utils/src/energyml/utils/data/mesh.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/energyml-utils/example/main_test_3D.py b/energyml-utils/example/main_test_3D.py index f39b209..0e26584 100644 --- a/energyml-utils/example/main_test_3D.py +++ b/energyml-utils/example/main_test_3D.py @@ -53,13 +53,13 @@ def export_all_representation(epc_path: str, output_dir: str, regex_type_filter: logging.info(f" - {t}") -# $env:PYTHONPATH="D:\Geosiris\Github\energyml\energyml-python\energyml-utils\src"; poetry run python example/main_test_3D.py +# $env:PYTHONPATH="$(pwd)\src"; poetry run python example/main_test_3D.py if __name__ == "__main__": import logging logging.basicConfig(level=logging.DEBUG) - epc_file = "rc/epc/output-val.epc" - # epc_file = "rc/epc/testingPackageCpp.epc" + epc_file = "rc/epc/testingPackageCpp.epc" + # epc_file = "rc/epc/output-val.epc" # epc_file = "rc/epc/Volve_Horizons_and_Faults_Depth_originEQN.epc" output_directory = Path("exported_meshes") / Path(epc_file).name.replace(".epc", "_3D_export") # export_all_representation(epc_file, output_directory) diff --git a/energyml-utils/src/energyml/utils/data/mesh.py b/energyml-utils/src/energyml/utils/data/mesh.py index 2b5daf0..896670e 100644 --- a/energyml-utils/src/energyml/utils/data/mesh.py +++ b/energyml-utils/src/energyml/utils/data/mesh.py @@ -36,6 +36,22 @@ Point = list[float] +# ============================ +# TODO : + +# obj_GridConnectionSetRepresentation +# obj_IjkGridRepresentation +# obj_PlaneSetRepresentation +# obj_RepresentationSetRepresentation +# obj_SealedSurfaceFrameworkRepresentation +# obj_SealedVolumeFrameworkRepresentation +# obj_SubRepresentation +# obj_UnstructuredGridRepresentation +# obj_WellboreMarkerFrameRepresentation +# obj_WellboreTrajectoryRepresentation + +# ============================ + class MeshFileFormat(Enum): OFF = "off" From 199db296ee3be500b07b5529054e6219723644f7 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Fri, 5 Dec 2025 16:35:24 +0100 Subject: [PATCH 04/70] new exports --- energyml-utils/.gitignore | 7 + energyml-utils/example/main_test_3D.py | 20 +- .../src/energyml/utils/data/export.py | 489 ++++++++++++++++++ .../src/energyml/utils/data/mesh.py | 28 +- 4 files changed, 521 insertions(+), 23 deletions(-) create mode 100644 energyml-utils/src/energyml/utils/data/export.py diff --git a/energyml-utils/.gitignore b/energyml-utils/.gitignore index 07739c5..016795e 100644 --- a/energyml-utils/.gitignore +++ b/energyml-utils/.gitignore @@ -55,6 +55,13 @@ manip* *.xml *.json +docs/*.md + +# DATA +*.obj +*.geojson +*.vtk +*.stl # WIP diff --git a/energyml-utils/example/main_test_3D.py b/energyml-utils/example/main_test_3D.py index 0e26584..2111be6 100644 --- a/energyml-utils/example/main_test_3D.py +++ b/energyml-utils/example/main_test_3D.py @@ -6,7 +6,8 @@ from pathlib import Path import traceback -from energyml.utils.data.mesh import export_obj, read_mesh_object +from energyml.utils.data.export import export_obj, export_stl, export_vtk +from energyml.utils.data.mesh import read_mesh_object from energyml.utils.epc_stream import EpcStreamReader from energyml.utils.exception import NotSupportedError @@ -39,6 +40,19 @@ def export_all_representation(epc_path: str, output_dir: str, regex_type_filter: mesh_list=mesh_list, out=f, ) + export_stl_path = path.with_suffix(".stl") + with export_stl_path.open("wb") as stl_f: + export_stl( + mesh_list=mesh_list, + out=stl_f, + ) + export_vtk_path = path.with_suffix(".vtk") + with export_vtk_path.open("wb") as vtk_f: + export_vtk( + mesh_list=mesh_list, + out=vtk_f, + ) + logging.info(f" ✓ Exported to {path.name}") except NotSupportedError: # print(f" ✗ Not supported: {e}") @@ -58,8 +72,8 @@ def export_all_representation(epc_path: str, output_dir: str, regex_type_filter: import logging logging.basicConfig(level=logging.DEBUG) - epc_file = "rc/epc/testingPackageCpp.epc" - # epc_file = "rc/epc/output-val.epc" + # epc_file = "rc/epc/testingPackageCpp.epc" + epc_file = "rc/epc/output-val.epc" # epc_file = "rc/epc/Volve_Horizons_and_Faults_Depth_originEQN.epc" output_directory = Path("exported_meshes") / Path(epc_file).name.replace(".epc", "_3D_export") # export_all_representation(epc_file, output_directory) diff --git a/energyml-utils/src/energyml/utils/data/export.py b/energyml-utils/src/energyml/utils/data/export.py new file mode 100644 index 0000000..48d9681 --- /dev/null +++ b/energyml-utils/src/energyml/utils/data/export.py @@ -0,0 +1,489 @@ +# Copyright (c) 2023-2024 Geosiris. +# SPDX-License-Identifier: Apache-2.0 +""" +Module for exporting mesh data to various file formats. +Supports OBJ, GeoJSON, VTK, and STL formats. +""" + +import json +import struct +from enum import Enum +from pathlib import Path +from typing import TYPE_CHECKING, BinaryIO, List, Optional, TextIO, Union + +import numpy as np + +if TYPE_CHECKING: + from .mesh import AbstractMesh + + +class ExportFormat(Enum): + """Supported mesh export formats.""" + + OBJ = "obj" + GEOJSON = "geojson" + VTK = "vtk" + STL = "stl" + + @classmethod + def from_extension(cls, extension: str) -> "ExportFormat": + """Get format from file extension.""" + ext = extension.lower().lstrip(".") + for fmt in cls: + if fmt.value == ext: + return fmt + raise ValueError(f"Unsupported file extension: {extension}") + + @classmethod + def all_extensions(cls) -> List[str]: + """Get all supported file extensions.""" + return [fmt.value for fmt in cls] + + +class ExportOptions: + """Base class for export options.""" + + pass + + +class STLExportOptions(ExportOptions): + """Options for STL export.""" + + def __init__(self, binary: bool = True, ascii_precision: int = 6): + """ + Initialize STL export options. + + :param binary: If True, export as binary STL; if False, export as ASCII STL + :param ascii_precision: Number of decimal places for ASCII format + """ + self.binary = binary + self.ascii_precision = ascii_precision + + +class VTKExportOptions(ExportOptions): + """Options for VTK export.""" + + def __init__(self, binary: bool = False, dataset_name: str = "mesh"): + """ + Initialize VTK export options. + + :param binary: If True, export as binary VTK; if False, export as ASCII VTK + :param dataset_name: Name of the dataset in VTK file + """ + self.binary = binary + self.dataset_name = dataset_name + + +class GeoJSONExportOptions(ExportOptions): + """Options for GeoJSON export.""" + + def __init__(self, indent: Optional[int] = 2, properties: Optional[dict] = None): + """ + Initialize GeoJSON export options. + + :param indent: JSON indentation level (None for compact) + :param properties: Additional properties to include in features + """ + self.indent = indent + self.properties = properties or {} + + +def export_obj(mesh_list: List["AbstractMesh"], out: BinaryIO, obj_name: Optional[str] = None) -> None: + """ + Export mesh data to Wavefront OBJ format. + + :param mesh_list: List of AbstractMesh objects to export + :param out: Binary output stream + :param obj_name: Optional object name for the OBJ file + """ + # Lazy import to avoid circular dependency + from .mesh import PolylineSetMesh + + # Write header + out.write(b"# Generated by energyml-utils a Geosiris python module\n\n") + + # Write object name if provided + if obj_name is not None: + out.write(f"o {obj_name}\n\n".encode("utf-8")) + + point_offset = 0 + + for mesh in mesh_list: + # Write group name using mesh identifier or uuid + mesh_id = getattr(mesh, "identifier", None) or getattr(mesh, "uuid", "mesh") + out.write(f"g {mesh_id}\n\n".encode("utf-8")) + + # Write vertices + for point in mesh.point_list: + if len(point) > 0: + out.write(f"v {' '.join(map(str, point))}\n".encode("utf-8")) + + # Write faces or lines depending on mesh type + indices = mesh.get_indices() + elt_letter = "l" if isinstance(mesh, PolylineSetMesh) else "f" + + for face_or_line in indices: + if len(face_or_line) > 1: + # OBJ indices are 1-based + indices_str = " ".join(str(idx + point_offset + 1) for idx in face_or_line) + out.write(f"{elt_letter} {indices_str}\n".encode("utf-8")) + + point_offset += len(mesh.point_list) + + +def export_geojson( + mesh_list: List["AbstractMesh"], out: TextIO, options: Optional[GeoJSONExportOptions] = None +) -> None: + """ + Export mesh data to GeoJSON format. + + :param mesh_list: List of AbstractMesh objects to export + :param out: Text output stream + :param options: GeoJSON export options + """ + # Lazy import to avoid circular dependency + from .mesh import PolylineSetMesh, SurfaceMesh + + if options is None: + options = GeoJSONExportOptions() + + features = [] + + for mesh_idx, mesh in enumerate(mesh_list): + indices = mesh.get_indices() + + if isinstance(mesh, PolylineSetMesh): + # Export as LineString features + for line_idx, line_indices in enumerate(indices): + if len(line_indices) < 2: + continue + coordinates = [list(mesh.point_list[idx]) for idx in line_indices] + feature = { + "type": "Feature", + "geometry": {"type": "LineString", "coordinates": coordinates}, + "properties": {"mesh_index": mesh_idx, "line_index": line_idx, **options.properties}, + } + features.append(feature) + + elif isinstance(mesh, SurfaceMesh): + # Export as Polygon features + for face_idx, face_indices in enumerate(indices): + if len(face_indices) < 3: + continue + # GeoJSON Polygon requires closed ring (first point == last point) + coordinates = [list(mesh.point_list[idx]) for idx in face_indices] + coordinates.append(coordinates[0]) # Close the ring + + feature = { + "type": "Feature", + "geometry": {"type": "Polygon", "coordinates": [coordinates]}, + "properties": {"mesh_index": mesh_idx, "face_index": face_idx, **options.properties}, + } + features.append(feature) + + geojson = {"type": "FeatureCollection", "features": features} + + json.dump(geojson, out, indent=options.indent) + + +def export_vtk(mesh_list: List["AbstractMesh"], out: BinaryIO, options: Optional[VTKExportOptions] = None) -> None: + """ + Export mesh data to VTK legacy format. + + :param mesh_list: List of AbstractMesh objects to export + :param out: Binary output stream + :param options: VTK export options + """ + # Lazy import to avoid circular dependency + from .mesh import PolylineSetMesh, SurfaceMesh + + if options is None: + options = VTKExportOptions() + + # Combine all meshes + all_points = [] + all_polygons = [] + all_lines = [] + vertex_offset = 0 + + for mesh in mesh_list: + all_points.extend(mesh.point_list) + indices = mesh.get_indices() + + if isinstance(mesh, SurfaceMesh): + # Adjust face indices + for face in indices: + adjusted_face = [idx + vertex_offset for idx in face] + all_polygons.append(adjusted_face) + elif isinstance(mesh, PolylineSetMesh): + # Adjust line indices + for line in indices: + adjusted_line = [idx + vertex_offset for idx in line] + all_lines.append(adjusted_line) + + vertex_offset += len(mesh.point_list) + + # Write VTK header + out.write(b"# vtk DataFile Version 3.0\n") + out.write(f"{options.dataset_name}\n".encode("utf-8")) + out.write(b"ASCII\n") + out.write(b"DATASET POLYDATA\n") + + # Write points + out.write(f"POINTS {len(all_points)} float\n".encode("utf-8")) + for point in all_points: + out.write(f"{point[0]} {point[1]} {point[2]}\n".encode("utf-8")) + + # Write polygons + if all_polygons: + total_poly_size = sum(len(poly) + 1 for poly in all_polygons) + out.write(f"POLYGONS {len(all_polygons)} {total_poly_size}\n".encode("utf-8")) + for poly in all_polygons: + out.write(f"{len(poly)} {' '.join(str(idx) for idx in poly)}\n".encode("utf-8")) + + # Write lines + if all_lines: + total_line_size = sum(len(line) + 1 for line in all_lines) + out.write(f"LINES {len(all_lines)} {total_line_size}\n".encode("utf-8")) + for line in all_lines: + out.write(f"{len(line)} {' '.join(str(idx) for idx in line)}\n".encode("utf-8")) + + +def export_stl(mesh_list: List["AbstractMesh"], out: BinaryIO, options: Optional[STLExportOptions] = None) -> None: + """ + Export mesh data to STL format (binary or ASCII). + + Note: STL format only supports triangles. Only triangular faces will be exported. + + :param mesh_list: List of AbstractMesh objects to export + :param out: Binary output stream + :param options: STL export options + """ + # Lazy import to avoid circular dependency + from .mesh import SurfaceMesh + + if options is None: + options = STLExportOptions(binary=True) + + # Collect all triangles (only from SurfaceMesh with triangular faces) + all_triangles = [] + for mesh in mesh_list: + if isinstance(mesh, SurfaceMesh): + indices = mesh.get_indices() + for face in indices: + # Only export triangular faces + if len(face) == 3: + p0 = np.array(mesh.point_list[face[0]]) + p1 = np.array(mesh.point_list[face[1]]) + p2 = np.array(mesh.point_list[face[2]]) + all_triangles.append((p0, p1, p2)) + + if options.binary: + _export_stl_binary(all_triangles, out) + else: + _export_stl_ascii(all_triangles, out, options.ascii_precision) + + +def _export_stl_binary(triangles: List[tuple], out: BinaryIO) -> None: + """Export STL in binary format.""" + # Write 80-byte header + header = b"Binary STL file generated by energyml-utils" + b"\0" * (80 - 44) + out.write(header) + + # Write number of triangles + out.write(struct.pack(" 0: + normal = normal / norm + else: + normal = np.array([0.0, 0.0, 0.0]) + + # Write normal + out.write(struct.pack(" None: + """Export STL in ASCII format.""" + out.write(b"solid mesh\n") + + for p0, p1, p2 in triangles: + # Calculate normal vector + v1 = p1 - p0 + v2 = p2 - p0 + normal = np.cross(v1, v2) + norm = np.linalg.norm(normal) + if norm > 0: + normal = normal / norm + else: + normal = np.array([0.0, 0.0, 0.0]) + + # Write facet + line = f" facet normal {normal[0]:.{precision}e} {normal[1]:.{precision}e} {normal[2]:.{precision}e}\n" + out.write(line.encode("utf-8")) + out.write(b" outer loop\n") + + for point in [p0, p1, p2]: + line = f" vertex {point[0]:.{precision}e} {point[1]:.{precision}e} {point[2]:.{precision}e}\n" + out.write(line.encode("utf-8")) + + out.write(b" endloop\n") + out.write(b" endfacet\n") + + out.write(b"endsolid mesh\n") + + +def export_mesh( + mesh_list: List["AbstractMesh"], + output_path: Union[str, Path], + format: Optional[ExportFormat] = None, + options: Optional[ExportOptions] = None, +) -> None: + """ + Export mesh data to a file in the specified format. + + :param mesh_list: List of Mesh objects to export + :param output_path: Output file path + :param format: Export format (auto-detected from extension if None) + :param options: Format-specific export options + """ + path = Path(output_path) + + # Auto-detect format from extension if not specified + if format is None: + format = ExportFormat.from_extension(path.suffix) + + # Determine if file should be opened in binary or text mode + binary_formats = {ExportFormat.OBJ, ExportFormat.STL, ExportFormat.VTK} + text_formats = {ExportFormat.GEOJSON} + + if format in binary_formats: + with path.open("wb") as f: + if format == ExportFormat.OBJ: + export_obj(mesh_list, f) + elif format == ExportFormat.STL: + export_stl(mesh_list, f, options) + elif format == ExportFormat.VTK: + export_vtk(mesh_list, f, options) + elif format in text_formats: + with path.open("w", encoding="utf-8") as f: + if format == ExportFormat.GEOJSON: + export_geojson(mesh_list, f, options) + else: + raise ValueError(f"Unsupported format: {format}") + + +# UI Helper Functions + + +def supported_formats() -> List[str]: + """ + Get list of supported export formats. + + :return: List of format names (e.g., ['obj', 'geojson', 'vtk', 'stl']) + """ + return ExportFormat.all_extensions() + + +def format_description(format: Union[str, ExportFormat]) -> str: + """ + Get human-readable description of a format. + + :param format: Format name or ExportFormat enum + :return: Description string + """ + if isinstance(format, str): + format = ExportFormat.from_extension(format) + + descriptions = { + ExportFormat.OBJ: "Wavefront OBJ - 3D geometry format (triangles and lines)", + ExportFormat.GEOJSON: "GeoJSON - Geographic data format (lines and polygons)", + ExportFormat.VTK: "VTK Legacy - Visualization Toolkit format", + ExportFormat.STL: "STL - Stereolithography format (triangles only)", + } + return descriptions.get(format, "Unknown format") + + +def format_filter_string(format: Union[str, ExportFormat]) -> str: + """ + Get file filter string for UI dialogs (Qt, tkinter, etc.). + + :param format: Format name or ExportFormat enum + :return: Filter string (e.g., "OBJ Files (*.obj)") + """ + if isinstance(format, str): + format = ExportFormat.from_extension(format) + + filters = { + ExportFormat.OBJ: "OBJ Files (*.obj)", + ExportFormat.GEOJSON: "GeoJSON Files (*.geojson)", + ExportFormat.VTK: "VTK Files (*.vtk)", + ExportFormat.STL: "STL Files (*.stl)", + } + return filters.get(format, "All Files (*.*)") + + +def all_formats_filter_string() -> str: + """ + Get file filter string for all supported formats. + Useful for Qt QFileDialog or similar UI components. + + :return: Filter string with all formats + """ + filters = [format_filter_string(fmt) for fmt in ExportFormat] + return ";;".join(filters) + + +def get_format_options_class(format: Union[str, ExportFormat]) -> Optional[type]: + """ + Get the options class for a specific format. + + :param format: Format name or ExportFormat enum + :return: Options class or None if no options available + """ + if isinstance(format, str): + format = ExportFormat.from_extension(format) + + options_map = { + ExportFormat.STL: STLExportOptions, + ExportFormat.VTK: VTKExportOptions, + ExportFormat.GEOJSON: GeoJSONExportOptions, + } + return options_map.get(format) + + +def supports_lines(format: Union[str, ExportFormat]) -> bool: + """ + Check if format supports line primitives. + + :param format: Format name or ExportFormat enum + :return: True if format supports lines + """ + if isinstance(format, str): + format = ExportFormat.from_extension(format) + + return format in {ExportFormat.OBJ, ExportFormat.GEOJSON, ExportFormat.VTK} + + +def supports_triangles(format: Union[str, ExportFormat]) -> bool: + """ + Check if format supports triangle primitives. + + :param format: Format name or ExportFormat enum + :return: True if format supports triangles + """ + # All formats support triangles + return True diff --git a/energyml-utils/src/energyml/utils/data/mesh.py b/energyml-utils/src/energyml/utils/data/mesh.py index 896670e..6dde2ff 100644 --- a/energyml-utils/src/energyml/utils/data/mesh.py +++ b/energyml-utils/src/energyml/utils/data/mesh.py @@ -32,6 +32,9 @@ get_object_attribute_rgx, ) +# Import export functions from new export module for backward compatibility +from .export import export_obj as _export_obj_new + _FILE_HEADER: bytes = b"# file exported by energyml-utils python module (Geosiris)\n" Point = list[float] @@ -1426,32 +1429,17 @@ def export_obj(mesh_list: List[AbstractMesh], out: BytesIO, obj_name: Optional[s """ Export an :class:`AbstractMesh` into obj format. + This function is maintained for backward compatibility and delegates to the + export module. For new code, consider importing from energyml.utils.data.export. + Each AbstractMesh from the list :param:`mesh_list` will be placed into its own group. :param mesh_list: :param out: :param obj_name: :return: """ - out.write("# Generated by energyml-utils a Geosiris python module\n\n".encode("utf-8")) - - if obj_name is not None: - out.write(f"o {obj_name}\n\n".encode("utf-8")) - - point_offset = 0 - for m in mesh_list: - mesh_id = getattr(m, "identifier", None) or getattr(m, "uuid", "mesh") - out.write(f"g {mesh_id}\n\n".encode("utf-8")) - _export_obj_elt( - off_point_part=out, - off_face_part=out, - points=m.point_list, - indices=m.get_indices(), - point_offset=point_offset, - colors=[], - elt_letter="l" if isinstance(m, PolylineSetMesh) else "f", - ) - point_offset = point_offset + len(m.point_list) - out.write("\n".encode("utf-8")) + # Delegate to the new export module + _export_obj_new(mesh_list, out, obj_name) def _export_obj_elt( From a65d18a02eb75cd849f5af4ea08f7af14ea7621f Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Mon, 8 Dec 2025 11:15:12 +0100 Subject: [PATCH 05/70] new storage interface --- .../example/epc_stream_keep_open_example.py | 1 - energyml-utils/example/main_data.py | 7 +- energyml-utils/example/main_test_3D.py | 12 ++- .../src/energyml/utils/data/helper.py | 90 ++++++++--------- .../src/energyml/utils/data/mesh.py | 98 +++++++++++-------- .../src/energyml/utils/storage_interface.py | 98 ++++++++++--------- 6 files changed, 166 insertions(+), 140 deletions(-) diff --git a/energyml-utils/example/epc_stream_keep_open_example.py b/energyml-utils/example/epc_stream_keep_open_example.py index 61e7d09..ea9d9cc 100644 --- a/energyml-utils/example/epc_stream_keep_open_example.py +++ b/energyml-utils/example/epc_stream_keep_open_example.py @@ -10,7 +10,6 @@ """ import time -from pathlib import Path import sys from pathlib import Path diff --git a/energyml-utils/example/main_data.py b/energyml-utils/example/main_data.py index a05cd20..78061de 100644 --- a/energyml-utils/example/main_data.py +++ b/energyml-utils/example/main_data.py @@ -1,6 +1,6 @@ # Copyright (c) 2023-2024 Geosiris. # SPDX-License-Identifier: Apache-2.0 - +import logging from energyml.eml.v2_3.commonv2 import ( JaggedArray, AbstractValueArray, @@ -12,7 +12,6 @@ from src.energyml.utils.data.helper import ( get_array_reader_function, ) -from src.energyml.utils.data.mesh import * from src.energyml.utils.data.mesh import _create_shape, _write_geojson_shape from src.energyml.utils.epc import gen_energyml_object_path from src.energyml.utils.introspection import ( @@ -28,11 +27,13 @@ ) from src.energyml.utils.validation import validate_epc from src.energyml.utils.xml import get_tree -from utils.data.datasets_io import ( +from src.energyml.utils.data.datasets_io import ( HDF5FileReader, get_path_in_external_with_path, get_external_file_path_from_external_path, ) +from energyml.utils.epc import Epc +from src.energyml.utils.data.mesh import * logger = logging.getLogger(__name__) diff --git a/energyml-utils/example/main_test_3D.py b/energyml-utils/example/main_test_3D.py index 2111be6..84f7113 100644 --- a/energyml-utils/example/main_test_3D.py +++ b/energyml-utils/example/main_test_3D.py @@ -9,26 +9,30 @@ from energyml.utils.data.export import export_obj, export_stl, export_vtk from energyml.utils.data.mesh import read_mesh_object from energyml.utils.epc_stream import EpcStreamReader +from energyml.utils.storage_interface import EPCStreamStorage + from energyml.utils.exception import NotSupportedError def export_all_representation(epc_path: str, output_dir: str, regex_type_filter: str = None): - epc = EpcStreamReader(epc_path, keep_open=True) + _epc = EpcStreamReader(epc_path, keep_open=True) + storage = EPCStreamStorage(stream_reader=_epc) + dt = datetime.datetime.now().strftime("%Hh%M_%d-%m-%Y") not_supported_types = set() - for mdata in epc.list_object_metadata(): + for mdata in storage.list_objects(): if "Representation" in mdata.object_type and ( regex_type_filter is None or len(regex_type_filter) == 0 or re.search(regex_type_filter, mdata.object_type, flags=re.IGNORECASE) ): logging.info(f"Exporting representation: {mdata.object_type} ({mdata.uuid})") - energyml_obj = epc.get_object_by_uuid(mdata.uuid)[0] + energyml_obj = storage.get_object_by_uuid(mdata.uuid)[0] try: mesh_list = read_mesh_object( energyml_object=energyml_obj, - workspace=epc, + workspace=storage, use_crs_displacement=True, ) diff --git a/energyml-utils/src/energyml/utils/data/helper.py b/energyml-utils/src/energyml/utils/data/helper.py index cf21892..3b5ee6a 100644 --- a/energyml-utils/src/energyml/utils/data/helper.py +++ b/energyml-utils/src/energyml/utils/data/helper.py @@ -5,13 +5,14 @@ import sys from typing import Any, Optional, Callable, List, Union +from energyml.utils.storage_interface import EnergymlStorageInterface import numpy as np from .datasets_io import read_external_dataset_array from ..constants import flatten_concatenation -from ..epc import get_obj_identifier from ..exception import ObjectNotFoundNotError from ..introspection import ( + get_obj_uri, snake_case, get_object_attribute_no_verif, search_attribute_matching_name_with_path, @@ -21,7 +22,8 @@ get_object_attribute, get_object_attribute_rgx, ) -from ..workspace import EnergymlWorkspace + +# from ..workspace import EnergymlWorkspace from .datasets_io import get_path_in_external_with_path _ARRAY_NAMES_ = [ @@ -123,7 +125,7 @@ def get_vertical_epsg_code(crs_object: Any): return vertical_epsg_code -def get_projected_epsg_code(crs_object: Any, workspace: Optional[EnergymlWorkspace] = None): +def get_projected_epsg_code(crs_object: Any, workspace: Optional[EnergymlStorageInterface] = None): if crs_object is not None: # LocalDepth3dCRS projected_epsg_code = get_object_attribute_rgx(crs_object, "ProjectedCrs.EpsgCode") if projected_epsg_code is None: # LocalEngineering2DCrs @@ -139,7 +141,7 @@ def get_projected_epsg_code(crs_object: Any, workspace: Optional[EnergymlWorkspa return None -def get_projected_uom(crs_object: Any, workspace: Optional[EnergymlWorkspace] = None): +def get_projected_uom(crs_object: Any, workspace: Optional[EnergymlStorageInterface] = None): if crs_object is not None: projected_epsg_uom = get_object_attribute_rgx(crs_object, "ProjectedUom") if projected_epsg_uom is None: @@ -153,7 +155,7 @@ def get_projected_uom(crs_object: Any, workspace: Optional[EnergymlWorkspace] = return None -def get_crs_origin_offset(crs_obj: Any) -> List[float]: +def get_crs_origin_offset(crs_obj: Any) -> List[float | int]: """ Return a list [X,Y,Z] corresponding to the crs Offset [XOffset/OriginProjectedCoordinate1, ... ] depending on the crs energyml version. @@ -172,12 +174,12 @@ def get_crs_origin_offset(crs_obj: Any) -> List[float]: if tmp_offset_z is None: tmp_offset_z = get_object_attribute_rgx(crs_obj, "OriginProjectedCoordinate3") - crs_point_offset = [0, 0, 0] + crs_point_offset = [0.0, 0.0, 0.0] try: crs_point_offset = [ - float(tmp_offset_x) if tmp_offset_x is not None else 0, - float(tmp_offset_y) if tmp_offset_y is not None else 0, - float(tmp_offset_z) if tmp_offset_z is not None else 0, + float(tmp_offset_x) if tmp_offset_x is not None else 0.0, + float(tmp_offset_y) if tmp_offset_y is not None else 0.0, + float(tmp_offset_z) if tmp_offset_z is not None else 0.0, ] except Exception as e: logging.info(f"ERR reading crs offset {e}") @@ -251,7 +253,7 @@ def get_crs_obj( context_obj: Any, path_in_root: Optional[str] = None, root_obj: Optional[Any] = None, - workspace: Optional[EnergymlWorkspace] = None, + workspace: Optional[EnergymlStorageInterface] = None, ) -> Optional[Any]: """ Search for the CRS object related to :param:`context_obj` into the :param:`workspace` @@ -267,12 +269,12 @@ def get_crs_obj( crs_list = search_attribute_matching_name(context_obj, r"\.*Crs", search_in_sub_obj=True, deep_search=False) if crs_list is not None and len(crs_list) > 0: # logging.debug(crs_list[0]) - crs = workspace.get_object_by_identifier(get_obj_identifier(crs_list[0])) + crs = workspace.get_object(get_obj_uri(crs_list[0])) if crs is None: crs = workspace.get_object_by_uuid(get_obj_uuid(crs_list[0])) if crs is None: logging.error(f"CRS {crs_list[0]} not found (or not read correctly)") - raise ObjectNotFoundNotError(get_obj_identifier(crs_list[0])) + raise ObjectNotFoundNotError(get_obj_uri(crs_list[0])) if crs is not None: return crs @@ -338,9 +340,9 @@ def read_external_array( energyml_array: Any, root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, - workspace: Optional[EnergymlWorkspace] = None, - sub_indices: Optional[List[int]] = None, -) -> Union[List[Any], np.ndarray]: + workspace: Optional[EnergymlStorageInterface] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, +) -> Optional[Union[List[Any], np.ndarray]]: """ Read an external array (BooleanExternalArray, BooleanHdf5Array, DoubleHdf5Array, IntegerHdf5Array, StringExternalArray ...) :param energyml_array: @@ -380,7 +382,7 @@ def read_external_array( if sub_indices is not None and len(sub_indices) > 0: if isinstance(array, np.ndarray): array = array[sub_indices] - else: + elif isinstance(array, list): # Fallback for non-numpy arrays array = [array[idx] for idx in sub_indices] @@ -403,8 +405,8 @@ def read_array( energyml_array: Any, root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, - workspace: Optional[EnergymlWorkspace] = None, - sub_indices: Optional[List[int]] = None, + workspace: Optional[EnergymlStorageInterface] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> Union[List[Any], np.ndarray]: """ Read an array and return a list. The array is read depending on its type. see. :py:func:`energyml.utils.data.helper.get_supported_array` @@ -439,8 +441,8 @@ def read_constant_array( energyml_array: Any, root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, - workspace: Optional[EnergymlWorkspace] = None, - sub_indices: Optional[List[int]] = None, + workspace: Optional[EnergymlStorageInterface] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[Any]: """ Read a constant array ( BooleanConstantArray, DoubleConstantArray, FloatingPointConstantArray, IntegerConstantArray ...) @@ -469,8 +471,8 @@ def read_xml_array( energyml_array: Any, root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, - workspace: Optional[EnergymlWorkspace] = None, - sub_indices: Optional[List[int]] = None, + workspace: Optional[EnergymlStorageInterface] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> Union[List[Any], np.ndarray]: """ Read a xml array ( BooleanXmlArray, FloatingPointXmlArray, IntegerXmlArray, StringXmlArray ...) @@ -497,8 +499,8 @@ def read_jagged_array( energyml_array: Any, root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, - workspace: Optional[EnergymlWorkspace] = None, - sub_indices: Optional[List[int]] = None, + workspace: Optional[EnergymlStorageInterface] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[Any]: """ Read a jagged array @@ -512,13 +514,13 @@ def read_jagged_array( elements = read_array( energyml_array=get_object_attribute_no_verif(energyml_array, "elements"), root_obj=root_obj, - path_in_root=path_in_root + ".elements", + path_in_root=(path_in_root or "") + ".elements", workspace=workspace, ) cumulative_length = read_array( energyml_array=read_array(get_object_attribute_no_verif(energyml_array, "cumulative_length")), root_obj=root_obj, - path_in_root=path_in_root + ".cumulative_length", + path_in_root=(path_in_root or "") + ".cumulative_length", workspace=workspace, ) @@ -536,8 +538,8 @@ def read_int_double_lattice_array( energyml_array: Any, root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, - workspace: Optional[EnergymlWorkspace] = None, - sub_indices: Optional[List[int]] = None, + workspace: Optional[EnergymlStorageInterface] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, ): """ Read DoubleLatticeArray or IntegerLatticeArray. @@ -573,8 +575,8 @@ def read_point3d_zvalue_array( energyml_array: Any, root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, - workspace: Optional[EnergymlWorkspace] = None, - sub_indices: Optional[List[int]] = None, + workspace: Optional[EnergymlStorageInterface] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, ): """ Read a Point3D2ValueArray @@ -589,7 +591,7 @@ def read_point3d_zvalue_array( sup_geom_array = read_array( energyml_array=supporting_geometry, root_obj=root_obj, - path_in_root=path_in_root + ".SupportingGeometry", + path_in_root=(path_in_root or "") + ".SupportingGeometry", workspace=workspace, sub_indices=sub_indices, ) @@ -599,7 +601,7 @@ def read_point3d_zvalue_array( read_array( energyml_array=zvalues, root_obj=root_obj, - path_in_root=path_in_root + ".ZValues", + path_in_root=(path_in_root or "") + ".ZValues", workspace=workspace, sub_indices=sub_indices, ) @@ -633,8 +635,8 @@ def read_point3d_from_representation_lattice_array( energyml_array: Any, root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, - workspace: Optional[EnergymlWorkspace] = None, - sub_indices: Optional[List[int]] = None, + workspace: Optional[EnergymlStorageInterface] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, ): """ Read a Point3DFromRepresentationLatticeArray. @@ -648,11 +650,9 @@ def read_point3d_from_representation_lattice_array( :param sub_indices: :return: """ - supporting_rep_identifier = get_obj_identifier( - get_object_attribute_no_verif(energyml_array, "supporting_representation") - ) + supporting_rep_identifier = get_obj_uri(get_object_attribute_no_verif(energyml_array, "supporting_representation")) # logging.debug(f"energyml_array : {energyml_array}\n\t{supporting_rep_identifier}") - supporting_rep = workspace.get_object_by_identifier(supporting_rep_identifier) + supporting_rep = workspace.get_object(supporting_rep_identifier) if workspace is not None else None # TODO chercher un pattern \.*patch\.*.[d]+ pour trouver le numero du patch dans le path_in_root puis lire le patch # logging.debug(f"path_in_root {path_in_root}") @@ -676,8 +676,8 @@ def read_grid2d_patch( patch: Any, grid2d: Optional[Any] = None, path_in_root: Optional[str] = None, - workspace: Optional[EnergymlWorkspace] = None, - sub_indices: Optional[List[int]] = None, + workspace: Optional[EnergymlStorageInterface] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> Union[List, np.ndarray]: points_path, points_obj = search_attribute_matching_name_with_path(patch, "Geometry.Points")[0] @@ -694,8 +694,8 @@ def read_point3d_lattice_array( energyml_array: Any, root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, - workspace: Optional[EnergymlWorkspace] = None, - sub_indices: Optional[List[int]] = None, + workspace: Optional[EnergymlStorageInterface] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List: """ Read a Point3DLatticeArray. @@ -721,14 +721,14 @@ def read_point3d_lattice_array( obj=energyml_array, name_rgx="slowestAxisCount", root_obj=root_obj, - current_path=path_in_root, + current_path=path_in_root or "", ) crs_fa_count = search_attribute_in_upper_matching_name( obj=energyml_array, name_rgx="fastestAxisCount", root_obj=root_obj, - current_path=path_in_root, + current_path=path_in_root or "", ) crs = None @@ -852,6 +852,6 @@ def read_point3d_lattice_array( # energyml_array: Any, # root_obj: Optional[Any] = None, # path_in_root: Optional[str] = None, -# workspace: Optional[EnergymlWorkspace] = None +# workspace: Optional[EnergymlStorageInterface] = None # ): # logging.debug(energyml_array) diff --git a/energyml-utils/src/energyml/utils/data/mesh.py b/energyml-utils/src/energyml/utils/data/mesh.py index 6dde2ff..32f02b8 100644 --- a/energyml-utils/src/energyml/utils/data/mesh.py +++ b/energyml-utils/src/energyml/utils/data/mesh.py @@ -16,21 +16,23 @@ from .helper import ( read_array, read_grid2d_patch, - EnergymlWorkspace, get_crs_obj, get_crs_origin_offset, is_z_reversed, ) -from ..epc import Epc, get_obj_identifier, gen_energyml_object_path +from ..epc import Epc, gen_energyml_object_path from ..epc_stream import EpcStreamReader from ..exception import NotSupportedError, ObjectNotFoundNotError from ..introspection import ( + get_obj_uri, search_attribute_matching_name, search_attribute_matching_name_with_path, snake_case, get_object_attribute, get_object_attribute_rgx, ) +from energyml.utils.storage_interface import EPCStorage, EPCStreamStorage, EnergymlStorageInterface + # Import export functions from new export module for backward compatibility from .export import export_obj as _export_obj_new @@ -97,12 +99,12 @@ class AbstractMesh: crs_object: Any = field(default=None) - point_list: List[Point] = field( + point_list: Union[List[Point], np.ndarray] = field( default_factory=list, ) identifier: str = field( - default=None, + default="", ) def get_nb_edges(self) -> int: @@ -111,7 +113,7 @@ def get_nb_edges(self) -> int: def get_nb_faces(self) -> int: return 0 - def get_indices(self) -> List[List[int]]: + def get_indices(self) -> Union[List[List[int]], np.ndarray]: return [] @@ -122,7 +124,7 @@ class PointSetMesh(AbstractMesh): @dataclass class PolylineSetMesh(AbstractMesh): - line_indices: List[List[int]] = field( + line_indices: Union[List[List[int]], np.ndarray] = field( default_factory=list, ) @@ -132,13 +134,13 @@ def get_nb_edges(self) -> int: def get_nb_faces(self) -> int: return 0 - def get_indices(self) -> List[List[int]]: + def get_indices(self) -> Union[List[List[int]], np.ndarray]: return self.line_indices @dataclass class SurfaceMesh(AbstractMesh): - faces_indices: List[List[int]] = field( + faces_indices: Union[List[List[int]], np.ndarray] = field( default_factory=list, ) @@ -148,7 +150,7 @@ def get_nb_edges(self) -> int: def get_nb_faces(self) -> int: return len(self.faces_indices) - def get_indices(self) -> List[List[int]]: + def get_indices(self) -> Union[List[List[int]], np.ndarray]: return self.faces_indices @@ -198,9 +200,9 @@ def _mesh_name_mapping(array_type_name: str) -> str: def read_mesh_object( energyml_object: Any, - workspace: Optional[EnergymlWorkspace] = None, + workspace: Optional[EnergymlStorageInterface] = None, use_crs_displacement: bool = False, - sub_indices: List[int] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[AbstractMesh]: """ Read and "meshable" object. If :param:`energyml_object` is not supported, an exception will be raised. @@ -210,6 +212,11 @@ def read_mesh_object( is used to translate the data with the CRS offsets :return: """ + if isinstance(workspace, EpcStreamReader): + workspace = EPCStreamStorage(stream_reader=workspace) + elif isinstance(workspace, Epc): + workspace = EPCStorage(epc=workspace) + if isinstance(energyml_object, list): return energyml_object array_type_name = _mesh_name_mapping(type(energyml_object).__name__) @@ -236,13 +243,17 @@ def read_mesh_object( def read_ijk_grid_representation( - energyml_object: Any, workspace: EnergymlWorkspace, sub_indices: List[int] = None + energyml_object: Any, + workspace: EnergymlStorageInterface, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[Any]: raise NotSupportedError("IJKGrid representation reading is not supported yet.") def read_point_representation( - energyml_object: Any, workspace: EnergymlWorkspace, sub_indices: List[int] = None + energyml_object: Any, + workspace: EnergymlStorageInterface, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[PointSetMesh]: # pt_geoms = search_attribute_matching_type(point_set, "AbstractGeometry") @@ -304,7 +315,9 @@ def read_point_representation( def read_polyline_representation( - energyml_object: Any, workspace: EnergymlWorkspace, sub_indices: List[int] = None + energyml_object: Any, + workspace: EnergymlStorageInterface, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[PolylineSetMesh]: # pt_geoms = search_attribute_matching_type(point_set, "AbstractGeometry") @@ -395,7 +408,7 @@ def read_polyline_representation( if len(points) > 0: meshes.append( PolylineSetMesh( - identifier=f"{get_obj_identifier(energyml_object)}_patch{patch_idx}", + identifier=f"{get_obj_uri(energyml_object)}_patch{patch_idx}", energyml_object=energyml_object, crs_object=crs, point_list=points, @@ -412,9 +425,9 @@ def gen_surface_grid_geometry( energyml_object: Any, patch: Any, patch_path: Any, - workspace: Optional[EnergymlWorkspace] = None, + workspace: Optional[EnergymlStorageInterface] = None, keep_holes=False, - sub_indices: List[int] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, offset: int = 0, ): points = read_grid2d_patch( @@ -463,7 +476,7 @@ def gen_surface_grid_geometry( sa_count = sa_count + 1 fa_count = fa_count + 1 - logging.debug(f"sa_count {sa_count} fa_count {fa_count} : {sa_count*fa_count} - {len(points)} ") + logging.debug(f"sa_count {sa_count} fa_count {fa_count} : {sa_count * fa_count} - {len(points)} ") for sa in range(sa_count - 1): for fa in range(fa_count - 1): @@ -511,7 +524,10 @@ def gen_surface_grid_geometry( def read_grid2d_representation( - energyml_object: Any, workspace: Optional[EnergymlWorkspace] = None, keep_holes=False, sub_indices: List[int] = None + energyml_object: Any, + workspace: Optional[EnergymlStorageInterface] = None, + keep_holes=False, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[SurfaceMesh]: # h5_reader = HDF5FileReader() meshes = [] @@ -549,7 +565,7 @@ def read_grid2d_representation( meshes.append( SurfaceMesh( - identifier=f"{get_obj_identifier(energyml_object)}_patch{patch_idx}", + identifier=f"{get_obj_uri(energyml_object)}_patch{patch_idx}", energyml_object=energyml_object, crs_object=crs, point_list=points, @@ -588,7 +604,7 @@ def read_grid2d_representation( ) meshes.append( SurfaceMesh( - identifier=f"{get_obj_identifier(energyml_object)}_patch{patch_idx}", + identifier=f"{get_obj_uri(energyml_object)}_patch{patch_idx}", energyml_object=energyml_object, crs_object=crs, point_list=points, @@ -601,8 +617,8 @@ def read_grid2d_representation( def read_triangulated_set_representation( energyml_object: Any, - workspace: EnergymlWorkspace, - sub_indices: List[int] = None, + workspace: EnergymlStorageInterface, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[SurfaceMesh]: meshes = [] @@ -667,7 +683,7 @@ def read_triangulated_set_representation( total_size = total_size + len(triangles_list) meshes.append( SurfaceMesh( - identifier=f"{get_obj_identifier(energyml_object)}_patch{patch_idx}", + identifier=f"{get_obj_uri(energyml_object)}_patch{patch_idx}", energyml_object=energyml_object, crs_object=crs, point_list=point_list, @@ -681,13 +697,15 @@ def read_triangulated_set_representation( def read_wellbore_frame_representation( - energyml_object: Any, workspace: EnergymlWorkspace, sub_indices: List[int] = None + energyml_object: Any, + workspace: EnergymlStorageInterface, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[PolylineSetMesh]: """ Read a WellboreFrameRepresentation and construct a polyline mesh from the trajectory. :param energyml_object: The WellboreFrameRepresentation object - :param workspace: The EnergymlWorkspace to access related objects + :param workspace: The EnergymlStorageInterface to access related objects :param sub_indices: Optional list of indices to filter specific nodes :return: List containing a single PolylineSetMesh representing the wellbore """ @@ -712,8 +730,8 @@ def read_wellbore_frame_representation( # Get trajectory reference trajectory_dor = search_attribute_matching_name(obj=energyml_object, name_rgx="Trajectory")[0] - trajectory_identifier = get_obj_identifier(trajectory_dor) - trajectory_obj = workspace.get_object_by_identifier(trajectory_identifier) + trajectory_identifier = get_obj_uri(trajectory_dor) + trajectory_obj = workspace.get_object(trajectory_identifier) if trajectory_obj is None: logging.error(f"Trajectory {trajectory_identifier} not found") @@ -738,8 +756,8 @@ def read_wellbore_frame_representation( pass if md_datum_dor is not None: - md_datum_identifier = get_obj_identifier(md_datum_dor) - md_datum_obj = workspace.get_object_by_identifier(md_datum_identifier) + md_datum_identifier = get_obj_uri(md_datum_dor) + md_datum_obj = workspace.get_object(md_datum_identifier) if md_datum_obj is not None: # Try to get coordinates from ReferencePointInACrs @@ -751,8 +769,8 @@ def read_wellbore_frame_representation( # Get vertical CRS to determine z direction try: vcrs_dor = search_attribute_matching_name(obj=md_datum_obj, name_rgx="VerticalCrs")[0] - vcrs_identifier = get_obj_identifier(vcrs_dor) - vcrs_obj = workspace.get_object_by_identifier(vcrs_identifier) + vcrs_identifier = get_obj_uri(vcrs_dor) + vcrs_obj = workspace.get_object(vcrs_identifier) if vcrs_obj is not None: z_is_up = not is_z_reversed(vcrs_obj) @@ -765,8 +783,8 @@ def read_wellbore_frame_representation( geometry_paths = search_attribute_matching_name_with_path(md_datum_obj, r"VerticalCrs") if len(geometry_paths) > 0: crs_dor_path, crs_dor = geometry_paths[0] - crs_identifier = get_obj_identifier(crs_dor) - crs = workspace.get_object_by_identifier(crs_identifier) + crs_identifier = get_obj_uri(crs_dor) + crs = workspace.get_object(crs_identifier) except Exception as e: logging.debug(f"Could not get CRS from trajectory: {e}") except Exception as e: @@ -809,7 +827,7 @@ def read_wellbore_frame_representation( if len(points) > 0: meshes.append( PolylineSetMesh( - identifier=f"{get_obj_identifier(energyml_object)}_wellbore", + identifier=f"{get_obj_uri(energyml_object)}_wellbore", energyml_object=energyml_object, crs_object=crs, point_list=points, @@ -828,14 +846,14 @@ def read_wellbore_frame_representation( def read_sub_representation( energyml_object: Any, - workspace: EnergymlWorkspace, - sub_indices: List[int] = None, + workspace: EnergymlStorageInterface, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[AbstractMesh]: supporting_rep_dor = search_attribute_matching_name( obj=energyml_object, name_rgx=r"(SupportingRepresentation|RepresentedObject)" )[0] - supporting_rep_identifier = get_obj_identifier(supporting_rep_dor) - supporting_rep = workspace.get_object_by_identifier(supporting_rep_identifier) + supporting_rep_identifier = get_obj_uri(supporting_rep_dor) + supporting_rep = workspace.get_object(supporting_rep_identifier) total_size = 0 all_indices = None @@ -877,7 +895,7 @@ def read_sub_representation( ) for m in meshes: - m.identifier = f"sub representation {get_obj_identifier(energyml_object)} of {m.identifier}" + m.identifier = f"sub representation {get_obj_uri(energyml_object)} of {m.identifier}" return meshes diff --git a/energyml-utils/src/energyml/utils/storage_interface.py b/energyml-utils/src/energyml/utils/storage_interface.py index 77ececb..a648455 100644 --- a/energyml-utils/src/energyml/utils/storage_interface.py +++ b/energyml-utils/src/energyml/utils/storage_interface.py @@ -369,30 +369,30 @@ def __init__(self, epc: "Epc"): # noqa: F821 def get_object(self, identifier: Union[str, Uri]) -> Optional[Any]: """Retrieve an object by identifier from EPC.""" - if self._closed: - raise RuntimeError("Storage is closed") + # if self._closed: + # raise RuntimeError("Storage is closed") # Convert URI to identifier if needed if isinstance(identifier, Uri): identifier = f"{identifier.uuid}.{identifier.version}" if identifier.version else identifier.uuid elif isinstance(identifier, str) and identifier.startswith("eml://"): parsed = parse_uri(identifier) - identifier = f"{parsed.uuid}.{parsed.version}" if parsed.version else parsed.uuid + identifier = f"{parsed.uuid}.{parsed.version}" if parsed.version else "" return self.epc.get_object_by_identifier(identifier) def get_object_by_uuid(self, uuid: str) -> List[Any]: """Retrieve all objects with the given UUID.""" - if self._closed: - raise RuntimeError("Storage is closed") + # if self._closed: + # raise RuntimeError("Storage is closed") result = self.epc.get_object_by_uuid(uuid) return result if isinstance(result, list) else [result] if result else [] def put_object(self, obj: Any, dataspace: Optional[str] = None) -> Optional[str]: """Store an object in EPC.""" - if self._closed: - raise RuntimeError("Storage is closed") + # if self._closed: + # raise RuntimeError("Storage is closed") try: # Check if object already exists @@ -412,8 +412,8 @@ def put_object(self, obj: Any, dataspace: Optional[str] = None) -> Optional[str] def delete_object(self, identifier: Union[str, Uri]) -> bool: """Delete an object from EPC.""" - if self._closed: - raise RuntimeError("Storage is closed") + # if self._closed: + # raise RuntimeError("Storage is closed") try: obj = self.get_object(identifier) @@ -427,8 +427,8 @@ def delete_object(self, identifier: Union[str, Uri]) -> bool: def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Optional[np.ndarray]: """Read array from HDF5 file associated with EPC.""" - if self._closed: - raise RuntimeError("Storage is closed") + # if self._closed: + # raise RuntimeError("Storage is closed") # Get object if proxy is identifier if isinstance(proxy, (str, Uri)): @@ -445,8 +445,8 @@ def write_array( array: np.ndarray, ) -> bool: """Write array to HDF5 file associated with EPC.""" - if self._closed: - raise RuntimeError("Storage is closed") + # if self._closed: + # raise RuntimeError("Storage is closed") # Get object if proxy is identifier if isinstance(proxy, (str, Uri)): @@ -460,8 +460,8 @@ def get_array_metadata( self, proxy: Union[str, Uri, Any], path_in_external: Optional[str] = None ) -> Union[DataArrayMetadata, List[DataArrayMetadata], None]: """Get array metadata (limited support for EPC).""" - if self._closed: - raise RuntimeError("Storage is closed") + # if self._closed: + # raise RuntimeError("Storage is closed") # EPC doesn't have native array metadata support # We can try to read the array and infer metadata @@ -487,8 +487,8 @@ def list_objects( self, dataspace: Optional[str] = None, object_type: Optional[str] = None ) -> List[ResourceMetadata]: """List all objects with metadata.""" - if self._closed: - raise RuntimeError("Storage is closed") + # if self._closed: + # raise RuntimeError("Storage is closed") results = [] for obj in self.epc.energyml_objects: @@ -509,9 +509,11 @@ def list_objects( title = obj.citation.title # Build URI - uri = f"eml:///{uuid}" + qualified_type = content_type_to_qualified_type(content_type) if version: - uri += f".{version}" + uri = f"eml:///{qualified_type}(uuid={uuid},version='{version}')" + else: + uri = f"eml:///{qualified_type}({uuid})" metadata = ResourceMetadata( uri=uri, @@ -540,8 +542,8 @@ def save(self, file_path: Optional[str] = None) -> None: Args: file_path: Optional path to save to. If None, uses epc.epc_file_path """ - if self._closed: - raise RuntimeError("Storage is closed") + # if self._closed: + # raise RuntimeError("Storage is closed") self.epc.export_file(file_path) @@ -580,29 +582,29 @@ def __init__(self, stream_reader: "EpcStreamReader"): # noqa: F821 def get_object(self, identifier: Union[str, Uri]) -> Optional[Any]: """Retrieve an object by identifier from EPC stream.""" - if self._closed: - raise RuntimeError("Storage is closed") + # if self._closed: + # raise RuntimeError("Storage is closed") # Convert URI to identifier if needed if isinstance(identifier, Uri): - identifier = f"{identifier.uuid}.{identifier.version}" if identifier.version else identifier.uuid + identifier = f"{identifier.uuid}.{identifier.version or ''}" elif isinstance(identifier, str) and identifier.startswith("eml://"): parsed = parse_uri(identifier) - identifier = f"{parsed.uuid}.{parsed.version}" if parsed.version else parsed.uuid + identifier = f"{parsed.uuid}.{parsed.version or ''}" return self.stream_reader.get_object_by_identifier(identifier) def get_object_by_uuid(self, uuid: str) -> List[Any]: """Retrieve all objects with the given UUID.""" - if self._closed: - raise RuntimeError("Storage is closed") + # if self._closed: + # raise RuntimeError("Storage is closed") return self.stream_reader.get_object_by_uuid(uuid) def put_object(self, obj: Any, dataspace: Optional[str] = None) -> Optional[str]: """Store an object in EPC stream.""" - if self._closed: - raise RuntimeError("Storage is closed") + # if self._closed: + # raise RuntimeError("Storage is closed") try: return self.stream_reader.add_object(obj, replace_if_exists=True) @@ -612,8 +614,8 @@ def put_object(self, obj: Any, dataspace: Optional[str] = None) -> Optional[str] def delete_object(self, identifier: Union[str, Uri]) -> bool: """Delete an object from EPC stream.""" - if self._closed: - raise RuntimeError("Storage is closed") + # if self._closed: + # raise RuntimeError("Storage is closed") try: return self.stream_reader.remove_object(identifier) @@ -635,8 +637,8 @@ def write_array( array: np.ndarray, ) -> bool: """Write array to HDF5 file associated with EPC stream.""" - if self._closed: - raise RuntimeError("Storage is closed") + # if self._closed: + # raise RuntimeError("Storage is closed") return self.stream_reader.write_array(proxy, path_in_external, array) @@ -644,8 +646,8 @@ def get_array_metadata( self, proxy: Union[str, Uri, Any], path_in_external: Optional[str] = None ) -> Union[DataArrayMetadata, List[DataArrayMetadata], None]: """Get array metadata (limited support for EPC Stream).""" - if self._closed: - raise RuntimeError("Storage is closed") + # if self._closed: + # raise RuntimeError("Storage is closed") # EPC Stream doesn't have native array metadata support # We can try to read the array and infer metadata @@ -671,8 +673,8 @@ def list_objects( self, dataspace: Optional[str] = None, object_type: Optional[str] = None ) -> List[ResourceMetadata]: """List all objects with metadata.""" - if self._closed: - raise RuntimeError("Storage is closed") + # if self._closed: + # raise RuntimeError("Storage is closed") results = [] metadata_list = self.stream_reader.list_object_metadata(object_type) @@ -680,22 +682,24 @@ def list_objects( for meta in metadata_list: try: # Load object to get title - obj = self.stream_reader.get_object_by_identifier(meta.identifier) - title = "Unknown" - if obj and hasattr(obj, "citation") and obj.citation: - if hasattr(obj.citation, "title"): - title = obj.citation.title + # obj = self.stream_reader.get_object_by_identifier(meta.identifier) + # title = "Unknown" + # if obj and hasattr(obj, "citation") and obj.citation: + # if hasattr(obj.citation, "title"): + # title = obj.citation.title # Build URI - uri = f"eml:///{meta.uuid}" + qualified_type = content_type_to_qualified_type(meta.content_type) if meta.version: - uri += f".{meta.version}" + uri = f"eml:///{qualified_type}(uuid={meta.uuid},version='{meta.version}')" + else: + uri = f"eml:///{qualified_type}({meta.uuid})" resource = ResourceMetadata( uri=uri, uuid=meta.uuid, version=meta.version, - title=title, + title="", # we do not fill the title to avoid loading the object object_type=meta.object_type, content_type=meta.content_type, ) @@ -718,8 +722,8 @@ def get_statistics(self) -> Dict[str, Any]: Returns: Dictionary with cache statistics and performance metrics """ - if self._closed: - raise RuntimeError("Storage is closed") + # if self._closed: + # raise RuntimeError("Storage is closed") stats = self.stream_reader.get_statistics() return { From 2d5f0a3a464c8ce7fc08496588e9dcf40193da5f Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Mon, 8 Dec 2025 14:20:57 +0100 Subject: [PATCH 06/70] reshapes for new energymlStorage interface --- energyml-utils/.flake8 | 2 +- energyml-utils/example/main.py | 78 ++- energyml-utils/example/main_data.py | 23 +- energyml-utils/example/main_datasets.py | 8 +- energyml-utils/example/main_hdf.py | 37 -- energyml-utils/example/main_stream.py | 10 +- energyml-utils/example/main_test_3D.py | 70 ++- .../example/storage_interface_example.py | 229 -------- energyml-utils/example/tools.py | 6 +- .../src/energyml/utils/data/helper.py | 3 +- .../src/energyml/utils/data/mesh.py | 14 +- energyml-utils/src/energyml/utils/epc.py | 59 +- .../src/energyml/utils/epc_stream.py | 132 ++++- .../src/energyml/utils/introspection.py | 38 +- energyml-utils/src/energyml/utils/manager.py | 4 +- .../src/energyml/utils/storage_interface.py | 530 ++---------------- .../src/energyml/utils/workspace.py | 45 -- energyml-utils/tests/test_epc.py | 4 +- 18 files changed, 424 insertions(+), 868 deletions(-) delete mode 100644 energyml-utils/example/main_hdf.py delete mode 100644 energyml-utils/example/storage_interface_example.py delete mode 100644 energyml-utils/src/energyml/utils/workspace.py diff --git a/energyml-utils/.flake8 b/energyml-utils/.flake8 index 07de32c..4830dae 100644 --- a/energyml-utils/.flake8 +++ b/energyml-utils/.flake8 @@ -1,6 +1,6 @@ [flake8] # Ignore specific error codes (comma-separated list) -ignore = E501, E722, W503, F403, E203, E202 +ignore = E501, E722, W503, F403, E203, E202, E402 # Max line length (default is 79, can be changed) max-line-length = 120 diff --git a/energyml-utils/example/main.py b/energyml-utils/example/main.py index 6301e7c..4313ed5 100644 --- a/energyml-utils/example/main.py +++ b/energyml-utils/example/main.py @@ -1,14 +1,27 @@ # Copyright (c) 2023-2024 Geosiris. # SPDX-License-Identifier: Apache-2.0 import sys +import logging from pathlib import Path import re from dataclasses import fields +from energyml.utils.constants import ( + RGX_CONTENT_TYPE, + EpcExportVersion, + date_to_epoch, + epoch, + epoch_to_date, + gen_uuid, + get_domain_version_from_content_or_qualified_type, + parse_content_or_qualified_type, + parse_content_type, +) + src_path = Path(__file__).parent.parent / "src" sys.path.insert(0, str(src_path)) -from energyml.eml.v2_3.commonv2 import * +from energyml.eml.v2_3.commonv2 import Citation, DataObjectReference, ExistenceKind, Activity from energyml.eml.v2_3.commonv2 import AbstractObject from energyml.resqml.v2_0_1.resqmlv2 import DoubleHdf5Array from energyml.resqml.v2_0_1.resqmlv2 import TriangulatedSetRepresentation as Tr20 @@ -22,17 +35,70 @@ # from src.energyml.utils.data.hdf import * from energyml.utils.data.helper import get_projected_uom, is_z_reversed -from energyml.utils.epc import * -from energyml.utils.introspection import * -from energyml.utils.manager import * -from energyml.utils.serialization import * +from energyml.utils.epc import ( + Epc, + EPCRelsRelationshipType, + as_dor, + create_energyml_object, + create_external_part_reference, + gen_energyml_object_path, + get_reverse_dor_list, +) +from energyml.utils.introspection import ( + class_match_rgx, + copy_attributes, + get_class_attributes, + get_class_fields, + get_class_from_content_type, + get_class_from_name, + get_class_from_qualified_type, + get_class_methods, + get_content_type_from_class, + get_obj_pkg_pkgv_type_uuid_version, + get_obj_uri, + get_object_attribute, + get_obj_uuid, + get_object_attribute_rgx, + get_qualified_type_from_class, + is_abstract, + is_primitive, + random_value_from_class, + search_attribute_matching_name, + search_attribute_matching_name_with_path, + search_attribute_matching_type, + search_attribute_matching_type_with_path, +) +from energyml.utils.manager import ( + # create_energyml_object, + # create_external_part_reference, + dict_energyml_modules, + get_class_pkg, + get_class_pkg_version, + get_classes_matching_name, + get_sub_classes, + list_energyml_modules, +) +from energyml.utils.serialization import ( + read_energyml_xml_file, + read_energyml_xml_str, + serialize_json, + JSON_VERSION, + serialize_xml, +) from energyml.utils.validation import ( patterns_validation, dor_validation, validate_epc, correct_dor, ) -from energyml.utils.xml import * +from energyml.utils.xml import ( + find_schema_version_in_element, + get_class_name_from_xml, + get_root_namespace, + get_root_type, + get_tree, + get_xml_encoding, +) from energyml.utils.data.datasets_io import HDF5FileReader, get_path_in_external_with_path fi_cit = Citation( diff --git a/energyml-utils/example/main_data.py b/energyml-utils/example/main_data.py index 78061de..52ff8ee 100644 --- a/energyml-utils/example/main_data.py +++ b/energyml-utils/example/main_data.py @@ -1,6 +1,7 @@ # Copyright (c) 2023-2024 Geosiris. # SPDX-License-Identifier: Apache-2.0 import logging +from io import BytesIO from energyml.eml.v2_3.commonv2 import ( JaggedArray, AbstractValueArray, @@ -8,15 +9,27 @@ StringXmlArray, IntegerXmlArray, ) +from energyml.utils.data.export import export_obj from src.energyml.utils.data.helper import ( get_array_reader_function, + read_array, +) +from src.energyml.utils.data.mesh import ( + GeoJsonGeometryType, + MeshFileFormat, + _create_shape, + _write_geojson_shape, + export_multiple_data, + export_off, + read_mesh_object, ) -from src.energyml.utils.data.mesh import _create_shape, _write_geojson_shape from src.energyml.utils.epc import gen_energyml_object_path from src.energyml.utils.introspection import ( + get_object_attribute, is_abstract, get_obj_uuid, + search_attribute_matching_name_with_path, ) from src.energyml.utils.manager import get_sub_classes from src.energyml.utils.serialization import ( @@ -33,7 +46,11 @@ get_external_file_path_from_external_path, ) from energyml.utils.epc import Epc -from src.energyml.utils.data.mesh import * +from src.energyml.utils.data.mesh import ( + read_polyline_representation, + read_point_representation, + read_grid2d_representation, +) logger = logging.getLogger(__name__) @@ -608,7 +625,7 @@ def test_simple_geojson(): ), ) - print(f"\n+++++++++++++++++++++++++\n") + print("\n+++++++++++++++++++++++++\n") def test_simple_geojson_io(): diff --git a/energyml-utils/example/main_datasets.py b/energyml-utils/example/main_datasets.py index edc1278..234ed43 100644 --- a/energyml-utils/example/main_datasets.py +++ b/energyml-utils/example/main_datasets.py @@ -1,15 +1,15 @@ # Copyright (c) 2023-2024 Geosiris. # SPDX-License-Identifier: Apache-2.0 -from src.energyml.utils.data.datasets_io import ( +from energyml.utils.data.datasets_io import ( ParquetFileReader, ParquetFileWriter, CSVFileReader, CSVFileWriter, read_dataset, ) -from utils.data.helper import read_array -from utils.introspection import search_attribute_matching_name_with_path -from utils.serialization import read_energyml_xml_file +from energyml.utils.data.helper import read_array +from energyml.utils.introspection import search_attribute_matching_name_with_path +from energyml.utils.serialization import read_energyml_xml_file def local_parquet(): diff --git a/energyml-utils/example/main_hdf.py b/energyml-utils/example/main_hdf.py deleted file mode 100644 index ac23ed4..0000000 --- a/energyml-utils/example/main_hdf.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright (c) 2023-2024 Geosiris. -# SPDX-License-Identifier: Apache-2.0 -import sys -from pathlib import Path - -# Add src directory to path -src_path = Path(__file__).parent.parent / "src" -sys.path.insert(0, str(src_path)) - -from energyml.utils.data.datasets_io import get_path_in_external_with_path -from energyml.utils.introspection import get_obj_uri - - -if __name__ == "__main__": - from energyml.utils.epc import Epc - - # Create an EPC file - epc = Epc.read_file("wip/BRGM_AVRE_all_march_25.epc") - - print("\n".join(map(lambda o: str(get_obj_uri(o)), epc.energyml_objects))) - - print(epc.get_h5_file_paths("eml:///resqml22.PolylineSetRepresentation(e75db94d-a251-4f31-8a24-23b9573fbf39)")) - - print( - get_path_in_external_with_path( - epc.get_object_by_identifier( - "eml:///resqml22.PolylineSetRepresentation(e75db94d-a251-4f31-8a24-23b9573fbf39)" - ) - ) - ) - - print( - epc.read_h5_dataset( - "eml:///resqml22.PolylineSetRepresentation(e75db94d-a251-4f31-8a24-23b9573fbf39)", - "/RESQML/e75db94d-a251-4f31-8a24-23b9573fbf39/points_patch0", - ) - ) diff --git a/energyml-utils/example/main_stream.py b/energyml-utils/example/main_stream.py index b1a712a..87f529a 100644 --- a/energyml-utils/example/main_stream.py +++ b/energyml-utils/example/main_stream.py @@ -24,12 +24,13 @@ from energyml.utils.serialization import serialize_json +from energyml.resqml.v2_2.resqmlv2 import TriangulatedSetRepresentation, ContactElement +from energyml.eml.v2_3.commonv2 import DataObjectReference + + def test_epc_stream_main(): logging.basicConfig(level=logging.DEBUG) - from energyml.resqml.v2_2.resqmlv2 import TriangulatedSetRepresentation, ContactElement - from energyml.eml.v2_3.commonv2 import DataObjectReference - # Use the test EPC file test_epc = "wip/my_stream_file.epc" @@ -115,9 +116,6 @@ def test_epc_stream_main(): def test_epc_im_main(): logging.basicConfig(level=logging.DEBUG) - from energyml.resqml.v2_2.resqmlv2 import TriangulatedSetRepresentation, ContactElement - from energyml.eml.v2_3.commonv2 import DataObjectReference - # Use the test EPC file test_epc = "wip/my_stream_file.epc" diff --git a/energyml-utils/example/main_test_3D.py b/energyml-utils/example/main_test_3D.py index 84f7113..0657bdf 100644 --- a/energyml-utils/example/main_test_3D.py +++ b/energyml-utils/example/main_test_3D.py @@ -5,19 +5,78 @@ import datetime from pathlib import Path import traceback +from typing import Optional from energyml.utils.data.export import export_obj, export_stl, export_vtk from energyml.utils.data.mesh import read_mesh_object from energyml.utils.epc_stream import EpcStreamReader -from energyml.utils.storage_interface import EPCStreamStorage +from energyml.utils.epc import Epc from energyml.utils.exception import NotSupportedError -def export_all_representation(epc_path: str, output_dir: str, regex_type_filter: str = None): +def export_all_representation(epc_path: str, output_dir: str, regex_type_filter: Optional[str] = None): - _epc = EpcStreamReader(epc_path, keep_open=True) - storage = EPCStreamStorage(stream_reader=_epc) + storage = EpcStreamReader(epc_path, keep_open=True) + + dt = datetime.datetime.now().strftime("%Hh%M_%d-%m-%Y") + not_supported_types = set() + for mdata in storage.list_objects(): + if "Representation" in mdata.object_type and ( + regex_type_filter is None + or len(regex_type_filter) == 0 + or re.search(regex_type_filter, mdata.object_type, flags=re.IGNORECASE) + ): + logging.info(f"Exporting representation: {mdata.object_type} ({mdata.uuid})") + energyml_obj = storage.get_object_by_uuid(mdata.uuid)[0] + try: + mesh_list = read_mesh_object( + energyml_object=energyml_obj, + workspace=storage, + use_crs_displacement=True, + ) + + os.makedirs(output_dir, exist_ok=True) + + path = Path(output_dir) / f"{dt}-{mdata.object_type}{mdata.uuid}_mesh.obj" + with path.open("wb") as f: + export_obj( + mesh_list=mesh_list, + out=f, + ) + export_stl_path = path.with_suffix(".stl") + with export_stl_path.open("wb") as stl_f: + export_stl( + mesh_list=mesh_list, + out=stl_f, + ) + export_vtk_path = path.with_suffix(".vtk") + with export_vtk_path.open("wb") as vtk_f: + export_vtk( + mesh_list=mesh_list, + out=vtk_f, + ) + + logging.info(f" ✓ Exported to {path.name}") + except NotSupportedError: + # print(f" ✗ Not supported: {e}") + not_supported_types.add(mdata.object_type) + except Exception: + traceback.print_exc() + + logging.info("Export completed.") + if not_supported_types: + logging.info("Not supported representation types encountered:") + for t in not_supported_types: + logging.info(f" - {t}") + + +def export_all_representation_in_memory(epc_path: str, output_dir: str, regex_type_filter: Optional[str] = None): + + storage = Epc.read_file(epc_path) + if storage is None: + logging.error(f"Failed to read EPC file: {epc_path}") + return dt = datetime.datetime.now().strftime("%Hh%M_%d-%m-%Y") not_supported_types = set() @@ -82,4 +141,5 @@ def export_all_representation(epc_path: str, output_dir: str, regex_type_filter: output_directory = Path("exported_meshes") / Path(epc_file).name.replace(".epc", "_3D_export") # export_all_representation(epc_file, output_directory) # export_all_representation(epc_file, output_directory, regex_type_filter="Wellbore") - export_all_representation(epc_file, str(output_directory), regex_type_filter="") + # export_all_representation(epc_file, str(output_directory), regex_type_filter="") + export_all_representation_in_memory(epc_file, str(output_directory), regex_type_filter="") diff --git a/energyml-utils/example/storage_interface_example.py b/energyml-utils/example/storage_interface_example.py deleted file mode 100644 index 54326dc..0000000 --- a/energyml-utils/example/storage_interface_example.py +++ /dev/null @@ -1,229 +0,0 @@ -#!/usr/bin/env python -# Copyright (c) 2023-2024 Geosiris. -# SPDX-License-Identifier: Apache-2.0 -""" -Example usage of the unified storage interface. - -This example demonstrates how to use the EnergymlStorageInterface to work -with EPC files in both regular and streaming modes, using the same API. -""" -import sys -from pathlib import Path - -# Add src directory to path -src_path = Path(__file__).parent.parent / "src" -sys.path.insert(0, str(src_path)) - -from energyml.utils.storage_interface import create_storage - - -def example_regular_epc(): - """Example using regular EPC storage (loads everything into memory)""" - print("=" * 60) - print("Example 1: Regular EPC Storage") - print("=" * 60) - - # Create storage from file path - with create_storage("example.epc", stream_mode=False) as storage: - # List all objects - print("\nListing all objects:") - for metadata in storage.list_objects(): - print(f" - {metadata.title} ({metadata.object_type})") - print(f" UUID: {metadata.uuid}") - print(f" URI: {metadata.uri}") - - # Get a specific object - objects = storage.list_objects() - if objects: - first_obj = storage.get_object(objects[0].identifier) - print(f"\nRetrieved object: {objects[0].title}") - - # Try to read an array - try: - array = storage.read_array(first_obj, "values/0") - if array is not None: - print(f" Array shape: {array.shape}") - print(f" Array dtype: {array.dtype}") - except Exception as e: - print(f" No array data or error: {e}") - - -def example_streaming_epc(): - """Example using streaming EPC storage (memory efficient for large files)""" - print("\n" + "=" * 60) - print("Example 2: Streaming EPC Storage (Memory Efficient)") - print("=" * 60) - - # Create storage in streaming mode - with create_storage("example.epc", stream_mode=True, cache_size=50) as storage: - # List all objects (metadata loaded, objects loaded on-demand) - print("\nListing all objects (lazy loading):") - all_metadata = storage.list_objects() - print(f"Total objects: {len(all_metadata)}") - - for metadata in all_metadata[:5]: # Show first 5 - print(f" - {metadata.title} ({metadata.object_type})") - - if len(all_metadata) > 5: - print(f" ... and {len(all_metadata) - 5} more") - - # Get statistics (only available in streaming mode) - if hasattr(storage, "get_statistics"): - stats = storage.get_statistics() - print("\nCache statistics:") - print(f" Total objects: {stats['total_objects']}") - print(f" Loaded objects: {stats['loaded_objects']}") - print(f" Cache hits: {stats['cache_hits']}") - print(f" Cache misses: {stats['cache_misses']}") - if stats["cache_hit_ratio"] is not None: - print(f" Cache hit ratio: {stats['cache_hit_ratio']:.2%}") - - -def example_filtering(): - """Example of filtering objects by type""" - print("\n" + "=" * 60) - print("Example 3: Filtering Objects by Type") - print("=" * 60) - - with create_storage("example.epc") as storage: - # List only specific object types - print("\nListing Grid2dRepresentation objects:") - grid_objects = storage.list_objects(object_type="resqml20.obj_Grid2dRepresentation") - - for metadata in grid_objects: - print(f" - {metadata.title}") - print(f" UUID: {metadata.uuid}") - - -def example_crud_operations(): - """Example of Create, Read, Update, Delete operations""" - print("\n" + "=" * 60) - print("Example 4: CRUD Operations") - print("=" * 60) - - with create_storage("example.epc") as storage: - # Get an object - objects = storage.list_objects() - if not objects: - print("No objects in EPC file") - return - - print(f"\nTotal objects: {len(objects)}") - - # Read - first_metadata = objects[0] - obj = storage.get_object(first_metadata.identifier) - print(f"\nRead object: {first_metadata.title}") - print(f"Object type: {type(obj).__name__}") - - # Get by UUID (may return multiple versions) - uuid_objects = storage.get_object_by_uuid(first_metadata.uuid) - print(f"Objects with UUID {first_metadata.uuid}: {len(uuid_objects)}") - - # Update would be: modify obj, then storage.put_object(obj) - # Delete would be: storage.delete_object(identifier) - - # Save changes (if using EPCStorage) - if hasattr(storage, "save"): - # storage.save("modified_example.epc") - print("\nChanges can be saved with storage.save()") - - -def example_array_operations(): - """Example of working with data arrays""" - print("\n" + "=" * 60) - print("Example 5: Array Operations") - print("=" * 60) - - with create_storage("example.epc") as storage: - objects = storage.list_objects() - - for metadata in objects: - obj = storage.get_object(metadata.identifier) - - # Try to get array metadata - try: - array_meta = storage.get_array_metadata(obj, "values/0") - if array_meta: - print(f"\nObject: {metadata.title}") - print(f" Array path: {array_meta.path_in_resource}") - print(f" Array type: {array_meta.array_type}") - print(f" Dimensions: {array_meta.dimensions}") - print(f" Total elements: {array_meta.size}") - - # Read the array - array = storage.read_array(obj, "values/0") - if array is not None: - print(f" Actual shape: {array.shape}") - print(f" Min/Max: {array.min():.2f} / {array.max():.2f}") - - break # Only show first array - except Exception: - continue - - -def example_comparison(): - """Compare regular vs streaming mode""" - print("\n" + "=" * 60) - print("Example 6: Regular vs Streaming Comparison") - print("=" * 60) - - import time - - file_path = "example.epc" - - # Regular mode - start = time.time() - with create_storage(file_path, stream_mode=False) as storage: - metadata_list = storage.list_objects() - regular_count = len(metadata_list) - regular_time = time.time() - start - - # Streaming mode - start = time.time() - with create_storage(file_path, stream_mode=True, cache_size=10) as storage: - metadata_list = storage.list_objects() - stream_count = len(metadata_list) - stream_time = time.time() - start - - print("\nRegular mode:") - print(f" Objects: {regular_count}") - print(f" Load time: {regular_time:.4f}s") - - print("\nStreaming mode:") - print(f" Objects: {stream_count}") - print(f" Load time: {stream_time:.4f}s") - - if stream_time < regular_time: - speedup = regular_time / stream_time - print(f"\nStreaming mode is {speedup:.2f}x faster for metadata loading!") - else: - print("\nRegular mode was faster (small file, overhead dominates)") - - -if __name__ == "__main__": - print("Unified Storage Interface Examples") - print("=" * 60) - print("\nNote: These examples require an 'example.epc' file to exist.") - print("Modify the file paths as needed for your environment.\n") - - try: - example_regular_epc() - example_streaming_epc() - example_filtering() - example_crud_operations() - example_array_operations() - example_comparison() - - print("\n" + "=" * 60) - print("All examples completed successfully!") - print("=" * 60) - - except FileNotFoundError as e: - print(f"\nError: {e}") - print("Please provide a valid EPC file path.") - except Exception as e: - print(f"\nError running examples: {e}") - import traceback - - traceback.print_exc() diff --git a/energyml-utils/example/tools.py b/energyml-utils/example/tools.py index 3c889ba..20dfe69 100644 --- a/energyml-utils/example/tools.py +++ b/energyml-utils/example/tools.py @@ -291,7 +291,7 @@ def generate_data(): "-ff", type=str, default="json", - help=f"Type of the output files (one of : ['json', 'xml']). Default is 'json'", + help="Type of the output files (one of : ['json', 'xml']). Default is 'json'", ) args = parser.parse_args() @@ -413,7 +413,7 @@ def xml_to_json(): def json_to_xml(): parser = argparse.ArgumentParser() parser.add_argument("--file", "-f", type=str, help="Input File") - parser.add_argument("--out", "-o", type=str, default=None, help=f"Output file") + parser.add_argument("--out", "-o", type=str, default=None, help="Output file") args = parser.parse_args() @@ -436,7 +436,7 @@ def json_to_xml(): def json_to_epc(): parser = argparse.ArgumentParser() parser.add_argument("--file", "-f", type=str, help="Input File") - parser.add_argument("--out", "-o", type=str, default=None, help=f"Output EPC file") + parser.add_argument("--out", "-o", type=str, default=None, help="Output EPC file") args = parser.parse_args() diff --git a/energyml-utils/src/energyml/utils/data/helper.py b/energyml-utils/src/energyml/utils/data/helper.py index 3b5ee6a..9ebde1d 100644 --- a/energyml-utils/src/energyml/utils/data/helper.py +++ b/energyml-utils/src/energyml/utils/data/helper.py @@ -23,7 +23,6 @@ get_object_attribute_rgx, ) -# from ..workspace import EnergymlWorkspace from .datasets_io import get_path_in_external_with_path _ARRAY_NAMES_ = [ @@ -759,7 +758,7 @@ def read_point3d_lattice_array( logging.debug(f"fastest vector: {fastest_vec}, spacing: {fastest_spacing}, size: {fastest_size}") logging.debug(f"origin: {origin}, zincreasing_downward: {zincreasing_downward}") - if len(crs_sa_count) > 0 and len(crs_fa_count) > 0: + if crs_sa_count is not None and len(crs_sa_count) > 0 and crs_fa_count is not None and len(crs_fa_count) > 0: if (crs_sa_count[0] == fastest_size and crs_fa_count[0] == slowest_size) or ( crs_sa_count[0] == fastest_size - 1 and crs_fa_count[0] == slowest_size - 1 ): diff --git a/energyml-utils/src/energyml/utils/data/mesh.py b/energyml-utils/src/energyml/utils/data/mesh.py index 32f02b8..108da7e 100644 --- a/energyml-utils/src/energyml/utils/data/mesh.py +++ b/energyml-utils/src/energyml/utils/data/mesh.py @@ -20,10 +20,10 @@ get_crs_origin_offset, is_z_reversed, ) -from ..epc import Epc, gen_energyml_object_path -from ..epc_stream import EpcStreamReader -from ..exception import NotSupportedError, ObjectNotFoundNotError -from ..introspection import ( +from energyml.utils.epc import gen_energyml_object_path +from energyml.utils.epc_stream import EpcStreamReader +from energyml.utils.exception import NotSupportedError, ObjectNotFoundNotError +from energyml.utils.introspection import ( get_obj_uri, search_attribute_matching_name, search_attribute_matching_name_with_path, @@ -31,7 +31,7 @@ get_object_attribute, get_object_attribute_rgx, ) -from energyml.utils.storage_interface import EPCStorage, EPCStreamStorage, EnergymlStorageInterface +from energyml.utils.storage_interface import EnergymlStorageInterface # Import export functions from new export module for backward compatibility @@ -212,10 +212,6 @@ def read_mesh_object( is used to translate the data with the CRS offsets :return: """ - if isinstance(workspace, EpcStreamReader): - workspace = EPCStreamStorage(stream_reader=workspace) - elif isinstance(workspace, Epc): - workspace = EPCStorage(epc=workspace) if isinstance(energyml_object, list): return energyml_object diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index 28e7c1b..296d87b 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -30,6 +30,7 @@ Keywords1, TargetMode, ) +from energyml.utils.storage_interface import DataArrayMetadata, EnergymlStorageInterface, ResourceMetadata import numpy as np from .uri import Uri, parse_uri from xsdata.formats.dataclass.models.generics import DerivedElement @@ -87,12 +88,11 @@ read_energyml_json_bytes, JSON_VERSION, ) -from .workspace import EnergymlWorkspace from .xml import is_energyml_content_type @dataclass -class Epc(EnergymlWorkspace): +class Epc(EnergymlStorageInterface): """ A class that represent an EPC file content """ @@ -452,8 +452,6 @@ def get_h5_file_paths(self, obj: Any) -> List[str]: h5_paths.add(possible_h5_path) return list(h5_paths) - # -- Functions inherited from EnergymlWorkspace - def get_object_as_dor(self, identifier: str, dor_qualified_type) -> Optional[Any]: """ Search an object by its identifier and returns a DOR @@ -487,8 +485,8 @@ def get_object_by_identifier(self, identifier: Union[str, Uri]) -> Optional[Any] return o return None - def get_object(self, uuid: str, object_version: Optional[str]) -> Optional[Any]: - return self.get_object_by_identifier(f"{uuid}.{object_version or ''}") + def get_object(self, identifier: Union[str, Uri]) -> Optional[Any]: + return self.get_object_by_identifier(identifier) def add_object(self, obj: Any) -> bool: """ @@ -634,11 +632,12 @@ def write_array( # Class methods @classmethod - def read_file(cls, epc_file_path: str): + def read_file(cls, epc_file_path: str) -> "Epc": with open(epc_file_path, "rb") as f: epc = cls.read_stream(BytesIO(f.read())) epc.epc_file_path = epc_file_path return epc + raise IOError(f"Failed to open EPC file {epc_file_path}") @classmethod def read_stream(cls, epc_file_io: BytesIO): # returns an Epc instance @@ -770,6 +769,45 @@ def read_stream(cls, epc_file_io: BytesIO): # returns an Epc instance return None + def list_objects(self, dataspace: str | None = None, object_type: str | None = None) -> List[ResourceMetadata]: + result = [] + for obj in self.energyml_objects: + if (dataspace is None or get_obj_type(get_obj_usable_class(obj)) == dataspace) and ( + object_type is None or get_qualified_type_from_class(type(obj)) == object_type + ): + res_meta = ResourceMetadata( + uri=str(get_obj_uri(obj)), + uuid=get_obj_uuid(obj), + title=get_object_attribute(obj, "citation.title") or "", + object_type=type(obj).__name__, + version=get_obj_version(obj), + content_type=get_content_type_from_class(type(obj)) or "", + ) + result.append(res_meta) + return result + + def put_object(self, obj: Any, dataspace: str | None = None) -> str | None: + if self.add_object(obj): + return str(get_obj_uri(obj)) + return None + + def delete_object(self, identifier: Union[str, Any]) -> bool: + obj = self.get_object_by_identifier(identifier) + if obj is not None: + self.remove_object(identifier) + return True + return False + + def get_array_metadata( + self, proxy: str | Uri | Any, path_in_external: str | None = None + ) -> DataArrayMetadata | List[DataArrayMetadata] | None: + array = self.read_array(proxy=proxy, path_in_external=path_in_external) + if array is not None: + if isinstance(array, np.ndarray): + return DataArrayMetadata.from_numpy_array(path_in_resource=path_in_external, array=array) + elif isinstance(array, list): + return DataArrayMetadata.from_list(path_in_resource=path_in_external, data=array) + def dumps_epc_content_and_files_lists(self) -> str: """ Dumps the EPC content and files lists for debugging purposes. @@ -782,6 +820,13 @@ def dumps_epc_content_and_files_lists(self) -> str: return "EPC Content:\n" + "\n".join(content_list) + "\n\nRaw Files:\n" + "\n".join(raw_files_list) + def close(self) -> None: + """ + Close the EPC file and release any resources. + :return: + """ + pass + # ______ __ ____ __ _ # / ____/___ ___ _________ ___ ______ ___ / / / __/_ ______ _____/ /_(_)___ ____ _____ diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index 93158e0..6ffc103 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -21,12 +21,18 @@ from energyml.opc.opc import Types, Override, CoreProperties, Relationships, Relationship from energyml.utils.data.datasets_io import HDF5FileReader, HDF5FileWriter +from energyml.utils.storage_interface import DataArrayMetadata, EnergymlStorageInterface, ResourceMetadata from energyml.utils.uri import Uri, parse_uri -from energyml.utils.workspace import EnergymlWorkspace import numpy as np -from .constants import EPCRelsRelationshipType, OptimizedRegex, EpcExportVersion -from .epc import Epc, gen_energyml_object_path, gen_rels_path, get_epc_content_type_path -from .introspection import ( +from energyml.utils.constants import ( + EPCRelsRelationshipType, + OptimizedRegex, + EpcExportVersion, + content_type_to_qualified_type, +) +from energyml.utils.epc import Epc, gen_energyml_object_path, gen_rels_path, get_epc_content_type_path + +from energyml.utils.introspection import ( get_class_from_content_type, get_obj_content_type, get_obj_identifier, @@ -36,7 +42,7 @@ get_obj_type, get_obj_usable_class, ) -from .serialization import read_energyml_xml_bytes, serialize_xml +from energyml.utils.serialization import read_energyml_xml_bytes, serialize_xml from .xml import is_energyml_content_type @@ -48,8 +54,8 @@ class EpcObjectMetadata: object_type: str content_type: str file_path: str - version: Optional[str] = None identifier: Optional[str] = None + version: Optional[str] = None def __post_init__(self): if self.identifier is None: @@ -79,7 +85,7 @@ def memory_efficiency(self) -> float: return (1 - (self.loaded_objects / self.total_objects)) * 100 if self.total_objects > 0 else 100.0 -class EpcStreamReader(EnergymlWorkspace): +class EpcStreamReader(EnergymlStorageInterface): """ Memory-efficient EPC file reader with lazy loading and smart caching. @@ -523,6 +529,9 @@ def get_object_by_uuid(self, uuid: str) -> List[Any]: return objects + def get_object(self, identifier: Union[str, Uri]) -> Optional[Any]: + return self.get_object_by_identifier(identifier) + def get_objects_by_type(self, object_type: str) -> List[Any]: """Get all objects of the specified type.""" if object_type not in self._type_index: @@ -555,6 +564,87 @@ def get_statistics(self) -> EpcStreamingStats: """Get current streaming statistics.""" return self.stats + def list_objects( + self, dataspace: Optional[str] = None, object_type: Optional[str] = None + ) -> List[ResourceMetadata]: + """ + List all objects with metadata (EnergymlStorageInterface method). + + Args: + dataspace: Optional dataspace filter (ignored for EPC files) + object_type: Optional type filter (qualified type) + + Returns: + List of ResourceMetadata for all matching objects + """ + + results = [] + metadata_list = self.list_object_metadata(object_type) + + for meta in metadata_list: + try: + # Load object to get title + obj = self.get_object_by_identifier(meta.identifier) + title = "Unknown" + if obj and hasattr(obj, "citation") and obj.citation: + if hasattr(obj.citation, "title"): + title = obj.citation.title + + # Build URI + qualified_type = content_type_to_qualified_type(meta.content_type) + if meta.version: + uri = f"eml:///{qualified_type}(uuid={meta.uuid},version='{meta.version}')" + else: + uri = f"eml:///{qualified_type}({meta.uuid})" + + resource = ResourceMetadata( + uri=uri, + uuid=meta.uuid, + version=meta.version, + title=title, + object_type=meta.object_type, + content_type=meta.content_type, + ) + + results.append(resource) + except Exception: + continue + + return results + + def get_array_metadata( + self, proxy: Union[str, Uri, Any], path_in_external: Optional[str] = None + ) -> Union[DataArrayMetadata, List[DataArrayMetadata], None]: + """ + Get metadata for data array(s) (EnergymlStorageInterface method). + + Args: + proxy: The object identifier/URI or the object itself + path_in_external: Optional specific path + + Returns: + DataArrayMetadata if path specified, List[DataArrayMetadata] if no path, + or None if not found + """ + from energyml.utils.storage_interface import DataArrayMetadata + + try: + if path_in_external: + array = self.read_array(proxy, path_in_external) + if array is not None: + return DataArrayMetadata( + path_in_resource=path_in_external, + array_type=str(array.dtype), + dimensions=list(array.shape), + ) + else: + # Would need to scan all possible paths - not practical + return [] + except Exception: + pass + + return None + def preload_objects(self, identifiers: List[str]) -> int: """ Preload specific objects into cache. @@ -826,6 +916,22 @@ def _reopen_persistent_zip(self) -> None: pass self._persistent_zip = zipfile.ZipFile(self.epc_file_path, "r") + def put_object(self, obj: Any, dataspace: Optional[str] = None) -> Optional[str]: + """ + Store an energyml object (EnergymlStorageInterface method). + + Args: + obj: The energyml object to store + dataspace: Optional dataspace name (ignored for EPC files) + + Returns: + The identifier of the stored object (UUID.version or UUID), or None on error + """ + try: + return self.add_object(obj, replace_if_exists=True) + except Exception: + return None + def add_object(self, obj: Any, file_path: Optional[str] = None, replace_if_exists: bool = True) -> str: """ Add a new object to the EPC file and update caches. @@ -920,6 +1026,18 @@ def add_object(self, obj: Any, file_path: Optional[str] = None, replace_if_exist self._rollback_add_object(identifier) raise RuntimeError(f"Failed to add object to EPC: {e}") + def delete_object(self, identifier: Union[str, Uri]) -> bool: + """ + Delete an object by its identifier (EnergymlStorageInterface method). + + Args: + identifier: Object identifier (UUID or UUID.version) or ETP URI + + Returns: + True if successfully deleted, False otherwise + """ + return self.remove_object(identifier) + def remove_object(self, identifier: Union[str, Uri]) -> bool: """ Remove an object (or all versions of an object) from the EPC file and update caches. diff --git a/energyml-utils/src/energyml/utils/introspection.py b/energyml-utils/src/energyml/utils/introspection.py index 0ca5951..94f1a51 100644 --- a/energyml-utils/src/energyml/utils/introspection.py +++ b/energyml-utils/src/energyml/utils/introspection.py @@ -233,6 +233,8 @@ def get_module_name_and_type_from_content_or_qualified_type(cqt: str) -> Tuple[s ct = parse_qualified_type(cqt) except AttributeError: pass + if ct is None: + raise ValueError(f"Cannot parse content-type or qualified-type: {cqt}") domain = ct.group("domain") if domain is None: @@ -425,6 +427,10 @@ def get_object_attribute(obj: Any, attr_dot_path: str, force_snake_case=True) -> """ current_attrib_name, path_next = path_next_attribute(attr_dot_path) + if current_attrib_name is None: + logging.error(f"Attribute path '{attr_dot_path}' is invalid.") + return None + if force_snake_case: current_attrib_name = snake_case(current_attrib_name) @@ -517,6 +523,10 @@ def get_object_attribute_or_create( """ current_attrib_name, path_next = path_next_attribute(attr_dot_path) + if current_attrib_name is None: + logging.error(f"Attribute path '{attr_dot_path}' is invalid.") + return None + if force_snake_case: current_attrib_name = snake_case(current_attrib_name) @@ -552,6 +562,10 @@ def get_object_attribute_advanced(obj: Any, attr_dot_path: str) -> Any: current_attrib_name = get_matching_class_attribute_name(obj, current_attrib_name) + if current_attrib_name is None: + logging.error(f"Attribute path '{attr_dot_path}' is invalid.") + return None + value = None if isinstance(obj, list): value = obj[int(current_attrib_name)] @@ -871,6 +885,9 @@ def search_attribute_matching_name_with_path( # current_match = attrib_list[0] # next_match = ".".join(attrib_list[1:]) current_match, next_match = path_next_attribute(name_rgx) + if current_match is None: + logging.error(f"Attribute name regex '{name_rgx}' is invalid.") + return [] res = [] if current_path is None: @@ -998,7 +1015,7 @@ def set_attribute_from_dict(obj: Any, values: Dict) -> None: set_attribute_from_path(obj=obj, attribute_path=k, value=v) -def set_attribute_from_path(obj: Any, attribute_path: str, value: Any): +def set_attribute_from_path(obj: Any, attribute_path: str, value: Any) -> None: """ Changes the value of a (sub)attribute. Example : @@ -1024,6 +1041,11 @@ def set_attribute_from_path(obj: Any, attribute_path: str, value: Any): """ upper = obj current_attrib_name, path_next = path_next_attribute(attribute_path) + + if current_attrib_name is None: + logging.error(f"Attribute path '{attribute_path}' is invalid.") + return + if path_next is not None: set_attribute_from_path( get_object_attribute( @@ -1067,12 +1089,12 @@ def set_attribute_from_path(obj: Any, attribute_path: str, value: Any): setattr(upper, current_attrib_name, value) -def set_attribute_value(obj: any, attribute_name_rgx, value: Any): +def set_attribute_value(obj: any, attribute_name_rgx, value: Any) -> None: copy_attributes(obj_in={attribute_name_rgx: value}, obj_out=obj, ignore_case=True) def copy_attributes( - obj_in: any, + obj_in: Any, obj_out: Any, only_existing_attributes: bool = True, ignore_case: bool = True, @@ -1082,7 +1104,7 @@ def copy_attributes( p_list = search_attribute_matching_name_with_path( obj=obj_out, name_rgx=k_in, - re_flags=re.IGNORECASE if ignore_case else 0, + re_flags=re.IGNORECASE if ignore_case else re.NOFLAG, deep_search=False, search_in_sub_obj=False, ) @@ -1338,7 +1360,7 @@ def get_qualified_type_from_class(cls: Union[type, Any], print_dev_version=True) return None -def get_object_uri(obj: any, dataspace: Optional[str] = None) -> Optional[Uri]: +def get_object_uri(obj: Any, dataspace: Optional[str] = None) -> Optional[Uri]: """Returns an ETP URI""" return parse_uri(f"eml:///dataspace('{dataspace or ''}')/{get_qualified_type_from_class(obj)}({get_obj_uuid(obj)})") @@ -1523,6 +1545,12 @@ def _gen_str_from_attribute_name(attribute_name: Optional[str], _parent_class: O :param _parent_class: :return: """ + if attribute_name is None: + return ( + "A random str (" + + str(random_value_from_class(int)) + + ") @_gen_str_from_attribute_name attribute 'attribute_name' was None" + ) attribute_name_lw = attribute_name.lower() if attribute_name is not None: if attribute_name_lw == "uuid" or attribute_name_lw == "uid": diff --git a/energyml-utils/src/energyml/utils/manager.py b/energyml-utils/src/energyml/utils/manager.py index 23933b3..10644ad 100644 --- a/energyml-utils/src/energyml/utils/manager.py +++ b/energyml-utils/src/energyml/utils/manager.py @@ -179,7 +179,7 @@ def get_class_pkg(cls): try: p = re.compile(RGX_ENERGYML_MODULE_NAME) match = p.search(cls.__module__) - return match.group("pkg") + return match.group("pkg") # type: ignore except AttributeError as e: logging.error(f"Exception to get class package for '{cls}'") raise e @@ -217,6 +217,8 @@ def reshape_version_from_regex_match( :param nb_digit: The number of digits to keep in the version. :return: The reshaped version string. """ + if match is None: + return "" return reshape_version(match.group("versionNumber"), nb_digit) + ( "dev" + match.group("versionDev") if match.group("versionDev") is not None and print_dev_version else "" ) diff --git a/energyml-utils/src/energyml/utils/storage_interface.py b/energyml-utils/src/energyml/utils/storage_interface.py index a648455..d07299b 100644 --- a/energyml-utils/src/energyml/utils/storage_interface.py +++ b/energyml-utils/src/energyml/utils/storage_interface.py @@ -12,12 +12,8 @@ Key Components: - EnergymlStorageInterface: Abstract base class defining the storage interface -- ETPStorage: Implementation for ETP server-based storage (requires py-etp-client) -- EPCStorage: Implementation for local EPC file-based storage -- EPCStreamStorage: Implementation for streaming EPC file-based storage - ResourceMetadata: Dataclass for object metadata (similar to ETP Resource) - DataArrayMetadata: Dataclass for array metadata -- create_storage: Factory function for creating storage instances Example Usage: ```python @@ -27,7 +23,7 @@ storage = create_storage("my_data.epc") # Same API for all implementations! - obj = storage.get_object("uuid.version") + obj = storage.get_object("uuid.version") or storage.get_object("eml:///dataspace('default')/resqml22.TriangulatedSetRepresentation('uuid')") metadata_list = storage.list_objects() array = storage.read_array(obj, "values/0") storage.put_object(new_obj) @@ -38,24 +34,11 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import datetime -from pathlib import Path from typing import Any, Dict, List, Optional, Union, Tuple -import logging +from energyml.utils.uri import Uri import numpy as np -from energyml.utils.uri import Uri, parse_uri -from energyml.utils.introspection import ( - get_obj_identifier, - get_obj_uuid, - get_obj_version, - get_obj_type, - get_content_type_from_class, - epoch_to_date, - epoch, -) -from energyml.utils.constants import content_type_to_qualified_type - @dataclass class ResourceMetadata: @@ -119,7 +102,7 @@ class DataArrayMetadata: similar to ETP DataArrayMetadata. """ - path_in_resource: str + path_in_resource: Optional[str] """Path to the array within the HDF5 file""" array_type: str @@ -144,6 +127,37 @@ def ndim(self) -> int: """Number of dimensions""" return len(self.dimensions) + @classmethod + def from_numpy_array(cls, path_in_resource: Optional[str], array: np.ndarray) -> "DataArrayMetadata": + """ + Create DataArrayMetadata from a numpy array. + + Args: + path_in_resource: Path to the array within the HDF5 file + array: Numpy array + Returns: + DataArrayMetadata instance + """ + return cls( + path_in_resource=path_in_resource, + array_type=str(array.dtype), + dimensions=list(array.shape), + ) + + @classmethod + def from_list(cls, path_in_resource: Optional[str], data: List[Any]) -> "DataArrayMetadata": + """ + Create DataArrayMetadata from a list. + + Args: + path_in_resource: Path to the array within the HDF5 file + data: List of data + Returns: + DataArrayMetadata instance + """ + array = np.array(data) + return cls.from_numpy_array(path_in_resource, array) + class EnergymlStorageInterface(ABC): """ @@ -341,484 +355,8 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.close() -class EPCStorage(EnergymlStorageInterface): - """ - EPC file-based storage implementation. - - This implementation uses an Epc object to interact with energyml data stored in - a local EPC file. Arrays are stored in associated HDF5 external files. - - Args: - epc: An initialized Epc instance - """ - - def __init__(self, epc: "Epc"): # noqa: F821 - """ - Initialize EPC storage with an Epc instance. - - Args: - epc: An Epc instance - """ - from energyml.utils.epc import Epc - - if not isinstance(epc, Epc): - raise TypeError(f"Expected Epc instance, got {type(epc)}") - - self.epc = epc - self._closed = False - - def get_object(self, identifier: Union[str, Uri]) -> Optional[Any]: - """Retrieve an object by identifier from EPC.""" - # if self._closed: - # raise RuntimeError("Storage is closed") - - # Convert URI to identifier if needed - if isinstance(identifier, Uri): - identifier = f"{identifier.uuid}.{identifier.version}" if identifier.version else identifier.uuid - elif isinstance(identifier, str) and identifier.startswith("eml://"): - parsed = parse_uri(identifier) - identifier = f"{parsed.uuid}.{parsed.version}" if parsed.version else "" - - return self.epc.get_object_by_identifier(identifier) - - def get_object_by_uuid(self, uuid: str) -> List[Any]: - """Retrieve all objects with the given UUID.""" - # if self._closed: - # raise RuntimeError("Storage is closed") - - result = self.epc.get_object_by_uuid(uuid) - return result if isinstance(result, list) else [result] if result else [] - - def put_object(self, obj: Any, dataspace: Optional[str] = None) -> Optional[str]: - """Store an object in EPC.""" - # if self._closed: - # raise RuntimeError("Storage is closed") - - try: - # Check if object already exists - identifier = get_obj_identifier(obj) - existing = self.epc.get_object_by_identifier(identifier) - - if existing: - # Update existing object - self.epc.energyml_objects.remove(existing) - - # Add new object - self.epc.energyml_objects.append(obj) - return identifier - except Exception as e: - logging.error(f"Failed to put object: {e}") - return None - - def delete_object(self, identifier: Union[str, Uri]) -> bool: - """Delete an object from EPC.""" - # if self._closed: - # raise RuntimeError("Storage is closed") - - try: - obj = self.get_object(identifier) - if obj and obj in self.epc.energyml_objects: - self.epc.energyml_objects.remove(obj) - return True - return False - except Exception as e: - logging.error(f"Failed to delete object: {e}") - return False - - def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Optional[np.ndarray]: - """Read array from HDF5 file associated with EPC.""" - # if self._closed: - # raise RuntimeError("Storage is closed") - - # Get object if proxy is identifier - if isinstance(proxy, (str, Uri)): - proxy = self.get_object(proxy) - if proxy is None: - return None - - return self.epc.read_array(proxy, path_in_external) - - def write_array( - self, - proxy: Union[str, Uri, Any], - path_in_external: str, - array: np.ndarray, - ) -> bool: - """Write array to HDF5 file associated with EPC.""" - # if self._closed: - # raise RuntimeError("Storage is closed") - - # Get object if proxy is identifier - if isinstance(proxy, (str, Uri)): - proxy = self.get_object(proxy) - if proxy is None: - return False - - return self.epc.write_array(proxy, path_in_external, array) - - def get_array_metadata( - self, proxy: Union[str, Uri, Any], path_in_external: Optional[str] = None - ) -> Union[DataArrayMetadata, List[DataArrayMetadata], None]: - """Get array metadata (limited support for EPC).""" - # if self._closed: - # raise RuntimeError("Storage is closed") - - # EPC doesn't have native array metadata support - # We can try to read the array and infer metadata - try: - if path_in_external: - array = self.read_array(proxy, path_in_external) - if array is not None: - return DataArrayMetadata( - path_in_resource=path_in_external, - array_type=str(array.dtype), - dimensions=list(array.shape), - ) - else: - # Would need to scan all possible paths - not practical - logging.warning("EPC does not support listing all arrays without specific path") - return [] - except Exception as e: - logging.error(f"Failed to get array metadata: {e}") - - return None - - def list_objects( - self, dataspace: Optional[str] = None, object_type: Optional[str] = None - ) -> List[ResourceMetadata]: - """List all objects with metadata.""" - # if self._closed: - # raise RuntimeError("Storage is closed") - - results = [] - for obj in self.epc.energyml_objects: - try: - uuid = get_obj_uuid(obj) - version = get_obj_version(obj) - obj_type = get_obj_type(obj) - content_type = get_content_type_from_class(obj.__class__) - - # Apply type filter - if object_type and obj_type != object_type: - continue - - # Get title from citation - title = "Unknown" - if hasattr(obj, "citation") and obj.citation: - if hasattr(obj.citation, "title"): - title = obj.citation.title - - # Build URI - qualified_type = content_type_to_qualified_type(content_type) - if version: - uri = f"eml:///{qualified_type}(uuid={uuid},version='{version}')" - else: - uri = f"eml:///{qualified_type}({uuid})" - - metadata = ResourceMetadata( - uri=uri, - uuid=uuid, - version=version, - title=title, - object_type=obj_type, - content_type=content_type, - ) - - results.append(metadata) - except Exception as e: - logging.warning(f"Failed to get metadata for object: {e}") - continue - - return results - - def close(self) -> None: - """Close the EPC storage.""" - self._closed = True - - def save(self, file_path: Optional[str] = None) -> None: - """ - Save the EPC to disk. - - Args: - file_path: Optional path to save to. If None, uses epc.epc_file_path - """ - # if self._closed: - # raise RuntimeError("Storage is closed") - - self.epc.export_file(file_path) - - -class EPCStreamStorage(EnergymlStorageInterface): - """ - Memory-efficient EPC stream-based storage implementation. - - This implementation uses EpcStreamReader for lazy loading and caching, - making it ideal for handling very large EPC files with thousands of objects. - - Features: - - Lazy loading: Objects loaded only when accessed - - Smart caching: LRU cache with configurable size - - Memory monitoring: Track memory usage and cache efficiency - - Same interface as EPCStorage for seamless switching - - Args: - stream_reader: An EpcStreamReader instance - """ - - def __init__(self, stream_reader: "EpcStreamReader"): # noqa: F821 - """ - Initialize EPC stream storage with an EpcStreamReader instance. - - Args: - stream_reader: An EpcStreamReader instance - """ - from energyml.utils.epc_stream import EpcStreamReader - - if not isinstance(stream_reader, EpcStreamReader): - raise TypeError(f"Expected EpcStreamReader instance, got {type(stream_reader)}") - - self.stream_reader = stream_reader - self._closed = False - - def get_object(self, identifier: Union[str, Uri]) -> Optional[Any]: - """Retrieve an object by identifier from EPC stream.""" - # if self._closed: - # raise RuntimeError("Storage is closed") - - # Convert URI to identifier if needed - if isinstance(identifier, Uri): - identifier = f"{identifier.uuid}.{identifier.version or ''}" - elif isinstance(identifier, str) and identifier.startswith("eml://"): - parsed = parse_uri(identifier) - identifier = f"{parsed.uuid}.{parsed.version or ''}" - - return self.stream_reader.get_object_by_identifier(identifier) - - def get_object_by_uuid(self, uuid: str) -> List[Any]: - """Retrieve all objects with the given UUID.""" - # if self._closed: - # raise RuntimeError("Storage is closed") - - return self.stream_reader.get_object_by_uuid(uuid) - - def put_object(self, obj: Any, dataspace: Optional[str] = None) -> Optional[str]: - """Store an object in EPC stream.""" - # if self._closed: - # raise RuntimeError("Storage is closed") - - try: - return self.stream_reader.add_object(obj, replace_if_exists=True) - except Exception as e: - logging.error(f"Failed to put object: {e}") - return None - - def delete_object(self, identifier: Union[str, Uri]) -> bool: - """Delete an object from EPC stream.""" - # if self._closed: - # raise RuntimeError("Storage is closed") - - try: - return self.stream_reader.remove_object(identifier) - except Exception as e: - logging.error(f"Failed to delete object: {e}") - return False - - def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Optional[np.ndarray]: - """Read array from HDF5 file associated with EPC stream.""" - if self._closed: - raise RuntimeError("Storage is closed") - - return self.stream_reader.read_array(proxy, path_in_external) - - def write_array( - self, - proxy: Union[str, Uri, Any], - path_in_external: str, - array: np.ndarray, - ) -> bool: - """Write array to HDF5 file associated with EPC stream.""" - # if self._closed: - # raise RuntimeError("Storage is closed") - - return self.stream_reader.write_array(proxy, path_in_external, array) - - def get_array_metadata( - self, proxy: Union[str, Uri, Any], path_in_external: Optional[str] = None - ) -> Union[DataArrayMetadata, List[DataArrayMetadata], None]: - """Get array metadata (limited support for EPC Stream).""" - # if self._closed: - # raise RuntimeError("Storage is closed") - - # EPC Stream doesn't have native array metadata support - # We can try to read the array and infer metadata - try: - if path_in_external: - array = self.read_array(proxy, path_in_external) - if array is not None: - return DataArrayMetadata( - path_in_resource=path_in_external, - array_type=str(array.dtype), - dimensions=list(array.shape), - ) - else: - # Would need to scan all possible paths - not practical - logging.warning("EPC Stream does not support listing all arrays without specific path") - return [] - except Exception as e: - logging.error(f"Failed to get array metadata: {e}") - - return None - - def list_objects( - self, dataspace: Optional[str] = None, object_type: Optional[str] = None - ) -> List[ResourceMetadata]: - """List all objects with metadata.""" - # if self._closed: - # raise RuntimeError("Storage is closed") - - results = [] - metadata_list = self.stream_reader.list_object_metadata(object_type) - - for meta in metadata_list: - try: - # Load object to get title - # obj = self.stream_reader.get_object_by_identifier(meta.identifier) - # title = "Unknown" - # if obj and hasattr(obj, "citation") and obj.citation: - # if hasattr(obj.citation, "title"): - # title = obj.citation.title - - # Build URI - qualified_type = content_type_to_qualified_type(meta.content_type) - if meta.version: - uri = f"eml:///{qualified_type}(uuid={meta.uuid},version='{meta.version}')" - else: - uri = f"eml:///{qualified_type}({meta.uuid})" - - resource = ResourceMetadata( - uri=uri, - uuid=meta.uuid, - version=meta.version, - title="", # we do not fill the title to avoid loading the object - object_type=meta.object_type, - content_type=meta.content_type, - ) - - results.append(resource) - except Exception as e: - logging.warning(f"Failed to get metadata for {meta.identifier}: {e}") - continue - - return results - - def close(self) -> None: - """Close the EPC stream storage.""" - self._closed = True - - def get_statistics(self) -> Dict[str, Any]: - """ - Get streaming statistics. - - Returns: - Dictionary with cache statistics and performance metrics - """ - # if self._closed: - # raise RuntimeError("Storage is closed") - - stats = self.stream_reader.get_statistics() - return { - "total_objects": stats.total_objects, - "loaded_objects": stats.loaded_objects, - "cache_hits": stats.cache_hits, - "cache_misses": stats.cache_misses, - "cache_hit_ratio": stats.cache_hit_ratio, - "bytes_read": stats.bytes_read, - } - - -def create_storage( - source: Union[str, Path, "Epc", "EpcStreamReader"], # noqa: F821 - stream_mode: bool = False, - cache_size: int = 100, - **kwargs, -) -> EnergymlStorageInterface: - """ - Factory function to create an appropriate storage interface from various sources. - - This convenience function automatically determines the correct storage implementation - based on the type of source provided. - - Args: - source: Can be: - - Epc instance: Creates EPCStorage - - EpcStreamReader instance: Creates EPCStreamStorage - - str/Path (file path): Loads EPC file and creates EPCStorage or EPCStreamStorage - stream_mode: If True and source is a file path, creates EPCStreamStorage for memory efficiency - cache_size: Cache size for stream mode (default: 100) - **kwargs: Additional arguments passed to EpcStreamReader if in stream mode - - Returns: - An EnergymlStorageInterface implementation (EPCStorage or EPCStreamStorage) - - Raises: - ValueError: If the source type is not supported - FileNotFoundError: If file path does not exist - - Example: - ```python - # From EPC instance - from energyml.utils.epc import Epc - epc = Epc() - storage = create_storage(epc) - - # From EPC stream reader - from energyml.utils.epc_stream import EpcStreamReader - stream_reader = EpcStreamReader("large_file.epc", cache_size=50) - storage = create_storage(stream_reader) - - # From file path (regular mode) - storage = create_storage("path/to/file.epc") - - # From file path (streaming mode for large files) - storage = create_storage("path/to/large_file.epc", stream_mode=True, cache_size=50) - ``` - """ - from energyml.utils.epc import Epc - from energyml.utils.epc_stream import EpcStreamReader - - if isinstance(source, Epc): - return EPCStorage(source) - - elif isinstance(source, EpcStreamReader): - return EPCStreamStorage(source) - - elif isinstance(source, (str, Path)): - file_path = Path(source) - if not file_path.exists(): - raise FileNotFoundError(f"EPC file not found: {file_path}") - - if stream_mode: - # Create streaming reader for memory efficiency - stream_reader = EpcStreamReader(file_path, cache_size=cache_size, **kwargs) - return EPCStreamStorage(stream_reader) - else: - # Load full EPC into memory - from energyml.utils.epc import read_energyml_epc_file - - epc = read_energyml_epc_file(str(file_path)) - return EPCStorage(epc) - - else: - raise ValueError( - f"Unsupported source type: {type(source)}. " "Expected Epc, EpcStreamReader, or file path (str/Path)" - ) - - __all__ = [ "EnergymlStorageInterface", - "EPCStorage", - "EPCStreamStorage", "ResourceMetadata", "DataArrayMetadata", - "create_storage", ] diff --git a/energyml-utils/src/energyml/utils/workspace.py b/energyml-utils/src/energyml/utils/workspace.py deleted file mode 100644 index 8371644..0000000 --- a/energyml-utils/src/energyml/utils/workspace.py +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright (c) 2023-2024 Geosiris. -# SPDX-License-Identifier: Apache-2.0 -from abc import abstractmethod -from dataclasses import dataclass -from typing import Optional, Any, Union - -from energyml.utils.uri import Uri -import numpy as np - - -@dataclass -class EnergymlWorkspace: - def get_object(self, uuid: str, object_version: Optional[str]) -> Optional[Any]: - raise NotImplementedError("EnergymlWorkspace.get_object") - - def get_object_by_identifier(self, identifier: str) -> Optional[Any]: - _tmp = identifier.split(".") - return self.get_object(_tmp[0], _tmp[1] if len(_tmp) > 1 else None) - - def get_object_by_uuid(self, uuid: str) -> Optional[Any]: - return self.get_object(uuid, None) - - # def read_external_array( - # self, - # energyml_array: Any, - # root_obj: Optional[Any] = None, - # path_in_root: Optional[str] = None, - # ) -> List[Any]: - # raise NotImplementedError("EnergymlWorkspace.get_object") - - @abstractmethod - def add_object(self, obj: Any) -> bool: - raise NotImplementedError("EnergymlWorkspace.add_object") - - @abstractmethod - def remove_object(self, identifier: Union[str, Uri]) -> None: - raise NotImplementedError("EnergymlWorkspace.remove_object") - - @abstractmethod - def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Optional[np.ndarray]: - raise NotImplementedError("EnergymlWorkspace.read_array") - - @abstractmethod - def write_array(self, proxy: Union[str, Uri, Any], path_in_external: str, array: Any) -> bool: - raise NotImplementedError("EnergymlWorkspace.write_array") diff --git a/energyml-utils/tests/test_epc.py b/energyml-utils/tests/test_epc.py index 11626a8..de6ea53 100644 --- a/energyml-utils/tests/test_epc.py +++ b/energyml-utils/tests/test_epc.py @@ -9,13 +9,13 @@ from energyml.resqml.v2_0_1.resqmlv2 import FaultInterpretation from energyml.resqml.v2_2.resqmlv2 import TriangulatedSetRepresentation -from src.energyml.utils.epc import ( +from energyml.utils.epc import ( as_dor, get_obj_identifier, gen_energyml_object_path, EpcExportVersion, ) -from src.energyml.utils.introspection import ( +from energyml.utils.introspection import ( epoch_to_date, epoch, gen_uuid, From 23ea6f3af93c0a70bc4347e5c4184118b40c80ea Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Mon, 8 Dec 2025 14:31:19 +0100 Subject: [PATCH 07/70] avoid log spam for failing importing modules --- energyml-utils/src/energyml/utils/introspection.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/energyml-utils/src/energyml/utils/introspection.py b/energyml-utils/src/energyml/utils/introspection.py index 94f1a51..db23fed 100644 --- a/energyml-utils/src/energyml/utils/introspection.py +++ b/energyml-utils/src/energyml/utils/introspection.py @@ -283,6 +283,10 @@ def get_module_name(domain: str, domain_version: str): return f"energyml.{domain}.{domain_version}.{ns[ns.rindex('/') + 1:]}" +# Track modules that failed to import to avoid duplicate logging +_FAILED_IMPORT_MODULES = set() + + def import_related_module(energyml_module_name: str) -> None: """ Import related modules for a specific energyml module. (See. :const:`RELATED_MODULES`) @@ -295,7 +299,10 @@ def import_related_module(energyml_module_name: str) -> None: try: import_module(m) except Exception as e: - logging.debug(f"Could not import related module {m}: {e}") + # Only log once per unique module + if m not in _FAILED_IMPORT_MODULES: + _FAILED_IMPORT_MODULES.add(m) + logging.debug(f"Could not import related module {m}: {e}") # logging.error(e) From 177ebf16fc8e30903e1290f1570b1d8da8ce1a87 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Tue, 16 Dec 2025 10:22:14 +0100 Subject: [PATCH 08/70] -- --- energyml-utils/src/energyml/utils/epc.py | 6 ++++++ energyml-utils/src/energyml/utils/epc_stream.py | 14 +++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index 296d87b..e44fe22 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -125,6 +125,8 @@ class Epc(EnergymlStorageInterface): default_factory=list, ) + force_h5_path: Optional[str] = field(default=None) + """ Additional rels for objects. Key is the object (same than in @energyml_objects) and value is a list of RelationShip. This can be used to link an HDF5 to an ExternalPartReference in resqml 2.0.1 @@ -429,6 +431,10 @@ def get_h5_file_paths(self, obj: Any) -> List[str]: Get all HDF5 file paths referenced in the EPC file (from rels to external resources) :return: list of HDF5 file paths """ + + if self.force_h5_path is not None: + return [self.force_h5_path] + is_uri = (isinstance(obj, str) and parse_uri(obj) is not None) or isinstance(obj, Uri) if is_uri: obj = self.get_object_by_identifier(obj) diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index 6ffc103..37a62fe 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -117,6 +117,7 @@ def __init__( export_version: EpcExportVersion = EpcExportVersion.CLASSIC, force_h5_path: Optional[str] = None, keep_open: bool = False, + force_title_load: bool = False, ): """ Initialize the EPC stream reader. @@ -129,12 +130,14 @@ def __init__( export_version: EPC packaging version (CLASSIC or EXPANDED) force_h5_path: Optional forced HDF5 file path for external resources. If set, all arrays will be read/written from/to this path. keep_open: If True, keeps the ZIP file open for better performance with multiple operations. File is closed only when instance is deleted or close() is called. + force_title_load: If True, forces loading object titles when listing objects (may impact performance) """ self.epc_file_path = Path(epc_file_path) self.cache_size = cache_size self.validate_on_load = validate_on_load self.force_h5_path = force_h5_path self.keep_open = keep_open + self.force_title_load = force_title_load is_new_file = False @@ -584,11 +587,12 @@ def list_objects( for meta in metadata_list: try: # Load object to get title - obj = self.get_object_by_identifier(meta.identifier) - title = "Unknown" - if obj and hasattr(obj, "citation") and obj.citation: - if hasattr(obj.citation, "title"): - title = obj.citation.title + title = "" + if self.force_title_load: + obj = self.get_object_by_identifier(meta.identifier) + if obj and hasattr(obj, "citation") and obj.citation: + if hasattr(obj.citation, "title"): + title = obj.citation.title # Build URI qualified_type = content_type_to_qualified_type(meta.content_type) From b958e6a7919d46ef785bad4e80dc0cf245b12153 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Tue, 16 Dec 2025 16:37:51 +0100 Subject: [PATCH 09/70] keep h5 open for efficiency --- .../src/energyml/utils/epc_stream.py | 44 +++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index 37a62fe..bad61ec 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -23,6 +23,7 @@ from energyml.utils.data.datasets_io import HDF5FileReader, HDF5FileWriter from energyml.utils.storage_interface import DataArrayMetadata, EnergymlStorageInterface, ResourceMetadata from energyml.utils.uri import Uri, parse_uri +import h5py import numpy as np from energyml.utils.constants import ( EPCRelsRelationshipType, @@ -136,6 +137,7 @@ def __init__( self.cache_size = cache_size self.validate_on_load = validate_on_load self.force_h5_path = force_h5_path + self.cache_opened_h5 = None self.keep_open = keep_open self.force_title_load = force_title_load @@ -769,12 +771,19 @@ def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Opti :return: the dataset as a numpy array """ # Resolve proxy to object - if isinstance(proxy, (str, Uri)): - obj = self.get_object_by_identifier(proxy) + + h5_path = [] + if self.force_h5_path is not None: + if self.cache_opened_h5 is None: + self.cache_opened_h5 = h5py.File(self.force_h5_path, "a") + h5_path = [self.cache_opened_h5] else: - obj = proxy + if isinstance(proxy, (str, Uri)): + obj = self.get_object_by_identifier(proxy) + else: + obj = proxy - h5_path = self.get_h5_file_paths(obj) + h5_path = self.get_h5_file_paths(obj) h5_reader = HDF5FileReader() @@ -798,13 +807,18 @@ def write_array(self, proxy: Union[str, Uri, Any], path_in_external: str, array: return: True if successful """ - # Resolve proxy to object - if isinstance(proxy, (str, Uri)): - obj = self.get_object_by_identifier(proxy) + h5_path = [] + if self.force_h5_path is not None: + if self.cache_opened_h5 is None: + self.cache_opened_h5 = h5py.File(self.force_h5_path, "a") + h5_path = [self.cache_opened_h5] else: - obj = proxy + if isinstance(proxy, (str, Uri)): + obj = self.get_object_by_identifier(proxy) + else: + obj = proxy - h5_path = self.get_h5_file_paths(obj) + h5_path = self.get_h5_file_paths(obj) h5_writer = HDF5FileWriter() @@ -883,11 +897,23 @@ def __exit__(self, exc_type, exc_val, exc_tb): """Context manager exit with cleanup.""" self.clear_cache() self.close() + if self.cache_opened_h5 is not None: + try: + self.cache_opened_h5.close() + except Exception: + pass + self.cache_opened_h5 = None def __del__(self): """Destructor to ensure persistent ZIP file is closed.""" try: self.close() + if self.cache_opened_h5 is not None: + try: + self.cache_opened_h5.close() + except Exception: + pass + self.cache_opened_h5 = None except Exception: pass # Ignore errors during cleanup From be04bcdd850ef5ce95a591c181c947f17eaa9a5e Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Wed, 17 Dec 2025 04:33:47 +0100 Subject: [PATCH 10/70] take file for h5 read/write --- .../src/energyml/utils/data/datasets_io.py | 101 ++++++++++++------ 1 file changed, 70 insertions(+), 31 deletions(-) diff --git a/energyml-utils/src/energyml/utils/data/datasets_io.py b/energyml-utils/src/energyml/utils/data/datasets_io.py index 5213029..a51cf7b 100644 --- a/energyml-utils/src/energyml/utils/data/datasets_io.py +++ b/energyml-utils/src/energyml/utils/data/datasets_io.py @@ -54,61 +54,92 @@ # HDF5 if __H5PY_MODULE_EXISTS__: - def h5_list_datasets(h5_file_path: Union[BytesIO, str]) -> List[str]: + def h5_list_datasets(h5_file_path: Union[BytesIO, str, "h5py.File"]) -> List[str]: """ List all datasets in an HDF5 file. - :param h5_file_path: Path to the HDF5 file + :param h5_file_path: Path to the HDF5 file, BytesIO object, or an already opened h5py.File :return: List of dataset names in the HDF5 file """ res = [] - with h5py.File(h5_file_path, "r") as f: # type: ignore - # Function to print the names of all datasets + + # Check if it's already an opened h5py.File + if isinstance(h5_file_path, h5py.File): # type: ignore def list_datasets(name, obj): - if isinstance(obj, h5py.Dataset): # Check if the object is a dataset # type: ignore + if isinstance(obj, h5py.Dataset): # type: ignore res.append(name) - - # Visit all items in the HDF5 file and apply the list function - f.visititems(list_datasets) + h5_file_path.visititems(list_datasets) + else: + with h5py.File(h5_file_path, "r") as f: # type: ignore + # Function to print the names of all datasets + def list_datasets(name, obj): + if isinstance(obj, h5py.Dataset): # Check if the object is a dataset # type: ignore + res.append(name) + + # Visit all items in the HDF5 file and apply the list function + f.visititems(list_datasets) return res @dataclass class HDF5FileReader(DatasetReader): # noqa: F401 - def read_array(self, source: Union[BytesIO, str], path_in_external_file: str) -> Optional[np.ndarray]: - with h5py.File(source, "r") as f: # type: ignore - d_group = f[path_in_external_file] + def read_array(self, source: Union[BytesIO, str, "h5py.File"], path_in_external_file: str) -> Optional[np.ndarray]: + # Check if it's already an opened h5py.File + if isinstance(source, h5py.File): # type: ignore + d_group = source[path_in_external_file] return d_group[()] # type: ignore - - def get_array_dimension(self, source: Union[BytesIO, str], path_in_external_file: str) -> Optional[List[int]]: - with h5py.File(source, "r") as f: # type: ignore - return list(f[path_in_external_file].shape) + else: + with h5py.File(source, "r") as f: # type: ignore + d_group = f[path_in_external_file] + return d_group[()] # type: ignore + + def get_array_dimension(self, source: Union[BytesIO, str, "h5py.File"], path_in_external_file: str) -> Optional[List[int]]: + # Check if it's already an opened h5py.File + if isinstance(source, h5py.File): # type: ignore + return list(source[path_in_external_file].shape) + else: + with h5py.File(source, "r") as f: # type: ignore + return list(f[path_in_external_file].shape) def extract_h5_datasets( self, - input_h5: Union[BytesIO, str], - output_h5: Union[BytesIO, str], + input_h5: Union[BytesIO, str, "h5py.File"], + output_h5: Union[BytesIO, str, "h5py.File"], h5_datasets_paths: List[str], ) -> None: """ Copy all dataset from :param input_h5 matching with paths in :param h5_datasets_paths into the :param output - :param input_h5: - :param output_h5: + :param input_h5: Path to HDF5 file, BytesIO, or already opened h5py.File + :param output_h5: Path to HDF5 file, BytesIO, or already opened h5py.File :param h5_datasets_paths: :return: """ if h5_datasets_paths is None: h5_datasets_paths = h5_list_datasets(input_h5) if len(h5_datasets_paths) > 0: - with h5py.File(output_h5, "a") as f_dest: # type: ignore - with h5py.File(input_h5, "r") as f_src: # type: ignore + # Handle output file + should_close_dest = not isinstance(output_h5, h5py.File) # type: ignore + f_dest = output_h5 if isinstance(output_h5, h5py.File) else h5py.File(output_h5, "a") # type: ignore + + try: + # Handle input file + should_close_src = not isinstance(input_h5, h5py.File) # type: ignore + f_src = input_h5 if isinstance(input_h5, h5py.File) else h5py.File(input_h5, "r") # type: ignore + + try: for dataset in h5_datasets_paths: f_dest.create_dataset(dataset, data=f_src[dataset]) + finally: + if should_close_src: + f_src.close() + finally: + if should_close_dest: + f_dest.close() @dataclass class HDF5FileWriter: def write_array( self, - target: Union[str, BytesIO, bytes], + target: Union[str, BytesIO, bytes, "h5py.File"], array: Union[list, np.ndarray], path_in_external_file: str, dtype: Optional[np.dtype] = None, @@ -119,28 +150,36 @@ def write_array( if dtype is not None and not isinstance(dtype, np.dtype): dtype = np.dtype(dtype) - with h5py.File(target, "a") as f: # type: ignore - # print(array.dtype, h5py.string_dtype(), array.dtype == 'O') - # print("\t", dtype or (h5py.string_dtype() if array.dtype == '0' else array.dtype)) + # Check if it's already an opened h5py.File + if isinstance(target, h5py.File): # type: ignore if isinstance(array, np.ndarray) and array.dtype == "O": array = np.asarray([s.encode() if isinstance(s, str) else s for s in array]) np.void(array) - dset = f.create_dataset(path_in_external_file, array.shape, dtype or array.dtype) + dset = target.create_dataset(path_in_external_file, array.shape, dtype or array.dtype) dset[()] = array + else: + with h5py.File(target, "a") as f: # type: ignore + # print(array.dtype, h5py.string_dtype(), array.dtype == 'O') + # print("\t", dtype or (h5py.string_dtype() if array.dtype == '0' else array.dtype)) + if isinstance(array, np.ndarray) and array.dtype == "O": + array = np.asarray([s.encode() if isinstance(s, str) else s for s in array]) + np.void(array) + dset = f.create_dataset(path_in_external_file, array.shape, dtype or array.dtype) + dset[()] = array else: class HDF5FileReader: - def read_array(self, source: Union[BytesIO, str], path_in_external_file: str) -> Optional[np.ndarray]: + def read_array(self, source: Union[BytesIO, str, Any], path_in_external_file: str) -> Optional[np.ndarray]: raise MissingExtraInstallation(extra_name="hdf5") - def get_array_dimension(self, source: Union[BytesIO, str], path_in_external_file: str) -> Optional[np.ndarray]: + def get_array_dimension(self, source: Union[BytesIO, str, Any], path_in_external_file: str) -> Optional[np.ndarray]: raise MissingExtraInstallation(extra_name="hdf5") def extract_h5_datasets( self, - input_h5: Union[BytesIO, str], - output_h5: Union[BytesIO, str], + input_h5: Union[BytesIO, str, Any], + output_h5: Union[BytesIO, str, Any], h5_datasets_paths: List[str], ) -> None: raise MissingExtraInstallation(extra_name="hdf5") @@ -149,7 +188,7 @@ class HDF5FileWriter: def write_array( self, - target: Union[str, BytesIO, bytes], + target: Union[str, BytesIO, bytes, Any], array: Union[list, np.ndarray], path_in_external_file: str, dtype: Optional[np.dtype] = None, From fef305e17e0f27d405723e4fcde822906caa80b4 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Wed, 17 Dec 2025 04:34:24 +0100 Subject: [PATCH 11/70] file format --- .../src/energyml/utils/data/datasets_io.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/energyml-utils/src/energyml/utils/data/datasets_io.py b/energyml-utils/src/energyml/utils/data/datasets_io.py index a51cf7b..d899015 100644 --- a/energyml-utils/src/energyml/utils/data/datasets_io.py +++ b/energyml-utils/src/energyml/utils/data/datasets_io.py @@ -61,12 +61,14 @@ def h5_list_datasets(h5_file_path: Union[BytesIO, str, "h5py.File"]) -> List[str :return: List of dataset names in the HDF5 file """ res = [] - + # Check if it's already an opened h5py.File if isinstance(h5_file_path, h5py.File): # type: ignore + def list_datasets(name, obj): if isinstance(obj, h5py.Dataset): # type: ignore res.append(name) + h5_file_path.visititems(list_datasets) else: with h5py.File(h5_file_path, "r") as f: # type: ignore @@ -81,7 +83,9 @@ def list_datasets(name, obj): @dataclass class HDF5FileReader(DatasetReader): # noqa: F401 - def read_array(self, source: Union[BytesIO, str, "h5py.File"], path_in_external_file: str) -> Optional[np.ndarray]: + def read_array( + self, source: Union[BytesIO, str, "h5py.File"], path_in_external_file: str + ) -> Optional[np.ndarray]: # Check if it's already an opened h5py.File if isinstance(source, h5py.File): # type: ignore d_group = source[path_in_external_file] @@ -91,7 +95,9 @@ def read_array(self, source: Union[BytesIO, str, "h5py.File"], path_in_external_ d_group = f[path_in_external_file] return d_group[()] # type: ignore - def get_array_dimension(self, source: Union[BytesIO, str, "h5py.File"], path_in_external_file: str) -> Optional[List[int]]: + def get_array_dimension( + self, source: Union[BytesIO, str, "h5py.File"], path_in_external_file: str + ) -> Optional[List[int]]: # Check if it's already an opened h5py.File if isinstance(source, h5py.File): # type: ignore return list(source[path_in_external_file].shape) @@ -118,12 +124,12 @@ def extract_h5_datasets( # Handle output file should_close_dest = not isinstance(output_h5, h5py.File) # type: ignore f_dest = output_h5 if isinstance(output_h5, h5py.File) else h5py.File(output_h5, "a") # type: ignore - + try: # Handle input file should_close_src = not isinstance(input_h5, h5py.File) # type: ignore f_src = input_h5 if isinstance(input_h5, h5py.File) else h5py.File(input_h5, "r") # type: ignore - + try: for dataset in h5_datasets_paths: f_dest.create_dataset(dataset, data=f_src[dataset]) @@ -173,7 +179,9 @@ class HDF5FileReader: def read_array(self, source: Union[BytesIO, str, Any], path_in_external_file: str) -> Optional[np.ndarray]: raise MissingExtraInstallation(extra_name="hdf5") - def get_array_dimension(self, source: Union[BytesIO, str, Any], path_in_external_file: str) -> Optional[np.ndarray]: + def get_array_dimension( + self, source: Union[BytesIO, str, Any], path_in_external_file: str + ) -> Optional[np.ndarray]: raise MissingExtraInstallation(extra_name="hdf5") def extract_h5_datasets( From a7bd953c54cc51ea259245e993c5e01033f55dda Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Tue, 3 Feb 2026 14:10:07 +0100 Subject: [PATCH 12/70] optimization for rels path --- energyml-utils/.gitignore | 3 +- energyml-utils/pyproject.toml | 1 + .../src/energyml/utils/epc_stream.py | 740 +++++++++++------- 3 files changed, 462 insertions(+), 282 deletions(-) diff --git a/energyml-utils/.gitignore b/energyml-utils/.gitignore index 016795e..f672e3c 100644 --- a/energyml-utils/.gitignore +++ b/energyml-utils/.gitignore @@ -66,4 +66,5 @@ docs/*.md # WIP src/energyml/utils/wip* -scripts \ No newline at end of file +scripts +rc/camunda \ No newline at end of file diff --git a/energyml-utils/pyproject.toml b/energyml-utils/pyproject.toml index a3ff9a8..6f66d30 100644 --- a/energyml-utils/pyproject.toml +++ b/energyml-utils/pyproject.toml @@ -76,6 +76,7 @@ black = "^22.3.0" pylint = "^2.7.2" click = ">=8.1.3, <=8.1.3" # upper version than 8.0.2 fail with black pdoc3 = "^0.10.0" +pydantic = { version = "^2.0", optional = true } energyml-common2-0 = "^1.12.0" energyml-common2-1 = "^1.12.0" energyml-common2-2 = "^1.12.0" diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index bad61ec..2209f40 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -590,7 +590,7 @@ def list_objects( try: # Load object to get title title = "" - if self.force_title_load: + if self.force_title_load and meta.identifier: obj = self.get_object_by_identifier(meta.identifier) if obj and hasattr(obj, "citation") and obj.citation: if hasattr(obj.citation, "title"): @@ -686,6 +686,42 @@ def get_core_properties(self) -> Optional[CoreProperties]: return self._core_props + def _gen_rels_path_from_metadata(self, metadata: EpcObjectMetadata) -> str: + """ + Generate rels path from object metadata without loading the object. + + Args: + metadata: Object metadata containing file path information + + Returns: + Path to the rels file for this object + """ + obj_path = metadata.file_path + # Extract folder and filename from the object path + if "/" in obj_path: + obj_folder = obj_path[: obj_path.rindex("/") + 1] + obj_file_name = obj_path[obj_path.rindex("/") + 1 :] + else: + obj_folder = "" + obj_file_name = obj_path + + return f"{obj_folder}_rels/{obj_file_name}.rels" + + def _gen_rels_path_from_identifier(self, identifier: str) -> Optional[str]: + """ + Generate rels path from object identifier without loading the object. + + Args: + identifier: Object identifier (uuid.version) + + Returns: + Path to the rels file, or None if metadata not found + """ + metadata = self._metadata.get(identifier) + if metadata is None: + return None + return self._gen_rels_path_from_metadata(metadata) + def to_epc(self, load_all: bool = False) -> Epc: """ Convert to standard Epc instance. @@ -714,44 +750,102 @@ def to_epc(self, load_all: bool = False) -> Epc: def get_obj_rels(self, obj: Union[str, Uri, Any]) -> List[Relationship]: """ Get all relationships for a given object. + Merges relationships from the EPC file with in-memory additional relationships. + + Optimized to avoid loading the object when identifier/URI is provided. + :param obj: the object or its identifier/URI :return: list of Relationship objects """ rels = [] + obj_identifier = None + rels_path = None - # read rels from EPC file + # Get identifier without loading the object if isinstance(obj, (str, Uri)): - obj = self.get_object_by_identifier(obj) - with zipfile.ZipFile(self.epc_file_path, "r") as zf: + # Convert URI to identifier if needed + if isinstance(obj, Uri) or parse_uri(obj) is not None: + uri = parse_uri(obj) if isinstance(obj, str) else obj + assert uri is not None and uri.uuid is not None + obj_identifier = uri.uuid + "." + (uri.version or "") + else: + obj_identifier = obj + + # Generate rels path from metadata without loading the object + rels_path = self._gen_rels_path_from_identifier(obj_identifier) + else: + # We have the actual object + obj_identifier = get_obj_identifier(obj) rels_path = gen_rels_path(obj, self.export_version) - try: - rels_data = zf.read(rels_path) - self.stats.bytes_read += len(rels_data) - relationships = read_energyml_xml_bytes(rels_data, Relationships) - rels.extend(relationships.relationship) - except KeyError: - # No rels file found for this object - pass + + # Read rels from EPC file using efficient context manager + if rels_path is not None: + with self._get_zip_file() as zf: + try: + rels_data = zf.read(rels_path) + self.stats.bytes_read += len(rels_data) + relationships = read_energyml_xml_bytes(rels_data, Relationships) + rels.extend(relationships.relationship) + except KeyError: + # No rels file found for this object + pass + + # Merge with in-memory additional relationships + if obj_identifier and obj_identifier in self.additional_rels: + rels.extend(self.additional_rels[obj_identifier]) return rels def get_h5_file_paths(self, obj: Union[str, Uri, Any]) -> List[str]: """ - Get all HDF5 file paths referenced in the EPC file (from rels to external resources) + Get all HDF5 file paths referenced in the EPC file (from rels to external resources). + Optimized to avoid loading the object when identifier/URI is provided. + :param obj: the object or its identifier/URI :return: list of HDF5 file paths """ if self.force_h5_path is not None: return [self.force_h5_path] h5_paths = set() - + + obj_identifier = None + rels_path = None + + # Get identifier and rels path without loading the object if isinstance(obj, (str, Uri)): - obj = self.get_object_by_identifier(obj) + # Convert URI to identifier if needed + if isinstance(obj, Uri) or parse_uri(obj) is not None: + uri = parse_uri(obj) if isinstance(obj, str) else obj + assert uri is not None and uri.uuid is not None + obj_identifier = uri.uuid + "." + (uri.version or "") + else: + obj_identifier = obj + + # Generate rels path from metadata without loading the object + rels_path = self._gen_rels_path_from_identifier(obj_identifier) + else: + # We have the actual object + obj_identifier = get_obj_identifier(obj) + rels_path = gen_rels_path(obj, self.export_version) - for rels in self.additional_rels.get(get_obj_identifier(obj), []): + # Check in-memory additional rels first + for rels in self.additional_rels.get(obj_identifier, []): if rels.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(): h5_paths.add(rels.target) + # Also check rels from the EPC file + if rels_path is not None: + with self._get_zip_file() as zf: + try: + rels_data = zf.read(rels_path) + self.stats.bytes_read += len(rels_data) + relationships = read_energyml_xml_bytes(rels_data, Relationships) + for rel in relationships.relationship: + if rel.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(): + h5_paths.add(rel.target) + except KeyError: + pass + if len(h5_paths) == 0: # search if an h5 file has the same name than the epc file epc_folder = os.path.dirname(self.epc_file_path) @@ -867,7 +961,7 @@ def validate_all_objects(self, fast_mode: bool = True) -> Dict[str, List[str]]: return results - def get_object_dependencies(self, identifier: str) -> List[str]: + def get_object_dependencies(self, identifier: Union[str, Uri]) -> List[str]: """ Get list of object identifiers that this object depends on. @@ -1198,26 +1292,28 @@ def update_object(self, obj: Any) -> str: logging.error(f"Failed to update object {identifier}: {e}") raise RuntimeError(f"Failed to update object in EPC: {e}") - def add_rels_for_object(self, identifier: Union[str, Uri, Any], relationships: List[Relationship]) -> None: + def add_rels_for_object( + self, identifier: Union[str, Uri, Any], relationships: List[Relationship], write_immediately: bool = False + ) -> None: """ Add additional relationships for a specific object. + Relationships are stored in memory and can be written immediately or deferred + until write_pending_rels() is called, or when the EPC is closed. + Args: identifier: The identifier of the object, can be str, Uri, or the object itself relationships: List of Relationship objects to add + write_immediately: If True, writes pending rels to disk immediately after adding. + If False (default), rels are kept in memory for batching. """ is_uri = isinstance(identifier, Uri) or (isinstance(identifier, str) and parse_uri(identifier) is not None) - object_instance = None if is_uri: uri = parse_uri(identifier) if isinstance(identifier, str) else identifier assert uri is not None and uri.uuid is not None identifier = uri.uuid + "." + (uri.version or "") - object_instance = self.get_object_by_identifier(identifier) elif not isinstance(identifier, str): identifier = get_obj_identifier(identifier) - object_instance = self.get_object_by_identifier(identifier) - else: - object_instance = identifier assert isinstance(identifier, str) @@ -1225,30 +1321,112 @@ def add_rels_for_object(self, identifier: Union[str, Uri, Any], relationships: L self.additional_rels[identifier] = [] self.additional_rels[identifier].extend(relationships) - if len(self.additional_rels[identifier]) > 0: - # Create temporary file for updated EPC - with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: - temp_path = temp_file.name - # Update the .rels file for this object by updating the rels file in the EPC - with ( - zipfile.ZipFile(self.epc_file_path, "r") as source_zip, - zipfile.ZipFile(temp_path, "a") as target_zip, - ): - # copy all files except the rels file to be updated - for item in source_zip.infolist(): - if item.filename != gen_rels_path(object_instance, self.export_version): - buffer = source_zip.read(item.filename) - target_zip.writestr(item, buffer) - - self._update_existing_rels_files( - Relationships(relationship=relationships), - gen_rels_path(object_instance, self.export_version), - source_zip, - target_zip, - ) + logging.debug(f"Added {len(relationships)} relationships for object {identifier} (in-memory)") + + if write_immediately: + self.write_pending_rels() + + def write_pending_rels(self) -> int: + """ + Write all pending in-memory relationships to the EPC file efficiently. + + This method reads existing rels, merges them in memory with pending rels, + then rewrites only the affected rels files in a single ZIP update. + + Returns: + Number of rels files updated + """ + if not self.additional_rels: + logging.debug("No pending relationships to write") + return 0 + + updated_count = 0 + + # Step 1: Read existing rels and merge with pending rels in memory + merged_rels: Dict[str, Relationships] = {} # rels_path -> merged Relationships + + with self._get_zip_file() as zf: + for obj_identifier, new_relationships in self.additional_rels.items(): + # Generate rels path from metadata without loading the object + rels_path = self._gen_rels_path_from_identifier(obj_identifier) + if rels_path is None: + logging.warning(f"Could not generate rels path for {obj_identifier}") + continue + + # Read existing rels from ZIP + existing_relationships = [] + try: + if rels_path in zf.namelist(): + rels_data = zf.read(rels_path) + existing_rels = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels and existing_rels.relationship: + existing_relationships = list(existing_rels.relationship) + except Exception as e: + logging.debug(f"Could not read existing rels for {rels_path}: {e}") + + # Merge new relationships, avoiding duplicates + for new_rel in new_relationships: + # Check if relationship already exists + rel_exists = any( + r.target == new_rel.target and r.type_value == new_rel.type_value + for r in existing_relationships + ) + + if not rel_exists: + # Ensure unique ID + cpt = 0 + new_rel_id = new_rel.id + while any(r.id == new_rel_id for r in existing_relationships): + new_rel_id = f"{new_rel.id}_{cpt}" + cpt += 1 + if new_rel_id != new_rel.id: + new_rel.id = new_rel_id + + existing_relationships.append(new_rel) + + # Store merged result + if existing_relationships: + merged_rels[rels_path] = Relationships(relationship=existing_relationships) + + # Step 2: Write updated rels back to ZIP (create temp, copy all, replace) + if not merged_rels: + return 0 + + with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: + temp_path = temp_file.name + + try: + # Copy entire ZIP, replacing only the updated rels files + with self._get_zip_file() as source_zf: + with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: + # Copy all files except the rels we're updating + for item in source_zf.infolist(): + if item.filename not in merged_rels: + buffer = source_zf.read(item.filename) + target_zf.writestr(item, buffer) + + # Write updated rels files + for rels_path, relationships in merged_rels.items(): + rels_xml = serialize_xml(relationships) + target_zf.writestr(rels_path, rels_xml) + updated_count += 1 + + # Replace original with updated ZIP shutil.move(temp_path, self.epc_file_path) self._reopen_persistent_zip() + # Clear pending rels after successful write + self.additional_rels.clear() + + logging.info(f"Wrote {updated_count} rels files to EPC") + return updated_count + + except Exception as e: + if os.path.exists(temp_path): + os.unlink(temp_path) + logging.error(f"Failed to write pending rels: {e}") + raise + def _compute_object_rels(self, obj: Any, obj_identifier: str) -> List[Relationship]: """ Compute relationships for a given object (SOURCE relationships). @@ -1269,11 +1447,18 @@ def _compute_object_rels(self, obj: Any, obj_identifier: str) -> List[Relationsh for dor in direct_dors: try: target_identifier = get_obj_identifier(dor) - target_rels_path = gen_rels_path(dor, self.export_version) + + # Get target file path from metadata without processing DOR + # The relationship target should be the object's file path, not its rels path + if target_identifier in self._metadata: + target_path = self._metadata[target_identifier].file_path + else: + # Fall back to generating path from DOR if metadata not found + target_path = gen_energyml_object_path(dor, self.export_version) # Create SOURCE relationship (this object -> target object) rel = Relationship( - target=target_rels_path, + target=target_path, type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), id=f"_{obj_identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", ) @@ -1313,266 +1498,257 @@ def _get_objects_referencing(self, target_identifier: str) -> List[Tuple[str, An return referencing_objects - def _update_existing_rels_files( - self, rels: Relationships, rel_path: str, source_zip: zipfile.ZipFile, target_zip: zipfile.ZipFile - ) -> None: - """Merge new relationships with existing .rels, reading from source and writing to target ZIP. + def _merge_rels( + self, new_rels: List[Relationship], existing_rels: List[Relationship] + ) -> List[Relationship]: + """Merge new relationships with existing ones, avoiding duplicates and ensuring unique IDs. Args: - rels: New Relationships to add - rel_path: Path to the .rels file - source_zip: ZIP to read existing rels from - target_zip: ZIP to write updated rels to - """ - # print("@ Updating rels file:", rel_path) - existing_relationships = [] - try: - if rel_path in source_zip.namelist(): - rels_data = source_zip.read(rel_path) - existing_rels = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels and existing_rels.relationship: - existing_relationships = list(existing_rels.relationship) - except Exception as e: - logging.debug(f"Could not read existing rels for {rel_path}: {e}") + new_rels: New relationships to add + existing_rels: Existing relationships - for new_rel in rels.relationship: + Returns: + Merged list of relationships + """ + merged = list(existing_rels) + + for new_rel in new_rels: + # Check if relationship already exists rel_exists = any( - r.target == new_rel.target and r.type_value == new_rel.type_value for r in existing_relationships + r.target == new_rel.target and r.type_value == new_rel.type_value for r in merged ) - cpt = 0 - new_rel_id = new_rel.id - while any(r.id == new_rel_id for r in existing_relationships): - new_rel_id = f"{new_rel.id}_{cpt}" - cpt += 1 - if new_rel_id != new_rel.id: - new_rel.id = new_rel_id + if not rel_exists: - existing_relationships.append(new_rel) - - if existing_relationships: - updated_rels = Relationships(relationship=existing_relationships) - updated_rels_xml = serialize_xml(updated_rels) - target_zip.writestr(rel_path, updated_rels_xml) + # Ensure unique ID + cpt = 0 + new_rel_id = new_rel.id + while any(r.id == new_rel_id for r in merged): + new_rel_id = f"{new_rel.id}_{cpt}" + cpt += 1 + if new_rel_id != new_rel.id: + new_rel.id = new_rel_id + + merged.append(new_rel) + + return merged - def _update_rels_files( - self, - obj: Any, - metadata: EpcObjectMetadata, - source_zip: zipfile.ZipFile, - target_zip: zipfile.ZipFile, - ) -> List[str]: - """ - Update all necessary .rels files when adding/updating an object. - - This includes: - 1. The object's own .rels file (for objects it references) - 2. The .rels files of objects that now reference this object (DESTINATION relationships) - - Args: - obj: The object being added/updated - metadata: Metadata for the object - source_zip: Source ZIP file to read existing rels from - target_zip: Target ZIP file to write updated rels to + def _add_object_to_file(self, obj: Any, metadata: EpcObjectMetadata) -> None: + """Add object to the EPC file efficiently. - returns: - List of updated .rels file paths + Reads existing rels, computes updates in memory, then writes everything + in a single ZIP operation. """ + xml_content = serialize_xml(obj) obj_identifier = metadata.identifier - updated_rels_paths = [] - if not obj_identifier: - logging.warning("Object identifier is None, skipping rels update") - return updated_rels_paths - - # 1. Create/update the object's own .rels file - obj_rels_path = gen_rels_path(obj, self.export_version) - obj_relationships = self._compute_object_rels(obj, obj_identifier) - - if obj_relationships: - self._update_existing_rels_files( - Relationships(relationship=obj_relationships), obj_rels_path, source_zip, target_zip - ) - updated_rels_paths.append(obj_rels_path) - - # 2. Update .rels files of objects referenced by this object - # These objects need DESTINATION relationships pointing to our object - direct_dors = get_direct_dor_list(obj) - - logging.debug(f"Updating rels for object {obj_identifier}, found {len(direct_dors)} direct DORs") - - for dor in direct_dors: - try: - target_rels_path = gen_rels_path(dor, self.export_version) - target_identifier = get_obj_identifier(dor) - - # Add DESTINATION relationship from target to our object - dest_rel = Relationship( - target=metadata.file_path, - type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), - id=f"_{target_identifier}_{get_obj_type(get_obj_usable_class(obj))}_{obj_identifier}", - ) - - self._update_existing_rels_files( - Relationships(relationship=[dest_rel]), target_rels_path, source_zip, target_zip - ) - updated_rels_paths.append(target_rels_path) - - except Exception as e: - logging.warning(f"Failed to update rels for referenced object: {e}") - return updated_rels_paths - - def _remove_rels_files( - self, obj: Any, metadata: EpcObjectMetadata, source_zip: zipfile.ZipFile, target_zip: zipfile.ZipFile - ) -> None: - """ - Remove/update .rels files when removing an object. - - This includes: - 1. Removing the object's own .rels file - 2. Removing DESTINATION relationships from objects that this object referenced - - Args: - obj: The object being removed - metadata: Metadata for the object - source_zip: Source ZIP file to read existing rels from - target_zip: Target ZIP file to write updated rels to - """ - # obj_identifier = metadata.identifier - - # 1. The object's own .rels file will be automatically excluded by not copying it - # obj_rels_path = gen_rels_path(obj, self.export_version) - - # 2. Update .rels files of objects that were referenced by this object - # Remove DESTINATION relationships that pointed to our object - direct_dors = get_direct_dor_list(obj) - - for dor in direct_dors: - try: - target_identifier = get_obj_identifier(dor) - - # Check if target object exists - if target_identifier not in self._metadata: - continue - - target_obj = self.get_object_by_identifier(target_identifier) - if target_obj is None: - continue - - target_rels_path = gen_rels_path(target_obj, self.export_version) - - # Read existing rels for the target object - existing_relationships = [] + assert obj_identifier is not None, "Object identifier must not be None" + + # Step 1: Compute which rels files need to be updated and prepare their content + rels_updates: Dict[str, str] = {} # rels_path -> XML content + + with self._get_zip_file() as zf: + # 1a. Object's own .rels file + obj_rels_path = gen_rels_path(obj, self.export_version) + obj_relationships = self._compute_object_rels(obj, obj_identifier) + + if obj_relationships: + # Read existing rels + existing_rels = [] try: - if target_rels_path in source_zip.namelist(): - rels_data = source_zip.read(target_rels_path) - existing_rels = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels and existing_rels.relationship: - existing_relationships = list(existing_rels.relationship) - except Exception as e: - logging.debug(f"Could not read existing rels for {target_identifier}: {e}") - - # Remove DESTINATION relationship that pointed to our object - updated_relationships = [ - r - for r in existing_relationships - if not ( - r.target == metadata.file_path - and r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type() + if obj_rels_path in zf.namelist(): + rels_data = zf.read(obj_rels_path) + existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels_obj and existing_rels_obj.relationship: + existing_rels = list(existing_rels_obj.relationship) + except Exception: + pass + + # Merge and serialize + merged_rels = self._merge_rels(obj_relationships, existing_rels) + if merged_rels: + rels_updates[obj_rels_path] = serialize_xml(Relationships(relationship=merged_rels)) + + # 1b. Update rels of referenced objects (DESTINATION relationships) + direct_dors = get_direct_dor_list(obj) + for dor in direct_dors: + try: + target_identifier = get_obj_identifier(dor) + + # Generate rels path from metadata without processing DOR + target_rels_path = self._gen_rels_path_from_identifier(target_identifier) + if target_rels_path is None: + # Fall back to generating from DOR if metadata not found + target_rels_path = gen_rels_path(dor, self.export_version) + + # Create DESTINATION relationship + dest_rel = Relationship( + target=metadata.file_path, + type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + id=f"_{target_identifier}_{get_obj_type(get_obj_usable_class(obj))}_{obj_identifier}", ) - ] - - # Write updated rels file (or skip if no relationships left) - if updated_relationships: - updated_rels = Relationships(relationship=updated_relationships) - updated_rels_xml = serialize_xml(updated_rels) - target_zip.writestr(target_rels_path, updated_rels_xml) - - except Exception as e: - logging.warning(f"Failed to update rels for referenced object during removal: {e}") - - def _add_object_to_file(self, obj: Any, metadata: EpcObjectMetadata) -> None: - """Add object to the EPC file by safely rewriting the ZIP archive. - - The method creates a temporary ZIP archive, copies all entries except - the ones to be updated (content types and relevant .rels), then writes - the new object, merges and writes updated .rels files and the - updated [Content_Types].xml before replacing the original file. This - avoids issues with append mode creating overlapped entries. - """ - xml_content = serialize_xml(obj) - - # Create temporary file for updated EPC + + # Read existing rels + existing_rels = [] + try: + if target_rels_path in zf.namelist(): + rels_data = zf.read(target_rels_path) + existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels_obj and existing_rels_obj.relationship: + existing_rels = list(existing_rels_obj.relationship) + except Exception: + pass + + # Merge and serialize + merged_rels = self._merge_rels([dest_rel], existing_rels) + if merged_rels: + rels_updates[target_rels_path] = serialize_xml(Relationships(relationship=merged_rels)) + + except Exception as e: + logging.warning(f"Failed to prepare rels update for referenced object: {e}") + + # 1c. Update [Content_Types].xml + content_types_xml = self._update_content_types_xml(zf, metadata, add=True) + + # Step 2: Write everything to new ZIP with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: temp_path = temp_file.name - + try: - with zipfile.ZipFile(self.epc_file_path, "r") as source_zip: - with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zip: - - # Add new object file - target_zip.writestr(metadata.file_path, xml_content) - - # Update .rels files by merging with existing ones read from source - updated_rels_paths = self._update_rels_files(obj, metadata, source_zip, target_zip) - - # Copy all existing files except [Content_Types].xml, the object file, and rels we already updated - for item in source_zip.infolist(): - if ( - item.filename == get_epc_content_type_path() - or item.filename == metadata.file_path - or item.filename in updated_rels_paths - ): - continue - data = source_zip.read(item.filename) - target_zip.writestr(item, data) - - # Update [Content_Types].xml - updated_content_types = self._update_content_types_xml(source_zip, metadata, add=True) - target_zip.writestr(get_epc_content_type_path(), updated_content_types) - - # Replace original file with updated version + with self._get_zip_file() as source_zf: + with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: + # Write new object + target_zf.writestr(metadata.file_path, xml_content) + + # Write updated [Content_Types].xml + target_zf.writestr(get_epc_content_type_path(), content_types_xml) + + # Write updated rels files + for rels_path, rels_xml in rels_updates.items(): + target_zf.writestr(rels_path, rels_xml) + + # Copy all other files + files_to_skip = {get_epc_content_type_path(), metadata.file_path} + files_to_skip.update(rels_updates.keys()) + + for item in source_zf.infolist(): + if item.filename not in files_to_skip: + buffer = source_zf.read(item.filename) + target_zf.writestr(item, buffer) + + # Replace original shutil.move(temp_path, self.epc_file_path) self._reopen_persistent_zip() - + except Exception as e: - # Clean up temp file on error if os.path.exists(temp_path): os.unlink(temp_path) logging.error(f"Failed to add object to EPC file: {e}") raise def _remove_object_from_file(self, metadata: EpcObjectMetadata) -> None: - """Remove object from the EPC file by updating the ZIP archive. + """Remove object from the EPC file efficiently. - Note: This does NOT remove .rels files. Use clean_rels() to remove orphaned relationships. + Reads existing rels, computes updates in memory, then writes everything + in a single ZIP operation. Note: This does NOT remove .rels files. + Use clean_rels() to remove orphaned relationships. """ - - # Create temporary file for updated EPC + # Load object first (needed to process its DORs) + if metadata.identifier is None: + logging.error("Cannot remove object with None identifier") + raise ValueError("Object identifier must not be None") + + obj = self.get_object_by_identifier(metadata.identifier) + if obj is None: + logging.warning(f"Object {metadata.identifier} not found, cannot remove rels") + # Still proceed with removal even if object can't be loaded + + # Step 1: Compute rels updates (remove DESTINATION relationships from referenced objects) + rels_updates: Dict[str, str] = {} # rels_path -> XML content + + if obj is not None: + with self._get_zip_file() as zf: + direct_dors = get_direct_dor_list(obj) + + for dor in direct_dors: + try: + target_identifier = get_obj_identifier(dor) + if target_identifier not in self._metadata: + continue + + # Use metadata to generate rels path without loading the object + target_rels_path = self._gen_rels_path_from_identifier(target_identifier) + if target_rels_path is None: + continue + + # Read existing rels + existing_relationships = [] + try: + if target_rels_path in zf.namelist(): + rels_data = zf.read(target_rels_path) + existing_rels = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels and existing_rels.relationship: + existing_relationships = list(existing_rels.relationship) + except Exception as e: + logging.debug(f"Could not read existing rels for {target_identifier}: {e}") + + # Remove DESTINATION relationship that pointed to our object + updated_relationships = [ + r for r in existing_relationships + if not ( + r.target == metadata.file_path + and r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type() + ) + ] + + # Only update if relationships remain + if updated_relationships: + rels_updates[target_rels_path] = serialize_xml( + Relationships(relationship=updated_relationships) + ) + + except Exception as e: + logging.warning(f"Failed to update rels for referenced object during removal: {e}") + + # Update [Content_Types].xml + content_types_xml = self._update_content_types_xml(zf, metadata, add=False) + else: + # If we couldn't load the object, still update content types + with self._get_zip_file() as zf: + content_types_xml = self._update_content_types_xml(zf, metadata, add=False) + + # Step 2: Write everything to new ZIP with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: temp_path = temp_file.name - + try: - # Copy existing EPC to temp file, excluding the object to remove - with zipfile.ZipFile(self.epc_file_path, "r") as source_zip: - with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zip: - # Copy all existing files except the one to remove and [Content_Types].xml - # We keep .rels files as-is (they will be cleaned by clean_rels() if needed) - for item in source_zip.infolist(): - if item.filename not in [metadata.file_path, get_epc_content_type_path()]: - data = source_zip.read(item.filename) - target_zip.writestr(item, data) - - # Update [Content_Types].xml - updated_content_types = self._update_content_types_xml(source_zip, metadata, add=False) - target_zip.writestr(get_epc_content_type_path(), updated_content_types) - - # Replace original file with updated version + with self._get_zip_file() as source_zf: + with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: + # Write updated [Content_Types].xml + target_zf.writestr(get_epc_content_type_path(), content_types_xml) + + # Write updated rels files + for rels_path, rels_xml in rels_updates.items(): + target_zf.writestr(rels_path, rels_xml) + + # Copy all files except removed object, its rels, and files we're updating + obj_rels_path = self._gen_rels_path_from_metadata(metadata) + files_to_skip = {get_epc_content_type_path(), metadata.file_path} + if obj_rels_path: + files_to_skip.add(obj_rels_path) + files_to_skip.update(rels_updates.keys()) + + for item in source_zf.infolist(): + if item.filename not in files_to_skip: + buffer = source_zf.read(item.filename) + target_zf.writestr(item, buffer) + + # Replace original shutil.move(temp_path, self.epc_file_path) self._reopen_persistent_zip() - - except Exception: - # Clean up temp file on error + + except Exception as e: if os.path.exists(temp_path): os.unlink(temp_path) + logging.error(f"Failed to remove object from EPC file: {e}") raise def _update_content_types_xml( @@ -1657,7 +1833,7 @@ def clean_rels(self) -> Dict[str, int]: temp_path = temp_file.name try: - with zipfile.ZipFile(self.epc_file_path, "r") as source_zip: + with self._get_zip_file() as source_zip: with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zip: # Get all existing object file paths for validation existing_object_files = {metadata.file_path for metadata in self._metadata.values()} @@ -1814,7 +1990,7 @@ def rebuild_all_rels(self, clean_first: bool = True) -> Dict[str, int]: continue # metadata = self._metadata[identifier] - obj_rels_path = gen_rels_path(obj, self.export_version) + obj_rels_path = self._gen_rels_path_from_identifier(identifier) # Get all DORs (objects this object references) dors = get_direct_dor_list(obj) @@ -1840,7 +2016,7 @@ def rebuild_all_rels(self, clean_first: bool = True) -> Dict[str, int]: except Exception as e: logging.debug(f"Failed to create SOURCE relationship: {e}") - if relationships: + if relationships and obj_rels_path: if obj_rels_path not in rels_files: rels_files[obj_rels_path] = Relationships(relationship=[]) rels_files[obj_rels_path].relationship.extend(relationships) @@ -1851,12 +2027,14 @@ def rebuild_all_rels(self, clean_first: bool = True) -> Dict[str, int]: # Add DESTINATION relationships for target_identifier, source_list in reverse_references.items(): try: - target_obj = self.get_object_by_identifier(target_identifier) - if target_obj is None: + if target_identifier not in self._metadata: continue target_metadata = self._metadata[target_identifier] - target_rels_path = gen_rels_path(target_obj, self.export_version) + target_rels_path = self._gen_rels_path_from_identifier(target_identifier) + + if not target_rels_path: + continue # Create DESTINATION relationships for each object that references this one for source_identifier, source_obj in source_list: @@ -1887,7 +2065,7 @@ def rebuild_all_rels(self, clean_first: bool = True) -> Dict[str, int]: temp_path = temp_file.name try: - with zipfile.ZipFile(self.epc_file_path, "r") as source_zip: + with self._get_zip_file() as source_zip: with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zip: # Copy all non-.rels files for item in source_zip.infolist(): @@ -1933,7 +2111,7 @@ def dumps_epc_content_and_files_lists(self): content_list = [] file_list = [] - with zipfile.ZipFile(self.epc_file_path, "r") as zf: + with self._get_zip_file() as zf: file_list = zf.namelist() for item in zf.infolist(): From 1579a3405e1e742f1efa859cf4149ef315e01c0a Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Tue, 3 Feb 2026 16:15:56 +0100 Subject: [PATCH 13/70] updating storage interface --- .../src/energyml/utils/storage_interface.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/energyml-utils/src/energyml/utils/storage_interface.py b/energyml-utils/src/energyml/utils/storage_interface.py index d07299b..99a58d1 100644 --- a/energyml-utils/src/energyml/utils/storage_interface.py +++ b/energyml-utils/src/energyml/utils/storage_interface.py @@ -37,6 +37,7 @@ from typing import Any, Dict, List, Optional, Union, Tuple from energyml.utils.uri import Uri +from energyml.opc.opc import Relationship import numpy as np @@ -296,6 +297,18 @@ def list_objects( """ pass + @abstractmethod + def get_obj_rels(self, obj: Union[str, Uri, Any]) -> List[Relationship]: + """Get relationships for an object. + + Args: + obj: The object identifier/URI or the object itself + + Returns: + List of Relationship objects + """ + pass + @abstractmethod def close(self) -> None: """ From 56678b34d39433108f0697eb4eefb3c5f9562eec Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Wed, 4 Feb 2026 10:30:55 +0100 Subject: [PATCH 14/70] stream reader rels improvement --- .../src/energyml/utils/epc_stream.py | 670 +++++++++++-- energyml-utils/tests/test_epc_stream.py | 934 ++++++++++++++++++ 2 files changed, 1520 insertions(+), 84 deletions(-) create mode 100644 energyml-utils/tests/test_epc_stream.py diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index 2209f40..dfeb2aa 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -45,6 +45,27 @@ ) from energyml.utils.serialization import read_energyml_xml_bytes, serialize_xml from .xml import is_energyml_content_type +from enum import Enum + + +class RelsUpdateMode(Enum): + """ + Relationship update modes for EPC file management. + + UPDATE_AT_MODIFICATION: Maintain relationships in real-time as objects are added/removed/modified. + This provides the best consistency but may be slower for bulk operations. + + UPDATE_ON_CLOSE: Rebuild all relationships when closing the EPC file. + This is more efficient for bulk operations but relationships are only + consistent after closing. + + MANUAL: No automatic relationship updates. User must manually call rebuild_all_rels(). + This provides maximum control and performance for advanced use cases. + """ + + UPDATE_AT_MODIFICATION = "update_at_modification" + UPDATE_ON_CLOSE = "update_on_close" + MANUAL = "manual" @dataclass(frozen=True) @@ -101,6 +122,15 @@ class EpcStreamReader(EnergymlStorageInterface): - Streaming validation: Validate objects without full loading - Batch operations: Efficient bulk operations - Context management: Automatic resource cleanup + - Flexible relationship management: Three modes for updating object relationships + + Relationship Update Modes: + - UPDATE_AT_MODIFICATION: Maintains relationships in real-time as objects are added/removed/modified. + Best for maintaining consistency but may be slower for bulk operations. + - UPDATE_ON_CLOSE: Rebuilds all relationships when closing the EPC file (default). + More efficient for bulk operations but relationships only consistent after closing. + - MANUAL: No automatic relationship updates. User must manually call rebuild_all_rels(). + Maximum control and performance for advanced use cases. Performance optimizations: - Pre-compiled regex patterns for 15-75% faster parsing @@ -119,6 +149,7 @@ def __init__( force_h5_path: Optional[str] = None, keep_open: bool = False, force_title_load: bool = False, + rels_update_mode: RelsUpdateMode = RelsUpdateMode.UPDATE_ON_CLOSE, ): """ Initialize the EPC stream reader. @@ -132,6 +163,7 @@ def __init__( force_h5_path: Optional forced HDF5 file path for external resources. If set, all arrays will be read/written from/to this path. keep_open: If True, keeps the ZIP file open for better performance with multiple operations. File is closed only when instance is deleted or close() is called. force_title_load: If True, forces loading object titles when listing objects (may impact performance) + rels_update_mode: Mode for updating relationships (UPDATE_AT_MODIFICATION, UPDATE_ON_CLOSE, or MANUAL) """ self.epc_file_path = Path(epc_file_path) self.cache_size = cache_size @@ -140,6 +172,7 @@ def __init__( self.cache_opened_h5 = None self.keep_open = keep_open self.force_title_load = force_title_load + self.rels_update_mode = rels_update_mode is_new_file = False @@ -689,10 +722,10 @@ def get_core_properties(self) -> Optional[CoreProperties]: def _gen_rels_path_from_metadata(self, metadata: EpcObjectMetadata) -> str: """ Generate rels path from object metadata without loading the object. - + Args: metadata: Object metadata containing file path information - + Returns: Path to the rels file for this object """ @@ -704,16 +737,16 @@ def _gen_rels_path_from_metadata(self, metadata: EpcObjectMetadata) -> str: else: obj_folder = "" obj_file_name = obj_path - + return f"{obj_folder}_rels/{obj_file_name}.rels" - + def _gen_rels_path_from_identifier(self, identifier: str) -> Optional[str]: """ Generate rels path from object identifier without loading the object. - + Args: identifier: Object identifier (uuid.version) - + Returns: Path to the rels file, or None if metadata not found """ @@ -722,6 +755,303 @@ def _gen_rels_path_from_identifier(self, identifier: str) -> Optional[str]: return None return self._gen_rels_path_from_metadata(metadata) + def _update_rels_for_new_object(self, obj: Any, obj_identifier: str) -> None: + """ + Update relationships when a new object is added (UPDATE_AT_MODIFICATION mode). + + Creates the object's rels file and updates destination objects' rels files. + + Args: + obj: The newly added object + obj_identifier: The identifier of the new object + """ + metadata = self._metadata.get(obj_identifier) + if not metadata: + logging.warning(f"Metadata not found for {obj_identifier}") + return + + # Get all objects this new object references + direct_dors = get_direct_dor_list(obj) + + # Build SOURCE relationships for this object + source_relationships = [] + dest_updates: Dict[str, Relationship] = {} # target_identifier -> DESTINATION relationship + + for dor in direct_dors: + try: + target_identifier = get_obj_identifier(dor) + if target_identifier not in self._metadata: + continue + + target_metadata = self._metadata[target_identifier] + + # Create SOURCE relationship (this object -> target) + source_rel = Relationship( + target=target_metadata.file_path, + type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + id=f"_{obj_identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", + ) + source_relationships.append(source_rel) + + # Create DESTINATION relationship (target -> this object) + dest_rel = Relationship( + target=metadata.file_path, + type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + id=f"_{target_identifier}_{get_obj_type(get_obj_usable_class(obj))}_{obj_identifier}", + ) + dest_updates[target_identifier] = dest_rel + + except Exception as e: + logging.warning(f"Failed to create relationship for DOR: {e}") + + # Write the new object's rels file and update destination rels + self._write_rels_updates(obj_identifier, source_relationships, dest_updates) + + def _update_rels_for_modified_object(self, obj: Any, obj_identifier: str, old_dors: List[Any]) -> None: + """ + Update relationships when an object is modified (UPDATE_AT_MODIFICATION mode). + + Compares old and new DORs, updates the object's rels file and affected destination rels. + + Args: + obj: The modified object + obj_identifier: The identifier of the modified object + old_dors: List of DORs from the previous version of the object + """ + metadata = self._metadata.get(obj_identifier) + if not metadata: + logging.warning(f"Metadata not found for {obj_identifier}") + return + + # Get new DORs + new_dors = get_direct_dor_list(obj) + + # Convert to sets of identifiers for comparison + old_dor_ids = {get_obj_identifier(dor) for dor in old_dors if get_obj_identifier(dor) in self._metadata} + new_dor_ids = {get_obj_identifier(dor) for dor in new_dors if get_obj_identifier(dor) in self._metadata} + + # Find added and removed references + added_dor_ids = new_dor_ids - old_dor_ids + removed_dor_ids = old_dor_ids - new_dor_ids + + # Build new SOURCE relationships (only for new DORs) + source_relationships = [] + dest_updates: Dict[str, Relationship] = {} + + # Create relationships for all new DORs + for dor in new_dors: + target_identifier = get_obj_identifier(dor) + if target_identifier not in self._metadata: + continue + + target_metadata = self._metadata[target_identifier] + + # SOURCE relationship + source_rel = Relationship( + target=target_metadata.file_path, + type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + id=f"_{obj_identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", + ) + source_relationships.append(source_rel) + + # DESTINATION relationship (for added DORs only) + if target_identifier in added_dor_ids: + dest_rel = Relationship( + target=metadata.file_path, + type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + id=f"_{target_identifier}_{get_obj_type(get_obj_usable_class(obj))}_{obj_identifier}", + ) + dest_updates[target_identifier] = dest_rel + + # For removed DORs, we need to remove DESTINATION relationships + removals: Dict[str, str] = {} # target_identifier -> relationship_id_pattern + for removed_id in removed_dor_ids: + removals[removed_id] = f"_{removed_id}_.*_{obj_identifier}" + + # Write updates + self._write_rels_updates(obj_identifier, source_relationships, dest_updates, removals) + + def _update_rels_for_removed_object(self, obj_identifier: str, obj: Optional[Any] = None) -> None: + """ + Update relationships when an object is removed (UPDATE_AT_MODIFICATION mode). + + Removes the object's rels file and removes references from destination objects' rels. + + Args: + obj_identifier: The identifier of the removed object + obj: The object being removed (if available) + """ + # Load object if not provided + if obj is None: + obj = self.get_object_by_identifier(obj_identifier) + + if obj is None: + logging.warning(f"Cannot update rels for removed object {obj_identifier}: object not found") + return + + # Get all objects this object references + direct_dors = get_direct_dor_list(obj) + + # Build removal patterns for DESTINATION relationships in referenced objects + removals: Dict[str, str] = {} # target_identifier -> relationship_id_pattern + for dor in direct_dors: + try: + target_identifier = get_obj_identifier(dor) + if target_identifier not in self._metadata: + continue + + # Pattern to match DESTINATION relationships pointing to the removed object + removals[target_identifier] = f"_{target_identifier}_.*_{obj_identifier}" + + except Exception as e: + logging.warning(f"Failed to process DOR for removal: {e}") + + # Write updates (no source relationships, no dest updates, only removals) + self._write_rels_updates(obj_identifier, [], {}, removals, delete_source_rels=True) + + def _write_rels_updates( + self, + source_identifier: str, + source_relationships: List[Relationship], + dest_updates: Dict[str, Relationship], + removals: Optional[Dict[str, str]] = None, + delete_source_rels: bool = False, + ) -> None: + """ + Write relationship updates to the EPC file efficiently. + + Args: + source_identifier: Identifier of the source object + source_relationships: List of SOURCE relationships for the source object + dest_updates: Dict mapping target_identifier to DESTINATION relationship to add + removals: Dict mapping target_identifier to regex pattern for relationships to remove + delete_source_rels: If True, delete the source object's rels file entirely + """ + import re + + removals = removals or {} + rels_updates: Dict[str, str] = {} # rels_path -> XML content + files_to_delete: List[str] = [] + + with self._get_zip_file() as zf: + # 1. Handle source object's rels file + if not delete_source_rels: + source_rels_path = self._gen_rels_path_from_identifier(source_identifier) + if source_rels_path: + # Read existing rels (excluding SOURCE_OBJECT type, we're replacing them) + existing_rels = [] + try: + if source_rels_path in zf.namelist(): + rels_data = zf.read(source_rels_path) + existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels_obj and existing_rels_obj.relationship: + # Keep only non-SOURCE relationships (e.g., EXTERNAL_RESOURCE) + existing_rels = [ + r + for r in existing_rels_obj.relationship + if r.type_value != EPCRelsRelationshipType.SOURCE_OBJECT.get_type() + ] + except Exception: + pass + + # Combine with new SOURCE relationships + all_rels = existing_rels + source_relationships + if all_rels: + # Write rels file if there are any relationships (SOURCE or other types) + rels_updates[source_rels_path] = serialize_xml(Relationships(relationship=all_rels)) + elif source_rels_path in zf.namelist() and not all_rels: + # If file exists but no relationships remain, mark for deletion + files_to_delete.append(source_rels_path) + else: + # Mark source rels file for deletion + source_rels_path = self._gen_rels_path_from_identifier(source_identifier) + if source_rels_path: + files_to_delete.append(source_rels_path) + + # 2. Handle destination updates + for target_identifier, dest_rel in dest_updates.items(): + target_rels_path = self._gen_rels_path_from_identifier(target_identifier) + if not target_rels_path: + continue + + # Read existing rels + existing_rels = [] + try: + if target_rels_path in zf.namelist(): + rels_data = zf.read(target_rels_path) + existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels_obj and existing_rels_obj.relationship: + existing_rels = list(existing_rels_obj.relationship) + except Exception: + pass + + # Add new DESTINATION relationship if not already present + rel_exists = any( + r.target == dest_rel.target and r.type_value == dest_rel.type_value for r in existing_rels + ) + + if not rel_exists: + existing_rels.append(dest_rel) + rels_updates[target_rels_path] = serialize_xml(Relationships(relationship=existing_rels)) + + # 3. Handle removals + for target_identifier, pattern in removals.items(): + target_rels_path = self._gen_rels_path_from_identifier(target_identifier) + if not target_rels_path: + continue + + # Read existing rels + existing_rels = [] + try: + if target_rels_path in zf.namelist(): + rels_data = zf.read(target_rels_path) + existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels_obj and existing_rels_obj.relationship: + existing_rels = list(existing_rels_obj.relationship) + except Exception: + pass + + # Filter out relationships matching the pattern + regex = re.compile(pattern) + filtered_rels = [r for r in existing_rels if not (r.id and regex.match(r.id))] + + if len(filtered_rels) != len(existing_rels): + # Something was removed + if filtered_rels: + rels_updates[target_rels_path] = serialize_xml(Relationships(relationship=filtered_rels)) + else: + # No relationships left, mark for deletion + files_to_delete.append(target_rels_path) + + # Write updates to EPC file + if rels_updates or files_to_delete: + with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: + temp_path = temp_file.name + + try: + with self._get_zip_file() as source_zf: + with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: + # Copy all files except those to delete or update + files_to_skip = set(files_to_delete) + for item in source_zf.infolist(): + if item.filename not in files_to_skip and item.filename not in rels_updates: + buffer = source_zf.read(item.filename) + target_zf.writestr(item, buffer) + + # Write updated rels files + for rels_path, rels_xml in rels_updates.items(): + target_zf.writestr(rels_path, rels_xml) + + # Replace original + shutil.move(temp_path, self.epc_file_path) + self._reopen_persistent_zip() + + except Exception as e: + if os.path.exists(temp_path): + os.unlink(temp_path) + logging.error(f"Failed to write rels updates: {e}") + raise + def to_epc(self, load_all: bool = False) -> Epc: """ Convert to standard Epc instance. @@ -747,11 +1077,39 @@ def to_epc(self, load_all: bool = False) -> Epc: return epc + def set_rels_update_mode(self, mode: RelsUpdateMode) -> None: + """ + Change the relationship update mode. + + Args: + mode: The new RelsUpdateMode to use + + Note: + Changing from MANUAL or UPDATE_ON_CLOSE to UPDATE_AT_MODIFICATION + may require calling rebuild_all_rels() first to ensure consistency. + """ + if not isinstance(mode, RelsUpdateMode): + raise ValueError(f"mode must be a RelsUpdateMode enum value, got {type(mode)}") + + old_mode = self.rels_update_mode + self.rels_update_mode = mode + + logging.info(f"Changed relationship update mode from {old_mode.value} to {mode.value}") + + def get_rels_update_mode(self) -> RelsUpdateMode: + """ + Get the current relationship update mode. + + Returns: + The current RelsUpdateMode + """ + return self.rels_update_mode + def get_obj_rels(self, obj: Union[str, Uri, Any]) -> List[Relationship]: """ Get all relationships for a given object. Merges relationships from the EPC file with in-memory additional relationships. - + Optimized to avoid loading the object when identifier/URI is provided. :param obj: the object or its identifier/URI @@ -770,7 +1128,7 @@ def get_obj_rels(self, obj: Union[str, Uri, Any]) -> List[Relationship]: obj_identifier = uri.uuid + "." + (uri.version or "") else: obj_identifier = obj - + # Generate rels path from metadata without loading the object rels_path = self._gen_rels_path_from_identifier(obj_identifier) else: @@ -800,17 +1158,17 @@ def get_h5_file_paths(self, obj: Union[str, Uri, Any]) -> List[str]: """ Get all HDF5 file paths referenced in the EPC file (from rels to external resources). Optimized to avoid loading the object when identifier/URI is provided. - + :param obj: the object or its identifier/URI :return: list of HDF5 file paths """ if self.force_h5_path is not None: return [self.force_h5_path] h5_paths = set() - + obj_identifier = None rels_path = None - + # Get identifier and rels path without loading the object if isinstance(obj, (str, Uri)): # Convert URI to identifier if needed @@ -820,7 +1178,7 @@ def get_h5_file_paths(self, obj: Union[str, Uri, Any]) -> List[str]: obj_identifier = uri.uuid + "." + (uri.version or "") else: obj_identifier = obj - + # Generate rels path from metadata without loading the object rels_path = self._gen_rels_path_from_identifier(obj_identifier) else: @@ -1012,12 +1370,14 @@ def __del__(self): pass # Ignore errors during cleanup def close(self) -> None: - """Close the persistent ZIP file if it's open, recomputing rels first.""" - # Recompute all relationships before closing to ensure consistency - try: - self.rebuild_all_rels(clean_first=True) - except Exception as e: - logging.warning(f"Error rebuilding rels on close: {e}") + """Close the persistent ZIP file if it's open, recomputing rels first if mode is UPDATE_ON_CLOSE.""" + # Recompute all relationships before closing if in UPDATE_ON_CLOSE mode + if self.rels_update_mode == RelsUpdateMode.UPDATE_ON_CLOSE: + try: + self.rebuild_all_rels(clean_first=True) + logging.info("Rebuilt all relationships on close (UPDATE_ON_CLOSE mode)") + except Exception as e: + logging.warning(f"Error rebuilding rels on close: {e}") if self._persistent_zip is not None: try: @@ -1137,6 +1497,10 @@ def add_object(self, obj: Any, file_path: Optional[str] = None, replace_if_exist # Save changes to file self._add_object_to_file(obj, metadata) + # Update relationships if in UPDATE_AT_MODIFICATION mode + if self.rels_update_mode == RelsUpdateMode.UPDATE_AT_MODIFICATION: + self._update_rels_for_new_object(obj, identifier) + # Update stats self.stats.total_objects += 1 @@ -1210,12 +1574,11 @@ def remove_object(self, identifier: Union[str, Uri]) -> bool: def _remove_single_object(self, identifier: str) -> bool: """ Remove a single object by its full identifier. - The rels files of other objects referencing this object are NOT updated. You must update them manually (or close the epc, the rels are regenerated on epc close). + Args: identifier: The full identifier (uuid.version) of the object to remove Returns: True if the object was successfully removed, False otherwise - """ try: if identifier not in self._metadata: @@ -1223,6 +1586,13 @@ def _remove_single_object(self, identifier: str) -> bool: metadata = self._metadata[identifier] + # If in UPDATE_AT_MODIFICATION mode, update rels before removing + obj = None + if self.rels_update_mode == RelsUpdateMode.UPDATE_AT_MODIFICATION: + obj = self.get_object_by_identifier(identifier) + if obj: + self._update_rels_for_removed_object(identifier, obj) + # IMPORTANT: Remove from file FIRST (before clearing cache/metadata) # because _remove_object_from_file needs to load the object to access its DORs self._remove_object_from_file(metadata) @@ -1279,11 +1649,114 @@ def update_object(self, obj: Any) -> str: raise ValueError("Object must have a valid identifier and exist in the EPC file") try: - # Remove existing object - self.remove_object(identifier) + # If in UPDATE_AT_MODIFICATION mode, get old DORs and handle update differently + if self.rels_update_mode == RelsUpdateMode.UPDATE_AT_MODIFICATION: + old_obj = self.get_object_by_identifier(identifier) + old_dors = get_direct_dor_list(old_obj) if old_obj else [] + + # Preserve non-SOURCE/DESTINATION relationships (like EXTERNAL_RESOURCE) before removal + preserved_rels = [] + try: + obj_rels = self.get_obj_rels(identifier) + preserved_rels = [ + r + for r in obj_rels + if r.type_value + not in ( + EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + ) + ] + except Exception: + pass + + # Remove existing object (without rels update since we're replacing it) + # Temporarily switch to MANUAL mode to avoid double updates + original_mode = self.rels_update_mode + self.rels_update_mode = RelsUpdateMode.MANUAL + self.remove_object(identifier) + self.rels_update_mode = original_mode + + # Add updated object (without rels update since we'll do custom update) + self.rels_update_mode = RelsUpdateMode.MANUAL + new_identifier = self.add_object(obj) + self.rels_update_mode = original_mode + + # Now do the specialized update that handles both adds and removes + self._update_rels_for_modified_object(obj, new_identifier, old_dors) + + # Restore preserved relationships (like EXTERNAL_RESOURCE) + if preserved_rels: + # These need to be written directly to the rels file + # since _update_rels_for_modified_object already wrote it + rels_path = self._gen_rels_path_from_identifier(new_identifier) + if rels_path: + with self._get_zip_file() as zf: + # Read current rels + current_rels = [] + try: + if rels_path in zf.namelist(): + rels_data = zf.read(rels_path) + rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + if rels_obj and rels_obj.relationship: + current_rels = list(rels_obj.relationship) + except Exception: + pass + + # Add preserved rels + all_rels = current_rels + preserved_rels + + # Write back + with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: + temp_path = temp_file.name + + try: + with self._get_zip_file() as source_zf: + with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: + # Copy all files except the rels file we're updating + for item in source_zf.infolist(): + if item.filename != rels_path: + buffer = source_zf.read(item.filename) + target_zf.writestr(item, buffer) + + # Write updated rels file + target_zf.writestr( + rels_path, serialize_xml(Relationships(relationship=all_rels)) + ) + + # Replace original + shutil.move(temp_path, self.epc_file_path) + self._reopen_persistent_zip() + + except Exception: + if os.path.exists(temp_path): + os.unlink(temp_path) + raise + + else: + # For other modes (UPDATE_ON_CLOSE, MANUAL), preserve non-SOURCE/DESTINATION relationships + preserved_rels = [] + try: + obj_rels = self.get_obj_rels(identifier) + preserved_rels = [ + r + for r in obj_rels + if r.type_value + not in ( + EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + ) + ] + except Exception: + pass - # Add updated object - new_identifier = self.add_object(obj) + # Simple remove + add + self.remove_object(identifier) + new_identifier = self.add_object(obj) + + # Restore preserved relationships if any + if preserved_rels: + self.add_rels_for_object(new_identifier, preserved_rels, write_immediately=True) logging.info(f"Updated object {identifier} to {new_identifier} in EPC file") return new_identifier @@ -1341,10 +1814,10 @@ def write_pending_rels(self) -> int: return 0 updated_count = 0 - + # Step 1: Read existing rels and merge with pending rels in memory merged_rels: Dict[str, Relationships] = {} # rels_path -> merged Relationships - + with self._get_zip_file() as zf: for obj_identifier, new_relationships in self.additional_rels.items(): # Generate rels path from metadata without loading the object @@ -1352,7 +1825,7 @@ def write_pending_rels(self) -> int: if rels_path is None: logging.warning(f"Could not generate rels path for {obj_identifier}") continue - + # Read existing rels from ZIP existing_relationships = [] try: @@ -1363,15 +1836,15 @@ def write_pending_rels(self) -> int: existing_relationships = list(existing_rels.relationship) except Exception as e: logging.debug(f"Could not read existing rels for {rels_path}: {e}") - + # Merge new relationships, avoiding duplicates for new_rel in new_relationships: # Check if relationship already exists rel_exists = any( - r.target == new_rel.target and r.type_value == new_rel.type_value + r.target == new_rel.target and r.type_value == new_rel.type_value for r in existing_relationships ) - + if not rel_exists: # Ensure unique ID cpt = 0 @@ -1381,17 +1854,17 @@ def write_pending_rels(self) -> int: cpt += 1 if new_rel_id != new_rel.id: new_rel.id = new_rel_id - + existing_relationships.append(new_rel) - + # Store merged result if existing_relationships: merged_rels[rels_path] = Relationships(relationship=existing_relationships) - + # Step 2: Write updated rels back to ZIP (create temp, copy all, replace) if not merged_rels: return 0 - + with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: temp_path = temp_file.name @@ -1404,7 +1877,7 @@ def write_pending_rels(self) -> int: if item.filename not in merged_rels: buffer = source_zf.read(item.filename) target_zf.writestr(item, buffer) - + # Write updated rels files for rels_path, relationships in merged_rels.items(): rels_xml = serialize_xml(relationships) @@ -1447,7 +1920,7 @@ def _compute_object_rels(self, obj: Any, obj_identifier: str) -> List[Relationsh for dor in direct_dors: try: target_identifier = get_obj_identifier(dor) - + # Get target file path from metadata without processing DOR # The relationship target should be the object's file path, not its rels path if target_identifier in self._metadata: @@ -1498,9 +1971,7 @@ def _get_objects_referencing(self, target_identifier: str) -> List[Tuple[str, An return referencing_objects - def _merge_rels( - self, new_rels: List[Relationship], existing_rels: List[Relationship] - ) -> List[Relationship]: + def _merge_rels(self, new_rels: List[Relationship], existing_rels: List[Relationship]) -> List[Relationship]: """Merge new relationships with existing ones, avoiding duplicates and ensuring unique IDs. Args: @@ -1511,13 +1982,11 @@ def _merge_rels( Merged list of relationships """ merged = list(existing_rels) - + for new_rel in new_rels: # Check if relationship already exists - rel_exists = any( - r.target == new_rel.target and r.type_value == new_rel.type_value for r in merged - ) - + rel_exists = any(r.target == new_rel.target and r.type_value == new_rel.type_value for r in merged) + if not rel_exists: # Ensure unique ID cpt = 0 @@ -1527,9 +1996,9 @@ def _merge_rels( cpt += 1 if new_rel_id != new_rel.id: new_rel.id = new_rel_id - + merged.append(new_rel) - + return merged def _add_object_to_file(self, obj: Any, metadata: EpcObjectMetadata) -> None: @@ -1541,15 +2010,15 @@ def _add_object_to_file(self, obj: Any, metadata: EpcObjectMetadata) -> None: xml_content = serialize_xml(obj) obj_identifier = metadata.identifier assert obj_identifier is not None, "Object identifier must not be None" - + # Step 1: Compute which rels files need to be updated and prepare their content rels_updates: Dict[str, str] = {} # rels_path -> XML content - + with self._get_zip_file() as zf: # 1a. Object's own .rels file obj_rels_path = gen_rels_path(obj, self.export_version) obj_relationships = self._compute_object_rels(obj, obj_identifier) - + if obj_relationships: # Read existing rels existing_rels = [] @@ -1561,31 +2030,31 @@ def _add_object_to_file(self, obj: Any, metadata: EpcObjectMetadata) -> None: existing_rels = list(existing_rels_obj.relationship) except Exception: pass - + # Merge and serialize merged_rels = self._merge_rels(obj_relationships, existing_rels) if merged_rels: rels_updates[obj_rels_path] = serialize_xml(Relationships(relationship=merged_rels)) - + # 1b. Update rels of referenced objects (DESTINATION relationships) direct_dors = get_direct_dor_list(obj) for dor in direct_dors: try: target_identifier = get_obj_identifier(dor) - + # Generate rels path from metadata without processing DOR target_rels_path = self._gen_rels_path_from_identifier(target_identifier) if target_rels_path is None: # Fall back to generating from DOR if metadata not found target_rels_path = gen_rels_path(dor, self.export_version) - + # Create DESTINATION relationship dest_rel = Relationship( target=metadata.file_path, type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), id=f"_{target_identifier}_{get_obj_type(get_obj_usable_class(obj))}_{obj_identifier}", ) - + # Read existing rels existing_rels = [] try: @@ -1596,48 +2065,48 @@ def _add_object_to_file(self, obj: Any, metadata: EpcObjectMetadata) -> None: existing_rels = list(existing_rels_obj.relationship) except Exception: pass - + # Merge and serialize merged_rels = self._merge_rels([dest_rel], existing_rels) if merged_rels: rels_updates[target_rels_path] = serialize_xml(Relationships(relationship=merged_rels)) - + except Exception as e: logging.warning(f"Failed to prepare rels update for referenced object: {e}") - + # 1c. Update [Content_Types].xml content_types_xml = self._update_content_types_xml(zf, metadata, add=True) - + # Step 2: Write everything to new ZIP with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: temp_path = temp_file.name - + try: with self._get_zip_file() as source_zf: with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: # Write new object target_zf.writestr(metadata.file_path, xml_content) - + # Write updated [Content_Types].xml target_zf.writestr(get_epc_content_type_path(), content_types_xml) - + # Write updated rels files for rels_path, rels_xml in rels_updates.items(): target_zf.writestr(rels_path, rels_xml) - + # Copy all other files files_to_skip = {get_epc_content_type_path(), metadata.file_path} files_to_skip.update(rels_updates.keys()) - + for item in source_zf.infolist(): if item.filename not in files_to_skip: buffer = source_zf.read(item.filename) target_zf.writestr(item, buffer) - + # Replace original shutil.move(temp_path, self.epc_file_path) self._reopen_persistent_zip() - + except Exception as e: if os.path.exists(temp_path): os.unlink(temp_path) @@ -1655,30 +2124,30 @@ def _remove_object_from_file(self, metadata: EpcObjectMetadata) -> None: if metadata.identifier is None: logging.error("Cannot remove object with None identifier") raise ValueError("Object identifier must not be None") - + obj = self.get_object_by_identifier(metadata.identifier) if obj is None: logging.warning(f"Object {metadata.identifier} not found, cannot remove rels") # Still proceed with removal even if object can't be loaded - + # Step 1: Compute rels updates (remove DESTINATION relationships from referenced objects) rels_updates: Dict[str, str] = {} # rels_path -> XML content - + if obj is not None: with self._get_zip_file() as zf: direct_dors = get_direct_dor_list(obj) - + for dor in direct_dors: try: target_identifier = get_obj_identifier(dor) if target_identifier not in self._metadata: continue - + # Use metadata to generate rels path without loading the object target_rels_path = self._gen_rels_path_from_identifier(target_identifier) if target_rels_path is None: continue - + # Read existing rels existing_relationships = [] try: @@ -1689,62 +2158,63 @@ def _remove_object_from_file(self, metadata: EpcObjectMetadata) -> None: existing_relationships = list(existing_rels.relationship) except Exception as e: logging.debug(f"Could not read existing rels for {target_identifier}: {e}") - + # Remove DESTINATION relationship that pointed to our object updated_relationships = [ - r for r in existing_relationships + r + for r in existing_relationships if not ( r.target == metadata.file_path and r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type() ) ] - + # Only update if relationships remain if updated_relationships: rels_updates[target_rels_path] = serialize_xml( Relationships(relationship=updated_relationships) ) - + except Exception as e: logging.warning(f"Failed to update rels for referenced object during removal: {e}") - + # Update [Content_Types].xml content_types_xml = self._update_content_types_xml(zf, metadata, add=False) else: # If we couldn't load the object, still update content types with self._get_zip_file() as zf: content_types_xml = self._update_content_types_xml(zf, metadata, add=False) - + # Step 2: Write everything to new ZIP with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: temp_path = temp_file.name - + try: with self._get_zip_file() as source_zf: with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: # Write updated [Content_Types].xml target_zf.writestr(get_epc_content_type_path(), content_types_xml) - + # Write updated rels files for rels_path, rels_xml in rels_updates.items(): target_zf.writestr(rels_path, rels_xml) - + # Copy all files except removed object, its rels, and files we're updating obj_rels_path = self._gen_rels_path_from_metadata(metadata) files_to_skip = {get_epc_content_type_path(), metadata.file_path} if obj_rels_path: files_to_skip.add(obj_rels_path) files_to_skip.update(rels_updates.keys()) - + for item in source_zf.infolist(): if item.filename not in files_to_skip: buffer = source_zf.read(item.filename) target_zf.writestr(item, buffer) - + # Replace original shutil.move(temp_path, self.epc_file_path) self._reopen_persistent_zip() - + except Exception as e: if os.path.exists(temp_path): os.unlink(temp_path) @@ -2032,7 +2502,7 @@ def rebuild_all_rels(self, clean_first: bool = True) -> Dict[str, int]: target_metadata = self._metadata[target_identifier] target_rels_path = self._gen_rels_path_from_identifier(target_identifier) - + if not target_rels_path: continue @@ -2060,6 +2530,38 @@ def rebuild_all_rels(self, clean_first: bool = True) -> Dict[str, int]: stats["rels_files_created"] = len(rels_files) + # Before writing, preserve EXTERNAL_RESOURCE and other non-SOURCE/DESTINATION relationships + # This includes rels files that may not be in rels_files yet + with self._get_zip_file() as zf: + # Check all existing .rels files + for filename in zf.namelist(): + if not filename.endswith(".rels"): + continue + + try: + rels_data = zf.read(filename) + existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels_obj and existing_rels_obj.relationship: + # Preserve non-SOURCE/DESTINATION relationships (e.g., EXTERNAL_RESOURCE) + preserved_rels = [ + r + for r in existing_rels_obj.relationship + if r.type_value + not in ( + EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + ) + ] + if preserved_rels: + if filename in rels_files: + # Add preserved relationships to existing entry + rels_files[filename].relationship = preserved_rels + rels_files[filename].relationship + else: + # Create new entry with only preserved relationships + rels_files[filename] = Relationships(relationship=preserved_rels) + except Exception as e: + logging.debug(f"Could not preserve existing rels from {filename}: {e}") + # Third pass: write the new EPC with updated .rels files with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: temp_path = temp_file.name diff --git a/energyml-utils/tests/test_epc_stream.py b/energyml-utils/tests/test_epc_stream.py new file mode 100644 index 0000000..f22824c --- /dev/null +++ b/energyml-utils/tests/test_epc_stream.py @@ -0,0 +1,934 @@ +# Copyright (c) 2023-2024 Geosiris. +# SPDX-License-Identifier: Apache-2.0 +""" +Comprehensive unit tests for EpcStreamReader functionality. + +Tests cover: +1. Relationship update modes (UPDATE_AT_MODIFICATION, UPDATE_ON_CLOSE, MANUAL) +2. Object lifecycle (add, update, remove) +3. Relationship consistency +4. Performance and caching +5. Edge cases and error handling +""" +import os +import tempfile +import zipfile +from pathlib import Path + +import pytest +import numpy as np + +from energyml.eml.v2_3.commonv2 import Citation, DataObjectReference +from energyml.resqml.v2_2.resqmlv2 import ( + TriangulatedSetRepresentation, + BoundaryFeatureInterpretation, + BoundaryFeature, + HorizonInterpretation, +) +from energyml.opc.opc import Relationships + +from energyml.utils.epc_stream import EpcStreamReader, RelsUpdateMode +from energyml.utils.epc import create_energyml_object, as_dor, get_obj_identifier +from energyml.utils.introspection import ( + epoch_to_date, + epoch, + gen_uuid, + get_direct_dor_list, +) +from energyml.utils.constants import EPCRelsRelationshipType +from energyml.utils.serialization import read_energyml_xml_bytes + + +@pytest.fixture +def temp_epc_file(): + """Create a temporary EPC file path for testing.""" + # Create temp file path but don't create the file itself + # Let EpcStreamReader create it + fd, temp_path = tempfile.mkstemp(suffix=".epc") + os.close(fd) # Close the file descriptor + os.unlink(temp_path) # Remove the empty file + + yield temp_path + + # Cleanup + if os.path.exists(temp_path): + os.unlink(temp_path) + + +@pytest.fixture +def sample_objects(): + """Create sample EnergyML objects for testing.""" + # Create a BoundaryFeature + bf = BoundaryFeature( + citation=Citation( + title="Test Boundary Feature", + originator="Test", + creation=epoch_to_date(epoch()), + ), + uuid=gen_uuid(), + object_version="1.0", + ) + + # Create a BoundaryFeatureInterpretation + bfi = BoundaryFeatureInterpretation( + citation=Citation( + title="Test Boundary Feature Interpretation", + originator="Test", + creation=epoch_to_date(epoch()), + ), + uuid=gen_uuid(), + object_version="1.0", + interpreted_feature=as_dor(bf), + ) + + # Create a TriangulatedSetRepresentation + trset = TriangulatedSetRepresentation( + citation=Citation( + title="Test TriangulatedSetRepresentation", + originator="Test", + creation=epoch_to_date(epoch()), + ), + uuid=gen_uuid(), + object_version="1.0", + represented_object=as_dor(bfi), + ) + + # Create a HorizonInterpretation (independent object) + horizon_interp = HorizonInterpretation( + citation=Citation( + title="Test HorizonInterpretation", + originator="Test", + creation=epoch_to_date(epoch()), + ), + uuid=gen_uuid(), + object_version="1.0", + domain="depth", + ) + + return { + "bf": bf, + "bfi": bfi, + "trset": trset, + "horizon_interp": horizon_interp, + } + + +class TestRelsUpdateModes: + """Test different relationship update modes.""" + + def test_manual_mode_no_auto_rebuild(self, temp_epc_file, sample_objects): + """Test that MANUAL mode does not automatically rebuild relationships on close.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.MANUAL) + + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + + # Add objects in MANUAL mode + reader.add_object(bf) + reader.add_object(bfi) + + # Close without rebuild (MANUAL mode should not call rebuild_all_rels) + reader.close() + + # Reopen and check - rels should exist from _add_object_to_file + # but they won't be "rebuilt" from scratch + reader2 = EpcStreamReader(temp_epc_file) + + # Objects should be there + assert len(reader2) == 2 + + # Basic rels should exist (from _add_object_to_file) + bfi_rels = reader2.get_obj_rels(get_obj_identifier(bfi)) + assert len(bfi_rels) > 0 # Should have SOURCE rels + + reader2.close() + + def test_update_on_close_mode(self, temp_epc_file, sample_objects): + """Test that UPDATE_ON_CLOSE mode rebuilds rels on close.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_ON_CLOSE) + + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + trset = sample_objects["trset"] + + # Add objects + reader.add_object(bf) + reader.add_object(bfi) + reader.add_object(trset) + + # Before closing, rels may not be complete + reader.close() + + # Reopen and verify relationships were built + reader2 = EpcStreamReader(temp_epc_file) + + # Check that bfi has a SOURCE relationship to bf + bfi_rels = reader2.get_obj_rels(get_obj_identifier(bfi)) + source_rels = [r for r in bfi_rels if r.type_value == EPCRelsRelationshipType.SOURCE_OBJECT.get_type()] + assert len(source_rels) >= 1, "Expected SOURCE relationship from bfi to bf" + + # Check that bf has a DESTINATION relationship from bfi + bf_rels = reader2.get_obj_rels(get_obj_identifier(bf)) + dest_rels = [r for r in bf_rels if r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type()] + assert len(dest_rels) >= 1, "Expected DESTINATION relationship from bfi to bf" + + reader2.close() + + def test_update_at_modification_mode_add(self, temp_epc_file, sample_objects): + """Test that UPDATE_AT_MODIFICATION mode updates rels immediately on add.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + + # Add objects + reader.add_object(bf) + reader.add_object(bfi) + + # Check relationships immediately (without closing) + bfi_rels = reader.get_obj_rels(get_obj_identifier(bfi)) + source_rels = [r for r in bfi_rels if r.type_value == EPCRelsRelationshipType.SOURCE_OBJECT.get_type()] + assert len(source_rels) >= 1, "Expected immediate SOURCE relationship from bfi to bf" + + bf_rels = reader.get_obj_rels(get_obj_identifier(bf)) + dest_rels = [r for r in bf_rels if r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type()] + assert len(dest_rels) >= 1, "Expected immediate DESTINATION relationship from bfi to bf" + + reader.close() + + def test_update_at_modification_mode_remove(self, temp_epc_file, sample_objects): + """Test that UPDATE_AT_MODIFICATION mode cleans up rels on remove.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + + # Add objects + reader.add_object(bf) + reader.add_object(bfi) + + # Verify relationships exist + bf_rels_before = reader.get_obj_rels(get_obj_identifier(bf)) + assert len(bf_rels_before) > 0, "Expected relationships before removal" + + # Remove bfi + reader.remove_object(get_obj_identifier(bfi)) + + # Check that bf's rels no longer has references to bfi + bf_rels_after = reader.get_obj_rels(get_obj_identifier(bf)) + bfi_refs = [r for r in bf_rels_after if get_obj_identifier(bfi) in r.id] + assert len(bfi_refs) == 0, "Expected no references to removed object" + + reader.close() + + def test_update_at_modification_mode_update(self, temp_epc_file, sample_objects): + """Test that UPDATE_AT_MODIFICATION mode updates rels on object modification.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + trset = sample_objects["trset"] + + # Add initial objects + reader.add_object(bf) + reader.add_object(bfi) + reader.add_object(trset) + + # Modify bfi to reference a different feature (create new one) + bf2 = BoundaryFeature( + citation=Citation( + title="Test Boundary Feature 2", + originator="Test", + creation=epoch_to_date(epoch()), + ), + uuid=gen_uuid(), + object_version="1.0", + ) + reader.add_object(bf2) + + # Update bfi to reference bf2 instead of bf + bfi_modified = BoundaryFeatureInterpretation( + citation=bfi.citation, + uuid=bfi.uuid, + object_version=bfi.object_version, + interpreted_feature=as_dor(bf2), + ) + + reader.update_object(bfi_modified) + + # Check that bf no longer has DESTINATION relationship from bfi + bf_rels = reader.get_obj_rels(get_obj_identifier(bf)) + bfi_dest_rels = [ + r + for r in bf_rels + if r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type() and get_obj_identifier(bfi) in r.id + ] + assert len(bfi_dest_rels) == 0, "Expected old DESTINATION relationship to be removed" + + # Check that bf2 now has DESTINATION relationship from bfi + bf2_rels = reader.get_obj_rels(get_obj_identifier(bf2)) + bfi_dest_rels2 = [ + r + for r in bf2_rels + if r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type() and get_obj_identifier(bfi) in r.id + ] + assert len(bfi_dest_rels2) >= 1, "Expected new DESTINATION relationship to be added" + + reader.close() + + +class TestObjectLifecycle: + """Test object lifecycle operations.""" + + def test_add_object(self, temp_epc_file, sample_objects): + """Test adding objects to EPC.""" + reader = EpcStreamReader(temp_epc_file) + + bf = sample_objects["bf"] + identifier = reader.add_object(bf) + + assert identifier == get_obj_identifier(bf) + assert identifier in reader._metadata + assert reader.get_object_by_identifier(identifier) is not None + + reader.close() + + def test_remove_object(self, temp_epc_file, sample_objects): + """Test removing objects from EPC.""" + reader = EpcStreamReader(temp_epc_file) + + bf = sample_objects["bf"] + identifier = reader.add_object(bf) + + result = reader.remove_object(identifier) + assert result is True + assert identifier not in reader._metadata + assert reader.get_object_by_identifier(identifier) is None + + reader.close() + + def test_update_object(self, temp_epc_file, sample_objects): + """Test updating existing objects.""" + reader = EpcStreamReader(temp_epc_file) + + bf = sample_objects["bf"] + identifier = reader.add_object(bf) + + # Modify the object + bf_modified = BoundaryFeature( + citation=Citation( + title="Modified Title", + originator="Test", + creation=epoch_to_date(epoch()), + ), + uuid=bf.uuid, + object_version=bf.object_version, + ) + + new_identifier = reader.update_object(bf_modified) + assert new_identifier == identifier + + # Verify the object was updated + obj = reader.get_object_by_identifier(identifier) + assert obj.citation.title == "Modified Title" + + reader.close() + + def test_replace_if_exists(self, temp_epc_file, sample_objects): + """Test replace_if_exists parameter.""" + reader = EpcStreamReader(temp_epc_file) + + bf = sample_objects["bf"] + identifier = reader.add_object(bf) + + # Try to add same object again with replace_if_exists=False + with pytest.raises((ValueError, RuntimeError)) as exc_info: + reader.add_object(bf, replace_if_exists=False) + # The error message should mention the object already exists + assert "already exists" in str(exc_info.value).lower() + + # Should work with replace_if_exists=True (default) + identifier2 = reader.add_object(bf, replace_if_exists=True) + assert identifier == identifier2 + + reader.close() + + +class TestRelationshipConsistency: + """Test relationship consistency and correctness.""" + + def test_bidirectional_relationships(self, temp_epc_file, sample_objects): + """Test that SOURCE and DESTINATION relationships are bidirectional.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + + reader.add_object(bf) + reader.add_object(bfi) + + # Check bfi -> bf (SOURCE) + bfi_rels = reader.get_obj_rels(get_obj_identifier(bfi)) + bfi_source_to_bf = [ + r + for r in bfi_rels + if r.type_value == EPCRelsRelationshipType.SOURCE_OBJECT.get_type() and get_obj_identifier(bf) in r.id + ] + assert len(bfi_source_to_bf) >= 1 + + # Check bf -> bfi (DESTINATION) + bf_rels = reader.get_obj_rels(get_obj_identifier(bf)) + bf_dest_from_bfi = [ + r + for r in bf_rels + if r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type() and get_obj_identifier(bfi) in r.id + ] + assert len(bf_dest_from_bfi) >= 1 + + reader.close() + + def test_cascade_relationships(self, temp_epc_file, sample_objects): + """Test relationships in a chain: trset -> bfi -> bf.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + trset = sample_objects["trset"] + + reader.add_object(bf) + reader.add_object(bfi) + reader.add_object(trset) + + # Check trset -> bfi + trset_rels = reader.get_obj_rels(get_obj_identifier(trset)) + trset_to_bfi = [ + r + for r in trset_rels + if r.type_value == EPCRelsRelationshipType.SOURCE_OBJECT.get_type() and get_obj_identifier(bfi) in r.id + ] + assert len(trset_to_bfi) >= 1 + + # Check bfi -> bf + bfi_rels = reader.get_obj_rels(get_obj_identifier(bfi)) + bfi_to_bf = [ + r + for r in bfi_rels + if r.type_value == EPCRelsRelationshipType.SOURCE_OBJECT.get_type() and get_obj_identifier(bf) in r.id + ] + assert len(bfi_to_bf) >= 1 + + # Check bf has 2 DESTINATION relationships (from bfi and indirectly from trset) + bf_rels = reader.get_obj_rels(get_obj_identifier(bf)) + bf_dest_rels = [r for r in bf_rels if r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type()] + assert len(bf_dest_rels) >= 1 + + reader.close() + + def test_independent_objects_no_rels(self, temp_epc_file, sample_objects): + """Test that independent objects don't have relationships between two boundary features.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + # Use two boundary features with no references to each other + bf1 = sample_objects["bf"] + bf2 = BoundaryFeature( + uuid="00000000-0000-0000-0000-000000000099", + citation=Citation(title="Second Boundary Feature", originator="Test", creation="2026-01-01T00:00:00Z"), + ) + + reader.add_object(bf1) + reader.add_object(bf2) + + # Check that bf2 has no relationships to bf1 + bf2_rels = reader.get_obj_rels(get_obj_identifier(bf2)) + bf1_refs = [r for r in bf2_rels if get_obj_identifier(bf1) in r.id] + assert len(bf1_refs) == 0 + + reader.close() + + +class TestCachingAndPerformance: + """Test caching functionality and performance optimizations.""" + + def test_cache_hit_rate(self, temp_epc_file, sample_objects): + """Test that cache is working properly.""" + reader = EpcStreamReader(temp_epc_file, cache_size=10) + + bf = sample_objects["bf"] + identifier = reader.add_object(bf) + + # First access - cache miss + obj1 = reader.get_object_by_identifier(identifier) + stats1 = reader.get_statistics() + + # Second access - cache hit + obj2 = reader.get_object_by_identifier(identifier) + stats2 = reader.get_statistics() + + assert stats2.cache_hits >= stats1.cache_hits + assert obj1 is obj2 # Should be same object reference + + reader.close() + + def test_metadata_access_without_loading(self, temp_epc_file, sample_objects): + """Test that metadata can be accessed without loading full objects.""" + reader = EpcStreamReader(temp_epc_file) + + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + + reader.add_object(bf) + reader.add_object(bfi) + + reader.close() + + # Reopen and access metadata + reader2 = EpcStreamReader(temp_epc_file, preload_metadata=True) + + # Check that we can list objects without loading them + metadata_list = reader2.list_object_metadata() + assert len(metadata_list) == 2 + assert reader2.stats.loaded_objects == 0, "Expected no objects loaded when accessing metadata" + + reader2.close() + + def test_lazy_loading(self, temp_epc_file, sample_objects): + """Test that objects are loaded on-demand.""" + reader = EpcStreamReader(temp_epc_file) + + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + trset = sample_objects["trset"] + + reader.add_object(bf) + reader.add_object(bfi) + reader.add_object(trset) + + reader.close() + + # Reopen + reader2 = EpcStreamReader(temp_epc_file) + assert len(reader2) == 3 + assert reader2.stats.loaded_objects == 0, "Expected no objects loaded initially" + + # Load one object + reader2.get_object_by_identifier(get_obj_identifier(bf)) + assert reader2.stats.loaded_objects == 1, "Expected exactly 1 object loaded" + + reader2.close() + + +class TestHelperMethods: + """Test helper methods for rels path generation.""" + + def test_gen_rels_path_from_metadata(self, temp_epc_file, sample_objects): + """Test generating rels path from metadata.""" + reader = EpcStreamReader(temp_epc_file) + + bf = sample_objects["bf"] + identifier = reader.add_object(bf) + + metadata = reader._metadata[identifier] + rels_path = reader._gen_rels_path_from_metadata(metadata) + + assert rels_path is not None + assert "_rels/" in rels_path + assert ".rels" in rels_path + + reader.close() + + def test_gen_rels_path_from_identifier(self, temp_epc_file, sample_objects): + """Test generating rels path from identifier.""" + reader = EpcStreamReader(temp_epc_file) + + bf = sample_objects["bf"] + identifier = reader.add_object(bf) + + rels_path = reader._gen_rels_path_from_identifier(identifier) + + assert rels_path is not None + assert "_rels/" in rels_path + assert ".rels" in rels_path + + reader.close() + + +class TestModeManagement: + """Test mode switching and management.""" + + def test_set_rels_update_mode(self, temp_epc_file): + """Test changing the relationship update mode.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.MANUAL) + + assert reader.get_rels_update_mode() == RelsUpdateMode.MANUAL + + reader.set_rels_update_mode(RelsUpdateMode.UPDATE_AT_MODIFICATION) + assert reader.get_rels_update_mode() == RelsUpdateMode.UPDATE_AT_MODIFICATION + + reader.close() + + def test_invalid_mode_raises_error(self, temp_epc_file): + """Test that invalid mode raises error.""" + reader = EpcStreamReader(temp_epc_file) + + with pytest.raises(ValueError): + reader.set_rels_update_mode("invalid_mode") + + reader.close() + + +class TestEdgeCases: + """Test edge cases and error handling.""" + + def test_remove_nonexistent_object(self, temp_epc_file): + """Test removing an object that doesn't exist.""" + reader = EpcStreamReader(temp_epc_file) + + result = reader.remove_object("nonexistent-uuid.0") + assert result is False + + reader.close() + + def test_update_nonexistent_object(self, temp_epc_file, sample_objects): + """Test updating an object that doesn't exist.""" + reader = EpcStreamReader(temp_epc_file) + + bf = sample_objects["bf"] + + with pytest.raises(ValueError): + reader.update_object(bf) + + reader.close() + + def test_empty_epc_operations(self, temp_epc_file): + """Test operations on empty EPC.""" + reader = EpcStreamReader(temp_epc_file) + + assert len(reader) == 0 + assert len(reader.list_object_metadata()) == 0 + + reader.close() + + def test_multiple_add_remove_cycles(self, temp_epc_file, sample_objects): + """Test multiple add/remove cycles.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + bf = sample_objects["bf"] + + for _ in range(3): + identifier = reader.add_object(bf) + assert identifier in reader._metadata + + reader.remove_object(identifier) + assert identifier not in reader._metadata + + reader.close() + + +class TestRebuildAllRels: + """Test the rebuild_all_rels functionality.""" + + def test_rebuild_all_rels_manual_mode(self, temp_epc_file, sample_objects): + """Test manually rebuilding relationships in MANUAL mode.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.MANUAL) + + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + + reader.add_object(bf) + reader.add_object(bfi) + + # Manually rebuild relationships + stats = reader.rebuild_all_rels(clean_first=True) + + assert stats["objects_processed"] == 2 + assert stats["source_relationships"] >= 1 + assert stats["destination_relationships"] >= 1 + + # Verify relationships exist now + bfi_rels = reader.get_obj_rels(get_obj_identifier(bfi)) + assert len(bfi_rels) > 0 + + reader.close() + + +class TestArrayOperations: + """Test HDF5 array operations.""" + + def test_write_read_array(self, temp_epc_file, sample_objects): + """Test writing and reading arrays.""" + # Create temp HDF5 file + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".h5") as f: + h5_path = f.name + + try: + reader = EpcStreamReader(temp_epc_file, force_h5_path=h5_path) + + trset = sample_objects["trset"] + reader.add_object(trset) + + # Write array + test_array = np.arange(12).reshape((3, 4)) + success = reader.write_array(trset, "/test_dataset", test_array) + assert success + + # Read array back + read_array = reader.read_array(trset, "/test_dataset") + assert read_array is not None + assert np.array_equal(read_array, test_array) + + # Close reader before deleting files + reader.close() + finally: + # Give time for file handles to be released + import time + + time.sleep(0.1) + if os.path.exists(h5_path): + try: + os.unlink(h5_path) + except PermissionError: + pass # File still locked, skip cleanup + + +class TestAdditionalRelsPreservation: + """Test that manually added relationships (like EXTERNAL_RESOURCE) are preserved during updates.""" + + def test_external_resource_preserved_on_object_update(self, temp_epc_file, sample_objects): + """Test that EXTERNAL_RESOURCE relationships are preserved when the object is updated.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + # Add initial object + trset = sample_objects["trset"] + identifier = reader.add_object(trset) + + # Add EXTERNAL_RESOURCE relationship manually + from energyml.opc.opc import Relationship + + h5_rel = Relationship( + target="data/test_data.h5", + type_value=EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(), + id=f"_external_{identifier}_h5", + ) + reader.add_rels_for_object(identifier, [h5_rel], write_immediately=True) + + # Verify the HDF5 path is returned + h5_paths_before = reader.get_h5_file_paths(identifier) + assert "data/test_data.h5" in h5_paths_before + + # Update the object (modify its title) + trset.citation.title = "Updated Triangulated Set" + reader.update_object(trset) + + # Verify EXTERNAL_RESOURCE relationship is still present + h5_paths_after = reader.get_h5_file_paths(identifier) + assert "data/test_data.h5" in h5_paths_after, "EXTERNAL_RESOURCE relationship was lost after update" + + # Also verify by checking rels directly + rels = reader.get_obj_rels(identifier) + external_rels = [r for r in rels if r.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type()] + assert len(external_rels) > 0, "EXTERNAL_RESOURCE relationship not found in rels" + assert any("test_data.h5" in r.target for r in external_rels) + + reader.close() + + def test_external_resource_preserved_when_referenced_by_other(self, temp_epc_file, sample_objects): + """Test that EXTERNAL_RESOURCE relationships are preserved when another object references this one.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + # Add BoundaryFeature with EXTERNAL_RESOURCE + bf = sample_objects["bf"] + bf_id = reader.add_object(bf) + + # Add EXTERNAL_RESOURCE relationship to BoundaryFeature + from energyml.opc.opc import Relationship + + h5_rel = Relationship( + target="data/boundary_data.h5", + type_value=EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(), + id=f"_external_{bf_id}_h5", + ) + reader.add_rels_for_object(bf_id, [h5_rel], write_immediately=True) + + # Verify initial state + h5_paths_initial = reader.get_h5_file_paths(bf_id) + assert "data/boundary_data.h5" in h5_paths_initial + + # Add BoundaryFeatureInterpretation that references the BoundaryFeature + # This will create DESTINATION_OBJECT relationship in bf's rels file + bfi = sample_objects["bfi"] + reader.add_object(bfi) + + # Verify EXTERNAL_RESOURCE is still present after adding referencing object + h5_paths_after = reader.get_h5_file_paths(bf_id) + assert "data/boundary_data.h5" in h5_paths_after, "EXTERNAL_RESOURCE lost after adding referencing object" + + # Verify rels directly + rels = reader.get_obj_rels(bf_id) + external_rels = [r for r in rels if r.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type()] + assert len(external_rels) > 0 + assert any("boundary_data.h5" in r.target for r in external_rels) + + reader.close() + + def test_external_resource_preserved_update_on_close_mode(self, temp_epc_file, sample_objects): + """Test EXTERNAL_RESOURCE preservation in UPDATE_ON_CLOSE mode.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_ON_CLOSE) + + # Add object + trset = sample_objects["trset"] + identifier = reader.add_object(trset) + + # Add EXTERNAL_RESOURCE relationship + from energyml.opc.opc import Relationship + + h5_rel = Relationship( + target="data/test_data.h5", + type_value=EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(), + id=f"_external_{identifier}_h5", + ) + reader.add_rels_for_object(identifier, [h5_rel], write_immediately=True) + + # Update object + trset.citation.title = "Modified in UPDATE_ON_CLOSE mode" + reader.update_object(trset) + + # Close (triggers rebuild_all_rels in UPDATE_ON_CLOSE mode) + reader.close() + + # Reopen and verify + reader2 = EpcStreamReader(temp_epc_file) + h5_paths = reader2.get_h5_file_paths(identifier) + assert "data/test_data.h5" in h5_paths, "EXTERNAL_RESOURCE lost after close in UPDATE_ON_CLOSE mode" + reader2.close() + + def test_multiple_external_resources_preserved(self, temp_epc_file, sample_objects): + """Test that multiple EXTERNAL_RESOURCE relationships are all preserved.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + # Add object + trset = sample_objects["trset"] + identifier = reader.add_object(trset) + + # Add multiple EXTERNAL_RESOURCE relationships + from energyml.opc.opc import Relationship + + h5_rels = [ + Relationship( + target="data/geometry.h5", + type_value=EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(), + id=f"_external_{identifier}_geometry", + ), + Relationship( + target="data/properties.h5", + type_value=EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(), + id=f"_external_{identifier}_properties", + ), + Relationship( + target="data/metadata.h5", + type_value=EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(), + id=f"_external_{identifier}_metadata", + ), + ] + reader.add_rels_for_object(identifier, h5_rels, write_immediately=True) + + # Verify all are present + h5_paths_before = reader.get_h5_file_paths(identifier) + assert "data/geometry.h5" in h5_paths_before + assert "data/properties.h5" in h5_paths_before + assert "data/metadata.h5" in h5_paths_before + + # Update object + trset.citation.title = "Updated with Multiple H5 Files" + reader.update_object(trset) + + # Verify all EXTERNAL_RESOURCE relationships are still present + h5_paths_after = reader.get_h5_file_paths(identifier) + assert "data/geometry.h5" in h5_paths_after + assert "data/properties.h5" in h5_paths_after + assert "data/metadata.h5" in h5_paths_after + + reader.close() + + def test_external_resource_preserved_cascade_updates(self, temp_epc_file, sample_objects): + """Test EXTERNAL_RESOURCE preserved through cascade of object updates.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + # Create chain: bf <- bfi <- trset + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + trset = sample_objects["trset"] + + # Add all objects + bf_id = reader.add_object(bf) + bfi_id = reader.add_object(bfi) + trset_id = reader.add_object(trset) + + # Add EXTERNAL_RESOURCE to bf (bottom of chain) + from energyml.opc.opc import Relationship + + h5_rel = Relationship( + target="data/bf_data.h5", + type_value=EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(), + id=f"_external_{bf_id}_h5", + ) + reader.add_rels_for_object(bf_id, [h5_rel], write_immediately=True) + + # Verify initial state + h5_paths = reader.get_h5_file_paths(bf_id) + assert "data/bf_data.h5" in h5_paths + + # Update intermediate object (bfi) + bfi.citation.title = "Modified BFI" + reader.update_object(bfi) + + # Update top object (trset) + trset.citation.title = "Modified TriSet" + reader.update_object(trset) + + # Verify EXTERNAL_RESOURCE still present after cascade of updates + h5_paths_final = reader.get_h5_file_paths(bf_id) + assert "data/bf_data.h5" in h5_paths_final, "EXTERNAL_RESOURCE lost after cascade updates" + + reader.close() + + def test_external_resource_with_object_removal(self, temp_epc_file, sample_objects): + """Test that EXTERNAL_RESOURCE is properly handled when referenced object is removed.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + # Create bf and bfi (bfi references bf) + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + + bf_id = reader.add_object(bf) + bfi_id = reader.add_object(bfi) + + # Add EXTERNAL_RESOURCE to bfi + from energyml.opc.opc import Relationship + + h5_rel = Relationship( + target="data/bfi_data.h5", + type_value=EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(), + id=f"_external_{bfi_id}_h5", + ) + reader.add_rels_for_object(bfi_id, [h5_rel], write_immediately=True) + + # Verify it exists + h5_paths = reader.get_h5_file_paths(bfi_id) + assert "data/bfi_data.h5" in h5_paths + + # Remove bf (which bfi references) + reader.remove_object(bf_id) + + # Update bfi (now its reference to bf is broken, but EXTERNAL_RESOURCE should remain) + bfi.citation.title = "Modified after BF removed" + reader.update_object(bfi) + + # Verify EXTERNAL_RESOURCE is still there + h5_paths_after = reader.get_h5_file_paths(bfi_id) + assert "data/bfi_data.h5" in h5_paths_after, "EXTERNAL_RESOURCE lost after referenced object removal" + + reader.close() + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From 87909f78b835aba23651a410126e4b272d7c20ec Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Wed, 4 Feb 2026 12:01:59 +0100 Subject: [PATCH 15/70] epc stream improvements. --- .../src/energyml/utils/epc_stream.py | 1620 ++++++++++------- 1 file changed, 950 insertions(+), 670 deletions(-) diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index dfeb2aa..962373d 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -107,150 +107,112 @@ def memory_efficiency(self) -> float: return (1 - (self.loaded_objects / self.total_objects)) * 100 if self.total_objects > 0 else 100.0 -class EpcStreamReader(EnergymlStorageInterface): - """ - Memory-efficient EPC file reader with lazy loading and smart caching. - - This class provides the same interface as the standard Epc class but loads - objects on-demand rather than keeping everything in memory. Perfect for - handling very large EPC files with thousands of objects. +# =========================================================================================== +# HELPER CLASSES FOR REFACTORED ARCHITECTURE +# =========================================================================================== - Features: - - Lazy loading: Objects loaded only when accessed - - Smart caching: LRU cache with configurable size - - Memory monitoring: Track memory usage and cache efficiency - - Streaming validation: Validate objects without full loading - - Batch operations: Efficient bulk operations - - Context management: Automatic resource cleanup - - Flexible relationship management: Three modes for updating object relationships - Relationship Update Modes: - - UPDATE_AT_MODIFICATION: Maintains relationships in real-time as objects are added/removed/modified. - Best for maintaining consistency but may be slower for bulk operations. - - UPDATE_ON_CLOSE: Rebuilds all relationships when closing the EPC file (default). - More efficient for bulk operations but relationships only consistent after closing. - - MANUAL: No automatic relationship updates. User must manually call rebuild_all_rels(). - Maximum control and performance for advanced use cases. +class _ZipFileAccessor: + """ + Internal helper class for managing ZIP file access with proper resource management. - Performance optimizations: - - Pre-compiled regex patterns for 15-75% faster parsing - - Weak references to prevent memory leaks - - Compressed metadata storage - - Efficient ZIP file handling + This class handles: + - Persistent ZIP connections when keep_open=True + - On-demand connections when keep_open=False + - Proper cleanup and resource management + - Connection pooling for better performance """ - def __init__( - self, - epc_file_path: Union[str, Path], - cache_size: int = 100, - validate_on_load: bool = True, - preload_metadata: bool = True, - export_version: EpcExportVersion = EpcExportVersion.CLASSIC, - force_h5_path: Optional[str] = None, - keep_open: bool = False, - force_title_load: bool = False, - rels_update_mode: RelsUpdateMode = RelsUpdateMode.UPDATE_ON_CLOSE, - ): + def __init__(self, epc_file_path: Path, keep_open: bool = False): """ - Initialize the EPC stream reader. + Initialize the ZIP file accessor. Args: epc_file_path: Path to the EPC file - cache_size: Maximum number of objects to keep in memory cache - validate_on_load: Whether to validate objects when loading - preload_metadata: Whether to preload all object metadata - export_version: EPC packaging version (CLASSIC or EXPANDED) - force_h5_path: Optional forced HDF5 file path for external resources. If set, all arrays will be read/written from/to this path. - keep_open: If True, keeps the ZIP file open for better performance with multiple operations. File is closed only when instance is deleted or close() is called. - force_title_load: If True, forces loading object titles when listing objects (may impact performance) - rels_update_mode: Mode for updating relationships (UPDATE_AT_MODIFICATION, UPDATE_ON_CLOSE, or MANUAL) + keep_open: If True, maintains a persistent connection """ - self.epc_file_path = Path(epc_file_path) - self.cache_size = cache_size - self.validate_on_load = validate_on_load - self.force_h5_path = force_h5_path - self.cache_opened_h5 = None + self.epc_file_path = epc_file_path self.keep_open = keep_open - self.force_title_load = force_title_load - self.rels_update_mode = rels_update_mode + self._persistent_zip: Optional[zipfile.ZipFile] = None - is_new_file = False - - # Validate file exists and is readable - if not self.epc_file_path.exists(): - logging.info(f"EPC file not found: {epc_file_path}. Creating a new empty EPC file.") - self._create_empty_epc() - is_new_file = True - # raise FileNotFoundError(f"EPC file not found: {epc_file_path}") + def open_persistent_connection(self) -> None: + """Open a persistent ZIP connection if keep_open is enabled.""" + if self.keep_open and self._persistent_zip is None: + self._persistent_zip = zipfile.ZipFile(self.epc_file_path, "r") - if not zipfile.is_zipfile(self.epc_file_path): - raise ValueError(f"File is not a valid ZIP/EPC file: {epc_file_path}") + @contextmanager + def get_zip_file(self) -> Iterator[zipfile.ZipFile]: + """ + Context manager for ZIP file access with proper resource management. - # Check if the ZIP file has the required EPC structure - if not is_new_file: + If keep_open is True, uses the persistent connection. Otherwise opens a new one. + """ + if self.keep_open and self._persistent_zip is not None: + # Use persistent connection, don't close it + yield self._persistent_zip + else: + # Open and close per request + zf = None try: - with zipfile.ZipFile(self.epc_file_path, "r") as zf: - content_types_path = get_epc_content_type_path() - if content_types_path not in zf.namelist(): - logging.info("EPC file is missing required structure. Initializing empty EPC file.") - self._create_empty_epc() - is_new_file = True - except Exception as e: - logging.warning(f"Failed to check EPC structure: {e}. Reinitializing.") - - # Object metadata storage - self._metadata: Dict[str, EpcObjectMetadata] = {} # identifier -> metadata - self._uuid_index: Dict[str, List[str]] = {} # uuid -> list of identifiers - self._type_index: Dict[str, List[str]] = {} # object_type -> list of identifiers - - # Caching system using weak references - self._object_cache: WeakValueDictionary = WeakValueDictionary() - self._access_order: List[str] = [] # LRU tracking - - # Core properties and stats - self._core_props: Optional[CoreProperties] = None - self.stats = EpcStreamingStats() + zf = zipfile.ZipFile(self.epc_file_path, "r") + yield zf + finally: + if zf is not None: + zf.close() - # File handle management - self._zip_file: Optional[zipfile.ZipFile] = None - self._persistent_zip: Optional[zipfile.ZipFile] = None # Used when keep_open=True + def reopen_persistent_zip(self) -> None: + """Reopen persistent ZIP file after modifications to reflect changes.""" + if self.keep_open and self._persistent_zip is not None: + try: + self._persistent_zip.close() + except Exception: + pass + self._persistent_zip = zipfile.ZipFile(self.epc_file_path, "r") - # EPC export version detection - self.export_version: EpcExportVersion = export_version or EpcExportVersion.CLASSIC # Default + def close(self) -> None: + """Close the persistent ZIP file if it's open.""" + if self._persistent_zip is not None: + try: + self._persistent_zip.close() + except Exception as e: + logging.debug(f"Error closing persistent ZIP file: {e}") + finally: + self._persistent_zip = None - # Additional rels management - self.additional_rels: Dict[str, List[Relationship]] = {} - # Initialize by loading metadata - if not is_new_file and preload_metadata: - self._load_metadata() - # Detect EPC version after loading metadata - self.export_version = self._detect_epc_version() +class _MetadataManager: + """ + Internal helper class for managing object metadata, indexing, and queries. - # Open persistent ZIP file if keep_open is enabled - if self.keep_open and not is_new_file: - self._persistent_zip = zipfile.ZipFile(self.epc_file_path, "r") + This class handles: + - Loading metadata from [Content_Types].xml + - Maintaining UUID and type indexes + - Fast metadata queries without loading objects + - Version detection + """ - def _create_empty_epc(self) -> None: - """Create an empty EPC file structure.""" - # Ensure directory exists - self.epc_file_path.parent.mkdir(parents=True, exist_ok=True) + def __init__(self, zip_accessor: _ZipFileAccessor, stats: EpcStreamingStats): + """ + Initialize the metadata manager. - with zipfile.ZipFile(self.epc_file_path, "w") as zf: - # Create [Content_Types].xml - content_types = Types() - content_types_xml = serialize_xml(content_types) - zf.writestr(get_epc_content_type_path(), content_types_xml) + Args: + zip_accessor: ZIP file accessor for reading from EPC + stats: Statistics tracker + """ + self.zip_accessor = zip_accessor + self.stats = stats - # Create _rels/.rels - rels = Relationships() - rels_xml = serialize_xml(rels) - zf.writestr("_rels/.rels", rels_xml) + # Object metadata storage + self._metadata: Dict[str, EpcObjectMetadata] = {} # identifier -> metadata + self._uuid_index: Dict[str, List[str]] = {} # uuid -> list of identifiers + self._type_index: Dict[str, List[str]] = {} # object_type -> list of identifiers + self._core_props: Optional[CoreProperties] = None + self._core_props_path: Optional[str] = None - def _load_metadata(self) -> None: + def load_metadata(self) -> None: """Load object metadata from [Content_Types].xml without loading actual objects.""" try: - with self._get_zip_file() as zf: + with self.zip_accessor.get_zip_file() as zf: # Read content types content_types = self._read_content_types(zf) @@ -268,25 +230,6 @@ def _load_metadata(self) -> None: logging.error(f"Failed to load metadata from EPC file: {e}") raise - @contextmanager - def _get_zip_file(self) -> Iterator[zipfile.ZipFile]: - """Context manager for ZIP file access with proper resource management. - - If keep_open is True, uses the persistent connection. Otherwise opens a new one. - """ - if self.keep_open and self._persistent_zip is not None: - # Use persistent connection, don't close it - yield self._persistent_zip - else: - # Open and close per request - zf = None - try: - zf = zipfile.ZipFile(self.epc_file_path, "r") - yield zf - finally: - if zf is not None: - zf.close() - def _read_content_types(self, zf: zipfile.ZipFile) -> Types: """Read and parse [Content_Types].xml file.""" content_types_path = get_epc_content_type_path() @@ -342,11 +285,7 @@ def _process_energyml_object_metadata(self, zf: zipfile.ZipFile, override: Overr def _extract_object_info_fast( self, zf: zipfile.ZipFile, file_path: str, content_type: str ) -> Tuple[Optional[str], Optional[str], str]: - """ - Fast extraction of UUID and version from XML without full parsing. - - Uses optimized regex patterns for performance. - """ + """Fast extraction of UUID and version from XML without full parsing.""" try: # Read only the beginning of the file for UUID extraction with zf.open(file_path) as f: @@ -398,54 +337,788 @@ def _extract_object_type_from_content_type(self, content_type: str) -> str: def _is_core_properties(self, content_type: str) -> bool: """Check if content type is CoreProperties.""" - return content_type == "application/vnd.openxmlformats-package.core-properties+xml" + return content_type == "application/vnd.openxmlformats-package.core-properties+xml" + + def _process_core_properties_metadata(self, override: Override) -> None: + """Process core properties metadata.""" + if override.part_name: + self._core_props_path = override.part_name.lstrip("/") + + def get_metadata(self, identifier: str) -> Optional[EpcObjectMetadata]: + """Get metadata for an object by identifier.""" + return self._metadata.get(identifier) + + def get_by_uuid(self, uuid: str) -> List[str]: + """Get all identifiers for objects with the given UUID.""" + return self._uuid_index.get(uuid, []) + + def get_by_type(self, object_type: str) -> List[str]: + """Get all identifiers for objects of the given type.""" + return self._type_index.get(object_type, []) + + def list_metadata(self, object_type: Optional[str] = None) -> List[EpcObjectMetadata]: + """List metadata for all objects, optionally filtered by type.""" + if object_type is None: + return list(self._metadata.values()) + return [self._metadata[identifier] for identifier in self._type_index.get(object_type, [])] + + def add_metadata(self, metadata: EpcObjectMetadata) -> None: + """Add metadata for a new object.""" + identifier = metadata.identifier + if identifier: + self._metadata[identifier] = metadata + + # Update UUID index + if metadata.uuid not in self._uuid_index: + self._uuid_index[metadata.uuid] = [] + self._uuid_index[metadata.uuid].append(identifier) + + # Update type index + if metadata.object_type not in self._type_index: + self._type_index[metadata.object_type] = [] + self._type_index[metadata.object_type].append(identifier) + + self.stats.total_objects += 1 + + def remove_metadata(self, identifier: str) -> Optional[EpcObjectMetadata]: + """Remove metadata for an object. Returns the removed metadata.""" + metadata = self._metadata.pop(identifier, None) + if metadata: + # Update UUID index + if metadata.uuid in self._uuid_index: + self._uuid_index[metadata.uuid].remove(identifier) + if not self._uuid_index[metadata.uuid]: + del self._uuid_index[metadata.uuid] + + # Update type index + if metadata.object_type in self._type_index: + self._type_index[metadata.object_type].remove(identifier) + if not self._type_index[metadata.object_type]: + del self._type_index[metadata.object_type] + + self.stats.total_objects -= 1 + + return metadata + + def contains(self, identifier: str) -> bool: + """Check if an object with the given identifier exists.""" + return identifier in self._metadata + + def __len__(self) -> int: + """Return total number of objects.""" + return len(self._metadata) + + def __iter__(self) -> Iterator[str]: + """Iterate over object identifiers.""" + return iter(self._metadata.keys()) + + def gen_rels_path_from_metadata(self, metadata: EpcObjectMetadata) -> str: + """Generate rels path from object metadata without loading the object.""" + obj_path = metadata.file_path + # Extract folder and filename from the object path + if "/" in obj_path: + obj_folder = obj_path[: obj_path.rindex("/") + 1] + obj_file_name = obj_path[obj_path.rindex("/") + 1 :] + else: + obj_folder = "" + obj_file_name = obj_path + + return f"{obj_folder}_rels/{obj_file_name}.rels" + + def gen_rels_path_from_identifier(self, identifier: str) -> Optional[str]: + """Generate rels path from object identifier without loading the object.""" + metadata = self._metadata.get(identifier) + if metadata is None: + return None + return self.gen_rels_path_from_metadata(metadata) + + def get_core_properties(self) -> Optional[CoreProperties]: + """Get core properties (loaded lazily).""" + if self._core_props is None and self._core_props_path: + try: + with self.zip_accessor.get_zip_file() as zf: + core_data = zf.read(self._core_props_path) + self.stats.bytes_read += len(core_data) + self._core_props = read_energyml_xml_bytes(core_data, CoreProperties) + except Exception as e: + logging.error(f"Failed to load core properties: {e}") + + return self._core_props + + def detect_epc_version(self) -> EpcExportVersion: + """Detect EPC packaging version based on file structure.""" + try: + with self.zip_accessor.get_zip_file() as zf: + file_list = zf.namelist() + + # Look for patterns that indicate EXPANDED version + for file_path in file_list: + # Skip metadata files + if ( + file_path.startswith("[Content_Types]") + or file_path.startswith("_rels/") + or file_path.endswith(".rels") + ): + continue + + # Check for namespace_ prefix pattern + if file_path.startswith("namespace_"): + path_parts = file_path.split("/") + if len(path_parts) >= 2: + logging.info(f"Detected EXPANDED EPC version based on path: {file_path}") + return EpcExportVersion.EXPANDED + + # If no EXPANDED patterns found, assume CLASSIC + logging.info("Detected CLASSIC EPC version") + return EpcExportVersion.CLASSIC + + except Exception as e: + logging.warning(f"Failed to detect EPC version, defaulting to CLASSIC: {e}") + return EpcExportVersion.CLASSIC + + def update_content_types_xml( + self, source_zip: zipfile.ZipFile, metadata: EpcObjectMetadata, add: bool = True + ) -> str: + """Update [Content_Types].xml to add or remove object entry. + + Args: + source_zip: Open ZIP file to read from + metadata: Object metadata + add: If True, add entry; if False, remove entry + + Returns: + Updated [Content_Types].xml as string + """ + # Read existing content types + content_types = self._read_content_types(source_zip) + + if add: + # Add new override entry + new_override = Override() + new_override.part_name = f"/{metadata.file_path}" + new_override.content_type = metadata.content_type + content_types.override.append(new_override) + else: + # Remove override entry + content_types.override = [ + override for override in content_types.override if override.part_name != f"/{metadata.file_path}" + ] + + # Serialize back to XML + return serialize_xml(content_types) + + +class _RelationshipManager: + """ + Internal helper class for managing relationships between objects. + + This class handles: + - Reading relationships from .rels files + - Writing relationship updates + - Supporting 3 update modes (UPDATE_AT_MODIFICATION, UPDATE_ON_CLOSE, MANUAL) + - Preserving EXTERNAL_RESOURCE relationships + - Rebuilding all relationships + """ + + def __init__( + self, + zip_accessor: _ZipFileAccessor, + metadata_manager: _MetadataManager, + stats: EpcStreamingStats, + export_version: EpcExportVersion, + rels_update_mode: RelsUpdateMode, + ): + """ + Initialize the relationship manager. + + Args: + zip_accessor: ZIP file accessor for reading/writing + metadata_manager: Metadata manager for object lookups + stats: Statistics tracker + export_version: EPC export version + rels_update_mode: Relationship update mode + """ + self.zip_accessor = zip_accessor + self.metadata_manager = metadata_manager + self.stats = stats + self.export_version = export_version + self.rels_update_mode = rels_update_mode + + # Additional rels management (for user-added relationships) + self.additional_rels: Dict[str, List[Relationship]] = {} + + def get_obj_rels(self, obj_identifier: str, rels_path: Optional[str] = None) -> List[Relationship]: + """ + Get all relationships for a given object. + Merges relationships from the EPC file with in-memory additional relationships. + """ + rels = [] + + # Read rels from EPC file + if rels_path is None: + rels_path = self.metadata_manager.gen_rels_path_from_identifier(obj_identifier) + + if rels_path is not None: + with self.zip_accessor.get_zip_file() as zf: + try: + rels_data = zf.read(rels_path) + self.stats.bytes_read += len(rels_data) + relationships = read_energyml_xml_bytes(rels_data, Relationships) + rels.extend(relationships.relationship) + except KeyError: + # No rels file found for this object + pass + + # Merge with in-memory additional relationships + if obj_identifier in self.additional_rels: + rels.extend(self.additional_rels[obj_identifier]) + + return rels + + def update_rels_for_new_object(self, obj: Any, obj_identifier: str) -> None: + """Update relationships when a new object is added (UPDATE_AT_MODIFICATION mode).""" + metadata = self.metadata_manager.get_metadata(obj_identifier) + if not metadata: + logging.warning(f"Metadata not found for {obj_identifier}") + return + + # Get all objects this new object references + direct_dors = get_direct_dor_list(obj) + + # Build SOURCE relationships for this object + source_relationships = [] + dest_updates: Dict[str, Relationship] = {} + + for dor in direct_dors: + try: + target_identifier = get_obj_identifier(dor) + if not self.metadata_manager.contains(target_identifier): + continue + + target_metadata = self.metadata_manager.get_metadata(target_identifier) + if not target_metadata: + continue + + # Create SOURCE relationship + source_rel = Relationship( + target=target_metadata.file_path, + type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + id=f"_{obj_identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", + ) + source_relationships.append(source_rel) + + # Create DESTINATION relationship + dest_rel = Relationship( + target=metadata.file_path, + type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + id=f"_{target_identifier}_{get_obj_type(get_obj_usable_class(obj))}_{obj_identifier}", + ) + dest_updates[target_identifier] = dest_rel + + except Exception as e: + logging.warning(f"Failed to create relationship for DOR: {e}") + + # Write updates + self.write_rels_updates(obj_identifier, source_relationships, dest_updates) + + def update_rels_for_modified_object(self, obj: Any, obj_identifier: str, old_dors: List[Any]) -> None: + """Update relationships when an object is modified (UPDATE_AT_MODIFICATION mode).""" + metadata = self.metadata_manager.get_metadata(obj_identifier) + if not metadata: + logging.warning(f"Metadata not found for {obj_identifier}") + return + + # Get new DORs + new_dors = get_direct_dor_list(obj) + + # Convert to sets of identifiers for comparison + old_dor_ids = { + get_obj_identifier(dor) for dor in old_dors if self.metadata_manager.contains(get_obj_identifier(dor)) + } + new_dor_ids = { + get_obj_identifier(dor) for dor in new_dors if self.metadata_manager.contains(get_obj_identifier(dor)) + } + + # Find added and removed references + added_dor_ids = new_dor_ids - old_dor_ids + removed_dor_ids = old_dor_ids - new_dor_ids + + # Build new SOURCE relationships + source_relationships = [] + dest_updates: Dict[str, Relationship] = {} + + # Create relationships for all new DORs + for dor in new_dors: + target_identifier = get_obj_identifier(dor) + if not self.metadata_manager.contains(target_identifier): + continue + + target_metadata = self.metadata_manager.get_metadata(target_identifier) + if not target_metadata: + continue + + # SOURCE relationship + source_rel = Relationship( + target=target_metadata.file_path, + type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + id=f"_{obj_identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", + ) + source_relationships.append(source_rel) + + # DESTINATION relationship (for added DORs only) + if target_identifier in added_dor_ids: + dest_rel = Relationship( + target=metadata.file_path, + type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + id=f"_{target_identifier}_{get_obj_type(get_obj_usable_class(obj))}_{obj_identifier}", + ) + dest_updates[target_identifier] = dest_rel + + # For removed DORs, remove DESTINATION relationships + removals: Dict[str, str] = {} + for removed_id in removed_dor_ids: + removals[removed_id] = f"_{removed_id}_.*_{obj_identifier}" + + # Write updates + self.write_rels_updates(obj_identifier, source_relationships, dest_updates, removals) + + def update_rels_for_removed_object(self, obj_identifier: str, obj: Optional[Any] = None) -> None: + """Update relationships when an object is removed (UPDATE_AT_MODIFICATION mode).""" + if obj is None: + # Object must be provided for removal + logging.warning(f"Cannot update rels for removed object {obj_identifier}: object not provided") + return + + # Get all objects this object references + direct_dors = get_direct_dor_list(obj) + + # Build removal patterns for DESTINATION relationships + removals: Dict[str, str] = {} + for dor in direct_dors: + try: + target_identifier = get_obj_identifier(dor) + if not self.metadata_manager.contains(target_identifier): + continue + + removals[target_identifier] = f"_{target_identifier}_.*_{obj_identifier}" + + except Exception as e: + logging.warning(f"Failed to process DOR for removal: {e}") + + # Write updates + self.write_rels_updates(obj_identifier, [], {}, removals, delete_source_rels=True) + + def write_rels_updates( + self, + source_identifier: str, + source_relationships: List[Relationship], + dest_updates: Dict[str, Relationship], + removals: Optional[Dict[str, str]] = None, + delete_source_rels: bool = False, + ) -> None: + """Write relationship updates to the EPC file efficiently.""" + import re + + removals = removals or {} + rels_updates: Dict[str, str] = {} + files_to_delete: List[str] = [] + + with self.zip_accessor.get_zip_file() as zf: + # 1. Handle source object's rels file + if not delete_source_rels: + source_rels_path = self.metadata_manager.gen_rels_path_from_identifier(source_identifier) + if source_rels_path: + # Read existing rels (excluding SOURCE_OBJECT type) + existing_rels = [] + try: + if source_rels_path in zf.namelist(): + rels_data = zf.read(source_rels_path) + existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels_obj and existing_rels_obj.relationship: + # Keep only non-SOURCE relationships + existing_rels = [ + r + for r in existing_rels_obj.relationship + if r.type_value != EPCRelsRelationshipType.SOURCE_OBJECT.get_type() + ] + except Exception: + pass + + # Combine with new SOURCE relationships + all_rels = existing_rels + source_relationships + if all_rels: + rels_updates[source_rels_path] = serialize_xml(Relationships(relationship=all_rels)) + elif source_rels_path in zf.namelist() and not all_rels: + files_to_delete.append(source_rels_path) + else: + # Mark source rels file for deletion + source_rels_path = self.metadata_manager.gen_rels_path_from_identifier(source_identifier) + if source_rels_path: + files_to_delete.append(source_rels_path) + + # 2. Handle destination updates + for target_identifier, dest_rel in dest_updates.items(): + target_rels_path = self.metadata_manager.gen_rels_path_from_identifier(target_identifier) + if not target_rels_path: + continue + + # Read existing rels + existing_rels = [] + try: + if target_rels_path in zf.namelist(): + rels_data = zf.read(target_rels_path) + existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels_obj and existing_rels_obj.relationship: + existing_rels = list(existing_rels_obj.relationship) + except Exception: + pass + + # Add new DESTINATION relationship if not already present + rel_exists = any( + r.target == dest_rel.target and r.type_value == dest_rel.type_value for r in existing_rels + ) + + if not rel_exists: + existing_rels.append(dest_rel) + rels_updates[target_rels_path] = serialize_xml(Relationships(relationship=existing_rels)) + + # 3. Handle removals + for target_identifier, pattern in removals.items(): + target_rels_path = self.metadata_manager.gen_rels_path_from_identifier(target_identifier) + if not target_rels_path: + continue + + # Read existing rels + existing_rels = [] + try: + if target_rels_path in zf.namelist(): + rels_data = zf.read(target_rels_path) + existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels_obj and existing_rels_obj.relationship: + existing_rels = list(existing_rels_obj.relationship) + except Exception: + pass + + # Filter out relationships matching the pattern + regex = re.compile(pattern) + filtered_rels = [r for r in existing_rels if not (r.id and regex.match(r.id))] + + if len(filtered_rels) != len(existing_rels): + if filtered_rels: + rels_updates[target_rels_path] = serialize_xml(Relationships(relationship=filtered_rels)) + else: + files_to_delete.append(target_rels_path) + + # Write updates to EPC file + if rels_updates or files_to_delete: + with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: + temp_path = temp_file.name + + try: + with self.zip_accessor.get_zip_file() as source_zf: + with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: + # Copy all files except those to delete or update + files_to_skip = set(files_to_delete) + for item in source_zf.infolist(): + if item.filename not in files_to_skip and item.filename not in rels_updates: + data = source_zf.read(item.filename) + target_zf.writestr(item, data) + + # Write updated rels files + for rels_path, rels_xml in rels_updates.items(): + target_zf.writestr(rels_path, rels_xml) + + # Replace original + shutil.move(temp_path, self.zip_accessor.epc_file_path) + self.zip_accessor.reopen_persistent_zip() + + except Exception as e: + if os.path.exists(temp_path): + os.unlink(temp_path) + logging.error(f"Failed to write rels updates: {e}") + raise + + def compute_object_rels(self, obj: Any, obj_identifier: str) -> List[Relationship]: + """ + Compute relationships for a given object (SOURCE relationships). + This object references other objects through DORs. + + Args: + obj: The EnergyML object + obj_identifier: The identifier of the object + + Returns: + List of Relationship objects for this object's .rels file + """ + rels = [] + + # Get all DORs (Data Object References) in this object + direct_dors = get_direct_dor_list(obj) + + for dor in direct_dors: + try: + target_identifier = get_obj_identifier(dor) + + # Get target file path from metadata without processing DOR + # The relationship target should be the object's file path, not its rels path + if self.metadata_manager.contains(target_identifier): + target_metadata = self.metadata_manager.get_metadata(target_identifier) + if target_metadata: + target_path = target_metadata.file_path + else: + target_path = gen_energyml_object_path(dor, self.export_version) + else: + # Fall back to generating path from DOR if metadata not found + target_path = gen_energyml_object_path(dor, self.export_version) + + # Create SOURCE relationship (this object -> target object) + rel = Relationship( + target=target_path, + type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + id=f"_{obj_identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", + ) + rels.append(rel) + except Exception as e: + logging.warning(f"Failed to create relationship for DOR in {obj_identifier}: {e}") + + return rels + + def merge_rels(self, new_rels: List[Relationship], existing_rels: List[Relationship]) -> List[Relationship]: + """Merge new relationships with existing ones, avoiding duplicates and ensuring unique IDs. + + Args: + new_rels: New relationships to add + existing_rels: Existing relationships + + Returns: + Merged list of relationships + """ + merged = list(existing_rels) + + for new_rel in new_rels: + # Check if relationship already exists + rel_exists = any(r.target == new_rel.target and r.type_value == new_rel.type_value for r in merged) + + if not rel_exists: + # Ensure unique ID + cpt = 0 + new_rel_id = new_rel.id + while any(r.id == new_rel_id for r in merged): + new_rel_id = f"{new_rel.id}_{cpt}" + cpt += 1 + if new_rel_id != new_rel.id: + new_rel.id = new_rel_id + + merged.append(new_rel) + + return merged + + +# =========================================================================================== +# MAIN CLASS (REFACTORED TO USE HELPER CLASSES) +# =========================================================================================== + + +class EpcStreamReader(EnergymlStorageInterface): + """ + Memory-efficient EPC file reader with lazy loading and smart caching. + + This class provides the same interface as the standard Epc class but loads + objects on-demand rather than keeping everything in memory. Perfect for + handling very large EPC files with thousands of objects. + + Features: + - Lazy loading: Objects loaded only when accessed + - Smart caching: LRU cache with configurable size + - Memory monitoring: Track memory usage and cache efficiency + - Streaming validation: Validate objects without full loading + - Batch operations: Efficient bulk operations + - Context management: Automatic resource cleanup + - Flexible relationship management: Three modes for updating object relationships + + Relationship Update Modes: + - UPDATE_AT_MODIFICATION: Maintains relationships in real-time as objects are added/removed/modified. + Best for maintaining consistency but may be slower for bulk operations. + - UPDATE_ON_CLOSE: Rebuilds all relationships when closing the EPC file (default). + More efficient for bulk operations but relationships only consistent after closing. + - MANUAL: No automatic relationship updates. User must manually call rebuild_all_rels(). + Maximum control and performance for advanced use cases. + + Performance optimizations: + - Pre-compiled regex patterns for 15-75% faster parsing + - Weak references to prevent memory leaks + - Compressed metadata storage + - Efficient ZIP file handling + """ + + def __init__( + self, + epc_file_path: Union[str, Path], + cache_size: int = 100, + validate_on_load: bool = True, + preload_metadata: bool = True, + export_version: EpcExportVersion = EpcExportVersion.CLASSIC, + force_h5_path: Optional[str] = None, + keep_open: bool = False, + force_title_load: bool = False, + rels_update_mode: RelsUpdateMode = RelsUpdateMode.UPDATE_ON_CLOSE, + ): + """ + Initialize the EPC stream reader. + + Args: + epc_file_path: Path to the EPC file + cache_size: Maximum number of objects to keep in memory cache + validate_on_load: Whether to validate objects when loading + preload_metadata: Whether to preload all object metadata + export_version: EPC packaging version (CLASSIC or EXPANDED) + force_h5_path: Optional forced HDF5 file path for external resources. If set, all arrays will be read/written from/to this path. + keep_open: If True, keeps the ZIP file open for better performance with multiple operations. File is closed only when instance is deleted or close() is called. + force_title_load: If True, forces loading object titles when listing objects (may impact performance) + rels_update_mode: Mode for updating relationships (UPDATE_AT_MODIFICATION, UPDATE_ON_CLOSE, or MANUAL) + """ + # Public attributes + self.epc_file_path = Path(epc_file_path) + self.cache_size = cache_size + self.validate_on_load = validate_on_load + self.force_h5_path = force_h5_path + self.cache_opened_h5 = None + self.keep_open = keep_open + self.force_title_load = force_title_load + self.rels_update_mode = rels_update_mode + self.export_version: EpcExportVersion = export_version or EpcExportVersion.CLASSIC + self.stats = EpcStreamingStats() + + # Caching system using weak references + self._object_cache: WeakValueDictionary = WeakValueDictionary() + self._access_order: List[str] = [] # LRU tracking + + is_new_file = False + + # Validate file exists and is readable + if not self.epc_file_path.exists(): + logging.info(f"EPC file not found: {epc_file_path}. Creating a new empty EPC file.") + self._create_empty_epc() + is_new_file = True + + if not zipfile.is_zipfile(self.epc_file_path): + raise ValueError(f"File is not a valid ZIP/EPC file: {epc_file_path}") + + # Check if the ZIP file has the required EPC structure + if not is_new_file: + try: + with zipfile.ZipFile(self.epc_file_path, "r") as zf: + content_types_path = get_epc_content_type_path() + if content_types_path not in zf.namelist(): + logging.info("EPC file is missing required structure. Initializing empty EPC file.") + self._create_empty_epc() + is_new_file = True + except Exception as e: + logging.warning(f"Failed to check EPC structure: {e}. Reinitializing.") + + # Initialize helper classes (internal architecture) + self._zip_accessor = _ZipFileAccessor(self.epc_file_path, keep_open=keep_open) + self._metadata_mgr = _MetadataManager(self._zip_accessor, self.stats) + self._rels_mgr = _RelationshipManager( + self._zip_accessor, self._metadata_mgr, self.stats, self.export_version, rels_update_mode + ) + + # Initialize by loading metadata + if not is_new_file and preload_metadata: + self._metadata_mgr.load_metadata() + # Detect EPC version after loading metadata + self.export_version = self._metadata_mgr.detect_epc_version() + # Update relationship manager's export version + self._rels_mgr.export_version = self.export_version + + # Open persistent ZIP connection if keep_open is enabled + if keep_open and not is_new_file: + self._zip_accessor.open_persistent_connection() + + # Backward compatibility: expose internal structures as properties + # This allows existing code to access _metadata, _uuid_index, etc. + self._metadata = self._metadata_mgr._metadata + self._uuid_index = self._metadata_mgr._uuid_index + self._type_index = self._metadata_mgr._type_index + self.additional_rels = self._rels_mgr.additional_rels + + def _create_empty_epc(self) -> None: + """Create an empty EPC file structure.""" + # Ensure directory exists + self.epc_file_path.parent.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(self.epc_file_path, "w") as zf: + # Create [Content_Types].xml + content_types = Types() + content_types_xml = serialize_xml(content_types) + zf.writestr(get_epc_content_type_path(), content_types_xml) + + # Create _rels/.rels + rels = Relationships() + rels_xml = serialize_xml(rels) + zf.writestr("_rels/.rels", rels_xml) + + def _load_metadata(self) -> None: + """Load object metadata from [Content_Types].xml without loading actual objects.""" + # Delegate to metadata manager + self._metadata_mgr.load_metadata() + + def _read_content_types(self, zf: zipfile.ZipFile) -> Types: + """Read and parse [Content_Types].xml file.""" + # Delegate to metadata manager + return self._metadata_mgr._read_content_types(zf) + + def _process_energyml_object_metadata(self, zf: zipfile.ZipFile, override: Override) -> None: + """Process metadata for an EnergyML object without loading it.""" + # Delegate to metadata manager + self._metadata_mgr._process_energyml_object_metadata(zf, override) + + def _extract_object_info_fast( + self, zf: zipfile.ZipFile, file_path: str, content_type: str + ) -> Tuple[Optional[str], Optional[str], str]: + """Fast extraction of UUID and version from XML without full parsing.""" + # Delegate to metadata manager + return self._metadata_mgr._extract_object_info_fast(zf, file_path, content_type) + + def _extract_object_type_from_content_type(self, content_type: str) -> str: + """Extract object type from content type string.""" + # Delegate to metadata manager + return self._metadata_mgr._extract_object_type_from_content_type(content_type) + + def _is_core_properties(self, content_type: str) -> bool: + """Check if content type is CoreProperties.""" + # Delegate to metadata manager + return self._metadata_mgr._is_core_properties(content_type) def _process_core_properties_metadata(self, override: Override) -> None: """Process core properties metadata.""" - # Store core properties path for lazy loading - if override.part_name: - self._core_props_path = override.part_name.lstrip("/") + # Delegate to metadata manager + self._metadata_mgr._process_core_properties_metadata(override) def _detect_epc_version(self) -> EpcExportVersion: - """ - Detect EPC packaging version based on file structure. - - CLASSIC version uses simple flat structure: obj_Type_UUID.xml - EXPANDED version uses namespace structure: namespace_pkg/UUID/version_X/Type_UUID.xml - - Returns: - EpcExportVersion: The detected version (CLASSIC or EXPANDED) - """ - try: - with self._get_zip_file() as zf: - file_list = zf.namelist() + """Detect EPC packaging version based on file structure.""" + # Delegate to metadata manager + return self._metadata_mgr.detect_epc_version() - # Look for patterns that indicate EXPANDED version - # EXPANDED uses paths like: namespace_resqml22/UUID/version_X/Type_UUID.xml - for file_path in file_list: - # Skip metadata files - if ( - file_path.startswith("[Content_Types]") - or file_path.startswith("_rels/") - or file_path.endswith(".rels") - ): - continue + def _gen_rels_path_from_metadata(self, metadata: EpcObjectMetadata) -> str: + """Generate rels path from object metadata without loading the object.""" + # Delegate to metadata manager + return self._metadata_mgr.gen_rels_path_from_metadata(metadata) - # Check for namespace_ prefix pattern - if file_path.startswith("namespace_"): - # Further validate it's the EXPANDED structure - path_parts = file_path.split("/") - if len(path_parts) >= 2: # namespace_pkg/filename or namespace_pkg/version_x/filename - logging.info(f"Detected EXPANDED EPC version based on path: {file_path}") - return EpcExportVersion.EXPANDED + def _gen_rels_path_from_identifier(self, identifier: str) -> Optional[str]: + """Generate rels path from object identifier without loading the object.""" + # Delegate to metadata manager + return self._metadata_mgr.gen_rels_path_from_identifier(identifier) - # If no EXPANDED patterns found, assume CLASSIC - logging.info("Detected CLASSIC EPC version") - return EpcExportVersion.CLASSIC + @contextmanager + def _get_zip_file(self) -> Iterator[zipfile.ZipFile]: + """Context manager for ZIP file access with proper resource management. - except Exception as e: - logging.warning(f"Failed to detect EPC version, defaulting to CLASSIC: {e}") - return EpcExportVersion.CLASSIC + If keep_open is True, uses the persistent connection. Otherwise opens a new one. + """ + # Delegate to the ZIP accessor helper class + with self._zip_accessor.get_zip_file() as zf: + yield zf def get_object_by_identifier(self, identifier: Union[str, Uri]) -> Optional[Any]: """ @@ -701,356 +1374,85 @@ def preload_objects(self, identifiers: List[str]) -> int: return loaded_count def clear_cache(self) -> None: - """Clear the object cache to free memory.""" - self._object_cache.clear() - self._access_order.clear() - self.stats.loaded_objects = 0 - - def get_core_properties(self) -> Optional[CoreProperties]: - """Get core properties (loaded lazily).""" - if self._core_props is None and hasattr(self, "_core_props_path"): - try: - with self._get_zip_file() as zf: - core_data = zf.read(self._core_props_path) - self.stats.bytes_read += len(core_data) - self._core_props = read_energyml_xml_bytes(core_data, CoreProperties) - except Exception as e: - logging.error(f"Failed to load core properties: {e}") - - return self._core_props - - def _gen_rels_path_from_metadata(self, metadata: EpcObjectMetadata) -> str: - """ - Generate rels path from object metadata without loading the object. - - Args: - metadata: Object metadata containing file path information - - Returns: - Path to the rels file for this object - """ - obj_path = metadata.file_path - # Extract folder and filename from the object path - if "/" in obj_path: - obj_folder = obj_path[: obj_path.rindex("/") + 1] - obj_file_name = obj_path[obj_path.rindex("/") + 1 :] - else: - obj_folder = "" - obj_file_name = obj_path - - return f"{obj_folder}_rels/{obj_file_name}.rels" - - def _gen_rels_path_from_identifier(self, identifier: str) -> Optional[str]: - """ - Generate rels path from object identifier without loading the object. - - Args: - identifier: Object identifier (uuid.version) - - Returns: - Path to the rels file, or None if metadata not found - """ - metadata = self._metadata.get(identifier) - if metadata is None: - return None - return self._gen_rels_path_from_metadata(metadata) - - def _update_rels_for_new_object(self, obj: Any, obj_identifier: str) -> None: - """ - Update relationships when a new object is added (UPDATE_AT_MODIFICATION mode). - - Creates the object's rels file and updates destination objects' rels files. - - Args: - obj: The newly added object - obj_identifier: The identifier of the new object - """ - metadata = self._metadata.get(obj_identifier) - if not metadata: - logging.warning(f"Metadata not found for {obj_identifier}") - return - - # Get all objects this new object references - direct_dors = get_direct_dor_list(obj) - - # Build SOURCE relationships for this object - source_relationships = [] - dest_updates: Dict[str, Relationship] = {} # target_identifier -> DESTINATION relationship - - for dor in direct_dors: - try: - target_identifier = get_obj_identifier(dor) - if target_identifier not in self._metadata: - continue - - target_metadata = self._metadata[target_identifier] - - # Create SOURCE relationship (this object -> target) - source_rel = Relationship( - target=target_metadata.file_path, - type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), - id=f"_{obj_identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", - ) - source_relationships.append(source_rel) - - # Create DESTINATION relationship (target -> this object) - dest_rel = Relationship( - target=metadata.file_path, - type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), - id=f"_{target_identifier}_{get_obj_type(get_obj_usable_class(obj))}_{obj_identifier}", - ) - dest_updates[target_identifier] = dest_rel - - except Exception as e: - logging.warning(f"Failed to create relationship for DOR: {e}") - - # Write the new object's rels file and update destination rels - self._write_rels_updates(obj_identifier, source_relationships, dest_updates) - - def _update_rels_for_modified_object(self, obj: Any, obj_identifier: str, old_dors: List[Any]) -> None: - """ - Update relationships when an object is modified (UPDATE_AT_MODIFICATION mode). - - Compares old and new DORs, updates the object's rels file and affected destination rels. - - Args: - obj: The modified object - obj_identifier: The identifier of the modified object - old_dors: List of DORs from the previous version of the object - """ - metadata = self._metadata.get(obj_identifier) - if not metadata: - logging.warning(f"Metadata not found for {obj_identifier}") - return - - # Get new DORs - new_dors = get_direct_dor_list(obj) - - # Convert to sets of identifiers for comparison - old_dor_ids = {get_obj_identifier(dor) for dor in old_dors if get_obj_identifier(dor) in self._metadata} - new_dor_ids = {get_obj_identifier(dor) for dor in new_dors if get_obj_identifier(dor) in self._metadata} - - # Find added and removed references - added_dor_ids = new_dor_ids - old_dor_ids - removed_dor_ids = old_dor_ids - new_dor_ids - - # Build new SOURCE relationships (only for new DORs) - source_relationships = [] - dest_updates: Dict[str, Relationship] = {} - - # Create relationships for all new DORs - for dor in new_dors: - target_identifier = get_obj_identifier(dor) - if target_identifier not in self._metadata: - continue - - target_metadata = self._metadata[target_identifier] - - # SOURCE relationship - source_rel = Relationship( - target=target_metadata.file_path, - type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), - id=f"_{obj_identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", - ) - source_relationships.append(source_rel) - - # DESTINATION relationship (for added DORs only) - if target_identifier in added_dor_ids: - dest_rel = Relationship( - target=metadata.file_path, - type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), - id=f"_{target_identifier}_{get_obj_type(get_obj_usable_class(obj))}_{obj_identifier}", - ) - dest_updates[target_identifier] = dest_rel - - # For removed DORs, we need to remove DESTINATION relationships - removals: Dict[str, str] = {} # target_identifier -> relationship_id_pattern - for removed_id in removed_dor_ids: - removals[removed_id] = f"_{removed_id}_.*_{obj_identifier}" - - # Write updates - self._write_rels_updates(obj_identifier, source_relationships, dest_updates, removals) - - def _update_rels_for_removed_object(self, obj_identifier: str, obj: Optional[Any] = None) -> None: - """ - Update relationships when an object is removed (UPDATE_AT_MODIFICATION mode). - - Removes the object's rels file and removes references from destination objects' rels. - - Args: - obj_identifier: The identifier of the removed object - obj: The object being removed (if available) - """ - # Load object if not provided - if obj is None: - obj = self.get_object_by_identifier(obj_identifier) - - if obj is None: - logging.warning(f"Cannot update rels for removed object {obj_identifier}: object not found") - return - - # Get all objects this object references - direct_dors = get_direct_dor_list(obj) - - # Build removal patterns for DESTINATION relationships in referenced objects - removals: Dict[str, str] = {} # target_identifier -> relationship_id_pattern - for dor in direct_dors: - try: - target_identifier = get_obj_identifier(dor) - if target_identifier not in self._metadata: - continue - - # Pattern to match DESTINATION relationships pointing to the removed object - removals[target_identifier] = f"_{target_identifier}_.*_{obj_identifier}" - - except Exception as e: - logging.warning(f"Failed to process DOR for removal: {e}") - - # Write updates (no source relationships, no dest updates, only removals) - self._write_rels_updates(obj_identifier, [], {}, removals, delete_source_rels=True) - - def _write_rels_updates( - self, - source_identifier: str, - source_relationships: List[Relationship], - dest_updates: Dict[str, Relationship], - removals: Optional[Dict[str, str]] = None, - delete_source_rels: bool = False, - ) -> None: - """ - Write relationship updates to the EPC file efficiently. - - Args: - source_identifier: Identifier of the source object - source_relationships: List of SOURCE relationships for the source object - dest_updates: Dict mapping target_identifier to DESTINATION relationship to add - removals: Dict mapping target_identifier to regex pattern for relationships to remove - delete_source_rels: If True, delete the source object's rels file entirely - """ - import re - - removals = removals or {} - rels_updates: Dict[str, str] = {} # rels_path -> XML content - files_to_delete: List[str] = [] - - with self._get_zip_file() as zf: - # 1. Handle source object's rels file - if not delete_source_rels: - source_rels_path = self._gen_rels_path_from_identifier(source_identifier) - if source_rels_path: - # Read existing rels (excluding SOURCE_OBJECT type, we're replacing them) - existing_rels = [] - try: - if source_rels_path in zf.namelist(): - rels_data = zf.read(source_rels_path) - existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels_obj and existing_rels_obj.relationship: - # Keep only non-SOURCE relationships (e.g., EXTERNAL_RESOURCE) - existing_rels = [ - r - for r in existing_rels_obj.relationship - if r.type_value != EPCRelsRelationshipType.SOURCE_OBJECT.get_type() - ] - except Exception: - pass - - # Combine with new SOURCE relationships - all_rels = existing_rels + source_relationships - if all_rels: - # Write rels file if there are any relationships (SOURCE or other types) - rels_updates[source_rels_path] = serialize_xml(Relationships(relationship=all_rels)) - elif source_rels_path in zf.namelist() and not all_rels: - # If file exists but no relationships remain, mark for deletion - files_to_delete.append(source_rels_path) - else: - # Mark source rels file for deletion - source_rels_path = self._gen_rels_path_from_identifier(source_identifier) - if source_rels_path: - files_to_delete.append(source_rels_path) + """Clear the object cache to free memory.""" + self._object_cache.clear() + self._access_order.clear() + self.stats.loaded_objects = 0 - # 2. Handle destination updates - for target_identifier, dest_rel in dest_updates.items(): - target_rels_path = self._gen_rels_path_from_identifier(target_identifier) - if not target_rels_path: - continue + def get_core_properties(self) -> Optional[CoreProperties]: + """Get core properties (loaded lazily).""" + # Delegate to metadata manager + return self._metadata_mgr.get_core_properties() - # Read existing rels - existing_rels = [] - try: - if target_rels_path in zf.namelist(): - rels_data = zf.read(target_rels_path) - existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels_obj and existing_rels_obj.relationship: - existing_rels = list(existing_rels_obj.relationship) - except Exception: - pass + def _gen_rels_path_from_metadata(self, metadata: EpcObjectMetadata) -> str: + """ + Generate rels path from object metadata without loading the object. - # Add new DESTINATION relationship if not already present - rel_exists = any( - r.target == dest_rel.target and r.type_value == dest_rel.type_value for r in existing_rels - ) + Args: + metadata: Object metadata containing file path information - if not rel_exists: - existing_rels.append(dest_rel) - rels_updates[target_rels_path] = serialize_xml(Relationships(relationship=existing_rels)) + Returns: + Path to the rels file for this object + """ + obj_path = metadata.file_path + # Extract folder and filename from the object path + if "/" in obj_path: + obj_folder = obj_path[: obj_path.rindex("/") + 1] + obj_file_name = obj_path[obj_path.rindex("/") + 1 :] + else: + obj_folder = "" + obj_file_name = obj_path - # 3. Handle removals - for target_identifier, pattern in removals.items(): - target_rels_path = self._gen_rels_path_from_identifier(target_identifier) - if not target_rels_path: - continue + return f"{obj_folder}_rels/{obj_file_name}.rels" - # Read existing rels - existing_rels = [] - try: - if target_rels_path in zf.namelist(): - rels_data = zf.read(target_rels_path) - existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels_obj and existing_rels_obj.relationship: - existing_rels = list(existing_rels_obj.relationship) - except Exception: - pass + def _gen_rels_path_from_identifier(self, identifier: str) -> Optional[str]: + """ + Generate rels path from object identifier without loading the object. - # Filter out relationships matching the pattern - regex = re.compile(pattern) - filtered_rels = [r for r in existing_rels if not (r.id and regex.match(r.id))] + Args: + identifier: Object identifier (uuid.version) - if len(filtered_rels) != len(existing_rels): - # Something was removed - if filtered_rels: - rels_updates[target_rels_path] = serialize_xml(Relationships(relationship=filtered_rels)) - else: - # No relationships left, mark for deletion - files_to_delete.append(target_rels_path) + Returns: + Path to the rels file, or None if metadata not found + """ + metadata = self._metadata.get(identifier) + if metadata is None: + return None + return self._gen_rels_path_from_metadata(metadata) - # Write updates to EPC file - if rels_updates or files_to_delete: - with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: - temp_path = temp_file.name + def _update_rels_for_new_object(self, obj: Any, obj_identifier: str) -> None: + """Update relationships when a new object is added (UPDATE_AT_MODIFICATION mode).""" + # Delegate to relationship manager + self._rels_mgr.update_rels_for_new_object(obj, obj_identifier) - try: - with self._get_zip_file() as source_zf: - with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: - # Copy all files except those to delete or update - files_to_skip = set(files_to_delete) - for item in source_zf.infolist(): - if item.filename not in files_to_skip and item.filename not in rels_updates: - buffer = source_zf.read(item.filename) - target_zf.writestr(item, buffer) + def _update_rels_for_modified_object(self, obj: Any, obj_identifier: str, old_dors: List[Any]) -> None: + """Update relationships when an object is modified (UPDATE_AT_MODIFICATION mode).""" + # Delegate to relationship manager + self._rels_mgr.update_rels_for_modified_object(obj, obj_identifier, old_dors) - # Write updated rels files - for rels_path, rels_xml in rels_updates.items(): - target_zf.writestr(rels_path, rels_xml) + def _update_rels_for_removed_object(self, obj_identifier: str, obj: Optional[Any] = None) -> None: + """Update relationships when an object is removed (UPDATE_AT_MODIFICATION mode).""" + # Delegate to relationship manager + self._rels_mgr.update_rels_for_removed_object(obj_identifier, obj) - # Replace original - shutil.move(temp_path, self.epc_file_path) - self._reopen_persistent_zip() + def _write_rels_updates( + self, + source_identifier: str, + source_relationships: List[Relationship], + dest_updates: Dict[str, Relationship], + removals: Optional[Dict[str, str]] = None, + delete_source_rels: bool = False, + ) -> None: + """Write relationship updates to the EPC file efficiently.""" + # Delegate to relationship manager + self._rels_mgr.write_rels_updates( + source_identifier, source_relationships, dest_updates, removals, delete_source_rels + ) - except Exception as e: - if os.path.exists(temp_path): - os.unlink(temp_path) - logging.error(f"Failed to write rels updates: {e}") - raise + def _reopen_persistent_zip(self) -> None: + """Reopen persistent ZIP file after modifications to reflect changes.""" + # Delegate to ZIP accessor + self._zip_accessor.reopen_persistent_zip() def to_epc(self, load_all: bool = False) -> Epc: """ @@ -1077,6 +1479,18 @@ def to_epc(self, load_all: bool = False) -> Epc: return epc + def set_rels_update_mode(self, mode: RelsUpdateMode) -> None: + """ + Change the relationship update mode. + + Args: + mode: The new RelsUpdateMode to use + + Note: + Changing from MANUAL or UPDATE_ON_CLOSE to UPDATE_AT_MODIFICATION + may require calling rebuild_all_rels() first to ensure consistency. + """ + def set_rels_update_mode(self, mode: RelsUpdateMode) -> None: """ Change the relationship update mode. @@ -1093,6 +1507,8 @@ def set_rels_update_mode(self, mode: RelsUpdateMode) -> None: old_mode = self.rels_update_mode self.rels_update_mode = mode + # Also update the relationship manager + self._rels_mgr.rels_update_mode = mode logging.info(f"Changed relationship update mode from {old_mode.value} to {mode.value}") @@ -1115,11 +1531,10 @@ def get_obj_rels(self, obj: Union[str, Uri, Any]) -> List[Relationship]: :param obj: the object or its identifier/URI :return: list of Relationship objects """ - rels = [] + # Get identifier without loading the object obj_identifier = None rels_path = None - # Get identifier without loading the object if isinstance(obj, (str, Uri)): # Convert URI to identifier if needed if isinstance(obj, Uri) or parse_uri(obj) is not None: @@ -1136,23 +1551,8 @@ def get_obj_rels(self, obj: Union[str, Uri, Any]) -> List[Relationship]: obj_identifier = get_obj_identifier(obj) rels_path = gen_rels_path(obj, self.export_version) - # Read rels from EPC file using efficient context manager - if rels_path is not None: - with self._get_zip_file() as zf: - try: - rels_data = zf.read(rels_path) - self.stats.bytes_read += len(rels_data) - relationships = read_energyml_xml_bytes(rels_data, Relationships) - rels.extend(relationships.relationship) - except KeyError: - # No rels file found for this object - pass - - # Merge with in-memory additional relationships - if obj_identifier and obj_identifier in self.additional_rels: - rels.extend(self.additional_rels[obj_identifier]) - - return rels + # Delegate to relationship manager + return self._rels_mgr.get_obj_rels(obj_identifier, rels_path) def get_h5_file_paths(self, obj: Union[str, Uri, Any]) -> List[str]: """ @@ -1379,26 +1779,8 @@ def close(self) -> None: except Exception as e: logging.warning(f"Error rebuilding rels on close: {e}") - if self._persistent_zip is not None: - try: - self._persistent_zip.close() - except Exception as e: - logging.debug(f"Error closing persistent ZIP file: {e}") - finally: - self._persistent_zip = None - - def _reopen_persistent_zip(self) -> None: - """Reopen persistent ZIP file after modifications to reflect changes. - - This is called after any operation that modifies the EPC file to ensure - that subsequent reads see the updated content. - """ - if self.keep_open and self._persistent_zip is not None: - try: - self._persistent_zip.close() - except Exception: - pass - self._persistent_zip = zipfile.ZipFile(self.epc_file_path, "r") + # Delegate to ZIP accessor + self._zip_accessor.close() def put_object(self, obj: Any, dataspace: Optional[str] = None) -> Optional[str]: """ @@ -1901,105 +2283,18 @@ def write_pending_rels(self) -> int: raise def _compute_object_rels(self, obj: Any, obj_identifier: str) -> List[Relationship]: + """Compute relationships for a given object (SOURCE relationships). + + Delegates to _rels_mgr.compute_object_rels() """ - Compute relationships for a given object (SOURCE relationships). - This object references other objects through DORs. - - Args: - obj: The EnergyML object - obj_identifier: The identifier of the object - - Returns: - List of Relationship objects for this object's .rels file - """ - rels = [] - - # Get all DORs (Data Object References) in this object - direct_dors = get_direct_dor_list(obj) - - for dor in direct_dors: - try: - target_identifier = get_obj_identifier(dor) - - # Get target file path from metadata without processing DOR - # The relationship target should be the object's file path, not its rels path - if target_identifier in self._metadata: - target_path = self._metadata[target_identifier].file_path - else: - # Fall back to generating path from DOR if metadata not found - target_path = gen_energyml_object_path(dor, self.export_version) - - # Create SOURCE relationship (this object -> target object) - rel = Relationship( - target=target_path, - type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), - id=f"_{obj_identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", - ) - rels.append(rel) - except Exception as e: - logging.warning(f"Failed to create relationship for DOR in {obj_identifier}: {e}") - - return rels - - def _get_objects_referencing(self, target_identifier: str) -> List[Tuple[str, Any]]: - """ - Find all objects that reference the target object. - - Args: - target_identifier: The identifier of the target object - - Returns: - List of tuples (identifier, object) of objects that reference the target - """ - referencing_objects = [] - - # We need to check all objects in the EPC to find those that reference our target - for identifier in self._metadata: - # Load the object to check its DORs - obj = self.get_object_by_identifier(identifier) - if obj is not None: - # Check if this object references our target - direct_dors = get_direct_dor_list(obj) - for dor in direct_dors: - try: - dor_identifier = get_obj_identifier(dor) - if dor_identifier == target_identifier: - referencing_objects.append((identifier, obj)) - break # Found a reference, no need to check other DORs in this object - except Exception: - continue - - return referencing_objects + return self._rels_mgr.compute_object_rels(obj, obj_identifier) def _merge_rels(self, new_rels: List[Relationship], existing_rels: List[Relationship]) -> List[Relationship]: """Merge new relationships with existing ones, avoiding duplicates and ensuring unique IDs. - Args: - new_rels: New relationships to add - existing_rels: Existing relationships - - Returns: - Merged list of relationships + Delegates to _rels_mgr.merge_rels() """ - merged = list(existing_rels) - - for new_rel in new_rels: - # Check if relationship already exists - rel_exists = any(r.target == new_rel.target and r.type_value == new_rel.type_value for r in merged) - - if not rel_exists: - # Ensure unique ID - cpt = 0 - new_rel_id = new_rel.id - while any(r.id == new_rel_id for r in merged): - new_rel_id = f"{new_rel.id}_{cpt}" - cpt += 1 - if new_rel_id != new_rel.id: - new_rel.id = new_rel_id - - merged.append(new_rel) - - return merged + return self._rels_mgr.merge_rels(new_rels, existing_rels) def _add_object_to_file(self, obj: Any, metadata: EpcObjectMetadata) -> None: """Add object to the EPC file efficiently. @@ -2224,26 +2519,11 @@ def _remove_object_from_file(self, metadata: EpcObjectMetadata) -> None: def _update_content_types_xml( self, source_zip: zipfile.ZipFile, metadata: EpcObjectMetadata, add: bool = True ) -> str: - """Update [Content_Types].xml to add or remove object entry.""" - # Read existing content types - content_types = self._read_content_types(source_zip) - - if add: - # Add new override entry - new_override = Override() - new_override.part_name = f"/{metadata.file_path}" - new_override.content_type = metadata.content_type - content_types.override.append(new_override) - else: - # Remove override entry - content_types.override = [ - override for override in content_types.override if override.part_name != f"/{metadata.file_path}" - ] - - # Serialize back to XML - from .serialization import serialize_xml - - return serialize_xml(content_types) + """Update [Content_Types].xml to add or remove object entry. + + Delegates to _metadata_mgr.update_content_types_xml() + """ + return self._metadata_mgr.update_content_types_xml(source_zip, metadata, add) def _rollback_add_object(self, identifier: Optional[str]) -> None: """Rollback changes made during failed add_object operation.""" From 80bec66dd6951587d3a8519355ee6d44dbe62bf2 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Wed, 4 Feb 2026 14:54:49 +0100 Subject: [PATCH 16/70] improved epc_stream_reader with parallel rels computing in the mode "compute on close" --- energyml-utils/pyproject.toml | 4 + .../src/energyml/utils/epc_stream.py | 365 +++++++++++++++++- .../tests/test_parallel_rels_performance.py | 309 +++++++++++++++ 3 files changed, 673 insertions(+), 5 deletions(-) create mode 100644 energyml-utils/tests/test_parallel_rels_performance.py diff --git a/energyml-utils/pyproject.toml b/energyml-utils/pyproject.toml index 6f66d30..4ce977f 100644 --- a/energyml-utils/pyproject.toml +++ b/energyml-utils/pyproject.toml @@ -48,6 +48,10 @@ include = [ [tool.pytest.ini_options] pythonpath = [ "src" ] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", +] +addopts = "-m 'not slow'" testpaths = [ "tests" ] python_files = [ "test_*.py", "*_test.py" ] python_classes = [ "Test*" ] diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index 962373d..6c8686a 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -16,7 +16,7 @@ from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path -from typing import Dict, List, Optional, Any, Iterator, Union, Tuple +from typing import Dict, List, Optional, Any, Iterator, Union, Tuple, TypedDict from weakref import WeakValueDictionary from energyml.opc.opc import Types, Override, CoreProperties, Relationships, Relationship @@ -107,6 +107,116 @@ def memory_efficiency(self) -> float: return (1 - (self.loaded_objects / self.total_objects)) * 100 if self.total_objects > 0 else 100.0 +# =========================================================================================== +# PARALLEL PROCESSING WORKER FUNCTIONS +# =========================================================================================== + +# Configuration constants for parallel processing +_MIN_OBJECTS_PER_WORKER = 10 # Minimum objects to justify spawning a worker +_WORKER_POOL_SIZE_RATIO = 10 # Number of objects per worker process + + +class _WorkerResult(TypedDict): + """Type definition for parallel worker function return value.""" + + identifier: str + object_type: str + source_rels: List[Dict[str, str]] + dor_targets: List[Tuple[str, str]] + + +def _process_object_for_rels_worker(args: Tuple[str, str, Dict[str, EpcObjectMetadata]]) -> Optional[_WorkerResult]: + """ + Worker function for parallel relationship processing (runs in separate process). + + This function is executed in a separate process to compute SOURCE relationships + for a single object. It bypasses Python's GIL for CPU-intensive XML parsing. + + Performance characteristics: + - Each worker process opens its own ZIP file handle + - XML parsing happens independently on separate CPU cores + - Results are serialized back to the main process via pickle + + Args: + args: Tuple containing: + - identifier: Object UUID/identifier to process + - epc_file_path: Absolute path to the EPC file + - metadata_dict: Dictionary of all object metadata (for validation) + + Returns: + Dictionary conforming to _WorkerResult TypedDict, or None if processing fails. + """ + identifier, epc_file_path, metadata_dict = args + + try: + # Open ZIP file in this worker process + import zipfile + from energyml.utils.serialization import read_energyml_xml_bytes + from energyml.utils.introspection import ( + get_direct_dor_list, + get_obj_identifier, + get_obj_type, + get_obj_usable_class, + ) + from energyml.utils.constants import EPCRelsRelationshipType + from energyml.utils.introspection import get_class_from_content_type + + metadata = metadata_dict.get(identifier) + if not metadata: + return None + + # Load object from ZIP + with zipfile.ZipFile(epc_file_path, "r") as zf: + obj_data = zf.read(metadata.file_path) + obj_class = get_class_from_content_type(metadata.content_type) + obj = read_energyml_xml_bytes(obj_data, obj_class) + + # Extract object type (cached to avoid reloading in Phase 3) + obj_type = get_obj_type(get_obj_usable_class(obj)) + + # Get all Data Object References (DORs) from this object + data_object_references = get_direct_dor_list(obj) + + # Build SOURCE relationships and track referenced objects + source_rels = [] + dor_targets = [] # Track (target_id, target_type) for reverse references + + for dor in data_object_references: + try: + target_identifier = get_obj_identifier(dor) + if target_identifier not in metadata_dict: + continue + + target_metadata = metadata_dict[target_identifier] + + # Extract target type (needed for relationship ID) + target_type = get_obj_type(get_obj_usable_class(dor)) + dor_targets.append((target_identifier, target_type)) + + # Serialize relationship as dict (Relationship objects aren't picklable) + rel_dict = { + "target": target_metadata.file_path, + "type_value": EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + "id": f"_{identifier}_{target_type}_{target_identifier}", + } + source_rels.append(rel_dict) + + except Exception as e: + # Don't fail entire object processing for one bad DOR + logging.debug(f"Skipping invalid DOR in {identifier}: {e}") + + return { + "identifier": identifier, + "object_type": obj_type, + "source_rels": source_rels, + "dor_targets": dor_targets, + } + + except Exception as e: + logging.warning(f"Worker failed to process {identifier}: {e}") + return None + + # =========================================================================================== # HELPER CLASSES FOR REFACTORED ARCHITECTURE # =========================================================================================== @@ -480,12 +590,12 @@ def update_content_types_xml( self, source_zip: zipfile.ZipFile, metadata: EpcObjectMetadata, add: bool = True ) -> str: """Update [Content_Types].xml to add or remove object entry. - + Args: source_zip: Open ZIP file to read from metadata: Object metadata add: If True, add entry; if False, remove entry - + Returns: Updated [Content_Types].xml as string """ @@ -962,6 +1072,8 @@ def __init__( keep_open: bool = False, force_title_load: bool = False, rels_update_mode: RelsUpdateMode = RelsUpdateMode.UPDATE_ON_CLOSE, + enable_parallel_rels: bool = False, + parallel_worker_ratio: int = 10, ): """ Initialize the EPC stream reader. @@ -976,9 +1088,13 @@ def __init__( keep_open: If True, keeps the ZIP file open for better performance with multiple operations. File is closed only when instance is deleted or close() is called. force_title_load: If True, forces loading object titles when listing objects (may impact performance) rels_update_mode: Mode for updating relationships (UPDATE_AT_MODIFICATION, UPDATE_ON_CLOSE, or MANUAL) + enable_parallel_rels: If True, uses parallel processing for rebuild_all_rels() operations (faster for large EPCs) + parallel_worker_ratio: Number of objects per worker process (default: 10). Lower values = more workers. Only used when enable_parallel_rels=True. """ # Public attributes self.epc_file_path = Path(epc_file_path) + self.enable_parallel_rels = enable_parallel_rels + self.parallel_worker_ratio = parallel_worker_ratio self.cache_size = cache_size self.validate_on_load = validate_on_load self.force_h5_path = force_h5_path @@ -2284,7 +2400,7 @@ def write_pending_rels(self) -> int: def _compute_object_rels(self, obj: Any, obj_identifier: str) -> List[Relationship]: """Compute relationships for a given object (SOURCE relationships). - + Delegates to _rels_mgr.compute_object_rels() """ return self._rels_mgr.compute_object_rels(obj, obj_identifier) @@ -2520,7 +2636,7 @@ def _update_content_types_xml( self, source_zip: zipfile.ZipFile, metadata: EpcObjectMetadata, add: bool = True ) -> str: """Update [Content_Types].xml to add or remove object entry. - + Delegates to _metadata_mgr.update_content_types_xml() """ return self._metadata_mgr.update_content_types_xml(source_zip, metadata, add) @@ -2676,6 +2792,33 @@ def rebuild_all_rels(self, clean_first: bool = True) -> Dict[str, int]: 3. Analyzes its Data Object References (DORs) 4. Creates/updates .rels files with proper SOURCE and DESTINATION relationships + Args: + clean_first: If True, remove all existing .rels files before rebuilding + + Returns: + Dictionary with statistics: + - 'objects_processed': Number of objects analyzed + - 'rels_files_created': Number of .rels files created + - 'source_relationships': Number of SOURCE relationships created + - 'destination_relationships': Number of DESTINATION relationships created + - 'parallel_mode': True if parallel processing was used (optional key) + - 'execution_time': Execution time in seconds (optional key) + """ + if self.enable_parallel_rels: + return self._rebuild_all_rels_parallel(clean_first) + else: + return self._rebuild_all_rels_sequential(clean_first) + + def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, int]: + """ + Rebuild all .rels files from scratch by analyzing all objects and their references. + + This method: + 1. Optionally cleans existing .rels files first + 2. Loads each object temporarily + 3. Analyzes its Data Object References (DORs) + 4. Creates/updates .rels files with proper SOURCE and DESTINATION relationships + Args: clean_first: If True, remove all existing .rels files before rebuilding @@ -2879,6 +3022,218 @@ def rebuild_all_rels(self, clean_first: bool = True) -> Dict[str, int]: os.unlink(temp_path) raise RuntimeError(f"Failed to rebuild .rels files: {e}") + def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int]: + """ + Parallel implementation of rebuild_all_rels using multiprocessing. + + Strategy: + 1. Use multiprocessing.Pool to process objects in parallel + 2. Each worker loads an object and computes its SOURCE relationships + 3. Main process aggregates results and builds DESTINATION relationships + 4. Sequential write phase (ZIP writing must be sequential) + + This bypasses Python's GIL for CPU-intensive XML parsing and provides + significant speedup for large EPCs (tested with 80+ objects). + """ + import tempfile + import shutil + import time + from multiprocessing import Pool, cpu_count + + start_time = time.time() + + stats = { + "objects_processed": 0, + "rels_files_created": 0, + "source_relationships": 0, + "destination_relationships": 0, + "parallel_mode": True, + } + + num_objects = len(self._metadata) + logging.info(f"Starting PARALLEL rebuild of all .rels files for {num_objects} objects...") + + # Prepare work items for parallel processing + # Pass metadata as dict (serializable) instead of keeping references + metadata_dict = {k: v for k, v in self._metadata.items()} + work_items = [(identifier, str(self.epc_file_path), metadata_dict) for identifier in self._metadata] + + # Determine optimal number of workers based on available CPUs and workload + # Don't spawn more workers than CPUs; use user-configurable ratio for workload per worker + worker_ratio = self.parallel_worker_ratio if hasattr(self, "parallel_worker_ratio") else _WORKER_POOL_SIZE_RATIO + num_workers = min(cpu_count(), max(1, num_objects // worker_ratio)) + logging.info(f"Using {num_workers} worker processes for {num_objects} objects (ratio: {worker_ratio})") + + # ============================================================================ + # PHASE 1: PARALLEL - Compute SOURCE relationships across worker processes + # ============================================================================ + results = [] + with Pool(processes=num_workers) as pool: + results = pool.map(_process_object_for_rels_worker, work_items) + + # ============================================================================ + # PHASE 2: SEQUENTIAL - Aggregate worker results + # ============================================================================ + # Build data structures for subsequent phases: + # - reverse_references: Map target objects to their sources (for DESTINATION rels) + # - rels_files: Accumulate all relationships by file path + # - object_types: Cache object types to eliminate redundant loads in Phase 3 + reverse_references: Dict[str, List[Tuple[str, str]]] = {} + rels_files: Dict[str, Relationships] = {} + object_types: Dict[str, str] = {} + + for result in results: + if result is None: + continue + + identifier = result["identifier"] + obj_type = result["object_type"] + source_rels = result["source_rels"] + dor_targets = result["dor_targets"] + + # Cache object type + object_types[identifier] = obj_type + + stats["objects_processed"] += 1 + + # Convert dicts back to Relationship objects + if source_rels: + obj_rels_path = self._gen_rels_path_from_identifier(identifier) + if obj_rels_path: + relationships = [] + for rel_dict in source_rels: + rel = Relationship( + target=rel_dict["target"], + type_value=rel_dict["type_value"], + id=rel_dict["id"], + ) + relationships.append(rel) + stats["source_relationships"] += 1 + + if obj_rels_path not in rels_files: + rels_files[obj_rels_path] = Relationships(relationship=[]) + rels_files[obj_rels_path].relationship.extend(relationships) + + # Build reverse reference map for DESTINATION relationships + # dor_targets now contains (target_id, target_type) tuples + for target_identifier, target_type in dor_targets: + if target_identifier not in reverse_references: + reverse_references[target_identifier] = [] + reverse_references[target_identifier].append((identifier, obj_type)) + + # ============================================================================ + # PHASE 3: SEQUENTIAL - Create DESTINATION relationships (zero object loading!) + # ============================================================================ + # Use cached object types from Phase 2 to build DESTINATION relationships + # without reloading any objects. This optimization is critical for performance. + for target_identifier, source_list in reverse_references.items(): + try: + if target_identifier not in self._metadata: + continue + + target_rels_path = self._gen_rels_path_from_identifier(target_identifier) + + if not target_rels_path: + continue + + # Use cached object types instead of loading objects! + for source_identifier, source_type in source_list: + try: + source_metadata = self._metadata[source_identifier] + + # No object loading needed - we have all the type info from Phase 2! + rel = Relationship( + target=source_metadata.file_path, + type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + id=f"_{target_identifier}_{source_type}_{source_identifier}", + ) + + if target_rels_path not in rels_files: + rels_files[target_rels_path] = Relationships(relationship=[]) + rels_files[target_rels_path].relationship.append(rel) + stats["destination_relationships"] += 1 + + except Exception as e: + logging.debug(f"Failed to create DESTINATION relationship: {e}") + + except Exception as e: + logging.warning(f"Failed to create DESTINATION rels for {target_identifier}: {e}") + + stats["rels_files_created"] = len(rels_files) + + # ============================================================================ + # PHASE 4: SEQUENTIAL - Preserve non-object relationships + # ============================================================================ + # Preserve EXTERNAL_RESOURCE and other non-standard relationship types + with self._get_zip_file() as zf: + for filename in zf.namelist(): + if not filename.endswith(".rels"): + continue + + try: + rels_data = zf.read(filename) + existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels_obj and existing_rels_obj.relationship: + preserved_rels = [ + r + for r in existing_rels_obj.relationship + if r.type_value + not in ( + EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + ) + ] + if preserved_rels: + if filename in rels_files: + rels_files[filename].relationship = preserved_rels + rels_files[filename].relationship + else: + rels_files[filename] = Relationships(relationship=preserved_rels) + except Exception as e: + logging.debug(f"Could not preserve existing rels from {filename}: {e}") + + # ============================================================================ + # PHASE 5: SEQUENTIAL - Write all relationships to ZIP file + # ============================================================================ + # ZIP file writing must be sequential (file format limitation) + with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: + temp_path = temp_file.name + + try: + with self._get_zip_file() as source_zip: + with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zip: + # Copy all non-.rels files + for item in source_zip.infolist(): + if not (item.filename.endswith(".rels") and clean_first): + data = source_zip.read(item.filename) + target_zip.writestr(item, data) + + # Write new .rels files + for rels_path, rels_obj in rels_files.items(): + rels_xml = serialize_xml(rels_obj) + target_zip.writestr(rels_path, rels_xml) + + # Replace original file + shutil.move(temp_path, self.epc_file_path) + self._reopen_persistent_zip() + + execution_time = time.time() - start_time + stats["execution_time"] = execution_time + + logging.info( + f"Rebuilt .rels files (PARALLEL): processed {stats['objects_processed']} objects, " + f"created {stats['rels_files_created']} .rels files, " + f"added {stats['source_relationships']} SOURCE and " + f"{stats['destination_relationships']} DESTINATION relationships " + f"in {execution_time:.2f}s using {num_workers} workers" + ) + + return stats + + except Exception as e: + if os.path.exists(temp_path): + os.unlink(temp_path) + raise RuntimeError(f"Failed to rebuild .rels files (parallel): {e}") + def __repr__(self) -> str: """String representation.""" return ( diff --git a/energyml-utils/tests/test_parallel_rels_performance.py b/energyml-utils/tests/test_parallel_rels_performance.py new file mode 100644 index 0000000..2e1b6fa --- /dev/null +++ b/energyml-utils/tests/test_parallel_rels_performance.py @@ -0,0 +1,309 @@ +""" +Performance benchmarking tests for parallel rebuild_all_rels implementation. + +This module compares sequential vs parallel relationship rebuilding performance +on real EPC files. +""" + +import os +import time +import tempfile +import shutil +from pathlib import Path +import pytest + +from energyml.utils.epc_stream import EpcStreamReader + + +# Default test file path - can be overridden via environment variable +DEFAULT_TEST_FILE = r"C:\Users\Cryptaro\Downloads\80wells_surf.epc" +TEST_EPC_PATH = os.environ.get("TEST_EPC_PATH", DEFAULT_TEST_FILE) + + +def create_test_copy(source_path: str) -> str: + """Create a temporary copy of the EPC file for testing.""" + temp_dir = tempfile.mkdtemp() + temp_path = os.path.join(temp_dir, "test.epc") + shutil.copy(source_path, temp_path) + return temp_path + + +@pytest.mark.slow +@pytest.mark.skipif(not os.path.exists(TEST_EPC_PATH), reason=f"Test EPC file not found: {TEST_EPC_PATH}") +class TestParallelRelsPerformance: + """Performance comparison tests for sequential vs parallel rebuild_all_rels. + + These tests are marked as 'slow' and skipped by default. + Run with: pytest -m slow + """ + + def test_sequential_rebuild_performance(self): + """Benchmark sequential rebuild_all_rels implementation.""" + # Create test copy + test_file = create_test_copy(TEST_EPC_PATH) + + try: + # Open with sequential mode + reader = EpcStreamReader(test_file, enable_parallel_rels=False, keep_open=True) + + # Measure rebuild time + start_time = time.time() + stats = reader.rebuild_all_rels(clean_first=True) + end_time = time.time() + + execution_time = end_time - start_time + + # Verify stats + assert stats["objects_processed"] > 0, "Should process some objects" + assert stats["source_relationships"] > 0, "Should create SOURCE relationships" + assert stats["rels_files_created"] > 0, "Should create .rels files" + + # Print results + print(f"\n{'='*70}") + print(f"SEQUENTIAL MODE PERFORMANCE") + print(f"{'='*70}") + print(f"Objects processed: {stats['objects_processed']}") + print(f"SOURCE relationships: {stats['source_relationships']}") + print(f"DESTINATION relationships: {stats['destination_relationships']}") + print(f"Rels files created: {stats['rels_files_created']}") + print(f"Execution time: {execution_time:.3f}s") + print(f"Objects per second: {stats['objects_processed']/execution_time:.2f}") + print(f"{'='*70}\n") + + # Close reader before cleanup + reader.close() + + # Allow time for file handles to be released + import time as time_module + + time_module.sleep(0.5) + + # Store for comparison + return {"mode": "sequential", "execution_time": execution_time, "stats": stats} + + finally: + # Cleanup + try: + # Ensure directory is cleaned up + temp_dir = os.path.dirname(test_file) + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir, ignore_errors=True) + except Exception as e: + print(f"Warning: Cleanup failed: {e}") + + def test_parallel_rebuild_performance(self): + """Benchmark parallel rebuild_all_rels implementation.""" + # Create test copy + test_file = create_test_copy(TEST_EPC_PATH) + + try: + # Open with parallel mode + reader = EpcStreamReader(test_file, enable_parallel_rels=True, keep_open=True) + + # Measure rebuild time + start_time = time.time() + stats = reader.rebuild_all_rels(clean_first=True) + end_time = time.time() + + execution_time = end_time - start_time + + # Verify stats + assert stats["objects_processed"] > 0, "Should process some objects" + assert stats["source_relationships"] > 0, "Should create SOURCE relationships" + assert stats["rels_files_created"] > 0, "Should create .rels files" + assert stats["parallel_mode"] is True, "Should indicate parallel mode" + + # Print results + print(f"\n{'='*70}") + print(f"PARALLEL MODE PERFORMANCE") + print(f"{'='*70}") + print(f"Objects processed: {stats['objects_processed']}") + print(f"SOURCE relationships: {stats['source_relationships']}") + print(f"DESTINATION relationships: {stats['destination_relationships']}") + print(f"Rels files created: {stats['rels_files_created']}") + print(f"Execution time: {execution_time:.3f}s") + print(f"Objects per second: {stats['objects_processed']/execution_time:.2f}") + print(f"{'='*70}\n") + + # Close reader before cleanup + reader.close() + + # Allow time for file handles to be released + import time as time_module + + time_module.sleep(0.5) + + return {"mode": "parallel", "execution_time": execution_time, "stats": stats} + + finally: + # Cleanup + try: + temp_dir = os.path.dirname(test_file) + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir, ignore_errors=True) + except Exception as e: + print(f"Warning: Cleanup failed: {e}") + + def test_compare_sequential_vs_parallel(self): + """Direct comparison of sequential vs parallel performance.""" + # Run sequential + test_file_seq = create_test_copy(TEST_EPC_PATH) + + try: + reader_seq = EpcStreamReader(test_file_seq, enable_parallel_rels=False, keep_open=True) + start_seq = time.time() + stats_seq = reader_seq.rebuild_all_rels(clean_first=True) + time_seq = time.time() - start_seq + reader_seq.close() + finally: + if os.path.exists(test_file_seq): + os.unlink(test_file_seq) + if os.path.exists(os.path.dirname(test_file_seq)): + shutil.rmtree(os.path.dirname(test_file_seq)) + + # Run parallel + test_file_par = create_test_copy(TEST_EPC_PATH) + + try: + reader_par = EpcStreamReader(test_file_par, enable_parallel_rels=True, keep_open=True) + start_par = time.time() + stats_par = reader_par.rebuild_all_rels(clean_first=True) + time_par = time.time() - start_par + reader_par.close() + finally: + if os.path.exists(test_file_par): + os.unlink(test_file_par) + if os.path.exists(os.path.dirname(test_file_par)): + shutil.rmtree(os.path.dirname(test_file_par)) + + # Verify consistency + assert stats_seq["objects_processed"] == stats_par["objects_processed"], "Should process same number of objects" + assert ( + stats_seq["source_relationships"] == stats_par["source_relationships"] + ), "Should create same SOURCE relationships" + assert ( + stats_seq["destination_relationships"] == stats_par["destination_relationships"] + ), "Should create same DESTINATION relationships" + + # Calculate speedup + speedup = time_seq / time_par + speedup_percent = (time_seq - time_par) / time_seq * 100 + + # Print comparison + print(f"\n{'='*70}") + print(f"PERFORMANCE COMPARISON") + print(f"{'='*70}") + print(f"Test file: {os.path.basename(TEST_EPC_PATH)}") + print(f"Objects processed: {stats_seq['objects_processed']}") + print(f"-" * 70) + print(f"Sequential time: {time_seq:.3f}s") + print(f"Parallel time: {time_par:.3f}s") + print(f"-" * 70) + print(f"Speedup: {speedup:.2f}x") + print(f"Time saved: {speedup_percent:.1f}%") + print(f"Absolute savings: {time_seq - time_par:.3f}s") + print(f"{'='*70}\n") + + # Assert some improvement (parallel should be faster or at least not much slower) + # For small EPCs, overhead might make parallel slightly slower + # For large EPCs (80+ objects), parallel should be significantly faster + if stats_seq["objects_processed"] >= 50: + assert ( + time_par < time_seq * 1.2 + ), f"Parallel mode should not be >20% slower for {stats_seq['objects_processed']} objects" + + def test_correctness_parallel_vs_sequential(self): + """Verify that parallel and sequential produce identical results.""" + # Test with sequential + test_file_seq = create_test_copy(TEST_EPC_PATH) + + try: + reader_seq = EpcStreamReader(test_file_seq, enable_parallel_rels=False) + stats_seq = reader_seq.rebuild_all_rels(clean_first=True) + + # Read back relationships + rels_seq = {} + for identifier in reader_seq._metadata: + try: + obj_rels = reader_seq.get_obj_rels(identifier) + rels_seq[identifier] = sorted([(r.target, r.type_value) for r in obj_rels]) + except Exception: + rels_seq[identifier] = [] + + reader_seq.close() + finally: + if os.path.exists(test_file_seq): + os.unlink(test_file_seq) + if os.path.exists(os.path.dirname(test_file_seq)): + shutil.rmtree(os.path.dirname(test_file_seq)) + + # Test with parallel + test_file_par = create_test_copy(TEST_EPC_PATH) + + try: + reader_par = EpcStreamReader(test_file_par, enable_parallel_rels=True) + stats_par = reader_par.rebuild_all_rels(clean_first=True) + + # Read back relationships + rels_par = {} + for identifier in reader_par._metadata: + try: + obj_rels = reader_par.get_obj_rels(identifier) + rels_par[identifier] = sorted([(r.target, r.type_value) for r in obj_rels]) + except Exception: + rels_par[identifier] = [] + + reader_par.close() + finally: + if os.path.exists(test_file_par): + os.unlink(test_file_par) + if os.path.exists(os.path.dirname(test_file_par)): + shutil.rmtree(os.path.dirname(test_file_par)) + + # Compare results + assert stats_seq["objects_processed"] == stats_par["objects_processed"] + assert stats_seq["source_relationships"] == stats_par["source_relationships"] + assert stats_seq["destination_relationships"] == stats_par["destination_relationships"] + + # Compare actual relationships (order-independent) + assert set(rels_seq.keys()) == set(rels_par.keys()), "Should have same objects" + + for identifier in rels_seq: + assert ( + rels_seq[identifier] == rels_par[identifier] + ), f"Relationships for {identifier} should match between sequential and parallel modes" + + print(f"\n✓ Correctness verified: Sequential and parallel modes produce identical results") + + +if __name__ == "__main__": + """Run performance tests directly.""" + import sys + + if len(sys.argv) > 1: + TEST_EPC_PATH = sys.argv[1] + + if not os.path.exists(TEST_EPC_PATH): + print(f"Error: Test file not found: {TEST_EPC_PATH}") + print(f"Usage: python {__file__} [path/to/test.epc]") + sys.exit(1) + + print(f"Running performance tests with: {TEST_EPC_PATH}\n") + + # Run tests + test = TestParallelRelsPerformance() + + try: + test.test_sequential_rebuild_performance() + test.test_parallel_rebuild_performance() + test.test_compare_sequential_vs_parallel() + test.test_correctness_parallel_vs_sequential() + + print("\n✓ All performance tests passed!") + + except Exception as e: + print(f"\n✗ Test failed: {e}") + import traceback + + traceback.print_exc() + sys.exit(1) From 13d25714c45a08bec6f9f315ea84b95023f79da7 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Thu, 5 Feb 2026 10:07:58 +0100 Subject: [PATCH 17/70] ci --- .../ci_energyml_utils_pull_request.yml | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/ci_energyml_utils_pull_request.yml b/.github/workflows/ci_energyml_utils_pull_request.yml index 8903539..b0c13a0 100644 --- a/.github/workflows/ci_energyml_utils_pull_request.yml +++ b/.github/workflows/ci_energyml_utils_pull_request.yml @@ -18,8 +18,31 @@ on: types: [published] jobs: + test: + name: Run tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install poetry + uses: ./.github/actions/prepare-poetry + with: + python-version: "3.10" + + - name: Install dependencies + run: | + poetry install + + - name: Run pytest + run: | + poetry run pytest -v --tb=short + build: name: Build distribution + needs: [test] runs-on: ubuntu-latest steps: - name: Checkout code From 195707026af403100e20e44dc401a6344a4ab296 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Thu, 5 Feb 2026 10:17:02 +0100 Subject: [PATCH 18/70] pytest --- .github/workflows/ci_energyml_utils_pull_request.yml | 2 +- energyml-utils/tests/test_uri.py | 2 +- energyml-utils/tests/test_xml.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci_energyml_utils_pull_request.yml b/.github/workflows/ci_energyml_utils_pull_request.yml index b0c13a0..50380a7 100644 --- a/.github/workflows/ci_energyml_utils_pull_request.yml +++ b/.github/workflows/ci_energyml_utils_pull_request.yml @@ -3,7 +3,7 @@ ## SPDX-License-Identifier: Apache-2.0 ## --- -name: Publish (pypiTest) +name: Test/Build/Publish (pypiTest) defaults: run: diff --git a/energyml-utils/tests/test_uri.py b/energyml-utils/tests/test_uri.py index 8bb6044..5dda5a3 100644 --- a/energyml-utils/tests/test_uri.py +++ b/energyml-utils/tests/test_uri.py @@ -1,7 +1,7 @@ # Copyright (c) 2023-2024 Geosiris. # SPDX-License-Identifier: Apache-2.0 -from src.energyml.utils.uri import Uri, parse_uri +from energyml.utils.uri import Uri, parse_uri from energyml.utils.introspection import get_obj_uri from energyml.resqml.v2_0_1.resqmlv2 import TriangulatedSetRepresentation, ObjTriangulatedSetRepresentation diff --git a/energyml-utils/tests/test_xml.py b/energyml-utils/tests/test_xml.py index 4bf1f67..bfd3309 100644 --- a/energyml-utils/tests/test_xml.py +++ b/energyml-utils/tests/test_xml.py @@ -3,7 +3,7 @@ import logging -from scripts.optimized_constants import parse_qualified_type +from energyml.utils.constants import parse_qualified_type from src.energyml.utils.xml import * CT_20 = "application/x-resqml+xml;version=2.0;type=obj_TriangulatedSetRepresentation" From 2e2d5d49ae5de32592f90e37f0f2e77f59d0dfd1 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Thu, 5 Feb 2026 10:25:39 +0100 Subject: [PATCH 19/70] retro compatibility --- energyml-utils/src/energyml/utils/constants.py | 5 ++++- energyml-utils/src/energyml/utils/introspection.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/energyml-utils/src/energyml/utils/constants.py b/energyml-utils/src/energyml/utils/constants.py index f2e13d8..5735660 100644 --- a/energyml-utils/src/energyml/utils/constants.py +++ b/energyml-utils/src/energyml/utils/constants.py @@ -427,7 +427,10 @@ def epoch(time_zone=datetime.timezone.utc) -> int: def date_to_epoch(date: str) -> int: """Convert energyml date string to epoch timestamp""" try: - return int(datetime.datetime.fromisoformat(date).timestamp()) + # Python 3.10 doesn't support 'Z' suffix in fromisoformat() + # Replace 'Z' with '+00:00' for compatibility + date_normalized = date.replace("Z", "+00:00") if date.endswith("Z") else date + return int(datetime.datetime.fromisoformat(date_normalized).timestamp()) except (ValueError, TypeError): raise ValueError(f"Invalid date format: {date}") diff --git a/energyml-utils/src/energyml/utils/introspection.py b/energyml-utils/src/energyml/utils/introspection.py index db23fed..00408aa 100644 --- a/energyml-utils/src/energyml/utils/introspection.py +++ b/energyml-utils/src/energyml/utils/introspection.py @@ -1111,7 +1111,7 @@ def copy_attributes( p_list = search_attribute_matching_name_with_path( obj=obj_out, name_rgx=k_in, - re_flags=re.IGNORECASE if ignore_case else re.NOFLAG, + re_flags=re.IGNORECASE if ignore_case else 0, # re.NOFLAG only available in Python 3.11+ deep_search=False, search_in_sub_obj=False, ) From eb450a6a41d54a8a355652eadda342fe675adf9b Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Wed, 11 Feb 2026 16:18:52 +0100 Subject: [PATCH 20/70] bugfix for epc rels export --- energyml-utils/.gitignore | 4 + energyml-utils/example/main_stream.py | 2 +- energyml-utils/example/main_stream_sample.py | 192 + .../example/validate_epc_example.py | 99 + energyml-utils/rc/epc/testingPackageCpp.h5 | Bin 100363 -> 100363 bytes .../src/energyml/utils/constants.py | 93 +- .../src/energyml/utils/data/datasets_io.py | 3 +- energyml-utils/src/energyml/utils/epc.py | 116 +- .../src/energyml/utils/epc_stream.py | 3506 ++++++---------- .../src/energyml/utils/epc_stream_old.py | 3572 +++++++++++++++++ .../src/energyml/utils/epc_utils.py | 306 ++ .../src/energyml/utils/epc_validator.py | 618 +++ .../src/energyml/utils/exception.py | 72 + .../src/energyml/utils/introspection.py | 9 +- .../src/energyml/utils/storage_interface.py | 36 + energyml-utils/src/energyml/utils/uri.py | 69 +- energyml-utils/tests/test_epc_stream.py | 255 +- energyml-utils/tests/test_epc_validator.py | 646 +++ .../tests/test_parallel_rels_performance.py | 2 +- energyml-utils/tests/test_uri.py | 28 +- 20 files changed, 7132 insertions(+), 2496 deletions(-) create mode 100644 energyml-utils/example/main_stream_sample.py create mode 100644 energyml-utils/example/validate_epc_example.py create mode 100644 energyml-utils/src/energyml/utils/epc_stream_old.py create mode 100644 energyml-utils/src/energyml/utils/epc_utils.py create mode 100644 energyml-utils/src/energyml/utils/epc_validator.py create mode 100644 energyml-utils/tests/test_epc_validator.py diff --git a/energyml-utils/.gitignore b/energyml-utils/.gitignore index f672e3c..e7c85c7 100644 --- a/energyml-utils/.gitignore +++ b/energyml-utils/.gitignore @@ -62,6 +62,10 @@ docs/*.md *.geojson *.vtk *.stl +rc/specs +rc/**/*.epc +rc/**/*.h5 +rc/**/*.hdf5 # WIP diff --git a/energyml-utils/example/main_stream.py b/energyml-utils/example/main_stream.py index 87f529a..db354d0 100644 --- a/energyml-utils/example/main_stream.py +++ b/energyml-utils/example/main_stream.py @@ -13,7 +13,7 @@ from energyml.utils.introspection import get_obj_uri from energyml.utils.constants import EpcExportVersion -from energyml.utils.epc_stream import read_epc_stream +from energyml.utils.epc_stream_old import read_epc_stream from energyml.utils.epc import ( Epc, create_energyml_object, diff --git a/energyml-utils/example/main_stream_sample.py b/energyml-utils/example/main_stream_sample.py new file mode 100644 index 0000000..8a2d11a --- /dev/null +++ b/energyml-utils/example/main_stream_sample.py @@ -0,0 +1,192 @@ +import os +import sys +import logging +from energyml.utils.epc_stream import EpcStreamReader, RelsUpdateMode +from energyml.eml.v2_3.commonv2 import Citation +from energyml.resqml.v2_2.resqmlv2 import ( + TriangulatedSetRepresentation, + BoundaryFeatureInterpretation, + BoundaryFeature, + HorizonInterpretation, +) +from energyml.utils.introspection import epoch_to_date, epoch +from energyml.utils.epc import as_dor, gen_uuid, get_obj_identifier +from energyml.utils.constants import EPCRelsRelationshipType + + +def sample_objects(): + """Create sample EnergyML objects for testing.""" + # Create a BoundaryFeature + bf = BoundaryFeature( + citation=Citation( + title="Test Boundary Feature", + originator="Test", + creation=epoch_to_date(epoch()), + ), + uuid="25773477-ffee-4cc2-867d-000000000001", + object_version="1.0", + ) + + # Create a BoundaryFeatureInterpretation + bfi = BoundaryFeatureInterpretation( + citation=Citation( + title="Test Boundary Feature Interpretation", + originator="Test", + creation=epoch_to_date(epoch()), + ), + uuid="25773477-ffee-4cc2-867d-000000000002", + object_version="1.0", + interpreted_feature=as_dor(bf), + ) + + # Create a HorizonInterpretation (independent object) + horizon_interp = HorizonInterpretation( + citation=Citation( + title="Test HorizonInterpretation", + originator="Test", + creation=epoch_to_date(epoch()), + ), + interpreted_feature=as_dor(bf), + uuid="25773477-ffee-4cc2-867d-000000000003", + object_version="1.0", + domain="depth", + ) + + # Create a TriangulatedSetRepresentation + trset = TriangulatedSetRepresentation( + citation=Citation( + title="Test TriangulatedSetRepresentation", + originator="Test", + creation=epoch_to_date(epoch()), + ), + uuid="25773477-ffee-4cc2-867d-000000000004", + object_version="1.0", + represented_object=as_dor(horizon_interp), + ) + + return { + "bf": bf, + "bfi": bfi, + "trset": trset, + "horizon_interp": horizon_interp, + } + + +def main(epc_file_path: str): + epc = EpcStreamReader( + epc_file_path=epc_file_path, enable_parallel_rels=True, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION + ) + + # logging.info(epc.get_statistics()) + + for obj in epc.list_objects(): + logging.info(f"Object: {obj}") + + +def test_create_epc(path: str): + # delete file if exists + if os.path.exists(path): + os.remove(path) + logging.info(f"==> Creating new EPC at {path}...") + epc = EpcStreamReader(epc_file_path=path, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + data = sample_objects() + + logging.info("==> Creating sample objects and adding to EPC...") + + logging.info("==> Adding horizon interpretation") + epc.add_object(data["horizon_interp"]) + logging.info(f"horizon rels : {epc.get_obj_rels(data['horizon_interp'])}") + + logging.info("==> Adding boundary feature") + epc.add_object(data["bf"]) + logging.info(f"boundary feature rels : {epc.get_obj_rels(data['bf'])}") + + logging.info("==> Adding boundary feature interpretation") + epc.add_object(data["bfi"]) + logging.info("==> Adding triangulated set representation") + epc.add_object(data["trset"]) + + # Debug: Print all metadata identifiers + logging.info(f"==> All metadata identifiers: {list(epc._metadata_mgr._metadata.keys())}") + + logging.info("==> All objects added. Closing EPC to write to disk.") + + horizon_id = get_obj_identifier(data["horizon_interp"]) + logging.info(f"==> Horizon identifier: {horizon_id}") + logging.info(f"==> Horizon in metadata: {horizon_id in epc._metadata_mgr._metadata}") + + # Debug: Test _id_from_uri_or_identifier + resolved_id = epc._id_from_uri_or_identifier(data["horizon_interp"]) + logging.info(f"==> Resolved ID from object: {resolved_id}") + logging.info( + f"==> Resolved ID in metadata: {resolved_id in epc._metadata_mgr._metadata if resolved_id else 'ID is None'}" + ) + + horizon_rels = epc.get_obj_rels(data["horizon_interp"]) + assert ( + len(horizon_rels) == 2 + ), f"Expected 2 relationships in horizon rels since both bfi and trset should refer to horizon as interpreted feature {horizon_rels}" + epc.close() + + epc_reopen = EpcStreamReader(epc_file_path=path, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + horizon_rels = epc_reopen.get_obj_rels(data["horizon_interp"]) + assert ( + len(horizon_rels) == 2 + ), f"Expected 2 relationships in horizon rels since both bfi and trset should refer to horizon as interpreted feature {horizon_rels}" + + logging.info("==> Reopened EPC, listing objects:") + for obj in epc_reopen.list_objects(): + logging.info(f"Object: {obj}") + obj_rels = epc_reopen.get_obj_rels(obj) + logging.info(f"\tObject rels: {obj_rels}") + dest_rels = [r for r in obj_rels if r.type_value == str(EPCRelsRelationshipType.DESTINATION_OBJECT)] + logging.info(f"\tObject DESTINATION rels: {dest_rels}") + + # remove trset to check if horizon has no more source rels + epc_reopen.remove_object(data["trset"]) + + horizon_rels_after_removal = epc_reopen.get_obj_rels(data["horizon_interp"]) + logging.info(f"Horizon interpretation rels after removing trset: {horizon_rels_after_removal}") + source_rels_after_removal = [ + r for r in horizon_rels_after_removal if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT) + ] + logging.info(f"Horizon interpretation SOURCE rels after removing trset: {source_rels_after_removal}") + assert ( + len(source_rels_after_removal) == 0 + ), "Expected no SOURCE relationships in horizon rels after removing trset since trset was the only destination referring to horizon" + + assert ( + len(horizon_rels_after_removal) == 1 + ), "Expected 1 relationship in horizon rels after removing trset since bfi should still refer to horizon as interpreted feature" + + epc_reopen.close() + + +def test_create_epc_v2(path: str): + + if os.path.exists(path): + os.remove(path) + logging.info(f"==> Creating new EPC at {path}...") + epc = EpcStreamReader(epc_file_path=path, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + data = sample_objects() + + epc.add_object(data["bf"]) + # epc.add_object(data["bfi"]) + epc.add_object(data["horizon_interp"]) + epc.add_object(data["trset"]) + + hi_rels = epc.get_obj_rels(data["horizon_interp"]) + + logging.info(f"Horizon interpretation rels: {hi_rels}") + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + + # main((sys.argv[1] if len(sys.argv) > 1 else None) or "wip/80wells_surf.epc") + + # test_create_epc("wip/test_create.epc") + test_create_epc_v2("wip/test_create.epc") diff --git a/energyml-utils/example/validate_epc_example.py b/energyml-utils/example/validate_epc_example.py new file mode 100644 index 0000000..8a5f5e2 --- /dev/null +++ b/energyml-utils/example/validate_epc_example.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# Copyright (c) 2023-2024 Geosiris. +# SPDX-License-Identifier: Apache-2.0 +""" +Example script demonstrating EPC validation. + +This script shows how to validate EPC files and generate reports. +""" + +import sys +from pathlib import Path + +from energyml.utils.epc_validator import validate_epc_file + + +def validate_single_file(epc_path: str) -> None: + """Validate a single EPC file and print results.""" + print(f"\n{'=' * 70}") + print(f"Validating: {epc_path}") + print(f"{'=' * 70}\n") + + try: + result = validate_epc_file(epc_path, strict=True, check_relationships=True) + + print(result) + + if result.is_valid: + print("\n✓ Validation PASSED!") + else: + print("\n✗ Validation FAILED!") + sys.exit(1) + + except Exception as e: + print(f"\n✗ Error during validation: {e}") + sys.exit(1) + + +def validate_directory(directory: str) -> None: + """Validate all EPC files in a directory.""" + print(f"\n{'=' * 70}") + print(f"Validating all EPC files in: {directory}") + print(f"{'=' * 70}\n") + + epc_files = list(Path(directory).glob("**/*.epc")) + + if not epc_files: + print(f"No EPC files found in {directory}") + return + + print(f"Found {len(epc_files)} EPC file(s)\n") + + results = {} + for epc_file in epc_files: + print(f"Validating {epc_file.name}...", end=" ") + result = validate_epc_file(str(epc_file)) + + if result.is_valid: + print("✓ PASSED") + else: + print("✗ FAILED") + for error in result.errors[:3]: # Show first 3 errors + print(f" - {error}") + if len(result.errors) > 3: + print(f" ... and {len(result.errors) - 3} more errors") + + results[epc_file.name] = result + + # Summary + print(f"\n{'=' * 70}") + print("SUMMARY") + print(f"{'=' * 70}") + passed = sum(1 for r in results.values() if r.is_valid) + failed = len(results) - passed + print(f"Total files: {len(results)}") + print(f"Passed: {passed}") + print(f"Failed: {failed}") + + +def main(): + """Main entry point.""" + if len(sys.argv) < 2: + print("Usage:") + print(f" {sys.argv[0]} # Validate a single file") + print(f" {sys.argv[0]} # Validate all EPC files in directory") + sys.exit(1) + + path = sys.argv[1] + + if Path(path).is_file(): + validate_single_file(path) + elif Path(path).is_dir(): + validate_directory(path) + else: + print(f"Error: '{path}' is neither a file nor a directory") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/energyml-utils/rc/epc/testingPackageCpp.h5 b/energyml-utils/rc/epc/testingPackageCpp.h5 index 21035b0eeb3ebdf7372d0a3534c303620848c9a7..07644d4189e264ee7a1e854ba9c94d9cbbd73e19 100644 GIT binary patch delta 38 ucmeC4z}7v1ji=MY#f_Isii?SZgK;8{0!z)Gox3InK9g?r-s;VGpdA3#Mhu+* delta 38 ucmeC4z}7v1ji=MY#f_Isii?SZgJB|%0*kXu*}}[^\"]+)\"|version\s*=\s*\"(?P[^\"]+)\"|standalone\s*=\s*\"(?P[^\"]+)\"))+" -RGX_IDENTIFIER = rf"{RGX_UUID}(.(?P\w+)?)?" +RGX_IDENTIFIER = rf"{RGX_UUID}.((?P\w+)?)?" # URI regex components URI_RGX_GRP_DOMAIN = "domain" @@ -208,6 +208,7 @@ class OptimizedRegex: # CONSTANTS AND ENUMS # =================================== +# TODO: RELS_CONTENT_TYPE may be incorrect or not well named, needs review RELS_CONTENT_TYPE = "application/vnd.openxmlformats-package.core-properties+xml" RELS_FOLDER_NAME = "_rels" @@ -222,6 +223,8 @@ class MimeType(Enum): PARQUET = "application/x-parquet" PDF = "application/pdf" RELS = "application/vnd.openxmlformats-package.relationships+xml" + CORE_PROPERTIES = "application/vnd.openxmlformats-package.core-properties+xml" + EXTENDED_CORE_PROPERTIES = "application/x-extended-core-properties+xml" def __str__(self): return self.value @@ -237,17 +240,26 @@ class EpcExportVersion(Enum): class EPCRelsRelationshipType(Enum): """EPC relationships types with proper URL generation""" - # Standard relationship types DESTINATION_OBJECT = "destinationObject" + """The object in Target is the destination of the relationship.""" SOURCE_OBJECT = "sourceObject" + """The current object is the source in the relationship with the target object.""" ML_TO_EXTERNAL_PART_PROXY = "mlToExternalPartProxy" + """The target object is a proxy object for an external file.""" EXTERNAL_PART_PROXY_TO_ML = "externalPartProxyToMl" + """The current object is used as a proxy object by the target object.""" EXTERNAL_RESOURCE = "externalResource" + """The target is a resource outside of the EPC package. Note that TargetMode should be "External" for this relationship.""" DestinationMedia = "destinationMedia" + """The object in Target is a media representation for the current object. As a guideline, media files should be stored in a "media" folder in the root of the package.""" SOURCE_MEDIA = "sourceMedia" + """The current object is a media representation for the object in Target.""" CHUNKED_PART = "chunkedPart" + """The target is part of a larger data object that has been chunked into several smaller files.""" CORE_PROPERTIES = "core-properties" - EXTENDED_CORE_PROPERTIES = "extended-core-properties" # Not in standard + """Core properties metadata relationship.""" + EXTENDED_CORE_PROPERTIES = "extended-core-properties" + """Extended core properties metadata relationship (not in standard).""" def get_type(self) -> str: """Get the full relationship type URL""" @@ -258,13 +270,16 @@ def get_type(self) -> str: else: return "http://schemas.energistics.org/package/2012/relationships/" + self.value + def __str__(self) -> str: + return self.get_type() + @dataclass class RawFile: """A class for non-energyml files to be stored in an EPC file""" path: str = field(default="_") - content: BytesIO = field(default=None) + content: Optional[BytesIO] = field(default=None) # =================================== @@ -360,11 +375,11 @@ def content_type_to_qualified_type(ct: str) -> Optional[str]: return None -def qualified_type_to_content_type(qt: str) -> Optional[str]: +def qualified_type_to_content_type(qt: str) -> str: """Convert qualified type to content type format""" parsed = parse_content_or_qualified_type(qt) if not parsed: - return None + raise ValueError(f"Failed to parse qualified type: {qt}") try: domain = parsed.group("domain") @@ -376,7 +391,7 @@ def qualified_type_to_content_type(qt: str) -> Optional[str]: return f"application/x-{domain}+xml;" f"version={formatted_version};" f"type={obj_type}" except (AttributeError, KeyError): - return None + raise ValueError(f"Failed to convert qualified type to content type: {qt}") def get_domain_version_from_content_or_qualified_type(cqt: str) -> Optional[str]: @@ -391,6 +406,18 @@ def get_domain_version_from_content_or_qualified_type(cqt: str) -> Optional[str] return None +def get_obj_type_from_content_or_qualified_type(cqt: str) -> str: + """Extract object type (e.g., "WellboreFeature") from content or qualified type""" + parsed = parse_content_or_qualified_type(cqt) + if not parsed: + raise ValueError(f"Failed to parse content or qualified type: {cqt}") + + if parsed.group("type") is None: + raise ValueError(f"Failed to extract object type from content or qualified type: {cqt}") + + return parsed.group("type") + + def split_identifier(identifier: str) -> Tuple[Optional[str], Optional[str]]: """Split identifier into UUID and version components""" if not identifier: @@ -435,6 +462,17 @@ def date_to_epoch(date: str) -> int: raise ValueError(f"Invalid date format: {date}") +def date_to_datetime(date: str) -> datetime.datetime: + """Convert energyml date string to datetime object""" + try: + # Python 3.10 doesn't support 'Z' suffix in fromisoformat() + # Replace 'Z' with '+00:00' for compatibility + date_normalized = date.replace("Z", "+00:00") if date.endswith("Z") else date + return datetime.datetime.fromisoformat(date_normalized) + except (ValueError, TypeError): + raise ValueError(f"Invalid date format: {date}") + + def epoch_to_date(epoch_value: int) -> str: """Convert epoch timestamp to energyml date format""" try: @@ -449,6 +487,18 @@ def gen_uuid() -> str: return str(uuid_mod.uuid4()) +def extract_uuid_from_string(s: str) -> Optional[str]: + """Extract UUID from a string using optimized regex""" + if not s: + return None + + match = OptimizedRegex.UUID_NO_GRP.search(s) + if match: + return match.group(0) + + return None + + def mime_type_to_file_extension(mime_type: str) -> Optional[str]: """Convert MIME type to file extension""" if not mime_type: @@ -465,11 +515,38 @@ def mime_type_to_file_extension(mime_type: str) -> Optional[str]: "text/csv": "csv", "application/vnd.openxmlformats-package.relationships+xml": "rels", "application/pdf": "pdf", + "application/xml": "xml", + "text/xml": "xml", + "application/json": "json", + "application/vnd.openxmlformats-package.core-properties+xml": "xml", + "application/x-extended-core-properties+xml": "xml", } return mime_to_ext.get(mime_type_lower) +def file_extension_to_mime_type(extension: str) -> Optional[str]: + """Convert file extension to MIME type""" + if not extension: + return None + + ext_lower = extension.lower() + + # Use dict for faster lookup than if/elif chain + ext_to_mime = { + "parquet": "application/x-parquet", + "h5": "application/x-hdf5", + "hdf5": "application/x-hdf5", + "csv": "text/csv", + "rels": "application/vnd.openxmlformats-package.relationships+xml", + "pdf": "application/pdf", + "xml": "application/xml", + "json": "application/json", + } + + return ext_to_mime.get(ext_lower) + + # =================================== # PATH UTILITIES # =================================== @@ -583,3 +660,5 @@ def get_property_kind_dict_path_as_xml() -> str: result = OptimizedRegex.URI.search(test_string) print(f" {name}: {'✓' if result else '✗'} - {test_string[:50]}{'...' if len(test_string) > 50 else ''}") + + print(EPCRelsRelationshipType.EXTENDED_CORE_PROPERTIES) diff --git a/energyml-utils/src/energyml/utils/data/datasets_io.py b/energyml-utils/src/energyml/utils/data/datasets_io.py index d899015..131f2bb 100644 --- a/energyml-utils/src/energyml/utils/data/datasets_io.py +++ b/energyml-utils/src/energyml/utils/data/datasets_io.py @@ -580,7 +580,6 @@ def read_dataset( mimetype: Optional[str] = "application/x-hdf5", ) -> Any: mimetype = (mimetype or "").lower() - file_reader = HDF5FileReader() # default is hdf5 if "parquet" in mimetype or ( isinstance(source, str) and (source.lower().endswith(".parquet") or source.lower().endswith(".pqt")) ): @@ -589,6 +588,8 @@ def read_dataset( isinstance(source, str) and (source.lower().endswith(".csv") or source.lower().endswith(".dat")) ): file_reader = CSVFileReader() + else: + file_reader = HDF5FileReader() # default is hdf5 return file_reader.read_array(source, path_in_external_file) diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index e44fe22..105acfc 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -30,6 +30,12 @@ Keywords1, TargetMode, ) +from energyml.utils.epc_utils import ( + gen_core_props_path, + gen_energyml_object_path, + gen_rels_path, + get_epc_content_type_path, +) from energyml.utils.storage_interface import DataArrayMetadata, EnergymlStorageInterface, ResourceMetadata import numpy as np from .uri import Uri, parse_uri @@ -103,7 +109,7 @@ class Epc(EnergymlStorageInterface): export_version: EpcExportVersion = field(default=EpcExportVersion.CLASSIC) - core_props: CoreProperties = field(default=None) + core_props: Optional[CoreProperties] = field(default=None) """ xml files referred in the [Content_Types].xml """ energyml_objects: List = field( @@ -724,14 +730,11 @@ def read_stream(cls, epc_file_io: BytesIO): # returns an Epc instance # RELS FILES READING START # logging.debug(f"reading rels {f_info.filename}") - ( - rels_folder, - rels_file_name, - ) = get_file_folder_and_name_from_path(f_info.filename) - while rels_folder.endswith("/"): - rels_folder = rels_folder[:-1] - obj_folder = rels_folder[: rels_folder.rindex("/") + 1] if "/" in rels_folder else "" - obj_file_name = rels_file_name[:-5] # removing the ".rels" + rels_path = Path(f_info.filename) + obj_folder = ( + str(rels_path.parent.parent) + "/" if str(rels_path.parent.parent) != "." else "" + ) + obj_file_name = rels_path.stem # removing the ".rels" rels_file: Relationships = read_energyml_xml_bytes( epc_file.read(f_info.filename), Relationships, @@ -907,7 +910,7 @@ def as_dor(obj_or_identifier: Any, dor_qualified_type: str = "eml23.DataObjectRe if isinstance(obj_or_identifier, str): # is an identifier or uri parsed_uri = parse_uri(obj_or_identifier) if parsed_uri is not None: - print(f"====> parsed uri {parsed_uri} : uuid is {parsed_uri.uuid}") + logging.debug(f"====> parsed uri {parsed_uri} : uuid is {parsed_uri.uuid}") if hasattr(dor, "qualified_type"): set_attribute_from_path(dor, "qualified_type", parsed_uri.get_qualified_type()) if hasattr(dor, "content_type"): @@ -960,8 +963,9 @@ def as_dor(obj_or_identifier: Any, dor_qualified_type: str = "eml23.DataObjectRe dor.content_type = get_object_attribute(obj_or_identifier, "content_type") set_attribute_from_path(dor, "title", get_object_attribute(obj_or_identifier, "Title")) - set_attribute_from_path(dor, "uuid", get_obj_uuid(obj_or_identifier)) - set_attribute_from_path(dor, "uid", get_obj_uuid(obj_or_identifier)) + obj_uuid = get_obj_uuid(obj_or_identifier) + set_attribute_from_path(dor, "uuid", obj_uuid) + set_attribute_from_path(dor, "uid", obj_uuid) if hasattr(dor, "object_version"): set_attribute_from_path(dor, "object_version", get_obj_version(obj_or_identifier)) if hasattr(dor, "version_string"): @@ -989,9 +993,10 @@ def as_dor(obj_or_identifier: Any, dor_qualified_type: str = "eml23.DataObjectRe logging.error(f"Failed to set content_type for DOR {e}") set_attribute_from_path(dor, "title", get_object_attribute(obj_or_identifier, "Citation.Title")) - - set_attribute_from_path(dor, "uuid", get_obj_uuid(obj_or_identifier)) - set_attribute_from_path(dor, "uid", get_obj_uuid(obj_or_identifier)) + obj_uuid = get_obj_uuid(obj_or_identifier) + # logging.debug(f"====> obj uuid is {obj_uuid}") + set_attribute_from_path(dor, "uid", obj_uuid) + set_attribute_from_path(dor, "uuid", obj_uuid) if hasattr(dor, "object_version"): set_attribute_from_path(dor, "object_version", get_obj_version(obj_or_identifier)) if hasattr(dor, "version_string"): @@ -1088,43 +1093,6 @@ def get_reverse_dor_list(obj_list: List[Any], key_func: Callable = get_obj_ident # PATHS -def gen_core_props_path( - export_version: EpcExportVersion = EpcExportVersion.CLASSIC, -): - return "docProps/core.xml" - - -def gen_energyml_object_path( - energyml_object: Union[str, Any], - export_version: EpcExportVersion = EpcExportVersion.CLASSIC, -): - """ - Generate a path to store the :param:`energyml_object` into an epc file (depending on the :param:`export_version`) - :param energyml_object: - :param export_version: - :return: - """ - if isinstance(energyml_object, str): - energyml_object = read_energyml_xml_str(energyml_object) - - obj_type = get_object_type_for_file_path_from_class(energyml_object.__class__) - # logging.debug("is_dor: ", str(is_dor(energyml_object)), "object type : " + str(obj_type)) - - if is_dor(energyml_object): - uuid, pkg, pkg_version, obj_cls, object_version = get_dor_obj_info(energyml_object) - obj_type = get_object_type_for_file_path_from_class(obj_cls) - else: - pkg = get_class_pkg(energyml_object) - pkg_version = get_class_pkg_version(energyml_object) - object_version = get_obj_version(energyml_object) - uuid = get_obj_uuid(energyml_object) - - if export_version == EpcExportVersion.EXPANDED: - return f"namespace_{pkg}{pkg_version.replace('.', '')}/{(('version_' + object_version + '/') if object_version is not None and len(object_version) > 0 else '')}{obj_type}_{uuid}.xml" - else: - return obj_type + "_" + uuid + ".xml" - - def get_file_folder_and_name_from_path(path: str) -> Tuple[str, str]: """ Returns a tuple (FOLDER_PATH, FILE_NAME) @@ -1136,48 +1104,4 @@ def get_file_folder_and_name_from_path(path: str) -> Tuple[str, str]: return obj_folder, obj_file_name -def gen_rels_path( - energyml_object: Any, - export_version: EpcExportVersion = EpcExportVersion.CLASSIC, -) -> str: - """ - Generate a path to store the :param:`energyml_object` rels file into an epc file - (depending on the :param:`export_version`) - :param energyml_object: - :param export_version: - :return: - """ - if isinstance(energyml_object, CoreProperties): - return f"{RELS_FOLDER_NAME}/.rels" - else: - obj_path = gen_energyml_object_path(energyml_object, export_version) - obj_folder, obj_file_name = get_file_folder_and_name_from_path(obj_path) - return f"{obj_folder}{RELS_FOLDER_NAME}/{obj_file_name}.rels" - - # def gen_rels_path_from_dor(dor: Any, export_version: EpcExportVersion = EpcExportVersion.CLASSIC) -> str: - - -def get_epc_content_type_path( - export_version: EpcExportVersion = EpcExportVersion.CLASSIC, -) -> str: - """ - Generate a path to store the "[Content_Types].xml" file into an epc file - (depending on the :param:`export_version`) - :return: - """ - return "[Content_Types].xml" - - -def create_h5_external_relationship(h5_path: str, current_idx: int = 0) -> Relationship: - """ - Create a Relationship object to link an external HDF5 file. - :param h5_path: - :return: - """ - return Relationship( - target=h5_path, - type_value=EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(), - id=f"Hdf5File{current_idx + 1 if current_idx > 0 else ''}", - target_mode=TargetMode.EXTERNAL, - ) diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index 6c8686a..163896b 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -8,46 +8,128 @@ content into memory at once. """ +import atexit +from datetime import datetime +from io import BytesIO import tempfile +import traceback +import numpy as np import shutil import logging import os +import re import zipfile from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path -from typing import Dict, List, Optional, Any, Iterator, Union, Tuple, TypedDict +from typing import Dict, List, Optional, Any, Iterator, Set, Union, Tuple, TypedDict from weakref import WeakValueDictionary -from energyml.opc.opc import Types, Override, CoreProperties, Relationships, Relationship +from energyml.opc.opc import ( + Types, + Override, + CoreProperties, + Relationships, + Relationship, + Default, + Created, + Creator, + Identifier, +) from energyml.utils.data.datasets_io import HDF5FileReader, HDF5FileWriter -from energyml.utils.storage_interface import DataArrayMetadata, EnergymlStorageInterface, ResourceMetadata -from energyml.utils.uri import Uri, parse_uri -import h5py -import numpy as np +from energyml.utils.epc_utils import ( + EXPANDED_EXPORT_FOLDER_PREFIX, + create_default_core_properties, + create_default_types, + create_mandatory_structure_epc, + extract_uuid_and_version_from_obj_path, + gen_core_props_rels_path, + gen_rels_path_from_obj_path, + repair_epc_structure_if_not_valid, + valdiate_basic_epc_structure, +) +from energyml.utils.storage_interface import ( + DataArrayMetadata, + EnergymlStorageInterface, + ResourceMetadata, + create_resource_metadata_from_uri, +) +from energyml.utils.uri import Uri, create_uri_from_content_type_or_qualified_type, parse_uri from energyml.utils.constants import ( EPCRelsRelationshipType, - OptimizedRegex, EpcExportVersion, - content_type_to_qualified_type, + MimeType, + OptimizedRegex, + file_extension_to_mime_type, + date_to_datetime, + get_obj_type_from_content_or_qualified_type, + parse_content_type, +) +from energyml.utils.epc import ( + gen_energyml_object_path, + gen_rels_path, + get_epc_content_type_path, + gen_core_props_path, ) -from energyml.utils.epc import Epc, gen_energyml_object_path, gen_rels_path, get_epc_content_type_path from energyml.utils.introspection import ( get_class_from_content_type, + get_content_type_from_class, get_obj_content_type, get_obj_identifier, + get_obj_title, + get_obj_uri, get_obj_uuid, + get_object_attribute_advanced, get_object_type_for_file_path_from_class, get_direct_dor_list, get_obj_type, get_obj_usable_class, + epoch_to_date, + epoch, + gen_uuid, ) from energyml.utils.serialization import read_energyml_xml_bytes, serialize_xml + + from .xml import is_energyml_content_type from enum import Enum +def get_dor_identifiers_from_obj(obj: Any) -> Set[str]: + """Get identifiers of all Data Object References (DORs) directly referenced by the given object.""" + identifiers = set() + try: + dor_list = get_direct_dor_list(obj) + for dor in dor_list: + try: + identifier = get_obj_identifier(dor) + if identifier: + identifiers.add(identifier) + except Exception as e: + logging.warning(f"Failed to extract identifier from DOR: {e}") + except Exception as e: + logging.warning(f"Failed to get DOR list from object: {e}") + return identifiers + + +def get_dor_uris_from_obj(obj: Any) -> Set[Uri]: + """Get uri of all Data Object References (DORs) directly referenced by the given object.""" + uri_set = set() + try: + dor_list = get_direct_dor_list(obj) + for dor in dor_list: + try: + uri = get_obj_uri(dor) + if uri: + uri_set.add(uri) + except Exception as e: + logging.warning(f"Failed to extract uri from DOR: {e}") + except Exception as e: + logging.warning(f"Failed to get DOR list from object: {e}") + return uri_set + + class RelsUpdateMode(Enum): """ Relationship update modes for EPC file management. @@ -72,17 +154,53 @@ class RelsUpdateMode(Enum): class EpcObjectMetadata: """Metadata for an object in the EPC file.""" - uuid: str - object_type: str - content_type: str - file_path: str - identifier: Optional[str] = None - version: Optional[str] = None + uri: Uri + + title: Optional[str] = None + custom_data: Optional[Dict[str, Any]] = None + last_changed: Optional[datetime] = None def __post_init__(self): - if self.identifier is None: - # Generate identifier if not provided - object.__setattr__(self, "identifier", f"{self.uuid}.{self.version or ''}") + if not self.uri.is_object_uri(): + raise ValueError(f"URI must be an object URI: {self.uri}") + + @property + def uuid(self) -> str: + return self.uri.uuid # type: ignore Guaranteed to be non-None for object URIs due to __post_init__ validation + + @property + def object_type(self) -> str: + return self.uri.object_type # type: ignore Guaranteed to be non-None for object URIs due to __post_init__ validation + + @property + def content_type(self) -> str: + return self.uri.get_content_type() + + @property + def qualified_type(self) -> str: + return self.uri.get_qualified_type() + + @property + def version(self) -> Optional[str]: + return self.uri.version + + @property + def identifier(self) -> str: + return self.uri.as_identifier() + + def file_path(self, export_version: EpcExportVersion) -> str: + return gen_energyml_object_path(self.uri, export_version=export_version) + + def rels_path(self, export_version: EpcExportVersion) -> str: + return gen_rels_path_from_obj_path(self.file_path(export_version=export_version)) + + def __str__(self): + return str(self.uri) + + def to_resource_metadata(self) -> ResourceMetadata: + return create_resource_metadata_from_uri( + self.uri, title=self.title, custom_data=self.custom_data, last_changed=self.last_changed + ) @dataclass @@ -120,12 +238,13 @@ class _WorkerResult(TypedDict): """Type definition for parallel worker function return value.""" identifier: str - object_type: str - source_rels: List[Dict[str, str]] - dor_targets: List[Tuple[str, str]] + file_path: str + dest_obj_identifiers: Set[str] -def _process_object_for_rels_worker(args: Tuple[str, str, Dict[str, EpcObjectMetadata]]) -> Optional[_WorkerResult]: +def process_object_for_rels_worker( + args: Tuple[str, str, Dict[str, EpcObjectMetadata]], export_version: EpcExportVersion +) -> Optional[_WorkerResult]: """ Worker function for parallel relationship processing (runs in separate process). @@ -138,78 +257,39 @@ def _process_object_for_rels_worker(args: Tuple[str, str, Dict[str, EpcObjectMet - Results are serialized back to the main process via pickle Args: - args: Tuple containing: + args: + - args: Tuple containing: - identifier: Object UUID/identifier to process - epc_file_path: Absolute path to the EPC file - metadata_dict: Dictionary of all object metadata (for validation) + - export_version: Version of EPC export format to use Returns: Dictionary conforming to _WorkerResult TypedDict, or None if processing fails. """ identifier, epc_file_path, metadata_dict = args + dor_targets = [] + try: # Open ZIP file in this worker process - import zipfile - from energyml.utils.serialization import read_energyml_xml_bytes - from energyml.utils.introspection import ( - get_direct_dor_list, - get_obj_identifier, - get_obj_type, - get_obj_usable_class, - ) - from energyml.utils.constants import EPCRelsRelationshipType - from energyml.utils.introspection import get_class_from_content_type - metadata = metadata_dict.get(identifier) if not metadata: return None # Load object from ZIP with zipfile.ZipFile(epc_file_path, "r") as zf: - obj_data = zf.read(metadata.file_path) + obj_data = zf.read(metadata.file_path(export_version=export_version)) obj_class = get_class_from_content_type(metadata.content_type) obj = read_energyml_xml_bytes(obj_data, obj_class) - # Extract object type (cached to avoid reloading in Phase 3) - obj_type = get_obj_type(get_obj_usable_class(obj)) - # Get all Data Object References (DORs) from this object - data_object_references = get_direct_dor_list(obj) - - # Build SOURCE relationships and track referenced objects - source_rels = [] - dor_targets = [] # Track (target_id, target_type) for reverse references - - for dor in data_object_references: - try: - target_identifier = get_obj_identifier(dor) - if target_identifier not in metadata_dict: - continue - - target_metadata = metadata_dict[target_identifier] - - # Extract target type (needed for relationship ID) - target_type = get_obj_type(get_obj_usable_class(dor)) - dor_targets.append((target_identifier, target_type)) - - # Serialize relationship as dict (Relationship objects aren't picklable) - rel_dict = { - "target": target_metadata.file_path, - "type_value": EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), - "id": f"_{identifier}_{target_type}_{target_identifier}", - } - source_rels.append(rel_dict) - - except Exception as e: - # Don't fail entire object processing for one bad DOR - logging.debug(f"Skipping invalid DOR in {identifier}: {e}") + dor_targets = get_dor_identifiers_from_obj(obj) return { "identifier": identifier, - "object_type": obj_type, - "source_rels": source_rels, - "dor_targets": dor_targets, + "file_path": metadata.file_path(export_version=export_version), + "dest_obj_identifiers": dor_targets, } except Exception as e: @@ -289,6 +369,20 @@ def close(self) -> None: finally: self._persistent_zip = None + def __del__(self): + """Ensure the persistent ZIP file is closed when the accessor is garbage collected.""" + try: + self.close() + except Exception: + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + return False + class _MetadataManager: """ @@ -315,11 +409,14 @@ def __init__(self, zip_accessor: _ZipFileAccessor, stats: EpcStreamingStats): # Object metadata storage self._metadata: Dict[str, EpcObjectMetadata] = {} # identifier -> metadata self._uuid_index: Dict[str, List[str]] = {} # uuid -> list of identifiers - self._type_index: Dict[str, List[str]] = {} # object_type -> list of identifiers self._core_props: Optional[CoreProperties] = None - self._core_props_path: Optional[str] = None + self._export_version = EpcExportVersion.CLASSIC # Store export version, default set to CLASSIC + + def set_export_version(self, version: EpcExportVersion) -> None: + """Set the export version.""" + self._export_version = version - def load_metadata(self) -> None: + def load_metadata(self, detect_export_version: bool = True) -> None: """Load object metadata from [Content_Types].xml without loading actual objects.""" try: with self.zip_accessor.get_zip_file() as zf: @@ -333,6 +430,21 @@ def load_metadata(self) -> None: self._process_energyml_object_metadata(zf, override) elif self._is_core_properties(override.content_type): self._process_core_properties_metadata(override) + else: + logging.debug( + f"Epc_StreamReader @load_metadata Skipping non-EnergyML content type: {override.content_type}" + ) + + # checking export version + if ( + detect_export_version + and self._export_version == EpcExportVersion.CLASSIC + and override.part_name.startswith( + (EXPANDED_EXPORT_FOLDER_PREFIX, f"/{EXPANDED_EXPORT_FOLDER_PREFIX}") + ) + ): + logging.debug(f"Detected EXPANDED EPC version based on path: {override.part_name}") + self._export_version = EpcExportVersion.EXPANDED self.stats.total_objects = len(self._metadata) @@ -340,137 +452,29 @@ def load_metadata(self) -> None: logging.error(f"Failed to load metadata from EPC file: {e}") raise - def _read_content_types(self, zf: zipfile.ZipFile) -> Types: - """Read and parse [Content_Types].xml file.""" - content_types_path = get_epc_content_type_path() - - try: - content_data = zf.read(content_types_path) - self.stats.bytes_read += len(content_data) - return read_energyml_xml_bytes(content_data, Types) - except KeyError: - # Try case-insensitive search - for name in zf.namelist(): - if name.lower() == content_types_path.lower(): - content_data = zf.read(name) - self.stats.bytes_read += len(content_data) - return read_energyml_xml_bytes(content_data, Types) - raise FileNotFoundError("No [Content_Types].xml found in EPC file") - - def _process_energyml_object_metadata(self, zf: zipfile.ZipFile, override: Override) -> None: - """Process metadata for an EnergyML object without loading it.""" - if not override.part_name or not override.content_type: - return - - file_path = override.part_name.lstrip("/") - content_type = override.content_type - - try: - # Quick peek to extract UUID and version without full parsing - uuid, version, obj_type = self._extract_object_info_fast(zf, file_path, content_type) - - if uuid: # Only process if we successfully extracted UUID - metadata = EpcObjectMetadata( - uuid=uuid, object_type=obj_type, content_type=content_type, file_path=file_path, version=version - ) - - # Store in indexes - identifier = metadata.identifier - if identifier: - self._metadata[identifier] = metadata - - # Update UUID index - if uuid not in self._uuid_index: - self._uuid_index[uuid] = [] - self._uuid_index[uuid].append(identifier) - - # Update type index - if obj_type not in self._type_index: - self._type_index[obj_type] = [] - self._type_index[obj_type].append(identifier) - - except Exception as e: - logging.warning(f"Failed to process metadata for {file_path}: {e}") - - def _extract_object_info_fast( - self, zf: zipfile.ZipFile, file_path: str, content_type: str - ) -> Tuple[Optional[str], Optional[str], str]: - """Fast extraction of UUID and version from XML without full parsing.""" - try: - # Read only the beginning of the file for UUID extraction - with zf.open(file_path) as f: - # Read first chunk (usually sufficient for root element) - chunk = f.read(2048) # 2KB should be enough for root element - self.stats.bytes_read += len(chunk) - - chunk_str = chunk.decode("utf-8", errors="ignore") - - # Extract UUID using optimized regex - uuid_match = OptimizedRegex.UUID_NO_GRP.search(chunk_str) - uuid = uuid_match.group(0) if uuid_match else None - - # Extract version if present - version = None - version_patterns = [ - r'object[Vv]ersion["\']?\s*[:=]\s*["\']([^"\']+)', - ] - - for pattern in version_patterns: - import re - - version_match = re.search(pattern, chunk_str) - if version_match: - version = version_match.group(1) - # Ensure version is a string - if not isinstance(version, str): - version = str(version) - break - - # Extract object type from content type - obj_type = self._extract_object_type_from_content_type(content_type) - - return uuid, version, obj_type - - except Exception as e: - logging.debug(f"Fast extraction failed for {file_path}: {e}") - return None, None, "Unknown" - - def _extract_object_type_from_content_type(self, content_type: str) -> str: - """Extract object type from content type string.""" - try: - match = OptimizedRegex.CONTENT_TYPE.search(content_type) - if match: - return match.group("type") - except (AttributeError, KeyError): - pass - return "Unknown" - - def _is_core_properties(self, content_type: str) -> bool: - """Check if content type is CoreProperties.""" - return content_type == "application/vnd.openxmlformats-package.core-properties+xml" - - def _process_core_properties_metadata(self, override: Override) -> None: - """Process core properties metadata.""" - if override.part_name: - self._core_props_path = override.part_name.lstrip("/") - def get_metadata(self, identifier: str) -> Optional[EpcObjectMetadata]: """Get metadata for an object by identifier.""" return self._metadata.get(identifier) - def get_by_uuid(self, uuid: str) -> List[str]: - """Get all identifiers for objects with the given UUID.""" + def get_uuid_identifiers(self, uuid: str) -> List[str]: + """Get all identifiers for objects with the given UUID. + Note: Multiple objects can share the same UUID if there are multiple versions of the same object in the EPC file. + """ return self._uuid_index.get(uuid, []) - def get_by_type(self, object_type: str) -> List[str]: - """Get all identifiers for objects of the given type.""" - return self._type_index.get(object_type, []) + def get_by_qualified_type(self, qualified_type: str) -> List[str]: + """Get all identifiers for objects of the given qualified type.""" + return [m.identifier for m in self._metadata.values() if m.qualified_type == qualified_type] - def list_metadata(self, object_type: Optional[str] = None) -> List[EpcObjectMetadata]: + def list_metadata(self, qualified_type_filter: Optional[str] = None) -> List[EpcObjectMetadata]: """List metadata for all objects, optionally filtered by type.""" - if object_type is None: + if qualified_type_filter is None: return list(self._metadata.values()) - return [self._metadata[identifier] for identifier in self._type_index.get(object_type, [])] + return [ + self._metadata[identifier] + for identifier in self._metadata + if self._metadata[identifier].qualified_type == qualified_type_filter + ] def add_metadata(self, metadata: EpcObjectMetadata) -> None: """Add metadata for a new object.""" @@ -481,17 +485,15 @@ def add_metadata(self, metadata: EpcObjectMetadata) -> None: # Update UUID index if metadata.uuid not in self._uuid_index: self._uuid_index[metadata.uuid] = [] - self._uuid_index[metadata.uuid].append(identifier) - - # Update type index - if metadata.object_type not in self._type_index: - self._type_index[metadata.object_type] = [] - self._type_index[metadata.object_type].append(identifier) + if identifier not in self._uuid_index[metadata.uuid]: + self._uuid_index[metadata.uuid].append(identifier) self.stats.total_objects += 1 - def remove_metadata(self, identifier: str) -> Optional[EpcObjectMetadata]: + def remove_metadata(self, identifier: Union[str, EpcObjectMetadata]) -> Optional[EpcObjectMetadata]: """Remove metadata for an object. Returns the removed metadata.""" + if isinstance(identifier, EpcObjectMetadata): + identifier = identifier.identifier metadata = self._metadata.pop(identifier, None) if metadata: # Update UUID index @@ -500,12 +502,6 @@ def remove_metadata(self, identifier: str) -> Optional[EpcObjectMetadata]: if not self._uuid_index[metadata.uuid]: del self._uuid_index[metadata.uuid] - # Update type index - if metadata.object_type in self._type_index: - self._type_index[metadata.object_type].remove(identifier) - if not self._type_index[metadata.object_type]: - del self._type_index[metadata.object_type] - self.stats.total_objects -= 1 return metadata @@ -514,29 +510,16 @@ def contains(self, identifier: str) -> bool: """Check if an object with the given identifier exists.""" return identifier in self._metadata - def __len__(self) -> int: - """Return total number of objects.""" - return len(self._metadata) - - def __iter__(self) -> Iterator[str]: - """Iterate over object identifiers.""" - return iter(self._metadata.keys()) - def gen_rels_path_from_metadata(self, metadata: EpcObjectMetadata) -> str: """Generate rels path from object metadata without loading the object.""" - obj_path = metadata.file_path - # Extract folder and filename from the object path - if "/" in obj_path: - obj_folder = obj_path[: obj_path.rindex("/") + 1] - obj_file_name = obj_path[obj_path.rindex("/") + 1 :] - else: - obj_folder = "" - obj_file_name = obj_path - - return f"{obj_folder}_rels/{obj_file_name}.rels" + if not isinstance(metadata, EpcObjectMetadata): + raise ValueError("Metadata must be an instance of EpcObjectMetadata") + return metadata.rels_path(export_version=self._export_version) def gen_rels_path_from_identifier(self, identifier: str) -> Optional[str]: """Generate rels path from object identifier without loading the object.""" + if not isinstance(identifier, str): + raise ValueError("Identifier must be a string") metadata = self._metadata.get(identifier) if metadata is None: return None @@ -551,7 +534,8 @@ def get_core_properties(self) -> Optional[CoreProperties]: self.stats.bytes_read += len(core_data) self._core_props = read_energyml_xml_bytes(core_data, CoreProperties) except Exception as e: - logging.error(f"Failed to load core properties: {e}") + logging.error(f"Failed to load core properties, creating a default one: {e}") + self._core_props = create_default_core_properties() return self._core_props @@ -586,36 +570,162 @@ def detect_epc_version(self) -> EpcExportVersion: logging.warning(f"Failed to detect EPC version, defaulting to CLASSIC: {e}") return EpcExportVersion.CLASSIC - def update_content_types_xml( - self, source_zip: zipfile.ZipFile, metadata: EpcObjectMetadata, add: bool = True - ) -> str: - """Update [Content_Types].xml to add or remove object entry. + # def update_content_types_xml( + # self, source_zip: zipfile.ZipFile, metadata: EpcObjectMetadata, add: bool = True + # ) -> str: + # """Update [Content_Types].xml to add or remove object entry. + + # Args: + # source_zip: Open ZIP file to read from + # metadata: Object metadata + # add: If True, add entry; if False, remove entry + + # Returns: + # Updated [Content_Types].xml as string + # """ + # # Read existing content types + # content_types = self._read_content_types(source_zip) + + # if add: + # # Add new override entry + # new_override = Override() + # new_override.part_name = f"/{metadata.file_path}" + # new_override.content_type = metadata.content_type + # content_types.override.append(new_override) + # else: + # # Remove existing override entry + # content_types.override = [ + # o for o in content_types.override if o.part_name and o.part_name.lstrip("/") != metadata.file_path + # ] + + # # Serialize back to XML + # return serialize_xml(content_types) + + def get_content_type(self, zf: zipfile.ZipFile) -> Types: + + meta_dict_key_path = { + m.file_path(export_version=self._export_version): m.content_type for m in self._metadata.values() + } + other_files_in_epc = set() + for name in zf.namelist(): + if ( + name not in meta_dict_key_path + and not name.endswith("rels") + and not name == get_epc_content_type_path() + and not name == gen_core_props_path() + ): + other_files_in_epc.add(name) + + content_types = create_default_types() + + # creating overrides + for file_path, content_type in meta_dict_key_path.items(): + override = Override(content_type=content_type, part_name=f"/{file_path}") + content_types.override.append(override) + + # Add overrides for other files in EPC that are not in metadata (to preserve them) + for file_path in other_files_in_epc: + file_extension = os.path.splitext(file_path)[1].lstrip(".").lower() + mime_type = file_extension_to_mime_type(file_extension) + if mime_type: + override = Override(content_type=mime_type, part_name=f"/{file_path}") + content_types.override.append(override) + + return content_types + + # ____ ____ _____ _____ ____________ __ _________________ ______ ____ _____ + # / __ \/ __ \/ _/ | / / |/_ __/ ____/ / |/ / ____/_ __/ / / / __ \/ __ \/ ___/ + # / /_/ / /_/ // / | | / / /| | / / / __/ / /|_/ / __/ / / / /_/ / / / / / / /\__ \ + # / ____/ _, _// / | |/ / ___ |/ / / /___ / / / / /___ / / / __ / /_/ / /_/ /___/ / + # /_/ /_/ |_/___/ |___/_/ |_/_/ /_____/ /_/ /_/_____/ /_/ /_/ /_/\____/_____//____/ - Args: - source_zip: Open ZIP file to read from - metadata: Object metadata - add: If True, add entry; if False, remove entry + def _read_content_types(self, zf: zipfile.ZipFile) -> Types: + """Read and parse [Content_Types].xml file.""" + content_types_path = get_epc_content_type_path() - Returns: - Updated [Content_Types].xml as string - """ - # Read existing content types - content_types = self._read_content_types(source_zip) - - if add: - # Add new override entry - new_override = Override() - new_override.part_name = f"/{metadata.file_path}" - new_override.content_type = metadata.content_type - content_types.override.append(new_override) - else: - # Remove override entry - content_types.override = [ - override for override in content_types.override if override.part_name != f"/{metadata.file_path}" - ] + try: + content_data = zf.read(content_types_path) + self.stats.bytes_read += len(content_data) + return read_energyml_xml_bytes(content_data, Types) + except KeyError: + # Try case-insensitive search + for name in zf.namelist(): + if name.lower() == content_types_path.lower(): + content_data = zf.read(name) + self.stats.bytes_read += len(content_data) + return read_energyml_xml_bytes(content_data, Types) + raise FileNotFoundError(f"No {content_types_path} found in EPC file") + + def _process_energyml_object_metadata(self, zf: zipfile.ZipFile, override: Override) -> None: + """Process metadata for an EnergyML object without loading it.""" + if not override.part_name or not override.content_type: + return + + file_path = override.part_name.lstrip("/") + content_type = override.content_type + + try: + # First try to extract UUID and version from file path (works for EXPANDED mode) + uuid, version = extract_uuid_and_version_from_obj_path(file_path) + + # For CLASSIC mode, version is not in the path, so we need to extract it from XML content + if uuid and version is None: + try: + # Read first chunk of XML to extract version without full parsing + with zf.open(file_path) as f: + chunk = f.read(2048) # 2KB should be enough for root element + self.stats.bytes_read += len(chunk) + chunk_str = chunk.decode("utf-8", errors="ignore") + + # Extract version if present + version_patterns = [ + r'object[Vv]ersion["\']?\s*[:=]\s*["\']([^"\']+)', + ] + + for pattern in version_patterns: + version_match = re.search(pattern, chunk_str) + if version_match: + version = version_match.group(1) + if not isinstance(version, str): + version = str(version) + break + except Exception as e: + logging.debug(f"Failed to extract version from XML content for {file_path}: {e}") + + if uuid: # Only process if we successfully extracted UUID + uri = create_uri_from_content_type_or_qualified_type(ct_or_qt=content_type, uuid=uuid, version=version) + metadata = EpcObjectMetadata(uri=uri) + + # Store in indexes + identifier = metadata.identifier + if identifier: + self._metadata[identifier] = metadata + + # Update UUID index + if uuid not in self._uuid_index: + self._uuid_index[uuid] = [] + self._uuid_index[uuid].append(identifier) + + except Exception as e: + traceback.print_exc() + logging.warning(f"Failed to process metadata for {file_path}: {e}") + + def _is_core_properties(self, content_type: str) -> bool: + """Check if content type is CoreProperties.""" + return content_type == MimeType.CORE_PROPERTIES.value + + def _process_core_properties_metadata(self, override: Override) -> None: + """Process core properties metadata.""" + if override.part_name: + self._core_props_path = override.part_name.lstrip("/") + + def __len__(self) -> int: + """Return total number of objects.""" + return len(self._metadata) - # Serialize back to XML - return serialize_xml(content_types) + def __iter__(self) -> Iterator[str]: + """Iterate over object identifiers.""" + return iter(self._metadata.keys()) class _RelationshipManager: @@ -635,7 +745,6 @@ def __init__( zip_accessor: _ZipFileAccessor, metadata_manager: _MetadataManager, stats: EpcStreamingStats, - export_version: EpcExportVersion, rels_update_mode: RelsUpdateMode, ): """ @@ -645,19 +754,17 @@ def __init__( zip_accessor: ZIP file accessor for reading/writing metadata_manager: Metadata manager for object lookups stats: Statistics tracker - export_version: EPC export version rels_update_mode: Relationship update mode """ self.zip_accessor = zip_accessor self.metadata_manager = metadata_manager self.stats = stats - self.export_version = export_version self.rels_update_mode = rels_update_mode # Additional rels management (for user-added relationships) self.additional_rels: Dict[str, List[Relationship]] = {} - def get_obj_rels(self, obj_identifier: str, rels_path: Optional[str] = None) -> List[Relationship]: + def get_obj_rels(self, obj_identifier: Optional[str] = None, rels_path: Optional[str] = None) -> List[Relationship]: """ Get all relationships for a given object. Merges relationships from the EPC file with in-memory additional relationships. @@ -665,7 +772,7 @@ def get_obj_rels(self, obj_identifier: str, rels_path: Optional[str] = None) -> rels = [] # Read rels from EPC file - if rels_path is None: + if rels_path is None and obj_identifier is not None: rels_path = self.metadata_manager.gen_rels_path_from_identifier(obj_identifier) if rels_path is not None: @@ -680,8 +787,8 @@ def get_obj_rels(self, obj_identifier: str, rels_path: Optional[str] = None) -> pass # Merge with in-memory additional relationships - if obj_identifier in self.additional_rels: - rels.extend(self.additional_rels[obj_identifier]) + if obj_identifier is not None and obj_identifier in self.additional_rels: + rels = self.merge_rels(rels, self.additional_rels[obj_identifier]) return rels @@ -693,308 +800,157 @@ def update_rels_for_new_object(self, obj: Any, obj_identifier: str) -> None: return # Get all objects this new object references - direct_dors = get_direct_dor_list(obj) + dest_target_uris = get_dor_uris_from_obj(obj) + # logging.debug(f"Updating relationships for new object {obj_identifier}, found DOR targets: {dest_target_uris}") - # Build SOURCE relationships for this object - source_relationships = [] - dest_updates: Dict[str, Relationship] = {} + obj_file_path = metadata.file_path(export_version=self.metadata_manager._export_version) - for dor in direct_dors: - try: - target_identifier = get_obj_identifier(dor) - if not self.metadata_manager.contains(target_identifier): - continue - - target_metadata = self.metadata_manager.get_metadata(target_identifier) - if not target_metadata: - continue + dest_rels = [] + source_relationships = {} + for target_uri in dest_target_uris: + target_path = gen_energyml_object_path(target_uri, export_version=self.metadata_manager._export_version) - # Create SOURCE relationship - source_rel = Relationship( - target=target_metadata.file_path, - type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), - id=f"_{obj_identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", - ) - source_relationships.append(source_rel) - - # Create DESTINATION relationship - dest_rel = Relationship( - target=metadata.file_path, - type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), - id=f"_{target_identifier}_{get_obj_type(get_obj_usable_class(obj))}_{obj_identifier}", - ) - dest_updates[target_identifier] = dest_rel + dest_rel = Relationship( + target=target_path, + type_value=str(EPCRelsRelationshipType.DESTINATION_OBJECT), + id=f"_{gen_uuid()}", + ) + dest_rels.append(dest_rel) - except Exception as e: - logging.warning(f"Failed to create relationship for DOR: {e}") + source_relationships[target_path] = Relationship( + target=obj_file_path, + type_value=str(EPCRelsRelationshipType.SOURCE_OBJECT), + id=f"_{gen_uuid()}", + ) # Write updates - self.write_rels_updates(obj_identifier, source_relationships, dest_updates) + self._write_rels_updates( + current_object_id=obj_identifier, + current_rels_additions=dest_rels, + target_path_rels_additions=source_relationships, + ) - def update_rels_for_modified_object(self, obj: Any, obj_identifier: str, old_dors: List[Any]) -> None: + def update_rels_for_modified_object(self, obj: Any, obj_identifier: str) -> None: """Update relationships when an object is modified (UPDATE_AT_MODIFICATION mode).""" metadata = self.metadata_manager.get_metadata(obj_identifier) if not metadata: logging.warning(f"Metadata not found for {obj_identifier}") return - # Get new DORs - new_dors = get_direct_dor_list(obj) + obj_path = metadata.file_path(export_version=self.metadata_manager._export_version) - # Convert to sets of identifiers for comparison - old_dor_ids = { - get_obj_identifier(dor) for dor in old_dors if self.metadata_manager.contains(get_obj_identifier(dor)) + previous_dest_rels_target_path = { + r.target + for r in self.get_obj_rels(obj_identifier) + if r.type_value == str(EPCRelsRelationshipType.DESTINATION_OBJECT) and r.target is not None } - new_dor_ids = { - get_obj_identifier(dor) for dor in new_dors if self.metadata_manager.contains(get_obj_identifier(dor)) - } - - # Find added and removed references - added_dor_ids = new_dor_ids - old_dor_ids - removed_dor_ids = old_dor_ids - new_dor_ids + # Latest DORs from the modified object + dest_target_uris = get_dor_uris_from_obj(obj) + # logging.debug(f"Updating relationships for new object {obj_identifier}, found DOR targets: {dest_target_uris}") # Build new SOURCE relationships - source_relationships = [] - dest_updates: Dict[str, Relationship] = {} + current_rels_additions: List[Relationship] = [] + reversed_source_relationships: Dict[str, Relationship] = {} # Create relationships for all new DORs - for dor in new_dors: - target_identifier = get_obj_identifier(dor) - if not self.metadata_manager.contains(target_identifier): - continue - - target_metadata = self.metadata_manager.get_metadata(target_identifier) - if not target_metadata: - continue - - # SOURCE relationship - source_rel = Relationship( - target=target_metadata.file_path, - type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), - id=f"_{obj_identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", + for target_uri in dest_target_uris: + target_path = gen_energyml_object_path(target_uri, export_version=self.metadata_manager._export_version) + + # DESTINATION relationship : current is referenced by + dest_rel = Relationship( + target=target_path, + type_value=str(EPCRelsRelationshipType.DESTINATION_OBJECT), + id=f"_{gen_uuid()}", ) - source_relationships.append(source_rel) - - # DESTINATION relationship (for added DORs only) - if target_identifier in added_dor_ids: - dest_rel = Relationship( - target=metadata.file_path, - type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), - id=f"_{target_identifier}_{get_obj_type(get_obj_usable_class(obj))}_{obj_identifier}", + current_rels_additions.append(dest_rel) + + if target_path not in previous_dest_rels_target_path: + # REVERSED SOURCE relationship : target references current, if not already existing (to avoid duplicates if DORs are not changed for this target) + source_rel = Relationship( + target=obj_path, + type_value=str(EPCRelsRelationshipType.SOURCE_OBJECT), + id=f"_{gen_uuid()}", ) - dest_updates[target_identifier] = dest_rel + reversed_source_relationships[target_path] = source_rel - # For removed DORs, remove DESTINATION relationships - removals: Dict[str, str] = {} - for removed_id in removed_dor_ids: - removals[removed_id] = f"_{removed_id}_.*_{obj_identifier}" + # list previous dest that does not exist anymore in the modified object, to remove the corresponding reversed source relationship on target side + outdated_dors_targets_paths = previous_dest_rels_target_path - reversed_source_relationships.keys() # Write updates - self.write_rels_updates(obj_identifier, source_relationships, dest_updates, removals) + self._write_rels_updates( + current_object_id=obj_identifier, + current_rels_additions=list(current_rels_additions), + target_path_rels_additions=reversed_source_relationships, + target_path_rels_removals=outdated_dors_targets_paths, + ) - def update_rels_for_removed_object(self, obj_identifier: str, obj: Optional[Any] = None) -> None: + def update_rels_for_removed_object(self, obj_identifier: str) -> None: """Update relationships when an object is removed (UPDATE_AT_MODIFICATION mode).""" - if obj is None: - # Object must be provided for removal - logging.warning(f"Cannot update rels for removed object {obj_identifier}: object not provided") - return - - # Get all objects this object references - direct_dors = get_direct_dor_list(obj) - - # Build removal patterns for DESTINATION relationships - removals: Dict[str, str] = {} - for dor in direct_dors: - try: - target_identifier = get_obj_identifier(dor) - if not self.metadata_manager.contains(target_identifier): - continue + current_rels = self.get_obj_rels(obj_identifier) # Ensure we have the latest relationships loaded - removals[target_identifier] = f"_{target_identifier}_.*_{obj_identifier}" - - except Exception as e: - logging.warning(f"Failed to process DOR for removal: {e}") + dest_rels = [r for r in current_rels if r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type()] # Write updates - self.write_rels_updates(obj_identifier, [], {}, removals, delete_source_rels=True) + self._write_rels_updates( + current_object_id=obj_identifier, + target_path_rels_removals=[ + r.target + for r in current_rels + if r.target is not None and r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type() + ], + delete_current_obj_rels_file_and_file=len(dest_rels) + == len( + current_rels + ), # If all relationships are DESTINATION_OBJECT, we can delete the .rels file entirely. If some source rels exists, we keep it to ease potential add of this element later, to avoid parsing all reals to find its sources rels from other object DEST rels + ) - def write_rels_updates( - self, - source_identifier: str, - source_relationships: List[Relationship], - dest_updates: Dict[str, Relationship], - removals: Optional[Dict[str, str]] = None, - delete_source_rels: bool = False, - ) -> None: - """Write relationship updates to the EPC file efficiently.""" - import re - - removals = removals or {} - rels_updates: Dict[str, str] = {} - files_to_delete: List[str] = [] - - with self.zip_accessor.get_zip_file() as zf: - # 1. Handle source object's rels file - if not delete_source_rels: - source_rels_path = self.metadata_manager.gen_rels_path_from_identifier(source_identifier) - if source_rels_path: - # Read existing rels (excluding SOURCE_OBJECT type) - existing_rels = [] - try: - if source_rels_path in zf.namelist(): - rels_data = zf.read(source_rels_path) - existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels_obj and existing_rels_obj.relationship: - # Keep only non-SOURCE relationships - existing_rels = [ - r - for r in existing_rels_obj.relationship - if r.type_value != EPCRelsRelationshipType.SOURCE_OBJECT.get_type() - ] - except Exception: - pass + # def compute_object_rels(self, obj: Any, obj_identifier: str) -> List[Relationship]: + # """ + # Compute relationships for a given object (SOURCE relationships). + # This object references other objects through DORs. + + # Args: + # obj: The EnergyML object + # obj_identifier: The identifier of the object + + # Returns: + # List of Relationship objects for this object's .rels file + # """ + # rels = [] + + # # Get all DORs (Data Object References) in this object + # direct_dors = get_direct_dor_list(obj) + + # for dor in direct_dors: + # try: + # target_identifier = get_obj_identifier(dor) + + # # Get target file path from metadata without processing DOR + # # The relationship target should be the object's file path, not its rels path + # if self.metadata_manager.contains(target_identifier): + # target_metadata = self.metadata_manager.get_metadata(target_identifier) + # if target_metadata: + # target_path = target_metadata.file_path + # else: + # target_path = gen_energyml_object_path(dor, self._metadata_mgr._export_version) + # else: + # # Fall back to generating path from DOR if metadata not found + # target_path = gen_energyml_object_path(dor, self._metadata_mgr._export_version) + + # # Create SOURCE relationship (this object -> target object) + # rel = Relationship( + # target=target_path, + # type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + # id=f"_{obj_identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", + # ) + # rels.append(rel) + # except Exception as e: + # logging.warning(f"Failed to create relationship for DOR in {obj_identifier}: {e}") + + # return rels - # Combine with new SOURCE relationships - all_rels = existing_rels + source_relationships - if all_rels: - rels_updates[source_rels_path] = serialize_xml(Relationships(relationship=all_rels)) - elif source_rels_path in zf.namelist() and not all_rels: - files_to_delete.append(source_rels_path) - else: - # Mark source rels file for deletion - source_rels_path = self.metadata_manager.gen_rels_path_from_identifier(source_identifier) - if source_rels_path: - files_to_delete.append(source_rels_path) - - # 2. Handle destination updates - for target_identifier, dest_rel in dest_updates.items(): - target_rels_path = self.metadata_manager.gen_rels_path_from_identifier(target_identifier) - if not target_rels_path: - continue - - # Read existing rels - existing_rels = [] - try: - if target_rels_path in zf.namelist(): - rels_data = zf.read(target_rels_path) - existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels_obj and existing_rels_obj.relationship: - existing_rels = list(existing_rels_obj.relationship) - except Exception: - pass - - # Add new DESTINATION relationship if not already present - rel_exists = any( - r.target == dest_rel.target and r.type_value == dest_rel.type_value for r in existing_rels - ) - - if not rel_exists: - existing_rels.append(dest_rel) - rels_updates[target_rels_path] = serialize_xml(Relationships(relationship=existing_rels)) - - # 3. Handle removals - for target_identifier, pattern in removals.items(): - target_rels_path = self.metadata_manager.gen_rels_path_from_identifier(target_identifier) - if not target_rels_path: - continue - - # Read existing rels - existing_rels = [] - try: - if target_rels_path in zf.namelist(): - rels_data = zf.read(target_rels_path) - existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels_obj and existing_rels_obj.relationship: - existing_rels = list(existing_rels_obj.relationship) - except Exception: - pass - - # Filter out relationships matching the pattern - regex = re.compile(pattern) - filtered_rels = [r for r in existing_rels if not (r.id and regex.match(r.id))] - - if len(filtered_rels) != len(existing_rels): - if filtered_rels: - rels_updates[target_rels_path] = serialize_xml(Relationships(relationship=filtered_rels)) - else: - files_to_delete.append(target_rels_path) - - # Write updates to EPC file - if rels_updates or files_to_delete: - with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: - temp_path = temp_file.name - - try: - with self.zip_accessor.get_zip_file() as source_zf: - with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: - # Copy all files except those to delete or update - files_to_skip = set(files_to_delete) - for item in source_zf.infolist(): - if item.filename not in files_to_skip and item.filename not in rels_updates: - data = source_zf.read(item.filename) - target_zf.writestr(item, data) - - # Write updated rels files - for rels_path, rels_xml in rels_updates.items(): - target_zf.writestr(rels_path, rels_xml) - - # Replace original - shutil.move(temp_path, self.zip_accessor.epc_file_path) - self.zip_accessor.reopen_persistent_zip() - - except Exception as e: - if os.path.exists(temp_path): - os.unlink(temp_path) - logging.error(f"Failed to write rels updates: {e}") - raise - - def compute_object_rels(self, obj: Any, obj_identifier: str) -> List[Relationship]: - """ - Compute relationships for a given object (SOURCE relationships). - This object references other objects through DORs. - - Args: - obj: The EnergyML object - obj_identifier: The identifier of the object - - Returns: - List of Relationship objects for this object's .rels file - """ - rels = [] - - # Get all DORs (Data Object References) in this object - direct_dors = get_direct_dor_list(obj) - - for dor in direct_dors: - try: - target_identifier = get_obj_identifier(dor) - - # Get target file path from metadata without processing DOR - # The relationship target should be the object's file path, not its rels path - if self.metadata_manager.contains(target_identifier): - target_metadata = self.metadata_manager.get_metadata(target_identifier) - if target_metadata: - target_path = target_metadata.file_path - else: - target_path = gen_energyml_object_path(dor, self.export_version) - else: - # Fall back to generating path from DOR if metadata not found - target_path = gen_energyml_object_path(dor, self.export_version) - - # Create SOURCE relationship (this object -> target object) - rel = Relationship( - target=target_path, - type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), - id=f"_{obj_identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", - ) - rels.append(rel) - except Exception as e: - logging.warning(f"Failed to create relationship for DOR in {obj_identifier}: {e}") - - return rels - - def merge_rels(self, new_rels: List[Relationship], existing_rels: List[Relationship]) -> List[Relationship]: - """Merge new relationships with existing ones, avoiding duplicates and ensuring unique IDs. + def merge_rels(self, new_rels: List[Relationship], existing_rels: List[Relationship]) -> List[Relationship]: + """Merge new relationships with existing ones, avoiding duplicates and ensuring unique IDs. Args: new_rels: New relationships to add @@ -1023,6 +979,176 @@ def merge_rels(self, new_rels: List[Relationship], existing_rels: List[Relations return merged + def _write_rels_updates( + self, + current_object_id: str, + current_rels_additions: Optional[List[Relationship]] = None, + current_rels_removals: Optional[Union[List[str], Set[str]]] = None, + target_path_rels_additions: Optional[Dict[str, Relationship]] = None, + target_path_rels_removals: Optional[Union[List[str], Set[str]]] = None, + delete_current_obj_rels_file_and_file: bool = False, + ) -> None: + """Write relationship updates to the EPC file efficiently. + + Args: + current_object_id: Identifier of the object being modified/added/removed + current_rels_additions: List of Relationship objects to add to the current object's .rels file + current_rels_removals: List or set of relationship ID patterns to remove from the current object's .rels file + target_path_rels_additions: Dict mapping target object file paths (not the .rels path) to Relationship objects to add to their .rels files (for SOURCE relationships) + target_path_rels_removals: List or set of relationship ID patterns to remove from target objects' .rels files (for SOURCE relationships) + delete_current_obj_rels_file_and_file: If True, deletes the current object's .rels file entirely (if contains only DEST relations) and the object file iteself + + + """ + # Implementation of this method would involve: + # - Reading existing .rels files for current and target objects + # - Merging additions and removals while preserving EXTERNAL_RESOURCE relationships + # - Writing back updated .rels files to the ZIP (either by modifying in place or rebuilding) + # - Handling different update modes (immediate vs on close) + + # 1st : debug log the inputs + # logging.debug( + # f"Writing rels updates for current_object_id={current_object_id}, current_rels_additions={current_rels_additions}, current_rels_removals={current_rels_removals}, target_path_rels_additions={target_path_rels_additions}, target_path_rels_removals={target_path_rels_removals}, delete_current_obj_rels_file_and_file={delete_current_obj_rels_file_and_file}\n\n" + # ) + + current_obj_meta = self.metadata_manager.get_metadata(current_object_id) + if not current_obj_meta: + logging.warning(f"Metadata not found for {current_object_id}, cannot write rels updates") + return + current_object_path = current_obj_meta.file_path(export_version=self.metadata_manager._export_version) + current_rels_path = self.metadata_manager.gen_rels_path_from_metadata(current_obj_meta) + + current_obj_actual_rels = self.get_obj_rels(current_object_id, rels_path=current_rels_path) + + current_updated_rels = ( + self.merge_rels(current_rels_additions, current_obj_actual_rels) + if current_rels_additions + else current_obj_actual_rels + ) + if current_rels_removals: + for removal_obj_id in current_rels_removals: + target_metadata = self.metadata_manager.get_metadata(removal_obj_id) + target_path = ( + target_metadata.file_path(export_version=self.metadata_manager._export_version) + if target_metadata + else None + ) + if target_path: + current_updated_rels = [ + r for r in current_updated_rels if r.target is not None and (target_path not in r.target) + ] + + # Now handle target objects' .rels updates + targets_new_rels_to_path: Dict[str, List[Relationship]] = {} + # First, get existing rels for all target objects + if target_path_rels_additions or target_path_rels_removals: + target_ids = set() + if target_path_rels_additions: + target_ids.update(target_path_rels_additions.keys()) + if target_path_rels_removals: + target_ids.update(target_path_rels_removals) + + for target_id in target_ids: + # we authorize to pass a rels path directly as target_id in target_rels_additions for more flexibility, but if it's not the case we try to find target metadata and generate rels path from it + target_rels_path = None + if target_id.endswith(".xml"): + target_rels_path = gen_rels_path_from_obj_path(target_id) + elif target_id.endswith(".rels"): + target_rels_path = target_id + else: + target_meta = self.metadata_manager.get_metadata(target_id) + if not target_meta: + logging.warning( + f"Metadata not found for target {target_id}, skipping rels updates for this target" + ) + continue + target_rels_path = self.metadata_manager.gen_rels_path_from_metadata(target_meta) + existing_target_rels = self.get_obj_rels(rels_path=target_rels_path) + + # Merge additions and removals for this target + updated_target_rels = existing_target_rels + if target_path_rels_additions and target_id in target_path_rels_additions: + updated_target_rels = self.merge_rels([target_path_rels_additions[target_id]], updated_target_rels) + if target_path_rels_removals and target_id in target_path_rels_removals: + # TODO: maybe we should be able to support non energyml objects and take target path to remove in a tuple in target_rels_removals instead of target_id only ? + updated_target_rels = [r for r in updated_target_rels if r.target != current_object_path] + + targets_new_rels_to_path[target_rels_path] = updated_target_rels + + files_to_delete = [] + if delete_current_obj_rels_file_and_file: + files_to_delete.append(current_object_path) + if ( + len( + [r for r in current_updated_rels if r.type_value != str(EPCRelsRelationshipType.DESTINATION_OBJECT)] + ) + == 0 + ): + # if current object must be removed and its rels file had only dest relationship. We can delete the rels file as well. + files_to_delete.append(current_rels_path) + + rels_updates = {} + if current_rels_additions is not None or current_rels_removals is not None: + rels_updates = {current_rels_path: serialize_xml(Relationships(relationship=current_updated_rels))} + for target_rels_path, updated_rels in targets_new_rels_to_path.items(): + rels_updates[target_rels_path] = serialize_xml(Relationships(relationship=updated_rels)) + + files_to_skip = set(files_to_delete).union(set(rels_updates.keys())) + + # logging.debug( + # f"====\nFiles to delete: {files_to_delete}, rels updates to write: {list(rels_updates.keys())}, files to skip in copy: {files_to_skip}\n\n" + # ) + + # Write in tmp file and then replace original to minimize I/O and handle multiple updates in one operation + with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: + temp_path = temp_file.name + try: + with self.zip_accessor.get_zip_file() as source_zf: + with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: + # Copy all files except those to delete or update + ct_xml = None + + for item in source_zf.infolist(): + if get_epc_content_type_path() in item.filename: + ct_xml = source_zf.read(item.filename) + elif item.filename not in files_to_skip and item.filename not in rels_updates: + data = source_zf.read(item.filename) + target_zf.writestr(item, data) + + # Write updated rels files + for rels_path, rels_xml in rels_updates.items(): + target_zf.writestr(rels_path, rels_xml) + # logging.debug(f"Wrote updated rels file: {rels_path} -> {rels_xml}") + + if delete_current_obj_rels_file_and_file: + ct_object: Optional[Types] = None + if ct_xml is not None: + # remove the object entry from [Content_Types].xml if the object file is deleted + ct_object = read_energyml_xml_bytes(ct_xml, Types) + if ct_object is not None: + ct_object.override = [ + o for o in ct_object.override if current_object_path not in (o.part_name or "") + ] + + if ct_object is None: + ct_object = self.metadata_manager.get_content_type(target_zf) + ct_xml = serialize_xml(ct_object) + + if ct_xml is None: + ct_xml = serialize_xml(self.metadata_manager.get_content_type(target_zf)) + + target_zf.writestr(get_epc_content_type_path(), ct_xml) + + # Replace original + shutil.move(temp_path, self.zip_accessor.epc_file_path) + self.zip_accessor.reopen_persistent_zip() + + except Exception as e: + if os.path.exists(temp_path): + os.unlink(temp_path) + logging.error(f"Failed to write rels updates: {e}") + raise + # =========================================================================================== # MAIN CLASS (REFACTORED TO USE HELPER CLASSES) @@ -1030,79 +1156,31 @@ def merge_rels(self, new_rels: List[Relationship], existing_rels: List[Relations class EpcStreamReader(EnergymlStorageInterface): - """ - Memory-efficient EPC file reader with lazy loading and smart caching. - - This class provides the same interface as the standard Epc class but loads - objects on-demand rather than keeping everything in memory. Perfect for - handling very large EPC files with thousands of objects. - - Features: - - Lazy loading: Objects loaded only when accessed - - Smart caching: LRU cache with configurable size - - Memory monitoring: Track memory usage and cache efficiency - - Streaming validation: Validate objects without full loading - - Batch operations: Efficient bulk operations - - Context management: Automatic resource cleanup - - Flexible relationship management: Three modes for updating object relationships - - Relationship Update Modes: - - UPDATE_AT_MODIFICATION: Maintains relationships in real-time as objects are added/removed/modified. - Best for maintaining consistency but may be slower for bulk operations. - - UPDATE_ON_CLOSE: Rebuilds all relationships when closing the EPC file (default). - More efficient for bulk operations but relationships only consistent after closing. - - MANUAL: No automatic relationship updates. User must manually call rebuild_all_rels(). - Maximum control and performance for advanced use cases. - - Performance optimizations: - - Pre-compiled regex patterns for 15-75% faster parsing - - Weak references to prevent memory leaks - - Compressed metadata storage - - Efficient ZIP file handling - """ def __init__( self, epc_file_path: Union[str, Path], - cache_size: int = 100, - validate_on_load: bool = True, - preload_metadata: bool = True, - export_version: EpcExportVersion = EpcExportVersion.CLASSIC, - force_h5_path: Optional[str] = None, - keep_open: bool = False, - force_title_load: bool = False, rels_update_mode: RelsUpdateMode = RelsUpdateMode.UPDATE_ON_CLOSE, + force_h5_path: Optional[str] = None, + export_version: EpcExportVersion = EpcExportVersion.CLASSIC, enable_parallel_rels: bool = False, parallel_worker_ratio: int = 10, + cache_size: int = 100, + # preload_metadata: bool = True, + keep_open: bool = True, + force_title_load: bool = False, ): - """ - Initialize the EPC stream reader. - - Args: - epc_file_path: Path to the EPC file - cache_size: Maximum number of objects to keep in memory cache - validate_on_load: Whether to validate objects when loading - preload_metadata: Whether to preload all object metadata - export_version: EPC packaging version (CLASSIC or EXPANDED) - force_h5_path: Optional forced HDF5 file path for external resources. If set, all arrays will be read/written from/to this path. - keep_open: If True, keeps the ZIP file open for better performance with multiple operations. File is closed only when instance is deleted or close() is called. - force_title_load: If True, forces loading object titles when listing objects (may impact performance) - rels_update_mode: Mode for updating relationships (UPDATE_AT_MODIFICATION, UPDATE_ON_CLOSE, or MANUAL) - enable_parallel_rels: If True, uses parallel processing for rebuild_all_rels() operations (faster for large EPCs) - parallel_worker_ratio: Number of objects per worker process (default: 10). Lower values = more workers. Only used when enable_parallel_rels=True. - """ # Public attributes self.epc_file_path = Path(epc_file_path) self.enable_parallel_rels = enable_parallel_rels self.parallel_worker_ratio = parallel_worker_ratio self.cache_size = cache_size - self.validate_on_load = validate_on_load self.force_h5_path = force_h5_path self.cache_opened_h5 = None self.keep_open = keep_open self.force_title_load = force_title_load - self.rels_update_mode = rels_update_mode - self.export_version: EpcExportVersion = export_version or EpcExportVersion.CLASSIC + # Note: rels_update_mode will be set on _rels_mgr when it's created below + # self.export_version: EpcExportVersion = export_version or EpcExportVersion.CLASSIC self.stats = EpcStreamingStats() # Caching system using weak references @@ -1110,606 +1188,179 @@ def __init__( self._access_order: List[str] = [] # LRU tracking is_new_file = False - + # ===================================== # Validate file exists and is readable + # ===================================== if not self.epc_file_path.exists(): - logging.info(f"EPC file not found: {epc_file_path}. Creating a new empty EPC file.") - self._create_empty_epc() + logging.info(f"EPC file not found: {self.epc_file_path}. Creating a new empty EPC file.") + create_mandatory_structure_epc(self.epc_file_path) is_new_file = True if not zipfile.is_zipfile(self.epc_file_path): - raise ValueError(f"File is not a valid ZIP/EPC file: {epc_file_path}") - - # Check if the ZIP file has the required EPC structure - if not is_new_file: - try: - with zipfile.ZipFile(self.epc_file_path, "r") as zf: - content_types_path = get_epc_content_type_path() - if content_types_path not in zf.namelist(): - logging.info("EPC file is missing required structure. Initializing empty EPC file.") - self._create_empty_epc() - is_new_file = True - except Exception as e: - logging.warning(f"Failed to check EPC structure: {e}. Reinitializing.") + raise ValueError(f"File is not a valid ZIP/EPC file: {self.epc_file_path}") - # Initialize helper classes (internal architecture) - self._zip_accessor = _ZipFileAccessor(self.epc_file_path, keep_open=keep_open) - self._metadata_mgr = _MetadataManager(self._zip_accessor, self.stats) - self._rels_mgr = _RelationshipManager( - self._zip_accessor, self._metadata_mgr, self.stats, self.export_version, rels_update_mode - ) + # validate mandatory files and structure, and auto-repair if enabled - # Initialize by loading metadata - if not is_new_file and preload_metadata: - self._metadata_mgr.load_metadata() - # Detect EPC version after loading metadata - self.export_version = self._metadata_mgr.detect_epc_version() - # Update relationship manager's export version - self._rels_mgr.export_version = self.export_version + repair_epc_structure_if_not_valid(self.epc_file_path) - # Open persistent ZIP connection if keep_open is enabled + self._zip_accessor = _ZipFileAccessor(self.epc_file_path, keep_open=keep_open) if keep_open and not is_new_file: self._zip_accessor.open_persistent_connection() - # Backward compatibility: expose internal structures as properties - # This allows existing code to access _metadata, _uuid_index, etc. - self._metadata = self._metadata_mgr._metadata - self._uuid_index = self._metadata_mgr._uuid_index - self._type_index = self._metadata_mgr._type_index - self.additional_rels = self._rels_mgr.additional_rels - - def _create_empty_epc(self) -> None: - """Create an empty EPC file structure.""" - # Ensure directory exists - self.epc_file_path.parent.mkdir(parents=True, exist_ok=True) - - with zipfile.ZipFile(self.epc_file_path, "w") as zf: - # Create [Content_Types].xml - content_types = Types() - content_types_xml = serialize_xml(content_types) - zf.writestr(get_epc_content_type_path(), content_types_xml) - - # Create _rels/.rels - rels = Relationships() - rels_xml = serialize_xml(rels) - zf.writestr("_rels/.rels", rels_xml) - - def _load_metadata(self) -> None: - """Load object metadata from [Content_Types].xml without loading actual objects.""" - # Delegate to metadata manager - self._metadata_mgr.load_metadata() + # ===================================== - def _read_content_types(self, zf: zipfile.ZipFile) -> Types: - """Read and parse [Content_Types].xml file.""" - # Delegate to metadata manager - return self._metadata_mgr._read_content_types(zf) + self._metadata_mgr = _MetadataManager(self._zip_accessor, self.stats) + self._metadata_mgr.load_metadata() # Load metadata at initialization (can be optimized to lazy load if needed) => export version may be auto-detected + if is_new_file: + self._metadata_mgr.set_export_version(export_version) + self._rels_mgr = _RelationshipManager(self._zip_accessor, self._metadata_mgr, self.stats, rels_update_mode) - def _process_energyml_object_metadata(self, zf: zipfile.ZipFile, override: Override) -> None: - """Process metadata for an EnergyML object without loading it.""" - # Delegate to metadata manager - self._metadata_mgr._process_energyml_object_metadata(zf, override) + # Register atexit handler to ensure cleanup on program shutdown + self._atexit_registered = True + atexit.register(self._atexit_close) - def _extract_object_info_fast( - self, zf: zipfile.ZipFile, file_path: str, content_type: str - ) -> Tuple[Optional[str], Optional[str], str]: - """Fast extraction of UUID and version from XML without full parsing.""" - # Delegate to metadata manager - return self._metadata_mgr._extract_object_info_fast(zf, file_path, content_type) + # ================================ + # Properties + # ================================ - def _extract_object_type_from_content_type(self, content_type: str) -> str: - """Extract object type from content type string.""" - # Delegate to metadata manager - return self._metadata_mgr._extract_object_type_from_content_type(content_type) + @property + def _metadata(self) -> Dict[str, EpcObjectMetadata]: + """Backward compatibility property for accessing metadata.""" + return self._metadata_mgr._metadata - def _is_core_properties(self, content_type: str) -> bool: - """Check if content type is CoreProperties.""" - # Delegate to metadata manager - return self._metadata_mgr._is_core_properties(content_type) + @property + def export_version(self) -> EpcExportVersion: + """Get the detected or set export version.""" + return self._metadata_mgr._export_version - def _process_core_properties_metadata(self, override: Override) -> None: - """Process core properties metadata.""" - # Delegate to metadata manager - self._metadata_mgr._process_core_properties_metadata(override) + @property + def rels_update_mode(self) -> RelsUpdateMode: + """Get the relationship update mode.""" + return self._rels_mgr.rels_update_mode - def _detect_epc_version(self) -> EpcExportVersion: - """Detect EPC packaging version based on file structure.""" - # Delegate to metadata manager - return self._metadata_mgr.detect_epc_version() + @rels_update_mode.setter + def rels_update_mode(self, mode: RelsUpdateMode) -> None: + """Set the relationship update mode.""" + if not isinstance(mode, RelsUpdateMode): + raise ValueError(f"Invalid rels_update_mode: {mode}. Must be an instance of RelsUpdateMode Enum.") + self._rels_mgr.rels_update_mode = mode - def _gen_rels_path_from_metadata(self, metadata: EpcObjectMetadata) -> str: - """Generate rels path from object metadata without loading the object.""" - # Delegate to metadata manager - return self._metadata_mgr.gen_rels_path_from_metadata(metadata) + # ================================ + # Public API Methods + # ================================ + + def add_object(self, obj: Any, replace_if_exists: bool = True) -> Optional[str]: + """Add an object to the EPC file. Returns the identifier of the added object.""" + # 1. Test if object already exists (by UUID) and handle according to replace_if_exists + # 2. Call put_object to write the object data and metadata to the EPC file + # 3. Update relationships if needed (depending on rels_update_mode) + # 4. Return the identifier of the added object + if not replace_if_exists: + obj_uri: Uri = get_obj_uri(obj=obj, dataspace=None) + if obj_uri is None: + logging.error("Failed to get URI for the object, cannot add to EPC") + return None + obj_identifier = obj_uri.as_identifier() + if self._metadata_mgr.get_metadata(obj_identifier) is not None: + logging.warning( + f"Object with identifier {obj_identifier} already exists and replace_if_exists is False, skipping add" + ) + raise ValueError( + f"Object with identifier {obj_identifier} already exists and replace_if_exists is False" + ) - def _gen_rels_path_from_identifier(self, identifier: str) -> Optional[str]: - """Generate rels path from object identifier without loading the object.""" - # Delegate to metadata manager - return self._metadata_mgr.gen_rels_path_from_identifier(identifier) + return self.put_object(obj=obj) - @contextmanager - def _get_zip_file(self) -> Iterator[zipfile.ZipFile]: - """Context manager for ZIP file access with proper resource management. + def clear_cache(self) -> None: + """Clear the object cache to free memory.""" + self._object_cache.clear() + self._access_order.clear() + self.stats.loaded_objects = 0 - If keep_open is True, uses the persistent connection. Otherwise opens a new one. + def rebuild_all_rels(self, clean_first: bool = True) -> Dict[str, int]: """ - # Delegate to the ZIP accessor helper class - with self._zip_accessor.get_zip_file() as zf: - yield zf + Rebuild all .rels files from scratch by analyzing all objects and their references. - def get_object_by_identifier(self, identifier: Union[str, Uri]) -> Optional[Any]: - """ - Get object by its identifier with smart caching. + This method: + 1. Optionally cleans existing .rels files first + 2. Loads each object temporarily + 3. Analyzes its Data Object References (DORs) + 4. Creates/updates .rels files with proper SOURCE and DESTINATION relationships Args: - identifier: Object identifier (uuid.version) + clean_first: If True, remove all existing .rels files before rebuilding Returns: - The requested object or None if not found + Dictionary with statistics: + - 'objects_processed': Number of objects analyzed + - 'rels_files_created': Number of .rels files created + - 'source_relationships': Number of SOURCE relationships created + - 'destination_relationships': Number of DESTINATION relationships created + - 'parallel_mode': True if parallel processing was used (optional key) + - 'execution_time': Execution time in seconds (optional key) """ - is_uri = isinstance(identifier, Uri) or parse_uri(identifier) is not None - if is_uri: - uri = parse_uri(identifier) if isinstance(identifier, str) else identifier - assert uri is not None and uri.uuid is not None - identifier = uri.uuid + "." + (uri.version or "") - - # Check cache first - if identifier in self._object_cache: - self._update_access_order(identifier) # type: ignore - self.stats.cache_hits += 1 - return self._object_cache[identifier] - - self.stats.cache_misses += 1 - - # Check if metadata exists - if identifier not in self._metadata: - return None - - # Load object from file - obj = self._load_object(identifier) - - if obj is not None: - # Add to cache with LRU management - self._add_to_cache(identifier, obj) - self.stats.loaded_objects += 1 - - return obj - - def _load_object(self, identifier: Union[str, Uri]) -> Optional[Any]: - """Load object from EPC file.""" - is_uri = isinstance(identifier, Uri) or parse_uri(identifier) is not None - if is_uri: - uri = parse_uri(identifier) if isinstance(identifier, str) else identifier - assert uri is not None and uri.uuid is not None - identifier = uri.uuid + "." + (uri.version or "") - assert isinstance(identifier, str) - metadata = self._metadata.get(identifier) - if not metadata: - return None - - try: - with self._get_zip_file() as zf: - obj_data = zf.read(metadata.file_path) - self.stats.bytes_read += len(obj_data) - - obj_class = get_class_from_content_type(metadata.content_type) - obj = read_energyml_xml_bytes(obj_data, obj_class) - - if self.validate_on_load: - self._validate_object(obj, metadata) - - return obj - - except Exception as e: - logging.error(f"Failed to load object {identifier}: {e}") - return None - - def _validate_object(self, obj: Any, metadata: EpcObjectMetadata) -> None: - """Validate loaded object against metadata.""" - try: - obj_uuid = get_obj_uuid(obj) - if obj_uuid != metadata.uuid: - logging.warning(f"UUID mismatch for {metadata.identifier}: expected {metadata.uuid}, got {obj_uuid}") - except Exception as e: - logging.debug(f"Validation failed for {metadata.identifier}: {e}") - - def _add_to_cache(self, identifier: Union[str, Uri], obj: Any) -> None: - """Add object to cache with LRU eviction.""" - is_uri = isinstance(identifier, Uri) or parse_uri(identifier) is not None - if is_uri: - uri = parse_uri(identifier) if isinstance(identifier, str) else identifier - assert uri is not None and uri.uuid is not None - identifier = uri.uuid + "." + (uri.version or "") - - assert isinstance(identifier, str) - - # Remove from access order if already present - if identifier in self._access_order: - self._access_order.remove(identifier) - - # Add to front (most recently used) - self._access_order.insert(0, identifier) - - # Add to cache - self._object_cache[identifier] = obj - - # Evict if cache is full - while len(self._access_order) > self.cache_size: - oldest = self._access_order.pop() - self._object_cache.pop(oldest, None) - - def _update_access_order(self, identifier: str) -> None: - """Update access order for LRU cache.""" - if identifier in self._access_order: - self._access_order.remove(identifier) - self._access_order.insert(0, identifier) - - def get_object_by_uuid(self, uuid: str) -> List[Any]: - """Get all objects with the specified UUID.""" - if uuid not in self._uuid_index: - return [] - - objects = [] - for identifier in self._uuid_index[uuid]: - obj = self.get_object_by_identifier(identifier) - if obj is not None: - objects.append(obj) - - return objects - - def get_object(self, identifier: Union[str, Uri]) -> Optional[Any]: - return self.get_object_by_identifier(identifier) - - def get_objects_by_type(self, object_type: str) -> List[Any]: - """Get all objects of the specified type.""" - if object_type not in self._type_index: - return [] - - objects = [] - for identifier in self._type_index[object_type]: - obj = self.get_object_by_identifier(identifier) - if obj is not None: - objects.append(obj) - - return objects + if self.enable_parallel_rels: + return self._rebuild_all_rels_parallel(clean_first) + else: + return self._rebuild_all_rels_sequential(clean_first) - def list_object_metadata(self, object_type: Optional[str] = None) -> List[EpcObjectMetadata]: + def add_rels_for_object( + self, identifier: Union[str, Uri, Any], relationships: Union[Relationship, List[Relationship]] + ) -> None: """ - List metadata for objects without loading them. + Add additional relationships for a specific object. Args: - object_type: Optional filter by object type - - Returns: - List of object metadata + identifier: The identifier of the object, can be str, Uri, or the object itself + relationships: List of Relationship objects to add """ - if object_type is None: - return list(self._metadata.values()) + _id = self._id_from_uri_or_identifier(identifier=identifier, get_first_if_simple_uuid=True) + + if _id is None: + logging.warning(f"Invalid identifier provided for adding relationships: {identifier}") + return + + if not isinstance(relationships, list): + relationships = [relationships] - return [self._metadata[identifier] for identifier in self._type_index.get(object_type, [])] + if _id not in self._rels_mgr.additional_rels: + self._rels_mgr.additional_rels[_id] = [] + self._rels_mgr.additional_rels[_id].extend(relationships) + self._rels_mgr._write_rels_updates( + current_object_id=_id, + current_rels_additions=relationships, + ) def get_statistics(self) -> EpcStreamingStats: - """Get current streaming statistics.""" + """Get current statistics about the EPC streaming operations.""" return self.stats - def list_objects( - self, dataspace: Optional[str] = None, object_type: Optional[str] = None - ) -> List[ResourceMetadata]: + def get_h5_file_paths(self, obj: Union[str, Uri, Any]) -> List[str]: """ - List all objects with metadata (EnergymlStorageInterface method). - - Args: - dataspace: Optional dataspace filter (ignored for EPC files) - object_type: Optional type filter (qualified type) + Get all HDF5 file paths referenced in the EPC file (from rels to external resources). + Optimized to avoid loading the object when identifier/URI is provided. - Returns: - List of ResourceMetadata for all matching objects + :param obj: the object or its identifier/URI + :return: list of HDF5 file paths """ + if self.force_h5_path is not None: + return [self.force_h5_path] + h5_paths = set() - results = [] - metadata_list = self.list_object_metadata(object_type) - - for meta in metadata_list: - try: - # Load object to get title - title = "" - if self.force_title_load and meta.identifier: - obj = self.get_object_by_identifier(meta.identifier) - if obj and hasattr(obj, "citation") and obj.citation: - if hasattr(obj.citation, "title"): - title = obj.citation.title - - # Build URI - qualified_type = content_type_to_qualified_type(meta.content_type) - if meta.version: - uri = f"eml:///{qualified_type}(uuid={meta.uuid},version='{meta.version}')" - else: - uri = f"eml:///{qualified_type}({meta.uuid})" - - resource = ResourceMetadata( - uri=uri, - uuid=meta.uuid, - version=meta.version, - title=title, - object_type=meta.object_type, - content_type=meta.content_type, - ) - - results.append(resource) - except Exception: - continue + rels_path = None - return results - - def get_array_metadata( - self, proxy: Union[str, Uri, Any], path_in_external: Optional[str] = None - ) -> Union[DataArrayMetadata, List[DataArrayMetadata], None]: - """ - Get metadata for data array(s) (EnergymlStorageInterface method). - - Args: - proxy: The object identifier/URI or the object itself - path_in_external: Optional specific path - - Returns: - DataArrayMetadata if path specified, List[DataArrayMetadata] if no path, - or None if not found - """ - from energyml.utils.storage_interface import DataArrayMetadata - - try: - if path_in_external: - array = self.read_array(proxy, path_in_external) - if array is not None: - return DataArrayMetadata( - path_in_resource=path_in_external, - array_type=str(array.dtype), - dimensions=list(array.shape), - ) - else: - # Would need to scan all possible paths - not practical - return [] - except Exception: - pass - - return None - - def preload_objects(self, identifiers: List[str]) -> int: - """ - Preload specific objects into cache. - - Args: - identifiers: List of object identifiers to preload - - Returns: - Number of objects successfully loaded - """ - loaded_count = 0 - for identifier in identifiers: - if self.get_object_by_identifier(identifier) is not None: - loaded_count += 1 - return loaded_count - - def clear_cache(self) -> None: - """Clear the object cache to free memory.""" - self._object_cache.clear() - self._access_order.clear() - self.stats.loaded_objects = 0 - - def get_core_properties(self) -> Optional[CoreProperties]: - """Get core properties (loaded lazily).""" - # Delegate to metadata manager - return self._metadata_mgr.get_core_properties() - - def _gen_rels_path_from_metadata(self, metadata: EpcObjectMetadata) -> str: - """ - Generate rels path from object metadata without loading the object. - - Args: - metadata: Object metadata containing file path information - - Returns: - Path to the rels file for this object - """ - obj_path = metadata.file_path - # Extract folder and filename from the object path - if "/" in obj_path: - obj_folder = obj_path[: obj_path.rindex("/") + 1] - obj_file_name = obj_path[obj_path.rindex("/") + 1 :] - else: - obj_folder = "" - obj_file_name = obj_path - - return f"{obj_folder}_rels/{obj_file_name}.rels" - - def _gen_rels_path_from_identifier(self, identifier: str) -> Optional[str]: - """ - Generate rels path from object identifier without loading the object. - - Args: - identifier: Object identifier (uuid.version) - - Returns: - Path to the rels file, or None if metadata not found - """ - metadata = self._metadata.get(identifier) - if metadata is None: - return None - return self._gen_rels_path_from_metadata(metadata) - - def _update_rels_for_new_object(self, obj: Any, obj_identifier: str) -> None: - """Update relationships when a new object is added (UPDATE_AT_MODIFICATION mode).""" - # Delegate to relationship manager - self._rels_mgr.update_rels_for_new_object(obj, obj_identifier) - - def _update_rels_for_modified_object(self, obj: Any, obj_identifier: str, old_dors: List[Any]) -> None: - """Update relationships when an object is modified (UPDATE_AT_MODIFICATION mode).""" - # Delegate to relationship manager - self._rels_mgr.update_rels_for_modified_object(obj, obj_identifier, old_dors) - - def _update_rels_for_removed_object(self, obj_identifier: str, obj: Optional[Any] = None) -> None: - """Update relationships when an object is removed (UPDATE_AT_MODIFICATION mode).""" - # Delegate to relationship manager - self._rels_mgr.update_rels_for_removed_object(obj_identifier, obj) - - def _write_rels_updates( - self, - source_identifier: str, - source_relationships: List[Relationship], - dest_updates: Dict[str, Relationship], - removals: Optional[Dict[str, str]] = None, - delete_source_rels: bool = False, - ) -> None: - """Write relationship updates to the EPC file efficiently.""" - # Delegate to relationship manager - self._rels_mgr.write_rels_updates( - source_identifier, source_relationships, dest_updates, removals, delete_source_rels - ) - - def _reopen_persistent_zip(self) -> None: - """Reopen persistent ZIP file after modifications to reflect changes.""" - # Delegate to ZIP accessor - self._zip_accessor.reopen_persistent_zip() - - def to_epc(self, load_all: bool = False) -> Epc: - """ - Convert to standard Epc instance. - - Args: - load_all: Whether to load all objects into memory - - Returns: - Standard Epc instance - """ - epc = Epc() - epc.epc_file_path = str(self.epc_file_path) - core_props = self.get_core_properties() - if core_props is not None: - epc.core_props = core_props - - if load_all: - # Load all objects - for identifier in self._metadata: - obj = self.get_object_by_identifier(identifier) - if obj is not None: - epc.energyml_objects.append(obj) - - return epc - - def set_rels_update_mode(self, mode: RelsUpdateMode) -> None: - """ - Change the relationship update mode. - - Args: - mode: The new RelsUpdateMode to use - - Note: - Changing from MANUAL or UPDATE_ON_CLOSE to UPDATE_AT_MODIFICATION - may require calling rebuild_all_rels() first to ensure consistency. - """ - - def set_rels_update_mode(self, mode: RelsUpdateMode) -> None: - """ - Change the relationship update mode. - - Args: - mode: The new RelsUpdateMode to use - - Note: - Changing from MANUAL or UPDATE_ON_CLOSE to UPDATE_AT_MODIFICATION - may require calling rebuild_all_rels() first to ensure consistency. - """ - if not isinstance(mode, RelsUpdateMode): - raise ValueError(f"mode must be a RelsUpdateMode enum value, got {type(mode)}") - - old_mode = self.rels_update_mode - self.rels_update_mode = mode - # Also update the relationship manager - self._rels_mgr.rels_update_mode = mode - - logging.info(f"Changed relationship update mode from {old_mode.value} to {mode.value}") - - def get_rels_update_mode(self) -> RelsUpdateMode: - """ - Get the current relationship update mode. - - Returns: - The current RelsUpdateMode - """ - return self.rels_update_mode - - def get_obj_rels(self, obj: Union[str, Uri, Any]) -> List[Relationship]: - """ - Get all relationships for a given object. - Merges relationships from the EPC file with in-memory additional relationships. - - Optimized to avoid loading the object when identifier/URI is provided. - - :param obj: the object or its identifier/URI - :return: list of Relationship objects - """ - # Get identifier without loading the object - obj_identifier = None - rels_path = None - - if isinstance(obj, (str, Uri)): - # Convert URI to identifier if needed - if isinstance(obj, Uri) or parse_uri(obj) is not None: - uri = parse_uri(obj) if isinstance(obj, str) else obj - assert uri is not None and uri.uuid is not None - obj_identifier = uri.uuid + "." + (uri.version or "") - else: - obj_identifier = obj - - # Generate rels path from metadata without loading the object - rels_path = self._gen_rels_path_from_identifier(obj_identifier) - else: - # We have the actual object - obj_identifier = get_obj_identifier(obj) - rels_path = gen_rels_path(obj, self.export_version) - - # Delegate to relationship manager - return self._rels_mgr.get_obj_rels(obj_identifier, rels_path) - - def get_h5_file_paths(self, obj: Union[str, Uri, Any]) -> List[str]: - """ - Get all HDF5 file paths referenced in the EPC file (from rels to external resources). - Optimized to avoid loading the object when identifier/URI is provided. - - :param obj: the object or its identifier/URI - :return: list of HDF5 file paths - """ - if self.force_h5_path is not None: - return [self.force_h5_path] - h5_paths = set() - - obj_identifier = None - rels_path = None - - # Get identifier and rels path without loading the object - if isinstance(obj, (str, Uri)): - # Convert URI to identifier if needed - if isinstance(obj, Uri) or parse_uri(obj) is not None: - uri = parse_uri(obj) if isinstance(obj, str) else obj - assert uri is not None and uri.uuid is not None - obj_identifier = uri.uuid + "." + (uri.version or "") - else: - obj_identifier = obj - - # Generate rels path from metadata without loading the object - rels_path = self._gen_rels_path_from_identifier(obj_identifier) - else: - # We have the actual object - obj_identifier = get_obj_identifier(obj) - rels_path = gen_rels_path(obj, self.export_version) + _id = self._id_from_uri_or_identifier(identifier=obj, get_first_if_simple_uuid=True) + if _id is not None: + rels_path = self._metadata_mgr.gen_rels_path_from_identifier(_id) # Check in-memory additional rels first - for rels in self.additional_rels.get(obj_identifier, []): + for rels in self._rels_mgr.additional_rels.get(_id, []): if rels.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(): h5_paths.add(rels.target) # Also check rels from the EPC file if rels_path is not None: - with self._get_zip_file() as zf: + with self._zip_accessor.get_zip_file() as zf: try: rels_data = zf.read(rels_path) self.stats.bytes_read += len(rels_data) @@ -1729,1085 +1380,465 @@ def get_h5_file_paths(self, obj: Union[str, Uri, Any]) -> List[str]: possible_h5_path = os.path.join(epc_folder, epc_file_base + ".h5") if os.path.exists(possible_h5_path): h5_paths.add(possible_h5_path) - return list(h5_paths) - - def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Optional[np.ndarray]: - """ - Read a dataset from the HDF5 file linked to the proxy object. - :param proxy: the object or its identifier - :param path_in_external: the path in the external HDF5 file - :return: the dataset as a numpy array - """ - # Resolve proxy to object - - h5_path = [] - if self.force_h5_path is not None: - if self.cache_opened_h5 is None: - self.cache_opened_h5 = h5py.File(self.force_h5_path, "a") - h5_path = [self.cache_opened_h5] - else: - if isinstance(proxy, (str, Uri)): - obj = self.get_object_by_identifier(proxy) - else: - obj = proxy - - h5_path = self.get_h5_file_paths(obj) - - h5_reader = HDF5FileReader() - - if h5_path is None or len(h5_path) == 0: - raise ValueError("No HDF5 file paths found for the given proxy object.") - else: - for h5p in h5_path: - # TODO: handle different type of files - try: - return h5_reader.read_array(source=h5p, path_in_external_file=path_in_external) - except Exception: - pass - # logging.error(f"Failed to read HDF5 dataset from {h5p}: {e}") - - def write_array(self, proxy: Union[str, Uri, Any], path_in_external: str, array: np.ndarray) -> bool: - """ - Write a dataset to the HDF5 file linked to the proxy object. - :param proxy: the object or its identifier - :param path_in_external: the path in the external HDF5 file - :param array: the numpy array to write - - return: True if successful - """ - h5_path = [] - if self.force_h5_path is not None: - if self.cache_opened_h5 is None: - self.cache_opened_h5 = h5py.File(self.force_h5_path, "a") - h5_path = [self.cache_opened_h5] - else: - if isinstance(proxy, (str, Uri)): - obj = self.get_object_by_identifier(proxy) - else: - obj = proxy - - h5_path = self.get_h5_file_paths(obj) - - h5_writer = HDF5FileWriter() - - if h5_path is None or len(h5_path) == 0: - raise ValueError("No HDF5 file paths found for the given proxy object.") - else: - for h5p in h5_path: - try: - h5_writer.write_array(target=h5p, path_in_external_file=path_in_external, array=array) - return True - except Exception as e: - logging.error(f"Failed to write HDF5 dataset to {h5p}: {e}") - return False - - def validate_all_objects(self, fast_mode: bool = True) -> Dict[str, List[str]]: - """ - Validate all objects in the EPC file. - - Args: - fast_mode: If True, only validate metadata without loading full objects - - Returns: - Dictionary with 'errors' and 'warnings' keys containing lists of issues - """ - results = {"errors": [], "warnings": []} - - for identifier, metadata in self._metadata.items(): - try: - if fast_mode: - # Quick validation - just check file exists and is readable - with self._get_zip_file() as zf: - try: - zf.getinfo(metadata.file_path) - except KeyError: - results["errors"].append(f"Missing file for object {identifier}: {metadata.file_path}") - else: - # Full validation - load and validate object - obj = self.get_object_by_identifier(identifier) - if obj is None: - results["errors"].append(f"Failed to load object {identifier}") - else: - self._validate_object(obj, metadata) - - except Exception as e: - results["errors"].append(f"Validation error for {identifier}: {e}") - - return results - - def get_object_dependencies(self, identifier: Union[str, Uri]) -> List[str]: - """ - Get list of object identifiers that this object depends on. - - This would need to be implemented based on DOR analysis. - """ - # Placeholder for dependency analysis - # Would need to parse DORs in the object - return [] - - def __len__(self) -> int: - """Return total number of objects in EPC.""" - return len(self._metadata) - - def __contains__(self, identifier: str) -> bool: - """Check if object with identifier exists.""" - return identifier in self._metadata - - def __iter__(self) -> Iterator[str]: - """Iterate over object identifiers.""" - return iter(self._metadata.keys()) - - def __enter__(self): - """Context manager entry.""" - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit with cleanup.""" - self.clear_cache() - self.close() - if self.cache_opened_h5 is not None: - try: - self.cache_opened_h5.close() - except Exception: - pass - self.cache_opened_h5 = None - - def __del__(self): - """Destructor to ensure persistent ZIP file is closed.""" - try: - self.close() - if self.cache_opened_h5 is not None: - try: - self.cache_opened_h5.close() - except Exception: - pass - self.cache_opened_h5 = None - except Exception: - pass # Ignore errors during cleanup - - def close(self) -> None: - """Close the persistent ZIP file if it's open, recomputing rels first if mode is UPDATE_ON_CLOSE.""" - # Recompute all relationships before closing if in UPDATE_ON_CLOSE mode - if self.rels_update_mode == RelsUpdateMode.UPDATE_ON_CLOSE: - try: - self.rebuild_all_rels(clean_first=True) - logging.info("Rebuilt all relationships on close (UPDATE_ON_CLOSE mode)") - except Exception as e: - logging.warning(f"Error rebuilding rels on close: {e}") - - # Delegate to ZIP accessor - self._zip_accessor.close() - - def put_object(self, obj: Any, dataspace: Optional[str] = None) -> Optional[str]: - """ - Store an energyml object (EnergymlStorageInterface method). - - Args: - obj: The energyml object to store - dataspace: Optional dataspace name (ignored for EPC files) - - Returns: - The identifier of the stored object (UUID.version or UUID), or None on error - """ - try: - return self.add_object(obj, replace_if_exists=True) - except Exception: - return None - - def add_object(self, obj: Any, file_path: Optional[str] = None, replace_if_exists: bool = True) -> str: - """ - Add a new object to the EPC file and update caches. - - Args: - obj: The EnergyML object to add - file_path: Optional custom file path, auto-generated if not provided - replace_if_exists: If True, replace the object if it already exists. If False, raise ValueError. - - Returns: - The identifier of the added object - - Raises: - ValueError: If object is invalid or already exists (when replace_if_exists=False) - RuntimeError: If file operations fail - """ - identifier = None - metadata = None - - try: - # Extract object information - identifier = get_obj_identifier(obj) - uuid = identifier.split(".")[0] if identifier else None - - if not uuid: - raise ValueError("Object must have a valid UUID") - - version = identifier[len(uuid) + 1 :] if identifier and "." in identifier else None - # Ensure version is treated as a string, not an integer - if version is not None and not isinstance(version, str): - version = str(version) - - object_type = get_object_type_for_file_path_from_class(obj) - - if identifier in self._metadata: - if replace_if_exists: - # Remove the existing object first - logging.info(f"Replacing existing object {identifier}") - self.remove_object(identifier) - else: - raise ValueError( - f"Object with identifier {identifier} already exists. Use update_object() or set replace_if_exists=True." - ) - - # Generate file path if not provided - file_path = gen_energyml_object_path(obj, self.export_version) - - print(f"Generated file path: {file_path} for export version: {self.export_version}") - - # Determine content type based on object type - content_type = get_obj_content_type(obj) - - # Create metadata - metadata = EpcObjectMetadata( - uuid=uuid, - object_type=object_type, - content_type=content_type, - file_path=file_path, - version=version, - identifier=identifier, - ) - - # Update internal structures - self._metadata[identifier] = metadata - - # Update UUID index - if uuid not in self._uuid_index: - self._uuid_index[uuid] = [] - self._uuid_index[uuid].append(identifier) - - # Update type index - if object_type not in self._type_index: - self._type_index[object_type] = [] - self._type_index[object_type].append(identifier) - - # Add to cache - self._add_to_cache(identifier, obj) - - # Save changes to file - self._add_object_to_file(obj, metadata) - - # Update relationships if in UPDATE_AT_MODIFICATION mode - if self.rels_update_mode == RelsUpdateMode.UPDATE_AT_MODIFICATION: - self._update_rels_for_new_object(obj, identifier) - - # Update stats - self.stats.total_objects += 1 - - logging.info(f"Added object {identifier} to EPC file") - return identifier - - except Exception as e: - logging.error(f"Failed to add object: {e}") - # Rollback changes if we created metadata - if identifier and metadata: - self._rollback_add_object(identifier) - raise RuntimeError(f"Failed to add object to EPC: {e}") - - def delete_object(self, identifier: Union[str, Uri]) -> bool: - """ - Delete an object by its identifier (EnergymlStorageInterface method). - - Args: - identifier: Object identifier (UUID or UUID.version) or ETP URI - - Returns: - True if successfully deleted, False otherwise - """ - return self.remove_object(identifier) - - def remove_object(self, identifier: Union[str, Uri]) -> bool: - """ - Remove an object (or all versions of an object) from the EPC file and update caches. - - Args: - identifier: The identifier of the object to remove. Can be either: - - Full identifier (uuid.version) to remove a specific version - - UUID only to remove ALL versions of that object - - Returns: - True if object(s) were successfully removed, False if not found - - Raises: - RuntimeError: If file operations fail - """ - try: - is_uri = isinstance(identifier, Uri) or parse_uri(identifier) is not None - if is_uri: - uri = parse_uri(identifier) if isinstance(identifier, str) else identifier - assert uri is not None and uri.uuid is not None - identifier = uri.uuid + "." + (uri.version or "") - assert isinstance(identifier, str) - - if identifier not in self._metadata: - # Check if identifier is a UUID only (should remove all versions) - if identifier in self._uuid_index: - # Remove all versions for this UUID - identifiers_to_remove = self._uuid_index[identifier].copy() - removed_count = 0 - - for id_to_remove in identifiers_to_remove: - if self._remove_single_object(id_to_remove): - removed_count += 1 - - return removed_count > 0 - else: - return False - - # Single identifier removal - return self._remove_single_object(identifier) - - except Exception as e: - logging.error(f"Failed to remove object {identifier}: {e}") - raise RuntimeError(f"Failed to remove object from EPC: {e}") - - def _remove_single_object(self, identifier: str) -> bool: - """ - Remove a single object by its full identifier. - - Args: - identifier: The full identifier (uuid.version) of the object to remove - Returns: - True if the object was successfully removed, False otherwise - """ - try: - if identifier not in self._metadata: - return False - - metadata = self._metadata[identifier] - - # If in UPDATE_AT_MODIFICATION mode, update rels before removing - obj = None - if self.rels_update_mode == RelsUpdateMode.UPDATE_AT_MODIFICATION: - obj = self.get_object_by_identifier(identifier) - if obj: - self._update_rels_for_removed_object(identifier, obj) - - # IMPORTANT: Remove from file FIRST (before clearing cache/metadata) - # because _remove_object_from_file needs to load the object to access its DORs - self._remove_object_from_file(metadata) - - # Now remove from cache - if identifier in self._object_cache: - del self._object_cache[identifier] - - if identifier in self._access_order: - self._access_order.remove(identifier) - - # Remove from indexes - uuid = metadata.uuid - object_type = metadata.object_type - - if uuid in self._uuid_index: - if identifier in self._uuid_index[uuid]: - self._uuid_index[uuid].remove(identifier) - if not self._uuid_index[uuid]: - del self._uuid_index[uuid] - - if object_type in self._type_index: - if identifier in self._type_index[object_type]: - self._type_index[object_type].remove(identifier) - if not self._type_index[object_type]: - del self._type_index[object_type] - - # Remove from metadata (do this last) - del self._metadata[identifier] - - # Update stats - self.stats.total_objects -= 1 - if self.stats.loaded_objects > 0: - self.stats.loaded_objects -= 1 - - logging.info(f"Removed object {identifier} from EPC file") - return True - - except Exception as e: - logging.error(f"Failed to remove single object {identifier}: {e}") - return False - - def update_object(self, obj: Any) -> str: - """ - Update an existing object in the EPC file. - - Args: - obj: The EnergyML object to update - Returns: - The identifier of the updated object - """ - identifier = get_obj_identifier(obj) - if not identifier or identifier not in self._metadata: - raise ValueError("Object must have a valid identifier and exist in the EPC file") - - try: - # If in UPDATE_AT_MODIFICATION mode, get old DORs and handle update differently - if self.rels_update_mode == RelsUpdateMode.UPDATE_AT_MODIFICATION: - old_obj = self.get_object_by_identifier(identifier) - old_dors = get_direct_dor_list(old_obj) if old_obj else [] - - # Preserve non-SOURCE/DESTINATION relationships (like EXTERNAL_RESOURCE) before removal - preserved_rels = [] - try: - obj_rels = self.get_obj_rels(identifier) - preserved_rels = [ - r - for r in obj_rels - if r.type_value - not in ( - EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), - EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), - ) - ] - except Exception: - pass - - # Remove existing object (without rels update since we're replacing it) - # Temporarily switch to MANUAL mode to avoid double updates - original_mode = self.rels_update_mode - self.rels_update_mode = RelsUpdateMode.MANUAL - self.remove_object(identifier) - self.rels_update_mode = original_mode - - # Add updated object (without rels update since we'll do custom update) - self.rels_update_mode = RelsUpdateMode.MANUAL - new_identifier = self.add_object(obj) - self.rels_update_mode = original_mode - - # Now do the specialized update that handles both adds and removes - self._update_rels_for_modified_object(obj, new_identifier, old_dors) - - # Restore preserved relationships (like EXTERNAL_RESOURCE) - if preserved_rels: - # These need to be written directly to the rels file - # since _update_rels_for_modified_object already wrote it - rels_path = self._gen_rels_path_from_identifier(new_identifier) - if rels_path: - with self._get_zip_file() as zf: - # Read current rels - current_rels = [] - try: - if rels_path in zf.namelist(): - rels_data = zf.read(rels_path) - rels_obj = read_energyml_xml_bytes(rels_data, Relationships) - if rels_obj and rels_obj.relationship: - current_rels = list(rels_obj.relationship) - except Exception: - pass - - # Add preserved rels - all_rels = current_rels + preserved_rels - - # Write back - with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: - temp_path = temp_file.name - - try: - with self._get_zip_file() as source_zf: - with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: - # Copy all files except the rels file we're updating - for item in source_zf.infolist(): - if item.filename != rels_path: - buffer = source_zf.read(item.filename) - target_zf.writestr(item, buffer) - - # Write updated rels file - target_zf.writestr( - rels_path, serialize_xml(Relationships(relationship=all_rels)) - ) - - # Replace original - shutil.move(temp_path, self.epc_file_path) - self._reopen_persistent_zip() - - except Exception: - if os.path.exists(temp_path): - os.unlink(temp_path) - raise - - else: - # For other modes (UPDATE_ON_CLOSE, MANUAL), preserve non-SOURCE/DESTINATION relationships - preserved_rels = [] - try: - obj_rels = self.get_obj_rels(identifier) - preserved_rels = [ - r - for r in obj_rels - if r.type_value - not in ( - EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), - EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), - ) - ] - except Exception: - pass - - # Simple remove + add - self.remove_object(identifier) - new_identifier = self.add_object(obj) - - # Restore preserved relationships if any - if preserved_rels: - self.add_rels_for_object(new_identifier, preserved_rels, write_immediately=True) - - logging.info(f"Updated object {identifier} to {new_identifier} in EPC file") - return new_identifier - - except Exception as e: - logging.error(f"Failed to update object {identifier}: {e}") - raise RuntimeError(f"Failed to update object in EPC: {e}") - - def add_rels_for_object( - self, identifier: Union[str, Uri, Any], relationships: List[Relationship], write_immediately: bool = False - ) -> None: - """ - Add additional relationships for a specific object. - - Relationships are stored in memory and can be written immediately or deferred - until write_pending_rels() is called, or when the EPC is closed. - - Args: - identifier: The identifier of the object, can be str, Uri, or the object itself - relationships: List of Relationship objects to add - write_immediately: If True, writes pending rels to disk immediately after adding. - If False (default), rels are kept in memory for batching. - """ - is_uri = isinstance(identifier, Uri) or (isinstance(identifier, str) and parse_uri(identifier) is not None) - if is_uri: - uri = parse_uri(identifier) if isinstance(identifier, str) else identifier - assert uri is not None and uri.uuid is not None - identifier = uri.uuid + "." + (uri.version or "") - elif not isinstance(identifier, str): - identifier = get_obj_identifier(identifier) - - assert isinstance(identifier, str) - - if identifier not in self.additional_rels: - self.additional_rels[identifier] = [] - - self.additional_rels[identifier].extend(relationships) - logging.debug(f"Added {len(relationships)} relationships for object {identifier} (in-memory)") - - if write_immediately: - self.write_pending_rels() - - def write_pending_rels(self) -> int: - """ - Write all pending in-memory relationships to the EPC file efficiently. - - This method reads existing rels, merges them in memory with pending rels, - then rewrites only the affected rels files in a single ZIP update. - - Returns: - Number of rels files updated - """ - if not self.additional_rels: - logging.debug("No pending relationships to write") - return 0 - - updated_count = 0 - - # Step 1: Read existing rels and merge with pending rels in memory - merged_rels: Dict[str, Relationships] = {} # rels_path -> merged Relationships - - with self._get_zip_file() as zf: - for obj_identifier, new_relationships in self.additional_rels.items(): - # Generate rels path from metadata without loading the object - rels_path = self._gen_rels_path_from_identifier(obj_identifier) - if rels_path is None: - logging.warning(f"Could not generate rels path for {obj_identifier}") - continue - - # Read existing rels from ZIP - existing_relationships = [] - try: - if rels_path in zf.namelist(): - rels_data = zf.read(rels_path) - existing_rels = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels and existing_rels.relationship: - existing_relationships = list(existing_rels.relationship) - except Exception as e: - logging.debug(f"Could not read existing rels for {rels_path}: {e}") - - # Merge new relationships, avoiding duplicates - for new_rel in new_relationships: - # Check if relationship already exists - rel_exists = any( - r.target == new_rel.target and r.type_value == new_rel.type_value - for r in existing_relationships - ) - - if not rel_exists: - # Ensure unique ID - cpt = 0 - new_rel_id = new_rel.id - while any(r.id == new_rel_id for r in existing_relationships): - new_rel_id = f"{new_rel.id}_{cpt}" - cpt += 1 - if new_rel_id != new_rel.id: - new_rel.id = new_rel_id - - existing_relationships.append(new_rel) - - # Store merged result - if existing_relationships: - merged_rels[rels_path] = Relationships(relationship=existing_relationships) - - # Step 2: Write updated rels back to ZIP (create temp, copy all, replace) - if not merged_rels: - return 0 - - with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: - temp_path = temp_file.name - - try: - # Copy entire ZIP, replacing only the updated rels files - with self._get_zip_file() as source_zf: - with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: - # Copy all files except the rels we're updating - for item in source_zf.infolist(): - if item.filename not in merged_rels: - buffer = source_zf.read(item.filename) - target_zf.writestr(item, buffer) - - # Write updated rels files - for rels_path, relationships in merged_rels.items(): - rels_xml = serialize_xml(relationships) - target_zf.writestr(rels_path, rels_xml) - updated_count += 1 - - # Replace original with updated ZIP - shutil.move(temp_path, self.epc_file_path) - self._reopen_persistent_zip() - - # Clear pending rels after successful write - self.additional_rels.clear() - - logging.info(f"Wrote {updated_count} rels files to EPC") - return updated_count - - except Exception as e: - if os.path.exists(temp_path): - os.unlink(temp_path) - logging.error(f"Failed to write pending rels: {e}") - raise + return list(h5_paths) + + # ________ ___ __________ __ _________________ ______ ____ _____ + # / ____/ / / | / ___/ ___/ / |/ / ____/_ __/ / / / __ \/ __ \/ ___/ + # / / / / / /| | \__ \\__ \ / /|_/ / __/ / / / /_/ / / / / / / /\__ \ + # / /___/ /___/ ___ |___/ /__/ / / / / / /___ / / / __ / /_/ / /_/ /___/ / + # \____/_____/_/ |_/____/____/ /_/ /_/_____/ /_/ /_/ /_/\____/_____//____/ - def _compute_object_rels(self, obj: Any, obj_identifier: str) -> List[Relationship]: - """Compute relationships for a given object (SOURCE relationships). + # ______ _______ __ ____ __ ____ + # / ____/___ ___ _________ ___ ______ ___ / / ___// /_____ _________ _____ ____ / _/___ / /____ _____/ __/___ _________ + # / __/ / __ \/ _ \/ ___/ __ `/ / / / __ `__ \/ /\__ \/ __/ __ \/ ___/ __ `/ __ `/ _ \ / // __ \/ __/ _ \/ ___/ /_/ __ `/ ___/ _ \ + # / /___/ / / / __/ / / /_/ / /_/ / / / / / / /___/ / /_/ /_/ / / / /_/ / /_/ / __// // / / / /_/ __/ / / __/ /_/ / /__/ __/ + # /_____/_/ /_/\___/_/ \__, /\__, /_/ /_/ /_/_//____/\__/\____/_/ \__,_/\__, /\___/___/_/ /_/\__/\___/_/ /_/ \__,_/\___/\___/ + # /____//____/ /____/ - Delegates to _rels_mgr.compute_object_rels() + def get_object(self, identifier: Union[str, Uri]) -> Optional[Any]: """ - return self._rels_mgr.compute_object_rels(obj, obj_identifier) + Retrieve an EnergyML object from the EPC file by its identifier or UUID. - def _merge_rels(self, new_rels: List[Relationship], existing_rels: List[Relationship]) -> List[Relationship]: - """Merge new relationships with existing ones, avoiding duplicates and ensuring unique IDs. + This method implements lazy loading with caching for memory efficiency. + If a simple UUID is provided and multiple versions exist, returns the first one. - Delegates to _rels_mgr.merge_rels() - """ - return self._rels_mgr.merge_rels(new_rels, existing_rels) + Args: + identifier: Object identifier (full identifier string or URI) or simple UUID. + Can be: + - A full identifier string (e.g., "eml:///resqml20.obj_TriangulatedSetRepresentation(uuid=abc-123, version='1.0')") + - A Uri object + - A simple UUID string (e.g., "abc-123") + + Returns: + The deserialized EnergyML object, or None if not found or an error occurred. - def _add_object_to_file(self, obj: Any, metadata: EpcObjectMetadata) -> None: - """Add object to the EPC file efficiently. + Behavior: + - Checks the in-memory cache first (fast path) + - If not cached, loads from ZIP file and deserializes XML + - Updates cache and LRU access order + - Updates statistics (cache hits/misses, bytes read) - Reads existing rels, computes updates in memory, then writes everything - in a single ZIP operation. + Notes: + - For simple UUID lookups with multiple versions, returns the first match + - Use get_object_by_uuid() to retrieve all versions of an object """ - xml_content = serialize_xml(obj) - obj_identifier = metadata.identifier - assert obj_identifier is not None, "Object identifier must not be None" + _id = self._id_from_uri_or_identifier(identifier=identifier, get_first_if_simple_uuid=True) + if _id is None: + logging.warning(f"Invalid identifier provided: {identifier}") + return None + metadata = self._metadata_mgr.get_metadata(_id) - # Step 1: Compute which rels files need to be updated and prepare their content - rels_updates: Dict[str, str] = {} # rels_path -> XML content + if metadata is None: + logging.warning(f"Object with identifier {_id} not found in metadata") + return None - with self._get_zip_file() as zf: - # 1a. Object's own .rels file - obj_rels_path = gen_rels_path(obj, self.export_version) - obj_relationships = self._compute_object_rels(obj, obj_identifier) + # Check cache first + if _id in self._object_cache: + self._update_access_order(_id) # type: ignore + self.stats.cache_hits += 1 + return self._object_cache[_id] - if obj_relationships: - # Read existing rels - existing_rels = [] - try: - if obj_rels_path in zf.namelist(): - rels_data = zf.read(obj_rels_path) - existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels_obj and existing_rels_obj.relationship: - existing_rels = list(existing_rels_obj.relationship) - except Exception: - pass + self.stats.cache_misses += 1 - # Merge and serialize - merged_rels = self._merge_rels(obj_relationships, existing_rels) - if merged_rels: - rels_updates[obj_rels_path] = serialize_xml(Relationships(relationship=merged_rels)) + file_path = metadata.file_path(export_version=self._metadata_mgr._export_version) - # 1b. Update rels of referenced objects (DESTINATION relationships) - direct_dors = get_direct_dor_list(obj) - for dor in direct_dors: - try: - target_identifier = get_obj_identifier(dor) - - # Generate rels path from metadata without processing DOR - target_rels_path = self._gen_rels_path_from_identifier(target_identifier) - if target_rels_path is None: - # Fall back to generating from DOR if metadata not found - target_rels_path = gen_rels_path(dor, self.export_version) - - # Create DESTINATION relationship - dest_rel = Relationship( - target=metadata.file_path, - type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), - id=f"_{target_identifier}_{get_obj_type(get_obj_usable_class(obj))}_{obj_identifier}", - ) + try: + with self._zip_accessor.get_zip_file() as zf: + obj_data = zf.read(file_path) + self.stats.bytes_read += len(obj_data) - # Read existing rels - existing_rels = [] - try: - if target_rels_path in zf.namelist(): - rels_data = zf.read(target_rels_path) - existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels_obj and existing_rels_obj.relationship: - existing_rels = list(existing_rels_obj.relationship) - except Exception: - pass + obj_class = get_class_from_content_type(metadata.content_type) + obj = read_energyml_xml_bytes(obj_data, obj_class) + # add to cache + self._object_cache[_id] = obj + self._update_access_order(_id) # type: ignore + return obj - # Merge and serialize - merged_rels = self._merge_rels([dest_rel], existing_rels) - if merged_rels: - rels_updates[target_rels_path] = serialize_xml(Relationships(relationship=merged_rels)) + except Exception as e: + logging.error(f"Failed to load object {identifier}: {e}") + return None - except Exception as e: - logging.warning(f"Failed to prepare rels update for referenced object: {e}") + def get_object_by_uuid(self, uuid: str) -> List[Any]: + """ + Retrieve all EnergyML objects with the given UUID from the EPC file. - # 1c. Update [Content_Types].xml - content_types_xml = self._update_content_types_xml(zf, metadata, add=True) + This method returns all versions/instances of objects sharing the same UUID. + In well-formed EPC files, typically only one object per UUID exists, but this + method handles cases where multiple versions are present. - # Step 2: Write everything to new ZIP - with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: - temp_path = temp_file.name + Args: + uuid: The UUID string to search for (e.g., "abc-123-def-456") - try: - with self._get_zip_file() as source_zf: - with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: - # Write new object - target_zf.writestr(metadata.file_path, xml_content) + Returns: + List of deserialized EnergyML objects with the given UUID. + Returns empty list if: + - UUID is invalid or None + - No objects with this UUID exist + - All objects failed to load + + Behavior: + - Validates UUID format and type + - Retrieves all identifiers for the UUID from metadata manager + - First collects all cached objects (fast path) + - Then opens ZIP file once to load all non-cached objects in batch (efficient) + - Maintains cache consistency across all loaded objects + - Updates statistics for each object loaded + + Notes: + - Objects are loaded lazily with caching for efficiency + - Cache is updated for each successfully loaded object + - Failed loads are logged but don't prevent other objects from loading + - ZIP file is opened only once for all non-cached objects (performance optimization) + """ + # Type guard: ensure uuid is a string + if not isinstance(uuid, str): + logging.warning(f"get_object_by_uuid called with non-string uuid: {type(uuid)}") + return [] - # Write updated [Content_Types].xml - target_zf.writestr(get_epc_content_type_path(), content_types_xml) + # Type guard: ensure uuid is not empty + if not uuid or not uuid.strip(): + logging.warning("get_object_by_uuid called with empty UUID") + return [] - # Write updated rels files - for rels_path, rels_xml in rels_updates.items(): - target_zf.writestr(rels_path, rels_xml) + # Type guard: validate UUID format + if OptimizedRegex.UUID.fullmatch(uuid) is None: + logging.warning(f"get_object_by_uuid called with invalid UUID format: {uuid}") + return [] - # Copy all other files - files_to_skip = {get_epc_content_type_path(), metadata.file_path} - files_to_skip.update(rels_updates.keys()) + # Get all identifiers for this UUID + identifiers = self._metadata_mgr.get_uuid_identifiers(uuid) - for item in source_zf.infolist(): - if item.filename not in files_to_skip: - buffer = source_zf.read(item.filename) - target_zf.writestr(item, buffer) + # Guard: check if identifiers list is valid + if identifiers is None or not isinstance(identifiers, list): + logging.debug(f"No identifiers found for UUID: {uuid}") + return [] - # Replace original - shutil.move(temp_path, self.epc_file_path) - self._reopen_persistent_zip() + if len(identifiers) == 0: + logging.debug(f"No objects found with UUID: {uuid}") + return [] - except Exception as e: - if os.path.exists(temp_path): - os.unlink(temp_path) - logging.error(f"Failed to add object to EPC file: {e}") - raise + # Phase 1: Collect cached objects and prepare list of non-cached identifiers + objects = [] + non_cached_metadata = [] # List of (identifier, metadata) tuples to load from ZIP + + for identifier in identifiers: + # Type guard: ensure identifier is valid + if not identifier or not isinstance(identifier, str): + logging.warning(f"Skipping invalid identifier in UUID lookup: {identifier}") + continue - def _remove_object_from_file(self, metadata: EpcObjectMetadata) -> None: - """Remove object from the EPC file efficiently. + # Get metadata first to validate object exists + metadata = self._metadata_mgr.get_metadata(identifier) + if metadata is None: + logging.warning(f"Metadata not found for identifier {identifier}, skipping") + continue - Reads existing rels, computes updates in memory, then writes everything - in a single ZIP operation. Note: This does NOT remove .rels files. - Use clean_rels() to remove orphaned relationships. - """ - # Load object first (needed to process its DORs) - if metadata.identifier is None: - logging.error("Cannot remove object with None identifier") - raise ValueError("Object identifier must not be None") + # Check cache first for consistency + if identifier in self._object_cache: + obj = self._object_cache[identifier] + if obj is not None: # Guard: ensure cached object is valid + self._update_access_order(identifier) + self.stats.cache_hits += 1 + objects.append(obj) + else: + # Remove invalid cached entry and mark for re-loading + logging.warning(f"Removing invalid cached object for {identifier}") + del self._object_cache[identifier] + non_cached_metadata.append((identifier, metadata)) + self.stats.cache_misses += 1 + else: + # Not in cache, need to load from ZIP + non_cached_metadata.append((identifier, metadata)) + self.stats.cache_misses += 1 - obj = self.get_object_by_identifier(metadata.identifier) - if obj is None: - logging.warning(f"Object {metadata.identifier} not found, cannot remove rels") - # Still proceed with removal even if object can't be loaded + # Phase 2: Load all non-cached objects in a single ZIP file access + if non_cached_metadata: + try: + with self._zip_accessor.get_zip_file() as zf: + for identifier, metadata in non_cached_metadata: + file_path = metadata.file_path(export_version=self._metadata_mgr._export_version) - # Step 1: Compute rels updates (remove DESTINATION relationships from referenced objects) - rels_updates: Dict[str, str] = {} # rels_path -> XML content + try: + obj_data = zf.read(file_path) + self.stats.bytes_read += len(obj_data) - if obj is not None: - with self._get_zip_file() as zf: - direct_dors = get_direct_dor_list(obj) + obj_class = get_class_from_content_type(metadata.content_type) + obj = read_energyml_xml_bytes(obj_data, obj_class) - for dor in direct_dors: - try: - target_identifier = get_obj_identifier(dor) - if target_identifier not in self._metadata: - continue + # Guard: validate deserialized object + if obj is None: + logging.warning(f"Deserialization returned None for {identifier}") + continue - # Use metadata to generate rels path without loading the object - target_rels_path = self._gen_rels_path_from_identifier(target_identifier) - if target_rels_path is None: - continue + # Add to cache with consistency check + self._object_cache[identifier] = obj + self._update_access_order(identifier) + objects.append(obj) - # Read existing rels - existing_relationships = [] - try: - if target_rels_path in zf.namelist(): - rels_data = zf.read(target_rels_path) - existing_rels = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels and existing_rels.relationship: - existing_relationships = list(existing_rels.relationship) + except KeyError: + logging.error(f"File not found in ZIP for identifier {identifier}: {file_path}") except Exception as e: - logging.debug(f"Could not read existing rels for {target_identifier}: {e}") + logging.error(f"Failed to deserialize object {identifier}: {e}") - # Remove DESTINATION relationship that pointed to our object - updated_relationships = [ - r - for r in existing_relationships - if not ( - r.target == metadata.file_path - and r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type() - ) - ] + except Exception as e: + logging.error(f"Failed to open ZIP file for batch loading: {e}") - # Only update if relationships remain - if updated_relationships: - rels_updates[target_rels_path] = serialize_xml( - Relationships(relationship=updated_relationships) - ) + return objects - except Exception as e: - logging.warning(f"Failed to update rels for referenced object during removal: {e}") + def put_object(self, obj: Any, dataspace: Optional[str] = None) -> Optional[str]: + # 1. Generate identifier and metadata for the object + # 2. Write object data and metadata to the EPC file in a temporary file and then replace original to minimize I/O + # 3. Update relationships if needed (depending on rels_update_mode) + # 4. Return the identifier of the added/updated object - # Update [Content_Types].xml - content_types_xml = self._update_content_types_xml(zf, metadata, add=False) - else: - # If we couldn't load the object, still update content types - with self._get_zip_file() as zf: - content_types_xml = self._update_content_types_xml(zf, metadata, add=False) + uri = get_obj_uri(obj=obj, dataspace=None) + if uri is None: + raise ValueError("Failed to generate URI for the object, cannot put into EPC") - # Step 2: Write everything to new ZIP - with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: - temp_path = temp_file.name + identifier = uri.as_identifier() + existing_metadata = self._metadata_mgr.get_metadata(identifier) + file_path = gen_energyml_object_path(obj, self._metadata_mgr._export_version) + is_update = existing_metadata is not None + # Write object data and metadata to EPC try: - with self._get_zip_file() as source_zf: - with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: - # Write updated [Content_Types].xml - target_zf.writestr(get_epc_content_type_path(), content_types_xml) - - # Write updated rels files - for rels_path, rels_xml in rels_updates.items(): - target_zf.writestr(rels_path, rels_xml) - - # Copy all files except removed object, its rels, and files we're updating - obj_rels_path = self._gen_rels_path_from_metadata(metadata) - files_to_skip = {get_epc_content_type_path(), metadata.file_path} - if obj_rels_path: - files_to_skip.add(obj_rels_path) - files_to_skip.update(rels_updates.keys()) + file_allready_exists = False + with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: + temp_path = temp_file.name + with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as zf: + epc_content_type = None + # Copy all existing files except the one being updated (if update) and its .rels file + with self._zip_accessor.get_zip_file() as source_zf: for item in source_zf.infolist(): - if item.filename not in files_to_skip: - buffer = source_zf.read(item.filename) - target_zf.writestr(item, buffer) - + # logging.debug( + # f"Test {get_epc_content_type_path() in item.filename} with {item.filename} and {get_epc_content_type_path()} " + # ) + if get_epc_content_type_path() in item.filename: + epc_content_type = source_zf.read(item.filename) + elif item.filename != file_path: + data = source_zf.read(item.filename) + zf.writestr(item, data) + else: + file_allready_exists = True + + # Write new/updated object data + obj_xml_bytes = serialize_xml(obj) + zf.writestr(file_path, obj_xml_bytes) + + if not file_allready_exists: + ct_object = None + if epc_content_type is not None: + # logging.debug("Existing content type found, adding new object to it") + # add the new object to the existing content type and write it + ct_object = read_energyml_xml_bytes(epc_content_type, Types) + # logging.debug("Existing content type before adding object: " + str(ct_object)) + ct_object.override.append( + Override(part_name=file_path, content_type=get_content_type_from_class(obj)) + ) + if ct_object is None: + # logging.debug("No existing content type found, generating new one from metadata manager") + ct_object = self._metadata_mgr.get_content_type(zf) + # logging.debug("New content type after adding object: " + str(ct_object)) + zf.writestr(get_epc_content_type_path(), serialize_xml(ct_object)) + # logging.debug("Written content type to EPC with new object : " + serialize_xml(ct_object)) + elif epc_content_type is not None: + zf.writestr(get_epc_content_type_path(), epc_content_type) # Replace original shutil.move(temp_path, self.epc_file_path) - self._reopen_persistent_zip() - + self._zip_accessor.reopen_persistent_zip() except Exception as e: - if os.path.exists(temp_path): - os.unlink(temp_path) - logging.error(f"Failed to remove object from EPC file: {e}") - raise + raise IOError(f"Failed to write object to EPC: {e}") + + # adding the metadata to the metadata manager (after writing the file to ensure we have the correct export version for path generation) + last_update = get_object_attribute_advanced(obj, "citation.lastUpdate") + if last_update is None and isinstance(last_update, str): + last_update = date_to_datetime(last_update) + self._metadata_mgr.add_metadata(EpcObjectMetadata(uri=uri, title=get_obj_title(obj), last_changed=last_update)) + + # update relationships if needed + if self.rels_update_mode == RelsUpdateMode.UPDATE_AT_MODIFICATION: + if file_allready_exists: + self._rels_mgr.update_rels_for_modified_object(obj, identifier) + else: + self._rels_mgr.update_rels_for_new_object(obj, identifier) + + return identifier + + def delete_object(self, identifier: Union[str, Uri, Any]) -> bool: + # 1. Validate identifier and check if object exists + # 2. Update rels by removing from current object rels the "Destination" relationships to the deleted object and from other objects rels the "Source" relationships to the deleted object (depending on rels_update_mode) + # 3. Remove object data and metadata from the EPC file in a temporary file and then replace original to minimize I/O + # 4. Return True if deletion was successful, False otherwise + _id = self._id_from_uri_or_identifier(identifier=identifier) + if _id is None: + logging.warning(f"Invalid identifier provided for deletion: {identifier}") + return False + metadata = self._metadata_mgr.get_metadata(_id) + if metadata is None: + logging.warning(f"Object with identifier {_id} not found in metadata, cannot delete") + return False - def _update_content_types_xml( - self, source_zip: zipfile.ZipFile, metadata: EpcObjectMetadata, add: bool = True - ) -> str: - """Update [Content_Types].xml to add or remove object entry. + if self.rels_update_mode == RelsUpdateMode.UPDATE_AT_MODIFICATION: + self._rels_mgr.update_rels_for_removed_object( + _id + ) # will update content_type when removing the object if needed - Delegates to _metadata_mgr.update_content_types_xml() - """ - return self._metadata_mgr.update_content_types_xml(source_zip, metadata, add) + # update metadata manager to remove the metadata of the deleted object + self._metadata_mgr.remove_metadata(_id) + return True - def _rollback_add_object(self, identifier: Optional[str]) -> None: - """Rollback changes made during failed add_object operation.""" - if identifier and identifier in self._metadata: - metadata = self._metadata[identifier] + def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Optional[np.ndarray]: + pass - # Remove from metadata - del self._metadata[identifier] + def write_array( + self, + proxy: Union[str, Uri, Any], + path_in_external: str, + array: np.ndarray, + ) -> bool: + pass - # Remove from indexes - uuid = metadata.uuid - object_type = metadata.object_type + def get_array_metadata( + self, proxy: Union[str, Uri, Any], path_in_external: Optional[str] = None + ) -> Union[DataArrayMetadata, List[DataArrayMetadata], None]: + pass - if uuid in self._uuid_index and identifier in self._uuid_index[uuid]: - self._uuid_index[uuid].remove(identifier) - if not self._uuid_index[uuid]: - del self._uuid_index[uuid] + def list_objects( + self, dataspace: Optional[str] = None, object_type: Optional[str] = None + ) -> List[ResourceMetadata]: + return [m.to_resource_metadata() for m in self._metadata_mgr.list_metadata(qualified_type_filter=object_type)] - if object_type in self._type_index and identifier in self._type_index[object_type]: - self._type_index[object_type].remove(identifier) - if not self._type_index[object_type]: - del self._type_index[object_type] + def get_obj_rels(self, obj: Union[str, Uri, Any]) -> List[Relationship]: + _id = self._id_from_uri_or_identifier(obj) - # Remove from cache - if identifier in self._object_cache: - del self._object_cache[identifier] - if identifier in self._access_order: - self._access_order.remove(identifier) + metadata = self._metadata_mgr.get_metadata(_id) + if metadata is None: + logging.warning(f"Object with identifier {_id} not found in metadata, cannot get relationships") + return [] - def clean_rels(self) -> Dict[str, int]: - """ - Clean all .rels files by removing relationships to objects that no longer exist. + return self._rels_mgr.get_obj_rels(_id) - This method: - 1. Scans all .rels files in the EPC - 2. For each relationship, checks if the target object exists - 3. Removes relationships pointing to non-existent objects - 4. Removes empty .rels files + def close(self) -> None: + """Close the persistent ZIP file if it's open, recomputing rels first if mode is UPDATE_ON_CLOSE.""" + # Unregister atexit handler to avoid double-close + if getattr(self, "_atexit_registered", False): + atexit.unregister(self._atexit_close) + self._atexit_registered = False - Returns: - Dictionary with statistics: - - 'rels_files_scanned': Number of .rels files examined - - 'relationships_removed': Number of orphaned relationships removed - - 'rels_files_removed': Number of empty .rels files removed - """ - import tempfile - import shutil + # Recompute all relationships before closing if in UPDATE_ON_CLOSE mode + if self.rels_update_mode == RelsUpdateMode.UPDATE_ON_CLOSE: + try: + self.rebuild_all_rels(clean_first=True) + logging.info("Rebuilt all relationships on close (UPDATE_ON_CLOSE mode)") + except Exception as e: + logging.warning(f"Error rebuilding rels on close: {e}") - stats = { - "rels_files_scanned": 0, - "relationships_removed": 0, - "rels_files_removed": 0, - } + # Delegate to ZIP accessor + self._zip_accessor.close() - # Create temporary file for updated EPC - with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: - temp_path = temp_file.name + def get_object_dependencies(self, identifier: Union[str, Uri]) -> List[str]: + return list(get_dor_identifiers_from_obj(self.get_object(identifier))) - try: - with self._get_zip_file() as source_zip: - with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zip: - # Get all existing object file paths for validation - existing_object_files = {metadata.file_path for metadata in self._metadata.values()} + def start_transaction(self) -> bool: + raise NotImplementedError("Transactions are not implemented in this version of EpcStreamReader") - # Process each file - for item in source_zip.infolist(): - if item.filename.endswith(".rels"): - # Process .rels file - stats["rels_files_scanned"] += 1 - - try: - rels_data = source_zip.read(item.filename) - rels_obj = read_energyml_xml_bytes(rels_data, Relationships) - - if rels_obj and rels_obj.relationship: - # Filter out relationships to non-existent objects - original_count = len(rels_obj.relationship) - - # Keep only relationships where the target exists - # or where the target is external (starts with ../ or http) - valid_relationships = [] - for rel in rels_obj.relationship: - target = rel.target - # Keep external references (HDF5, etc.) and existing objects - if ( - target.startswith("../") - or target.startswith("http") - or target in existing_object_files - or target.lstrip("/") - in existing_object_files # Also check without leading slash - ): - valid_relationships.append(rel) - - removed_count = original_count - len(valid_relationships) - stats["relationships_removed"] += removed_count - - if removed_count > 0: - logging.info( - f"Removed {removed_count} orphaned relationships from {item.filename}" - ) - - # Only write the .rels file if it has remaining relationships - if valid_relationships: - rels_obj.relationship = valid_relationships - updated_rels = serialize_xml(rels_obj) - target_zip.writestr(item.filename, updated_rels) - else: - # Empty .rels file, don't write it - stats["rels_files_removed"] += 1 - logging.info(f"Removed empty .rels file: {item.filename}") - else: - # Empty or invalid .rels, don't copy it - stats["rels_files_removed"] += 1 - - except Exception as e: - logging.warning(f"Failed to process .rels file {item.filename}: {e}") - # Copy as-is on error - data = source_zip.read(item.filename) - target_zip.writestr(item, data) + def commit_transaction(self) -> Tuple[bool, Optional[str]]: + raise NotImplementedError("Transactions are not implemented in this version of EpcStreamReader") - else: - # Copy non-.rels files as-is - data = source_zip.read(item.filename) - target_zip.writestr(item, data) + def rollback_transaction(self) -> bool: + raise NotImplementedError("Transactions are not implemented in this version of EpcStreamReader") - # Replace original file - shutil.move(temp_path, self.epc_file_path) + def __enter__(self): + """Context manager entry.""" + return self - logging.info( - f"Cleaned .rels files: scanned {stats['rels_files_scanned']}, " - f"removed {stats['relationships_removed']} orphaned relationships, " - f"removed {stats['rels_files_removed']} empty .rels files" - ) + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit with cleanup.""" + self.clear_cache() + self.close() + if self.cache_opened_h5 is not None: + try: + self.cache_opened_h5.close() + except Exception: + pass + self.cache_opened_h5 = None - return stats + def __len__(self) -> int: + """Return total number of objects.""" + return len(self._metadata) - except Exception as e: - # Clean up temp file on error - if os.path.exists(temp_path): - os.unlink(temp_path) - raise RuntimeError(f"Failed to clean .rels files: {e}") + def __iter__(self) -> Iterator[str]: + """Iterate over object identifiers.""" + return iter(self._metadata.keys()) - def rebuild_all_rels(self, clean_first: bool = True) -> Dict[str, int]: - """ - Rebuild all .rels files from scratch by analyzing all objects and their references. + # ____ ____ _____ _____ ____________ + # / __ \/ __ \/ _/ | / / |/_ __/ ____/ + # / /_/ / /_/ // / | | / / /| | / / / __/ + # / ____/ _, _// / | |/ / ___ |/ / / /___ + # /_/ /_/ |_/___/ |___/_/ |_/_/ /_____/ - This method: - 1. Optionally cleans existing .rels files first - 2. Loads each object temporarily - 3. Analyzes its Data Object References (DORs) - 4. Creates/updates .rels files with proper SOURCE and DESTINATION relationships + def __del__(self): + """Destructor to ensure persistent ZIP file is closed.""" + try: + self.close() + if self.cache_opened_h5 is not None: + try: + self.cache_opened_h5.close() + except Exception: + pass + self.cache_opened_h5 = None + except Exception: + pass # Ignore errors during cleanup - Args: - clean_first: If True, remove all existing .rels files before rebuilding + def _atexit_close(self) -> None: + """Atexit callback — performs minimal cleanup without rebuilding rels.""" + try: + self._zip_accessor.close() + except Exception: + pass - Returns: - Dictionary with statistics: - - 'objects_processed': Number of objects analyzed - - 'rels_files_created': Number of .rels files created - - 'source_relationships': Number of SOURCE relationships created - - 'destination_relationships': Number of DESTINATION relationships created - - 'parallel_mode': True if parallel processing was used (optional key) - - 'execution_time': Execution time in seconds (optional key) - """ - if self.enable_parallel_rels: - return self._rebuild_all_rels_parallel(clean_first) + def _update_access_order(self, identifier: str) -> None: + """Update access order for LRU cache.""" + if identifier in self._access_order: + self._access_order.remove(identifier) + self._access_order.insert(0, identifier) + + def _id_from_uri_or_identifier( + self, identifier: Union[str, Uri, Any], get_first_if_simple_uuid: bool = True + ) -> Optional[str]: + if identifier is None: + return None + elif isinstance(identifier, str): + if OptimizedRegex.UUID.fullmatch(identifier) is not None: + if not get_first_if_simple_uuid: + logging.warning( + f"Identifier {identifier} is a simple UUID, but get_first_if_simple_uuid is False, cannot resolve to full identifier" + ) + return None + # If it's a simple UUID, we need to find the corresponding identifier from metadata + t_metadata_identifiers = self._metadata_mgr.get_uuid_identifiers(identifier) + if t_metadata_identifiers is not None and len(t_metadata_identifiers) > 0: + return t_metadata_identifiers[ + 0 + ] # If multiple metadata entries for the same UUID, we take the first one (this should not happen in a well-formed EPC file) + else: + logging.warning(f"No metadata found for UUID {identifier}, cannot get relationships") + return None + else: + return identifier + elif isinstance(identifier, Uri): + return identifier.as_identifier() + elif isinstance(identifier, ResourceMetadata): + return self._id_from_uri_or_identifier(identifier.identifier) + elif isinstance(identifier, EpcObjectMetadata): + return self._id_from_uri_or_identifier(identifier.uri) else: - return self._rebuild_all_rels_sequential(clean_first) + # Try to get URI from object + obj_uri = get_obj_uri(obj=identifier, dataspace=None) + if obj_uri is not None: + return obj_uri.as_identifier() + return str(identifier) def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, int]: """ @@ -2848,7 +1879,7 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in # First pass: analyze all objects and build the reference map for identifier in self._metadata: try: - obj = self.get_object_by_identifier(identifier) + obj = self.get_object(identifier) if obj is None: continue @@ -2878,12 +1909,12 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in # Process each object to create SOURCE relationships for identifier in self._metadata: try: - obj = self.get_object_by_identifier(identifier) + obj = self.get_object(identifier) if obj is None: continue # metadata = self._metadata[identifier] - obj_rels_path = self._gen_rels_path_from_identifier(identifier) + obj_rels_path = self._metadata_mgr.gen_rels_path_from_identifier(identifier) # Get all DORs (objects this object references) dors = get_direct_dor_list(obj) @@ -2899,7 +1930,7 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in target_metadata = self._metadata[target_identifier] rel = Relationship( - target=target_metadata.file_path, + target=target_metadata.file_path(export_version=self._metadata_mgr._export_version), type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), id=f"_{identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", ) @@ -2924,7 +1955,7 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in continue target_metadata = self._metadata[target_identifier] - target_rels_path = self._gen_rels_path_from_identifier(target_identifier) + target_rels_path = self._metadata_mgr.gen_rels_path_from_identifier(target_identifier) if not target_rels_path: continue @@ -2935,7 +1966,7 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in source_metadata = self._metadata[source_identifier] rel = Relationship( - target=source_metadata.file_path, + target=source_metadata.file_path(export_version=self._metadata_mgr._export_version), type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), id=f"_{target_identifier}_{get_obj_type(get_obj_usable_class(source_obj))}_{source_identifier}", ) @@ -2955,7 +1986,7 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in # Before writing, preserve EXTERNAL_RESOURCE and other non-SOURCE/DESTINATION relationships # This includes rels files that may not be in rels_files yet - with self._get_zip_file() as zf: + with self._zip_accessor.get_zip_file() as zf: # Check all existing .rels files for filename in zf.namelist(): if not filename.endswith(".rels"): @@ -2990,7 +2021,7 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in temp_path = temp_file.name try: - with self._get_zip_file() as source_zip: + with self._zip_accessor.get_zip_file() as source_zip: with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zip: # Copy all non-.rels files for item in source_zip.infolist(): @@ -3005,7 +2036,7 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in # Replace original file shutil.move(temp_path, self.epc_file_path) - self._reopen_persistent_zip() + self._zip_accessor.reopen_persistent_zip() logging.info( f"Rebuilt .rels files: processed {stats['objects_processed']} objects, " @@ -3035,8 +2066,6 @@ def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int] This bypasses Python's GIL for CPU-intensive XML parsing and provides significant speedup for large EPCs (tested with 80+ objects). """ - import tempfile - import shutil import time from multiprocessing import Pool, cpu_count @@ -3056,7 +2085,10 @@ def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int] # Prepare work items for parallel processing # Pass metadata as dict (serializable) instead of keeping references metadata_dict = {k: v for k, v in self._metadata.items()} - work_items = [(identifier, str(self.epc_file_path), metadata_dict) for identifier in self._metadata] + export_version = self._metadata_mgr._export_version + work_items = [ + ((identifier, str(self.epc_file_path), metadata_dict), export_version) for identifier in self._metadata + ] # Determine optimal number of workers based on available CPUs and workload # Don't spawn more workers than CPUs; use user-configurable ratio for workload per worker @@ -3069,81 +2101,76 @@ def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int] # ============================================================================ results = [] with Pool(processes=num_workers) as pool: - results = pool.map(_process_object_for_rels_worker, work_items) + results = pool.starmap(process_object_for_rels_worker, work_items) # ============================================================================ - # PHASE 2: SEQUENTIAL - Aggregate worker results + # PHASE 2: SEQUENTIAL - Aggregate worker results and build SOURCE relationships # ============================================================================ # Build data structures for subsequent phases: # - reverse_references: Map target objects to their sources (for DESTINATION rels) # - rels_files: Accumulate all relationships by file path - # - object_types: Cache object types to eliminate redundant loads in Phase 3 - reverse_references: Dict[str, List[Tuple[str, str]]] = {} + reverse_references: Dict[str, List[str]] = {} rels_files: Dict[str, Relationships] = {} - object_types: Dict[str, str] = {} for result in results: if result is None: continue identifier = result["identifier"] - obj_type = result["object_type"] - source_rels = result["source_rels"] - dor_targets = result["dor_targets"] - - # Cache object type - object_types[identifier] = obj_type + dest_obj_identifiers = result["dest_obj_identifiers"] stats["objects_processed"] += 1 - # Convert dicts back to Relationship objects - if source_rels: - obj_rels_path = self._gen_rels_path_from_identifier(identifier) - if obj_rels_path: - relationships = [] - for rel_dict in source_rels: - rel = Relationship( - target=rel_dict["target"], - type_value=rel_dict["type_value"], - id=rel_dict["id"], - ) - relationships.append(rel) - stats["source_relationships"] += 1 + # Create SOURCE relationships for this object + obj_rels_path = self._metadata_mgr.gen_rels_path_from_identifier(identifier) + if obj_rels_path and dest_obj_identifiers: + if obj_rels_path not in rels_files: + rels_files[obj_rels_path] = Relationships(relationship=[]) + + for target_identifier in dest_obj_identifiers: + # Verify target exists in metadata + if target_identifier not in self._metadata: + continue + + target_metadata = self._metadata[target_identifier] + target_class = get_class_from_content_type(target_metadata.content_type) + target_type = get_obj_type(target_class) if target_class else "Unknown" - if obj_rels_path not in rels_files: - rels_files[obj_rels_path] = Relationships(relationship=[]) - rels_files[obj_rels_path].relationship.extend(relationships) + # Create SOURCE relationship + rel = Relationship( + target=target_metadata.file_path(export_version=export_version), + type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + id=f"_{identifier}_{target_type}_{target_identifier}", + ) + rels_files[obj_rels_path].relationship.append(rel) + stats["source_relationships"] += 1 - # Build reverse reference map for DESTINATION relationships - # dor_targets now contains (target_id, target_type) tuples - for target_identifier, target_type in dor_targets: - if target_identifier not in reverse_references: - reverse_references[target_identifier] = [] - reverse_references[target_identifier].append((identifier, obj_type)) + # Build reverse reference map for DESTINATION relationships + if target_identifier not in reverse_references: + reverse_references[target_identifier] = [] + reverse_references[target_identifier].append(identifier) # ============================================================================ - # PHASE 3: SEQUENTIAL - Create DESTINATION relationships (zero object loading!) + # PHASE 3: SEQUENTIAL - Create DESTINATION relationships # ============================================================================ - # Use cached object types from Phase 2 to build DESTINATION relationships - # without reloading any objects. This optimization is critical for performance. for target_identifier, source_list in reverse_references.items(): try: if target_identifier not in self._metadata: continue - target_rels_path = self._gen_rels_path_from_identifier(target_identifier) + target_rels_path = self._metadata_mgr.gen_rels_path_from_identifier(target_identifier) if not target_rels_path: continue - # Use cached object types instead of loading objects! - for source_identifier, source_type in source_list: + for source_identifier in source_list: try: source_metadata = self._metadata[source_identifier] + source_class = get_class_from_content_type(source_metadata.content_type) + source_type = get_obj_type(source_class) if source_class else "Unknown" - # No object loading needed - we have all the type info from Phase 2! rel = Relationship( - target=source_metadata.file_path, + target=source_metadata.file_path(export_version=export_version), type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), id=f"_{target_identifier}_{source_type}_{source_identifier}", ) @@ -3165,7 +2192,7 @@ def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int] # PHASE 4: SEQUENTIAL - Preserve non-object relationships # ============================================================================ # Preserve EXTERNAL_RESOURCE and other non-standard relationship types - with self._get_zip_file() as zf: + with self._zip_accessor.get_zip_file() as zf: for filename in zf.namelist(): if not filename.endswith(".rels"): continue @@ -3199,7 +2226,7 @@ def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int] temp_path = temp_file.name try: - with self._get_zip_file() as source_zip: + with self._zip_accessor.get_zip_file() as source_zip: with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zip: # Copy all non-.rels files for item in source_zip.infolist(): @@ -3214,7 +2241,7 @@ def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int] # Replace original file shutil.move(temp_path, self.epc_file_path) - self._reopen_persistent_zip() + self._zip_accessor.reopen_persistent_zip() execution_time = time.time() - start_time stats["execution_time"] = execution_time @@ -3234,70 +2261,17 @@ def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int] os.unlink(temp_path) raise RuntimeError(f"Failed to rebuild .rels files (parallel): {e}") - def __repr__(self) -> str: - """String representation.""" - return ( - f"EpcStreamReader(path='{self.epc_file_path}', " - f"objects={len(self._metadata)}, " - f"cached={len(self._object_cache)}, " - f"cache_hit_rate={self.stats.cache_hit_rate:.1f}%)" - ) - - def dumps_epc_content_and_files_lists(self): - """Dump EPC content and files lists for debugging.""" - content_list = [] - file_list = [] - - with self._get_zip_file() as zf: - file_list = zf.namelist() - - for item in zf.infolist(): - content_list.append(f"{item.filename} - {item.file_size} bytes") - - return { - "content_list": sorted(content_list), - "file_list": sorted(file_list), - } - - -# Utility functions for backward compatibility - - -def read_epc_stream(epc_file_path: Union[str, Path], **kwargs) -> EpcStreamReader: - """ - Factory function to create EpcStreamReader instance. - - Args: - epc_file_path: Path to EPC file - **kwargs: Additional arguments for EpcStreamReader - - Returns: - EpcStreamReader instance - """ - return EpcStreamReader(epc_file_path, **kwargs) - - -def convert_to_streaming_epc(epc: Epc, output_path: Optional[Union[str, Path]] = None) -> EpcStreamReader: - """ - Convert standard Epc to streaming version. - - Args: - epc: Standard Epc instance - output_path: Optional path to save EPC file - - Returns: - EpcStreamReader instance - """ - if output_path is None and epc.epc_file_path: - output_path = epc.epc_file_path - elif output_path is None: - raise ValueError("Output path must be provided if EPC doesn't have a file path") - - # Export EPC to file if needed - if not Path(output_path).exists(): - epc.export_file(str(output_path)) + # ================================================================================= + # Retro compatibility aliases (to avoid breaking changes in tests and example code) + # ================================================================================= + def remove_object(self, identifier: Union[str, Uri, Any]) -> bool: + """Alias for delete_object for backward compatibility.""" + return self.delete_object(identifier) - return EpcStreamReader(output_path) + def update_object(self, obj: Any) -> Optional[str]: + """Alias for put_object for backward compatibility.""" + return self.put_object(obj) - -__all__ = ["EpcStreamReader", "EpcObjectMetadata", "EpcStreamingStats", "read_epc_stream", "convert_to_streaming_epc"] + def get_object_by_identifier(self, identifier: Union[str, Uri]) -> Optional[Any]: + """Alias for get_object for backward compatibility.""" + return self.get_object(identifier) diff --git a/energyml-utils/src/energyml/utils/epc_stream_old.py b/energyml-utils/src/energyml/utils/epc_stream_old.py new file mode 100644 index 0000000..2a7b98b --- /dev/null +++ b/energyml-utils/src/energyml/utils/epc_stream_old.py @@ -0,0 +1,3572 @@ +# Copyright (c) 2023-2024 Geosiris. +# SPDX-License-Identifier: Apache-2.0 +""" +Memory-efficient EPC file handler for large files. + +This module provides EpcStreamReader - a lazy-loading, memory-efficient alternative +to the standard Epc class for handling very large EPC files without loading all +content into memory at once. +""" + +import tempfile +import shutil +import logging +import os +import zipfile +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional, Any, Iterator, Union, Tuple, TypedDict +from weakref import WeakValueDictionary + +from energyml.opc.opc import ( + Types, + Override, + CoreProperties, + Relationships, + Relationship, + Default, + Created, + Creator, + Identifier, +) +from energyml.utils.data.datasets_io import HDF5FileReader, HDF5FileWriter +from energyml.utils.epc_utils import gen_rels_path_from_obj_path +from energyml.utils.storage_interface import DataArrayMetadata, EnergymlStorageInterface, ResourceMetadata +from energyml.utils.uri import Uri, parse_uri +import h5py +import numpy as np +from energyml.utils.constants import ( + EPCRelsRelationshipType, + OptimizedRegex, + EpcExportVersion, + content_type_to_qualified_type, + get_obj_type_from_content_or_qualified_type, +) +from energyml.utils.epc import ( + gen_energyml_object_path, + gen_rels_path, + get_epc_content_type_path, + gen_core_props_path, +) + +from energyml.utils.introspection import ( + get_class_from_content_type, + get_obj_content_type, + get_obj_identifier, + get_obj_uuid, + get_object_type_for_file_path_from_class, + get_direct_dor_list, + get_obj_type, + get_obj_usable_class, + epoch_to_date, + epoch, + gen_uuid, +) +from energyml.utils.serialization import read_energyml_xml_bytes, serialize_xml +from .xml import is_energyml_content_type +from enum import Enum + + +class RelsUpdateMode(Enum): + """ + Relationship update modes for EPC file management. + + UPDATE_AT_MODIFICATION: Maintain relationships in real-time as objects are added/removed/modified. + This provides the best consistency but may be slower for bulk operations. + + UPDATE_ON_CLOSE: Rebuild all relationships when closing the EPC file. + This is more efficient for bulk operations but relationships are only + consistent after closing. + + MANUAL: No automatic relationship updates. User must manually call rebuild_all_rels(). + This provides maximum control and performance for advanced use cases. + """ + + UPDATE_AT_MODIFICATION = "update_at_modification" + UPDATE_ON_CLOSE = "update_on_close" + MANUAL = "manual" + + +@dataclass(frozen=True) +class EpcObjectMetadata: + """Metadata for an object in the EPC file. + Identifier is generated as uuid.version + """ + + uuid: str + object_type: str + content_type: str + file_path: str + identifier: Optional[str] = None + version: Optional[str] = None + + def __post_init__(self): + if self.identifier is None: + # Generate identifier if not provided + object.__setattr__(self, "identifier", f"{self.uuid}.{self.version or ''}") + + +@dataclass +class EpcStreamingStats: + """Statistics for EPC streaming operations.""" + + total_objects: int = 0 + loaded_objects: int = 0 + cache_hits: int = 0 + cache_misses: int = 0 + bytes_read: int = 0 + + @property + def cache_hit_rate(self) -> float: + """Calculate cache hit rate percentage.""" + total_requests = self.cache_hits + self.cache_misses + return (self.cache_hits / total_requests * 100) if total_requests > 0 else 0.0 + + @property + def memory_efficiency(self) -> float: + """Calculate memory efficiency percentage.""" + return (1 - (self.loaded_objects / self.total_objects)) * 100 if self.total_objects > 0 else 100.0 + + +# =========================================================================================== +# PARALLEL PROCESSING WORKER FUNCTIONS +# =========================================================================================== + +# Configuration constants for parallel processing +_MIN_OBJECTS_PER_WORKER = 10 # Minimum objects to justify spawning a worker +_WORKER_POOL_SIZE_RATIO = 10 # Number of objects per worker process + + +class _WorkerResult(TypedDict): + """Type definition for parallel worker function return value.""" + + identifier: str + object_type: str + source_rels: List[Dict[str, str]] + dor_targets: List[Tuple[str, str]] + + +def _process_object_for_rels_worker(args: Tuple[str, str, Dict[str, EpcObjectMetadata]]) -> Optional[_WorkerResult]: + """ + Worker function for parallel relationship processing (runs in separate process). + + This function is executed in a separate process to compute SOURCE relationships + for a single object. It bypasses Python's GIL for CPU-intensive XML parsing. + + Performance characteristics: + - Each worker process opens its own ZIP file handle + - XML parsing happens independently on separate CPU cores + - Results are serialized back to the main process via pickle + + Args: + args: Tuple containing: + - identifier: Object UUID/identifier to process + - epc_file_path: Absolute path to the EPC file + - metadata_dict: Dictionary of all object metadata (for validation) + + Returns: + Dictionary conforming to _WorkerResult TypedDict, or None if processing fails. + """ + identifier, epc_file_path, metadata_dict = args + + try: + # Open ZIP file in this worker process + metadata = metadata_dict.get(identifier) + if not metadata: + return None + + # Load object from ZIP + with zipfile.ZipFile(epc_file_path, "r") as zf: + obj_data = zf.read(metadata.file_path) + obj_class = get_class_from_content_type(metadata.content_type) + obj = read_energyml_xml_bytes(obj_data, obj_class) + + # Extract object type (cached to avoid reloading in Phase 3) + obj_type = get_obj_type(get_obj_usable_class(obj)) + + # Get all Data Object References (DORs) from this object + data_object_references = get_direct_dor_list(obj) + + # Build SOURCE relationships and track referenced objects + source_rels = [] + dor_targets = [] # Track (target_id, target_type) for reverse references + + for dor in data_object_references: + try: + target_identifier = get_obj_identifier(dor) + if target_identifier not in metadata_dict: + continue + + target_metadata = metadata_dict[target_identifier] + + # Extract target type (needed for relationship ID) + target_type = get_obj_type(get_obj_usable_class(dor)) + dor_targets.append((target_identifier, target_type)) + + # Serialize relationship as dict (Relationship objects aren't picklable) + rel_dict = { + "target": target_metadata.file_path, + "type_value": str(EPCRelsRelationshipType.DESTINATION_OBJECT), + "id": f"_{identifier}_{target_type}_{target_identifier}", + } + source_rels.append(rel_dict) + + except Exception as e: + # Don't fail entire object processing for one bad DOR + logging.debug(f"Skipping invalid DOR in {identifier}: {e}") + + return { + "identifier": identifier, + "object_type": obj_type, + "source_rels": source_rels, + "dor_targets": dor_targets, + } + + except Exception as e: + logging.warning(f"Worker failed to process {identifier}: {e}") + return None + + +# =========================================================================================== +# HELPER CLASSES FOR REFACTORED ARCHITECTURE +# =========================================================================================== + + +class _ZipFileAccessor: + """ + Internal helper class for managing ZIP file access with proper resource management. + + This class handles: + - Persistent ZIP connections when keep_open=True + - On-demand connections when keep_open=False + - Proper cleanup and resource management + - Connection pooling for better performance + """ + + def __init__(self, epc_file_path: Path, keep_open: bool = False): + """ + Initialize the ZIP file accessor. + + Args: + epc_file_path: Path to the EPC file + keep_open: If True, maintains a persistent connection + """ + self.epc_file_path = epc_file_path + self.keep_open = keep_open + self._persistent_zip: Optional[zipfile.ZipFile] = None + + def open_persistent_connection(self) -> None: + """Open a persistent ZIP connection if keep_open is enabled.""" + if self.keep_open and self._persistent_zip is None: + self._persistent_zip = zipfile.ZipFile(self.epc_file_path, "r") + + @contextmanager + def get_zip_file(self) -> Iterator[zipfile.ZipFile]: + """ + Context manager for ZIP file access with proper resource management. + + If keep_open is True, uses the persistent connection. Otherwise opens a new one. + """ + if self.keep_open and self._persistent_zip is not None: + # Use persistent connection, don't close it + yield self._persistent_zip + else: + # Open and close per request + zf = None + try: + zf = zipfile.ZipFile(self.epc_file_path, "r") + yield zf + finally: + if zf is not None: + zf.close() + + def reopen_persistent_zip(self) -> None: + """Reopen persistent ZIP file after modifications to reflect changes.""" + if self.keep_open and self._persistent_zip is not None: + try: + self._persistent_zip.close() + except Exception: + pass + self._persistent_zip = zipfile.ZipFile(self.epc_file_path, "r") + + def close(self) -> None: + """Close the persistent ZIP file if it's open.""" + if self._persistent_zip is not None: + try: + self._persistent_zip.close() + except Exception as e: + logging.debug(f"Error closing persistent ZIP file: {e}") + finally: + self._persistent_zip = None + + +class _MetadataManager: + """ + Internal helper class for managing object metadata, indexing, and queries. + + This class handles: + - Loading metadata from [Content_Types].xml + - Maintaining UUID and type indexes + - Fast metadata queries without loading objects + - Version detection + """ + + def __init__(self, zip_accessor: _ZipFileAccessor, stats: EpcStreamingStats): + """ + Initialize the metadata manager. + + Args: + zip_accessor: ZIP file accessor for reading from EPC + stats: Statistics tracker + """ + self.zip_accessor = zip_accessor + self.stats = stats + + # Object metadata storage + self._metadata: Dict[str, EpcObjectMetadata] = {} # identifier -> metadata + self._uuid_index: Dict[str, List[str]] = {} # uuid -> list of identifiers + self._type_index: Dict[str, List[str]] = {} # object_type -> list of identifiers + self._core_props: Optional[CoreProperties] = None + self._core_props_path: Optional[str] = None + self._export_version = EpcExportVersion.CLASSIC # Store export version + + def set_export_version(self, version: EpcExportVersion) -> None: + """Set the export version.""" + self._export_version = version + + def load_metadata(self) -> None: + """Load object metadata from [Content_Types].xml without loading actual objects.""" + try: + with self.zip_accessor.get_zip_file() as zf: + # Read content types + content_types = self._read_content_types(zf) + + # Process each override entry + for override in content_types.override: + if override.content_type and override.part_name: + if is_energyml_content_type(override.content_type): + self._process_energyml_object_metadata(zf, override) + elif self._is_core_properties(override.content_type): + self._process_core_properties_metadata(override) + + self.stats.total_objects = len(self._metadata) + + except Exception as e: + logging.error(f"Failed to load metadata from EPC file: {e}") + raise + + def _read_content_types(self, zf: zipfile.ZipFile) -> Types: + """Read and parse [Content_Types].xml file.""" + content_types_path = get_epc_content_type_path() + + try: + content_data = zf.read(content_types_path) + self.stats.bytes_read += len(content_data) + return read_energyml_xml_bytes(content_data, Types) + except KeyError: + # Try case-insensitive search + for name in zf.namelist(): + if name.lower() == content_types_path.lower(): + content_data = zf.read(name) + self.stats.bytes_read += len(content_data) + return read_energyml_xml_bytes(content_data, Types) + raise FileNotFoundError("No [Content_Types].xml found in EPC file") + + def _process_energyml_object_metadata(self, zf: zipfile.ZipFile, override: Override) -> None: + """Process metadata for an EnergyML object without loading it.""" + if not override.part_name or not override.content_type: + return + + file_path = override.part_name.lstrip("/") + content_type = override.content_type + + try: + # Quick peek to extract UUID and version without full parsing + uuid, version, obj_type = self._extract_object_info_fast(zf, file_path, content_type) + + if uuid: # Only process if we successfully extracted UUID + metadata = EpcObjectMetadata( + uuid=uuid, object_type=obj_type, content_type=content_type, file_path=file_path, version=version + ) + + # Store in indexes + identifier = metadata.identifier + if identifier: + self._metadata[identifier] = metadata + + # Update UUID index + if uuid not in self._uuid_index: + self._uuid_index[uuid] = [] + self._uuid_index[uuid].append(identifier) + + # Update type index + if obj_type not in self._type_index: + self._type_index[obj_type] = [] + self._type_index[obj_type].append(identifier) + + except Exception as e: + logging.warning(f"Failed to process metadata for {file_path}: {e}") + + def _extract_object_info_fast( + self, zf: zipfile.ZipFile, file_path: str, content_type: str + ) -> Tuple[Optional[str], Optional[str], str]: + """Fast extraction of UUID and version from XML without full parsing.""" + try: + # Read only the beginning of the file for UUID extraction + with zf.open(file_path) as f: + # Read first chunk (usually sufficient for root element) + chunk = f.read(2048) # 2KB should be enough for root element + self.stats.bytes_read += len(chunk) + + chunk_str = chunk.decode("utf-8", errors="ignore") + + # Extract UUID using optimized regex + uuid_match = OptimizedRegex.UUID_NO_GRP.search(chunk_str) + uuid = uuid_match.group(0) if uuid_match else None + + # Extract version if present + version = None + version_patterns = [ + r'object[Vv]ersion["\']?\s*[:=]\s*["\']([^"\']+)', + ] + + for pattern in version_patterns: + import re + + version_match = re.search(pattern, chunk_str) + if version_match: + version = version_match.group(1) + # Ensure version is a string + if not isinstance(version, str): + version = str(version) + break + + # Extract object type from content type + obj_type = get_obj_type_from_content_or_qualified_type(content_type) + + return uuid, version, obj_type + + except Exception as e: + logging.debug(f"Fast extraction failed for {file_path}: {e}") + return None, None, "Unknown" + + def _is_core_properties(self, content_type: str) -> bool: + """Check if content type is CoreProperties.""" + return content_type == "application/vnd.openxmlformats-package.core-properties+xml" + + def _process_core_properties_metadata(self, override: Override) -> None: + """Process core properties metadata.""" + if override.part_name: + self._core_props_path = override.part_name.lstrip("/") + + def get_metadata(self, identifier: str) -> Optional[EpcObjectMetadata]: + """Get metadata for an object by identifier.""" + return self._metadata.get(identifier) + + def get_by_uuid(self, uuid: str) -> List[str]: + """Get all identifiers for objects with the given UUID.""" + return self._uuid_index.get(uuid, []) + + def get_by_type(self, object_type: str) -> List[str]: + """Get all identifiers for objects of the given type.""" + return self._type_index.get(object_type, []) + + def list_metadata(self, object_type: Optional[str] = None) -> List[EpcObjectMetadata]: + """List metadata for all objects, optionally filtered by type.""" + if object_type is None: + return list(self._metadata.values()) + return [self._metadata[identifier] for identifier in self._type_index.get(object_type, [])] + + def add_metadata(self, metadata: EpcObjectMetadata) -> None: + """Add metadata for a new object.""" + identifier = metadata.identifier + if identifier: + self._metadata[identifier] = metadata + + # Update UUID index + if metadata.uuid not in self._uuid_index: + self._uuid_index[metadata.uuid] = [] + self._uuid_index[metadata.uuid].append(identifier) + + # Update type index + if metadata.object_type not in self._type_index: + self._type_index[metadata.object_type] = [] + self._type_index[metadata.object_type].append(identifier) + + self.stats.total_objects += 1 + + def remove_metadata(self, identifier: str) -> Optional[EpcObjectMetadata]: + """Remove metadata for an object. Returns the removed metadata.""" + metadata = self._metadata.pop(identifier, None) + if metadata: + # Update UUID index + if metadata.uuid in self._uuid_index: + self._uuid_index[metadata.uuid].remove(identifier) + if not self._uuid_index[metadata.uuid]: + del self._uuid_index[metadata.uuid] + + # Update type index + if metadata.object_type in self._type_index: + self._type_index[metadata.object_type].remove(identifier) + if not self._type_index[metadata.object_type]: + del self._type_index[metadata.object_type] + + self.stats.total_objects -= 1 + + return metadata + + def contains(self, identifier: str) -> bool: + """Check if an object with the given identifier exists.""" + return identifier in self._metadata + + def __len__(self) -> int: + """Return total number of objects.""" + return len(self._metadata) + + def __iter__(self) -> Iterator[str]: + """Iterate over object identifiers.""" + return iter(self._metadata.keys()) + + def gen_rels_path_from_metadata(self, metadata: EpcObjectMetadata) -> str: + """Generate rels path from object metadata without loading the object.""" + return gen_rels_path_from_obj_path(obj_path=metadata.file_path) + + def gen_rels_path_from_identifier(self, identifier: str) -> Optional[str]: + """Generate rels path from object identifier without loading the object.""" + metadata = self._metadata.get(identifier) + if metadata is None: + return None + return self.gen_rels_path_from_metadata(metadata) + + def get_core_properties(self) -> Optional[CoreProperties]: + """Get core properties (loaded lazily).""" + if self._core_props is None and self._core_props_path: + try: + with self.zip_accessor.get_zip_file() as zf: + core_data = zf.read(self._core_props_path) + self.stats.bytes_read += len(core_data) + self._core_props = read_energyml_xml_bytes(core_data, CoreProperties) + except Exception as e: + logging.error(f"Failed to load core properties: {e}") + + return self._core_props + + def detect_epc_version(self) -> EpcExportVersion: + """Detect EPC packaging version based on file structure.""" + try: + with self.zip_accessor.get_zip_file() as zf: + file_list = zf.namelist() + + # Look for patterns that indicate EXPANDED version + for file_path in file_list: + # Skip metadata files + if ( + file_path.startswith("[Content_Types]") + or file_path.startswith("_rels/") + or file_path.endswith(".rels") + ): + continue + + # Check for namespace_ prefix pattern + if file_path.startswith("namespace_"): + path_parts = file_path.split("/") + if len(path_parts) >= 2: + logging.info(f"Detected EXPANDED EPC version based on path: {file_path}") + return EpcExportVersion.EXPANDED + + # If no EXPANDED patterns found, assume CLASSIC + logging.info("Detected CLASSIC EPC version") + return EpcExportVersion.CLASSIC + + except Exception as e: + logging.warning(f"Failed to detect EPC version, defaulting to CLASSIC: {e}") + return EpcExportVersion.CLASSIC + + def update_content_types_xml( + self, source_zip: zipfile.ZipFile, metadata: EpcObjectMetadata, add: bool = True + ) -> str: + """Update [Content_Types].xml to add or remove object entry. + + Args: + source_zip: Open ZIP file to read from + metadata: Object metadata + add: If True, add entry; if False, remove entry + + Returns: + Updated [Content_Types].xml as string + """ + # Read existing content types + content_types = self._read_content_types(source_zip) + + if add: + # Add new override entry + new_override = Override() + new_override.part_name = f"/{metadata.file_path}" + new_override.content_type = metadata.content_type + content_types.override.append(new_override) + else: + # Remove existing override entry + content_types.override = [ + o for o in content_types.override if o.part_name and o.part_name.lstrip("/") != metadata.file_path + ] + + # Serialize back to XML + return serialize_xml(content_types) + + +class _StructureValidator: + """ + Internal helper class for validating and repairing EPC file structure. + + Ensures compliance with EPC v1.0 specification by validating: + - Presence of [Content_Types].xml with correct default types + - Presence of _rels/.rels with Core Properties relationship + - Presence of Core Properties file + - Proper URI encoding and Part Name conventions + + This class provides idempotent repair operations that can be safely + called multiple times without corrupting the file. + """ + + # EPC specification constants + RELS_CONTENT_TYPE = "application/vnd.openxmlformats-package.relationships+xml" + CORE_PROPS_CONTENT_TYPE = "application/vnd.openxmlformats-package.core-properties+xml" + CORE_PROPS_REL_TYPE = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" + + def __init__( + self, zip_accessor: _ZipFileAccessor, metadata_manager: _MetadataManager, export_version: EpcExportVersion + ): + """ + Initialize the structure validator. + + Args: + zip_accessor: ZIP file accessor for reading/writing + metadata_manager: Metadata manager for object lookups + export_version: EPC export version + """ + self.zip_accessor = zip_accessor + self.metadata_manager = metadata_manager + self.export_version = export_version + + def validate_and_repair(self, is_new_file: bool = False) -> Dict[str, bool]: + """ + Validate EPC structure and repair if necessary. + + This method is idempotent - can be called multiple times safely. + + Args: + is_new_file: If True, bootstrap a complete minimal structure + + Returns: + Dictionary with validation/repair results: + { + 'content_types_ok': bool, + 'root_rels_ok': bool, + 'core_props_ok': bool, + 'repaired': bool + } + """ + results = {"content_types_ok": False, "root_rels_ok": False, "core_props_ok": False, "repaired": False} + + if is_new_file: + logging.info("[EPC Structure] Bootstrapping new EPC file with minimal valid structure") + self._bootstrap_new_file() + results.update({"content_types_ok": True, "root_rels_ok": True, "core_props_ok": True, "repaired": True}) + else: + logging.info("[EPC Structure] Validating existing EPC file structure") + + # Check Content Types + results["content_types_ok"] = self._validate_content_types() + if not results["content_types_ok"]: + logging.warning("[EPC Structure] Content Types invalid, repairing...") + self._repair_content_types() + results["repaired"] = True + + # Check Root Relationships + results["root_rels_ok"] = self._validate_root_relationships() + if not results["root_rels_ok"]: + logging.warning("[EPC Structure] Root relationships invalid, repairing...") + self._repair_root_relationships() + results["repaired"] = True + + # Check Core Properties + results["core_props_ok"] = self._validate_core_properties() + if not results["core_props_ok"]: + logging.warning("[EPC Structure] Core Properties missing or invalid, repairing...") + self._repair_core_properties() + results["repaired"] = True + + if results["repaired"]: + logging.info("[EPC Structure] Structure validation complete - repairs applied") + else: + logging.info("[EPC Structure] Structure validation complete - no repairs needed") + + return results + + def _bootstrap_new_file(self) -> None: + """Bootstrap a new EPC file with complete minimal structure.""" + with zipfile.ZipFile(self.zip_accessor.epc_file_path, "w") as zf: + # 1. Create Core Properties + core_props = self._create_default_core_properties() + core_props_path = gen_core_props_path(self.export_version) + core_props_xml = serialize_xml(core_props) + zf.writestr(core_props_path, core_props_xml) + logging.info(f"[EPC Structure] Created Core Properties at {core_props_path}") + + # 2. Create Content Types with defaults + content_types = Types( + default=[ + Default(extension="rels", content_type=self.RELS_CONTENT_TYPE), + Default(extension="xml", content_type="application/xml"), + ], + override=[Override(part_name=f"/{core_props_path}", content_type=self.CORE_PROPS_CONTENT_TYPE)], + ) + content_types_xml = serialize_xml(content_types) + zf.writestr(get_epc_content_type_path(), content_types_xml) + logging.info("[EPC Structure] Created [Content_Types].xml with default types") + + # 3. Create Root Relationships + root_rels = Relationships( + relationship=[ + Relationship(id="CoreProperties", type_value=self.CORE_PROPS_REL_TYPE, target=core_props_path) + ] + ) + root_rels_xml = serialize_xml(root_rels) + zf.writestr("_rels/.rels", root_rels_xml) + logging.info("[EPC Structure] Created _rels/.rels with Core Properties relationship") + + # Update metadata manager + self.metadata_manager._core_props = core_props + self.metadata_manager._core_props_path = core_props_path + + def _validate_content_types(self) -> bool: + """Validate [Content_Types].xml structure.""" + try: + with self.zip_accessor.get_zip_file() as zf: + content_types = self.metadata_manager._read_content_types(zf) + + # Check for .rels default + has_rels_default = any( + d.extension == "rels" and d.content_type == self.RELS_CONTENT_TYPE + for d in (content_types.default or []) + ) + + if not has_rels_default: + logging.warning("[EPC Structure] Missing or incorrect .rels default content type") + return False + + # Check for Core Properties override + core_props_path = gen_core_props_path(self.export_version) + has_core_props = any( + o.part_name and o.part_name.lstrip("/") == core_props_path for o in (content_types.override or []) + ) + + if not has_core_props: + logging.warning("[EPC Structure] Core Properties not declared in Content Types") + return False + + return True + + except Exception as e: + logging.error(f"[EPC Structure] Error validating Content Types: {e}") + return False + + def _validate_root_relationships(self) -> bool: + """Validate _rels/.rels structure.""" + try: + with self.zip_accessor.get_zip_file() as zf: + try: + rels_xml = zf.read("_rels/.rels") + root_rels = read_energyml_xml_bytes(rels_xml, Relationships) + + # Check for Core Properties relationship + has_core_props_rel = any( + r.type_value == self.CORE_PROPS_REL_TYPE for r in (root_rels.relationship or []) + ) + + if not has_core_props_rel: + logging.warning("[EPC Structure] Core Properties relationship missing from root rels") + return False + + return True + + except KeyError: + logging.warning("[EPC Structure] _rels/.rels file missing") + return False + + except Exception as e: + logging.error(f"[EPC Structure] Error validating root relationships: {e}") + return False + + def _validate_core_properties(self) -> bool: + """Validate Core Properties file existence.""" + try: + core_props_path = gen_core_props_path(self.export_version) + with self.zip_accessor.get_zip_file() as zf: + try: + zf.getinfo(core_props_path) + return True + except KeyError: + logging.warning(f"[EPC Structure] Core Properties file missing: {core_props_path}") + return False + + except Exception as e: + logging.error(f"[EPC Structure] Error validating Core Properties: {e}") + return False + + def _repair_content_types(self) -> None: + """Repair [Content_Types].xml.""" + try: + with self.zip_accessor.get_zip_file() as source_zf: + try: + content_types = self.metadata_manager._read_content_types(source_zf) + except: + # Create new if doesn't exist + content_types = Types() + + # Ensure .rels default + if not content_types.default: + content_types.default = [] + + has_rels = any(d.extension == "rels" for d in content_types.default) + if not has_rels: + content_types.default.append(Default(extension="rels", content_type=self.RELS_CONTENT_TYPE)) + logging.info("[EPC Structure] Added .rels default content type") + + # Ensure Core Properties override + if not content_types.override: + content_types.override = [] + + core_props_path = gen_core_props_path(self.export_version) + has_core_props = any( + o.part_name and o.part_name.lstrip("/") == core_props_path for o in content_types.override + ) + + if not has_core_props: + content_types.override.append( + Override(part_name=f"/{core_props_path}", content_type=self.CORE_PROPS_CONTENT_TYPE) + ) + logging.info("[EPC Structure] Added Core Properties to Content Types") + + # Write back + self._write_to_zip(get_epc_content_type_path(), serialize_xml(content_types)) + + except Exception as e: + logging.error(f"[EPC Structure] Error repairing Content Types: {e}") + raise + + def _repair_root_relationships(self) -> None: + """Repair _rels/.rels.""" + try: + core_props_path = gen_core_props_path(self.export_version) + + with self.zip_accessor.get_zip_file() as source_zf: + try: + rels_xml = source_zf.read("_rels/.rels") + root_rels = read_energyml_xml_bytes(rels_xml, Relationships) + except: + root_rels = Relationships(relationship=[]) + + # Ensure Core Properties relationship exists + if not root_rels.relationship: + root_rels.relationship = [] + + has_core_props = any(r.type_value == self.CORE_PROPS_REL_TYPE for r in root_rels.relationship) + + if not has_core_props: + root_rels.relationship.append( + Relationship(id="CoreProperties", type_value=self.CORE_PROPS_REL_TYPE, target=core_props_path) + ) + logging.info("[EPC Structure] Added Core Properties relationship to root rels") + + # Write back + self._write_to_zip("_rels/.rels", serialize_xml(root_rels)) + + except Exception as e: + logging.error(f"[EPC Structure] Error repairing root relationships: {e}") + raise + + def _repair_core_properties(self) -> None: + """Repair Core Properties file.""" + try: + core_props_path = gen_core_props_path(self.export_version) + + # Check if exists + try: + with self.zip_accessor.get_zip_file() as zf: + zf.getinfo(core_props_path) + return # Already exists + except KeyError: + pass + + # Create new Core Properties + core_props = self._create_default_core_properties() + self._write_to_zip(core_props_path, serialize_xml(core_props)) + logging.info(f"[EPC Structure] Created missing Core Properties at {core_props_path}") + + # Update metadata manager + self.metadata_manager._core_props = core_props + self.metadata_manager._core_props_path = core_props_path + + except Exception as e: + logging.error(f"[EPC Structure] Error repairing Core Properties: {e}") + raise + + def _create_default_core_properties(self) -> CoreProperties: + """Create default Core Properties object.""" + return CoreProperties( + created=Created(any_element=epoch_to_date(epoch())), + creator=Creator(any_element="energyml-utils EpcStreamReader (Geosiris)"), + identifier=Identifier(any_element=f"urn:uuid:{gen_uuid()}"), + version="1.0", + ) + + def _write_to_zip(self, path: str, content: str) -> None: + """Write content to ZIP file using atomic operation.""" + import tempfile + + temp_path = None + try: + # Create temporary file + with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: + temp_path = temp_file.name + + # Copy all files except the one we're updating + with self.zip_accessor.get_zip_file() as source_zf: + with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: + for item in source_zf.infolist(): + if item.filename != path: + target_zf.writestr(item, source_zf.read(item.filename)) + + # Write new content + target_zf.writestr(path, content) + + # Replace original + import shutil + + shutil.move(temp_path, str(self.zip_accessor.epc_file_path)) + temp_path = None + + # Reopen persistent connection if needed + self.zip_accessor.reopen_persistent_zip() + + finally: + if temp_path and Path(temp_path).exists(): + Path(temp_path).unlink() + + +class _RelationshipManager: + """ + Internal helper class for managing relationships between objects. + + This class handles: + - Reading relationships from .rels files + - Writing relationship updates + - Supporting 3 update modes (UPDATE_AT_MODIFICATION, UPDATE_ON_CLOSE, MANUAL) + - Preserving EXTERNAL_RESOURCE relationships + - Rebuilding all relationships + """ + + def __init__( + self, + zip_accessor: _ZipFileAccessor, + metadata_manager: _MetadataManager, + stats: EpcStreamingStats, + export_version: EpcExportVersion, + rels_update_mode: RelsUpdateMode, + ): + """ + Initialize the relationship manager. + + Args: + zip_accessor: ZIP file accessor for reading/writing + metadata_manager: Metadata manager for object lookups + stats: Statistics tracker + export_version: EPC export version + rels_update_mode: Relationship update mode + """ + self.zip_accessor = zip_accessor + self.metadata_manager = metadata_manager + self.stats = stats + self.export_version = export_version + self.rels_update_mode = rels_update_mode + + # Additional rels management (for user-added relationships) + self.additional_rels: Dict[str, List[Relationship]] = {} + + def get_obj_rels(self, obj_identifier: str, rels_path: Optional[str] = None) -> List[Relationship]: + """ + Get all relationships for a given object. + Merges relationships from the EPC file with in-memory additional relationships. + """ + rels = [] + + # Read rels from EPC file + if rels_path is None: + rels_path = self.metadata_manager.gen_rels_path_from_identifier(obj_identifier) + + if rels_path is not None: + with self.zip_accessor.get_zip_file() as zf: + try: + rels_data = zf.read(rels_path) + self.stats.bytes_read += len(rels_data) + relationships = read_energyml_xml_bytes(rels_data, Relationships) + rels.extend(relationships.relationship) + except KeyError: + # No rels file found for this object + pass + + # Merge with in-memory additional relationships + if obj_identifier in self.additional_rels: + rels.extend(self.additional_rels[obj_identifier]) + + return rels + + def update_rels_for_new_object(self, obj: Any, obj_identifier: str) -> None: + """Update relationships when a new object is added (UPDATE_AT_MODIFICATION mode).""" + metadata = self.metadata_manager.get_metadata(obj_identifier) + if not metadata: + logging.warning(f"Metadata not found for {obj_identifier}") + return + + # Get all objects this new object references + direct_dors = get_direct_dor_list(obj) + + # Build SOURCE relationships for this object + source_relationships = [] + dest_updates: Dict[str, Relationship] = {} + + for dor in direct_dors: + try: + target_identifier = get_obj_identifier(dor) + if not self.metadata_manager.contains(target_identifier): + continue + + target_metadata = self.metadata_manager.get_metadata(target_identifier) + if not target_metadata: + continue + + # Create SOURCE relationship : current is referenced by + source_rel = Relationship( + target=metadata.file_path, + type_value=str(EPCRelsRelationshipType.SOURCE_OBJECT), + id=f"_{gen_uuid()}", + ) + source_relationships.append(source_rel) + + # Create DESTINATION relationship current depends on target + dest_rel = Relationship( + target=target_metadata.file_path, + type_value=str(EPCRelsRelationshipType.DESTINATION_OBJECT), + id=f"_{gen_uuid()}", + ) + dest_updates[target_identifier] = dest_rel + + except Exception as e: + logging.warning(f"Failed to create relationship for DOR: {e}") + + # Write updates + self.write_rels_updates(obj_identifier, source_relationships, dest_updates) + + def update_rels_for_modified_object(self, obj: Any, obj_identifier: str, old_dors: List[Any]) -> None: + """Update relationships when an object is modified (UPDATE_AT_MODIFICATION mode).""" + metadata = self.metadata_manager.get_metadata(obj_identifier) + if not metadata: + logging.warning(f"Metadata not found for {obj_identifier}") + return + + # Get new DORs + new_dors = get_direct_dor_list(obj) + + # Convert to sets of identifiers for comparison + old_dor_ids = { + get_obj_identifier(dor) for dor in old_dors if self.metadata_manager.contains(get_obj_identifier(dor)) + } + new_dor_ids = { + get_obj_identifier(dor) for dor in new_dors if self.metadata_manager.contains(get_obj_identifier(dor)) + } + + # Find added and removed references + added_dor_ids = new_dor_ids - old_dor_ids + removed_dor_ids = old_dor_ids - new_dor_ids + + # Build new SOURCE relationships + source_relationships = [] + dest_updates: Dict[str, Relationship] = {} + + # Create relationships for all new DORs + for dor in new_dors: + target_identifier = get_obj_identifier(dor) + if not self.metadata_manager.contains(target_identifier): + continue + + target_metadata = self.metadata_manager.get_metadata(target_identifier) + if not target_metadata: + continue + + # SOURCE relationship : current is referenced by + source_rel = Relationship( + target=metadata.file_path, + type_value=str(EPCRelsRelationshipType.SOURCE_OBJECT), + id=f"_{gen_uuid()}", + ) + source_relationships.append(source_rel) + + # DESTINATION relationship (for added DORs only) current depends on target + if target_identifier in added_dor_ids: + dest_rel = Relationship( + target=target_metadata.file_path, + type_value=str(EPCRelsRelationshipType.DESTINATION_OBJECT), + id=f"_{gen_uuid()}", + ) + dest_updates[target_identifier] = dest_rel + + # For removed DORs, remove DESTINATION relationships + removals: Dict[str, str] = {} + for removed_id in removed_dor_ids: + removals[removed_id] = f"_{removed_id}_.*_{obj_identifier}" + + # Write updates + self.write_rels_updates(obj_identifier, source_relationships, dest_updates, removals) + + def update_rels_for_removed_object(self, obj_identifier: str, obj: Optional[Any] = None) -> None: + """Update relationships when an object is removed (UPDATE_AT_MODIFICATION mode).""" + if obj is None: + # Object must be provided for removal + logging.warning(f"Cannot update rels for removed object {obj_identifier}: object not provided") + return + + # Get all objects this object references + direct_dors = get_direct_dor_list(obj) + + # Build removal patterns for DESTINATION relationships + removals: Dict[str, str] = {} + for dor in direct_dors: + try: + target_identifier = get_obj_identifier(dor) + if not self.metadata_manager.contains(target_identifier): + continue + + removals[target_identifier] = f"_{target_identifier}_.*_{obj_identifier}" + + except Exception as e: + logging.warning(f"Failed to process DOR for removal: {e}") + + # Write updates + self.write_rels_updates(obj_identifier, [], {}, removals, delete_source_rels=True) + + def write_rels_updates( + self, + source_identifier: str, + destination_relationships: List[Relationship], + source_updates: Dict[str, Relationship], + removals: Optional[Dict[str, str]] = None, + delete_source_rels: bool = False, + ) -> None: + """Write relationship updates to the EPC file efficiently.""" + + removals = removals or {} + rels_updates: Dict[str, str] = {} + files_to_delete: List[str] = [] + + with self.zip_accessor.get_zip_file() as zf: + # 1. Handle source object's rels file + if not delete_source_rels: + dest_rels_path = self.metadata_manager.gen_rels_path_from_identifier(source_identifier) + if dest_rels_path: + # Read existing rels (excluding DESTINATION_OBJECT type) + existing_rels = [] + try: + if dest_rels_path in zf.namelist(): + rels_data = zf.read(dest_rels_path) + existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels_obj and existing_rels_obj.relationship: + # Keep only non-DESTINATION relationships that will be re-generated from the object + existing_rels = [ + r + for r in existing_rels_obj.relationship + if r.type_value != str(EPCRelsRelationshipType.DESTINATION_OBJECT) + ] + except Exception: + pass + + # Combine with new DESTINATION relationships + all_rels = existing_rels + destination_relationships + if all_rels: + rels_updates[dest_rels_path] = serialize_xml(Relationships(relationship=all_rels)) + elif dest_rels_path in zf.namelist() and not all_rels: + files_to_delete.append(dest_rels_path) + else: + # Mark dest rels file for deletion + dest_rels_path = self.metadata_manager.gen_rels_path_from_identifier(source_identifier) + if dest_rels_path: + files_to_delete.append(dest_rels_path) + + # 2. Handle SOURCE updates the objects that refers to the current + for target_identifier, source_rel in source_updates.items(): + target_rels_path = self.metadata_manager.gen_rels_path_from_identifier(target_identifier) + if not target_rels_path: + continue + + # Read existing rels + existing_rels = [] + try: + if target_rels_path in zf.namelist(): + rels_data = zf.read(target_rels_path) + existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels_obj and existing_rels_obj.relationship: + existing_rels = list(existing_rels_obj.relationship) + except Exception: + pass + + # Add new SOURCE relationship if not already present + rel_exists = any( + r.target == source_rel.target and r.type_value == source_rel.type_value for r in existing_rels + ) + + if not rel_exists: + existing_rels.append(source_rel) + rels_updates[target_rels_path] = serialize_xml(Relationships(relationship=existing_rels)) + + # 3. Handle removals + for target_identifier, pattern in removals.items(): + target_rels_path = self.metadata_manager.gen_rels_path_from_identifier(target_identifier) + if not target_rels_path: + continue + + # Read existing rels + existing_rels = [] + try: + if target_rels_path in zf.namelist(): + rels_data = zf.read(target_rels_path) + existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels_obj and existing_rels_obj.relationship: + existing_rels = list(existing_rels_obj.relationship) + except Exception: + pass + + # Filter out relationships matching the pattern + regex = re.compile(pattern) + filtered_rels = [r for r in existing_rels if not (r.id and regex.match(r.id))] + + if len(filtered_rels) != len(existing_rels): + if filtered_rels: + rels_updates[target_rels_path] = serialize_xml(Relationships(relationship=filtered_rels)) + else: + files_to_delete.append(target_rels_path) + + # Write updates to EPC file + if rels_updates or files_to_delete: + with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: + temp_path = temp_file.name + + try: + with self.zip_accessor.get_zip_file() as source_zf: + with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: + # Copy all files except those to delete or update + files_to_skip = set(files_to_delete) + for item in source_zf.infolist(): + if item.filename not in files_to_skip and item.filename not in rels_updates: + data = source_zf.read(item.filename) + target_zf.writestr(item, data) + + # Write updated rels files + for rels_path, rels_xml in rels_updates.items(): + target_zf.writestr(rels_path, rels_xml) + + # Replace original + shutil.move(temp_path, self.zip_accessor.epc_file_path) + self.zip_accessor.reopen_persistent_zip() + + except Exception as e: + if os.path.exists(temp_path): + os.unlink(temp_path) + logging.error(f"Failed to write rels updates: {e}") + raise + + def compute_object_rels(self, obj: Any, obj_identifier: str) -> List[Relationship]: + """ + Compute relationships for a given object (SOURCE relationships). + This object references other objects through DORs. + + Args: + obj: The EnergyML object + obj_identifier: The identifier of the object + + Returns: + List of Relationship objects for this object's .rels file + """ + rels = [] + + # Get all DORs (Data Object References) in this object + direct_dors = get_direct_dor_list(obj) + + for dor in direct_dors: + try: + target_identifier = get_obj_identifier(dor) + + # Get target file path from metadata without processing DOR + # The relationship target should be the object's file path, not its rels path + if self.metadata_manager.contains(target_identifier): + target_metadata = self.metadata_manager.get_metadata(target_identifier) + if target_metadata: + target_path = target_metadata.file_path + else: + target_path = gen_energyml_object_path(dor, self.export_version) + else: + # Fall back to generating path from DOR if metadata not found + target_path = gen_energyml_object_path(dor, self.export_version) + + # Create SOURCE relationship (this object -> target object) + rel = Relationship( + target=target_path, + type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + id=f"_{obj_identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", + ) + rels.append(rel) + except Exception as e: + logging.warning(f"Failed to create relationship for DOR in {obj_identifier}: {e}") + + return rels + + def merge_rels(self, new_rels: List[Relationship], existing_rels: List[Relationship]) -> List[Relationship]: + """Merge new relationships with existing ones, avoiding duplicates and ensuring unique IDs. + + Args: + new_rels: New relationships to add + existing_rels: Existing relationships + + Returns: + Merged list of relationships + """ + merged = list(existing_rels) + + for new_rel in new_rels: + # Check if relationship already exists + rel_exists = any(r.target == new_rel.target and r.type_value == new_rel.type_value for r in merged) + + if not rel_exists: + # Ensure unique ID + cpt = 0 + new_rel_id = new_rel.id + while any(r.id == new_rel_id for r in merged): + new_rel_id = f"{new_rel.id}_{cpt}" + cpt += 1 + if new_rel_id != new_rel.id: + new_rel.id = new_rel_id + + merged.append(new_rel) + + return merged + + +# =========================================================================================== +# MAIN CLASS (REFACTORED TO USE HELPER CLASSES) +# =========================================================================================== + + +class EpcStreamReader(EnergymlStorageInterface): + """ + Memory-efficient EPC file reader with lazy loading and smart caching. + + This class provides the same interface as the standard Epc class but loads + objects on-demand rather than keeping everything in memory. Perfect for + handling very large EPC files with thousands of objects. + + Features: + - Lazy loading: Objects loaded only when accessed + - Smart caching: LRU cache with configurable size + - Memory monitoring: Track memory usage and cache efficiency + - Streaming validation: Validate objects without full loading + - Batch operations: Efficient bulk operations + - Context management: Automatic resource cleanup + - Flexible relationship management: Three modes for updating object relationships + + Relationship Update Modes: + - UPDATE_AT_MODIFICATION: Maintains relationships in real-time as objects are added/removed/modified. + Best for maintaining consistency but may be slower for bulk operations. + - UPDATE_ON_CLOSE: Rebuilds all relationships when closing the EPC file (default). + More efficient for bulk operations but relationships only consistent after closing. + - MANUAL: No automatic relationship updates. User must manually call rebuild_all_rels(). + Maximum control and performance for advanced use cases. + + Performance optimizations: + - Pre-compiled regex patterns for 15-75% faster parsing + - Weak references to prevent memory leaks + - Compressed metadata storage + - Efficient ZIP file handling + """ + + def __init__( + self, + epc_file_path: Union[str, Path], + cache_size: int = 100, + validate_on_load: bool = True, + preload_metadata: bool = True, + export_version: EpcExportVersion = EpcExportVersion.CLASSIC, + force_h5_path: Optional[str] = None, + keep_open: bool = False, + force_title_load: bool = False, + rels_update_mode: RelsUpdateMode = RelsUpdateMode.UPDATE_ON_CLOSE, + enable_parallel_rels: bool = False, + parallel_worker_ratio: int = 10, + auto_repair_structure: bool = True, + ): + """ + Initialize the EPC stream reader. + + Args: + epc_file_path: Path to the EPC file + cache_size: Maximum number of objects to keep in memory cache + validate_on_load: Whether to validate objects when loading + preload_metadata: Whether to preload all object metadata + export_version: EPC packaging version (CLASSIC or EXPANDED) + force_h5_path: Optional forced HDF5 file path for external resources. If set, all arrays will be read/written from/to this path. + keep_open: If True, keeps the ZIP file open for better performance with multiple operations. File is closed only when instance is deleted or close() is called. + force_title_load: If True, forces loading object titles when listing objects (may impact performance) + rels_update_mode: Mode for updating relationships (UPDATE_AT_MODIFICATION, UPDATE_ON_CLOSE, or MANUAL) + enable_parallel_rels: If True, uses parallel processing for rebuild_all_rels() operations (faster for large EPCs) + parallel_worker_ratio: Number of objects per worker process (default: 10). Lower values = more workers. Only used when enable_parallel_rels=True. + auto_repair_structure: If True, automatically validates and repairs EPC structure on load (default: True) + """ + # Public attributes + self.epc_file_path = Path(epc_file_path) + self.auto_repair_structure = auto_repair_structure + self.enable_parallel_rels = enable_parallel_rels + self.parallel_worker_ratio = parallel_worker_ratio + self.cache_size = cache_size + self.validate_on_load = validate_on_load + self.force_h5_path = force_h5_path + self.cache_opened_h5 = None + self.keep_open = keep_open + self.force_title_load = force_title_load + self.rels_update_mode = rels_update_mode + self.export_version: EpcExportVersion = export_version or EpcExportVersion.CLASSIC + self.stats = EpcStreamingStats() + + # Caching system using weak references + self._object_cache: WeakValueDictionary = WeakValueDictionary() + self._access_order: List[str] = [] # LRU tracking + + is_new_file = False + + # Validate file exists and is readable + if not self.epc_file_path.exists(): + logging.info(f"EPC file not found: {epc_file_path}. Creating a new empty EPC file.") + self._create_empty_epc() + is_new_file = True + + if not zipfile.is_zipfile(self.epc_file_path): + raise ValueError(f"File is not a valid ZIP/EPC file: {epc_file_path}") + + # Check if the ZIP file has the required EPC structure + if not is_new_file: + try: + with zipfile.ZipFile(self.epc_file_path, "r") as zf: + content_types_path = get_epc_content_type_path() + if content_types_path not in zf.namelist(): + logging.info("EPC file is missing required structure. Initializing empty EPC file.") + self._create_empty_epc() + is_new_file = True + except Exception as e: + logging.warning(f"Failed to check EPC structure: {e}. Reinitializing.") + + # Initialize helper classes (internal architecture) + self._zip_accessor = _ZipFileAccessor(self.epc_file_path, keep_open=keep_open) + self._metadata_mgr = _MetadataManager(self._zip_accessor, self.stats) + self._metadata_mgr.set_export_version(self.export_version) + self._rels_mgr = _RelationshipManager( + self._zip_accessor, self._metadata_mgr, self.stats, self.export_version, rels_update_mode + ) + self._structure_validator = _StructureValidator(self._zip_accessor, self._metadata_mgr, self.export_version) + + # Validate and repair structure (idempotent) + if auto_repair_structure: + validation_results = self._structure_validator.validate_and_repair(is_new_file=is_new_file) + if validation_results["repaired"]: + logging.info("[EPC Stream] EPC structure has been validated and repaired") + elif is_new_file: + # Even without auto-repair, we need to create minimal structure for new files + self._structure_validator.validate_and_repair(is_new_file=True) + + # Initialize by loading metadata + if not is_new_file and preload_metadata: + self._metadata_mgr.load_metadata() + # Detect EPC version after loading metadata + self.export_version = self._metadata_mgr.detect_epc_version() + # Update relationship manager's export version + self._rels_mgr.export_version = self.export_version + + # Open persistent ZIP connection if keep_open is enabled + if keep_open and not is_new_file: + self._zip_accessor.open_persistent_connection() + + # Backward compatibility: expose internal structures as properties + # This allows existing code to access _metadata, _uuid_index, etc. + + @property + def _metadata(self) -> Dict[str, EpcObjectMetadata]: + """Backward compatibility property for accessing metadata.""" + return self._metadata_mgr._metadata + + @property + def _uuid_index(self) -> Dict[str, List[str]]: + """Backward compatibility property for accessing UUID index.""" + return self._metadata_mgr._uuid_index + + @property + def _type_index(self) -> Dict[str, List[str]]: + """Backward compatibility property for accessing type index.""" + return self._metadata_mgr._type_index + + @property + def _core_props(self) -> Optional[CoreProperties]: + """Backward compatibility property for accessing Core Properties.""" + return self._metadata_mgr._core_props + + @property + def additional_rels(self) -> Dict[str, List[Relationship]]: + """Backward compatibility property for accessing additional relationships.""" + return self._rels_mgr.additional_rels + + def _create_empty_epc(self) -> None: + """ + Create an empty EPC file structure. + + Note: This method is deprecated in favor of _StructureValidator.validate_and_repair(). + It's kept for backward compatibility but now delegates to the structure validator. + """ + # Ensure directory exists + self.epc_file_path.parent.mkdir(parents=True, exist_ok=True) + + # Create empty ZIP to allow structure validator to work + with zipfile.ZipFile(self.epc_file_path, "w") as zf: + pass # Create empty ZIP + + def _load_metadata(self) -> None: + """Load object metadata from [Content_Types].xml without loading actual objects.""" + # Delegate to metadata manager + self._metadata_mgr.load_metadata() + + def _read_content_types(self, zf: zipfile.ZipFile) -> Types: + """Read and parse [Content_Types].xml file.""" + # Delegate to metadata manager + return self._metadata_mgr._read_content_types(zf) + + def _process_energyml_object_metadata(self, zf: zipfile.ZipFile, override: Override) -> None: + """Process metadata for an EnergyML object without loading it.""" + # Delegate to metadata manager + self._metadata_mgr._process_energyml_object_metadata(zf, override) + + def _extract_object_info_fast( + self, zf: zipfile.ZipFile, file_path: str, content_type: str + ) -> Tuple[Optional[str], Optional[str], str]: + """Fast extraction of UUID and version from XML without full parsing.""" + # Delegate to metadata manager + return self._metadata_mgr._extract_object_info_fast(zf, file_path, content_type) + + def _is_core_properties(self, content_type: str) -> bool: + """Check if content type is CoreProperties.""" + # Delegate to metadata manager + return self._metadata_mgr._is_core_properties(content_type) + + def _process_core_properties_metadata(self, override: Override) -> None: + """Process core properties metadata.""" + # Delegate to metadata manager + self._metadata_mgr._process_core_properties_metadata(override) + + def _detect_epc_version(self) -> EpcExportVersion: + """Detect EPC packaging version based on file structure.""" + # Delegate to metadata manager + return self._metadata_mgr.detect_epc_version() + + def _gen_rels_path_from_metadata(self, metadata: EpcObjectMetadata) -> str: + """Generate rels path from object metadata without loading the object.""" + # Delegate to metadata manager + return self._metadata_mgr.gen_rels_path_from_metadata(metadata) + + def _gen_rels_path_from_identifier(self, identifier: str) -> Optional[str]: + """Generate rels path from object identifier without loading the object.""" + # Delegate to metadata manager + return self._metadata_mgr.gen_rels_path_from_identifier(identifier) + + @contextmanager + def _get_zip_file(self) -> Iterator[zipfile.ZipFile]: + """Context manager for ZIP file access with proper resource management. + + If keep_open is True, uses the persistent connection. Otherwise opens a new one. + """ + # Delegate to the ZIP accessor helper class + with self._zip_accessor.get_zip_file() as zf: + yield zf + + def get_object_by_identifier(self, identifier: Union[str, Uri]) -> Optional[Any]: + """ + Get object by its identifier with smart caching. + + Args: + identifier: Object identifier (uuid.version) + + Returns: + The requested object or None if not found + """ + is_uri = isinstance(identifier, Uri) or parse_uri(identifier) is not None + if is_uri: + uri = parse_uri(identifier) if isinstance(identifier, str) else identifier + assert uri is not None and uri.uuid is not None + identifier = uri.uuid + "." + (uri.version or "") + + # Check cache first + if identifier in self._object_cache: + self._update_access_order(identifier) # type: ignore + self.stats.cache_hits += 1 + return self._object_cache[identifier] + + self.stats.cache_misses += 1 + + # Check if metadata exists + if identifier not in self._metadata: + return None + + # Load object from file + obj = self._load_object(identifier) + + if obj is not None: + # Add to cache with LRU management + self._add_to_cache(identifier, obj) + self.stats.loaded_objects += 1 + + return obj + + def _load_object(self, identifier: Union[str, Uri]) -> Optional[Any]: + """Load object from EPC file.""" + is_uri = isinstance(identifier, Uri) or parse_uri(identifier) is not None + if is_uri: + uri = parse_uri(identifier) if isinstance(identifier, str) else identifier + assert uri is not None and uri.uuid is not None + identifier = uri.uuid + "." + (uri.version or "") + assert isinstance(identifier, str) + metadata = self._metadata.get(identifier) + if not metadata: + return None + + try: + with self._get_zip_file() as zf: + obj_data = zf.read(metadata.file_path) + self.stats.bytes_read += len(obj_data) + + obj_class = get_class_from_content_type(metadata.content_type) + obj = read_energyml_xml_bytes(obj_data, obj_class) + + if self.validate_on_load: + self._validate_object(obj, metadata) + + return obj + + except Exception as e: + logging.error(f"Failed to load object {identifier}: {e}") + return None + + def _validate_object(self, obj: Any, metadata: EpcObjectMetadata) -> None: + """Validate loaded object against metadata.""" + try: + obj_uuid = get_obj_uuid(obj) + if obj_uuid != metadata.uuid: + logging.warning(f"UUID mismatch for {metadata.identifier}: expected {metadata.uuid}, got {obj_uuid}") + except Exception as e: + logging.debug(f"Validation failed for {metadata.identifier}: {e}") + + def _add_to_cache(self, identifier: Union[str, Uri], obj: Any) -> None: + """Add object to cache with LRU eviction.""" + is_uri = isinstance(identifier, Uri) or parse_uri(identifier) is not None + if is_uri: + uri = parse_uri(identifier) if isinstance(identifier, str) else identifier + assert uri is not None and uri.uuid is not None + identifier = uri.uuid + "." + (uri.version or "") + + assert isinstance(identifier, str) + + # Remove from access order if already present + if identifier in self._access_order: + self._access_order.remove(identifier) + + # Add to front (most recently used) + self._access_order.insert(0, identifier) + + # Add to cache + self._object_cache[identifier] = obj + + # Evict if cache is full + while len(self._access_order) > self.cache_size: + oldest = self._access_order.pop() + self._object_cache.pop(oldest, None) + + def _update_access_order(self, identifier: str) -> None: + """Update access order for LRU cache.""" + if identifier in self._access_order: + self._access_order.remove(identifier) + self._access_order.insert(0, identifier) + + def get_object_by_uuid(self, uuid: str) -> List[Any]: + """Get all objects with the specified UUID.""" + if uuid not in self._uuid_index: + return [] + + objects = [] + for identifier in self._uuid_index[uuid]: + obj = self.get_object_by_identifier(identifier) + if obj is not None: + objects.append(obj) + + return objects + + def get_object(self, identifier: Union[str, Uri]) -> Optional[Any]: + return self.get_object_by_identifier(identifier) + + def get_objects_by_type(self, object_type: str) -> List[Any]: + """Get all objects of the specified type.""" + if object_type not in self._type_index: + return [] + + objects = [] + for identifier in self._type_index[object_type]: + obj = self.get_object_by_identifier(identifier) + if obj is not None: + objects.append(obj) + + return objects + + def list_object_metadata(self, object_type: Optional[str] = None) -> List[EpcObjectMetadata]: + """ + List metadata for objects without loading them. + + Args: + object_type: Optional filter by object type + + Returns: + List of object metadata + """ + if object_type is None: + return list(self._metadata.values()) + + return [self._metadata[identifier] for identifier in self._type_index.get(object_type, [])] + + def get_statistics(self) -> EpcStreamingStats: + """Get current streaming statistics.""" + return self.stats + + def list_objects( + self, dataspace: Optional[str] = None, object_type: Optional[str] = None + ) -> List[ResourceMetadata]: + """ + List all objects with metadata (EnergymlStorageInterface method). + + Args: + dataspace: Optional dataspace filter (ignored for EPC files) + object_type: Optional type filter (qualified type) + + Returns: + List of ResourceMetadata for all matching objects + """ + + results = [] + metadata_list = self.list_object_metadata(object_type) + + for meta in metadata_list: + try: + # Load object to get title + title = "" + if self.force_title_load and meta.identifier: + obj = self.get_object_by_identifier(meta.identifier) + if obj and hasattr(obj, "citation") and obj.citation: + if hasattr(obj.citation, "title"): + title = obj.citation.title + + # Build URI + qualified_type = content_type_to_qualified_type(meta.content_type) + if meta.version: + uri = f"eml:///{qualified_type}(uuid={meta.uuid},version='{meta.version}')" + else: + uri = f"eml:///{qualified_type}({meta.uuid})" + + resource = ResourceMetadata( + uri=uri, + uuid=meta.uuid, + version=meta.version, + title=title, + object_type=meta.object_type, + content_type=meta.content_type, + ) + + results.append(resource) + except Exception: + continue + + return results + + def get_array_metadata( + self, proxy: Union[str, Uri, Any], path_in_external: Optional[str] = None + ) -> Union[DataArrayMetadata, List[DataArrayMetadata], None]: + """ + Get metadata for data array(s) (EnergymlStorageInterface method). + + Args: + proxy: The object identifier/URI or the object itself + path_in_external: Optional specific path + + Returns: + DataArrayMetadata if path specified, List[DataArrayMetadata] if no path, + or None if not found + """ + from energyml.utils.storage_interface import DataArrayMetadata + + try: + if path_in_external: + array = self.read_array(proxy, path_in_external) + if array is not None: + return DataArrayMetadata( + path_in_resource=path_in_external, + array_type=str(array.dtype), + dimensions=list(array.shape), + ) + else: + # Would need to scan all possible paths - not practical + return [] + except Exception: + pass + + return None + + def preload_objects(self, identifiers: List[str]) -> int: + """ + Preload specific objects into cache. + + Args: + identifiers: List of object identifiers to preload + + Returns: + Number of objects successfully loaded + """ + loaded_count = 0 + for identifier in identifiers: + if self.get_object_by_identifier(identifier) is not None: + loaded_count += 1 + return loaded_count + + def clear_cache(self) -> None: + """Clear the object cache to free memory.""" + self._object_cache.clear() + self._access_order.clear() + self.stats.loaded_objects = 0 + + def get_core_properties(self) -> Optional[CoreProperties]: + """Get core properties (loaded lazily).""" + # Delegate to metadata manager + return self._metadata_mgr.get_core_properties() + + def _update_rels_for_new_object(self, obj: Any, obj_identifier: str) -> None: + """Update relationships when a new object is added (UPDATE_AT_MODIFICATION mode).""" + # Delegate to relationship manager + self._rels_mgr.update_rels_for_new_object(obj, obj_identifier) + + def _update_rels_for_modified_object(self, obj: Any, obj_identifier: str, old_dors: List[Any]) -> None: + """Update relationships when an object is modified (UPDATE_AT_MODIFICATION mode).""" + # Delegate to relationship manager + self._rels_mgr.update_rels_for_modified_object(obj, obj_identifier, old_dors) + + def _update_rels_for_removed_object(self, obj_identifier: str, obj: Optional[Any] = None) -> None: + """Update relationships when an object is removed (UPDATE_AT_MODIFICATION mode).""" + # Delegate to relationship manager + self._rels_mgr.update_rels_for_removed_object(obj_identifier, obj) + + def _write_rels_updates( + self, + source_identifier: str, + source_relationships: List[Relationship], + dest_updates: Dict[str, Relationship], + removals: Optional[Dict[str, str]] = None, + delete_source_rels: bool = False, + ) -> None: + """Write relationship updates to the EPC file efficiently.""" + # Delegate to relationship manager + self._rels_mgr.write_rels_updates( + source_identifier, source_relationships, dest_updates, removals, delete_source_rels + ) + + def _reopen_persistent_zip(self) -> None: + """Reopen persistent ZIP file after modifications to reflect changes.""" + # Delegate to ZIP accessor + self._zip_accessor.reopen_persistent_zip() + + def set_rels_update_mode(self, mode: RelsUpdateMode) -> None: + """ + Change the relationship update mode. + + Args: + mode: The new RelsUpdateMode to use + + Note: + Changing from MANUAL or UPDATE_ON_CLOSE to UPDATE_AT_MODIFICATION + may require calling rebuild_all_rels() first to ensure consistency. + """ + if not isinstance(mode, RelsUpdateMode): + raise ValueError(f"mode must be a RelsUpdateMode enum value, got {type(mode)}") + + old_mode = self.rels_update_mode + self.rels_update_mode = mode + # Also update the relationship manager + self._rels_mgr.rels_update_mode = mode + + logging.info(f"Changed relationship update mode from {old_mode.value} to {mode.value}") + + def get_rels_update_mode(self) -> RelsUpdateMode: + """ + Get the current relationship update mode. + + Returns: + The current RelsUpdateMode + """ + return self.rels_update_mode + + def get_obj_rels(self, obj: Union[str, Uri, Any]) -> List[Relationship]: + """ + Get all relationships for a given object. + Merges relationships from the EPC file with in-memory additional relationships. + + Optimized to avoid loading the object when identifier/URI is provided. + + :param obj: the object or its identifier/URI + :return: list of Relationship objects + """ + # Get identifier without loading the object + obj_identifier = None + rels_path = None + + if isinstance(obj, (str, Uri)): + # Convert URI to identifier if needed + if isinstance(obj, Uri) or parse_uri(obj) is not None: + uri = parse_uri(obj) if isinstance(obj, str) else obj + assert uri is not None and uri.uuid is not None + obj_identifier = uri.uuid + "." + (uri.version or "") + else: + obj_identifier = obj + + # Generate rels path from metadata without loading the object + rels_path = self._gen_rels_path_from_identifier(obj_identifier) + else: + # We have the actual object + obj_identifier = get_obj_identifier(obj) + rels_path = gen_rels_path(obj, self.export_version) + + # Delegate to relationship manager + return self._rels_mgr.get_obj_rels(obj_identifier, rels_path) + + def get_h5_file_paths(self, obj: Union[str, Uri, Any]) -> List[str]: + """ + Get all HDF5 file paths referenced in the EPC file (from rels to external resources). + Optimized to avoid loading the object when identifier/URI is provided. + + :param obj: the object or its identifier/URI + :return: list of HDF5 file paths + """ + if self.force_h5_path is not None: + return [self.force_h5_path] + h5_paths = set() + + obj_identifier = None + rels_path = None + + # Get identifier and rels path without loading the object + if isinstance(obj, (str, Uri)): + # Convert URI to identifier if needed + if isinstance(obj, Uri) or parse_uri(obj) is not None: + uri = parse_uri(obj) if isinstance(obj, str) else obj + assert uri is not None and uri.uuid is not None + obj_identifier = uri.uuid + "." + (uri.version or "") + else: + obj_identifier = obj + + # Generate rels path from metadata without loading the object + rels_path = self._gen_rels_path_from_identifier(obj_identifier) + else: + # We have the actual object + obj_identifier = get_obj_identifier(obj) + rels_path = gen_rels_path(obj, self.export_version) + + # Check in-memory additional rels first + for rels in self.additional_rels.get(obj_identifier, []): + if rels.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(): + h5_paths.add(rels.target) + + # Also check rels from the EPC file + if rels_path is not None: + with self._get_zip_file() as zf: + try: + rels_data = zf.read(rels_path) + self.stats.bytes_read += len(rels_data) + relationships = read_energyml_xml_bytes(rels_data, Relationships) + for rel in relationships.relationship: + if rel.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(): + h5_paths.add(rel.target) + except KeyError: + pass + + if len(h5_paths) == 0: + # search if an h5 file has the same name than the epc file + epc_folder = os.path.dirname(self.epc_file_path) + if epc_folder is not None and self.epc_file_path is not None: + epc_file_name = os.path.basename(self.epc_file_path) + epc_file_base, _ = os.path.splitext(epc_file_name) + possible_h5_path = os.path.join(epc_folder, epc_file_base + ".h5") + if os.path.exists(possible_h5_path): + h5_paths.add(possible_h5_path) + return list(h5_paths) + + def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Optional[np.ndarray]: + """ + Read a dataset from the HDF5 file linked to the proxy object. + :param proxy: the object or its identifier + :param path_in_external: the path in the external HDF5 file + :return: the dataset as a numpy array + """ + # Resolve proxy to object + + h5_path = [] + if self.force_h5_path is not None: + if self.cache_opened_h5 is None: + self.cache_opened_h5 = h5py.File(self.force_h5_path, "a") + h5_path = [self.cache_opened_h5] + else: + if isinstance(proxy, (str, Uri)): + obj = self.get_object_by_identifier(proxy) + else: + obj = proxy + + h5_path = self.get_h5_file_paths(obj) + + h5_reader = HDF5FileReader() + + if h5_path is None or len(h5_path) == 0: + raise ValueError("No HDF5 file paths found for the given proxy object.") + else: + for h5p in h5_path: + # TODO: handle different type of files + try: + return h5_reader.read_array(source=h5p, path_in_external_file=path_in_external) + except Exception: + pass + # logging.error(f"Failed to read HDF5 dataset from {h5p}: {e}") + + def write_array(self, proxy: Union[str, Uri, Any], path_in_external: str, array: np.ndarray) -> bool: + """ + Write a dataset to the HDF5 file linked to the proxy object. + :param proxy: the object or its identifier + :param path_in_external: the path in the external HDF5 file + :param array: the numpy array to write + + return: True if successful + """ + h5_path = [] + if self.force_h5_path is not None: + if self.cache_opened_h5 is None: + self.cache_opened_h5 = h5py.File(self.force_h5_path, "a") + h5_path = [self.cache_opened_h5] + else: + if isinstance(proxy, (str, Uri)): + obj = self.get_object_by_identifier(proxy) + else: + obj = proxy + + h5_path = self.get_h5_file_paths(obj) + + h5_writer = HDF5FileWriter() + + if h5_path is None or len(h5_path) == 0: + raise ValueError("No HDF5 file paths found for the given proxy object.") + else: + for h5p in h5_path: + try: + h5_writer.write_array(target=h5p, path_in_external_file=path_in_external, array=array) + return True + except Exception as e: + logging.error(f"Failed to write HDF5 dataset to {h5p}: {e}") + return False + + def validate_all_objects(self, fast_mode: bool = True) -> Dict[str, List[str]]: + """ + Validate all objects in the EPC file. + + Args: + fast_mode: If True, only validate metadata without loading full objects + + Returns: + Dictionary with 'errors' and 'warnings' keys containing lists of issues + """ + results = {"errors": [], "warnings": []} + + for identifier, metadata in self._metadata.items(): + try: + if fast_mode: + # Quick validation - just check file exists and is readable + with self._get_zip_file() as zf: + try: + zf.getinfo(metadata.file_path) + except KeyError: + results["errors"].append(f"Missing file for object {identifier}: {metadata.file_path}") + else: + # Full validation - load and validate object + obj = self.get_object_by_identifier(identifier) + if obj is None: + results["errors"].append(f"Failed to load object {identifier}") + else: + self._validate_object(obj, metadata) + + except Exception as e: + results["errors"].append(f"Validation error for {identifier}: {e}") + + return results + + def get_object_dependencies(self, identifier: Union[str, Uri]) -> List[str]: + """ + Get list of object identifiers that this object depends on. + + This would need to be implemented based on DOR analysis. + """ + # Placeholder for dependency analysis + # Would need to parse DORs in the object + return [] + + def __len__(self) -> int: + """Return total number of objects in EPC.""" + return len(self._metadata) + + def __contains__(self, identifier: str) -> bool: + """Check if object with identifier exists.""" + return identifier in self._metadata + + def __iter__(self) -> Iterator[str]: + """Iterate over object identifiers.""" + return iter(self._metadata.keys()) + + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit with cleanup.""" + self.clear_cache() + self.close() + if self.cache_opened_h5 is not None: + try: + self.cache_opened_h5.close() + except Exception: + pass + self.cache_opened_h5 = None + + def __del__(self): + """Destructor to ensure persistent ZIP file is closed.""" + try: + self.close() + if self.cache_opened_h5 is not None: + try: + self.cache_opened_h5.close() + except Exception: + pass + self.cache_opened_h5 = None + except Exception: + pass # Ignore errors during cleanup + + def close(self) -> None: + """Close the persistent ZIP file if it's open, recomputing rels first if mode is UPDATE_ON_CLOSE.""" + # Recompute all relationships before closing if in UPDATE_ON_CLOSE mode + if self.rels_update_mode == RelsUpdateMode.UPDATE_ON_CLOSE: + try: + self.rebuild_all_rels(clean_first=True) + logging.info("Rebuilt all relationships on close (UPDATE_ON_CLOSE mode)") + except Exception as e: + logging.warning(f"Error rebuilding rels on close: {e}") + + # Delegate to ZIP accessor + self._zip_accessor.close() + + def put_object(self, obj: Any, dataspace: Optional[str] = None) -> Optional[str]: + """ + Store an energyml object (EnergymlStorageInterface method). + + Args: + obj: The energyml object to store + dataspace: Optional dataspace name (ignored for EPC files) + + Returns: + The identifier of the stored object (UUID.version or UUID), or None on error + """ + try: + return self.add_object(obj, replace_if_exists=True) + except Exception: + return None + + def add_object(self, obj: Any, file_path: Optional[str] = None, replace_if_exists: bool = True) -> str: + """ + Add a new object to the EPC file and update caches. + + Args: + obj: The EnergyML object to add + file_path: Optional custom file path, auto-generated if not provided + replace_if_exists: If True, replace the object if it already exists. If False, raise ValueError. + + Returns: + The identifier of the added object + + Raises: + ValueError: If object is invalid or already exists (when replace_if_exists=False) + RuntimeError: If file operations fail + """ + identifier = None + metadata = None + + try: + # Extract object information + identifier = get_obj_identifier(obj) + uuid = identifier.split(".")[0] if identifier else None + + if not uuid: + raise ValueError("Object must have a valid UUID") + + version = identifier[len(uuid) + 1 :] if identifier and "." in identifier else None + # Ensure version is treated as a string, not an integer + if version is not None and not isinstance(version, str): + version = str(version) + + object_type = get_object_type_for_file_path_from_class(obj) + + if identifier in self._metadata: + if replace_if_exists: + # Remove the existing object first + logging.info(f"Replacing existing object {identifier}") + self.remove_object(identifier) + else: + raise ValueError( + f"Object with identifier {identifier} already exists. Use update_object() or set replace_if_exists=True." + ) + + # Generate file path if not provided + file_path = gen_energyml_object_path(obj, self.export_version) + + print(f"Generated file path: {file_path} for export version: {self.export_version}") + + # Determine content type based on object type + content_type = get_obj_content_type(obj) + + # Create metadata + metadata = EpcObjectMetadata( + uuid=uuid, + object_type=object_type, + content_type=content_type, + file_path=file_path, + version=version, + identifier=identifier, + ) + + # Update internal structures + self._metadata[identifier] = metadata + + # Update UUID index + if uuid not in self._uuid_index: + self._uuid_index[uuid] = [] + self._uuid_index[uuid].append(identifier) + + # Update type index + if object_type not in self._type_index: + self._type_index[object_type] = [] + self._type_index[object_type].append(identifier) + + # Add to cache + self._add_to_cache(identifier, obj) + + # Save changes to file + self._add_object_to_file(obj, metadata) + + # Update relationships if in UPDATE_AT_MODIFICATION mode + if self.rels_update_mode == RelsUpdateMode.UPDATE_AT_MODIFICATION: + self._update_rels_for_new_object(obj, identifier) + + # Update stats + self.stats.total_objects += 1 + + logging.info(f"Added object {identifier} to EPC file") + return identifier + + except Exception as e: + logging.error(f"Failed to add object: {e}") + # Rollback changes if we created metadata + if identifier and metadata: + self._rollback_add_object(identifier) + raise RuntimeError(f"Failed to add object to EPC: {e}") + + def delete_object(self, identifier: Union[str, Uri]) -> bool: + """ + Delete an object by its identifier (EnergymlStorageInterface method). + + Args: + identifier: Object identifier (UUID or UUID.version) or ETP URI + + Returns: + True if successfully deleted, False otherwise + """ + return self.remove_object(identifier) + + def remove_object(self, identifier: Union[str, Uri]) -> bool: + """ + Remove an object (or all versions of an object) from the EPC file and update caches. + + Args: + identifier: The identifier of the object to remove. Can be either: + - Full identifier (uuid.version) to remove a specific version + - UUID only to remove ALL versions of that object + + Returns: + True if object(s) were successfully removed, False if not found + + Raises: + RuntimeError: If file operations fail + """ + try: + is_uri = isinstance(identifier, Uri) or parse_uri(identifier) is not None + if is_uri: + uri = parse_uri(identifier) if isinstance(identifier, str) else identifier + assert uri is not None and uri.uuid is not None + identifier = uri.uuid + "." + (uri.version or "") + assert isinstance(identifier, str) + + if identifier not in self._metadata: + # Check if identifier is a UUID only (should remove all versions) + if identifier in self._uuid_index: + # Remove all versions for this UUID + identifiers_to_remove = self._uuid_index[identifier].copy() + removed_count = 0 + + for id_to_remove in identifiers_to_remove: + if self._remove_single_object(id_to_remove): + removed_count += 1 + + return removed_count > 0 + else: + return False + + # Single identifier removal + return self._remove_single_object(identifier) + + except Exception as e: + logging.error(f"Failed to remove object {identifier}: {e}") + raise RuntimeError(f"Failed to remove object from EPC: {e}") + + def _remove_single_object(self, identifier: str) -> bool: + """ + Remove a single object by its full identifier. + + Args: + identifier: The full identifier (uuid.version) of the object to remove + Returns: + True if the object was successfully removed, False otherwise + """ + try: + if identifier not in self._metadata: + return False + + metadata = self._metadata[identifier] + + # If in UPDATE_AT_MODIFICATION mode, update rels before removing + obj = None + if self.rels_update_mode == RelsUpdateMode.UPDATE_AT_MODIFICATION: + obj = self.get_object_by_identifier(identifier) + if obj: + self._update_rels_for_removed_object(identifier, obj) + + # IMPORTANT: Remove from file FIRST (before clearing cache/metadata) + # because _remove_object_from_file needs to load the object to access its DORs + self._remove_object_from_file(metadata) + + # Now remove from cache + if identifier in self._object_cache: + del self._object_cache[identifier] + + if identifier in self._access_order: + self._access_order.remove(identifier) + + # Remove from indexes + uuid = metadata.uuid + object_type = metadata.object_type + + if uuid in self._uuid_index: + if identifier in self._uuid_index[uuid]: + self._uuid_index[uuid].remove(identifier) + if not self._uuid_index[uuid]: + del self._uuid_index[uuid] + + if object_type in self._type_index: + if identifier in self._type_index[object_type]: + self._type_index[object_type].remove(identifier) + if not self._type_index[object_type]: + del self._type_index[object_type] + + # Remove from metadata (do this last) + del self._metadata[identifier] + + # Update stats + self.stats.total_objects -= 1 + if self.stats.loaded_objects > 0: + self.stats.loaded_objects -= 1 + + logging.info(f"Removed object {identifier} from EPC file") + return True + + except Exception as e: + logging.error(f"Failed to remove single object {identifier}: {e}") + return False + + def update_object(self, obj: Any) -> str: + """ + Update an existing object in the EPC file. + + Args: + obj: The EnergyML object to update + Returns: + The identifier of the updated object + """ + identifier = get_obj_identifier(obj) + if not identifier or identifier not in self._metadata: + raise ValueError("Object must have a valid identifier and exist in the EPC file") + + try: + # If in UPDATE_AT_MODIFICATION mode, get old DORs and handle update differently + if self.rels_update_mode == RelsUpdateMode.UPDATE_AT_MODIFICATION: + old_obj = self.get_object_by_identifier(identifier) + old_dors = get_direct_dor_list(old_obj) if old_obj else [] + + # Preserve non-SOURCE/DESTINATION relationships (like EXTERNAL_RESOURCE) before removal + preserved_rels = [] + try: + obj_rels = self.get_obj_rels(identifier) + preserved_rels = [ + r + for r in obj_rels + if r.type_value + not in ( + EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + ) + ] + except Exception: + pass + + # Remove existing object (without rels update since we're replacing it) + # Temporarily switch to MANUAL mode to avoid double updates + original_mode = self.rels_update_mode + self.rels_update_mode = RelsUpdateMode.MANUAL + self.remove_object(identifier) + self.rels_update_mode = original_mode + + # Add updated object (without rels update since we'll do custom update) + self.rels_update_mode = RelsUpdateMode.MANUAL + new_identifier = self.add_object(obj) + self.rels_update_mode = original_mode + + # Now do the specialized update that handles both adds and removes + self._update_rels_for_modified_object(obj, new_identifier, old_dors) + + # Restore preserved relationships (like EXTERNAL_RESOURCE) + if preserved_rels: + # These need to be written directly to the rels file + # since _update_rels_for_modified_object already wrote it + rels_path = self._gen_rels_path_from_identifier(new_identifier) + if rels_path: + with self._get_zip_file() as zf: + # Read current rels + current_rels = [] + try: + if rels_path in zf.namelist(): + rels_data = zf.read(rels_path) + rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + if rels_obj and rels_obj.relationship: + current_rels = list(rels_obj.relationship) + except Exception: + pass + + # Add preserved rels + all_rels = current_rels + preserved_rels + + # Write back + with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: + temp_path = temp_file.name + + try: + with self._get_zip_file() as source_zf: + with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: + # Copy all files except the rels file we're updating + for item in source_zf.infolist(): + if item.filename != rels_path: + buffer = source_zf.read(item.filename) + target_zf.writestr(item, buffer) + + # Write updated rels file + target_zf.writestr( + rels_path, serialize_xml(Relationships(relationship=all_rels)) + ) + + # Replace original + shutil.move(temp_path, self.epc_file_path) + self._reopen_persistent_zip() + + except Exception: + if os.path.exists(temp_path): + os.unlink(temp_path) + raise + + else: + # For other modes (UPDATE_ON_CLOSE, MANUAL), preserve non-SOURCE/DESTINATION relationships + preserved_rels = [] + try: + obj_rels = self.get_obj_rels(identifier) + preserved_rels = [ + r + for r in obj_rels + if r.type_value + not in ( + EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + ) + ] + except Exception: + pass + + # Simple remove + add + self.remove_object(identifier) + new_identifier = self.add_object(obj) + + # Restore preserved relationships if any + if preserved_rels: + self.add_rels_for_object(new_identifier, preserved_rels, write_immediately=True) + + logging.info(f"Updated object {identifier} to {new_identifier} in EPC file") + return new_identifier + + except Exception as e: + logging.error(f"Failed to update object {identifier}: {e}") + raise RuntimeError(f"Failed to update object in EPC: {e}") + + def add_rels_for_object( + self, identifier: Union[str, Uri, Any], relationships: List[Relationship], write_immediately: bool = False + ) -> None: + """ + Add additional relationships for a specific object. + + Relationships are stored in memory and can be written immediately or deferred + until write_pending_rels() is called, or when the EPC is closed. + + Args: + identifier: The identifier of the object, can be str, Uri, or the object itself + relationships: List of Relationship objects to add + write_immediately: If True, writes pending rels to disk immediately after adding. + If False (default), rels are kept in memory for batching. + """ + is_uri = isinstance(identifier, Uri) or (isinstance(identifier, str) and parse_uri(identifier) is not None) + if is_uri: + uri = parse_uri(identifier) if isinstance(identifier, str) else identifier + assert uri is not None and uri.uuid is not None + identifier = uri.uuid + "." + (uri.version or "") + elif not isinstance(identifier, str): + identifier = get_obj_identifier(identifier) + + assert isinstance(identifier, str) + + if identifier not in self.additional_rels: + self.additional_rels[identifier] = [] + + self.additional_rels[identifier].extend(relationships) + logging.debug(f"Added {len(relationships)} relationships for object {identifier} (in-memory)") + + if write_immediately: + self.write_pending_rels() + + def write_pending_rels(self) -> int: + """ + Write all pending in-memory relationships to the EPC file efficiently. + + This method reads existing rels, merges them in memory with pending rels, + then rewrites only the affected rels files in a single ZIP update. + + Returns: + Number of rels files updated + """ + if not self.additional_rels: + logging.debug("No pending relationships to write") + return 0 + + updated_count = 0 + + # Step 1: Read existing rels and merge with pending rels in memory + merged_rels: Dict[str, Relationships] = {} # rels_path -> merged Relationships + + with self._get_zip_file() as zf: + for obj_identifier, new_relationships in self.additional_rels.items(): + # Generate rels path from metadata without loading the object + rels_path = self._gen_rels_path_from_identifier(obj_identifier) + if rels_path is None: + logging.warning(f"Could not generate rels path for {obj_identifier}") + continue + + # Read existing rels from ZIP + existing_relationships = [] + try: + if rels_path in zf.namelist(): + rels_data = zf.read(rels_path) + existing_rels = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels and existing_rels.relationship: + existing_relationships = list(existing_rels.relationship) + except Exception as e: + logging.debug(f"Could not read existing rels for {rels_path}: {e}") + + # Merge new relationships, avoiding duplicates + for new_rel in new_relationships: + # Check if relationship already exists + rel_exists = any( + r.target == new_rel.target and r.type_value == new_rel.type_value + for r in existing_relationships + ) + + if not rel_exists: + # Ensure unique ID + cpt = 0 + new_rel_id = new_rel.id + while any(r.id == new_rel_id for r in existing_relationships): + new_rel_id = f"{new_rel.id}_{cpt}" + cpt += 1 + if new_rel_id != new_rel.id: + new_rel.id = new_rel_id + + existing_relationships.append(new_rel) + + # Store merged result + if existing_relationships: + merged_rels[rels_path] = Relationships(relationship=existing_relationships) + + # Step 2: Write updated rels back to ZIP (create temp, copy all, replace) + if not merged_rels: + return 0 + + with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: + temp_path = temp_file.name + + try: + # Copy entire ZIP, replacing only the updated rels files + with self._get_zip_file() as source_zf: + with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: + # Copy all files except the rels we're updating + for item in source_zf.infolist(): + if item.filename not in merged_rels: + buffer = source_zf.read(item.filename) + target_zf.writestr(item, buffer) + + # Write updated rels files + for rels_path, relationships in merged_rels.items(): + rels_xml = serialize_xml(relationships) + target_zf.writestr(rels_path, rels_xml) + updated_count += 1 + + # Replace original with updated ZIP + shutil.move(temp_path, self.epc_file_path) + self._reopen_persistent_zip() + + # Clear pending rels after successful write + self.additional_rels.clear() + + logging.info(f"Wrote {updated_count} rels files to EPC") + return updated_count + + except Exception as e: + if os.path.exists(temp_path): + os.unlink(temp_path) + logging.error(f"Failed to write pending rels: {e}") + raise + + def _compute_object_rels(self, obj: Any, obj_identifier: str) -> List[Relationship]: + """Compute relationships for a given object (SOURCE relationships). + + Delegates to _rels_mgr.compute_object_rels() + """ + return self._rels_mgr.compute_object_rels(obj, obj_identifier) + + def _merge_rels(self, new_rels: List[Relationship], existing_rels: List[Relationship]) -> List[Relationship]: + """Merge new relationships with existing ones, avoiding duplicates and ensuring unique IDs. + + Delegates to _rels_mgr.merge_rels() + """ + return self._rels_mgr.merge_rels(new_rels, existing_rels) + + def _add_object_to_file(self, obj: Any, metadata: EpcObjectMetadata) -> None: + """Add object to the EPC file efficiently. + + Reads existing rels, computes updates in memory, then writes everything + in a single ZIP operation. + """ + xml_content = serialize_xml(obj) + obj_identifier = metadata.identifier + assert obj_identifier is not None, "Object identifier must not be None" + + # Step 1: Compute which rels files need to be updated and prepare their content + rels_updates: Dict[str, str] = {} # rels_path -> XML content + + with self._get_zip_file() as zf: + # 1a. Object's own .rels file + obj_rels_path = gen_rels_path(obj, self.export_version) + obj_relationships = self._compute_object_rels(obj, obj_identifier) + + if obj_relationships: + # Read existing rels + existing_rels = [] + try: + if obj_rels_path in zf.namelist(): + rels_data = zf.read(obj_rels_path) + existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels_obj and existing_rels_obj.relationship: + existing_rels = list(existing_rels_obj.relationship) + except Exception: + pass + + # Merge and serialize + merged_rels = self._merge_rels(obj_relationships, existing_rels) + if merged_rels: + rels_updates[obj_rels_path] = serialize_xml(Relationships(relationship=merged_rels)) + + # 1b. Update rels of referenced objects (DESTINATION relationships) + direct_dors = get_direct_dor_list(obj) + for dor in direct_dors: + try: + target_identifier = get_obj_identifier(dor) + + # Generate rels path from metadata without processing DOR + target_rels_path = self._gen_rels_path_from_identifier(target_identifier) + if target_rels_path is None: + # Fall back to generating from DOR if metadata not found + target_rels_path = gen_rels_path(dor, self.export_version) + + # Create DESTINATION relationship + dest_rel = Relationship( + target=metadata.file_path, + type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + id=f"_{target_identifier}_{get_obj_type(get_obj_usable_class(obj))}_{obj_identifier}", + ) + + # Read existing rels + existing_rels = [] + try: + if target_rels_path in zf.namelist(): + rels_data = zf.read(target_rels_path) + existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels_obj and existing_rels_obj.relationship: + existing_rels = list(existing_rels_obj.relationship) + except Exception: + pass + + # Merge and serialize + merged_rels = self._merge_rels([dest_rel], existing_rels) + if merged_rels: + rels_updates[target_rels_path] = serialize_xml(Relationships(relationship=merged_rels)) + + except Exception as e: + logging.warning(f"Failed to prepare rels update for referenced object: {e}") + + # 1c. Update [Content_Types].xml + content_types_xml = self._update_content_types_xml(zf, metadata, add=True) + + # Step 2: Write everything to new ZIP + with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: + temp_path = temp_file.name + + try: + with self._get_zip_file() as source_zf: + with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: + # Write new object + target_zf.writestr(metadata.file_path, xml_content) + + # Write updated [Content_Types].xml + target_zf.writestr(get_epc_content_type_path(), content_types_xml) + + # Write updated rels files + for rels_path, rels_xml in rels_updates.items(): + target_zf.writestr(rels_path, rels_xml) + + # Copy all other files + files_to_skip = {get_epc_content_type_path(), metadata.file_path} + files_to_skip.update(rels_updates.keys()) + + for item in source_zf.infolist(): + if item.filename not in files_to_skip: + buffer = source_zf.read(item.filename) + target_zf.writestr(item, buffer) + + # Replace original + shutil.move(temp_path, self.epc_file_path) + self._reopen_persistent_zip() + + except Exception as e: + if os.path.exists(temp_path): + os.unlink(temp_path) + logging.error(f"Failed to add object to EPC file: {e}") + raise + + def _remove_object_from_file(self, metadata: EpcObjectMetadata) -> None: + """Remove object from the EPC file efficiently. + + Reads existing rels, computes updates in memory, then writes everything + in a single ZIP operation. Note: This does NOT remove .rels files. + Use clean_rels() to remove orphaned relationships. + """ + # Load object first (needed to process its DORs) + if metadata.identifier is None: + logging.error("Cannot remove object with None identifier") + raise ValueError("Object identifier must not be None") + + obj = self.get_object_by_identifier(metadata.identifier) + if obj is None: + logging.warning(f"Object {metadata.identifier} not found, cannot remove rels") + # Still proceed with removal even if object can't be loaded + + # Step 1: Compute rels updates (remove DESTINATION relationships from referenced objects) + rels_updates: Dict[str, str] = {} # rels_path -> XML content + + if obj is not None: + with self._get_zip_file() as zf: + direct_dors = get_direct_dor_list(obj) + + for dor in direct_dors: + try: + target_identifier = get_obj_identifier(dor) + if target_identifier not in self._metadata: + continue + + # Use metadata to generate rels path without loading the object + target_rels_path = self._gen_rels_path_from_identifier(target_identifier) + if target_rels_path is None: + continue + + # Read existing rels + existing_relationships = [] + try: + if target_rels_path in zf.namelist(): + rels_data = zf.read(target_rels_path) + existing_rels = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels and existing_rels.relationship: + existing_relationships = list(existing_rels.relationship) + except Exception as e: + logging.debug(f"Could not read existing rels for {target_identifier}: {e}") + + # Remove DESTINATION relationship that pointed to our object + updated_relationships = [ + r + for r in existing_relationships + if not ( + r.target == metadata.file_path + and r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type() + ) + ] + + # Only update if relationships remain + if updated_relationships: + rels_updates[target_rels_path] = serialize_xml( + Relationships(relationship=updated_relationships) + ) + + except Exception as e: + logging.warning(f"Failed to update rels for referenced object during removal: {e}") + + # Update [Content_Types].xml + content_types_xml = self._update_content_types_xml(zf, metadata, add=False) + else: + # If we couldn't load the object, still update content types + with self._get_zip_file() as zf: + content_types_xml = self._update_content_types_xml(zf, metadata, add=False) + + # Step 2: Write everything to new ZIP + with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: + temp_path = temp_file.name + + try: + with self._get_zip_file() as source_zf: + with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: + # Write updated [Content_Types].xml + target_zf.writestr(get_epc_content_type_path(), content_types_xml) + + # Write updated rels files + for rels_path, rels_xml in rels_updates.items(): + target_zf.writestr(rels_path, rels_xml) + + # Copy all files except removed object, its rels, and files we're updating + obj_rels_path = self._gen_rels_path_from_metadata(metadata) + files_to_skip = {get_epc_content_type_path(), metadata.file_path} + if obj_rels_path: + files_to_skip.add(obj_rels_path) + files_to_skip.update(rels_updates.keys()) + + for item in source_zf.infolist(): + if item.filename not in files_to_skip: + buffer = source_zf.read(item.filename) + target_zf.writestr(item, buffer) + + # Replace original + shutil.move(temp_path, self.epc_file_path) + self._reopen_persistent_zip() + + except Exception as e: + if os.path.exists(temp_path): + os.unlink(temp_path) + logging.error(f"Failed to remove object from EPC file: {e}") + raise + + def _update_content_types_xml( + self, source_zip: zipfile.ZipFile, metadata: EpcObjectMetadata, add: bool = True + ) -> str: + """Update [Content_Types].xml to add or remove object entry. + + Delegates to _metadata_mgr.update_content_types_xml() + """ + return self._metadata_mgr.update_content_types_xml(source_zip, metadata, add) + + def _rollback_add_object(self, identifier: Optional[str]) -> None: + """Rollback changes made during failed add_object operation.""" + if identifier and identifier in self._metadata: + metadata = self._metadata[identifier] + + # Remove from metadata + del self._metadata[identifier] + + # Remove from indexes + uuid = metadata.uuid + object_type = metadata.object_type + + if uuid in self._uuid_index and identifier in self._uuid_index[uuid]: + self._uuid_index[uuid].remove(identifier) + if not self._uuid_index[uuid]: + del self._uuid_index[uuid] + + if object_type in self._type_index and identifier in self._type_index[object_type]: + self._type_index[object_type].remove(identifier) + if not self._type_index[object_type]: + del self._type_index[object_type] + + # Remove from cache + if identifier in self._object_cache: + del self._object_cache[identifier] + if identifier in self._access_order: + self._access_order.remove(identifier) + + def clean_rels(self) -> Dict[str, int]: + """ + Clean all .rels files by removing relationships to objects that no longer exist. + + This method: + 1. Scans all .rels files in the EPC + 2. For each relationship, checks if the target object exists + 3. Removes relationships pointing to non-existent objects + 4. Removes empty .rels files + + Returns: + Dictionary with statistics: + - 'rels_files_scanned': Number of .rels files examined + - 'relationships_removed': Number of orphaned relationships removed + - 'rels_files_removed': Number of empty .rels files removed + """ + import tempfile + import shutil + + stats = { + "rels_files_scanned": 0, + "relationships_removed": 0, + "rels_files_removed": 0, + } + + # Create temporary file for updated EPC + with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: + temp_path = temp_file.name + + try: + with self._get_zip_file() as source_zip: + with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zip: + # Get all existing object file paths for validation + existing_object_files = {metadata.file_path for metadata in self._metadata.values()} + + # Process each file + for item in source_zip.infolist(): + if item.filename.endswith(".rels"): + # Process .rels file + stats["rels_files_scanned"] += 1 + + try: + rels_data = source_zip.read(item.filename) + rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + + if rels_obj and rels_obj.relationship: + # Filter out relationships to non-existent objects + original_count = len(rels_obj.relationship) + + # Keep only relationships where the target exists + # or where the target is external (starts with ../ or http) + valid_relationships = [] + for rel in rels_obj.relationship: + target = rel.target + # Keep external references (HDF5, etc.) and existing objects + if ( + target.startswith("../") + or target.startswith("http") + or target in existing_object_files + or target.lstrip("/") + in existing_object_files # Also check without leading slash + ): + valid_relationships.append(rel) + + removed_count = original_count - len(valid_relationships) + stats["relationships_removed"] += removed_count + + if removed_count > 0: + logging.info( + f"Removed {removed_count} orphaned relationships from {item.filename}" + ) + + # Only write the .rels file if it has remaining relationships + if valid_relationships: + rels_obj.relationship = valid_relationships + updated_rels = serialize_xml(rels_obj) + target_zip.writestr(item.filename, updated_rels) + else: + # Empty .rels file, don't write it + stats["rels_files_removed"] += 1 + logging.info(f"Removed empty .rels file: {item.filename}") + else: + # Empty or invalid .rels, don't copy it + stats["rels_files_removed"] += 1 + + except Exception as e: + logging.warning(f"Failed to process .rels file {item.filename}: {e}") + # Copy as-is on error + data = source_zip.read(item.filename) + target_zip.writestr(item, data) + + else: + # Copy non-.rels files as-is + data = source_zip.read(item.filename) + target_zip.writestr(item, data) + + # Replace original file + shutil.move(temp_path, self.epc_file_path) + + logging.info( + f"Cleaned .rels files: scanned {stats['rels_files_scanned']}, " + f"removed {stats['relationships_removed']} orphaned relationships, " + f"removed {stats['rels_files_removed']} empty .rels files" + ) + + return stats + + except Exception as e: + # Clean up temp file on error + if os.path.exists(temp_path): + os.unlink(temp_path) + raise RuntimeError(f"Failed to clean .rels files: {e}") + + def rebuild_all_rels(self, clean_first: bool = True) -> Dict[str, int]: + """ + Rebuild all .rels files from scratch by analyzing all objects and their references. + + This method: + 1. Optionally cleans existing .rels files first + 2. Loads each object temporarily + 3. Analyzes its Data Object References (DORs) + 4. Creates/updates .rels files with proper SOURCE and DESTINATION relationships + + Args: + clean_first: If True, remove all existing .rels files before rebuilding + + Returns: + Dictionary with statistics: + - 'objects_processed': Number of objects analyzed + - 'rels_files_created': Number of .rels files created + - 'source_relationships': Number of SOURCE relationships created + - 'destination_relationships': Number of DESTINATION relationships created + - 'parallel_mode': True if parallel processing was used (optional key) + - 'execution_time': Execution time in seconds (optional key) + """ + if self.enable_parallel_rels: + return self._rebuild_all_rels_parallel(clean_first) + else: + return self._rebuild_all_rels_sequential(clean_first) + + def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, int]: + """ + Rebuild all .rels files from scratch by analyzing all objects and their references. + + This method: + 1. Optionally cleans existing .rels files first + 2. Loads each object temporarily + 3. Analyzes its Data Object References (DORs) + 4. Creates/updates .rels files with proper SOURCE and DESTINATION relationships + + Args: + clean_first: If True, remove all existing .rels files before rebuilding + + Returns: + Dictionary with statistics: + - 'objects_processed': Number of objects analyzed + - 'rels_files_created': Number of .rels files created + - 'source_relationships': Number of SOURCE relationships created + - 'destination_relationships': Number of DESTINATION relationships created + """ + import tempfile + import shutil + + stats = { + "objects_processed": 0, + "rels_files_created": 0, + "source_relationships": 0, + "destination_relationships": 0, + } + + logging.info(f"Starting rebuild of all .rels files for {len(self._metadata)} objects...") + + # Build a map of which objects are referenced by which objects + # Key: target identifier, Value: list of (source_identifier, source_obj) + reverse_references: Dict[str, List[Tuple[str, Any]]] = {} + + # First pass: analyze all objects and build the reference map + for identifier in self._metadata: + try: + obj = self.get_object_by_identifier(identifier) + if obj is None: + continue + + stats["objects_processed"] += 1 + + # Get all DORs in this object + dors = get_direct_dor_list(obj) + + for dor in dors: + try: + target_identifier = get_obj_identifier(dor) + if target_identifier in self._metadata: + # Record this reference + if target_identifier not in reverse_references: + reverse_references[target_identifier] = [] + reverse_references[target_identifier].append((identifier, obj)) + except Exception: + pass + + except Exception as e: + logging.warning(f"Failed to analyze object {identifier}: {e}") + + # Second pass: create the .rels files + # Map of rels_file_path -> Relationships object + rels_files: Dict[str, Relationships] = {} + + # Process each object to create SOURCE relationships + for identifier in self._metadata: + try: + obj = self.get_object_by_identifier(identifier) + if obj is None: + continue + + # metadata = self._metadata[identifier] + obj_rels_path = self._gen_rels_path_from_identifier(identifier) + + # Get all DORs (objects this object references) + dors = get_direct_dor_list(obj) + + if dors: + # Create SOURCE relationships + relationships = [] + + for dor in dors: + try: + target_identifier = get_obj_identifier(dor) + if target_identifier in self._metadata: + target_metadata = self._metadata[target_identifier] + + rel = Relationship( + target=target_metadata.file_path, + type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + id=f"_{identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", + ) + relationships.append(rel) + stats["source_relationships"] += 1 + + except Exception as e: + logging.debug(f"Failed to create SOURCE relationship: {e}") + + if relationships and obj_rels_path: + if obj_rels_path not in rels_files: + rels_files[obj_rels_path] = Relationships(relationship=[]) + rels_files[obj_rels_path].relationship.extend(relationships) + + except Exception as e: + logging.warning(f"Failed to create SOURCE rels for {identifier}: {e}") + + # Add DESTINATION relationships + for target_identifier, source_list in reverse_references.items(): + try: + if target_identifier not in self._metadata: + continue + + target_metadata = self._metadata[target_identifier] + target_rels_path = self._gen_rels_path_from_identifier(target_identifier) + + if not target_rels_path: + continue + + # Create DESTINATION relationships for each object that references this one + for source_identifier, source_obj in source_list: + try: + source_metadata = self._metadata[source_identifier] + + rel = Relationship( + target=source_metadata.file_path, + type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + id=f"_{target_identifier}_{get_obj_type(get_obj_usable_class(source_obj))}_{source_identifier}", + ) + + if target_rels_path not in rels_files: + rels_files[target_rels_path] = Relationships(relationship=[]) + rels_files[target_rels_path].relationship.append(rel) + stats["destination_relationships"] += 1 + + except Exception as e: + logging.debug(f"Failed to create DESTINATION relationship: {e}") + + except Exception as e: + logging.warning(f"Failed to create DESTINATION rels for {target_identifier}: {e}") + + stats["rels_files_created"] = len(rels_files) + + # Before writing, preserve EXTERNAL_RESOURCE and other non-SOURCE/DESTINATION relationships + # This includes rels files that may not be in rels_files yet + with self._get_zip_file() as zf: + # Check all existing .rels files + for filename in zf.namelist(): + if not filename.endswith(".rels"): + continue + + try: + rels_data = zf.read(filename) + existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels_obj and existing_rels_obj.relationship: + # Preserve non-SOURCE/DESTINATION relationships (e.g., EXTERNAL_RESOURCE) + preserved_rels = [ + r + for r in existing_rels_obj.relationship + if r.type_value + not in ( + EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + ) + ] + if preserved_rels: + if filename in rels_files: + # Add preserved relationships to existing entry + rels_files[filename].relationship = preserved_rels + rels_files[filename].relationship + else: + # Create new entry with only preserved relationships + rels_files[filename] = Relationships(relationship=preserved_rels) + except Exception as e: + logging.debug(f"Could not preserve existing rels from {filename}: {e}") + + # Third pass: write the new EPC with updated .rels files + with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: + temp_path = temp_file.name + + try: + with self._get_zip_file() as source_zip: + with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zip: + # Copy all non-.rels files + for item in source_zip.infolist(): + if not (item.filename.endswith(".rels") and clean_first): + data = source_zip.read(item.filename) + target_zip.writestr(item, data) + + # Write new .rels files + for rels_path, rels_obj in rels_files.items(): + rels_xml = serialize_xml(rels_obj) + target_zip.writestr(rels_path, rels_xml) + + # Replace original file + shutil.move(temp_path, self.epc_file_path) + self._reopen_persistent_zip() + + logging.info( + f"Rebuilt .rels files: processed {stats['objects_processed']} objects, " + f"created {stats['rels_files_created']} .rels files, " + f"added {stats['source_relationships']} SOURCE and " + f"{stats['destination_relationships']} DESTINATION relationships" + ) + + return stats + + except Exception as e: + # Clean up temp file on error + if os.path.exists(temp_path): + os.unlink(temp_path) + raise RuntimeError(f"Failed to rebuild .rels files: {e}") + + def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int]: + """ + Parallel implementation of rebuild_all_rels using multiprocessing. + + Strategy: + 1. Use multiprocessing.Pool to process objects in parallel + 2. Each worker loads an object and computes its SOURCE relationships + 3. Main process aggregates results and builds DESTINATION relationships + 4. Sequential write phase (ZIP writing must be sequential) + + This bypasses Python's GIL for CPU-intensive XML parsing and provides + significant speedup for large EPCs (tested with 80+ objects). + """ + import tempfile + import shutil + import time + from multiprocessing import Pool, cpu_count + + start_time = time.time() + + stats = { + "objects_processed": 0, + "rels_files_created": 0, + "source_relationships": 0, + "destination_relationships": 0, + "parallel_mode": True, + } + + num_objects = len(self._metadata) + logging.info(f"Starting PARALLEL rebuild of all .rels files for {num_objects} objects...") + + # Prepare work items for parallel processing + # Pass metadata as dict (serializable) instead of keeping references + metadata_dict = {k: v for k, v in self._metadata.items()} + work_items = [(identifier, str(self.epc_file_path), metadata_dict) for identifier in self._metadata] + + # Determine optimal number of workers based on available CPUs and workload + # Don't spawn more workers than CPUs; use user-configurable ratio for workload per worker + worker_ratio = self.parallel_worker_ratio if hasattr(self, "parallel_worker_ratio") else _WORKER_POOL_SIZE_RATIO + num_workers = min(cpu_count(), max(1, num_objects // worker_ratio)) + logging.info(f"Using {num_workers} worker processes for {num_objects} objects (ratio: {worker_ratio})") + + # ============================================================================ + # PHASE 1: PARALLEL - Compute SOURCE relationships across worker processes + # ============================================================================ + results = [] + with Pool(processes=num_workers) as pool: + results = pool.map(_process_object_for_rels_worker, work_items) + + # ============================================================================ + # PHASE 2: SEQUENTIAL - Aggregate worker results + # ============================================================================ + # Build data structures for subsequent phases: + # - reverse_references: Map target objects to their sources (for DESTINATION rels) + # - rels_files: Accumulate all relationships by file path + # - object_types: Cache object types to eliminate redundant loads in Phase 3 + reverse_references: Dict[str, List[Tuple[str, str]]] = {} + rels_files: Dict[str, Relationships] = {} + object_types: Dict[str, str] = {} + + for result in results: + if result is None: + continue + + identifier = result["identifier"] + obj_type = result["object_type"] + source_rels = result["source_rels"] + dor_targets = result["dor_targets"] + + # Cache object type + object_types[identifier] = obj_type + + stats["objects_processed"] += 1 + + # Convert dicts back to Relationship objects + if source_rels: + obj_rels_path = self._gen_rels_path_from_identifier(identifier) + if obj_rels_path: + relationships = [] + for rel_dict in source_rels: + rel = Relationship( + target=rel_dict["target"], + type_value=rel_dict["type_value"], + id=rel_dict["id"], + ) + relationships.append(rel) + stats["source_relationships"] += 1 + + if obj_rels_path not in rels_files: + rels_files[obj_rels_path] = Relationships(relationship=[]) + rels_files[obj_rels_path].relationship.extend(relationships) + + # Build reverse reference map for DESTINATION relationships + # dor_targets now contains (target_id, target_type) tuples + for target_identifier, target_type in dor_targets: + if target_identifier not in reverse_references: + reverse_references[target_identifier] = [] + reverse_references[target_identifier].append((identifier, obj_type)) + + # ============================================================================ + # PHASE 3: SEQUENTIAL - Create DESTINATION relationships (zero object loading!) + # ============================================================================ + # Use cached object types from Phase 2 to build DESTINATION relationships + # without reloading any objects. This optimization is critical for performance. + for target_identifier, source_list in reverse_references.items(): + try: + if target_identifier not in self._metadata: + continue + + target_rels_path = self._gen_rels_path_from_identifier(target_identifier) + + if not target_rels_path: + continue + + # Use cached object types instead of loading objects! + for source_identifier, source_type in source_list: + try: + source_metadata = self._metadata[source_identifier] + + # No object loading needed - we have all the type info from Phase 2! + rel = Relationship( + target=source_metadata.file_path, + type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + id=f"_{target_identifier}_{source_type}_{source_identifier}", + ) + + if target_rels_path not in rels_files: + rels_files[target_rels_path] = Relationships(relationship=[]) + rels_files[target_rels_path].relationship.append(rel) + stats["destination_relationships"] += 1 + + except Exception as e: + logging.debug(f"Failed to create DESTINATION relationship: {e}") + + except Exception as e: + logging.warning(f"Failed to create DESTINATION rels for {target_identifier}: {e}") + + stats["rels_files_created"] = len(rels_files) + + # ============================================================================ + # PHASE 4: SEQUENTIAL - Preserve non-object relationships + # ============================================================================ + # Preserve EXTERNAL_RESOURCE and other non-standard relationship types + with self._get_zip_file() as zf: + for filename in zf.namelist(): + if not filename.endswith(".rels"): + continue + + try: + rels_data = zf.read(filename) + existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) + if existing_rels_obj and existing_rels_obj.relationship: + preserved_rels = [ + r + for r in existing_rels_obj.relationship + if r.type_value + not in ( + EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + ) + ] + if preserved_rels: + if filename in rels_files: + rels_files[filename].relationship = preserved_rels + rels_files[filename].relationship + else: + rels_files[filename] = Relationships(relationship=preserved_rels) + except Exception as e: + logging.debug(f"Could not preserve existing rels from {filename}: {e}") + + # ============================================================================ + # PHASE 5: SEQUENTIAL - Write all relationships to ZIP file + # ============================================================================ + # ZIP file writing must be sequential (file format limitation) + with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: + temp_path = temp_file.name + + try: + with self._get_zip_file() as source_zip: + with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zip: + # Copy all non-.rels files + for item in source_zip.infolist(): + if not (item.filename.endswith(".rels") and clean_first): + data = source_zip.read(item.filename) + target_zip.writestr(item, data) + + # Write new .rels files + for rels_path, rels_obj in rels_files.items(): + rels_xml = serialize_xml(rels_obj) + target_zip.writestr(rels_path, rels_xml) + + # Replace original file + shutil.move(temp_path, self.epc_file_path) + self._reopen_persistent_zip() + + execution_time = time.time() - start_time + stats["execution_time"] = execution_time + + logging.info( + f"Rebuilt .rels files (PARALLEL): processed {stats['objects_processed']} objects, " + f"created {stats['rels_files_created']} .rels files, " + f"added {stats['source_relationships']} SOURCE and " + f"{stats['destination_relationships']} DESTINATION relationships " + f"in {execution_time:.2f}s using {num_workers} workers" + ) + + return stats + + except Exception as e: + if os.path.exists(temp_path): + os.unlink(temp_path) + raise RuntimeError(f"Failed to rebuild .rels files (parallel): {e}") + + def __repr__(self) -> str: + """String representation.""" + return ( + f"EpcStreamReader(path='{self.epc_file_path}', " + f"objects={len(self._metadata)}, " + f"cached={len(self._object_cache)}, " + f"cache_hit_rate={self.stats.cache_hit_rate:.1f}%)" + ) + + def dumps_epc_content_and_files_lists(self): + """Dump EPC content and files lists for debugging.""" + content_list = [] + file_list = [] + + with self._get_zip_file() as zf: + file_list = zf.namelist() + + for item in zf.infolist(): + content_list.append(f"{item.filename} - {item.file_size} bytes") + + return { + "content_list": sorted(content_list), + "file_list": sorted(file_list), + } + + +# Utility functions for backward compatibility + + +def read_epc_stream(epc_file_path: Union[str, Path], **kwargs) -> EpcStreamReader: + """ + Factory function to create EpcStreamReader instance. + + Args: + epc_file_path: Path to EPC file + **kwargs: Additional arguments for EpcStreamReader + + Returns: + EpcStreamReader instance + """ + return EpcStreamReader(epc_file_path, **kwargs) + + +__all__ = ["EpcStreamReader", "EpcObjectMetadata", "EpcStreamingStats", "read_epc_stream"] diff --git a/energyml-utils/src/energyml/utils/epc_utils.py b/energyml-utils/src/energyml/utils/epc_utils.py new file mode 100644 index 0000000..6df94fb --- /dev/null +++ b/energyml-utils/src/energyml/utils/epc_utils.py @@ -0,0 +1,306 @@ +# Copyright (c) 2023-2024 Geosiris. +# SPDX-License-Identifier: Apache-2.0 + + +from io import BytesIO +import logging +from typing import Optional, Tuple, Union, Any, List, Dict +from pathlib import Path +import zipfile + +from energyml.opc.opc import ( + CoreProperties, + Relationship, + TargetMode, + Created, + Creator, + Identifier, + Types, + Default, + Override, +) + +from energyml.utils.constants import ( + EPCRelsRelationshipType, + EpcExportVersion, + RELS_FOLDER_NAME, + epoch, + epoch_to_date, + extract_uuid_from_string, + gen_uuid, + MimeType, +) +from energyml.utils.introspection import ( + get_dor_obj_info, + get_object_type_for_file_path_from_class, + is_dor, + get_class_pkg_version, + get_obj_version, + get_obj_uuid, +) +from energyml.utils.manager import get_class_pkg +from energyml.utils.serialization import read_energyml_xml_str, serialize_xml +from energyml.utils.uri import Uri, parse_uri + + +# ____ ___ ________ __ +# / __ \/ |/_ __/ / / / +# / /_/ / /| | / / / /_/ / +# / ____/ ___ |/ / / __ / +# /_/ /_/ |_/_/ /_/ /_/ + +EXPANDED_EXPORT_FOLDER_PREFIX = "namespace_" +PATH_VERSION_PREFIX = "version_" + + +def gen_core_props_rels_path() -> str: + """ + Generate a path to store the core properties rels file into an epc file + :return: + """ + core_path = Path(gen_core_props_path()) + + return (core_path.parent / RELS_FOLDER_NAME / f"{core_path.name}.rels").as_posix() + + +def gen_core_props_path( + export_version: EpcExportVersion = EpcExportVersion.CLASSIC, +) -> str: + """ + Generate a path to store the core properties file into an epc file (depending on the :param:`export_version`) + :param export_version: the version of the EPC export to use (classic or expanded) + :return: + """ + return "docProps/core.xml" + + +def gen_energyml_object_path( + energyml_object: Union[str, Uri, Any], + export_version: EpcExportVersion = EpcExportVersion.CLASSIC, +) -> str: + """ + Generate a path to store the :param:`energyml_object` into an epc file (depending on the :param:`export_version`) + :param energyml_object: can be either an EnergyML object or a string containing the XML representation of an EnergyML object, or a string containing the URI of an EnergyML object, or a Uri object representing an EnergyML object + :param export_version: the version of the EPC export to use (classic or expanded) + :return: + """ + if isinstance(energyml_object, str): + if energyml_object.startswith("eml:///"): + energyml_object = parse_uri(energyml_object.strip()) + else: + energyml_object = read_energyml_xml_str(energyml_object) + if isinstance(energyml_object, Uri): + obj_type = energyml_object.object_type + uuid = energyml_object.uuid + pkg = energyml_object.domain + pkg_version = energyml_object.domain_version + object_version = energyml_object.version + elif is_dor(energyml_object): + uuid, pkg, pkg_version, obj_cls, object_version = get_dor_obj_info(energyml_object) + obj_type = get_object_type_for_file_path_from_class(obj_cls) + elif isinstance(energyml_object, CoreProperties): + return gen_core_props_path(export_version) + else: + obj_type = get_object_type_for_file_path_from_class(energyml_object.__class__) + # logging.debug("is_dor: ", str(is_dor(energyml_object)), "object type : " + str(obj_type)) + pkg = get_class_pkg(energyml_object) + pkg_version = get_class_pkg_version(energyml_object) + object_version = get_obj_version(energyml_object) + uuid = get_obj_uuid(energyml_object) + + if not uuid or len(uuid) == 0: + raise ValueError(f"The object must have a valid uuid to be stored in an epc file - {energyml_object}") + if not obj_type or len(obj_type) == 0: + raise ValueError(f"The object must have a valid type to be stored in an epc file - {energyml_object}") + if not pkg or len(pkg) == 0: + raise ValueError(f"The object must have a valid package to be stored in an epc file - {energyml_object}") + if not pkg_version or len(pkg_version) == 0: + raise ValueError( + f"The object must have a valid package version to be stored in an epc file - {energyml_object}" + ) + + if export_version == EpcExportVersion.EXPANDED: + # TODO: verify if we need to add a "/" at the begining of the path or not + return f"{EXPANDED_EXPORT_FOLDER_PREFIX}{pkg}{pkg_version.replace('.', '')}/{(PATH_VERSION_PREFIX + object_version + '/') if object_version is not None and len(object_version) > 0 else ''}{obj_type}_{uuid}.xml" + else: + return obj_type + "_" + uuid + ".xml" + + +def gen_rels_path( + energyml_object: Any, + export_version: EpcExportVersion = EpcExportVersion.CLASSIC, +) -> str: + """ + Generate a path to store the :param:`energyml_object` rels file into an epc file + (depending on the :param:`export_version`) + :param energyml_object: + :param export_version: + :return: + """ + if isinstance(energyml_object, CoreProperties): + return gen_core_props_rels_path() + else: + obj_path = Path(gen_energyml_object_path(energyml_object, export_version)) + return gen_rels_path_from_obj_path(obj_path=obj_path) + + +def gen_rels_path_from_obj_path(obj_path: Union[str, Path]) -> str: + """ + Generate a path to store the rels file into an epc file from the object path + :param obj_path: the path of the object file (e.g. "namespace_pkg1.0/version_1.0/ObjType_uuid.xml" or "ObjType_uuid.xml") + :return: the path to store the rels file (e.g. "namespace_pkg1.0/version_1.0/_rels/ObjType_uuid.xml.rels" or "_rels/ObjType_uuid.xml.rels") + """ + _obj_path = Path(obj_path) if not isinstance(obj_path, Path) else obj_path + if _obj_path.parent.name == RELS_FOLDER_NAME: + raise ValueError(f"The object path cannot be in the '{RELS_FOLDER_NAME}' folder") + return (_obj_path.parent / RELS_FOLDER_NAME / f"{_obj_path.name}.rels").as_posix() + + +def get_epc_content_type_path( + # export_version: EpcExportVersion = EpcExportVersion.CLASSIC, +) -> str: + """ + Generate a path to store the "[Content_Types].xml" file into an epc file + :return: + """ + return "[Content_Types].xml" + + +def extract_uuid_and_version_from_obj_path(obj_path: Union[str, Path]) -> Tuple[str, Optional[str]]: + """ + Extract the uuid and version of an object from its path in the epc file + :param obj_path: the path of the object file (e.g. "namespace_pkg1.0/version_1.0/ObjType_uuid.xml" or "ObjType_uuid.xml") + :return: a tuple containing the uuid and version of the object + """ + _obj_path = Path(obj_path) if not isinstance(obj_path, Path) else obj_path + + uuid_match = extract_uuid_from_string(str(_obj_path)) + if uuid_match is None: + raise ValueError(f"Cannot extract uuid from object path: {obj_path}") + + # If this data object is versioned, the unique path should contain a directory called 'version_id' (where id is the identifier for the data object version). + version = None + for part in _obj_path.parts: + if part.startswith(PATH_VERSION_PREFIX): + version = part[len(PATH_VERSION_PREFIX) :] + + return uuid_match, version + + +# __ ____________ ______ +# / |/ / _/ ___// ____/ +# / /|_/ // / \__ \/ / +# / / / // / ___/ / /___ +# /_/ /_/___//____/\____/ + + +def create_h5_external_relationship(h5_path: str, current_idx: int = 0) -> Relationship: + """ + Create a Relationship object to link an external HDF5 file. + :param h5_path: + :return: + """ + return Relationship( + target=h5_path, + type_value=EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(), + id=f"Hdf5File{current_idx + 1 if current_idx > 0 else ''}", + target_mode=TargetMode.EXTERNAL, + ) + + +def create_default_core_properties(creator: Optional[str] = None) -> CoreProperties: + """Create default Core Properties object.""" + return CoreProperties( + created=Created(any_element=epoch_to_date(epoch())), + creator=Creator(any_element=creator or "energyml-utils python module (Geosiris)"), + identifier=Identifier(any_element=f"urn:uuid:{gen_uuid()}"), + version="1.0", + ) + + +def create_default_types() -> Types: + """Create default Types object.""" + return Types( + default=[Default(extension="rels", content_type=str(MimeType.RELS))], + override=[Override(content_type=str(MimeType.CORE_PROPERTIES), part_name=gen_core_props_path())], + ) + + +# _ __ ___ __ __ _ +# | | / /___ _/ (_)___/ /___ _/ /_(_)___ ____ +# | | / / __ `/ / / __ / __ `/ __/ / __ \/ __ \ +# | |/ / /_/ / / / /_/ / /_/ / /_/ / /_/ / / / / +# |___/\__,_/_/_/\__,_/\__,_/\__/_/\____/_/ /_/ + + +def valdiate_basic_epc_structure(epc: Union[str, Path, zipfile.ZipFile, BytesIO]) -> bool: + should_close = False + if isinstance(epc, (str, Path)): + epc_io = zipfile.ZipFile(epc, "r") + should_close = True + elif isinstance(epc, BytesIO): + epc_io = zipfile.ZipFile(epc, "r") + should_close = True + elif isinstance(epc, zipfile.ZipFile): + epc_io = epc + else: + raise ValueError("The epc parameter must be a string, a Path, a ZipFile or a BytesIO object") + + # Check if the EPC file contains the required files: [Content_Types].xml, _rels/.rels and docProps/core.xml + required_files = { + get_epc_content_type_path(), + gen_core_props_rels_path(), + gen_core_props_path(), + } + + try: + epc_files = set(epc_io.namelist()) + missing_files = required_files - epc_files + if missing_files: + logging.warning(f"The EPC file is missing the following required files: {missing_files}") + return False + finally: + if should_close: + epc_io.close() + + return True + + +def create_mandatory_structure_epc(epc: Union[str, Path, zipfile.ZipFile, BytesIO]) -> None: + # Create a zip file with the minimal structure of an EPC file, including [Content_Types].xml and _rels/.rels and core properties + should_close = False + if isinstance(epc, (str, Path)): + epc_io = zipfile.ZipFile(epc, "a", zipfile.ZIP_DEFLATED) + should_close = True + elif isinstance(epc, BytesIO): + epc_io = zipfile.ZipFile(epc, "a", zipfile.ZIP_DEFLATED) + should_close = True + elif isinstance(epc, zipfile.ZipFile): + if epc.mode == "r": + raise ValueError("Cannot write to a read-only ZipFile. Open it in 'a' or 'w' mode.") + epc_io = epc + else: + raise ValueError("The epc parameter must be a string, a Path, a ZipFile or a BytesIO object") + + core_props = create_default_core_properties() + empty_epc_structure = { + get_epc_content_type_path(): serialize_xml(Types()), + gen_core_props_rels_path(): serialize_xml(Relationship()), + gen_core_props_path(): serialize_xml(core_props), + } + + # print(f"Current files in the EPC: {epc_io.namelist()}") + # print(f"Potential created files: {list(empty_epc_structure.keys())}") + try: + for path, content in empty_epc_structure.items(): + if path not in epc_io.namelist(): + epc_io.writestr(path, content) + finally: + if should_close: + epc_io.close() + + +def repair_epc_structure_if_not_valid(epc: Union[str, Path, zipfile.ZipFile, BytesIO]) -> None: + if not valdiate_basic_epc_structure(epc): + logging.warning("EPC structure validation failed. Attempting auto-repair.") + create_mandatory_structure_epc(epc) diff --git a/energyml-utils/src/energyml/utils/epc_validator.py b/energyml-utils/src/energyml/utils/epc_validator.py new file mode 100644 index 0000000..253d670 --- /dev/null +++ b/energyml-utils/src/energyml/utils/epc_validator.py @@ -0,0 +1,618 @@ +# Copyright (c) 2023-2024 Geosiris. +# SPDX-License-Identifier: Apache-2.0 +""" +EPC (Energistics Packaging Conventions) Validator Module. + +This module provides comprehensive validation for EPC v1.0 files according to +the Energistics Packaging Conventions specification. It validates: +- ZIP container integrity +- Presence and validity of Core Properties +- Content Types XML structure and validity +- Relationships (.rels) consistency +- Compliance with EPC naming conventions and structure +""" + +import logging +import re +import zipfile +from dataclasses import dataclass, field +from io import BytesIO +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Union + +from energyml.opc.opc import CoreProperties, Override, Relationship, Relationships, Types +from xsdata.formats.dataclass.parsers import XmlParser +from xsdata.exceptions import ParserError + +from .constants import RELS_CONTENT_TYPE, EpcExportVersion +from .exception import ( + ContentTypeValidationError, + CorePropertiesValidationError, + EpcValidationError, + InvalidXmlStructureError, + MissingRequiredFileError, + NamingConventionError, + RelationshipValidationError, + ZipIntegrityError, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class ValidationResult: + """Results from EPC validation. + + Attributes: + is_valid: Whether the EPC file passed validation. + errors: List of critical errors that prevent file from being valid. + warnings: List of non-critical issues that should be reviewed. + info: List of informational messages about the validation. + """ + + is_valid: bool = True + errors: List[str] = field(default_factory=list) + warnings: List[str] = field(default_factory=list) + info: List[str] = field(default_factory=list) + + def add_error(self, message: str) -> None: + """Add an error and mark validation as failed. + + Args: + message: Error message to add. + """ + self.errors.append(message) + self.is_valid = False + + def add_warning(self, message: str) -> None: + """Add a warning message. + + Args: + message: Warning message to add. + """ + self.warnings.append(message) + + def add_info(self, message: str) -> None: + """Add an informational message. + + Args: + message: Info message to add. + """ + self.info.append(message) + + def __str__(self) -> str: + """Return formatted validation result.""" + lines = [f"Validation Result: {'PASSED' if self.is_valid else 'FAILED'}"] + if self.errors: + lines.append(f"\nErrors ({len(self.errors)}):") + for error in self.errors: + lines.append(f" - {error}") + if self.warnings: + lines.append(f"\nWarnings ({len(self.warnings)}):") + for warning in self.warnings: + lines.append(f" - {warning}") + if self.info: + lines.append(f"\nInfo ({len(self.info)}):") + for info in self.info: + lines.append(f" - {info}") + return "\n".join(lines) + + +class EpcParser: + """Parser for EPC file components. + + This class handles parsing of EPC files without performing validation. + It extracts and parses the various components of an EPC file. + """ + + def __init__(self, epc_path: Union[str, Path, BytesIO]): + """Initialize EPC parser. + + Args: + epc_path: Path to EPC file or BytesIO object containing EPC data. + + Raises: + FileNotFoundError: If the specified file doesn't exist. + ZipIntegrityError: If the file is not a valid ZIP archive. + """ + self.epc_path = epc_path + self._zip_file: Optional[zipfile.ZipFile] = None + self._content_types: Optional[Types] = None + self._core_properties: Optional[CoreProperties] = None + self._relationships: Dict[str, Relationships] = {} + self._xml_parser = XmlParser() + + def __enter__(self): + """Context manager entry.""" + self.open() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.close() + + def open(self) -> None: + """Open the EPC ZIP file. + + Raises: + FileNotFoundError: If the file doesn't exist. + ZipIntegrityError: If the file is not a valid ZIP archive. + """ + try: + if isinstance(self.epc_path, BytesIO): + self._zip_file = zipfile.ZipFile(self.epc_path, "r") + else: + path = Path(self.epc_path) + if not path.exists(): + raise FileNotFoundError(f"EPC file not found: {self.epc_path}") + self._zip_file = zipfile.ZipFile(path, "r") + except zipfile.BadZipFile as e: + raise ZipIntegrityError(f"Invalid ZIP file: {e}") from e + + def close(self) -> None: + """Close the ZIP file.""" + if self._zip_file: + self._zip_file.close() + self._zip_file = None + + def list_files(self) -> List[str]: + """List all files in the EPC archive. + + Returns: + List of file paths within the archive. + + Raises: + ZipIntegrityError: If ZIP file is not open. + """ + if not self._zip_file: + raise ZipIntegrityError("ZIP file is not open") + return self._zip_file.namelist() + + def read_file(self, path: str) -> bytes: + """Read a file from the EPC archive. + + Args: + path: Path to file within the archive. + + Returns: + File contents as bytes. + + Raises: + MissingRequiredFileError: If the file doesn't exist in the archive. + """ + if not self._zip_file: + raise ZipIntegrityError("ZIP file is not open") + try: + return self._zip_file.read(path) + except KeyError as e: + raise MissingRequiredFileError(f"File not found in archive: {path}") from e + + def parse_content_types(self) -> Types: + """Parse [Content_Types].xml file. + + Returns: + Parsed Types object. + + Raises: + MissingRequiredFileError: If [Content_Types].xml is missing. + InvalidXmlStructureError: If XML is malformed. + """ + if self._content_types is not None: + return self._content_types + + content_types_path = "[Content_Types].xml" + try: + xml_content = self.read_file(content_types_path) + self._content_types = self._xml_parser.from_bytes(xml_content, Types) + return self._content_types + except MissingRequiredFileError: + raise + except (ParserError, Exception) as e: + raise InvalidXmlStructureError( + f"Failed to parse {content_types_path}: {e}", + details={"file": content_types_path}, + ) from e + + def parse_core_properties(self, core_props_path: str = "docProps/core.xml") -> Optional[CoreProperties]: + """Parse core properties XML file. + + Args: + core_props_path: Path to core properties file. + + Returns: + Parsed CoreProperties object or None if file doesn't exist. + + Raises: + InvalidXmlStructureError: If XML is malformed. + """ + if self._core_properties is not None: + return self._core_properties + + try: + xml_content = self.read_file(core_props_path) + self._core_properties = self._xml_parser.from_bytes(xml_content, CoreProperties) + return self._core_properties + except MissingRequiredFileError: + return None + except (ParserError, Exception) as e: + raise InvalidXmlStructureError( + f"Failed to parse {core_props_path}: {e}", + details={"file": core_props_path}, + ) from e + + def parse_relationships(self, rels_path: str) -> Relationships: + """Parse a relationships file. + + Args: + rels_path: Path to .rels file. + + Returns: + Parsed Relationships object. + + Raises: + InvalidXmlStructureError: If XML is malformed. + """ + if rels_path in self._relationships: + return self._relationships[rels_path] + + try: + xml_content = self.read_file(rels_path) + relationships = self._xml_parser.from_bytes(xml_content, Relationships) + self._relationships[rels_path] = relationships + return relationships + except MissingRequiredFileError: + # Return empty relationships if file doesn't exist + return Relationships(relationship=[]) + except (ParserError, Exception) as e: + raise InvalidXmlStructureError( + f"Failed to parse {rels_path}: {e}", + details={"file": rels_path}, + ) from e + + def find_all_rels_files(self) -> List[str]: + """Find all .rels files in the archive. + + Returns: + List of paths to .rels files. + """ + if not self._zip_file: + raise ZipIntegrityError("ZIP file is not open") + return [f for f in self._zip_file.namelist() if f.endswith(".rels")] + + +class EpcValidator: + """Validator for EPC (Energistics Packaging Conventions) files. + + This class provides comprehensive validation of EPC v1.0 files according + to the Energistics Packaging Conventions specification. + + Example: + >>> validator = EpcValidator("my_file.epc") + >>> result = validator.validate() + >>> if result.is_valid: + ... print("EPC file is valid!") + ... else: + ... print("Validation errors:") + ... for error in result.errors: + ... print(f" - {error}") + """ + + # Required EPC files + REQUIRED_FILES = ["[Content_Types].xml", "_rels/.rels"] + + # Core properties content type + CORE_PROPS_CONTENT_TYPE = "application/vnd.openxmlformats-package.core-properties+xml" + + # Correct relationships content type (note: the constant RELS_CONTENT_TYPE in constants.py is incorrect) + RELS_CONTENT_TYPE_CORRECT = "application/vnd.openxmlformats-package.relationships+xml" + + # Valid EPC object content type patterns + ENERGYML_CONTENT_TYPE_PATTERN = re.compile(r"^application/x-(resqml|witsml|prodml)\+xml;version=\d+\.\d+;type=obj_") + + def __init__( + self, + epc_path: Union[str, Path, BytesIO], + strict: bool = True, + check_relationships: bool = True, + ): + """Initialize EPC validator. + + Args: + epc_path: Path to EPC file or BytesIO object. + strict: If True, enforce strict validation rules. + check_relationships: If True, validate relationship consistency. + """ + self.epc_path = epc_path + self.strict = strict + self.check_relationships = check_relationships + self.parser = EpcParser(epc_path) + self.result = ValidationResult() + + def validate(self) -> ValidationResult: + """Perform comprehensive EPC validation. + + Returns: + ValidationResult with validation outcome and any issues found. + """ + try: + with self.parser: + self._validate_zip_integrity() + self._validate_required_files() + self._validate_content_types() + self._validate_core_properties() + + if self.check_relationships: + self._validate_relationships() + + self._validate_naming_conventions() + + if self.result.is_valid: + self.result.add_info("EPC file validation passed successfully") + + except EpcValidationError as e: + self.result.add_error(str(e)) + except Exception as e: + self.result.add_error(f"Unexpected error during validation: {e}") + logger.exception("Unexpected validation error") + + return self.result + + def _validate_zip_integrity(self) -> None: + """Validate ZIP container integrity. + + Raises: + ZipIntegrityError: If ZIP structure is corrupt. + """ + try: + # Test ZIP integrity by attempting to list files + files = self.parser.list_files() + self.result.add_info(f"ZIP container contains {len(files)} files") + + # Check for directory entries + directories = [f for f in files if f.endswith("/")] + if directories: + self.result.add_info(f"Found {len(directories)} directory entries") + + except Exception as e: + raise ZipIntegrityError(f"ZIP integrity check failed: {e}") from e + + def _validate_required_files(self) -> None: + """Validate presence of required EPC files. + + Raises: + MissingRequiredFileError: If required files are missing. + """ + files = self.parser.list_files() + files_lower = {f.lower(): f for f in files} + + for required_file in self.REQUIRED_FILES: + # Case-insensitive check + if required_file.lower() not in files_lower: + raise MissingRequiredFileError( + f"Required file missing: {required_file}", + details={"file": required_file}, + ) + self.result.add_info(f"Found required file: {required_file}") + + def _validate_content_types(self) -> None: + """Validate [Content_Types].xml structure and content. + + Raises: + ContentTypeValidationError: If content types are invalid. + """ + try: + content_types = self.parser.parse_content_types() + + # Validate that .rels extension has correct content type + rels_default = None + if content_types.default: + for default in content_types.default: + if default.extension == "rels": + rels_default = default + if default.content_type != self.RELS_CONTENT_TYPE_CORRECT: + self.result.add_warning( + f"Non-standard content type for .rels files: {default.content_type}. " + f"Expected: {self.RELS_CONTENT_TYPE_CORRECT}" + ) + break + + if not rels_default: + self.result.add_warning("No default content type defined for .rels files") + + # Validate overrides + if content_types.override: + self.result.add_info(f"Found {len(content_types.override)} content type overrides") + + energyml_objects = 0 + core_props_found = False + + for override in content_types.override: + # Check for core properties + if override.content_type == self.CORE_PROPS_CONTENT_TYPE: + core_props_found = True + self.result.add_info(f"Core properties defined at: {override.part_name}") + + # Check for Energyml objects + elif override.content_type and self.ENERGYML_CONTENT_TYPE_PATTERN.match(override.content_type): + energyml_objects += 1 + + # Validate part name format + if override.part_name and not override.part_name.startswith("/"): + if self.strict: + self.result.add_error(f"Part name must start with '/': {override.part_name}") + else: + self.result.add_warning(f"Part name should start with '/': {override.part_name}") + + self.result.add_info(f"Found {energyml_objects} Energyml objects") + + if not core_props_found: + self.result.add_warning("No core properties override found in content types") + + else: + self.result.add_warning("No content type overrides defined") + + except InvalidXmlStructureError: + raise + except Exception as e: + raise ContentTypeValidationError(f"Content types validation failed: {e}") from e + + def _validate_core_properties(self) -> None: + """Validate core properties file. + + Raises: + CorePropertiesValidationError: If core properties are invalid. + """ + try: + # Try different possible paths for core properties + core_props_paths = [ + "docProps/core.xml", + "/docProps/core.xml", + "metadata/core.xml", + "/metadata/core.xml", + ] + + core_props = None + found_path = None + + for path in core_props_paths: + core_props = self.parser.parse_core_properties(path) + if core_props: + found_path = path + break + + if not core_props: + if self.strict: + raise CorePropertiesValidationError("Core properties file not found") + else: + self.result.add_warning("Core properties file not found") + return + + self.result.add_info(f"Found core properties at: {found_path}") + + # Validate core properties content + if hasattr(core_props, "creator") and core_props.creator: + self.result.add_info(f"Creator: {core_props.creator}") + else: + self.result.add_warning("Core properties missing 'creator' field") + + if hasattr(core_props, "created") and core_props.created: + self.result.add_info("Core properties contain creation date") + else: + self.result.add_warning("Core properties missing 'created' field") + + except InvalidXmlStructureError: + raise + except Exception as e: + raise CorePropertiesValidationError(f"Core properties validation failed: {e}") from e + + def _validate_relationships(self) -> None: + """Validate relationships consistency. + + Raises: + RelationshipValidationError: If relationships are invalid. + """ + try: + # Find all .rels files + rels_files = self.parser.find_all_rels_files() + self.result.add_info(f"Found {len(rels_files)} relationship files") + + all_files = set(self.parser.list_files()) + relationship_targets: Set[str] = set() + + for rels_file in rels_files: + try: + relationships = self.parser.parse_relationships(rels_file) + + if not relationships.relationship: + self.result.add_warning(f"Empty relationships file: {rels_file}") + continue + + for rel in relationships.relationship: + # Validate relationship has required attributes + if not rel.id or rel.id.strip() == "": + self.result.add_error(f"Relationship missing or has empty 'Id' in {rels_file}") + + if not rel.type_value: + self.result.add_error(f"Relationship missing 'Type' in {rels_file}") + + if not rel.target: + self.result.add_error(f"Relationship missing 'Target' in {rels_file}") + continue + + # Check if target exists (for internal targets) + if not rel.target.startswith("http"): + # Normalize target path + target = rel.target.lstrip("/") + relationship_targets.add(target) + + if target not in all_files: + # Check with leading slash + target_with_slash = "/" + target + if target_with_slash not in all_files: + self.result.add_error( + f"Relationship target not found: {rel.target} (from {rels_file})" + ) + + except InvalidXmlStructureError as e: + self.result.add_error(f"Invalid relationships file {rels_file}: {e}") + + except Exception as e: + raise RelationshipValidationError(f"Relationships validation failed: {e}") from e + + def _validate_naming_conventions(self) -> None: + """Validate EPC naming conventions. + + Raises: + NamingConventionError: If naming conventions are violated. + """ + try: + files = self.parser.list_files() + + # Check for invalid characters in file names + invalid_chars = ["\\", ":", "*", "?", '"', "<", ">", "|"] + + for file_path in files: + # Check for invalid characters + for char in invalid_chars: + if char in file_path: + self.result.add_error(f"Invalid character '{char}' in file path: {file_path}") + + # Check _rels folder naming + if "_rels" in file_path and not file_path.startswith("_rels/"): + parts = file_path.split("/") + valid_rels = any(i > 0 and parts[i] == "_rels" and parts[i - 1] != "" for i in range(len(parts))) + if not valid_rels and file_path != "_rels/.rels": + self.result.add_warning(f"Unusual _rels folder location: {file_path}") + + # Check .rels file naming + if file_path.endswith(".rels"): + if not file_path.endswith("/.rels"): + # Should be in _rels folder with corresponding source file + if "/_rels/" not in file_path: + self.result.add_warning(f"Relationship file not in _rels folder: {file_path}") + + except Exception as e: + raise NamingConventionError(f"Naming convention validation failed: {e}") from e + + +def validate_epc_file( + epc_path: Union[str, Path, BytesIO], + strict: bool = True, + check_relationships: bool = True, +) -> ValidationResult: + """Convenience function to validate an EPC file. + + Args: + epc_path: Path to EPC file or BytesIO object. + strict: If True, enforce strict validation rules. + check_relationships: If True, validate relationship consistency. + + Returns: + ValidationResult with validation outcome. + + Example: + >>> result = validate_epc_file("my_file.epc") + >>> print(result) + """ + validator = EpcValidator(epc_path, strict=strict, check_relationships=check_relationships) + return validator.validate() diff --git a/energyml-utils/src/energyml/utils/exception.py b/energyml-utils/src/energyml/utils/exception.py index fac041f..31638ec 100644 --- a/energyml-utils/src/energyml/utils/exception.py +++ b/energyml-utils/src/energyml/utils/exception.py @@ -46,3 +46,75 @@ class NotSupportedError(Exception): def __init__(self, msg): super().__init__(msg) + + +# EPC Validation Exceptions + + +class EpcValidationError(Exception): + """Base exception for EPC validation errors.""" + + def __init__(self, message: str, details: Optional[dict] = None): + """Initialize EPC validation error. + + Args: + message: Error message describing the validation failure. + details: Optional dictionary with additional error context. + """ + super().__init__(message) + self.message = message + self.details = details or {} + + def __str__(self) -> str: + """Return string representation of the error.""" + if self.details: + details_str = ", ".join(f"{k}={v}" for k, v in self.details.items()) + return f"{self.message} ({details_str})" + return self.message + + +class ZipIntegrityError(EpcValidationError): + """Exception raised when ZIP container integrity check fails.""" + + pass + + +class MissingRequiredFileError(EpcValidationError): + """Exception raised when required EPC files are missing.""" + + pass + + +class InvalidXmlStructureError(EpcValidationError): + """Exception raised when XML structure is invalid.""" + + pass + + +class RelationshipValidationError(EpcValidationError): + """Exception raised when relationship validation fails.""" + + pass + + +class NamingConventionError(EpcValidationError): + """Exception raised when naming conventions are violated.""" + + pass + + +class ContentTypeValidationError(EpcValidationError): + """Exception raised when content type validation fails.""" + + pass + + +class CorePropertiesValidationError(EpcValidationError): + """Exception raised when core properties validation fails.""" + + pass + + +class NotUriError(Exception): + def __init__(self, uri: Optional[str] = None): + super().__init__(f"Not a valid URI: {uri}") diff --git a/energyml-utils/src/energyml/utils/introspection.py b/energyml-utils/src/energyml/utils/introspection.py index 00408aa..f962ebd 100644 --- a/energyml-utils/src/energyml/utils/introspection.py +++ b/energyml-utils/src/energyml/utils/introspection.py @@ -6,6 +6,7 @@ import random import re import sys +import traceback import typing from dataclasses import Field, field from enum import Enum @@ -1147,10 +1148,16 @@ def get_obj_version(obj: Any) -> Optional[str]: try: return get_object_attribute_no_verif(obj, "object_version") except AttributeError: + # AttributeError is expected when attribute doesn't exist - try alternative try: return get_object_attribute_no_verif(obj, "version_string") except Exception: - logging.error(f"Error with {type(obj)}") + # Log with full call stack to see WHO called this function + logging.error( + f"Error getting version for {type(obj)} -- {obj}", + exc_info=True, + stack_info=True, # This shows the full call stack including caller + ) return None # raise e diff --git a/energyml-utils/src/energyml/utils/storage_interface.py b/energyml-utils/src/energyml/utils/storage_interface.py index 99a58d1..45008d4 100644 --- a/energyml-utils/src/energyml/utils/storage_interface.py +++ b/energyml-utils/src/energyml/utils/storage_interface.py @@ -93,6 +93,42 @@ def identifier(self) -> str: return f"{self.uuid}.{self.version}" return self.uuid + def __str__(self): + return f"{'[' + self.title + '] ' if self.title else ''}{self.uri}" + + +def create_resource_metadata_from_uri( + uri: Uri, + title: Optional[str] = None, + last_changed: Optional[datetime] = None, + custom_data: Optional[Dict[str, Any]] = None, + source_count: Optional[int] = None, + target_count: Optional[int] = None, +) -> ResourceMetadata: + """ + Create ResourceMetadata from an ETP URI. + + Args: + uri: ETP URI (e.g., 'eml:///dataspace('default')/resqml22.TriangulatedSetRepresentation('uuid.version')') + Returns: + ResourceMetadata instance with fields extracted from the URI + """ + if not uri.is_object_uri(): + raise ValueError("URI must be an object URI to create ResourceMetadata") + return ResourceMetadata( + uri=str(uri), + uuid=uri.uuid or "", + title=title or "", + object_type=uri.object_type or "", + content_type=uri.get_content_type(), + version=uri.version, + dataspace=uri.dataspace, + custom_data=custom_data or {}, + source_count=source_count, + target_count=target_count, + last_changed=last_changed, + ) + @dataclass class DataArrayMetadata: diff --git a/energyml-utils/src/energyml/utils/uri.py b/energyml-utils/src/energyml/utils/uri.py index da05b1d..681c4ca 100644 --- a/energyml-utils/src/energyml/utils/uri.py +++ b/energyml-utils/src/energyml/utils/uri.py @@ -1,8 +1,14 @@ # Copyright (c) 2023-2024 Geosiris. # SPDX-License-Identifier: Apache-2.0 +import re from typing import Optional from dataclasses import dataclass, field + +from energyml.utils.exception import ContentTypeValidationError, NotUriError from .constants import ( + RGX_CT_ENERGYML_DOMAIN, + RGX_CT_TOKEN_TYPE, + RGX_CT_TOKEN_VERSION, URI_RGX_GRP_DATASPACE, URI_RGX_GRP_DOMAIN, URI_RGX_GRP_DOMAIN_VERSION, @@ -15,6 +21,7 @@ URI_RGX_GRP_COLLECTION_TYPE, URI_RGX_GRP_QUERY, OptimizedRegex, + parse_content_or_qualified_type, ) @@ -39,7 +46,7 @@ class Uri: query: Optional[str] = field(default=None) @classmethod - def parse(cls, uri: str): + def parse(cls, uri: str) -> "Uri": m = OptimizedRegex.URI.match(uri) if m is not None: res = Uri() @@ -57,7 +64,7 @@ def parse(cls, uri: str): res.query = m.group(URI_RGX_GRP_QUERY) return res else: - return None + raise NotUriError(uri) def is_dataspace_uri(self): return ( @@ -75,12 +82,21 @@ def is_object_uri(self): and self.uuid is not None ) - def get_qualified_type(self): + def get_qualified_type(self) -> str: + if self.domain is None or self.domain_version is None or self.object_type is None: + raise ValueError("The URI must have a domain, domain version and object type to get the qualified type") return f"{self.domain}{self.domain_version}.{self.object_type}" - def as_identifier(self): + def get_content_type(self) -> str: + if self.domain is None or self.domain_version is None or self.object_type is None: + raise ValueError("The URI must have a domain, domain version and object type to get the content type") + # Format version with dots + formatted_version = re.sub(r"(\d)(\d)", r"\1.\2", self.domain_version) + return f"application/x-{self.domain}+xml;" f"version={formatted_version};" f"type={self.object_type}" + + def as_identifier(self) -> str: if not self.is_object_uri(): - return None + raise ValueError("Only object URIs can be converted to identifiers") return f"{self.uuid}.{self.version if self.version is not None else ''}" def __str__(self): @@ -108,8 +124,47 @@ def __str__(self): return res + def __hash__(self) -> int: + return hash(str(self)) -def parse_uri(uri: str) -> Optional[Uri]: + +def parse_uri_raise_if_failed(uri: str) -> Uri: if uri is None or len(uri) <= 0: - return None + raise NotUriError(uri) return Uri.parse(uri.strip()) + + +def parse_uri(uri: str) -> Optional[Uri]: + try: + return parse_uri_raise_if_failed(uri) + except NotUriError: + return None + + +def create_uri_from_content_type_or_qualified_type(ct_or_qt: str, uuid: str, version: Optional[str] = None) -> Uri: + """Create a URI from a content type or a qualified type and a uuid (and optionally an object version) + :param ct_or_qt: the content type or qualified type to create the URI from + :param uuid: the uuid of the object + :param version: the version of the object (optional) + :return: the created URI + """ + if ct_or_qt is None or len(ct_or_qt) <= 0: + raise ContentTypeValidationError("Content type or qualified type cannot be null or empty") + if uuid is None or len(uuid) <= 0: + raise ValueError("UUID cannot be null or empty") + m = parse_content_or_qualified_type(ct_or_qt) + if m is not None: + try: + domain = m.group("domain") + domain_version = m.group("domainVersion") + # ensure domaine version has no dots and is in the format of digits only + formatted_version = re.sub(r"(\d)[^\d]+(\d)", r"\1\2", domain_version) + object_type = m.group("type") + return Uri( + domain=domain, domain_version=formatted_version, object_type=object_type, uuid=uuid, version=version + ) + except Exception as e: + raise ContentTypeValidationError( + f"Failed to parse content type or qualified type: {ct_or_qt} -- {m}" + ) from e + raise NotUriError(f"Unable to parse content type: {ct_or_qt}") diff --git a/energyml-utils/tests/test_epc_stream.py b/energyml-utils/tests/test_epc_stream.py index f22824c..927c604 100644 --- a/energyml-utils/tests/test_epc_stream.py +++ b/energyml-utils/tests/test_epc_stream.py @@ -12,13 +12,12 @@ """ import os import tempfile -import zipfile -from pathlib import Path +from energyml.utils.epc_utils import gen_energyml_object_path import pytest import numpy as np -from energyml.eml.v2_3.commonv2 import Citation, DataObjectReference +from energyml.eml.v2_3.commonv2 import Citation from energyml.resqml.v2_2.resqmlv2 import ( TriangulatedSetRepresentation, BoundaryFeatureInterpretation, @@ -65,7 +64,7 @@ def sample_objects(): originator="Test", creation=epoch_to_date(epoch()), ), - uuid=gen_uuid(), + uuid="25773477-ffee-4cc2-867d-000000000001", object_version="1.0", ) @@ -76,33 +75,34 @@ def sample_objects(): originator="Test", creation=epoch_to_date(epoch()), ), - uuid=gen_uuid(), + uuid="25773477-ffee-4cc2-867d-000000000002", object_version="1.0", interpreted_feature=as_dor(bf), ) - # Create a TriangulatedSetRepresentation - trset = TriangulatedSetRepresentation( + # Create a HorizonInterpretation (independent object) + horizon_interp = HorizonInterpretation( citation=Citation( - title="Test TriangulatedSetRepresentation", + title="Test HorizonInterpretation", originator="Test", creation=epoch_to_date(epoch()), ), - uuid=gen_uuid(), + interpreted_feature=as_dor(bf), + uuid="25773477-ffee-4cc2-867d-000000000003", object_version="1.0", - represented_object=as_dor(bfi), + # domain="depth", ) - # Create a HorizonInterpretation (independent object) - horizon_interp = HorizonInterpretation( + # Create a TriangulatedSetRepresentation + trset = TriangulatedSetRepresentation( citation=Citation( - title="Test HorizonInterpretation", + title="Test TriangulatedSetRepresentation", originator="Test", creation=epoch_to_date(epoch()), ), - uuid=gen_uuid(), + uuid="25773477-ffee-4cc2-867d-000000000004", object_version="1.0", - domain="depth", + represented_object=as_dor(horizon_interp), ) return { @@ -139,7 +139,7 @@ def test_manual_mode_no_auto_rebuild(self, temp_epc_file, sample_objects): # Basic rels should exist (from _add_object_to_file) bfi_rels = reader2.get_obj_rels(get_obj_identifier(bfi)) - assert len(bfi_rels) > 0 # Should have SOURCE rels + assert len(bfi_rels) == 0, "Expected no relationships in MANUAL mode without explicit rebuild" reader2.close() @@ -162,15 +162,22 @@ def test_update_on_close_mode(self, temp_epc_file, sample_objects): # Reopen and verify relationships were built reader2 = EpcStreamReader(temp_epc_file) - # Check that bfi has a SOURCE relationship to bf + # Check that bfi has a DEST relationship to bf bfi_rels = reader2.get_obj_rels(get_obj_identifier(bfi)) - source_rels = [r for r in bfi_rels if r.type_value == EPCRelsRelationshipType.SOURCE_OBJECT.get_type()] - assert len(source_rels) >= 1, "Expected SOURCE relationship from bfi to bf" + dest_rels = [r for r in bfi_rels if r.type_value == str(EPCRelsRelationshipType.DESTINATION_OBJECT)] + source_rels = [r for r in bfi_rels if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT)] + assert len(dest_rels) == 1, "Expected DESTINATION relationship from bfi to bf" + assert len(source_rels) == 1, "Expected SOURCE relationship from bfi to trset" - # Check that bf has a DESTINATION relationship from bfi + # Check that bf has a SOURCE relationship from bfi bf_rels = reader2.get_obj_rels(get_obj_identifier(bf)) - dest_rels = [r for r in bf_rels if r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type()] - assert len(dest_rels) >= 1, "Expected DESTINATION relationship from bfi to bf" + source_rels = [r for r in bf_rels if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT)] + assert len(source_rels) == 1, "Expected SOURCE relationship in bf rels targeting bfi" + + # Check that bf has a SOURCE relationship from bfi + trset_rels = reader2.get_obj_rels(get_obj_identifier(trset)) + dest_rels = [r for r in trset_rels if r.type_value == str(EPCRelsRelationshipType.DESTINATION_OBJECT)] + assert len(dest_rels) >= 1, "Expected DESTINATION relationship in trset rels targeting bfi" reader2.close() @@ -187,12 +194,37 @@ def test_update_at_modification_mode_add(self, temp_epc_file, sample_objects): # Check relationships immediately (without closing) bfi_rels = reader.get_obj_rels(get_obj_identifier(bfi)) - source_rels = [r for r in bfi_rels if r.type_value == EPCRelsRelationshipType.SOURCE_OBJECT.get_type()] - assert len(source_rels) >= 1, "Expected immediate SOURCE relationship from bfi to bf" + source_rels = [r for r in bfi_rels if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT)] + assert len(source_rels) == 0, "Expected no SOURCE relationships in bfi rels since bf does not refers to bfi" + + dest_rels = [r for r in bfi_rels if r.type_value == str(EPCRelsRelationshipType.DESTINATION_OBJECT)] + assert len(dest_rels) >= 1, f"Expected immediate DESTINATION relationship from bfi to bf {bfi_rels}" + + bf_rels = reader.get_obj_rels(get_obj_identifier(bf)) + source_rels = [r for r in bf_rels if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT)] + assert len(source_rels) >= 1, f"Expected immediate SOURCE relationship from bfi to bf {bf_rels}" + + reader.close() + + def test_update_at_modification_mode_add_reversed_order(self, temp_epc_file, sample_objects): + """Test that UPDATE_AT_MODIFICATION mode updates rels immediately on add even if objects are added in reversed order.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + + # Add objects in reversed order to test that relationships are created even if the interpreted feature is added after the interpretation + reader.add_object(bfi) + reader.add_object(bf) + + # Check relationships immediately (without closing) + bfi_rels = reader.get_obj_rels(get_obj_identifier(bfi)) + dest_rels = [r for r in bfi_rels if r.type_value == str(EPCRelsRelationshipType.DESTINATION_OBJECT)] + assert len(dest_rels) >= 1, "Expected immediate DESTINATION relationship in bfi rels targeting bf" bf_rels = reader.get_obj_rels(get_obj_identifier(bf)) - dest_rels = [r for r in bf_rels if r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type()] - assert len(dest_rels) >= 1, "Expected immediate DESTINATION relationship from bfi to bf" + source_rels = [r for r in bf_rels if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT)] + assert len(source_rels) >= 1, "Expected immediate SOURCE relationship in bf rels targeting bfi" reader.close() @@ -209,14 +241,14 @@ def test_update_at_modification_mode_remove(self, temp_epc_file, sample_objects) # Verify relationships exist bf_rels_before = reader.get_obj_rels(get_obj_identifier(bf)) - assert len(bf_rels_before) > 0, "Expected relationships before removal" + assert len(bf_rels_before) == 1, "Expected relationships before removal" # Remove bfi reader.remove_object(get_obj_identifier(bfi)) # Check that bf's rels no longer has references to bfi bf_rels_after = reader.get_obj_rels(get_obj_identifier(bf)) - bfi_refs = [r for r in bf_rels_after if get_obj_identifier(bfi) in r.id] + bfi_refs = [r for r in bf_rels_after if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT)] assert len(bfi_refs) == 0, "Expected no references to removed object" reader.close() @@ -256,23 +288,25 @@ def test_update_at_modification_mode_update(self, temp_epc_file, sample_objects) reader.update_object(bfi_modified) - # Check that bf no longer has DESTINATION relationship from bfi + # Check that bf no longer has SOURCE relationship from bfi bf_rels = reader.get_obj_rels(get_obj_identifier(bf)) - bfi_dest_rels = [ + bfi_source_rels = [ r for r in bf_rels - if r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type() and get_obj_identifier(bfi) in r.id + if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT) + and gen_energyml_object_path(bfi, reader.export_version) in r.target ] - assert len(bfi_dest_rels) == 0, "Expected old DESTINATION relationship to be removed" + assert len(bfi_source_rels) == 0, "Expected old SOURCE relationship to be removed" - # Check that bf2 now has DESTINATION relationship from bfi + # Check that bf2 now has SOURCE relationship from bfi bf2_rels = reader.get_obj_rels(get_obj_identifier(bf2)) - bfi_dest_rels2 = [ + bfi_source_rels2 = [ r for r in bf2_rels - if r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type() and get_obj_identifier(bfi) in r.id + if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT) + and gen_energyml_object_path(bfi, reader.export_version) in r.target ] - assert len(bfi_dest_rels2) >= 1, "Expected new DESTINATION relationship to be added" + assert len(bfi_source_rels2) >= 1, "Expected new SOURCE relationship to be added" reader.close() @@ -369,21 +403,23 @@ def test_bidirectional_relationships(self, temp_epc_file, sample_objects): # Check bfi -> bf (SOURCE) bfi_rels = reader.get_obj_rels(get_obj_identifier(bfi)) - bfi_source_to_bf = [ + bfi_dest_to_bf = [ r for r in bfi_rels - if r.type_value == EPCRelsRelationshipType.SOURCE_OBJECT.get_type() and get_obj_identifier(bf) in r.id + if r.type_value == str(EPCRelsRelationshipType.DESTINATION_OBJECT) + and gen_energyml_object_path(bf, reader.export_version) in r.target ] - assert len(bfi_source_to_bf) >= 1 + assert len(bfi_dest_to_bf) >= 1 # Check bf -> bfi (DESTINATION) bf_rels = reader.get_obj_rels(get_obj_identifier(bf)) - bf_dest_from_bfi = [ + bf_source_from_bfi = [ r for r in bf_rels - if r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type() and get_obj_identifier(bfi) in r.id + if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT) + and gen_energyml_object_path(bfi, reader.export_version) in r.target ] - assert len(bf_dest_from_bfi) >= 1 + assert len(bf_source_from_bfi) >= 1 reader.close() @@ -392,35 +428,38 @@ def test_cascade_relationships(self, temp_epc_file, sample_objects): reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) bf = sample_objects["bf"] - bfi = sample_objects["bfi"] + hi = sample_objects["horizon_interp"] trset = sample_objects["trset"] reader.add_object(bf) - reader.add_object(bfi) + reader.add_object(hi) reader.add_object(trset) - # Check trset -> bfi - trset_rels = reader.get_obj_rels(get_obj_identifier(trset)) - trset_to_bfi = [ + # Check trset -> hi + trset_rels = reader.get_obj_rels(trset) + assert len(trset_rels) == 1, "Expected relationships in trset rels" + hi_dest_rels = [ r for r in trset_rels - if r.type_value == EPCRelsRelationshipType.SOURCE_OBJECT.get_type() and get_obj_identifier(bfi) in r.id + if r.type_value == str(EPCRelsRelationshipType.DESTINATION_OBJECT) + and gen_energyml_object_path(hi, reader.export_version) in r.target ] - assert len(trset_to_bfi) >= 1 + assert len(hi_dest_rels) == 1, "Expected DESTINATION relationship from trset to hi" - # Check bfi -> bf - bfi_rels = reader.get_obj_rels(get_obj_identifier(bfi)) - bfi_to_bf = [ + # Check hi -> bf + hi_rels = reader.get_obj_rels(hi) + hi_to_bf = [ r - for r in bfi_rels - if r.type_value == EPCRelsRelationshipType.SOURCE_OBJECT.get_type() and get_obj_identifier(bf) in r.id + for r in hi_rels + if r.type_value == str(EPCRelsRelationshipType.DESTINATION_OBJECT) + and gen_energyml_object_path(bf, reader.export_version) in r.target ] - assert len(bfi_to_bf) >= 1 + assert len(hi_to_bf) == 1, "Expected DESTINATION relationship from hi to bf" - # Check bf has 2 DESTINATION relationships (from bfi and indirectly from trset) - bf_rels = reader.get_obj_rels(get_obj_identifier(bf)) - bf_dest_rels = [r for r in bf_rels if r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type()] - assert len(bf_dest_rels) >= 1 + # Check bf has 1 SOURCE relationships (from hi and indirectly from trset) + bf_rels = reader.get_obj_rels(bf) + bf_source_rels = [r for r in bf_rels if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT)] + assert len(bf_source_rels) == 1, "Expected 1 SOURCE relationship in bf rels targeting hi" reader.close() @@ -482,39 +521,40 @@ def test_metadata_access_without_loading(self, temp_epc_file, sample_objects): reader.close() # Reopen and access metadata - reader2 = EpcStreamReader(temp_epc_file, preload_metadata=True) + reader2 = EpcStreamReader(temp_epc_file) # Check that we can list objects without loading them - metadata_list = reader2.list_object_metadata() + metadata_list = reader2.list_objects() assert len(metadata_list) == 2 assert reader2.stats.loaded_objects == 0, "Expected no objects loaded when accessing metadata" reader2.close() - def test_lazy_loading(self, temp_epc_file, sample_objects): - """Test that objects are loaded on-demand.""" - reader = EpcStreamReader(temp_epc_file) + # ==> no lazy loading for now + # def test_lazy_loading(self, temp_epc_file, sample_objects): + # """Test that objects are loaded on-demand.""" + # reader = EpcStreamReader(temp_epc_file) - bf = sample_objects["bf"] - bfi = sample_objects["bfi"] - trset = sample_objects["trset"] + # bf = sample_objects["bf"] + # hi = sample_objects["horizon_interp"] + # trset = sample_objects["trset"] - reader.add_object(bf) - reader.add_object(bfi) - reader.add_object(trset) + # reader.add_object(bf) + # reader.add_object(hi) + # reader.add_object(trset) - reader.close() + # reader.close() - # Reopen - reader2 = EpcStreamReader(temp_epc_file) - assert len(reader2) == 3 - assert reader2.stats.loaded_objects == 0, "Expected no objects loaded initially" + # # Reopen + # reader2 = EpcStreamReader(temp_epc_file) + # assert len(reader2) == 3 + # assert reader2.stats.loaded_objects == 0, "Expected no objects loaded initially" - # Load one object - reader2.get_object_by_identifier(get_obj_identifier(bf)) - assert reader2.stats.loaded_objects == 1, "Expected exactly 1 object loaded" + # # Load one object + # reader2.get_object_by_identifier(get_obj_identifier(bf)) + # assert reader2.stats.loaded_objects == 1, "Expected exactly 1 object loaded" - reader2.close() + # reader2.close() class TestHelperMethods: @@ -528,7 +568,7 @@ def test_gen_rels_path_from_metadata(self, temp_epc_file, sample_objects): identifier = reader.add_object(bf) metadata = reader._metadata[identifier] - rels_path = reader._gen_rels_path_from_metadata(metadata) + rels_path = reader._metadata_mgr.gen_rels_path_from_metadata(metadata) assert rels_path is not None assert "_rels/" in rels_path @@ -543,7 +583,7 @@ def test_gen_rels_path_from_identifier(self, temp_epc_file, sample_objects): bf = sample_objects["bf"] identifier = reader.add_object(bf) - rels_path = reader._gen_rels_path_from_identifier(identifier) + rels_path = reader._metadata_mgr.gen_rels_path_from_identifier(identifier) assert rels_path is not None assert "_rels/" in rels_path @@ -559,10 +599,10 @@ def test_set_rels_update_mode(self, temp_epc_file): """Test changing the relationship update mode.""" reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.MANUAL) - assert reader.get_rels_update_mode() == RelsUpdateMode.MANUAL + assert reader.rels_update_mode == RelsUpdateMode.MANUAL - reader.set_rels_update_mode(RelsUpdateMode.UPDATE_AT_MODIFICATION) - assert reader.get_rels_update_mode() == RelsUpdateMode.UPDATE_AT_MODIFICATION + reader.rels_update_mode = RelsUpdateMode.UPDATE_AT_MODIFICATION + assert reader.rels_update_mode == RelsUpdateMode.UPDATE_AT_MODIFICATION reader.close() @@ -571,7 +611,7 @@ def test_invalid_mode_raises_error(self, temp_epc_file): reader = EpcStreamReader(temp_epc_file) with pytest.raises(ValueError): - reader.set_rels_update_mode("invalid_mode") + reader.rels_update_mode = "invalid_mode" reader.close() @@ -588,23 +628,12 @@ def test_remove_nonexistent_object(self, temp_epc_file): reader.close() - def test_update_nonexistent_object(self, temp_epc_file, sample_objects): - """Test updating an object that doesn't exist.""" - reader = EpcStreamReader(temp_epc_file) - - bf = sample_objects["bf"] - - with pytest.raises(ValueError): - reader.update_object(bf) - - reader.close() - def test_empty_epc_operations(self, temp_epc_file): """Test operations on empty EPC.""" reader = EpcStreamReader(temp_epc_file) assert len(reader) == 0 - assert len(reader.list_object_metadata()) == 0 + assert len(reader.list_objects()) == 0 reader.close() @@ -706,10 +735,10 @@ def test_external_resource_preserved_on_object_update(self, temp_epc_file, sampl h5_rel = Relationship( target="data/test_data.h5", - type_value=EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(), + type_value=str(EPCRelsRelationshipType.EXTERNAL_RESOURCE), id=f"_external_{identifier}_h5", ) - reader.add_rels_for_object(identifier, [h5_rel], write_immediately=True) + reader.add_rels_for_object(identifier, [h5_rel]) # Verify the HDF5 path is returned h5_paths_before = reader.get_h5_file_paths(identifier) @@ -725,7 +754,7 @@ def test_external_resource_preserved_on_object_update(self, temp_epc_file, sampl # Also verify by checking rels directly rels = reader.get_obj_rels(identifier) - external_rels = [r for r in rels if r.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type()] + external_rels = [r for r in rels if r.type_value == str(EPCRelsRelationshipType.EXTERNAL_RESOURCE)] assert len(external_rels) > 0, "EXTERNAL_RESOURCE relationship not found in rels" assert any("test_data.h5" in r.target for r in external_rels) @@ -744,10 +773,10 @@ def test_external_resource_preserved_when_referenced_by_other(self, temp_epc_fil h5_rel = Relationship( target="data/boundary_data.h5", - type_value=EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(), + type_value=str(EPCRelsRelationshipType.EXTERNAL_RESOURCE), id=f"_external_{bf_id}_h5", ) - reader.add_rels_for_object(bf_id, [h5_rel], write_immediately=True) + reader.add_rels_for_object(bf_id, [h5_rel]) # Verify initial state h5_paths_initial = reader.get_h5_file_paths(bf_id) @@ -764,7 +793,7 @@ def test_external_resource_preserved_when_referenced_by_other(self, temp_epc_fil # Verify rels directly rels = reader.get_obj_rels(bf_id) - external_rels = [r for r in rels if r.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type()] + external_rels = [r for r in rels if r.type_value == str(EPCRelsRelationshipType.EXTERNAL_RESOURCE)] assert len(external_rels) > 0 assert any("boundary_data.h5" in r.target for r in external_rels) @@ -783,10 +812,10 @@ def test_external_resource_preserved_update_on_close_mode(self, temp_epc_file, s h5_rel = Relationship( target="data/test_data.h5", - type_value=EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(), + type_value=str(EPCRelsRelationshipType.EXTERNAL_RESOURCE), id=f"_external_{identifier}_h5", ) - reader.add_rels_for_object(identifier, [h5_rel], write_immediately=True) + reader.add_rels_for_object(identifier, [h5_rel]) # Update object trset.citation.title = "Modified in UPDATE_ON_CLOSE mode" @@ -815,21 +844,21 @@ def test_multiple_external_resources_preserved(self, temp_epc_file, sample_objec h5_rels = [ Relationship( target="data/geometry.h5", - type_value=EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(), + type_value=str(EPCRelsRelationshipType.EXTERNAL_RESOURCE), id=f"_external_{identifier}_geometry", ), Relationship( target="data/properties.h5", - type_value=EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(), + type_value=str(EPCRelsRelationshipType.EXTERNAL_RESOURCE), id=f"_external_{identifier}_properties", ), Relationship( target="data/metadata.h5", - type_value=EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(), + type_value=str(EPCRelsRelationshipType.EXTERNAL_RESOURCE), id=f"_external_{identifier}_metadata", ), ] - reader.add_rels_for_object(identifier, h5_rels, write_immediately=True) + reader.add_rels_for_object(identifier, h5_rels) # Verify all are present h5_paths_before = reader.get_h5_file_paths(identifier) @@ -868,10 +897,10 @@ def test_external_resource_preserved_cascade_updates(self, temp_epc_file, sample h5_rel = Relationship( target="data/bf_data.h5", - type_value=EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(), + type_value=str(EPCRelsRelationshipType.EXTERNAL_RESOURCE), id=f"_external_{bf_id}_h5", ) - reader.add_rels_for_object(bf_id, [h5_rel], write_immediately=True) + reader.add_rels_for_object(bf_id, [h5_rel]) # Verify initial state h5_paths = reader.get_h5_file_paths(bf_id) @@ -907,10 +936,10 @@ def test_external_resource_with_object_removal(self, temp_epc_file, sample_objec h5_rel = Relationship( target="data/bfi_data.h5", - type_value=EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(), + type_value=str(EPCRelsRelationshipType.EXTERNAL_RESOURCE), id=f"_external_{bfi_id}_h5", ) - reader.add_rels_for_object(bfi_id, [h5_rel], write_immediately=True) + reader.add_rels_for_object(bfi_id, [h5_rel]) # Verify it exists h5_paths = reader.get_h5_file_paths(bfi_id) diff --git a/energyml-utils/tests/test_epc_validator.py b/energyml-utils/tests/test_epc_validator.py new file mode 100644 index 0000000..57347c3 --- /dev/null +++ b/energyml-utils/tests/test_epc_validator.py @@ -0,0 +1,646 @@ +# Copyright (c) 2023-2024 Geosiris. +# SPDX-License-Identifier: Apache-2.0 +""" +Unit tests for EPC validator module. + +Tests comprehensive validation of EPC (Energistics Packaging Conventions) files +according to the EPC v1.0 specification. +""" + +import io +import zipfile +from pathlib import Path +from typing import Optional + +import pytest + +from energyml.opc.opc import ( + CoreProperties, + Created, + Creator, + Default, + Identifier, + Override, + Relationship, + Relationships, + TargetMode, + Types, +) +from energyml.utils.epc_validator import ( + EpcParser, + EpcValidator, + ValidationResult, + validate_epc_file, +) +from energyml.utils.exception import ( + ContentTypeValidationError, + CorePropertiesValidationError, + InvalidXmlStructureError, + MissingRequiredFileError, + NamingConventionError, + RelationshipValidationError, + ZipIntegrityError, +) +from energyml.utils.serialization import serialize_xml + + +class TestValidationResult: + """Test ValidationResult class.""" + + def test_validation_result_initialization(self): + """Test ValidationResult initializes correctly.""" + result = ValidationResult() + assert result.is_valid is True + assert len(result.errors) == 0 + assert len(result.warnings) == 0 + assert len(result.info) == 0 + + def test_add_error_marks_invalid(self): + """Test adding error marks validation as invalid.""" + result = ValidationResult() + result.add_error("Test error") + assert result.is_valid is False + assert len(result.errors) == 1 + assert result.errors[0] == "Test error" + + def test_add_warning_keeps_valid(self): + """Test adding warning doesn't affect validity.""" + result = ValidationResult() + result.add_warning("Test warning") + assert result.is_valid is True + assert len(result.warnings) == 1 + + def test_add_info(self): + """Test adding info message.""" + result = ValidationResult() + result.add_info("Test info") + assert len(result.info) == 1 + + def test_str_representation(self): + """Test string representation of ValidationResult.""" + result = ValidationResult() + result.add_error("Error 1") + result.add_warning("Warning 1") + result.add_info("Info 1") + + output = str(result) + assert "FAILED" in output + assert "Error 1" in output + assert "Warning 1" in output + assert "Info 1" in output + + +class TestEpcParser: + """Test EPC parser functionality.""" + + @pytest.fixture + def minimal_epc(self) -> io.BytesIO: + """Create minimal valid EPC file in memory.""" + buffer = io.BytesIO() + + # Create content types + content_types = Types( + default=[ + Default(extension="rels", content_type="application/vnd.openxmlformats-package.relationships+xml") + ], + override=[ + Override( + part_name="/docProps/core.xml", + content_type="application/vnd.openxmlformats-package.core-properties+xml", + ) + ], + ) + + # Create core properties + core_props = CoreProperties( + created=Created(any_element="2024-01-01T00:00:00Z"), + creator=Creator(any_element="Test Creator"), + identifier=Identifier(any_element="test-identifier"), + ) + + # Create root relationships + root_rels = Relationships( + relationship=[ + Relationship( + id="CoreProperties", + type_value="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties", + target="docProps/core.xml", + ) + ] + ) + + # Create ZIP file + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("[Content_Types].xml", serialize_xml(content_types)) + zf.writestr("_rels/.rels", serialize_xml(root_rels)) + zf.writestr("docProps/core.xml", serialize_xml(core_props)) + + buffer.seek(0) + return buffer + + def test_parser_context_manager(self, minimal_epc): + """Test parser as context manager.""" + with EpcParser(minimal_epc) as parser: + files = parser.list_files() + assert len(files) > 0 + + def test_parser_open_close(self, minimal_epc): + """Test explicit open/close.""" + parser = EpcParser(minimal_epc) + parser.open() + files = parser.list_files() + assert len(files) > 0 + parser.close() + + def test_parser_list_files(self, minimal_epc): + """Test listing files in archive.""" + with EpcParser(minimal_epc) as parser: + files = parser.list_files() + assert "[Content_Types].xml" in files + assert "_rels/.rels" in files + + def test_parser_read_file(self, minimal_epc): + """Test reading file from archive.""" + with EpcParser(minimal_epc) as parser: + content = parser.read_file("[Content_Types].xml") + assert content is not None + assert len(content) > 0 + + def test_parser_read_missing_file(self, minimal_epc): + """Test reading non-existent file raises error.""" + with EpcParser(minimal_epc) as parser: + with pytest.raises(MissingRequiredFileError): + parser.read_file("non_existent.xml") + + def test_parse_content_types(self, minimal_epc): + """Test parsing content types.""" + with EpcParser(minimal_epc) as parser: + content_types = parser.parse_content_types() + assert content_types is not None + assert len(content_types.default) > 0 + + def test_parse_core_properties(self, minimal_epc): + """Test parsing core properties.""" + with EpcParser(minimal_epc) as parser: + core_props = parser.parse_core_properties() + assert core_props is not None + + def test_parse_relationships(self, minimal_epc): + """Test parsing relationships.""" + with EpcParser(minimal_epc) as parser: + rels = parser.parse_relationships("_rels/.rels") + assert rels is not None + + def test_find_all_rels_files(self, minimal_epc): + """Test finding all .rels files.""" + with EpcParser(minimal_epc) as parser: + rels_files = parser.find_all_rels_files() + assert len(rels_files) > 0 + assert "_rels/.rels" in rels_files + + +class TestEpcValidator: + """Test EPC validator functionality.""" + + @pytest.fixture + def valid_epc(self) -> io.BytesIO: + """Create valid EPC file for testing.""" + buffer = io.BytesIO() + + # Create content types + content_types = Types( + default=[ + Default(extension="rels", content_type="application/vnd.openxmlformats-package.relationships+xml") + ], + override=[ + Override( + part_name="/docProps/core.xml", + content_type="application/vnd.openxmlformats-package.core-properties+xml", + ), + Override( + part_name="/resqml/obj_BoundaryFeature_12345.xml", + content_type="application/x-resqml+xml;version=2.0;type=obj_BoundaryFeature", + ), + ], + ) + + # Create core properties + core_props = CoreProperties( + created=Created(any_element="2024-01-01T00:00:00Z"), + creator=Creator(any_element="Test Creator"), + identifier=Identifier(any_element="test-identifier"), + ) + + # Create root relationships + root_rels = Relationships( + relationship=[ + Relationship( + id="CoreProperties", + type_value="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties", + target="docProps/core.xml", + ) + ] + ) + + # Create ZIP file + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("[Content_Types].xml", serialize_xml(content_types)) + zf.writestr("_rels/.rels", serialize_xml(root_rels)) + zf.writestr("docProps/core.xml", serialize_xml(core_props)) + zf.writestr("resqml/obj_BoundaryFeature_12345.xml", "") + + buffer.seek(0) + return buffer + + def test_validate_valid_epc(self, valid_epc): + """Test validation of valid EPC file.""" + result = validate_epc_file(valid_epc) + assert result.is_valid is True + assert len(result.errors) == 0 + + def test_validator_initialization(self, valid_epc): + """Test validator initialization.""" + validator = EpcValidator(valid_epc) + assert validator.epc_path == valid_epc + assert validator.strict is True + assert validator.check_relationships is True + + def test_validate_with_strict_mode(self, valid_epc): + """Test validation in strict mode.""" + validator = EpcValidator(valid_epc, strict=True) + result = validator.validate() + assert result is not None + + def test_validate_with_lenient_mode(self, valid_epc): + """Test validation in lenient mode.""" + validator = EpcValidator(valid_epc, strict=False) + result = validator.validate() + assert result is not None + + def test_validate_missing_content_types(self): + """Test validation fails when [Content_Types].xml is missing.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("_rels/.rels", "") + buffer.seek(0) + + result = validate_epc_file(buffer) + assert result.is_valid is False + assert any("Content_Types" in error for error in result.errors) + + def test_validate_missing_root_rels(self): + """Test validation fails when _rels/.rels is missing.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + content_types = Types( + default=[ + Default(extension="rels", content_type="application/vnd.openxmlformats-package.relationships+xml") + ] + ) + zf.writestr("[Content_Types].xml", serialize_xml(content_types)) + buffer.seek(0) + + result = validate_epc_file(buffer) + assert result.is_valid is False + assert any("_rels/.rels" in error for error in result.errors) + + def test_validate_invalid_zip(self): + """Test validation fails for invalid ZIP file.""" + buffer = io.BytesIO(b"This is not a ZIP file") + result = validate_epc_file(buffer) + assert result.is_valid is False + + def test_validate_relationships_missing_target(self): + """Test validation detects missing relationship targets.""" + buffer = io.BytesIO() + + content_types = Types( + default=[Default(extension="rels", content_type="application/vnd.openxmlformats-package.relationships+xml")] + ) + + # Relationship pointing to non-existent file + root_rels = Relationships( + relationship=[ + Relationship( + id="Missing", + type_value="http://schemas.example.org/test", + target="missing_file.xml", + ) + ] + ) + + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("[Content_Types].xml", serialize_xml(content_types)) + zf.writestr("_rels/.rels", serialize_xml(root_rels)) + + buffer.seek(0) + + result = validate_epc_file(buffer) + assert result.is_valid is False + assert any("not found" in error.lower() for error in result.errors) + + def test_validate_content_type_rels_default(self, valid_epc): + """Test validation checks .rels content type.""" + result = validate_epc_file(valid_epc) + # Should have info about .rels content type + assert result is not None + + def test_validate_core_properties_missing_fields(self): + """Test validation warns about missing core properties fields.""" + buffer = io.BytesIO() + + content_types = Types( + default=[ + Default(extension="rels", content_type="application/vnd.openxmlformats-package.relationships+xml") + ], + override=[ + Override( + part_name="/docProps/core.xml", + content_type="application/vnd.openxmlformats-package.core-properties+xml", + ) + ], + ) + + # Core properties with minimal fields + core_props = CoreProperties() + + root_rels = Relationships( + relationship=[ + Relationship( + id="CoreProperties", + type_value="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties", + target="docProps/core.xml", + ) + ] + ) + + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("[Content_Types].xml", serialize_xml(content_types)) + zf.writestr("_rels/.rels", serialize_xml(root_rels)) + zf.writestr("docProps/core.xml", serialize_xml(core_props)) + + buffer.seek(0) + + result = validate_epc_file(buffer, strict=False) + # Should have warnings about missing fields + assert len(result.warnings) > 0 + + def test_validate_naming_invalid_characters(self): + """Test validation detects invalid characters in file names.""" + buffer = io.BytesIO() + + content_types = Types( + default=[Default(extension="rels", content_type="application/vnd.openxmlformats-package.relationships+xml")] + ) + + root_rels = Relationships(relationship=[]) + + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("[Content_Types].xml", serialize_xml(content_types)) + zf.writestr("_rels/.rels", serialize_xml(root_rels)) + # This would be difficult to create in a real ZIP, but we can test the logic + + buffer.seek(0) + + result = validate_epc_file(buffer) + # Basic validation should pass + assert result is not None + + def test_validate_without_relationships_check(self, valid_epc): + """Test validation with relationship checking disabled.""" + result = validate_epc_file(valid_epc, check_relationships=False) + assert result is not None + + def test_validate_energyml_content_type_detection(self, valid_epc): + """Test detection of Energyml content types.""" + result = validate_epc_file(valid_epc) + # Should detect the resqml object + assert any("Energyml objects" in info for info in result.info) + + +class TestEpcValidatorWithRealFile: + """Test EPC validator with real EPC files.""" + + def test_validate_sample_epc_if_exists(self): + """Test validation with actual sample EPC file if available.""" + sample_paths = [ + Path("D:/Geosiris/OSDU/manifestTranslation/commons/data/testingPackageCpp.epc"), + Path("rc/epc/test.epc"), + Path("example/result/test.epc"), + ] + + sample_file = None + for path in sample_paths: + if path.exists(): + sample_file = path + break + + if sample_file is None: + pytest.skip("No sample EPC file available for testing") + + result = validate_epc_file(str(sample_file)) + # Real EPC files should generally be valid + print(f"\nValidation result for {sample_file}:") + print(result) + + +class TestEpcValidatorEdgeCases: + """Test edge cases and error handling.""" + + def test_validate_empty_relationships(self): + """Test validation with empty relationships file.""" + buffer = io.BytesIO() + + content_types = Types( + default=[Default(extension="rels", content_type="application/vnd.openxmlformats-package.relationships+xml")] + ) + + # Empty relationships + root_rels = Relationships(relationship=[]) + + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("[Content_Types].xml", serialize_xml(content_types)) + zf.writestr("_rels/.rels", serialize_xml(root_rels)) + + buffer.seek(0) + + result = validate_epc_file(buffer) + print(f"\nErrors: {result.errors}") + print(f"Warnings: {result.warnings}") + # Should warn about empty relationships or pass in strict=False + assert any("empty" in warning.lower() for warning in result.warnings) or not result.is_valid + + def test_validate_malformed_xml(self): + """Test validation with malformed XML.""" + buffer = io.BytesIO() + + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("[Content_Types].xml", "This is not valid XML") + zf.writestr("_rels/.rels", "") + + buffer.seek(0) + + result = validate_epc_file(buffer) + assert result.is_valid is False + + def test_validate_relationship_without_id(self): + """Test validation detects relationships without ID.""" + buffer = io.BytesIO() + + content_types = Types( + default=[ + Default(extension="rels", content_type="application/vnd.openxmlformats-package.relationships+xml") + ], + override=[ + Override( + part_name="/docProps/core.xml", + content_type="application/vnd.openxmlformats-package.core-properties+xml", + ) + ], + ) + + # Core props to avoid that error + core_props = CoreProperties( + created=Created(any_element="2024-01-01T00:00:00Z"), + creator=Creator(any_element="Test"), + ) + + # Relationship without ID (not valid per spec) + root_rels = Relationships( + relationship=[ + Relationship( + id="", # Empty ID + type_value="http://schemas.example.org/test", + target="test.xml", + ) + ] + ) + + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("[Content_Types].xml", serialize_xml(content_types)) + zf.writestr("_rels/.rels", serialize_xml(root_rels)) + zf.writestr("docProps/core.xml", serialize_xml(core_props)) + + buffer.seek(0) + + result = validate_epc_file(buffer) + print(f"\nErrors: {result.errors}") + print(f"Warnings: {result.warnings}") + assert result.is_valid is False + assert any("missing" in error.lower() and "id" in error.lower() for error in result.errors) + + def test_validate_external_relationship(self): + """Test validation handles external relationships correctly.""" + buffer = io.BytesIO() + + content_types = Types( + default=[Default(extension="rels", content_type="application/vnd.openxmlformats-package.relationships+xml")] + ) + + # External relationship (should not check if target exists) + root_rels = Relationships( + relationship=[ + Relationship( + id="External", + type_value="http://schemas.example.org/external", + target="http://example.com/resource", + target_mode=TargetMode.EXTERNAL, + ) + ] + ) + + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("[Content_Types].xml", serialize_xml(content_types)) + zf.writestr("_rels/.rels", serialize_xml(root_rels)) + + buffer.seek(0) + + result = validate_epc_file(buffer) + # External relationships should not cause "target not found" errors + assert not any("http://example.com" in error for error in result.errors) + + +class TestValidationIntegration: + """Integration tests for complete validation workflows.""" + + def test_full_validation_workflow(self): + """Test complete validation workflow.""" + # Create a comprehensive EPC file + buffer = io.BytesIO() + + content_types = Types( + default=[ + Default(extension="rels", content_type="application/vnd.openxmlformats-package.relationships+xml") + ], + override=[ + Override( + part_name="/docProps/core.xml", + content_type="application/vnd.openxmlformats-package.core-properties+xml", + ), + Override( + part_name="/resqml/obj_TriangulatedSetRepresentation_uuid1.xml", + content_type="application/x-resqml+xml;version=2.2;type=obj_TriangulatedSetRepresentation", + ), + ], + ) + + core_props = CoreProperties( + created=Created(any_element="2024-01-01T00:00:00Z"), + creator=Creator(any_element="Test Integration"), + identifier=Identifier(any_element="integration-test-id"), + ) + + root_rels = Relationships( + relationship=[ + Relationship( + id="CoreProperties", + type_value="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties", + target="docProps/core.xml", + ), + Relationship( + id="Object1", + type_value="http://schemas.energistics.org/package/2012/relationships/destinationObject", + target="resqml/obj_TriangulatedSetRepresentation_uuid1.xml", + ), + ] + ) + + obj_rels = Relationships(relationship=[]) + + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("[Content_Types].xml", serialize_xml(content_types)) + zf.writestr("_rels/.rels", serialize_xml(root_rels)) + zf.writestr("docProps/core.xml", serialize_xml(core_props)) + zf.writestr("resqml/obj_TriangulatedSetRepresentation_uuid1.xml", "") + zf.writestr("resqml/_rels/obj_TriangulatedSetRepresentation_uuid1.xml.rels", serialize_xml(obj_rels)) + + buffer.seek(0) + + # Test with different validation modes + result_strict = validate_epc_file(buffer, strict=True, check_relationships=True) + assert result_strict.is_valid is True + + buffer.seek(0) + result_lenient = validate_epc_file(buffer, strict=False, check_relationships=False) + assert result_lenient is not None + + def test_validation_result_formatting(self): + """Test validation result provides useful output.""" + buffer = io.BytesIO() + + # Create invalid EPC (missing required files) + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("test.txt", "Invalid EPC") + + buffer.seek(0) + + result = validate_epc_file(buffer) + output = str(result) + + # Check output contains useful information + assert "FAILED" in output or "PASSED" in output + assert len(output) > 0 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/energyml-utils/tests/test_parallel_rels_performance.py b/energyml-utils/tests/test_parallel_rels_performance.py index 2e1b6fa..27b70f6 100644 --- a/energyml-utils/tests/test_parallel_rels_performance.py +++ b/energyml-utils/tests/test_parallel_rels_performance.py @@ -12,7 +12,7 @@ from pathlib import Path import pytest -from energyml.utils.epc_stream import EpcStreamReader +from energyml.utils.epc_stream_old import EpcStreamReader # Default test file path - can be overridden via environment variable diff --git a/energyml-utils/tests/test_uri.py b/energyml-utils/tests/test_uri.py index 5dda5a3..4ecf994 100644 --- a/energyml-utils/tests/test_uri.py +++ b/energyml-utils/tests/test_uri.py @@ -1,7 +1,8 @@ # Copyright (c) 2023-2024 Geosiris. # SPDX-License-Identifier: Apache-2.0 -from energyml.utils.uri import Uri, parse_uri +from energyml.utils.exception import NotUriError +from energyml.utils.uri import Uri, parse_uri, parse_uri_raise_if_failed from energyml.utils.introspection import get_obj_uri from energyml.resqml.v2_0_1.resqmlv2 import TriangulatedSetRepresentation, ObjTriangulatedSetRepresentation @@ -46,8 +47,17 @@ def test_uri_eq(): def test_uri_error(): - assert parse_uri("eml//") is None - assert parse_uri("a random text") is None + try: + parse_uri_raise_if_failed("eml//") + raise AssertionError("Expected NotUriError to be raised") + except NotUriError: + pass + + try: + parse_uri_raise_if_failed("a random text") + raise AssertionError("Expected NotUriError to be raised") + except NotUriError: + pass def test_uri_default_dataspace(): @@ -111,6 +121,18 @@ def test_uri_full(): assert uri == str(parse_uri(uri)) +def test_uri_content_type(): + uri = parse_uri( + "eml:///witsml20.Well(uuid=ec8c3f16-1454-4f36-ae10-27d2a2680cf2,version='1.0')/witsml20.Wellbore?query" + ) + assert uri.get_content_type() == "application/x-witsml+xml;version=2.0;type=Well" + + uri = parse_uri( + "eml:///resqml20.obj_HorizonInterpretation(uuid=421a7a05-033a-450d-bcef-051352023578,version='2.0')" + ) + assert uri.get_content_type() == "application/x-resqml+xml;version=2.0;type=obj_HorizonInterpretation" + + def test_uuid(): uri = parse_uri( "eml:///witsml20.Well(uuid=ec8c3f16-1454-4f36-ae10-27d2a2680cf2,version='1.0')/witsml20.Wellbore?query" From 3387aacbc6f12dd1b4bf38d4a1704ff2fc757cab Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Wed, 11 Feb 2026 23:57:35 +0100 Subject: [PATCH 21/70] -- --- energyml-utils/example/main_stream_sample.py | 80 +- .../src/energyml/utils/data/datasets_io.py | 573 +++ .../src/energyml/utils/data/model.py | 100 +- energyml-utils/src/energyml/utils/epc.py | 4 - .../src/energyml/utils/epc_stream.py | 412 +- .../src/energyml/utils/epc_stream_old.py | 3572 ----------------- energyml-utils/tests/test_epc_stream.py | 192 +- 7 files changed, 1252 insertions(+), 3681 deletions(-) delete mode 100644 energyml-utils/src/energyml/utils/epc_stream_old.py diff --git a/energyml-utils/example/main_stream_sample.py b/energyml-utils/example/main_stream_sample.py index 8a2d11a..87ebbce 100644 --- a/energyml-utils/example/main_stream_sample.py +++ b/energyml-utils/example/main_stream_sample.py @@ -2,16 +2,27 @@ import sys import logging from energyml.utils.epc_stream import EpcStreamReader, RelsUpdateMode -from energyml.eml.v2_3.commonv2 import Citation +from energyml.eml.v2_3.commonv2 import Citation, ExternalDataArrayPart, from energyml.resqml.v2_2.resqmlv2 import ( TriangulatedSetRepresentation, BoundaryFeatureInterpretation, BoundaryFeature, HorizonInterpretation, + TrianglePatch, + IntegerExternalArray, + ExternalDataArray, + PointGeometry, + Point3DExternalArray, ) from energyml.utils.introspection import epoch_to_date, epoch from energyml.utils.epc import as_dor, gen_uuid, get_obj_identifier -from energyml.utils.constants import EPCRelsRelationshipType +from energyml.utils.constants import EPCRelsRelationshipType, MimeType + +from energyml.opc.opc import Relationship + + +CONST_H5_PATH = "wip/external_data.h5" +CONST_CSV_PATH = "wip/external_data.csv" def sample_objects(): @@ -53,6 +64,7 @@ def sample_objects(): ) # Create a TriangulatedSetRepresentation + trset_uuid = "25773477-ffee-4cc2-867d-000000000004" trset = TriangulatedSetRepresentation( citation=Citation( title="Test TriangulatedSetRepresentation", @@ -62,6 +74,37 @@ def sample_objects(): uuid="25773477-ffee-4cc2-867d-000000000004", object_version="1.0", represented_object=as_dor(horizon_interp), + triangle_patch=[ + TrianglePatch( + node_count=3, + triangles=IntegerExternalArray( + values=ExternalDataArray( + external_data_array_part=[ + ExternalDataArrayPart( + count=[6], + path_in_external_file=f"/RESQML/{trset_uuid}/triangles", + uri=CONST_H5_PATH, + mime_type=str(MimeType.HDF5), + ) + ] + ) + ), + geometry=PointGeometry( + points=Point3DExternalArray( + coordinates=ExternalDataArray( + external_data_array_part=[ + ExternalDataArrayPart( + count=[9], + path_in_external_file=f"/RESQML/{trset_uuid}/points", + uri=CONST_CSV_PATH, + mime_type=str(MimeType.CSV), + ) + ] + ) + ), + ), + ) + ], ) return { @@ -183,10 +226,41 @@ def test_create_epc_v2(path: str): logging.info(f"Horizon interpretation rels: {hi_rels}") +def test_create_epc_v3_with_different_external_files(path: str): + + if os.path.exists(path): + os.remove(path) + logging.info(f"==> Creating new EPC at {path}...") + epc = EpcStreamReader(epc_file_path=path, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + data = sample_objects() + + epc.add_object(data["bf"]) + # epc.add_object(data["bfi"]) + epc.add_object(data["horizon_interp"]) + tr_set_id = epc.add_object(data["trset"]) + + hi_rels = epc.get_obj_rels(data["horizon_interp"]) + + logging.info(f"Horizon interpretation rels: {hi_rels}") + + # Create an h5 file + h5_file_path = "wip/notARealFile.h5" + epc.add_rels_for_object( + tr_set_id, + relationships=[Relationship(type_value=str(EPCRelsRelationshipType.EXTERNAL_RESOURCE), target=h5_file_path)], + ) + + epc.write_array() + + # Create an + + if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) # main((sys.argv[1] if len(sys.argv) > 1 else None) or "wip/80wells_surf.epc") # test_create_epc("wip/test_create.epc") - test_create_epc_v2("wip/test_create.epc") + # test_create_epc_v2("wip/test_create.epc") + test_create_epc_v3_with_different_external_files("wip/test_create_v3.epc") diff --git a/energyml-utils/src/energyml/utils/data/datasets_io.py b/energyml-utils/src/energyml/utils/data/datasets_io.py index 131f2bb..edbc200 100644 --- a/energyml-utils/src/energyml/utils/data/datasets_io.py +++ b/energyml-utils/src/energyml/utils/data/datasets_io.py @@ -707,3 +707,576 @@ def get_proxy_uri_for_path_in_external(obj: Any, dataspace_name_or_uri: Union[st else: logging.debug(f"No datasets found in object {str(get_obj_uri(obj))}") return uri_path_map + + +# =========================================================================================== +# FILE CACHE MANAGER AND HANDLER REGISTRY +# =========================================================================================== + +from collections import OrderedDict +from typing import Callable +from energyml.utils.data.model import ExternalArrayHandler + + +class FileCacheManager: + """ + Manages a cache of open file handles to avoid reopening overhead. + + Keeps up to `max_open_files` (default 3) files open using an LRU strategy. + When a file is accessed, it moves to the front of the cache. When the cache + is full, the least recently used file is closed and removed. + + Features: + - Thread-safe access to file handles + - Automatic cleanup of least-recently-used files + - Support for any file type with proper handlers + - Explicit close() method for cleanup + """ + + def __init__(self, max_open_files: int = 3): + """ + Initialize file cache manager. + + Args: + max_open_files: Maximum number of files to keep open simultaneously + """ + self.max_open_files = max_open_files + self._cache: OrderedDict[str, Any] = OrderedDict() # file_path -> open file handle + self._handlers: Dict[str, ExternalArrayHandler] = {} # file_path -> handler instance + + def get_or_open(self, file_path: str, handler: ExternalArrayHandler, mode: str = "r") -> Optional[Any]: + """ + Get an open file handle from cache, or open it if not cached. + + Args: + file_path: Path to the file + handler: Handler instance that knows how to open this file type + mode: File open mode ('r', 'a', etc.) + + Returns: + Open file handle, or None if opening failed + """ + # Normalize path + file_path = os.path.abspath(file_path) if os.path.exists(file_path) else file_path + + # Check cache first + if file_path in self._cache: + # Move to end (most recently used) + self._cache.move_to_end(file_path) + return self._cache[file_path] + + # Not in cache - try to open it + try: + file_handle = self._open_file(file_path, mode) + if file_handle is None: + return None + + # Add to cache + self._cache[file_path] = file_handle + self._handlers[file_path] = handler + self._cache.move_to_end(file_path) + + # Evict oldest if cache is full + if len(self._cache) > self.max_open_files: + self._evict_oldest() + + return file_handle + + except Exception as e: + logging.debug(f"Failed to open file {file_path}: {e}") + return None + + def _open_file(self, file_path: str, mode: str) -> Optional[Any]: + """ + Open a file based on its extension. + + Args: + file_path: Path to the file + mode: File open mode + + Returns: + Open file handle specific to the file type + """ + ext = os.path.splitext(file_path)[1].lower() + + if ext in [".h5", ".hdf5"] and __H5PY_MODULE_EXISTS__: + return h5py.File(file_path, mode) # type: ignore + # Add other file types as needed + # For now, other types will be opened on-demand by their handlers + + return None + + def _evict_oldest(self) -> None: + """Remove the least recently used file from cache.""" + if not self._cache: + return + + # Get oldest (first) item + oldest_path, oldest_handle = self._cache.popitem(last=False) + + # Close the file handle + try: + if hasattr(oldest_handle, "close"): + oldest_handle.close() + except Exception as e: + logging.debug(f"Error closing cached file {oldest_path}: {e}") + + # Remove handler reference + if oldest_path in self._handlers: + del self._handlers[oldest_path] + + def close_all(self) -> None: + """Close all cached file handles.""" + for file_path, file_handle in list(self._cache.items()): + try: + if hasattr(file_handle, "close"): + file_handle.close() + except Exception as e: + logging.debug(f"Error closing file {file_path}: {e}") + + self._cache.clear() + self._handlers.clear() + + def remove(self, file_path: str) -> None: + """ + Remove a specific file from cache and close it. + + Args: + file_path: Path to the file to remove + """ + file_path = os.path.abspath(file_path) if os.path.exists(file_path) else file_path + + if file_path in self._cache: + file_handle = self._cache.pop(file_path) + try: + if hasattr(file_handle, "close"): + file_handle.close() + except Exception as e: + logging.debug(f"Error closing file {file_path}: {e}") + + if file_path in self._handlers: + del self._handlers[file_path] + + def __len__(self) -> int: + """Return number of cached files.""" + return len(self._cache) + + def __contains__(self, file_path: str) -> bool: + """Check if a file is in cache.""" + file_path = os.path.abspath(file_path) if os.path.exists(file_path) else file_path + return file_path in self._cache + + +class FileHandlerRegistry: + """ + Global registry that maps file extensions to handler classes. + + This allows the system to automatically select the correct handler + based on file extension without hardcoding dependencies. + + Usage: + registry = FileHandlerRegistry() + handler = registry.get_handler_for_file("data.h5") + if handler: + array = handler.read_array("data.h5", "/dataset/path") + """ + + def __init__(self): + self._handlers: Dict[str, Callable[[], ExternalArrayHandler]] = {} + self._register_default_handlers() + + def _register_default_handlers(self) -> None: + """Register all available handlers based on installed dependencies.""" + # HDF5 Handler + if __H5PY_MODULE_EXISTS__: + self.register_handler([".h5", ".hdf5"], lambda: HDF5ArrayHandler()) + else: + self.register_handler([".h5", ".hdf5"], lambda: MockHDF5ArrayHandler()) + + # Parquet Handler + if __PARQUET_MODULE_EXISTS__: + self.register_handler([".parquet", ".pq"], lambda: ParquetArrayHandler()) + else: + self.register_handler([".parquet", ".pq"], lambda: MockParquetArrayHandler()) + + # CSV Handler - always available (uses Python's csv module) + if __CSV_MODULE_EXISTS__: + self.register_handler([".csv", ".txt", ".dat"], lambda: CSVArrayHandler()) + + def register_handler(self, extensions: List[str], handler_factory: Callable[[], ExternalArrayHandler]) -> None: + """ + Register a handler factory for given file extensions. + + Args: + extensions: List of file extensions (with leading dot, e.g., ['.h5', '.hdf5']) + handler_factory: Callable that returns a new handler instance + """ + for ext in extensions: + ext_lower = ext.lower() if ext.startswith(".") else "." + ext.lower() + self._handlers[ext_lower] = handler_factory + + def get_handler_for_file(self, file_path: str) -> Optional[ExternalArrayHandler]: + """ + Get appropriate handler for a file based on its extension. + + Args: + file_path: Path to the file + + Returns: + Handler instance, or None if no handler registered for this extension + """ + ext = os.path.splitext(file_path)[1].lower() + + if ext in self._handlers: + return self._handlers[ext]() + + return None + + def supports_extension(self, extension: str) -> bool: + """ + Check if a handler is registered for the given extension. + + Args: + extension: File extension (with or without leading dot) + + Returns: + True if a handler is registered + """ + ext_lower = extension.lower() if extension.startswith(".") else "." + extension.lower() + return ext_lower in self._handlers + + +# Global registry instance +_GLOBAL_HANDLER_REGISTRY = FileHandlerRegistry() + + +def get_handler_registry() -> FileHandlerRegistry: + """Get the global file handler registry.""" + return _GLOBAL_HANDLER_REGISTRY + + +# =========================================================================================== +# CONCRETE HANDLER IMPLEMENTATIONS +# =========================================================================================== + +# HDF5 Handler +if __H5PY_MODULE_EXISTS__: + + class HDF5ArrayHandler(ExternalArrayHandler): + """Handler for HDF5 files (.h5, .hdf5).""" + + def read_array( + self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + ) -> Optional[np.ndarray]: + """Read array from HDF5 file.""" + if isinstance(source, h5py.File): # type: ignore + if path_in_external_file: + d_group = source[path_in_external_file] + return d_group[()] # type: ignore + return None + else: + with h5py.File(source, "r") as f: # type: ignore + if path_in_external_file: + d_group = f[path_in_external_file] + return d_group[()] # type: ignore + return None + + def write_array( + self, + target: Union[str, BytesIO, Any], + array: Union[list, np.ndarray], + path_in_external_file: Optional[str] = None, + **kwargs, + ) -> bool: + """Write array to HDF5 file.""" + if not path_in_external_file: + return False + + if isinstance(array, list): + array = np.asarray(array) + + dtype = kwargs.get("dtype") + if dtype is not None and not isinstance(dtype, np.dtype): + dtype = np.dtype(dtype) + + try: + if isinstance(target, h5py.File): # type: ignore + if isinstance(array, np.ndarray) and array.dtype == "O": + array = np.asarray([s.encode() if isinstance(s, str) else s for s in array]) + np.void(array) + dset = target.create_dataset(path_in_external_file, array.shape, dtype or array.dtype) + dset[()] = array + else: + with h5py.File(target, "a") as f: # type: ignore + if isinstance(array, np.ndarray) and array.dtype == "O": + array = np.asarray([s.encode() if isinstance(s, str) else s for s in array]) + np.void(array) + dset = f.create_dataset(path_in_external_file, array.shape, dtype or array.dtype) + dset[()] = array + return True + except Exception as e: + logging.error(f"Failed to write array to HDF5: {e}") + return False + + def get_array_metadata( + self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + ) -> Optional[Union[dict, List[dict]]]: + """Get metadata for HDF5 datasets.""" + try: + if isinstance(source, h5py.File): # type: ignore + if path_in_external_file: + dset = source[path_in_external_file] + return { + "path": path_in_external_file, + "dtype": str(dset.dtype), + "shape": list(dset.shape), + "size": dset.size, + } + else: + # List all datasets + datasets = h5_list_datasets(source) + return [self.get_array_metadata(source, ds) for ds in datasets] + else: + with h5py.File(source, "r") as f: # type: ignore + return self.get_array_metadata(f, path_in_external_file) + except Exception as e: + logging.debug(f"Failed to get HDF5 metadata: {e}") + return None + + def list_arrays(self, source: Union[BytesIO, str, Any]) -> List[str]: + """List all datasets in HDF5 file.""" + return h5_list_datasets(source) + + def can_handle_file(self, file_path: str) -> bool: + """Check if this handler can process the file.""" + ext = os.path.splitext(file_path)[1].lower() + return ext in [".h5", ".hdf5"] + +else: + + class MockHDF5ArrayHandler(ExternalArrayHandler): + """Mock handler when h5py is not installed.""" + + def read_array( + self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + ) -> Optional[np.ndarray]: + raise MissingExtraInstallation(extra_name="hdf5") + + def write_array( + self, + target: Union[str, BytesIO, Any], + array: Union[list, np.ndarray], + path_in_external_file: Optional[str] = None, + **kwargs, + ) -> bool: + raise MissingExtraInstallation(extra_name="hdf5") + + def get_array_metadata( + self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + ) -> Optional[Union[dict, List[dict]]]: + raise MissingExtraInstallation(extra_name="hdf5") + + def list_arrays(self, source: Union[BytesIO, str, Any]) -> List[str]: + raise MissingExtraInstallation(extra_name="hdf5") + + def can_handle_file(self, file_path: str) -> bool: + return os.path.splitext(file_path)[1].lower() in [".h5", ".hdf5"] + + +# Parquet Handler +if __PARQUET_MODULE_EXISTS__: + + class ParquetArrayHandler(ExternalArrayHandler): + """Handler for Parquet files (.parquet, .pq).""" + + def read_array( + self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + ) -> Optional[np.ndarray]: + """Read array from Parquet file.""" + if isinstance(source, bytes): + source = pa.BufferReader(source) + + table = pq.read_table(source) + + if path_in_external_file: + return np.array(table[path_in_external_file]) + else: + # Return all columns as 2D array + return table.to_pandas().values + + def write_array( + self, + target: Union[str, BytesIO, Any], + array: Union[list, np.ndarray], + path_in_external_file: Optional[str] = None, + **kwargs, + ) -> bool: + """Write array to Parquet file.""" + column_titles = kwargs.get("column_titles") + + try: + if not isinstance(array[0], (list, np.ndarray, pd.Series)): + array = [array] + + array_as_pd_df = pd.DataFrame( + {k: array[idx] for idx, k in enumerate(column_titles or range(len(array)))} + ) + + pq.write_table( + pa.Table.from_pandas(array_as_pd_df), + target, + version="2.6", + compression="snappy", + ) + return True + except Exception as e: + logging.error(f"Failed to write array to Parquet: {e}") + return False + + def get_array_metadata( + self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + ) -> Optional[Union[dict, List[dict]]]: + """Get metadata for Parquet columns.""" + try: + if isinstance(source, bytes): + source = pa.BufferReader(source) + + metadata = pq.read_metadata(source) + schema = pq.read_schema(source) + + if path_in_external_file: + # Get specific column metadata + col_idx = schema.get_field_index(path_in_external_file) + if col_idx >= 0: + field = schema.field(col_idx) + return { + "path": path_in_external_file, + "dtype": str(field.type), + "shape": [metadata.num_rows], + "size": metadata.num_rows, + } + else: + # Get all columns + return [ + { + "path": field.name, + "dtype": str(field.type), + "shape": [metadata.num_rows], + "size": metadata.num_rows, + } + for field in schema + ] + except Exception as e: + logging.debug(f"Failed to get Parquet metadata: {e}") + return None + + def list_arrays(self, source: Union[BytesIO, str, Any]) -> List[str]: + """List all columns in Parquet file.""" + try: + if isinstance(source, bytes): + source = pa.BufferReader(source) + schema = pq.read_schema(source) + return [field.name for field in schema] + except Exception: + return [] + + def can_handle_file(self, file_path: str) -> bool: + """Check if this handler can process the file.""" + ext = os.path.splitext(file_path)[1].lower() + return ext in [".parquet", ".pq"] + +else: + + class MockParquetArrayHandler(ExternalArrayHandler): + """Mock handler when parquet libraries are not installed.""" + + def read_array( + self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + ) -> Optional[np.ndarray]: + raise MissingExtraInstallation(extra_name="parquet") + + def write_array( + self, + target: Union[str, BytesIO, Any], + array: Union[list, np.ndarray], + path_in_external_file: Optional[str] = None, + **kwargs, + ) -> bool: + raise MissingExtraInstallation(extra_name="parquet") + + def get_array_metadata( + self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + ) -> Optional[Union[dict, List[dict]]]: + raise MissingExtraInstallation(extra_name="parquet") + + def list_arrays(self, source: Union[BytesIO, str, Any]) -> List[str]: + raise MissingExtraInstallation(extra_name="parquet") + + def can_handle_file(self, file_path: str) -> bool: + return os.path.splitext(file_path)[1].lower() in [".parquet", ".pq"] + + +# CSV Handler +if __CSV_MODULE_EXISTS__: + + class CSVArrayHandler(ExternalArrayHandler): + """Handler for CSV files (.csv, .txt, .dat).""" + + def read_array( + self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + ) -> Optional[np.ndarray]: + """Read array from CSV file.""" + # For CSV, path_in_external_file can be column name or index + # This is a simplified implementation + try: + if isinstance(source, str): + data = np.genfromtxt(source, delimiter=",") + else: + data = np.genfromtxt(source, delimiter=",") + return data + except Exception as e: + logging.debug(f"Failed to read CSV: {e}") + return None + + def write_array( + self, + target: Union[str, BytesIO, Any], + array: Union[list, np.ndarray], + path_in_external_file: Optional[str] = None, + **kwargs, + ) -> bool: + """Write array to CSV file.""" + try: + if isinstance(array, list): + array = np.asarray(array) + np.savetxt(target, array, delimiter=",") + return True + except Exception as e: + logging.error(f"Failed to write CSV: {e}") + return False + + def get_array_metadata( + self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + ) -> Optional[Union[dict, List[dict]]]: + """Get metadata for CSV file.""" + try: + data = self.read_array(source, path_in_external_file) + if data is not None: + return { + "path": path_in_external_file or "", + "dtype": str(data.dtype), + "shape": list(data.shape), + "size": data.size, + } + except Exception as e: + logging.debug(f"Failed to get CSV metadata: {e}") + return None + + def list_arrays(self, source: Union[BytesIO, str, Any]) -> List[str]: + """CSV files don't have named datasets.""" + return [] + + def can_handle_file(self, file_path: str) -> bool: + """Check if this handler can process the file.""" + ext = os.path.splitext(file_path)[1].lower() + return ext in [".csv", ".txt", ".dat"] diff --git a/energyml-utils/src/energyml/utils/data/model.py b/energyml-utils/src/energyml/utils/data/model.py index e798ce8..2844ce7 100644 --- a/energyml-utils/src/energyml/utils/data/model.py +++ b/energyml-utils/src/energyml/utils/data/model.py @@ -1,8 +1,9 @@ # Copyright (c) 2023-2024 Geosiris. # SPDX-License-Identifier: Apache-2.0 +from abc import ABC, abstractmethod from dataclasses import dataclass from io import BytesIO -from typing import Optional, List, Union +from typing import Optional, List, Union, Any import numpy as np @@ -16,6 +17,103 @@ def get_array_dimension(self, source: Union[BytesIO, str], path_in_external_file return None +class ExternalArrayHandler(ABC): + """ + Base class for handling external array storage (HDF5, Parquet, CSV, etc.). + + This abstract interface defines the contract for reading, writing, and querying + metadata from external array files. Implementations for specific formats extend + this class and handle format-specific details. + + Key features: + - Format-agnostic interface + - Support for file paths, BytesIO, or already-opened file handles + - Metadata queries without loading full arrays + """ + + @abstractmethod + def read_array( + self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + ) -> Optional[np.ndarray]: + """ + Read array data from external storage. + + Args: + source: File path, BytesIO, or already-opened file handle + path_in_external_file: Path/dataset name within the file (format-specific) + + Returns: + Numpy array if successful, None otherwise + """ + pass + + @abstractmethod + def write_array( + self, + target: Union[str, BytesIO, Any], + array: Union[list, np.ndarray], + path_in_external_file: Optional[str] = None, + **kwargs, + ) -> bool: + """ + Write array data to external storage. + + Args: + target: File path, BytesIO, or already-opened file handle + array: Data to write + path_in_external_file: Path/dataset name within the file (format-specific) + **kwargs: Additional format-specific parameters + + Returns: + True if successful, False otherwise + """ + pass + + @abstractmethod + def get_array_metadata( + self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + ) -> Optional[Union[dict, List[dict]]]: + """ + Get metadata about arrays in external storage without loading the data. + + Args: + source: File path, BytesIO, or already-opened file handle + path_in_external_file: Specific array path, or None to get all arrays + + Returns: + Dict with keys: 'path', 'dtype', 'shape', 'size' for single array + List of such dicts if path_in_external_file is None + None if not found or error + """ + pass + + @abstractmethod + def list_arrays(self, source: Union[BytesIO, str, Any]) -> List[str]: + """ + List all array paths/dataset names in the external file. + + Args: + source: File path, BytesIO, or already-opened file handle + + Returns: + List of array path strings + """ + pass + + @abstractmethod + def can_handle_file(self, file_path: str) -> bool: + """ + Check if this handler can process the given file based on extension. + + Args: + file_path: Path to the file + + Returns: + True if this handler supports the file format + """ + pass + + # @dataclass # class ETPReader(DatasetReader): # def read_array(self, obj_uri: str, path_in_external_file: str) -> Optional[np.ndarray]: diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index 105acfc..215b4b2 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -28,7 +28,6 @@ Creator, Identifier, Keywords1, - TargetMode, ) from energyml.utils.epc_utils import ( gen_core_props_path, @@ -43,7 +42,6 @@ from .constants import ( RELS_CONTENT_TYPE, - RELS_FOLDER_NAME, EpcExportVersion, RawFile, EPCRelsRelationshipType, @@ -62,7 +60,6 @@ from .exception import UnparsableFile from .introspection import ( get_class_from_content_type, - get_dor_obj_info, get_obj_type, get_obj_uri, get_obj_usable_class, @@ -70,7 +67,6 @@ search_attribute_matching_type, get_obj_version, get_obj_uuid, - get_object_type_for_file_path_from_class, get_content_type_from_class, get_direct_dor_list, epoch_to_date, diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index 163896b..bf8973a 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -10,7 +10,6 @@ import atexit from datetime import datetime -from io import BytesIO import tempfile import traceback import numpy as np @@ -19,6 +18,7 @@ import os import re import zipfile +from enum import Enum from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path @@ -31,22 +31,19 @@ CoreProperties, Relationships, Relationship, - Default, - Created, - Creator, - Identifier, ) -from energyml.utils.data.datasets_io import HDF5FileReader, HDF5FileWriter +from energyml.utils.data.datasets_io import ( + FileCacheManager, + get_handler_registry, +) from energyml.utils.epc_utils import ( EXPANDED_EXPORT_FOLDER_PREFIX, create_default_core_properties, create_default_types, create_mandatory_structure_epc, extract_uuid_and_version_from_obj_path, - gen_core_props_rels_path, gen_rels_path_from_obj_path, repair_epc_structure_if_not_valid, - valdiate_basic_epc_structure, ) from energyml.utils.storage_interface import ( DataArrayMetadata, @@ -54,7 +51,7 @@ ResourceMetadata, create_resource_metadata_from_uri, ) -from energyml.utils.uri import Uri, create_uri_from_content_type_or_qualified_type, parse_uri +from energyml.utils.uri import Uri, create_uri_from_content_type_or_qualified_type from energyml.utils.constants import ( EPCRelsRelationshipType, EpcExportVersion, @@ -62,12 +59,9 @@ OptimizedRegex, file_extension_to_mime_type, date_to_datetime, - get_obj_type_from_content_or_qualified_type, - parse_content_type, ) from energyml.utils.epc import ( gen_energyml_object_path, - gen_rels_path, get_epc_content_type_path, gen_core_props_path, ) @@ -75,25 +69,18 @@ from energyml.utils.introspection import ( get_class_from_content_type, get_content_type_from_class, - get_obj_content_type, get_obj_identifier, get_obj_title, get_obj_uri, - get_obj_uuid, get_object_attribute_advanced, - get_object_type_for_file_path_from_class, get_direct_dor_list, get_obj_type, get_obj_usable_class, - epoch_to_date, - epoch, gen_uuid, ) from energyml.utils.serialization import read_energyml_xml_bytes, serialize_xml - -from .xml import is_energyml_content_type -from enum import Enum +from energyml.utils.xml import is_energyml_content_type def get_dor_identifiers_from_obj(obj: Any) -> Set[str]: @@ -239,7 +226,8 @@ class _WorkerResult(TypedDict): identifier: str file_path: str - dest_obj_identifiers: Set[str] + object_type: str + referenced_objects: List[Tuple[str, str]] # List of (target_identifier, target_type) def process_object_for_rels_worker( @@ -248,7 +236,7 @@ def process_object_for_rels_worker( """ Worker function for parallel relationship processing (runs in separate process). - This function is executed in a separate process to compute SOURCE relationships + This function is executed in a separate process to compute DESTINATION relationships for a single object. It bypasses Python's GIL for CPU-intensive XML parsing. Performance characteristics: @@ -257,20 +245,23 @@ def process_object_for_rels_worker( - Results are serialized back to the main process via pickle Args: - args: - - args: Tuple containing: + args: Tuple containing: - identifier: Object UUID/identifier to process - epc_file_path: Absolute path to the EPC file - metadata_dict: Dictionary of all object metadata (for validation) - - export_version: Version of EPC export format to use + export_version: Version of EPC export format to use Returns: - Dictionary conforming to _WorkerResult TypedDict, or None if processing fails. + Dictionary conforming to _WorkerResult TypedDict with the following keys: + - 'identifier': The identifier of the processed object + - 'file_path': The file path of the object within the EPC archive + - 'object_type': The type of the object (e.g., 'BoundaryFeature', 'TriangulatedSetRepresentation') + - 'referenced_objects': List of tuples (target_identifier, target_type) for all + Data Object References (DORs) found in this object that exist in the EPC + Returns None if processing fails (e.g., object not found, parsing error). """ identifier, epc_file_path, metadata_dict = args - dor_targets = [] - try: # Open ZIP file in this worker process metadata = metadata_dict.get(identifier) @@ -283,13 +274,32 @@ def process_object_for_rels_worker( obj_class = get_class_from_content_type(metadata.content_type) obj = read_energyml_xml_bytes(obj_data, obj_class) - # Get all Data Object References (DORs) from this object - dor_targets = get_dor_identifiers_from_obj(obj) + # Extract this object's type from metadata (no need to parse object) + obj_type = metadata.object_type + + # Get all DOR URIs - URIs contain all necessary info (type, uuid, version) + dor_uris = get_dor_uris_from_obj(obj) + + # Build list of (target_identifier, target_type) tuples from URIs + referenced_objects = [] + for uri in dor_uris: + try: + target_identifier = uri.as_identifier() + # Only include if target exists in metadata + if target_identifier and target_identifier in metadata_dict: + # Extract type directly from URI (no need to load target object) + target_type = uri.object_type + if target_type: + referenced_objects.append((target_identifier, target_type)) + except Exception as e: + # Don't fail entire object for one bad DOR + logging.debug(f"Skipping invalid DOR URI in {identifier}: {e}") return { "identifier": identifier, "file_path": metadata.file_path(export_version=export_version), - "dest_obj_identifiers": dor_targets, + "object_type": obj_type, + "referenced_objects": referenced_objects, } except Exception as e: @@ -1215,6 +1225,10 @@ def __init__( self._metadata_mgr.set_export_version(export_version) self._rels_mgr = _RelationshipManager(self._zip_accessor, self._metadata_mgr, self.stats, rels_update_mode) + # Initialize file cache manager for external array files (HDF5, Parquet, CSV, etc.) + self._file_cache = FileCacheManager(max_open_files=3) + self._handler_registry = get_handler_registry() + # Register atexit handler to ensure cleanup on program shutdown self._atexit_registered = True atexit.register(self._atexit_close) @@ -1353,10 +1367,10 @@ def get_h5_file_paths(self, obj: Union[str, Uri, Any]) -> List[str]: if _id is not None: rels_path = self._metadata_mgr.gen_rels_path_from_identifier(_id) - # Check in-memory additional rels first - for rels in self._rels_mgr.additional_rels.get(_id, []): - if rels.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(): - h5_paths.add(rels.target) + # Check in-memory additional rels first + for rels in self._rels_mgr.additional_rels.get(_id, []): + if rels.type_value == str(EPCRelsRelationshipType.EXTERNAL_RESOURCE): + h5_paths.add(rels.target) # Also check rels from the EPC file if rels_path is not None: @@ -1366,7 +1380,7 @@ def get_h5_file_paths(self, obj: Union[str, Uri, Any]) -> List[str]: self.stats.bytes_read += len(rels_data) relationships = read_energyml_xml_bytes(rels_data, Relationships) for rel in relationships.relationship: - if rel.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(): + if rel.type_value == str(EPCRelsRelationshipType.EXTERNAL_RESOURCE): h5_paths.add(rel.target) except KeyError: pass @@ -1689,20 +1703,224 @@ def delete_object(self, identifier: Union[str, Uri, Any]) -> bool: return True def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Optional[np.ndarray]: - pass + """ + Read a dataset from an external file (HDF5, Parquet, CSV, etc.) linked to the proxy object. - def write_array( - self, - proxy: Union[str, Uri, Any], - path_in_external: str, - array: np.ndarray, - ) -> bool: - pass + Uses an intelligent caching mechanism that: + 1. Checks cached open files first (up to 3 files kept open) + 2. Tries all possible file paths + 3. Automatically selects the correct reader based on file extension + 4. Adds successfully opened files to cache + + Args: + proxy: The object, its identifier, or URI + path_in_external: Path/dataset name within the external file + + Returns: + Numpy array if successful, None otherwise + """ + # Get possible file paths for this object + file_paths = [] + + if self.force_h5_path is not None: + # Use forced path if specified + file_paths = [self.force_h5_path] + else: + # Get file paths from relationships + file_paths = self.get_h5_file_paths(proxy) + + if not file_paths: + logging.warning(f"No external file paths found for proxy: {proxy}") + return None + + # Keep track of which paths we've tried from cache vs from scratch + cached_paths = [p for p in file_paths if p in self._file_cache] + non_cached_paths = [p for p in file_paths if p not in self._file_cache] + + # Try cached files first (most recently used first) + for file_path in cached_paths: + handler = self._handler_registry.get_handler_for_file(file_path) + if handler is None: + logging.debug(f"No handler found for file: {file_path}") + continue + + try: + # Get cached file handle + file_handle = self._file_cache.get_or_open(file_path, handler, mode="r") + if file_handle is not None: + # Try to read from cached handle + result = handler.read_array(file_handle, path_in_external) + if result is not None: + return result + except Exception as e: + logging.debug(f"Failed to read from cached file {file_path}: {e}") + # Remove from cache if it's causing issues + self._file_cache.remove(file_path) + + # Try non-cached files + for file_path in non_cached_paths: + handler = self._handler_registry.get_handler_for_file(file_path) + if handler is None: + logging.debug(f"No handler found for file: {file_path}") + continue + + try: + # Try to open and read, which will add to cache if successful + file_handle = self._file_cache.get_or_open(file_path, handler, mode="r") + if file_handle is not None: + result = handler.read_array(file_handle, path_in_external) + if result is not None: + return result + else: + # Cache failed, try direct read without caching + result = handler.read_array(file_path, path_in_external) + if result is not None: + return result + except Exception as e: + logging.debug(f"Failed to read from file {file_path}: {e}") + + logging.error(f"Failed to read array from any available file paths: {file_paths}") + return None + + def write_array(self, proxy: Union[str, Uri, Any], path_in_external: str, array: np.ndarray, **kwargs) -> bool: + """ + Write a dataset to an external file (HDF5, Parquet, CSV, etc.) linked to the proxy object. + + Uses the same caching mechanism as read_array for efficiency. + + Args: + proxy: The object, its identifier, or URI + path_in_external: Path/dataset name within the external file + array: Numpy array to write + **kwargs: Additional format-specific parameters (e.g., dtype for HDF5, column_titles for Parquet) + + Returns: + True if successful, False otherwise + """ + # Get possible file paths for this object + file_paths = [] + + if self.force_h5_path is not None: + # Use forced path if specified + file_paths = [self.force_h5_path] + else: + # Get file paths from relationships + file_paths = self.get_h5_file_paths(proxy) + + if not file_paths: + logging.warning(f"No external file paths found for proxy: {proxy}") + return False + + # Try to write to the first available file + # For writes, we prefer cached files first, then non-cached + cached_paths = [p for p in file_paths if p in self._file_cache] + non_cached_paths = [p for p in file_paths if p not in self._file_cache] + + # Try cached files first + for file_path in cached_paths: + handler = self._handler_registry.get_handler_for_file(file_path) + if handler is None: + continue + + try: + file_handle = self._file_cache.get_or_open(file_path, handler, mode="a") + if file_handle is not None: + success = handler.write_array(file_handle, array, path_in_external, **kwargs) + if success: + return True + except Exception as e: + logging.debug(f"Failed to write to cached file {file_path}: {e}") + self._file_cache.remove(file_path) + + # Try non-cached files + for file_path in non_cached_paths: + handler = self._handler_registry.get_handler_for_file(file_path) + if handler is None: + continue + + try: + # Open in append mode and add to cache + file_handle = self._file_cache.get_or_open(file_path, handler, mode="a") + if file_handle is not None: + success = handler.write_array(file_handle, array, path_in_external, **kwargs) + if success: + return True + else: + # Cache failed, try direct write + success = handler.write_array(file_path, array, path_in_external, **kwargs) + if success: + return True + except Exception as e: + logging.error(f"Failed to write to file {file_path}: {e}") + + return False def get_array_metadata( self, proxy: Union[str, Uri, Any], path_in_external: Optional[str] = None ) -> Union[DataArrayMetadata, List[DataArrayMetadata], None]: - pass + """ + Get metadata for data array(s) without loading the full array data. + + Args: + proxy: The object, its identifier, or URI + path_in_external: Optional specific array path. If None, returns metadata for all arrays. + + Returns: + DataArrayMetadata if path specified, List[DataArrayMetadata] if no path, + or None if not found + """ + # Get possible file paths for this object + file_paths = [] + + if self.force_h5_path is not None: + file_paths = [self.force_h5_path] + else: + file_paths = self.get_h5_file_paths(proxy) + + if not file_paths: + logging.warning(f"No external file paths found for proxy: {proxy}") + return None + + # Try cached files first + cached_paths = [p for p in file_paths if p in self._file_cache] + non_cached_paths = [p for p in file_paths if p not in self._file_cache] + + for file_path in cached_paths + non_cached_paths: + handler = self._handler_registry.get_handler_for_file(file_path) + if handler is None: + continue + + try: + file_handle = self._file_cache.get_or_open(file_path, handler, mode="r") + source = file_handle if file_handle is not None else file_path + + metadata_dict = handler.get_array_metadata(source, path_in_external) + + if metadata_dict is None: + continue + + # Convert dict(s) to DataArrayMetadata + if isinstance(metadata_dict, list): + return [ + DataArrayMetadata( + path_in_resource=m.get("path"), + array_type=m.get("dtype", "unknown"), + dimensions=m.get("shape", []), + custom_data={"size": m.get("size", 0)}, + ) + for m in metadata_dict + ] + else: + return DataArrayMetadata( + path_in_resource=metadata_dict.get("path"), + array_type=metadata_dict.get("dtype", "unknown"), + dimensions=metadata_dict.get("shape", []), + custom_data={"size": metadata_dict.get("size", 0)}, + ) + except Exception as e: + logging.debug(f"Failed to get metadata from file {file_path}: {e}") + + return None def list_objects( self, dataspace: Optional[str] = None, object_type: Optional[str] = None @@ -1712,6 +1930,10 @@ def list_objects( def get_obj_rels(self, obj: Union[str, Uri, Any]) -> List[Relationship]: _id = self._id_from_uri_or_identifier(obj) + if _id is None: + logging.warning(f"Could not resolve identifier for object {obj}, cannot get relationships") + return [] + metadata = self._metadata_mgr.get_metadata(_id) if metadata is None: logging.warning(f"Object with identifier {_id} not found in metadata, cannot get relationships") @@ -1734,6 +1956,18 @@ def close(self) -> None: except Exception as e: logging.warning(f"Error rebuilding rels on close: {e}") + # Close file cache + if hasattr(self, "_file_cache"): + self._file_cache.close_all() + + # Close cached h5 if using force_h5_path + if self.cache_opened_h5 is not None: + try: + self.cache_opened_h5.close() + except Exception as e: + logging.debug(f"Error closing cache_opened_h5: {e}") + self.cache_opened_h5 = None + # Delegate to ZIP accessor self._zip_accessor.close() @@ -1757,12 +1991,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): """Context manager exit with cleanup.""" self.clear_cache() self.close() - if self.cache_opened_h5 is not None: - try: - self.cache_opened_h5.close() - except Exception: - pass - self.cache_opened_h5 = None + # Note: close() now handles cache_opened_h5 def __len__(self) -> int: """Return total number of objects.""" @@ -1885,6 +2114,9 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in stats["objects_processed"] += 1 + # Extract this object's type + obj_type = get_obj_type(get_obj_usable_class(obj)) + # Get all DORs in this object dors = get_direct_dor_list(obj) @@ -1892,10 +2124,10 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in try: target_identifier = get_obj_identifier(dor) if target_identifier in self._metadata: - # Record this reference + # Record this reference (for building SOURCE rels in target's file) if target_identifier not in reverse_references: reverse_references[target_identifier] = [] - reverse_references[target_identifier].append((identifier, obj)) + reverse_references[target_identifier].append((identifier, obj_type)) except Exception: pass @@ -1906,21 +2138,20 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in # Map of rels_file_path -> Relationships object rels_files: Dict[str, Relationships] = {} - # Process each object to create SOURCE relationships + # Process each object to create DESTINATION relationships for identifier in self._metadata: try: obj = self.get_object(identifier) if obj is None: continue - # metadata = self._metadata[identifier] obj_rels_path = self._metadata_mgr.gen_rels_path_from_identifier(identifier) # Get all DORs (objects this object references) dors = get_direct_dor_list(obj) if dors: - # Create SOURCE relationships + # Create DESTINATION relationships (this object -> targets it references) relationships = [] for dor in dors: @@ -1928,17 +2159,18 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in target_identifier = get_obj_identifier(dor) if target_identifier in self._metadata: target_metadata = self._metadata[target_identifier] + target_type = get_obj_type(get_obj_usable_class(dor)) rel = Relationship( target=target_metadata.file_path(export_version=self._metadata_mgr._export_version), - type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), - id=f"_{identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", + type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + id=f"_{identifier}_{target_type}_{target_identifier}", ) relationships.append(rel) - stats["source_relationships"] += 1 + stats["destination_relationships"] += 1 except Exception as e: - logging.debug(f"Failed to create SOURCE relationship: {e}") + logging.debug(f"Failed to create DESTINATION relationship: {e}") if relationships and obj_rels_path: if obj_rels_path not in rels_files: @@ -1946,9 +2178,9 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in rels_files[obj_rels_path].relationship.extend(relationships) except Exception as e: - logging.warning(f"Failed to create SOURCE rels for {identifier}: {e}") + logging.warning(f"Failed to create DESTINATION rels for {identifier}: {e}") - # Add DESTINATION relationships + # Add SOURCE relationships (in target's .rels file, pointing back to sources) for target_identifier, source_list in reverse_references.items(): try: if target_identifier not in self._metadata: @@ -1960,27 +2192,27 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in if not target_rels_path: continue - # Create DESTINATION relationships for each object that references this one - for source_identifier, source_obj in source_list: + # Create SOURCE relationships for each object that references this one + for source_identifier, source_type in source_list: try: source_metadata = self._metadata[source_identifier] rel = Relationship( target=source_metadata.file_path(export_version=self._metadata_mgr._export_version), - type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), - id=f"_{target_identifier}_{get_obj_type(get_obj_usable_class(source_obj))}_{source_identifier}", + type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + id=f"_{target_identifier}_{source_type}_{source_identifier}", ) if target_rels_path not in rels_files: rels_files[target_rels_path] = Relationships(relationship=[]) rels_files[target_rels_path].relationship.append(rel) - stats["destination_relationships"] += 1 + stats["source_relationships"] += 1 except Exception as e: - logging.debug(f"Failed to create DESTINATION relationship: {e}") + logging.debug(f"Failed to create SOURCE relationship: {e}") except Exception as e: - logging.warning(f"Failed to create DESTINATION rels for {target_identifier}: {e}") + logging.warning(f"Failed to create SOURCE rels for {target_identifier}: {e}") stats["rels_files_created"] = len(rels_files) @@ -2104,12 +2336,12 @@ def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int] results = pool.starmap(process_object_for_rels_worker, work_items) # ============================================================================ - # PHASE 2: SEQUENTIAL - Aggregate worker results and build SOURCE relationships + # PHASE 2: SEQUENTIAL - Aggregate worker results and build DESTINATION relationships # ============================================================================ # Build data structures for subsequent phases: - # - reverse_references: Map target objects to their sources (for DESTINATION rels) + # - reverse_references: Map target objects to their sources (for SOURCE rels in target) # - rels_files: Accumulate all relationships by file path - reverse_references: Dict[str, List[str]] = {} + reverse_references: Dict[str, List[Tuple[str, str]]] = {} # target_id -> [(source_id, source_type)] rels_files: Dict[str, Relationships] = {} for result in results: @@ -2117,41 +2349,40 @@ def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int] continue identifier = result["identifier"] - dest_obj_identifiers = result["dest_obj_identifiers"] + object_type = result["object_type"] + referenced_objects = result["referenced_objects"] stats["objects_processed"] += 1 - # Create SOURCE relationships for this object + # Create DESTINATION relationships for this object (objects this one references) obj_rels_path = self._metadata_mgr.gen_rels_path_from_identifier(identifier) - if obj_rels_path and dest_obj_identifiers: + if obj_rels_path and referenced_objects: if obj_rels_path not in rels_files: rels_files[obj_rels_path] = Relationships(relationship=[]) - for target_identifier in dest_obj_identifiers: + for target_identifier, target_type in referenced_objects: # Verify target exists in metadata if target_identifier not in self._metadata: continue target_metadata = self._metadata[target_identifier] - target_class = get_class_from_content_type(target_metadata.content_type) - target_type = get_obj_type(target_class) if target_class else "Unknown" - # Create SOURCE relationship + # Create DESTINATION relationship (this object -> target) rel = Relationship( target=target_metadata.file_path(export_version=export_version), - type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), id=f"_{identifier}_{target_type}_{target_identifier}", ) rels_files[obj_rels_path].relationship.append(rel) - stats["source_relationships"] += 1 + stats["destination_relationships"] += 1 - # Build reverse reference map for DESTINATION relationships + # Build reverse reference map for SOURCE relationships if target_identifier not in reverse_references: reverse_references[target_identifier] = [] - reverse_references[target_identifier].append(identifier) + reverse_references[target_identifier].append((identifier, object_type)) # ============================================================================ - # PHASE 3: SEQUENTIAL - Create DESTINATION relationships + # PHASE 3: SEQUENTIAL - Create SOURCE relationships # ============================================================================ for target_identifier, source_list in reverse_references.items(): try: @@ -2163,28 +2394,27 @@ def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int] if not target_rels_path: continue - for source_identifier in source_list: + for source_identifier, source_type in source_list: try: source_metadata = self._metadata[source_identifier] - source_class = get_class_from_content_type(source_metadata.content_type) - source_type = get_obj_type(source_class) if source_class else "Unknown" + # Create SOURCE relationship (source object -> this target object) rel = Relationship( target=source_metadata.file_path(export_version=export_version), - type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), id=f"_{target_identifier}_{source_type}_{source_identifier}", ) if target_rels_path not in rels_files: rels_files[target_rels_path] = Relationships(relationship=[]) rels_files[target_rels_path].relationship.append(rel) - stats["destination_relationships"] += 1 + stats["source_relationships"] += 1 except Exception as e: - logging.debug(f"Failed to create DESTINATION relationship: {e}") + logging.debug(f"Failed to create SOURCE relationship: {e}") except Exception as e: - logging.warning(f"Failed to create DESTINATION rels for {target_identifier}: {e}") + logging.warning(f"Failed to create SOURCE rels for {target_identifier}: {e}") stats["rels_files_created"] = len(rels_files) diff --git a/energyml-utils/src/energyml/utils/epc_stream_old.py b/energyml-utils/src/energyml/utils/epc_stream_old.py deleted file mode 100644 index 2a7b98b..0000000 --- a/energyml-utils/src/energyml/utils/epc_stream_old.py +++ /dev/null @@ -1,3572 +0,0 @@ -# Copyright (c) 2023-2024 Geosiris. -# SPDX-License-Identifier: Apache-2.0 -""" -Memory-efficient EPC file handler for large files. - -This module provides EpcStreamReader - a lazy-loading, memory-efficient alternative -to the standard Epc class for handling very large EPC files without loading all -content into memory at once. -""" - -import tempfile -import shutil -import logging -import os -import zipfile -from contextlib import contextmanager -from dataclasses import dataclass -from pathlib import Path -from typing import Dict, List, Optional, Any, Iterator, Union, Tuple, TypedDict -from weakref import WeakValueDictionary - -from energyml.opc.opc import ( - Types, - Override, - CoreProperties, - Relationships, - Relationship, - Default, - Created, - Creator, - Identifier, -) -from energyml.utils.data.datasets_io import HDF5FileReader, HDF5FileWriter -from energyml.utils.epc_utils import gen_rels_path_from_obj_path -from energyml.utils.storage_interface import DataArrayMetadata, EnergymlStorageInterface, ResourceMetadata -from energyml.utils.uri import Uri, parse_uri -import h5py -import numpy as np -from energyml.utils.constants import ( - EPCRelsRelationshipType, - OptimizedRegex, - EpcExportVersion, - content_type_to_qualified_type, - get_obj_type_from_content_or_qualified_type, -) -from energyml.utils.epc import ( - gen_energyml_object_path, - gen_rels_path, - get_epc_content_type_path, - gen_core_props_path, -) - -from energyml.utils.introspection import ( - get_class_from_content_type, - get_obj_content_type, - get_obj_identifier, - get_obj_uuid, - get_object_type_for_file_path_from_class, - get_direct_dor_list, - get_obj_type, - get_obj_usable_class, - epoch_to_date, - epoch, - gen_uuid, -) -from energyml.utils.serialization import read_energyml_xml_bytes, serialize_xml -from .xml import is_energyml_content_type -from enum import Enum - - -class RelsUpdateMode(Enum): - """ - Relationship update modes for EPC file management. - - UPDATE_AT_MODIFICATION: Maintain relationships in real-time as objects are added/removed/modified. - This provides the best consistency but may be slower for bulk operations. - - UPDATE_ON_CLOSE: Rebuild all relationships when closing the EPC file. - This is more efficient for bulk operations but relationships are only - consistent after closing. - - MANUAL: No automatic relationship updates. User must manually call rebuild_all_rels(). - This provides maximum control and performance for advanced use cases. - """ - - UPDATE_AT_MODIFICATION = "update_at_modification" - UPDATE_ON_CLOSE = "update_on_close" - MANUAL = "manual" - - -@dataclass(frozen=True) -class EpcObjectMetadata: - """Metadata for an object in the EPC file. - Identifier is generated as uuid.version - """ - - uuid: str - object_type: str - content_type: str - file_path: str - identifier: Optional[str] = None - version: Optional[str] = None - - def __post_init__(self): - if self.identifier is None: - # Generate identifier if not provided - object.__setattr__(self, "identifier", f"{self.uuid}.{self.version or ''}") - - -@dataclass -class EpcStreamingStats: - """Statistics for EPC streaming operations.""" - - total_objects: int = 0 - loaded_objects: int = 0 - cache_hits: int = 0 - cache_misses: int = 0 - bytes_read: int = 0 - - @property - def cache_hit_rate(self) -> float: - """Calculate cache hit rate percentage.""" - total_requests = self.cache_hits + self.cache_misses - return (self.cache_hits / total_requests * 100) if total_requests > 0 else 0.0 - - @property - def memory_efficiency(self) -> float: - """Calculate memory efficiency percentage.""" - return (1 - (self.loaded_objects / self.total_objects)) * 100 if self.total_objects > 0 else 100.0 - - -# =========================================================================================== -# PARALLEL PROCESSING WORKER FUNCTIONS -# =========================================================================================== - -# Configuration constants for parallel processing -_MIN_OBJECTS_PER_WORKER = 10 # Minimum objects to justify spawning a worker -_WORKER_POOL_SIZE_RATIO = 10 # Number of objects per worker process - - -class _WorkerResult(TypedDict): - """Type definition for parallel worker function return value.""" - - identifier: str - object_type: str - source_rels: List[Dict[str, str]] - dor_targets: List[Tuple[str, str]] - - -def _process_object_for_rels_worker(args: Tuple[str, str, Dict[str, EpcObjectMetadata]]) -> Optional[_WorkerResult]: - """ - Worker function for parallel relationship processing (runs in separate process). - - This function is executed in a separate process to compute SOURCE relationships - for a single object. It bypasses Python's GIL for CPU-intensive XML parsing. - - Performance characteristics: - - Each worker process opens its own ZIP file handle - - XML parsing happens independently on separate CPU cores - - Results are serialized back to the main process via pickle - - Args: - args: Tuple containing: - - identifier: Object UUID/identifier to process - - epc_file_path: Absolute path to the EPC file - - metadata_dict: Dictionary of all object metadata (for validation) - - Returns: - Dictionary conforming to _WorkerResult TypedDict, or None if processing fails. - """ - identifier, epc_file_path, metadata_dict = args - - try: - # Open ZIP file in this worker process - metadata = metadata_dict.get(identifier) - if not metadata: - return None - - # Load object from ZIP - with zipfile.ZipFile(epc_file_path, "r") as zf: - obj_data = zf.read(metadata.file_path) - obj_class = get_class_from_content_type(metadata.content_type) - obj = read_energyml_xml_bytes(obj_data, obj_class) - - # Extract object type (cached to avoid reloading in Phase 3) - obj_type = get_obj_type(get_obj_usable_class(obj)) - - # Get all Data Object References (DORs) from this object - data_object_references = get_direct_dor_list(obj) - - # Build SOURCE relationships and track referenced objects - source_rels = [] - dor_targets = [] # Track (target_id, target_type) for reverse references - - for dor in data_object_references: - try: - target_identifier = get_obj_identifier(dor) - if target_identifier not in metadata_dict: - continue - - target_metadata = metadata_dict[target_identifier] - - # Extract target type (needed for relationship ID) - target_type = get_obj_type(get_obj_usable_class(dor)) - dor_targets.append((target_identifier, target_type)) - - # Serialize relationship as dict (Relationship objects aren't picklable) - rel_dict = { - "target": target_metadata.file_path, - "type_value": str(EPCRelsRelationshipType.DESTINATION_OBJECT), - "id": f"_{identifier}_{target_type}_{target_identifier}", - } - source_rels.append(rel_dict) - - except Exception as e: - # Don't fail entire object processing for one bad DOR - logging.debug(f"Skipping invalid DOR in {identifier}: {e}") - - return { - "identifier": identifier, - "object_type": obj_type, - "source_rels": source_rels, - "dor_targets": dor_targets, - } - - except Exception as e: - logging.warning(f"Worker failed to process {identifier}: {e}") - return None - - -# =========================================================================================== -# HELPER CLASSES FOR REFACTORED ARCHITECTURE -# =========================================================================================== - - -class _ZipFileAccessor: - """ - Internal helper class for managing ZIP file access with proper resource management. - - This class handles: - - Persistent ZIP connections when keep_open=True - - On-demand connections when keep_open=False - - Proper cleanup and resource management - - Connection pooling for better performance - """ - - def __init__(self, epc_file_path: Path, keep_open: bool = False): - """ - Initialize the ZIP file accessor. - - Args: - epc_file_path: Path to the EPC file - keep_open: If True, maintains a persistent connection - """ - self.epc_file_path = epc_file_path - self.keep_open = keep_open - self._persistent_zip: Optional[zipfile.ZipFile] = None - - def open_persistent_connection(self) -> None: - """Open a persistent ZIP connection if keep_open is enabled.""" - if self.keep_open and self._persistent_zip is None: - self._persistent_zip = zipfile.ZipFile(self.epc_file_path, "r") - - @contextmanager - def get_zip_file(self) -> Iterator[zipfile.ZipFile]: - """ - Context manager for ZIP file access with proper resource management. - - If keep_open is True, uses the persistent connection. Otherwise opens a new one. - """ - if self.keep_open and self._persistent_zip is not None: - # Use persistent connection, don't close it - yield self._persistent_zip - else: - # Open and close per request - zf = None - try: - zf = zipfile.ZipFile(self.epc_file_path, "r") - yield zf - finally: - if zf is not None: - zf.close() - - def reopen_persistent_zip(self) -> None: - """Reopen persistent ZIP file after modifications to reflect changes.""" - if self.keep_open and self._persistent_zip is not None: - try: - self._persistent_zip.close() - except Exception: - pass - self._persistent_zip = zipfile.ZipFile(self.epc_file_path, "r") - - def close(self) -> None: - """Close the persistent ZIP file if it's open.""" - if self._persistent_zip is not None: - try: - self._persistent_zip.close() - except Exception as e: - logging.debug(f"Error closing persistent ZIP file: {e}") - finally: - self._persistent_zip = None - - -class _MetadataManager: - """ - Internal helper class for managing object metadata, indexing, and queries. - - This class handles: - - Loading metadata from [Content_Types].xml - - Maintaining UUID and type indexes - - Fast metadata queries without loading objects - - Version detection - """ - - def __init__(self, zip_accessor: _ZipFileAccessor, stats: EpcStreamingStats): - """ - Initialize the metadata manager. - - Args: - zip_accessor: ZIP file accessor for reading from EPC - stats: Statistics tracker - """ - self.zip_accessor = zip_accessor - self.stats = stats - - # Object metadata storage - self._metadata: Dict[str, EpcObjectMetadata] = {} # identifier -> metadata - self._uuid_index: Dict[str, List[str]] = {} # uuid -> list of identifiers - self._type_index: Dict[str, List[str]] = {} # object_type -> list of identifiers - self._core_props: Optional[CoreProperties] = None - self._core_props_path: Optional[str] = None - self._export_version = EpcExportVersion.CLASSIC # Store export version - - def set_export_version(self, version: EpcExportVersion) -> None: - """Set the export version.""" - self._export_version = version - - def load_metadata(self) -> None: - """Load object metadata from [Content_Types].xml without loading actual objects.""" - try: - with self.zip_accessor.get_zip_file() as zf: - # Read content types - content_types = self._read_content_types(zf) - - # Process each override entry - for override in content_types.override: - if override.content_type and override.part_name: - if is_energyml_content_type(override.content_type): - self._process_energyml_object_metadata(zf, override) - elif self._is_core_properties(override.content_type): - self._process_core_properties_metadata(override) - - self.stats.total_objects = len(self._metadata) - - except Exception as e: - logging.error(f"Failed to load metadata from EPC file: {e}") - raise - - def _read_content_types(self, zf: zipfile.ZipFile) -> Types: - """Read and parse [Content_Types].xml file.""" - content_types_path = get_epc_content_type_path() - - try: - content_data = zf.read(content_types_path) - self.stats.bytes_read += len(content_data) - return read_energyml_xml_bytes(content_data, Types) - except KeyError: - # Try case-insensitive search - for name in zf.namelist(): - if name.lower() == content_types_path.lower(): - content_data = zf.read(name) - self.stats.bytes_read += len(content_data) - return read_energyml_xml_bytes(content_data, Types) - raise FileNotFoundError("No [Content_Types].xml found in EPC file") - - def _process_energyml_object_metadata(self, zf: zipfile.ZipFile, override: Override) -> None: - """Process metadata for an EnergyML object without loading it.""" - if not override.part_name or not override.content_type: - return - - file_path = override.part_name.lstrip("/") - content_type = override.content_type - - try: - # Quick peek to extract UUID and version without full parsing - uuid, version, obj_type = self._extract_object_info_fast(zf, file_path, content_type) - - if uuid: # Only process if we successfully extracted UUID - metadata = EpcObjectMetadata( - uuid=uuid, object_type=obj_type, content_type=content_type, file_path=file_path, version=version - ) - - # Store in indexes - identifier = metadata.identifier - if identifier: - self._metadata[identifier] = metadata - - # Update UUID index - if uuid not in self._uuid_index: - self._uuid_index[uuid] = [] - self._uuid_index[uuid].append(identifier) - - # Update type index - if obj_type not in self._type_index: - self._type_index[obj_type] = [] - self._type_index[obj_type].append(identifier) - - except Exception as e: - logging.warning(f"Failed to process metadata for {file_path}: {e}") - - def _extract_object_info_fast( - self, zf: zipfile.ZipFile, file_path: str, content_type: str - ) -> Tuple[Optional[str], Optional[str], str]: - """Fast extraction of UUID and version from XML without full parsing.""" - try: - # Read only the beginning of the file for UUID extraction - with zf.open(file_path) as f: - # Read first chunk (usually sufficient for root element) - chunk = f.read(2048) # 2KB should be enough for root element - self.stats.bytes_read += len(chunk) - - chunk_str = chunk.decode("utf-8", errors="ignore") - - # Extract UUID using optimized regex - uuid_match = OptimizedRegex.UUID_NO_GRP.search(chunk_str) - uuid = uuid_match.group(0) if uuid_match else None - - # Extract version if present - version = None - version_patterns = [ - r'object[Vv]ersion["\']?\s*[:=]\s*["\']([^"\']+)', - ] - - for pattern in version_patterns: - import re - - version_match = re.search(pattern, chunk_str) - if version_match: - version = version_match.group(1) - # Ensure version is a string - if not isinstance(version, str): - version = str(version) - break - - # Extract object type from content type - obj_type = get_obj_type_from_content_or_qualified_type(content_type) - - return uuid, version, obj_type - - except Exception as e: - logging.debug(f"Fast extraction failed for {file_path}: {e}") - return None, None, "Unknown" - - def _is_core_properties(self, content_type: str) -> bool: - """Check if content type is CoreProperties.""" - return content_type == "application/vnd.openxmlformats-package.core-properties+xml" - - def _process_core_properties_metadata(self, override: Override) -> None: - """Process core properties metadata.""" - if override.part_name: - self._core_props_path = override.part_name.lstrip("/") - - def get_metadata(self, identifier: str) -> Optional[EpcObjectMetadata]: - """Get metadata for an object by identifier.""" - return self._metadata.get(identifier) - - def get_by_uuid(self, uuid: str) -> List[str]: - """Get all identifiers for objects with the given UUID.""" - return self._uuid_index.get(uuid, []) - - def get_by_type(self, object_type: str) -> List[str]: - """Get all identifiers for objects of the given type.""" - return self._type_index.get(object_type, []) - - def list_metadata(self, object_type: Optional[str] = None) -> List[EpcObjectMetadata]: - """List metadata for all objects, optionally filtered by type.""" - if object_type is None: - return list(self._metadata.values()) - return [self._metadata[identifier] for identifier in self._type_index.get(object_type, [])] - - def add_metadata(self, metadata: EpcObjectMetadata) -> None: - """Add metadata for a new object.""" - identifier = metadata.identifier - if identifier: - self._metadata[identifier] = metadata - - # Update UUID index - if metadata.uuid not in self._uuid_index: - self._uuid_index[metadata.uuid] = [] - self._uuid_index[metadata.uuid].append(identifier) - - # Update type index - if metadata.object_type not in self._type_index: - self._type_index[metadata.object_type] = [] - self._type_index[metadata.object_type].append(identifier) - - self.stats.total_objects += 1 - - def remove_metadata(self, identifier: str) -> Optional[EpcObjectMetadata]: - """Remove metadata for an object. Returns the removed metadata.""" - metadata = self._metadata.pop(identifier, None) - if metadata: - # Update UUID index - if metadata.uuid in self._uuid_index: - self._uuid_index[metadata.uuid].remove(identifier) - if not self._uuid_index[metadata.uuid]: - del self._uuid_index[metadata.uuid] - - # Update type index - if metadata.object_type in self._type_index: - self._type_index[metadata.object_type].remove(identifier) - if not self._type_index[metadata.object_type]: - del self._type_index[metadata.object_type] - - self.stats.total_objects -= 1 - - return metadata - - def contains(self, identifier: str) -> bool: - """Check if an object with the given identifier exists.""" - return identifier in self._metadata - - def __len__(self) -> int: - """Return total number of objects.""" - return len(self._metadata) - - def __iter__(self) -> Iterator[str]: - """Iterate over object identifiers.""" - return iter(self._metadata.keys()) - - def gen_rels_path_from_metadata(self, metadata: EpcObjectMetadata) -> str: - """Generate rels path from object metadata without loading the object.""" - return gen_rels_path_from_obj_path(obj_path=metadata.file_path) - - def gen_rels_path_from_identifier(self, identifier: str) -> Optional[str]: - """Generate rels path from object identifier without loading the object.""" - metadata = self._metadata.get(identifier) - if metadata is None: - return None - return self.gen_rels_path_from_metadata(metadata) - - def get_core_properties(self) -> Optional[CoreProperties]: - """Get core properties (loaded lazily).""" - if self._core_props is None and self._core_props_path: - try: - with self.zip_accessor.get_zip_file() as zf: - core_data = zf.read(self._core_props_path) - self.stats.bytes_read += len(core_data) - self._core_props = read_energyml_xml_bytes(core_data, CoreProperties) - except Exception as e: - logging.error(f"Failed to load core properties: {e}") - - return self._core_props - - def detect_epc_version(self) -> EpcExportVersion: - """Detect EPC packaging version based on file structure.""" - try: - with self.zip_accessor.get_zip_file() as zf: - file_list = zf.namelist() - - # Look for patterns that indicate EXPANDED version - for file_path in file_list: - # Skip metadata files - if ( - file_path.startswith("[Content_Types]") - or file_path.startswith("_rels/") - or file_path.endswith(".rels") - ): - continue - - # Check for namespace_ prefix pattern - if file_path.startswith("namespace_"): - path_parts = file_path.split("/") - if len(path_parts) >= 2: - logging.info(f"Detected EXPANDED EPC version based on path: {file_path}") - return EpcExportVersion.EXPANDED - - # If no EXPANDED patterns found, assume CLASSIC - logging.info("Detected CLASSIC EPC version") - return EpcExportVersion.CLASSIC - - except Exception as e: - logging.warning(f"Failed to detect EPC version, defaulting to CLASSIC: {e}") - return EpcExportVersion.CLASSIC - - def update_content_types_xml( - self, source_zip: zipfile.ZipFile, metadata: EpcObjectMetadata, add: bool = True - ) -> str: - """Update [Content_Types].xml to add or remove object entry. - - Args: - source_zip: Open ZIP file to read from - metadata: Object metadata - add: If True, add entry; if False, remove entry - - Returns: - Updated [Content_Types].xml as string - """ - # Read existing content types - content_types = self._read_content_types(source_zip) - - if add: - # Add new override entry - new_override = Override() - new_override.part_name = f"/{metadata.file_path}" - new_override.content_type = metadata.content_type - content_types.override.append(new_override) - else: - # Remove existing override entry - content_types.override = [ - o for o in content_types.override if o.part_name and o.part_name.lstrip("/") != metadata.file_path - ] - - # Serialize back to XML - return serialize_xml(content_types) - - -class _StructureValidator: - """ - Internal helper class for validating and repairing EPC file structure. - - Ensures compliance with EPC v1.0 specification by validating: - - Presence of [Content_Types].xml with correct default types - - Presence of _rels/.rels with Core Properties relationship - - Presence of Core Properties file - - Proper URI encoding and Part Name conventions - - This class provides idempotent repair operations that can be safely - called multiple times without corrupting the file. - """ - - # EPC specification constants - RELS_CONTENT_TYPE = "application/vnd.openxmlformats-package.relationships+xml" - CORE_PROPS_CONTENT_TYPE = "application/vnd.openxmlformats-package.core-properties+xml" - CORE_PROPS_REL_TYPE = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" - - def __init__( - self, zip_accessor: _ZipFileAccessor, metadata_manager: _MetadataManager, export_version: EpcExportVersion - ): - """ - Initialize the structure validator. - - Args: - zip_accessor: ZIP file accessor for reading/writing - metadata_manager: Metadata manager for object lookups - export_version: EPC export version - """ - self.zip_accessor = zip_accessor - self.metadata_manager = metadata_manager - self.export_version = export_version - - def validate_and_repair(self, is_new_file: bool = False) -> Dict[str, bool]: - """ - Validate EPC structure and repair if necessary. - - This method is idempotent - can be called multiple times safely. - - Args: - is_new_file: If True, bootstrap a complete minimal structure - - Returns: - Dictionary with validation/repair results: - { - 'content_types_ok': bool, - 'root_rels_ok': bool, - 'core_props_ok': bool, - 'repaired': bool - } - """ - results = {"content_types_ok": False, "root_rels_ok": False, "core_props_ok": False, "repaired": False} - - if is_new_file: - logging.info("[EPC Structure] Bootstrapping new EPC file with minimal valid structure") - self._bootstrap_new_file() - results.update({"content_types_ok": True, "root_rels_ok": True, "core_props_ok": True, "repaired": True}) - else: - logging.info("[EPC Structure] Validating existing EPC file structure") - - # Check Content Types - results["content_types_ok"] = self._validate_content_types() - if not results["content_types_ok"]: - logging.warning("[EPC Structure] Content Types invalid, repairing...") - self._repair_content_types() - results["repaired"] = True - - # Check Root Relationships - results["root_rels_ok"] = self._validate_root_relationships() - if not results["root_rels_ok"]: - logging.warning("[EPC Structure] Root relationships invalid, repairing...") - self._repair_root_relationships() - results["repaired"] = True - - # Check Core Properties - results["core_props_ok"] = self._validate_core_properties() - if not results["core_props_ok"]: - logging.warning("[EPC Structure] Core Properties missing or invalid, repairing...") - self._repair_core_properties() - results["repaired"] = True - - if results["repaired"]: - logging.info("[EPC Structure] Structure validation complete - repairs applied") - else: - logging.info("[EPC Structure] Structure validation complete - no repairs needed") - - return results - - def _bootstrap_new_file(self) -> None: - """Bootstrap a new EPC file with complete minimal structure.""" - with zipfile.ZipFile(self.zip_accessor.epc_file_path, "w") as zf: - # 1. Create Core Properties - core_props = self._create_default_core_properties() - core_props_path = gen_core_props_path(self.export_version) - core_props_xml = serialize_xml(core_props) - zf.writestr(core_props_path, core_props_xml) - logging.info(f"[EPC Structure] Created Core Properties at {core_props_path}") - - # 2. Create Content Types with defaults - content_types = Types( - default=[ - Default(extension="rels", content_type=self.RELS_CONTENT_TYPE), - Default(extension="xml", content_type="application/xml"), - ], - override=[Override(part_name=f"/{core_props_path}", content_type=self.CORE_PROPS_CONTENT_TYPE)], - ) - content_types_xml = serialize_xml(content_types) - zf.writestr(get_epc_content_type_path(), content_types_xml) - logging.info("[EPC Structure] Created [Content_Types].xml with default types") - - # 3. Create Root Relationships - root_rels = Relationships( - relationship=[ - Relationship(id="CoreProperties", type_value=self.CORE_PROPS_REL_TYPE, target=core_props_path) - ] - ) - root_rels_xml = serialize_xml(root_rels) - zf.writestr("_rels/.rels", root_rels_xml) - logging.info("[EPC Structure] Created _rels/.rels with Core Properties relationship") - - # Update metadata manager - self.metadata_manager._core_props = core_props - self.metadata_manager._core_props_path = core_props_path - - def _validate_content_types(self) -> bool: - """Validate [Content_Types].xml structure.""" - try: - with self.zip_accessor.get_zip_file() as zf: - content_types = self.metadata_manager._read_content_types(zf) - - # Check for .rels default - has_rels_default = any( - d.extension == "rels" and d.content_type == self.RELS_CONTENT_TYPE - for d in (content_types.default or []) - ) - - if not has_rels_default: - logging.warning("[EPC Structure] Missing or incorrect .rels default content type") - return False - - # Check for Core Properties override - core_props_path = gen_core_props_path(self.export_version) - has_core_props = any( - o.part_name and o.part_name.lstrip("/") == core_props_path for o in (content_types.override or []) - ) - - if not has_core_props: - logging.warning("[EPC Structure] Core Properties not declared in Content Types") - return False - - return True - - except Exception as e: - logging.error(f"[EPC Structure] Error validating Content Types: {e}") - return False - - def _validate_root_relationships(self) -> bool: - """Validate _rels/.rels structure.""" - try: - with self.zip_accessor.get_zip_file() as zf: - try: - rels_xml = zf.read("_rels/.rels") - root_rels = read_energyml_xml_bytes(rels_xml, Relationships) - - # Check for Core Properties relationship - has_core_props_rel = any( - r.type_value == self.CORE_PROPS_REL_TYPE for r in (root_rels.relationship or []) - ) - - if not has_core_props_rel: - logging.warning("[EPC Structure] Core Properties relationship missing from root rels") - return False - - return True - - except KeyError: - logging.warning("[EPC Structure] _rels/.rels file missing") - return False - - except Exception as e: - logging.error(f"[EPC Structure] Error validating root relationships: {e}") - return False - - def _validate_core_properties(self) -> bool: - """Validate Core Properties file existence.""" - try: - core_props_path = gen_core_props_path(self.export_version) - with self.zip_accessor.get_zip_file() as zf: - try: - zf.getinfo(core_props_path) - return True - except KeyError: - logging.warning(f"[EPC Structure] Core Properties file missing: {core_props_path}") - return False - - except Exception as e: - logging.error(f"[EPC Structure] Error validating Core Properties: {e}") - return False - - def _repair_content_types(self) -> None: - """Repair [Content_Types].xml.""" - try: - with self.zip_accessor.get_zip_file() as source_zf: - try: - content_types = self.metadata_manager._read_content_types(source_zf) - except: - # Create new if doesn't exist - content_types = Types() - - # Ensure .rels default - if not content_types.default: - content_types.default = [] - - has_rels = any(d.extension == "rels" for d in content_types.default) - if not has_rels: - content_types.default.append(Default(extension="rels", content_type=self.RELS_CONTENT_TYPE)) - logging.info("[EPC Structure] Added .rels default content type") - - # Ensure Core Properties override - if not content_types.override: - content_types.override = [] - - core_props_path = gen_core_props_path(self.export_version) - has_core_props = any( - o.part_name and o.part_name.lstrip("/") == core_props_path for o in content_types.override - ) - - if not has_core_props: - content_types.override.append( - Override(part_name=f"/{core_props_path}", content_type=self.CORE_PROPS_CONTENT_TYPE) - ) - logging.info("[EPC Structure] Added Core Properties to Content Types") - - # Write back - self._write_to_zip(get_epc_content_type_path(), serialize_xml(content_types)) - - except Exception as e: - logging.error(f"[EPC Structure] Error repairing Content Types: {e}") - raise - - def _repair_root_relationships(self) -> None: - """Repair _rels/.rels.""" - try: - core_props_path = gen_core_props_path(self.export_version) - - with self.zip_accessor.get_zip_file() as source_zf: - try: - rels_xml = source_zf.read("_rels/.rels") - root_rels = read_energyml_xml_bytes(rels_xml, Relationships) - except: - root_rels = Relationships(relationship=[]) - - # Ensure Core Properties relationship exists - if not root_rels.relationship: - root_rels.relationship = [] - - has_core_props = any(r.type_value == self.CORE_PROPS_REL_TYPE for r in root_rels.relationship) - - if not has_core_props: - root_rels.relationship.append( - Relationship(id="CoreProperties", type_value=self.CORE_PROPS_REL_TYPE, target=core_props_path) - ) - logging.info("[EPC Structure] Added Core Properties relationship to root rels") - - # Write back - self._write_to_zip("_rels/.rels", serialize_xml(root_rels)) - - except Exception as e: - logging.error(f"[EPC Structure] Error repairing root relationships: {e}") - raise - - def _repair_core_properties(self) -> None: - """Repair Core Properties file.""" - try: - core_props_path = gen_core_props_path(self.export_version) - - # Check if exists - try: - with self.zip_accessor.get_zip_file() as zf: - zf.getinfo(core_props_path) - return # Already exists - except KeyError: - pass - - # Create new Core Properties - core_props = self._create_default_core_properties() - self._write_to_zip(core_props_path, serialize_xml(core_props)) - logging.info(f"[EPC Structure] Created missing Core Properties at {core_props_path}") - - # Update metadata manager - self.metadata_manager._core_props = core_props - self.metadata_manager._core_props_path = core_props_path - - except Exception as e: - logging.error(f"[EPC Structure] Error repairing Core Properties: {e}") - raise - - def _create_default_core_properties(self) -> CoreProperties: - """Create default Core Properties object.""" - return CoreProperties( - created=Created(any_element=epoch_to_date(epoch())), - creator=Creator(any_element="energyml-utils EpcStreamReader (Geosiris)"), - identifier=Identifier(any_element=f"urn:uuid:{gen_uuid()}"), - version="1.0", - ) - - def _write_to_zip(self, path: str, content: str) -> None: - """Write content to ZIP file using atomic operation.""" - import tempfile - - temp_path = None - try: - # Create temporary file - with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: - temp_path = temp_file.name - - # Copy all files except the one we're updating - with self.zip_accessor.get_zip_file() as source_zf: - with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: - for item in source_zf.infolist(): - if item.filename != path: - target_zf.writestr(item, source_zf.read(item.filename)) - - # Write new content - target_zf.writestr(path, content) - - # Replace original - import shutil - - shutil.move(temp_path, str(self.zip_accessor.epc_file_path)) - temp_path = None - - # Reopen persistent connection if needed - self.zip_accessor.reopen_persistent_zip() - - finally: - if temp_path and Path(temp_path).exists(): - Path(temp_path).unlink() - - -class _RelationshipManager: - """ - Internal helper class for managing relationships between objects. - - This class handles: - - Reading relationships from .rels files - - Writing relationship updates - - Supporting 3 update modes (UPDATE_AT_MODIFICATION, UPDATE_ON_CLOSE, MANUAL) - - Preserving EXTERNAL_RESOURCE relationships - - Rebuilding all relationships - """ - - def __init__( - self, - zip_accessor: _ZipFileAccessor, - metadata_manager: _MetadataManager, - stats: EpcStreamingStats, - export_version: EpcExportVersion, - rels_update_mode: RelsUpdateMode, - ): - """ - Initialize the relationship manager. - - Args: - zip_accessor: ZIP file accessor for reading/writing - metadata_manager: Metadata manager for object lookups - stats: Statistics tracker - export_version: EPC export version - rels_update_mode: Relationship update mode - """ - self.zip_accessor = zip_accessor - self.metadata_manager = metadata_manager - self.stats = stats - self.export_version = export_version - self.rels_update_mode = rels_update_mode - - # Additional rels management (for user-added relationships) - self.additional_rels: Dict[str, List[Relationship]] = {} - - def get_obj_rels(self, obj_identifier: str, rels_path: Optional[str] = None) -> List[Relationship]: - """ - Get all relationships for a given object. - Merges relationships from the EPC file with in-memory additional relationships. - """ - rels = [] - - # Read rels from EPC file - if rels_path is None: - rels_path = self.metadata_manager.gen_rels_path_from_identifier(obj_identifier) - - if rels_path is not None: - with self.zip_accessor.get_zip_file() as zf: - try: - rels_data = zf.read(rels_path) - self.stats.bytes_read += len(rels_data) - relationships = read_energyml_xml_bytes(rels_data, Relationships) - rels.extend(relationships.relationship) - except KeyError: - # No rels file found for this object - pass - - # Merge with in-memory additional relationships - if obj_identifier in self.additional_rels: - rels.extend(self.additional_rels[obj_identifier]) - - return rels - - def update_rels_for_new_object(self, obj: Any, obj_identifier: str) -> None: - """Update relationships when a new object is added (UPDATE_AT_MODIFICATION mode).""" - metadata = self.metadata_manager.get_metadata(obj_identifier) - if not metadata: - logging.warning(f"Metadata not found for {obj_identifier}") - return - - # Get all objects this new object references - direct_dors = get_direct_dor_list(obj) - - # Build SOURCE relationships for this object - source_relationships = [] - dest_updates: Dict[str, Relationship] = {} - - for dor in direct_dors: - try: - target_identifier = get_obj_identifier(dor) - if not self.metadata_manager.contains(target_identifier): - continue - - target_metadata = self.metadata_manager.get_metadata(target_identifier) - if not target_metadata: - continue - - # Create SOURCE relationship : current is referenced by - source_rel = Relationship( - target=metadata.file_path, - type_value=str(EPCRelsRelationshipType.SOURCE_OBJECT), - id=f"_{gen_uuid()}", - ) - source_relationships.append(source_rel) - - # Create DESTINATION relationship current depends on target - dest_rel = Relationship( - target=target_metadata.file_path, - type_value=str(EPCRelsRelationshipType.DESTINATION_OBJECT), - id=f"_{gen_uuid()}", - ) - dest_updates[target_identifier] = dest_rel - - except Exception as e: - logging.warning(f"Failed to create relationship for DOR: {e}") - - # Write updates - self.write_rels_updates(obj_identifier, source_relationships, dest_updates) - - def update_rels_for_modified_object(self, obj: Any, obj_identifier: str, old_dors: List[Any]) -> None: - """Update relationships when an object is modified (UPDATE_AT_MODIFICATION mode).""" - metadata = self.metadata_manager.get_metadata(obj_identifier) - if not metadata: - logging.warning(f"Metadata not found for {obj_identifier}") - return - - # Get new DORs - new_dors = get_direct_dor_list(obj) - - # Convert to sets of identifiers for comparison - old_dor_ids = { - get_obj_identifier(dor) for dor in old_dors if self.metadata_manager.contains(get_obj_identifier(dor)) - } - new_dor_ids = { - get_obj_identifier(dor) for dor in new_dors if self.metadata_manager.contains(get_obj_identifier(dor)) - } - - # Find added and removed references - added_dor_ids = new_dor_ids - old_dor_ids - removed_dor_ids = old_dor_ids - new_dor_ids - - # Build new SOURCE relationships - source_relationships = [] - dest_updates: Dict[str, Relationship] = {} - - # Create relationships for all new DORs - for dor in new_dors: - target_identifier = get_obj_identifier(dor) - if not self.metadata_manager.contains(target_identifier): - continue - - target_metadata = self.metadata_manager.get_metadata(target_identifier) - if not target_metadata: - continue - - # SOURCE relationship : current is referenced by - source_rel = Relationship( - target=metadata.file_path, - type_value=str(EPCRelsRelationshipType.SOURCE_OBJECT), - id=f"_{gen_uuid()}", - ) - source_relationships.append(source_rel) - - # DESTINATION relationship (for added DORs only) current depends on target - if target_identifier in added_dor_ids: - dest_rel = Relationship( - target=target_metadata.file_path, - type_value=str(EPCRelsRelationshipType.DESTINATION_OBJECT), - id=f"_{gen_uuid()}", - ) - dest_updates[target_identifier] = dest_rel - - # For removed DORs, remove DESTINATION relationships - removals: Dict[str, str] = {} - for removed_id in removed_dor_ids: - removals[removed_id] = f"_{removed_id}_.*_{obj_identifier}" - - # Write updates - self.write_rels_updates(obj_identifier, source_relationships, dest_updates, removals) - - def update_rels_for_removed_object(self, obj_identifier: str, obj: Optional[Any] = None) -> None: - """Update relationships when an object is removed (UPDATE_AT_MODIFICATION mode).""" - if obj is None: - # Object must be provided for removal - logging.warning(f"Cannot update rels for removed object {obj_identifier}: object not provided") - return - - # Get all objects this object references - direct_dors = get_direct_dor_list(obj) - - # Build removal patterns for DESTINATION relationships - removals: Dict[str, str] = {} - for dor in direct_dors: - try: - target_identifier = get_obj_identifier(dor) - if not self.metadata_manager.contains(target_identifier): - continue - - removals[target_identifier] = f"_{target_identifier}_.*_{obj_identifier}" - - except Exception as e: - logging.warning(f"Failed to process DOR for removal: {e}") - - # Write updates - self.write_rels_updates(obj_identifier, [], {}, removals, delete_source_rels=True) - - def write_rels_updates( - self, - source_identifier: str, - destination_relationships: List[Relationship], - source_updates: Dict[str, Relationship], - removals: Optional[Dict[str, str]] = None, - delete_source_rels: bool = False, - ) -> None: - """Write relationship updates to the EPC file efficiently.""" - - removals = removals or {} - rels_updates: Dict[str, str] = {} - files_to_delete: List[str] = [] - - with self.zip_accessor.get_zip_file() as zf: - # 1. Handle source object's rels file - if not delete_source_rels: - dest_rels_path = self.metadata_manager.gen_rels_path_from_identifier(source_identifier) - if dest_rels_path: - # Read existing rels (excluding DESTINATION_OBJECT type) - existing_rels = [] - try: - if dest_rels_path in zf.namelist(): - rels_data = zf.read(dest_rels_path) - existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels_obj and existing_rels_obj.relationship: - # Keep only non-DESTINATION relationships that will be re-generated from the object - existing_rels = [ - r - for r in existing_rels_obj.relationship - if r.type_value != str(EPCRelsRelationshipType.DESTINATION_OBJECT) - ] - except Exception: - pass - - # Combine with new DESTINATION relationships - all_rels = existing_rels + destination_relationships - if all_rels: - rels_updates[dest_rels_path] = serialize_xml(Relationships(relationship=all_rels)) - elif dest_rels_path in zf.namelist() and not all_rels: - files_to_delete.append(dest_rels_path) - else: - # Mark dest rels file for deletion - dest_rels_path = self.metadata_manager.gen_rels_path_from_identifier(source_identifier) - if dest_rels_path: - files_to_delete.append(dest_rels_path) - - # 2. Handle SOURCE updates the objects that refers to the current - for target_identifier, source_rel in source_updates.items(): - target_rels_path = self.metadata_manager.gen_rels_path_from_identifier(target_identifier) - if not target_rels_path: - continue - - # Read existing rels - existing_rels = [] - try: - if target_rels_path in zf.namelist(): - rels_data = zf.read(target_rels_path) - existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels_obj and existing_rels_obj.relationship: - existing_rels = list(existing_rels_obj.relationship) - except Exception: - pass - - # Add new SOURCE relationship if not already present - rel_exists = any( - r.target == source_rel.target and r.type_value == source_rel.type_value for r in existing_rels - ) - - if not rel_exists: - existing_rels.append(source_rel) - rels_updates[target_rels_path] = serialize_xml(Relationships(relationship=existing_rels)) - - # 3. Handle removals - for target_identifier, pattern in removals.items(): - target_rels_path = self.metadata_manager.gen_rels_path_from_identifier(target_identifier) - if not target_rels_path: - continue - - # Read existing rels - existing_rels = [] - try: - if target_rels_path in zf.namelist(): - rels_data = zf.read(target_rels_path) - existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels_obj and existing_rels_obj.relationship: - existing_rels = list(existing_rels_obj.relationship) - except Exception: - pass - - # Filter out relationships matching the pattern - regex = re.compile(pattern) - filtered_rels = [r for r in existing_rels if not (r.id and regex.match(r.id))] - - if len(filtered_rels) != len(existing_rels): - if filtered_rels: - rels_updates[target_rels_path] = serialize_xml(Relationships(relationship=filtered_rels)) - else: - files_to_delete.append(target_rels_path) - - # Write updates to EPC file - if rels_updates or files_to_delete: - with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: - temp_path = temp_file.name - - try: - with self.zip_accessor.get_zip_file() as source_zf: - with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: - # Copy all files except those to delete or update - files_to_skip = set(files_to_delete) - for item in source_zf.infolist(): - if item.filename not in files_to_skip and item.filename not in rels_updates: - data = source_zf.read(item.filename) - target_zf.writestr(item, data) - - # Write updated rels files - for rels_path, rels_xml in rels_updates.items(): - target_zf.writestr(rels_path, rels_xml) - - # Replace original - shutil.move(temp_path, self.zip_accessor.epc_file_path) - self.zip_accessor.reopen_persistent_zip() - - except Exception as e: - if os.path.exists(temp_path): - os.unlink(temp_path) - logging.error(f"Failed to write rels updates: {e}") - raise - - def compute_object_rels(self, obj: Any, obj_identifier: str) -> List[Relationship]: - """ - Compute relationships for a given object (SOURCE relationships). - This object references other objects through DORs. - - Args: - obj: The EnergyML object - obj_identifier: The identifier of the object - - Returns: - List of Relationship objects for this object's .rels file - """ - rels = [] - - # Get all DORs (Data Object References) in this object - direct_dors = get_direct_dor_list(obj) - - for dor in direct_dors: - try: - target_identifier = get_obj_identifier(dor) - - # Get target file path from metadata without processing DOR - # The relationship target should be the object's file path, not its rels path - if self.metadata_manager.contains(target_identifier): - target_metadata = self.metadata_manager.get_metadata(target_identifier) - if target_metadata: - target_path = target_metadata.file_path - else: - target_path = gen_energyml_object_path(dor, self.export_version) - else: - # Fall back to generating path from DOR if metadata not found - target_path = gen_energyml_object_path(dor, self.export_version) - - # Create SOURCE relationship (this object -> target object) - rel = Relationship( - target=target_path, - type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), - id=f"_{obj_identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", - ) - rels.append(rel) - except Exception as e: - logging.warning(f"Failed to create relationship for DOR in {obj_identifier}: {e}") - - return rels - - def merge_rels(self, new_rels: List[Relationship], existing_rels: List[Relationship]) -> List[Relationship]: - """Merge new relationships with existing ones, avoiding duplicates and ensuring unique IDs. - - Args: - new_rels: New relationships to add - existing_rels: Existing relationships - - Returns: - Merged list of relationships - """ - merged = list(existing_rels) - - for new_rel in new_rels: - # Check if relationship already exists - rel_exists = any(r.target == new_rel.target and r.type_value == new_rel.type_value for r in merged) - - if not rel_exists: - # Ensure unique ID - cpt = 0 - new_rel_id = new_rel.id - while any(r.id == new_rel_id for r in merged): - new_rel_id = f"{new_rel.id}_{cpt}" - cpt += 1 - if new_rel_id != new_rel.id: - new_rel.id = new_rel_id - - merged.append(new_rel) - - return merged - - -# =========================================================================================== -# MAIN CLASS (REFACTORED TO USE HELPER CLASSES) -# =========================================================================================== - - -class EpcStreamReader(EnergymlStorageInterface): - """ - Memory-efficient EPC file reader with lazy loading and smart caching. - - This class provides the same interface as the standard Epc class but loads - objects on-demand rather than keeping everything in memory. Perfect for - handling very large EPC files with thousands of objects. - - Features: - - Lazy loading: Objects loaded only when accessed - - Smart caching: LRU cache with configurable size - - Memory monitoring: Track memory usage and cache efficiency - - Streaming validation: Validate objects without full loading - - Batch operations: Efficient bulk operations - - Context management: Automatic resource cleanup - - Flexible relationship management: Three modes for updating object relationships - - Relationship Update Modes: - - UPDATE_AT_MODIFICATION: Maintains relationships in real-time as objects are added/removed/modified. - Best for maintaining consistency but may be slower for bulk operations. - - UPDATE_ON_CLOSE: Rebuilds all relationships when closing the EPC file (default). - More efficient for bulk operations but relationships only consistent after closing. - - MANUAL: No automatic relationship updates. User must manually call rebuild_all_rels(). - Maximum control and performance for advanced use cases. - - Performance optimizations: - - Pre-compiled regex patterns for 15-75% faster parsing - - Weak references to prevent memory leaks - - Compressed metadata storage - - Efficient ZIP file handling - """ - - def __init__( - self, - epc_file_path: Union[str, Path], - cache_size: int = 100, - validate_on_load: bool = True, - preload_metadata: bool = True, - export_version: EpcExportVersion = EpcExportVersion.CLASSIC, - force_h5_path: Optional[str] = None, - keep_open: bool = False, - force_title_load: bool = False, - rels_update_mode: RelsUpdateMode = RelsUpdateMode.UPDATE_ON_CLOSE, - enable_parallel_rels: bool = False, - parallel_worker_ratio: int = 10, - auto_repair_structure: bool = True, - ): - """ - Initialize the EPC stream reader. - - Args: - epc_file_path: Path to the EPC file - cache_size: Maximum number of objects to keep in memory cache - validate_on_load: Whether to validate objects when loading - preload_metadata: Whether to preload all object metadata - export_version: EPC packaging version (CLASSIC or EXPANDED) - force_h5_path: Optional forced HDF5 file path for external resources. If set, all arrays will be read/written from/to this path. - keep_open: If True, keeps the ZIP file open for better performance with multiple operations. File is closed only when instance is deleted or close() is called. - force_title_load: If True, forces loading object titles when listing objects (may impact performance) - rels_update_mode: Mode for updating relationships (UPDATE_AT_MODIFICATION, UPDATE_ON_CLOSE, or MANUAL) - enable_parallel_rels: If True, uses parallel processing for rebuild_all_rels() operations (faster for large EPCs) - parallel_worker_ratio: Number of objects per worker process (default: 10). Lower values = more workers. Only used when enable_parallel_rels=True. - auto_repair_structure: If True, automatically validates and repairs EPC structure on load (default: True) - """ - # Public attributes - self.epc_file_path = Path(epc_file_path) - self.auto_repair_structure = auto_repair_structure - self.enable_parallel_rels = enable_parallel_rels - self.parallel_worker_ratio = parallel_worker_ratio - self.cache_size = cache_size - self.validate_on_load = validate_on_load - self.force_h5_path = force_h5_path - self.cache_opened_h5 = None - self.keep_open = keep_open - self.force_title_load = force_title_load - self.rels_update_mode = rels_update_mode - self.export_version: EpcExportVersion = export_version or EpcExportVersion.CLASSIC - self.stats = EpcStreamingStats() - - # Caching system using weak references - self._object_cache: WeakValueDictionary = WeakValueDictionary() - self._access_order: List[str] = [] # LRU tracking - - is_new_file = False - - # Validate file exists and is readable - if not self.epc_file_path.exists(): - logging.info(f"EPC file not found: {epc_file_path}. Creating a new empty EPC file.") - self._create_empty_epc() - is_new_file = True - - if not zipfile.is_zipfile(self.epc_file_path): - raise ValueError(f"File is not a valid ZIP/EPC file: {epc_file_path}") - - # Check if the ZIP file has the required EPC structure - if not is_new_file: - try: - with zipfile.ZipFile(self.epc_file_path, "r") as zf: - content_types_path = get_epc_content_type_path() - if content_types_path not in zf.namelist(): - logging.info("EPC file is missing required structure. Initializing empty EPC file.") - self._create_empty_epc() - is_new_file = True - except Exception as e: - logging.warning(f"Failed to check EPC structure: {e}. Reinitializing.") - - # Initialize helper classes (internal architecture) - self._zip_accessor = _ZipFileAccessor(self.epc_file_path, keep_open=keep_open) - self._metadata_mgr = _MetadataManager(self._zip_accessor, self.stats) - self._metadata_mgr.set_export_version(self.export_version) - self._rels_mgr = _RelationshipManager( - self._zip_accessor, self._metadata_mgr, self.stats, self.export_version, rels_update_mode - ) - self._structure_validator = _StructureValidator(self._zip_accessor, self._metadata_mgr, self.export_version) - - # Validate and repair structure (idempotent) - if auto_repair_structure: - validation_results = self._structure_validator.validate_and_repair(is_new_file=is_new_file) - if validation_results["repaired"]: - logging.info("[EPC Stream] EPC structure has been validated and repaired") - elif is_new_file: - # Even without auto-repair, we need to create minimal structure for new files - self._structure_validator.validate_and_repair(is_new_file=True) - - # Initialize by loading metadata - if not is_new_file and preload_metadata: - self._metadata_mgr.load_metadata() - # Detect EPC version after loading metadata - self.export_version = self._metadata_mgr.detect_epc_version() - # Update relationship manager's export version - self._rels_mgr.export_version = self.export_version - - # Open persistent ZIP connection if keep_open is enabled - if keep_open and not is_new_file: - self._zip_accessor.open_persistent_connection() - - # Backward compatibility: expose internal structures as properties - # This allows existing code to access _metadata, _uuid_index, etc. - - @property - def _metadata(self) -> Dict[str, EpcObjectMetadata]: - """Backward compatibility property for accessing metadata.""" - return self._metadata_mgr._metadata - - @property - def _uuid_index(self) -> Dict[str, List[str]]: - """Backward compatibility property for accessing UUID index.""" - return self._metadata_mgr._uuid_index - - @property - def _type_index(self) -> Dict[str, List[str]]: - """Backward compatibility property for accessing type index.""" - return self._metadata_mgr._type_index - - @property - def _core_props(self) -> Optional[CoreProperties]: - """Backward compatibility property for accessing Core Properties.""" - return self._metadata_mgr._core_props - - @property - def additional_rels(self) -> Dict[str, List[Relationship]]: - """Backward compatibility property for accessing additional relationships.""" - return self._rels_mgr.additional_rels - - def _create_empty_epc(self) -> None: - """ - Create an empty EPC file structure. - - Note: This method is deprecated in favor of _StructureValidator.validate_and_repair(). - It's kept for backward compatibility but now delegates to the structure validator. - """ - # Ensure directory exists - self.epc_file_path.parent.mkdir(parents=True, exist_ok=True) - - # Create empty ZIP to allow structure validator to work - with zipfile.ZipFile(self.epc_file_path, "w") as zf: - pass # Create empty ZIP - - def _load_metadata(self) -> None: - """Load object metadata from [Content_Types].xml without loading actual objects.""" - # Delegate to metadata manager - self._metadata_mgr.load_metadata() - - def _read_content_types(self, zf: zipfile.ZipFile) -> Types: - """Read and parse [Content_Types].xml file.""" - # Delegate to metadata manager - return self._metadata_mgr._read_content_types(zf) - - def _process_energyml_object_metadata(self, zf: zipfile.ZipFile, override: Override) -> None: - """Process metadata for an EnergyML object without loading it.""" - # Delegate to metadata manager - self._metadata_mgr._process_energyml_object_metadata(zf, override) - - def _extract_object_info_fast( - self, zf: zipfile.ZipFile, file_path: str, content_type: str - ) -> Tuple[Optional[str], Optional[str], str]: - """Fast extraction of UUID and version from XML without full parsing.""" - # Delegate to metadata manager - return self._metadata_mgr._extract_object_info_fast(zf, file_path, content_type) - - def _is_core_properties(self, content_type: str) -> bool: - """Check if content type is CoreProperties.""" - # Delegate to metadata manager - return self._metadata_mgr._is_core_properties(content_type) - - def _process_core_properties_metadata(self, override: Override) -> None: - """Process core properties metadata.""" - # Delegate to metadata manager - self._metadata_mgr._process_core_properties_metadata(override) - - def _detect_epc_version(self) -> EpcExportVersion: - """Detect EPC packaging version based on file structure.""" - # Delegate to metadata manager - return self._metadata_mgr.detect_epc_version() - - def _gen_rels_path_from_metadata(self, metadata: EpcObjectMetadata) -> str: - """Generate rels path from object metadata without loading the object.""" - # Delegate to metadata manager - return self._metadata_mgr.gen_rels_path_from_metadata(metadata) - - def _gen_rels_path_from_identifier(self, identifier: str) -> Optional[str]: - """Generate rels path from object identifier without loading the object.""" - # Delegate to metadata manager - return self._metadata_mgr.gen_rels_path_from_identifier(identifier) - - @contextmanager - def _get_zip_file(self) -> Iterator[zipfile.ZipFile]: - """Context manager for ZIP file access with proper resource management. - - If keep_open is True, uses the persistent connection. Otherwise opens a new one. - """ - # Delegate to the ZIP accessor helper class - with self._zip_accessor.get_zip_file() as zf: - yield zf - - def get_object_by_identifier(self, identifier: Union[str, Uri]) -> Optional[Any]: - """ - Get object by its identifier with smart caching. - - Args: - identifier: Object identifier (uuid.version) - - Returns: - The requested object or None if not found - """ - is_uri = isinstance(identifier, Uri) or parse_uri(identifier) is not None - if is_uri: - uri = parse_uri(identifier) if isinstance(identifier, str) else identifier - assert uri is not None and uri.uuid is not None - identifier = uri.uuid + "." + (uri.version or "") - - # Check cache first - if identifier in self._object_cache: - self._update_access_order(identifier) # type: ignore - self.stats.cache_hits += 1 - return self._object_cache[identifier] - - self.stats.cache_misses += 1 - - # Check if metadata exists - if identifier not in self._metadata: - return None - - # Load object from file - obj = self._load_object(identifier) - - if obj is not None: - # Add to cache with LRU management - self._add_to_cache(identifier, obj) - self.stats.loaded_objects += 1 - - return obj - - def _load_object(self, identifier: Union[str, Uri]) -> Optional[Any]: - """Load object from EPC file.""" - is_uri = isinstance(identifier, Uri) or parse_uri(identifier) is not None - if is_uri: - uri = parse_uri(identifier) if isinstance(identifier, str) else identifier - assert uri is not None and uri.uuid is not None - identifier = uri.uuid + "." + (uri.version or "") - assert isinstance(identifier, str) - metadata = self._metadata.get(identifier) - if not metadata: - return None - - try: - with self._get_zip_file() as zf: - obj_data = zf.read(metadata.file_path) - self.stats.bytes_read += len(obj_data) - - obj_class = get_class_from_content_type(metadata.content_type) - obj = read_energyml_xml_bytes(obj_data, obj_class) - - if self.validate_on_load: - self._validate_object(obj, metadata) - - return obj - - except Exception as e: - logging.error(f"Failed to load object {identifier}: {e}") - return None - - def _validate_object(self, obj: Any, metadata: EpcObjectMetadata) -> None: - """Validate loaded object against metadata.""" - try: - obj_uuid = get_obj_uuid(obj) - if obj_uuid != metadata.uuid: - logging.warning(f"UUID mismatch for {metadata.identifier}: expected {metadata.uuid}, got {obj_uuid}") - except Exception as e: - logging.debug(f"Validation failed for {metadata.identifier}: {e}") - - def _add_to_cache(self, identifier: Union[str, Uri], obj: Any) -> None: - """Add object to cache with LRU eviction.""" - is_uri = isinstance(identifier, Uri) or parse_uri(identifier) is not None - if is_uri: - uri = parse_uri(identifier) if isinstance(identifier, str) else identifier - assert uri is not None and uri.uuid is not None - identifier = uri.uuid + "." + (uri.version or "") - - assert isinstance(identifier, str) - - # Remove from access order if already present - if identifier in self._access_order: - self._access_order.remove(identifier) - - # Add to front (most recently used) - self._access_order.insert(0, identifier) - - # Add to cache - self._object_cache[identifier] = obj - - # Evict if cache is full - while len(self._access_order) > self.cache_size: - oldest = self._access_order.pop() - self._object_cache.pop(oldest, None) - - def _update_access_order(self, identifier: str) -> None: - """Update access order for LRU cache.""" - if identifier in self._access_order: - self._access_order.remove(identifier) - self._access_order.insert(0, identifier) - - def get_object_by_uuid(self, uuid: str) -> List[Any]: - """Get all objects with the specified UUID.""" - if uuid not in self._uuid_index: - return [] - - objects = [] - for identifier in self._uuid_index[uuid]: - obj = self.get_object_by_identifier(identifier) - if obj is not None: - objects.append(obj) - - return objects - - def get_object(self, identifier: Union[str, Uri]) -> Optional[Any]: - return self.get_object_by_identifier(identifier) - - def get_objects_by_type(self, object_type: str) -> List[Any]: - """Get all objects of the specified type.""" - if object_type not in self._type_index: - return [] - - objects = [] - for identifier in self._type_index[object_type]: - obj = self.get_object_by_identifier(identifier) - if obj is not None: - objects.append(obj) - - return objects - - def list_object_metadata(self, object_type: Optional[str] = None) -> List[EpcObjectMetadata]: - """ - List metadata for objects without loading them. - - Args: - object_type: Optional filter by object type - - Returns: - List of object metadata - """ - if object_type is None: - return list(self._metadata.values()) - - return [self._metadata[identifier] for identifier in self._type_index.get(object_type, [])] - - def get_statistics(self) -> EpcStreamingStats: - """Get current streaming statistics.""" - return self.stats - - def list_objects( - self, dataspace: Optional[str] = None, object_type: Optional[str] = None - ) -> List[ResourceMetadata]: - """ - List all objects with metadata (EnergymlStorageInterface method). - - Args: - dataspace: Optional dataspace filter (ignored for EPC files) - object_type: Optional type filter (qualified type) - - Returns: - List of ResourceMetadata for all matching objects - """ - - results = [] - metadata_list = self.list_object_metadata(object_type) - - for meta in metadata_list: - try: - # Load object to get title - title = "" - if self.force_title_load and meta.identifier: - obj = self.get_object_by_identifier(meta.identifier) - if obj and hasattr(obj, "citation") and obj.citation: - if hasattr(obj.citation, "title"): - title = obj.citation.title - - # Build URI - qualified_type = content_type_to_qualified_type(meta.content_type) - if meta.version: - uri = f"eml:///{qualified_type}(uuid={meta.uuid},version='{meta.version}')" - else: - uri = f"eml:///{qualified_type}({meta.uuid})" - - resource = ResourceMetadata( - uri=uri, - uuid=meta.uuid, - version=meta.version, - title=title, - object_type=meta.object_type, - content_type=meta.content_type, - ) - - results.append(resource) - except Exception: - continue - - return results - - def get_array_metadata( - self, proxy: Union[str, Uri, Any], path_in_external: Optional[str] = None - ) -> Union[DataArrayMetadata, List[DataArrayMetadata], None]: - """ - Get metadata for data array(s) (EnergymlStorageInterface method). - - Args: - proxy: The object identifier/URI or the object itself - path_in_external: Optional specific path - - Returns: - DataArrayMetadata if path specified, List[DataArrayMetadata] if no path, - or None if not found - """ - from energyml.utils.storage_interface import DataArrayMetadata - - try: - if path_in_external: - array = self.read_array(proxy, path_in_external) - if array is not None: - return DataArrayMetadata( - path_in_resource=path_in_external, - array_type=str(array.dtype), - dimensions=list(array.shape), - ) - else: - # Would need to scan all possible paths - not practical - return [] - except Exception: - pass - - return None - - def preload_objects(self, identifiers: List[str]) -> int: - """ - Preload specific objects into cache. - - Args: - identifiers: List of object identifiers to preload - - Returns: - Number of objects successfully loaded - """ - loaded_count = 0 - for identifier in identifiers: - if self.get_object_by_identifier(identifier) is not None: - loaded_count += 1 - return loaded_count - - def clear_cache(self) -> None: - """Clear the object cache to free memory.""" - self._object_cache.clear() - self._access_order.clear() - self.stats.loaded_objects = 0 - - def get_core_properties(self) -> Optional[CoreProperties]: - """Get core properties (loaded lazily).""" - # Delegate to metadata manager - return self._metadata_mgr.get_core_properties() - - def _update_rels_for_new_object(self, obj: Any, obj_identifier: str) -> None: - """Update relationships when a new object is added (UPDATE_AT_MODIFICATION mode).""" - # Delegate to relationship manager - self._rels_mgr.update_rels_for_new_object(obj, obj_identifier) - - def _update_rels_for_modified_object(self, obj: Any, obj_identifier: str, old_dors: List[Any]) -> None: - """Update relationships when an object is modified (UPDATE_AT_MODIFICATION mode).""" - # Delegate to relationship manager - self._rels_mgr.update_rels_for_modified_object(obj, obj_identifier, old_dors) - - def _update_rels_for_removed_object(self, obj_identifier: str, obj: Optional[Any] = None) -> None: - """Update relationships when an object is removed (UPDATE_AT_MODIFICATION mode).""" - # Delegate to relationship manager - self._rels_mgr.update_rels_for_removed_object(obj_identifier, obj) - - def _write_rels_updates( - self, - source_identifier: str, - source_relationships: List[Relationship], - dest_updates: Dict[str, Relationship], - removals: Optional[Dict[str, str]] = None, - delete_source_rels: bool = False, - ) -> None: - """Write relationship updates to the EPC file efficiently.""" - # Delegate to relationship manager - self._rels_mgr.write_rels_updates( - source_identifier, source_relationships, dest_updates, removals, delete_source_rels - ) - - def _reopen_persistent_zip(self) -> None: - """Reopen persistent ZIP file after modifications to reflect changes.""" - # Delegate to ZIP accessor - self._zip_accessor.reopen_persistent_zip() - - def set_rels_update_mode(self, mode: RelsUpdateMode) -> None: - """ - Change the relationship update mode. - - Args: - mode: The new RelsUpdateMode to use - - Note: - Changing from MANUAL or UPDATE_ON_CLOSE to UPDATE_AT_MODIFICATION - may require calling rebuild_all_rels() first to ensure consistency. - """ - if not isinstance(mode, RelsUpdateMode): - raise ValueError(f"mode must be a RelsUpdateMode enum value, got {type(mode)}") - - old_mode = self.rels_update_mode - self.rels_update_mode = mode - # Also update the relationship manager - self._rels_mgr.rels_update_mode = mode - - logging.info(f"Changed relationship update mode from {old_mode.value} to {mode.value}") - - def get_rels_update_mode(self) -> RelsUpdateMode: - """ - Get the current relationship update mode. - - Returns: - The current RelsUpdateMode - """ - return self.rels_update_mode - - def get_obj_rels(self, obj: Union[str, Uri, Any]) -> List[Relationship]: - """ - Get all relationships for a given object. - Merges relationships from the EPC file with in-memory additional relationships. - - Optimized to avoid loading the object when identifier/URI is provided. - - :param obj: the object or its identifier/URI - :return: list of Relationship objects - """ - # Get identifier without loading the object - obj_identifier = None - rels_path = None - - if isinstance(obj, (str, Uri)): - # Convert URI to identifier if needed - if isinstance(obj, Uri) or parse_uri(obj) is not None: - uri = parse_uri(obj) if isinstance(obj, str) else obj - assert uri is not None and uri.uuid is not None - obj_identifier = uri.uuid + "." + (uri.version or "") - else: - obj_identifier = obj - - # Generate rels path from metadata without loading the object - rels_path = self._gen_rels_path_from_identifier(obj_identifier) - else: - # We have the actual object - obj_identifier = get_obj_identifier(obj) - rels_path = gen_rels_path(obj, self.export_version) - - # Delegate to relationship manager - return self._rels_mgr.get_obj_rels(obj_identifier, rels_path) - - def get_h5_file_paths(self, obj: Union[str, Uri, Any]) -> List[str]: - """ - Get all HDF5 file paths referenced in the EPC file (from rels to external resources). - Optimized to avoid loading the object when identifier/URI is provided. - - :param obj: the object or its identifier/URI - :return: list of HDF5 file paths - """ - if self.force_h5_path is not None: - return [self.force_h5_path] - h5_paths = set() - - obj_identifier = None - rels_path = None - - # Get identifier and rels path without loading the object - if isinstance(obj, (str, Uri)): - # Convert URI to identifier if needed - if isinstance(obj, Uri) or parse_uri(obj) is not None: - uri = parse_uri(obj) if isinstance(obj, str) else obj - assert uri is not None and uri.uuid is not None - obj_identifier = uri.uuid + "." + (uri.version or "") - else: - obj_identifier = obj - - # Generate rels path from metadata without loading the object - rels_path = self._gen_rels_path_from_identifier(obj_identifier) - else: - # We have the actual object - obj_identifier = get_obj_identifier(obj) - rels_path = gen_rels_path(obj, self.export_version) - - # Check in-memory additional rels first - for rels in self.additional_rels.get(obj_identifier, []): - if rels.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(): - h5_paths.add(rels.target) - - # Also check rels from the EPC file - if rels_path is not None: - with self._get_zip_file() as zf: - try: - rels_data = zf.read(rels_path) - self.stats.bytes_read += len(rels_data) - relationships = read_energyml_xml_bytes(rels_data, Relationships) - for rel in relationships.relationship: - if rel.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(): - h5_paths.add(rel.target) - except KeyError: - pass - - if len(h5_paths) == 0: - # search if an h5 file has the same name than the epc file - epc_folder = os.path.dirname(self.epc_file_path) - if epc_folder is not None and self.epc_file_path is not None: - epc_file_name = os.path.basename(self.epc_file_path) - epc_file_base, _ = os.path.splitext(epc_file_name) - possible_h5_path = os.path.join(epc_folder, epc_file_base + ".h5") - if os.path.exists(possible_h5_path): - h5_paths.add(possible_h5_path) - return list(h5_paths) - - def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Optional[np.ndarray]: - """ - Read a dataset from the HDF5 file linked to the proxy object. - :param proxy: the object or its identifier - :param path_in_external: the path in the external HDF5 file - :return: the dataset as a numpy array - """ - # Resolve proxy to object - - h5_path = [] - if self.force_h5_path is not None: - if self.cache_opened_h5 is None: - self.cache_opened_h5 = h5py.File(self.force_h5_path, "a") - h5_path = [self.cache_opened_h5] - else: - if isinstance(proxy, (str, Uri)): - obj = self.get_object_by_identifier(proxy) - else: - obj = proxy - - h5_path = self.get_h5_file_paths(obj) - - h5_reader = HDF5FileReader() - - if h5_path is None or len(h5_path) == 0: - raise ValueError("No HDF5 file paths found for the given proxy object.") - else: - for h5p in h5_path: - # TODO: handle different type of files - try: - return h5_reader.read_array(source=h5p, path_in_external_file=path_in_external) - except Exception: - pass - # logging.error(f"Failed to read HDF5 dataset from {h5p}: {e}") - - def write_array(self, proxy: Union[str, Uri, Any], path_in_external: str, array: np.ndarray) -> bool: - """ - Write a dataset to the HDF5 file linked to the proxy object. - :param proxy: the object or its identifier - :param path_in_external: the path in the external HDF5 file - :param array: the numpy array to write - - return: True if successful - """ - h5_path = [] - if self.force_h5_path is not None: - if self.cache_opened_h5 is None: - self.cache_opened_h5 = h5py.File(self.force_h5_path, "a") - h5_path = [self.cache_opened_h5] - else: - if isinstance(proxy, (str, Uri)): - obj = self.get_object_by_identifier(proxy) - else: - obj = proxy - - h5_path = self.get_h5_file_paths(obj) - - h5_writer = HDF5FileWriter() - - if h5_path is None or len(h5_path) == 0: - raise ValueError("No HDF5 file paths found for the given proxy object.") - else: - for h5p in h5_path: - try: - h5_writer.write_array(target=h5p, path_in_external_file=path_in_external, array=array) - return True - except Exception as e: - logging.error(f"Failed to write HDF5 dataset to {h5p}: {e}") - return False - - def validate_all_objects(self, fast_mode: bool = True) -> Dict[str, List[str]]: - """ - Validate all objects in the EPC file. - - Args: - fast_mode: If True, only validate metadata without loading full objects - - Returns: - Dictionary with 'errors' and 'warnings' keys containing lists of issues - """ - results = {"errors": [], "warnings": []} - - for identifier, metadata in self._metadata.items(): - try: - if fast_mode: - # Quick validation - just check file exists and is readable - with self._get_zip_file() as zf: - try: - zf.getinfo(metadata.file_path) - except KeyError: - results["errors"].append(f"Missing file for object {identifier}: {metadata.file_path}") - else: - # Full validation - load and validate object - obj = self.get_object_by_identifier(identifier) - if obj is None: - results["errors"].append(f"Failed to load object {identifier}") - else: - self._validate_object(obj, metadata) - - except Exception as e: - results["errors"].append(f"Validation error for {identifier}: {e}") - - return results - - def get_object_dependencies(self, identifier: Union[str, Uri]) -> List[str]: - """ - Get list of object identifiers that this object depends on. - - This would need to be implemented based on DOR analysis. - """ - # Placeholder for dependency analysis - # Would need to parse DORs in the object - return [] - - def __len__(self) -> int: - """Return total number of objects in EPC.""" - return len(self._metadata) - - def __contains__(self, identifier: str) -> bool: - """Check if object with identifier exists.""" - return identifier in self._metadata - - def __iter__(self) -> Iterator[str]: - """Iterate over object identifiers.""" - return iter(self._metadata.keys()) - - def __enter__(self): - """Context manager entry.""" - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit with cleanup.""" - self.clear_cache() - self.close() - if self.cache_opened_h5 is not None: - try: - self.cache_opened_h5.close() - except Exception: - pass - self.cache_opened_h5 = None - - def __del__(self): - """Destructor to ensure persistent ZIP file is closed.""" - try: - self.close() - if self.cache_opened_h5 is not None: - try: - self.cache_opened_h5.close() - except Exception: - pass - self.cache_opened_h5 = None - except Exception: - pass # Ignore errors during cleanup - - def close(self) -> None: - """Close the persistent ZIP file if it's open, recomputing rels first if mode is UPDATE_ON_CLOSE.""" - # Recompute all relationships before closing if in UPDATE_ON_CLOSE mode - if self.rels_update_mode == RelsUpdateMode.UPDATE_ON_CLOSE: - try: - self.rebuild_all_rels(clean_first=True) - logging.info("Rebuilt all relationships on close (UPDATE_ON_CLOSE mode)") - except Exception as e: - logging.warning(f"Error rebuilding rels on close: {e}") - - # Delegate to ZIP accessor - self._zip_accessor.close() - - def put_object(self, obj: Any, dataspace: Optional[str] = None) -> Optional[str]: - """ - Store an energyml object (EnergymlStorageInterface method). - - Args: - obj: The energyml object to store - dataspace: Optional dataspace name (ignored for EPC files) - - Returns: - The identifier of the stored object (UUID.version or UUID), or None on error - """ - try: - return self.add_object(obj, replace_if_exists=True) - except Exception: - return None - - def add_object(self, obj: Any, file_path: Optional[str] = None, replace_if_exists: bool = True) -> str: - """ - Add a new object to the EPC file and update caches. - - Args: - obj: The EnergyML object to add - file_path: Optional custom file path, auto-generated if not provided - replace_if_exists: If True, replace the object if it already exists. If False, raise ValueError. - - Returns: - The identifier of the added object - - Raises: - ValueError: If object is invalid or already exists (when replace_if_exists=False) - RuntimeError: If file operations fail - """ - identifier = None - metadata = None - - try: - # Extract object information - identifier = get_obj_identifier(obj) - uuid = identifier.split(".")[0] if identifier else None - - if not uuid: - raise ValueError("Object must have a valid UUID") - - version = identifier[len(uuid) + 1 :] if identifier and "." in identifier else None - # Ensure version is treated as a string, not an integer - if version is not None and not isinstance(version, str): - version = str(version) - - object_type = get_object_type_for_file_path_from_class(obj) - - if identifier in self._metadata: - if replace_if_exists: - # Remove the existing object first - logging.info(f"Replacing existing object {identifier}") - self.remove_object(identifier) - else: - raise ValueError( - f"Object with identifier {identifier} already exists. Use update_object() or set replace_if_exists=True." - ) - - # Generate file path if not provided - file_path = gen_energyml_object_path(obj, self.export_version) - - print(f"Generated file path: {file_path} for export version: {self.export_version}") - - # Determine content type based on object type - content_type = get_obj_content_type(obj) - - # Create metadata - metadata = EpcObjectMetadata( - uuid=uuid, - object_type=object_type, - content_type=content_type, - file_path=file_path, - version=version, - identifier=identifier, - ) - - # Update internal structures - self._metadata[identifier] = metadata - - # Update UUID index - if uuid not in self._uuid_index: - self._uuid_index[uuid] = [] - self._uuid_index[uuid].append(identifier) - - # Update type index - if object_type not in self._type_index: - self._type_index[object_type] = [] - self._type_index[object_type].append(identifier) - - # Add to cache - self._add_to_cache(identifier, obj) - - # Save changes to file - self._add_object_to_file(obj, metadata) - - # Update relationships if in UPDATE_AT_MODIFICATION mode - if self.rels_update_mode == RelsUpdateMode.UPDATE_AT_MODIFICATION: - self._update_rels_for_new_object(obj, identifier) - - # Update stats - self.stats.total_objects += 1 - - logging.info(f"Added object {identifier} to EPC file") - return identifier - - except Exception as e: - logging.error(f"Failed to add object: {e}") - # Rollback changes if we created metadata - if identifier and metadata: - self._rollback_add_object(identifier) - raise RuntimeError(f"Failed to add object to EPC: {e}") - - def delete_object(self, identifier: Union[str, Uri]) -> bool: - """ - Delete an object by its identifier (EnergymlStorageInterface method). - - Args: - identifier: Object identifier (UUID or UUID.version) or ETP URI - - Returns: - True if successfully deleted, False otherwise - """ - return self.remove_object(identifier) - - def remove_object(self, identifier: Union[str, Uri]) -> bool: - """ - Remove an object (or all versions of an object) from the EPC file and update caches. - - Args: - identifier: The identifier of the object to remove. Can be either: - - Full identifier (uuid.version) to remove a specific version - - UUID only to remove ALL versions of that object - - Returns: - True if object(s) were successfully removed, False if not found - - Raises: - RuntimeError: If file operations fail - """ - try: - is_uri = isinstance(identifier, Uri) or parse_uri(identifier) is not None - if is_uri: - uri = parse_uri(identifier) if isinstance(identifier, str) else identifier - assert uri is not None and uri.uuid is not None - identifier = uri.uuid + "." + (uri.version or "") - assert isinstance(identifier, str) - - if identifier not in self._metadata: - # Check if identifier is a UUID only (should remove all versions) - if identifier in self._uuid_index: - # Remove all versions for this UUID - identifiers_to_remove = self._uuid_index[identifier].copy() - removed_count = 0 - - for id_to_remove in identifiers_to_remove: - if self._remove_single_object(id_to_remove): - removed_count += 1 - - return removed_count > 0 - else: - return False - - # Single identifier removal - return self._remove_single_object(identifier) - - except Exception as e: - logging.error(f"Failed to remove object {identifier}: {e}") - raise RuntimeError(f"Failed to remove object from EPC: {e}") - - def _remove_single_object(self, identifier: str) -> bool: - """ - Remove a single object by its full identifier. - - Args: - identifier: The full identifier (uuid.version) of the object to remove - Returns: - True if the object was successfully removed, False otherwise - """ - try: - if identifier not in self._metadata: - return False - - metadata = self._metadata[identifier] - - # If in UPDATE_AT_MODIFICATION mode, update rels before removing - obj = None - if self.rels_update_mode == RelsUpdateMode.UPDATE_AT_MODIFICATION: - obj = self.get_object_by_identifier(identifier) - if obj: - self._update_rels_for_removed_object(identifier, obj) - - # IMPORTANT: Remove from file FIRST (before clearing cache/metadata) - # because _remove_object_from_file needs to load the object to access its DORs - self._remove_object_from_file(metadata) - - # Now remove from cache - if identifier in self._object_cache: - del self._object_cache[identifier] - - if identifier in self._access_order: - self._access_order.remove(identifier) - - # Remove from indexes - uuid = metadata.uuid - object_type = metadata.object_type - - if uuid in self._uuid_index: - if identifier in self._uuid_index[uuid]: - self._uuid_index[uuid].remove(identifier) - if not self._uuid_index[uuid]: - del self._uuid_index[uuid] - - if object_type in self._type_index: - if identifier in self._type_index[object_type]: - self._type_index[object_type].remove(identifier) - if not self._type_index[object_type]: - del self._type_index[object_type] - - # Remove from metadata (do this last) - del self._metadata[identifier] - - # Update stats - self.stats.total_objects -= 1 - if self.stats.loaded_objects > 0: - self.stats.loaded_objects -= 1 - - logging.info(f"Removed object {identifier} from EPC file") - return True - - except Exception as e: - logging.error(f"Failed to remove single object {identifier}: {e}") - return False - - def update_object(self, obj: Any) -> str: - """ - Update an existing object in the EPC file. - - Args: - obj: The EnergyML object to update - Returns: - The identifier of the updated object - """ - identifier = get_obj_identifier(obj) - if not identifier or identifier not in self._metadata: - raise ValueError("Object must have a valid identifier and exist in the EPC file") - - try: - # If in UPDATE_AT_MODIFICATION mode, get old DORs and handle update differently - if self.rels_update_mode == RelsUpdateMode.UPDATE_AT_MODIFICATION: - old_obj = self.get_object_by_identifier(identifier) - old_dors = get_direct_dor_list(old_obj) if old_obj else [] - - # Preserve non-SOURCE/DESTINATION relationships (like EXTERNAL_RESOURCE) before removal - preserved_rels = [] - try: - obj_rels = self.get_obj_rels(identifier) - preserved_rels = [ - r - for r in obj_rels - if r.type_value - not in ( - EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), - EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), - ) - ] - except Exception: - pass - - # Remove existing object (without rels update since we're replacing it) - # Temporarily switch to MANUAL mode to avoid double updates - original_mode = self.rels_update_mode - self.rels_update_mode = RelsUpdateMode.MANUAL - self.remove_object(identifier) - self.rels_update_mode = original_mode - - # Add updated object (without rels update since we'll do custom update) - self.rels_update_mode = RelsUpdateMode.MANUAL - new_identifier = self.add_object(obj) - self.rels_update_mode = original_mode - - # Now do the specialized update that handles both adds and removes - self._update_rels_for_modified_object(obj, new_identifier, old_dors) - - # Restore preserved relationships (like EXTERNAL_RESOURCE) - if preserved_rels: - # These need to be written directly to the rels file - # since _update_rels_for_modified_object already wrote it - rels_path = self._gen_rels_path_from_identifier(new_identifier) - if rels_path: - with self._get_zip_file() as zf: - # Read current rels - current_rels = [] - try: - if rels_path in zf.namelist(): - rels_data = zf.read(rels_path) - rels_obj = read_energyml_xml_bytes(rels_data, Relationships) - if rels_obj and rels_obj.relationship: - current_rels = list(rels_obj.relationship) - except Exception: - pass - - # Add preserved rels - all_rels = current_rels + preserved_rels - - # Write back - with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: - temp_path = temp_file.name - - try: - with self._get_zip_file() as source_zf: - with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: - # Copy all files except the rels file we're updating - for item in source_zf.infolist(): - if item.filename != rels_path: - buffer = source_zf.read(item.filename) - target_zf.writestr(item, buffer) - - # Write updated rels file - target_zf.writestr( - rels_path, serialize_xml(Relationships(relationship=all_rels)) - ) - - # Replace original - shutil.move(temp_path, self.epc_file_path) - self._reopen_persistent_zip() - - except Exception: - if os.path.exists(temp_path): - os.unlink(temp_path) - raise - - else: - # For other modes (UPDATE_ON_CLOSE, MANUAL), preserve non-SOURCE/DESTINATION relationships - preserved_rels = [] - try: - obj_rels = self.get_obj_rels(identifier) - preserved_rels = [ - r - for r in obj_rels - if r.type_value - not in ( - EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), - EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), - ) - ] - except Exception: - pass - - # Simple remove + add - self.remove_object(identifier) - new_identifier = self.add_object(obj) - - # Restore preserved relationships if any - if preserved_rels: - self.add_rels_for_object(new_identifier, preserved_rels, write_immediately=True) - - logging.info(f"Updated object {identifier} to {new_identifier} in EPC file") - return new_identifier - - except Exception as e: - logging.error(f"Failed to update object {identifier}: {e}") - raise RuntimeError(f"Failed to update object in EPC: {e}") - - def add_rels_for_object( - self, identifier: Union[str, Uri, Any], relationships: List[Relationship], write_immediately: bool = False - ) -> None: - """ - Add additional relationships for a specific object. - - Relationships are stored in memory and can be written immediately or deferred - until write_pending_rels() is called, or when the EPC is closed. - - Args: - identifier: The identifier of the object, can be str, Uri, or the object itself - relationships: List of Relationship objects to add - write_immediately: If True, writes pending rels to disk immediately after adding. - If False (default), rels are kept in memory for batching. - """ - is_uri = isinstance(identifier, Uri) or (isinstance(identifier, str) and parse_uri(identifier) is not None) - if is_uri: - uri = parse_uri(identifier) if isinstance(identifier, str) else identifier - assert uri is not None and uri.uuid is not None - identifier = uri.uuid + "." + (uri.version or "") - elif not isinstance(identifier, str): - identifier = get_obj_identifier(identifier) - - assert isinstance(identifier, str) - - if identifier not in self.additional_rels: - self.additional_rels[identifier] = [] - - self.additional_rels[identifier].extend(relationships) - logging.debug(f"Added {len(relationships)} relationships for object {identifier} (in-memory)") - - if write_immediately: - self.write_pending_rels() - - def write_pending_rels(self) -> int: - """ - Write all pending in-memory relationships to the EPC file efficiently. - - This method reads existing rels, merges them in memory with pending rels, - then rewrites only the affected rels files in a single ZIP update. - - Returns: - Number of rels files updated - """ - if not self.additional_rels: - logging.debug("No pending relationships to write") - return 0 - - updated_count = 0 - - # Step 1: Read existing rels and merge with pending rels in memory - merged_rels: Dict[str, Relationships] = {} # rels_path -> merged Relationships - - with self._get_zip_file() as zf: - for obj_identifier, new_relationships in self.additional_rels.items(): - # Generate rels path from metadata without loading the object - rels_path = self._gen_rels_path_from_identifier(obj_identifier) - if rels_path is None: - logging.warning(f"Could not generate rels path for {obj_identifier}") - continue - - # Read existing rels from ZIP - existing_relationships = [] - try: - if rels_path in zf.namelist(): - rels_data = zf.read(rels_path) - existing_rels = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels and existing_rels.relationship: - existing_relationships = list(existing_rels.relationship) - except Exception as e: - logging.debug(f"Could not read existing rels for {rels_path}: {e}") - - # Merge new relationships, avoiding duplicates - for new_rel in new_relationships: - # Check if relationship already exists - rel_exists = any( - r.target == new_rel.target and r.type_value == new_rel.type_value - for r in existing_relationships - ) - - if not rel_exists: - # Ensure unique ID - cpt = 0 - new_rel_id = new_rel.id - while any(r.id == new_rel_id for r in existing_relationships): - new_rel_id = f"{new_rel.id}_{cpt}" - cpt += 1 - if new_rel_id != new_rel.id: - new_rel.id = new_rel_id - - existing_relationships.append(new_rel) - - # Store merged result - if existing_relationships: - merged_rels[rels_path] = Relationships(relationship=existing_relationships) - - # Step 2: Write updated rels back to ZIP (create temp, copy all, replace) - if not merged_rels: - return 0 - - with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: - temp_path = temp_file.name - - try: - # Copy entire ZIP, replacing only the updated rels files - with self._get_zip_file() as source_zf: - with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: - # Copy all files except the rels we're updating - for item in source_zf.infolist(): - if item.filename not in merged_rels: - buffer = source_zf.read(item.filename) - target_zf.writestr(item, buffer) - - # Write updated rels files - for rels_path, relationships in merged_rels.items(): - rels_xml = serialize_xml(relationships) - target_zf.writestr(rels_path, rels_xml) - updated_count += 1 - - # Replace original with updated ZIP - shutil.move(temp_path, self.epc_file_path) - self._reopen_persistent_zip() - - # Clear pending rels after successful write - self.additional_rels.clear() - - logging.info(f"Wrote {updated_count} rels files to EPC") - return updated_count - - except Exception as e: - if os.path.exists(temp_path): - os.unlink(temp_path) - logging.error(f"Failed to write pending rels: {e}") - raise - - def _compute_object_rels(self, obj: Any, obj_identifier: str) -> List[Relationship]: - """Compute relationships for a given object (SOURCE relationships). - - Delegates to _rels_mgr.compute_object_rels() - """ - return self._rels_mgr.compute_object_rels(obj, obj_identifier) - - def _merge_rels(self, new_rels: List[Relationship], existing_rels: List[Relationship]) -> List[Relationship]: - """Merge new relationships with existing ones, avoiding duplicates and ensuring unique IDs. - - Delegates to _rels_mgr.merge_rels() - """ - return self._rels_mgr.merge_rels(new_rels, existing_rels) - - def _add_object_to_file(self, obj: Any, metadata: EpcObjectMetadata) -> None: - """Add object to the EPC file efficiently. - - Reads existing rels, computes updates in memory, then writes everything - in a single ZIP operation. - """ - xml_content = serialize_xml(obj) - obj_identifier = metadata.identifier - assert obj_identifier is not None, "Object identifier must not be None" - - # Step 1: Compute which rels files need to be updated and prepare their content - rels_updates: Dict[str, str] = {} # rels_path -> XML content - - with self._get_zip_file() as zf: - # 1a. Object's own .rels file - obj_rels_path = gen_rels_path(obj, self.export_version) - obj_relationships = self._compute_object_rels(obj, obj_identifier) - - if obj_relationships: - # Read existing rels - existing_rels = [] - try: - if obj_rels_path in zf.namelist(): - rels_data = zf.read(obj_rels_path) - existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels_obj and existing_rels_obj.relationship: - existing_rels = list(existing_rels_obj.relationship) - except Exception: - pass - - # Merge and serialize - merged_rels = self._merge_rels(obj_relationships, existing_rels) - if merged_rels: - rels_updates[obj_rels_path] = serialize_xml(Relationships(relationship=merged_rels)) - - # 1b. Update rels of referenced objects (DESTINATION relationships) - direct_dors = get_direct_dor_list(obj) - for dor in direct_dors: - try: - target_identifier = get_obj_identifier(dor) - - # Generate rels path from metadata without processing DOR - target_rels_path = self._gen_rels_path_from_identifier(target_identifier) - if target_rels_path is None: - # Fall back to generating from DOR if metadata not found - target_rels_path = gen_rels_path(dor, self.export_version) - - # Create DESTINATION relationship - dest_rel = Relationship( - target=metadata.file_path, - type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), - id=f"_{target_identifier}_{get_obj_type(get_obj_usable_class(obj))}_{obj_identifier}", - ) - - # Read existing rels - existing_rels = [] - try: - if target_rels_path in zf.namelist(): - rels_data = zf.read(target_rels_path) - existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels_obj and existing_rels_obj.relationship: - existing_rels = list(existing_rels_obj.relationship) - except Exception: - pass - - # Merge and serialize - merged_rels = self._merge_rels([dest_rel], existing_rels) - if merged_rels: - rels_updates[target_rels_path] = serialize_xml(Relationships(relationship=merged_rels)) - - except Exception as e: - logging.warning(f"Failed to prepare rels update for referenced object: {e}") - - # 1c. Update [Content_Types].xml - content_types_xml = self._update_content_types_xml(zf, metadata, add=True) - - # Step 2: Write everything to new ZIP - with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: - temp_path = temp_file.name - - try: - with self._get_zip_file() as source_zf: - with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: - # Write new object - target_zf.writestr(metadata.file_path, xml_content) - - # Write updated [Content_Types].xml - target_zf.writestr(get_epc_content_type_path(), content_types_xml) - - # Write updated rels files - for rels_path, rels_xml in rels_updates.items(): - target_zf.writestr(rels_path, rels_xml) - - # Copy all other files - files_to_skip = {get_epc_content_type_path(), metadata.file_path} - files_to_skip.update(rels_updates.keys()) - - for item in source_zf.infolist(): - if item.filename not in files_to_skip: - buffer = source_zf.read(item.filename) - target_zf.writestr(item, buffer) - - # Replace original - shutil.move(temp_path, self.epc_file_path) - self._reopen_persistent_zip() - - except Exception as e: - if os.path.exists(temp_path): - os.unlink(temp_path) - logging.error(f"Failed to add object to EPC file: {e}") - raise - - def _remove_object_from_file(self, metadata: EpcObjectMetadata) -> None: - """Remove object from the EPC file efficiently. - - Reads existing rels, computes updates in memory, then writes everything - in a single ZIP operation. Note: This does NOT remove .rels files. - Use clean_rels() to remove orphaned relationships. - """ - # Load object first (needed to process its DORs) - if metadata.identifier is None: - logging.error("Cannot remove object with None identifier") - raise ValueError("Object identifier must not be None") - - obj = self.get_object_by_identifier(metadata.identifier) - if obj is None: - logging.warning(f"Object {metadata.identifier} not found, cannot remove rels") - # Still proceed with removal even if object can't be loaded - - # Step 1: Compute rels updates (remove DESTINATION relationships from referenced objects) - rels_updates: Dict[str, str] = {} # rels_path -> XML content - - if obj is not None: - with self._get_zip_file() as zf: - direct_dors = get_direct_dor_list(obj) - - for dor in direct_dors: - try: - target_identifier = get_obj_identifier(dor) - if target_identifier not in self._metadata: - continue - - # Use metadata to generate rels path without loading the object - target_rels_path = self._gen_rels_path_from_identifier(target_identifier) - if target_rels_path is None: - continue - - # Read existing rels - existing_relationships = [] - try: - if target_rels_path in zf.namelist(): - rels_data = zf.read(target_rels_path) - existing_rels = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels and existing_rels.relationship: - existing_relationships = list(existing_rels.relationship) - except Exception as e: - logging.debug(f"Could not read existing rels for {target_identifier}: {e}") - - # Remove DESTINATION relationship that pointed to our object - updated_relationships = [ - r - for r in existing_relationships - if not ( - r.target == metadata.file_path - and r.type_value == EPCRelsRelationshipType.DESTINATION_OBJECT.get_type() - ) - ] - - # Only update if relationships remain - if updated_relationships: - rels_updates[target_rels_path] = serialize_xml( - Relationships(relationship=updated_relationships) - ) - - except Exception as e: - logging.warning(f"Failed to update rels for referenced object during removal: {e}") - - # Update [Content_Types].xml - content_types_xml = self._update_content_types_xml(zf, metadata, add=False) - else: - # If we couldn't load the object, still update content types - with self._get_zip_file() as zf: - content_types_xml = self._update_content_types_xml(zf, metadata, add=False) - - # Step 2: Write everything to new ZIP - with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: - temp_path = temp_file.name - - try: - with self._get_zip_file() as source_zf: - with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: - # Write updated [Content_Types].xml - target_zf.writestr(get_epc_content_type_path(), content_types_xml) - - # Write updated rels files - for rels_path, rels_xml in rels_updates.items(): - target_zf.writestr(rels_path, rels_xml) - - # Copy all files except removed object, its rels, and files we're updating - obj_rels_path = self._gen_rels_path_from_metadata(metadata) - files_to_skip = {get_epc_content_type_path(), metadata.file_path} - if obj_rels_path: - files_to_skip.add(obj_rels_path) - files_to_skip.update(rels_updates.keys()) - - for item in source_zf.infolist(): - if item.filename not in files_to_skip: - buffer = source_zf.read(item.filename) - target_zf.writestr(item, buffer) - - # Replace original - shutil.move(temp_path, self.epc_file_path) - self._reopen_persistent_zip() - - except Exception as e: - if os.path.exists(temp_path): - os.unlink(temp_path) - logging.error(f"Failed to remove object from EPC file: {e}") - raise - - def _update_content_types_xml( - self, source_zip: zipfile.ZipFile, metadata: EpcObjectMetadata, add: bool = True - ) -> str: - """Update [Content_Types].xml to add or remove object entry. - - Delegates to _metadata_mgr.update_content_types_xml() - """ - return self._metadata_mgr.update_content_types_xml(source_zip, metadata, add) - - def _rollback_add_object(self, identifier: Optional[str]) -> None: - """Rollback changes made during failed add_object operation.""" - if identifier and identifier in self._metadata: - metadata = self._metadata[identifier] - - # Remove from metadata - del self._metadata[identifier] - - # Remove from indexes - uuid = metadata.uuid - object_type = metadata.object_type - - if uuid in self._uuid_index and identifier in self._uuid_index[uuid]: - self._uuid_index[uuid].remove(identifier) - if not self._uuid_index[uuid]: - del self._uuid_index[uuid] - - if object_type in self._type_index and identifier in self._type_index[object_type]: - self._type_index[object_type].remove(identifier) - if not self._type_index[object_type]: - del self._type_index[object_type] - - # Remove from cache - if identifier in self._object_cache: - del self._object_cache[identifier] - if identifier in self._access_order: - self._access_order.remove(identifier) - - def clean_rels(self) -> Dict[str, int]: - """ - Clean all .rels files by removing relationships to objects that no longer exist. - - This method: - 1. Scans all .rels files in the EPC - 2. For each relationship, checks if the target object exists - 3. Removes relationships pointing to non-existent objects - 4. Removes empty .rels files - - Returns: - Dictionary with statistics: - - 'rels_files_scanned': Number of .rels files examined - - 'relationships_removed': Number of orphaned relationships removed - - 'rels_files_removed': Number of empty .rels files removed - """ - import tempfile - import shutil - - stats = { - "rels_files_scanned": 0, - "relationships_removed": 0, - "rels_files_removed": 0, - } - - # Create temporary file for updated EPC - with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: - temp_path = temp_file.name - - try: - with self._get_zip_file() as source_zip: - with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zip: - # Get all existing object file paths for validation - existing_object_files = {metadata.file_path for metadata in self._metadata.values()} - - # Process each file - for item in source_zip.infolist(): - if item.filename.endswith(".rels"): - # Process .rels file - stats["rels_files_scanned"] += 1 - - try: - rels_data = source_zip.read(item.filename) - rels_obj = read_energyml_xml_bytes(rels_data, Relationships) - - if rels_obj and rels_obj.relationship: - # Filter out relationships to non-existent objects - original_count = len(rels_obj.relationship) - - # Keep only relationships where the target exists - # or where the target is external (starts with ../ or http) - valid_relationships = [] - for rel in rels_obj.relationship: - target = rel.target - # Keep external references (HDF5, etc.) and existing objects - if ( - target.startswith("../") - or target.startswith("http") - or target in existing_object_files - or target.lstrip("/") - in existing_object_files # Also check without leading slash - ): - valid_relationships.append(rel) - - removed_count = original_count - len(valid_relationships) - stats["relationships_removed"] += removed_count - - if removed_count > 0: - logging.info( - f"Removed {removed_count} orphaned relationships from {item.filename}" - ) - - # Only write the .rels file if it has remaining relationships - if valid_relationships: - rels_obj.relationship = valid_relationships - updated_rels = serialize_xml(rels_obj) - target_zip.writestr(item.filename, updated_rels) - else: - # Empty .rels file, don't write it - stats["rels_files_removed"] += 1 - logging.info(f"Removed empty .rels file: {item.filename}") - else: - # Empty or invalid .rels, don't copy it - stats["rels_files_removed"] += 1 - - except Exception as e: - logging.warning(f"Failed to process .rels file {item.filename}: {e}") - # Copy as-is on error - data = source_zip.read(item.filename) - target_zip.writestr(item, data) - - else: - # Copy non-.rels files as-is - data = source_zip.read(item.filename) - target_zip.writestr(item, data) - - # Replace original file - shutil.move(temp_path, self.epc_file_path) - - logging.info( - f"Cleaned .rels files: scanned {stats['rels_files_scanned']}, " - f"removed {stats['relationships_removed']} orphaned relationships, " - f"removed {stats['rels_files_removed']} empty .rels files" - ) - - return stats - - except Exception as e: - # Clean up temp file on error - if os.path.exists(temp_path): - os.unlink(temp_path) - raise RuntimeError(f"Failed to clean .rels files: {e}") - - def rebuild_all_rels(self, clean_first: bool = True) -> Dict[str, int]: - """ - Rebuild all .rels files from scratch by analyzing all objects and their references. - - This method: - 1. Optionally cleans existing .rels files first - 2. Loads each object temporarily - 3. Analyzes its Data Object References (DORs) - 4. Creates/updates .rels files with proper SOURCE and DESTINATION relationships - - Args: - clean_first: If True, remove all existing .rels files before rebuilding - - Returns: - Dictionary with statistics: - - 'objects_processed': Number of objects analyzed - - 'rels_files_created': Number of .rels files created - - 'source_relationships': Number of SOURCE relationships created - - 'destination_relationships': Number of DESTINATION relationships created - - 'parallel_mode': True if parallel processing was used (optional key) - - 'execution_time': Execution time in seconds (optional key) - """ - if self.enable_parallel_rels: - return self._rebuild_all_rels_parallel(clean_first) - else: - return self._rebuild_all_rels_sequential(clean_first) - - def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, int]: - """ - Rebuild all .rels files from scratch by analyzing all objects and their references. - - This method: - 1. Optionally cleans existing .rels files first - 2. Loads each object temporarily - 3. Analyzes its Data Object References (DORs) - 4. Creates/updates .rels files with proper SOURCE and DESTINATION relationships - - Args: - clean_first: If True, remove all existing .rels files before rebuilding - - Returns: - Dictionary with statistics: - - 'objects_processed': Number of objects analyzed - - 'rels_files_created': Number of .rels files created - - 'source_relationships': Number of SOURCE relationships created - - 'destination_relationships': Number of DESTINATION relationships created - """ - import tempfile - import shutil - - stats = { - "objects_processed": 0, - "rels_files_created": 0, - "source_relationships": 0, - "destination_relationships": 0, - } - - logging.info(f"Starting rebuild of all .rels files for {len(self._metadata)} objects...") - - # Build a map of which objects are referenced by which objects - # Key: target identifier, Value: list of (source_identifier, source_obj) - reverse_references: Dict[str, List[Tuple[str, Any]]] = {} - - # First pass: analyze all objects and build the reference map - for identifier in self._metadata: - try: - obj = self.get_object_by_identifier(identifier) - if obj is None: - continue - - stats["objects_processed"] += 1 - - # Get all DORs in this object - dors = get_direct_dor_list(obj) - - for dor in dors: - try: - target_identifier = get_obj_identifier(dor) - if target_identifier in self._metadata: - # Record this reference - if target_identifier not in reverse_references: - reverse_references[target_identifier] = [] - reverse_references[target_identifier].append((identifier, obj)) - except Exception: - pass - - except Exception as e: - logging.warning(f"Failed to analyze object {identifier}: {e}") - - # Second pass: create the .rels files - # Map of rels_file_path -> Relationships object - rels_files: Dict[str, Relationships] = {} - - # Process each object to create SOURCE relationships - for identifier in self._metadata: - try: - obj = self.get_object_by_identifier(identifier) - if obj is None: - continue - - # metadata = self._metadata[identifier] - obj_rels_path = self._gen_rels_path_from_identifier(identifier) - - # Get all DORs (objects this object references) - dors = get_direct_dor_list(obj) - - if dors: - # Create SOURCE relationships - relationships = [] - - for dor in dors: - try: - target_identifier = get_obj_identifier(dor) - if target_identifier in self._metadata: - target_metadata = self._metadata[target_identifier] - - rel = Relationship( - target=target_metadata.file_path, - type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), - id=f"_{identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", - ) - relationships.append(rel) - stats["source_relationships"] += 1 - - except Exception as e: - logging.debug(f"Failed to create SOURCE relationship: {e}") - - if relationships and obj_rels_path: - if obj_rels_path not in rels_files: - rels_files[obj_rels_path] = Relationships(relationship=[]) - rels_files[obj_rels_path].relationship.extend(relationships) - - except Exception as e: - logging.warning(f"Failed to create SOURCE rels for {identifier}: {e}") - - # Add DESTINATION relationships - for target_identifier, source_list in reverse_references.items(): - try: - if target_identifier not in self._metadata: - continue - - target_metadata = self._metadata[target_identifier] - target_rels_path = self._gen_rels_path_from_identifier(target_identifier) - - if not target_rels_path: - continue - - # Create DESTINATION relationships for each object that references this one - for source_identifier, source_obj in source_list: - try: - source_metadata = self._metadata[source_identifier] - - rel = Relationship( - target=source_metadata.file_path, - type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), - id=f"_{target_identifier}_{get_obj_type(get_obj_usable_class(source_obj))}_{source_identifier}", - ) - - if target_rels_path not in rels_files: - rels_files[target_rels_path] = Relationships(relationship=[]) - rels_files[target_rels_path].relationship.append(rel) - stats["destination_relationships"] += 1 - - except Exception as e: - logging.debug(f"Failed to create DESTINATION relationship: {e}") - - except Exception as e: - logging.warning(f"Failed to create DESTINATION rels for {target_identifier}: {e}") - - stats["rels_files_created"] = len(rels_files) - - # Before writing, preserve EXTERNAL_RESOURCE and other non-SOURCE/DESTINATION relationships - # This includes rels files that may not be in rels_files yet - with self._get_zip_file() as zf: - # Check all existing .rels files - for filename in zf.namelist(): - if not filename.endswith(".rels"): - continue - - try: - rels_data = zf.read(filename) - existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels_obj and existing_rels_obj.relationship: - # Preserve non-SOURCE/DESTINATION relationships (e.g., EXTERNAL_RESOURCE) - preserved_rels = [ - r - for r in existing_rels_obj.relationship - if r.type_value - not in ( - EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), - EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), - ) - ] - if preserved_rels: - if filename in rels_files: - # Add preserved relationships to existing entry - rels_files[filename].relationship = preserved_rels + rels_files[filename].relationship - else: - # Create new entry with only preserved relationships - rels_files[filename] = Relationships(relationship=preserved_rels) - except Exception as e: - logging.debug(f"Could not preserve existing rels from {filename}: {e}") - - # Third pass: write the new EPC with updated .rels files - with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: - temp_path = temp_file.name - - try: - with self._get_zip_file() as source_zip: - with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zip: - # Copy all non-.rels files - for item in source_zip.infolist(): - if not (item.filename.endswith(".rels") and clean_first): - data = source_zip.read(item.filename) - target_zip.writestr(item, data) - - # Write new .rels files - for rels_path, rels_obj in rels_files.items(): - rels_xml = serialize_xml(rels_obj) - target_zip.writestr(rels_path, rels_xml) - - # Replace original file - shutil.move(temp_path, self.epc_file_path) - self._reopen_persistent_zip() - - logging.info( - f"Rebuilt .rels files: processed {stats['objects_processed']} objects, " - f"created {stats['rels_files_created']} .rels files, " - f"added {stats['source_relationships']} SOURCE and " - f"{stats['destination_relationships']} DESTINATION relationships" - ) - - return stats - - except Exception as e: - # Clean up temp file on error - if os.path.exists(temp_path): - os.unlink(temp_path) - raise RuntimeError(f"Failed to rebuild .rels files: {e}") - - def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int]: - """ - Parallel implementation of rebuild_all_rels using multiprocessing. - - Strategy: - 1. Use multiprocessing.Pool to process objects in parallel - 2. Each worker loads an object and computes its SOURCE relationships - 3. Main process aggregates results and builds DESTINATION relationships - 4. Sequential write phase (ZIP writing must be sequential) - - This bypasses Python's GIL for CPU-intensive XML parsing and provides - significant speedup for large EPCs (tested with 80+ objects). - """ - import tempfile - import shutil - import time - from multiprocessing import Pool, cpu_count - - start_time = time.time() - - stats = { - "objects_processed": 0, - "rels_files_created": 0, - "source_relationships": 0, - "destination_relationships": 0, - "parallel_mode": True, - } - - num_objects = len(self._metadata) - logging.info(f"Starting PARALLEL rebuild of all .rels files for {num_objects} objects...") - - # Prepare work items for parallel processing - # Pass metadata as dict (serializable) instead of keeping references - metadata_dict = {k: v for k, v in self._metadata.items()} - work_items = [(identifier, str(self.epc_file_path), metadata_dict) for identifier in self._metadata] - - # Determine optimal number of workers based on available CPUs and workload - # Don't spawn more workers than CPUs; use user-configurable ratio for workload per worker - worker_ratio = self.parallel_worker_ratio if hasattr(self, "parallel_worker_ratio") else _WORKER_POOL_SIZE_RATIO - num_workers = min(cpu_count(), max(1, num_objects // worker_ratio)) - logging.info(f"Using {num_workers} worker processes for {num_objects} objects (ratio: {worker_ratio})") - - # ============================================================================ - # PHASE 1: PARALLEL - Compute SOURCE relationships across worker processes - # ============================================================================ - results = [] - with Pool(processes=num_workers) as pool: - results = pool.map(_process_object_for_rels_worker, work_items) - - # ============================================================================ - # PHASE 2: SEQUENTIAL - Aggregate worker results - # ============================================================================ - # Build data structures for subsequent phases: - # - reverse_references: Map target objects to their sources (for DESTINATION rels) - # - rels_files: Accumulate all relationships by file path - # - object_types: Cache object types to eliminate redundant loads in Phase 3 - reverse_references: Dict[str, List[Tuple[str, str]]] = {} - rels_files: Dict[str, Relationships] = {} - object_types: Dict[str, str] = {} - - for result in results: - if result is None: - continue - - identifier = result["identifier"] - obj_type = result["object_type"] - source_rels = result["source_rels"] - dor_targets = result["dor_targets"] - - # Cache object type - object_types[identifier] = obj_type - - stats["objects_processed"] += 1 - - # Convert dicts back to Relationship objects - if source_rels: - obj_rels_path = self._gen_rels_path_from_identifier(identifier) - if obj_rels_path: - relationships = [] - for rel_dict in source_rels: - rel = Relationship( - target=rel_dict["target"], - type_value=rel_dict["type_value"], - id=rel_dict["id"], - ) - relationships.append(rel) - stats["source_relationships"] += 1 - - if obj_rels_path not in rels_files: - rels_files[obj_rels_path] = Relationships(relationship=[]) - rels_files[obj_rels_path].relationship.extend(relationships) - - # Build reverse reference map for DESTINATION relationships - # dor_targets now contains (target_id, target_type) tuples - for target_identifier, target_type in dor_targets: - if target_identifier not in reverse_references: - reverse_references[target_identifier] = [] - reverse_references[target_identifier].append((identifier, obj_type)) - - # ============================================================================ - # PHASE 3: SEQUENTIAL - Create DESTINATION relationships (zero object loading!) - # ============================================================================ - # Use cached object types from Phase 2 to build DESTINATION relationships - # without reloading any objects. This optimization is critical for performance. - for target_identifier, source_list in reverse_references.items(): - try: - if target_identifier not in self._metadata: - continue - - target_rels_path = self._gen_rels_path_from_identifier(target_identifier) - - if not target_rels_path: - continue - - # Use cached object types instead of loading objects! - for source_identifier, source_type in source_list: - try: - source_metadata = self._metadata[source_identifier] - - # No object loading needed - we have all the type info from Phase 2! - rel = Relationship( - target=source_metadata.file_path, - type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), - id=f"_{target_identifier}_{source_type}_{source_identifier}", - ) - - if target_rels_path not in rels_files: - rels_files[target_rels_path] = Relationships(relationship=[]) - rels_files[target_rels_path].relationship.append(rel) - stats["destination_relationships"] += 1 - - except Exception as e: - logging.debug(f"Failed to create DESTINATION relationship: {e}") - - except Exception as e: - logging.warning(f"Failed to create DESTINATION rels for {target_identifier}: {e}") - - stats["rels_files_created"] = len(rels_files) - - # ============================================================================ - # PHASE 4: SEQUENTIAL - Preserve non-object relationships - # ============================================================================ - # Preserve EXTERNAL_RESOURCE and other non-standard relationship types - with self._get_zip_file() as zf: - for filename in zf.namelist(): - if not filename.endswith(".rels"): - continue - - try: - rels_data = zf.read(filename) - existing_rels_obj = read_energyml_xml_bytes(rels_data, Relationships) - if existing_rels_obj and existing_rels_obj.relationship: - preserved_rels = [ - r - for r in existing_rels_obj.relationship - if r.type_value - not in ( - EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), - EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), - ) - ] - if preserved_rels: - if filename in rels_files: - rels_files[filename].relationship = preserved_rels + rels_files[filename].relationship - else: - rels_files[filename] = Relationships(relationship=preserved_rels) - except Exception as e: - logging.debug(f"Could not preserve existing rels from {filename}: {e}") - - # ============================================================================ - # PHASE 5: SEQUENTIAL - Write all relationships to ZIP file - # ============================================================================ - # ZIP file writing must be sequential (file format limitation) - with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: - temp_path = temp_file.name - - try: - with self._get_zip_file() as source_zip: - with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zip: - # Copy all non-.rels files - for item in source_zip.infolist(): - if not (item.filename.endswith(".rels") and clean_first): - data = source_zip.read(item.filename) - target_zip.writestr(item, data) - - # Write new .rels files - for rels_path, rels_obj in rels_files.items(): - rels_xml = serialize_xml(rels_obj) - target_zip.writestr(rels_path, rels_xml) - - # Replace original file - shutil.move(temp_path, self.epc_file_path) - self._reopen_persistent_zip() - - execution_time = time.time() - start_time - stats["execution_time"] = execution_time - - logging.info( - f"Rebuilt .rels files (PARALLEL): processed {stats['objects_processed']} objects, " - f"created {stats['rels_files_created']} .rels files, " - f"added {stats['source_relationships']} SOURCE and " - f"{stats['destination_relationships']} DESTINATION relationships " - f"in {execution_time:.2f}s using {num_workers} workers" - ) - - return stats - - except Exception as e: - if os.path.exists(temp_path): - os.unlink(temp_path) - raise RuntimeError(f"Failed to rebuild .rels files (parallel): {e}") - - def __repr__(self) -> str: - """String representation.""" - return ( - f"EpcStreamReader(path='{self.epc_file_path}', " - f"objects={len(self._metadata)}, " - f"cached={len(self._object_cache)}, " - f"cache_hit_rate={self.stats.cache_hit_rate:.1f}%)" - ) - - def dumps_epc_content_and_files_lists(self): - """Dump EPC content and files lists for debugging.""" - content_list = [] - file_list = [] - - with self._get_zip_file() as zf: - file_list = zf.namelist() - - for item in zf.infolist(): - content_list.append(f"{item.filename} - {item.file_size} bytes") - - return { - "content_list": sorted(content_list), - "file_list": sorted(file_list), - } - - -# Utility functions for backward compatibility - - -def read_epc_stream(epc_file_path: Union[str, Path], **kwargs) -> EpcStreamReader: - """ - Factory function to create EpcStreamReader instance. - - Args: - epc_file_path: Path to EPC file - **kwargs: Additional arguments for EpcStreamReader - - Returns: - EpcStreamReader instance - """ - return EpcStreamReader(epc_file_path, **kwargs) - - -__all__ = ["EpcStreamReader", "EpcObjectMetadata", "EpcStreamingStats", "read_epc_stream"] diff --git a/energyml-utils/tests/test_epc_stream.py b/energyml-utils/tests/test_epc_stream.py index 927c604..e93dcbf 100644 --- a/energyml-utils/tests/test_epc_stream.py +++ b/energyml-utils/tests/test_epc_stream.py @@ -143,17 +143,67 @@ def test_manual_mode_no_auto_rebuild(self, temp_epc_file, sample_objects): reader2.close() - def test_update_on_close_mode(self, temp_epc_file, sample_objects): - """Test that UPDATE_ON_CLOSE mode rebuilds rels on close.""" - reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_ON_CLOSE) + def test_update_on_close_mode_sequential(self, temp_epc_file, sample_objects): + """Test that UPDATE_ON_CLOSE mode rebuilds rels on close (sequential processing).""" + reader = EpcStreamReader( + temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_ON_CLOSE, enable_parallel_rels=False + ) bf = sample_objects["bf"] bfi = sample_objects["bfi"] + horizon_interp = sample_objects["horizon_interp"] trset = sample_objects["trset"] - # Add objects + # Add objects (including horizon_interp that trset references) reader.add_object(bf) reader.add_object(bfi) + reader.add_object(horizon_interp) + reader.add_object(trset) + + # Before closing, rels may not be complete + reader.close() + + # Reopen and verify relationships were built + reader2 = EpcStreamReader(temp_epc_file) + + # Check that bfi has a DEST relationship to bf + bfi_rels = reader2.get_obj_rels(get_obj_identifier(bfi)) + dest_rels = [r for r in bfi_rels if r.type_value == str(EPCRelsRelationshipType.DESTINATION_OBJECT)] + assert len(dest_rels) == 1, "Expected DESTINATION relationship from bfi to bf" + + # Check that bf has SOURCE relationships from bfi and horizon_interp + bf_rels = reader2.get_obj_rels(get_obj_identifier(bf)) + source_rels = [r for r in bf_rels if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT)] + assert len(source_rels) == 2, "Expected 2 SOURCE relationships in bf rels (from bfi and horizon_interp)" + + # Check that horizon_interp has a SOURCE relationship from trset + hi_rels = reader2.get_obj_rels(get_obj_identifier(horizon_interp)) + hi_source_rels = [r for r in hi_rels if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT)] + assert len(hi_source_rels) == 1, "Expected SOURCE relationship in horizon_interp rels from trset" + + # Check that trset has a DESTINATION relationship to horizon_interp + trset_rels = reader2.get_obj_rels(get_obj_identifier(trset)) + dest_rels = [r for r in trset_rels if r.type_value == str(EPCRelsRelationshipType.DESTINATION_OBJECT)] + assert len(dest_rels) == 1, "Expected DESTINATION relationship in trset rels targeting horizon_interp" + + # Close to release file handles (important on Windows) + reader2.close() + + def test_update_on_close_mode_parallel(self, temp_epc_file, sample_objects): + """Test that UPDATE_ON_CLOSE mode rebuilds rels on close (parallel processing).""" + reader = EpcStreamReader( + temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_ON_CLOSE, enable_parallel_rels=True + ) + + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + horizon_interp = sample_objects["horizon_interp"] + trset = sample_objects["trset"] + + # Add objects (including horizon_interp that trset references) + reader.add_object(bf) + reader.add_object(bfi) + reader.add_object(horizon_interp) reader.add_object(trset) # Before closing, rels may not be complete @@ -165,19 +215,141 @@ def test_update_on_close_mode(self, temp_epc_file, sample_objects): # Check that bfi has a DEST relationship to bf bfi_rels = reader2.get_obj_rels(get_obj_identifier(bfi)) dest_rels = [r for r in bfi_rels if r.type_value == str(EPCRelsRelationshipType.DESTINATION_OBJECT)] - source_rels = [r for r in bfi_rels if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT)] assert len(dest_rels) == 1, "Expected DESTINATION relationship from bfi to bf" - assert len(source_rels) == 1, "Expected SOURCE relationship from bfi to trset" - # Check that bf has a SOURCE relationship from bfi + # Check that bf has SOURCE relationships from bfi and horizon_interp bf_rels = reader2.get_obj_rels(get_obj_identifier(bf)) source_rels = [r for r in bf_rels if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT)] - assert len(source_rels) == 1, "Expected SOURCE relationship in bf rels targeting bfi" + assert len(source_rels) == 2, "Expected 2 SOURCE relationships in bf rels (from bfi and horizon_interp)" + + # Check that horizon_interp has a SOURCE relationship from trset + hi_rels = reader2.get_obj_rels(get_obj_identifier(horizon_interp)) + hi_source_rels = [r for r in hi_rels if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT)] + assert len(hi_source_rels) == 1, "Expected SOURCE relationship in horizon_interp rels from trset" - # Check that bf has a SOURCE relationship from bfi + # Check that trset has a DESTINATION relationship to horizon_interp trset_rels = reader2.get_obj_rels(get_obj_identifier(trset)) dest_rels = [r for r in trset_rels if r.type_value == str(EPCRelsRelationshipType.DESTINATION_OBJECT)] - assert len(dest_rels) >= 1, "Expected DESTINATION relationship in trset rels targeting bfi" + assert len(dest_rels) == 1, "Expected DESTINATION relationship in trset rels targeting horizon_interp" + + # Close to release file handles (important on Windows) + reader2.close() + + def test_update_on_close_mode_metadata_before_close_sequential(self, temp_epc_file, sample_objects): + """Test that UPDATE_ON_CLOSE mode updates metadata immediately but delays rels until close (sequential).""" + reader = EpcStreamReader( + temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_ON_CLOSE, enable_parallel_rels=False + ) + + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + horizon_interp = sample_objects["horizon_interp"] + trset = sample_objects["trset"] + + # Add objects + reader.add_object(bf) + reader.add_object(bfi) + reader.add_object(horizon_interp) + reader.add_object(trset) + + # BEFORE closing, verify metadata is updated + objects_list = reader.list_objects() + assert len(objects_list) == 4, "Expected 4 objects in metadata before close" + assert len(reader) == 4, "Expected length of reader to be 4 before close" + + # BEFORE closing, verify NO relationships exist yet (UPDATE_ON_CLOSE behavior) + bfi_rels = reader.get_obj_rels(get_obj_identifier(bfi)) + assert len(bfi_rels) == 0, "Expected no relationships before close in UPDATE_ON_CLOSE mode" + + bf_rels = reader.get_obj_rels(get_obj_identifier(bf)) + assert len(bf_rels) == 0, "Expected no relationships before close in UPDATE_ON_CLOSE mode" + + hi_rels = reader.get_obj_rels(get_obj_identifier(horizon_interp)) + assert len(hi_rels) == 0, "Expected no relationships before close in UPDATE_ON_CLOSE mode" + + trset_rels = reader.get_obj_rels(get_obj_identifier(trset)) + assert len(trset_rels) == 0, "Expected no relationships before close in UPDATE_ON_CLOSE mode" + + # Now close to trigger rels rebuild + reader.close() + + # Reopen and verify relationships were built AFTER close + reader2 = EpcStreamReader(temp_epc_file) + + # Verify metadata is still correct + assert len(reader2) == 4, "Expected 4 objects after reopen" + + # Verify relationships NOW exist + bfi_rels = reader2.get_obj_rels(get_obj_identifier(bfi)) + assert len(bfi_rels) == 1, "Expected relationships after close in UPDATE_ON_CLOSE mode" + + bf_rels = reader2.get_obj_rels(get_obj_identifier(bf)) + assert len(bf_rels) == 2, "Expected relationships after close in UPDATE_ON_CLOSE mode" + + hi_rels = reader2.get_obj_rels(get_obj_identifier(horizon_interp)) + assert len(hi_rels) == 2, "Expected relationships after close in UPDATE_ON_CLOSE mode" + + trset_rels = reader2.get_obj_rels(get_obj_identifier(trset)) + assert len(trset_rels) == 1, "Expected relationships after close in UPDATE_ON_CLOSE mode" + + reader2.close() + + def test_update_on_close_mode_metadata_before_close_parallel(self, temp_epc_file, sample_objects): + """Test that UPDATE_ON_CLOSE mode updates metadata immediately but delays rels until close (parallel).""" + reader = EpcStreamReader( + temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_ON_CLOSE, enable_parallel_rels=True + ) + + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + horizon_interp = sample_objects["horizon_interp"] + trset = sample_objects["trset"] + + # Add objects + reader.add_object(bf) + reader.add_object(bfi) + reader.add_object(horizon_interp) + reader.add_object(trset) + + # BEFORE closing, verify metadata is updated + objects_list = reader.list_objects() + assert len(objects_list) == 4, "Expected 4 objects in metadata before close" + assert len(reader) == 4, "Expected length of reader to be 4 before close" + + # BEFORE closing, verify NO relationships exist yet (UPDATE_ON_CLOSE behavior) + bfi_rels = reader.get_obj_rels(get_obj_identifier(bfi)) + assert len(bfi_rels) == 0, "Expected no relationships before close in UPDATE_ON_CLOSE mode" + + bf_rels = reader.get_obj_rels(get_obj_identifier(bf)) + assert len(bf_rels) == 0, "Expected no relationships before close in UPDATE_ON_CLOSE mode" + + hi_rels = reader.get_obj_rels(get_obj_identifier(horizon_interp)) + assert len(hi_rels) == 0, "Expected no relationships before close in UPDATE_ON_CLOSE mode" + + trset_rels = reader.get_obj_rels(get_obj_identifier(trset)) + assert len(trset_rels) == 0, "Expected no relationships before close in UPDATE_ON_CLOSE mode" + + # Now close to trigger rels rebuild + reader.close() + + # Reopen and verify relationships were built AFTER close + reader2 = EpcStreamReader(temp_epc_file) + + # Verify metadata is still correct + assert len(reader2) == 4, "Expected 4 objects after reopen" + + # Verify relationships NOW exist + bfi_rels = reader2.get_obj_rels(get_obj_identifier(bfi)) + assert len(bfi_rels) == 1, "Expected relationships after close in UPDATE_ON_CLOSE mode" + + bf_rels = reader2.get_obj_rels(get_obj_identifier(bf)) + assert len(bf_rels) == 2, "Expected relationships after close in UPDATE_ON_CLOSE mode" + + hi_rels = reader2.get_obj_rels(get_obj_identifier(horizon_interp)) + assert len(hi_rels) == 2, "Expected relationships after close in UPDATE_ON_CLOSE mode" + + trset_rels = reader2.get_obj_rels(get_obj_identifier(trset)) + assert len(trset_rels) == 1, "Expected relationships after close in UPDATE_ON_CLOSE mode" reader2.close() From 3e015cb6869b789481e5c2dcf8ab6a114b1253bb Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Thu, 12 Feb 2026 02:12:54 +0100 Subject: [PATCH 22/70] epc stream fully working even for arrays --- energyml-utils/.gitignore | 3 + energyml-utils/example/main_stream_sample.py | 25 ++- energyml-utils/example/main_test_3D.py | 8 +- .../src/energyml/utils/data/datasets_io.py | 163 +++++++++++++----- .../src/energyml/utils/data/helper.py | 127 ++++++++++++-- .../src/energyml/utils/data/model.py | 33 +++- .../src/energyml/utils/epc_stream.py | 77 +++++++-- .../src/energyml/utils/storage_interface.py | 57 ++++-- 8 files changed, 390 insertions(+), 103 deletions(-) diff --git a/energyml-utils/.gitignore b/energyml-utils/.gitignore index e7c85c7..9cbbdf4 100644 --- a/energyml-utils/.gitignore +++ b/energyml-utils/.gitignore @@ -39,6 +39,9 @@ src/energyml/utils/converter/ # Other files requirements.txt +.github/ + + #doc/ sample/ gen*/ diff --git a/energyml-utils/example/main_stream_sample.py b/energyml-utils/example/main_stream_sample.py index 87ebbce..bdaf5c0 100644 --- a/energyml-utils/example/main_stream_sample.py +++ b/energyml-utils/example/main_stream_sample.py @@ -2,7 +2,7 @@ import sys import logging from energyml.utils.epc_stream import EpcStreamReader, RelsUpdateMode -from energyml.eml.v2_3.commonv2 import Citation, ExternalDataArrayPart, +from energyml.eml.v2_3.commonv2 import Citation, ExternalDataArrayPart from energyml.resqml.v2_2.resqmlv2 import ( TriangulatedSetRepresentation, BoundaryFeatureInterpretation, @@ -14,15 +14,18 @@ PointGeometry, Point3DExternalArray, ) + +from energyml.resqml.v2_0_1.resqmlv2 import TrianglePatch as TrianglePatchV2_0_1 from energyml.utils.introspection import epoch_to_date, epoch from energyml.utils.epc import as_dor, gen_uuid, get_obj_identifier from energyml.utils.constants import EPCRelsRelationshipType, MimeType from energyml.opc.opc import Relationship +import numpy as np -CONST_H5_PATH = "wip/external_data.h5" -CONST_CSV_PATH = "wip/external_data.csv" +CONST_H5_PATH = "external_data.h5" +CONST_CSV_PATH = "external_data.csv" def sample_objects(): @@ -250,8 +253,20 @@ def test_create_epc_v3_with_different_external_files(path: str): tr_set_id, relationships=[Relationship(type_value=str(EPCRelsRelationshipType.EXTERNAL_RESOURCE), target=h5_file_path)], ) - - epc.write_array() + + epc.write_array( + proxy=tr_set_id, + path_in_external=f"/RESQML/{tr_set_id}/triangles", + array=np.array([0, 1, 2, 2, 3, 0], dtype=np.int32), + external_uri=CONST_H5_PATH, + ) + + epc.write_array( + proxy=tr_set_id, + path_in_external=f"/RESQML/{tr_set_id}/points", + array=np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], dtype=np.float32), + external_uri=CONST_CSV_PATH, + ) # Create an diff --git a/energyml-utils/example/main_test_3D.py b/energyml-utils/example/main_test_3D.py index 0657bdf..aafc979 100644 --- a/energyml-utils/example/main_test_3D.py +++ b/energyml-utils/example/main_test_3D.py @@ -135,11 +135,11 @@ def export_all_representation_in_memory(epc_path: str, output_dir: str, regex_ty import logging logging.basicConfig(level=logging.DEBUG) - # epc_file = "rc/epc/testingPackageCpp.epc" - epc_file = "rc/epc/output-val.epc" + epc_file = "rc/epc/testingPackageCpp.epc" + # epc_file = "rc/epc/output-val.epc" # epc_file = "rc/epc/Volve_Horizons_and_Faults_Depth_originEQN.epc" output_directory = Path("exported_meshes") / Path(epc_file).name.replace(".epc", "_3D_export") # export_all_representation(epc_file, output_directory) # export_all_representation(epc_file, output_directory, regex_type_filter="Wellbore") - # export_all_representation(epc_file, str(output_directory), regex_type_filter="") - export_all_representation_in_memory(epc_file, str(output_directory), regex_type_filter="") + export_all_representation(epc_file, str(output_directory), regex_type_filter="") + # export_all_representation_in_memory(epc_file, str(output_directory), regex_type_filter="") diff --git a/energyml-utils/src/energyml/utils/data/datasets_io.py b/energyml-utils/src/energyml/utils/data/datasets_io.py index edbc200..fe33bb3 100644 --- a/energyml-utils/src/energyml/utils/data/datasets_io.py +++ b/energyml-utils/src/energyml/utils/data/datasets_io.py @@ -966,29 +966,36 @@ class HDF5ArrayHandler(ExternalArrayHandler): """Handler for HDF5 files (.h5, .hdf5).""" def read_array( - self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, ) -> Optional[np.ndarray]: - """Read array from HDF5 file.""" + """Read array from HDF5 file with optional sub-selection.""" if isinstance(source, h5py.File): # type: ignore if path_in_external_file: d_group = source[path_in_external_file] - return d_group[()] # type: ignore + full_array = d_group[()] # type: ignore + # Apply sub-selection if specified + if start_indices is not None and counts is not None: + slices = tuple(slice(start, start + count) for start, count in zip(start_indices, counts)) + return full_array[slices] + return full_array return None else: with h5py.File(source, "r") as f: # type: ignore - if path_in_external_file: - d_group = f[path_in_external_file] - return d_group[()] # type: ignore - return None + return self.read_array(f, path_in_external_file, start_indices, counts) def write_array( self, target: Union[str, BytesIO, Any], array: Union[list, np.ndarray], path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, **kwargs, ) -> bool: - """Write array to HDF5 file.""" + """Write array to HDF5 file with optional offset.""" if not path_in_external_file: return False @@ -1004,41 +1011,56 @@ def write_array( if isinstance(array, np.ndarray) and array.dtype == "O": array = np.asarray([s.encode() if isinstance(s, str) else s for s in array]) np.void(array) - dset = target.create_dataset(path_in_external_file, array.shape, dtype or array.dtype) - dset[()] = array + + # Handle partial writes if start_indices provided + if start_indices is not None and path_in_external_file in target: + dset = target[path_in_external_file] + slices = tuple(slice(start, start + dim) for start, dim in zip(start_indices, array.shape)) + dset[slices] = array + else: + dset = target.create_dataset(path_in_external_file, array.shape, dtype or array.dtype) + dset[()] = array else: with h5py.File(target, "a") as f: # type: ignore - if isinstance(array, np.ndarray) and array.dtype == "O": - array = np.asarray([s.encode() if isinstance(s, str) else s for s in array]) - np.void(array) - dset = f.create_dataset(path_in_external_file, array.shape, dtype or array.dtype) - dset[()] = array + return self.write_array(f, array, path_in_external_file, start_indices, **kwargs) return True except Exception as e: logging.error(f"Failed to write array to HDF5: {e}") return False def get_array_metadata( - self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, ) -> Optional[Union[dict, List[dict]]]: - """Get metadata for HDF5 datasets.""" + """Get metadata for HDF5 datasets with optional sub-selection.""" try: if isinstance(source, h5py.File): # type: ignore if path_in_external_file: dset = source[path_in_external_file] + shape = list(dset.shape) + size = dset.size + + # Adjust shape and size for sub-selection + if start_indices is not None and counts is not None: + shape = counts + size = int(np.prod(counts)) + return { "path": path_in_external_file, "dtype": str(dset.dtype), - "shape": list(dset.shape), - "size": dset.size, + "shape": shape, + "size": size, } else: # List all datasets datasets = h5_list_datasets(source) - return [self.get_array_metadata(source, ds) for ds in datasets] + return [self.get_array_metadata(source, ds, start_indices, counts) for ds in datasets] else: with h5py.File(source, "r") as f: # type: ignore - return self.get_array_metadata(f, path_in_external_file) + return self.get_array_metadata(f, path_in_external_file, start_indices, counts) except Exception as e: logging.debug(f"Failed to get HDF5 metadata: {e}") return None @@ -1058,7 +1080,11 @@ class MockHDF5ArrayHandler(ExternalArrayHandler): """Mock handler when h5py is not installed.""" def read_array( - self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, ) -> Optional[np.ndarray]: raise MissingExtraInstallation(extra_name="hdf5") @@ -1067,12 +1093,17 @@ def write_array( target: Union[str, BytesIO, Any], array: Union[list, np.ndarray], path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, **kwargs, ) -> bool: raise MissingExtraInstallation(extra_name="hdf5") def get_array_metadata( - self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, ) -> Optional[Union[dict, List[dict]]]: raise MissingExtraInstallation(extra_name="hdf5") @@ -1090,25 +1121,36 @@ class ParquetArrayHandler(ExternalArrayHandler): """Handler for Parquet files (.parquet, .pq).""" def read_array( - self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, ) -> Optional[np.ndarray]: - """Read array from Parquet file.""" + """Read array from Parquet file with optional sub-selection.""" if isinstance(source, bytes): source = pa.BufferReader(source) table = pq.read_table(source) if path_in_external_file: - return np.array(table[path_in_external_file]) + array = np.array(table[path_in_external_file]) else: # Return all columns as 2D array - return table.to_pandas().values + array = table.to_pandas().values + + # Apply sub-selection if specified + if array is not None and start_indices is not None and counts is not None: + slices = tuple(slice(start, start + count) for start, count in zip(start_indices, counts)) + return array[slices] + return array def write_array( self, target: Union[str, BytesIO, Any], array: Union[list, np.ndarray], path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, **kwargs, ) -> bool: """Write array to Parquet file.""" @@ -1134,9 +1176,13 @@ def write_array( return False def get_array_metadata( - self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, ) -> Optional[Union[dict, List[dict]]]: - """Get metadata for Parquet columns.""" + """Get metadata for Parquet columns with optional sub-selection.""" try: if isinstance(source, bytes): source = pa.BufferReader(source) @@ -1149,23 +1195,23 @@ def get_array_metadata( col_idx = schema.get_field_index(path_in_external_file) if col_idx >= 0: field = schema.field(col_idx) + shape = [metadata.num_rows] + size = metadata.num_rows + + # Adjust for sub-selection + if start_indices is not None and counts is not None: + shape = counts + size = int(np.prod(counts)) + return { "path": path_in_external_file, "dtype": str(field.type), - "shape": [metadata.num_rows], - "size": metadata.num_rows, + "shape": shape, + "size": size, } else: # Get all columns - return [ - { - "path": field.name, - "dtype": str(field.type), - "shape": [metadata.num_rows], - "size": metadata.num_rows, - } - for field in schema - ] + return [self.get_array_metadata(source, field.name, start_indices, counts) for field in schema] except Exception as e: logging.debug(f"Failed to get Parquet metadata: {e}") return None @@ -1191,7 +1237,11 @@ class MockParquetArrayHandler(ExternalArrayHandler): """Mock handler when parquet libraries are not installed.""" def read_array( - self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, ) -> Optional[np.ndarray]: raise MissingExtraInstallation(extra_name="parquet") @@ -1200,12 +1250,17 @@ def write_array( target: Union[str, BytesIO, Any], array: Union[list, np.ndarray], path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, **kwargs, ) -> bool: raise MissingExtraInstallation(extra_name="parquet") def get_array_metadata( - self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, ) -> Optional[Union[dict, List[dict]]]: raise MissingExtraInstallation(extra_name="parquet") @@ -1223,9 +1278,13 @@ class CSVArrayHandler(ExternalArrayHandler): """Handler for CSV files (.csv, .txt, .dat).""" def read_array( - self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, ) -> Optional[np.ndarray]: - """Read array from CSV file.""" + """Read array from CSV file with optional sub-selection.""" # For CSV, path_in_external_file can be column name or index # This is a simplified implementation try: @@ -1233,6 +1292,11 @@ def read_array( data = np.genfromtxt(source, delimiter=",") else: data = np.genfromtxt(source, delimiter=",") + + # Apply sub-selection if specified + if data is not None and start_indices is not None and counts is not None: + slices = tuple(slice(start, start + count) for start, count in zip(start_indices, counts)) + return data[slices] return data except Exception as e: logging.debug(f"Failed to read CSV: {e}") @@ -1243,6 +1307,7 @@ def write_array( target: Union[str, BytesIO, Any], array: Union[list, np.ndarray], path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, **kwargs, ) -> bool: """Write array to CSV file.""" @@ -1256,11 +1321,15 @@ def write_array( return False def get_array_metadata( - self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, ) -> Optional[Union[dict, List[dict]]]: - """Get metadata for CSV file.""" + """Get metadata for CSV file with optional sub-selection.""" try: - data = self.read_array(source, path_in_external_file) + data = self.read_array(source, path_in_external_file, start_indices, counts) if data is not None: return { "path": path_in_external_file or "", diff --git a/energyml-utils/src/energyml/utils/data/helper.py b/energyml-utils/src/energyml/utils/data/helper.py index 9ebde1d..c5ab57b 100644 --- a/energyml-utils/src/energyml/utils/data/helper.py +++ b/energyml-utils/src/energyml/utils/data/helper.py @@ -9,18 +9,20 @@ import numpy as np from .datasets_io import read_external_dataset_array -from ..constants import flatten_concatenation +from ..constants import flatten_concatenation, path_last_attribute from ..exception import ObjectNotFoundNotError -from ..introspection import ( +from energyml.utils.introspection import ( get_obj_uri, snake_case, get_object_attribute_no_verif, search_attribute_matching_name_with_path, search_attribute_matching_name, + search_attribute_matching_type, search_attribute_in_upper_matching_name, get_obj_uuid, get_object_attribute, get_object_attribute_rgx, + get_object_attribute_advanced, ) from .datasets_io import get_path_in_external_with_path @@ -335,6 +337,60 @@ def get_not_supported_array(): return [x for x in _ARRAY_NAMES_ if get_array_reader_function(_array_name_mapping(x)) is None] +def _extract_external_data_array_part_params( + obj: Any, +) -> tuple[Optional[List[int]], Optional[List[int]], Optional[str]]: + """ + Extract array parameters (Count, StartIndex, URI) from an object. + Uses regex to match various attribute name formats (snake_case, PascalCase). + + Args: + obj: The object to extract parameters from (ExternalDataArrayPart or parent object) + + Returns: + Tuple of (start_indices, counts, external_uri) + """ + start_indices = None + counts = None + external_uri = None + + # Extract StartIndex using regex (matches: StartIndex, start_index, startIndex) + start_attr = get_object_attribute_rgx(obj, "[Ss]tart[_]?[Ii]ndex") + if start_attr is not None: + if isinstance(start_attr, list): + start_indices = start_attr + elif isinstance(start_attr, (int, float)): + start_indices = [int(start_attr)] + elif hasattr(start_attr, "value"): + if isinstance(start_attr.value, list): + start_indices = start_attr.value + elif isinstance(start_attr.value, (int, float)): + start_indices = [int(start_attr.value)] + + # Extract Count using regex (matches: Count, count, NodeCount, node_count) + count_attr = get_object_attribute_rgx(obj, "([Nn]ode[_]?)?[Cc]ount") + if count_attr is not None: + if isinstance(count_attr, list): + counts = count_attr + elif isinstance(count_attr, (int, float)): + counts = [int(count_attr)] + elif hasattr(count_attr, "value"): + if isinstance(count_attr.value, list): + counts = count_attr.value + elif isinstance(count_attr.value, (int, float)): + counts = [int(count_attr.value)] + + # Extract URI using regex (matches: URI, uri) + uri_attr = get_object_attribute_rgx(obj, "[Uu][Rr][Ii]") + if uri_attr is not None: + if isinstance(uri_attr, str): + external_uri = uri_attr + elif hasattr(uri_attr, "value") and isinstance(uri_attr.value, str): + external_uri = uri_attr.value + + return start_indices, counts, external_uri + + def read_external_array( energyml_array: Any, root_obj: Optional[Any] = None, @@ -344,32 +400,75 @@ def read_external_array( ) -> Optional[Union[List[Any], np.ndarray]]: """ Read an external array (BooleanExternalArray, BooleanHdf5Array, DoubleHdf5Array, IntegerHdf5Array, StringExternalArray ...) + Automatically handles RESQML v2.2 (multiple ExternalDataArrayPart with individual parameters) + and RESQML v2.0.1 (count from parent object). + :param energyml_array: :param root_obj: :param path_in_root: :param workspace: + :param sub_indices: :return: """ array = None if workspace is not None: - # array = workspace.read_external_array( - # energyml_array=energyml_array, - # root_obj=root_obj, - # path_in_root=path_in_root, - # ) crs = get_crs_obj( context_obj=root_obj, root_obj=root_obj, path_in_root=path_in_root, workspace=workspace, ) - pief_list = get_path_in_external_with_path(obj=energyml_array) - # empty array - array = None - for pief_path_in_obj, pief in pief_list: - arr = workspace.read_array(proxy=crs or root_obj, path_in_external=pief) - if arr is not None: - array = arr if array is None else np.concatenate((array, arr)) + + # Search for ExternalDataArrayPart type objects (RESQML v2.2) + external_parts = search_attribute_matching_type( + energyml_array, "ExternalDataArrayPart", return_self=False, deep_search=True + ) + + if external_parts and len(external_parts) > 0: + # RESQML v2.2: Loop over each ExternalDataArrayPart + # Each part has its own start/count/uri and path_in_external + for ext_part in external_parts: + start_indices, counts, external_uri = _extract_external_data_array_part_params(ext_part) + pief_list = get_path_in_external_with_path(obj=ext_part) + + for pief_path_in_obj, pief in pief_list: + arr = workspace.read_array( + proxy=crs or root_obj, + path_in_external=pief, + start_indices=start_indices, + counts=counts, + external_uri=external_uri, + ) + if arr is not None: + array = arr if array is None else np.concatenate((array, arr)) + else: + # RESQML v2.0.1: Extract count from parent object, no StartIndex or URI + counts = None + if path_in_root and root_obj: + last_attr = path_last_attribute(path_in_root) + if last_attr: + parent_path = path_in_root[: path_in_root.rfind("." + last_attr)] + if parent_path: + try: + parent_obj = get_object_attribute_advanced(root_obj, parent_path) + if parent_obj: + # Extract count from parent using simplified function + _, counts, _ = _extract_external_data_array_part_params(parent_obj) + except Exception as e: + logging.debug(f"Failed to extract count from parent: {e}") + + # Read array using path_in_external from the array object itself + pief_list = get_path_in_external_with_path(obj=energyml_array) + for pief_path_in_obj, pief in pief_list: + arr = workspace.read_array( + proxy=crs or root_obj, + path_in_external=pief, + start_indices=None, + counts=counts, + external_uri=None, + ) + if arr is not None: + array = arr if array is None else np.concatenate((array, arr)) else: array = read_external_dataset_array( diff --git a/energyml-utils/src/energyml/utils/data/model.py b/energyml-utils/src/energyml/utils/data/model.py index 2844ce7..37a8453 100644 --- a/energyml-utils/src/energyml/utils/data/model.py +++ b/energyml-utils/src/energyml/utils/data/model.py @@ -29,21 +29,29 @@ class ExternalArrayHandler(ABC): - Format-agnostic interface - Support for file paths, BytesIO, or already-opened file handles - Metadata queries without loading full arrays + - Support for sub-array selection via start_indices and counts (RESQML v2.2) """ @abstractmethod def read_array( - self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, ) -> Optional[np.ndarray]: """ - Read array data from external storage. + Read array data from external storage with optional sub-selection. Args: source: File path, BytesIO, or already-opened file handle path_in_external_file: Path/dataset name within the file (format-specific) + start_indices: Optional start index for each dimension (RESQML v2.2 StartIndex) + counts: Optional count of elements for each dimension (RESQML v2.2 Count) Returns: - Numpy array if successful, None otherwise + Numpy array if successful, None otherwise. If start_indices and counts are + provided, returns the sub-selected portion of the array. """ pass @@ -53,15 +61,17 @@ def write_array( target: Union[str, BytesIO, Any], array: Union[list, np.ndarray], path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, **kwargs, ) -> bool: """ - Write array data to external storage. + Write array data to external storage with optional offset. Args: target: File path, BytesIO, or already-opened file handle array: Data to write path_in_external_file: Path/dataset name within the file (format-specific) + start_indices: Optional start index for each dimension for partial writes **kwargs: Additional format-specific parameters Returns: @@ -71,7 +81,11 @@ def write_array( @abstractmethod def get_array_metadata( - self, source: Union[BytesIO, str, Any], path_in_external_file: Optional[str] = None + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, ) -> Optional[Union[dict, List[dict]]]: """ Get metadata about arrays in external storage without loading the data. @@ -79,11 +93,14 @@ def get_array_metadata( Args: source: File path, BytesIO, or already-opened file handle path_in_external_file: Specific array path, or None to get all arrays + start_indices: Optional start index for each dimension + counts: Optional count of elements for each dimension Returns: - Dict with keys: 'path', 'dtype', 'shape', 'size' for single array - List of such dicts if path_in_external_file is None - None if not found or error + Dict with keys: 'path', 'dtype', 'shape', 'size' for single array. + If start_indices and counts provided, 'shape' reflects the sub-selection. + List of such dicts if path_in_external_file is None. + None if not found or error. """ pass diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index bf8973a..06767b8 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -1702,7 +1702,14 @@ def delete_object(self, identifier: Union[str, Uri, Any]) -> bool: self._metadata_mgr.remove_metadata(_id) return True - def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Optional[np.ndarray]: + def read_array( + self, + proxy: Union[str, Uri, Any], + path_in_external: str, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, + external_uri: Optional[str] = None, + ) -> Optional[np.ndarray]: """ Read a dataset from an external file (HDF5, Parquet, CSV, etc.) linked to the proxy object. @@ -1711,18 +1718,30 @@ def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Opti 2. Tries all possible file paths 3. Automatically selects the correct reader based on file extension 4. Adds successfully opened files to cache + 5. Supports RESQML v2.2 sub-array selection via start_indices and counts Args: proxy: The object, its identifier, or URI path_in_external: Path/dataset name within the external file + start_indices: Optional start index for each dimension (auto-extracted from proxy if not provided) + counts: Optional count of elements for each dimension (auto-extracted from proxy if not provided) + external_uri: Optional URI to override file path resolution (auto-extracted from proxy if not provided) Returns: - Numpy array if successful, None otherwise + Numpy array if successful, None otherwise. Returns sub-selected portion if start_indices/counts provided. """ # Get possible file paths for this object file_paths = [] - if self.force_h5_path is not None: + if external_uri is not None: + # Use external_uri if provided (RESQML v2.2) + # May need to resolve relative to EPC folder + epc_folder = os.path.dirname(self.epc_file_path) if self.epc_file_path else "." + if os.path.isabs(external_uri): + file_paths = [external_uri] + else: + file_paths = [os.path.join(epc_folder, external_uri), external_uri] + elif self.force_h5_path is not None: # Use forced path if specified file_paths = [self.force_h5_path] else: @@ -1748,8 +1767,8 @@ def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Opti # Get cached file handle file_handle = self._file_cache.get_or_open(file_path, handler, mode="r") if file_handle is not None: - # Try to read from cached handle - result = handler.read_array(file_handle, path_in_external) + # Try to read from cached handle with sub-selection + result = handler.read_array(file_handle, path_in_external, start_indices, counts) if result is not None: return result except Exception as e: @@ -1768,12 +1787,12 @@ def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Opti # Try to open and read, which will add to cache if successful file_handle = self._file_cache.get_or_open(file_path, handler, mode="r") if file_handle is not None: - result = handler.read_array(file_handle, path_in_external) + result = handler.read_array(file_handle, path_in_external, start_indices, counts) if result is not None: return result else: # Cache failed, try direct read without caching - result = handler.read_array(file_path, path_in_external) + result = handler.read_array(file_path, path_in_external, start_indices, counts) if result is not None: return result except Exception as e: @@ -1782,16 +1801,27 @@ def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Opti logging.error(f"Failed to read array from any available file paths: {file_paths}") return None - def write_array(self, proxy: Union[str, Uri, Any], path_in_external: str, array: np.ndarray, **kwargs) -> bool: + def write_array( + self, + proxy: Union[str, Uri, Any], + path_in_external: str, + array: np.ndarray, + start_indices: Optional[List[int]] = None, + external_uri: Optional[str] = None, + **kwargs, + ) -> bool: """ Write a dataset to an external file (HDF5, Parquet, CSV, etc.) linked to the proxy object. Uses the same caching mechanism as read_array for efficiency. + Supports RESQML v2.2 partial writes via start_indices. Args: proxy: The object, its identifier, or URI path_in_external: Path/dataset name within the external file array: Numpy array to write + start_indices: Optional start index for each dimension for partial writes + external_uri: Optional URI to override file path resolution **kwargs: Additional format-specific parameters (e.g., dtype for HDF5, column_titles for Parquet) Returns: @@ -1800,7 +1830,14 @@ def write_array(self, proxy: Union[str, Uri, Any], path_in_external: str, array: # Get possible file paths for this object file_paths = [] - if self.force_h5_path is not None: + if external_uri is not None: + # Use external_uri if provided (RESQML v2.2) + epc_folder = os.path.dirname(self.epc_file_path) if self.epc_file_path else "." + if os.path.isabs(external_uri): + file_paths = [external_uri] + else: + file_paths = [os.path.join(epc_folder, external_uri), external_uri] + elif self.force_h5_path is not None: # Use forced path if specified file_paths = [self.force_h5_path] else: @@ -1825,7 +1862,7 @@ def write_array(self, proxy: Union[str, Uri, Any], path_in_external: str, array: try: file_handle = self._file_cache.get_or_open(file_path, handler, mode="a") if file_handle is not None: - success = handler.write_array(file_handle, array, path_in_external, **kwargs) + success = handler.write_array(file_handle, array, path_in_external, start_indices, **kwargs) if success: return True except Exception as e: @@ -1842,12 +1879,12 @@ def write_array(self, proxy: Union[str, Uri, Any], path_in_external: str, array: # Open in append mode and add to cache file_handle = self._file_cache.get_or_open(file_path, handler, mode="a") if file_handle is not None: - success = handler.write_array(file_handle, array, path_in_external, **kwargs) + success = handler.write_array(file_handle, array, path_in_external, start_indices, **kwargs) if success: return True else: # Cache failed, try direct write - success = handler.write_array(file_path, array, path_in_external, **kwargs) + success = handler.write_array(file_path, array, path_in_external, start_indices, **kwargs) if success: return True except Exception as e: @@ -1856,18 +1893,26 @@ def write_array(self, proxy: Union[str, Uri, Any], path_in_external: str, array: return False def get_array_metadata( - self, proxy: Union[str, Uri, Any], path_in_external: Optional[str] = None + self, + proxy: Union[str, Uri, Any], + path_in_external: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, ) -> Union[DataArrayMetadata, List[DataArrayMetadata], None]: """ Get metadata for data array(s) without loading the full array data. + Supports RESQML v2.2 sub-array selection metadata. Args: proxy: The object, its identifier, or URI path_in_external: Optional specific array path. If None, returns metadata for all arrays. + start_indices: Optional start index for each dimension (auto-extracted from proxy if not provided) + counts: Optional count of elements for each dimension (auto-extracted from proxy if not provided). + When provided, the returned dimensions will reflect the sub-selected size. Returns: DataArrayMetadata if path specified, List[DataArrayMetadata] if no path, - or None if not found + or None if not found. The dimensions field reflects the sub-selection when counts provided. """ # Get possible file paths for this object file_paths = [] @@ -1894,7 +1939,7 @@ def get_array_metadata( file_handle = self._file_cache.get_or_open(file_path, handler, mode="r") source = file_handle if file_handle is not None else file_path - metadata_dict = handler.get_array_metadata(source, path_in_external) + metadata_dict = handler.get_array_metadata(source, path_in_external, start_indices, counts) if metadata_dict is None: continue @@ -1906,6 +1951,7 @@ def get_array_metadata( path_in_resource=m.get("path"), array_type=m.get("dtype", "unknown"), dimensions=m.get("shape", []), + start_indices=start_indices, custom_data={"size": m.get("size", 0)}, ) for m in metadata_dict @@ -1915,6 +1961,7 @@ def get_array_metadata( path_in_resource=metadata_dict.get("path"), array_type=metadata_dict.get("dtype", "unknown"), dimensions=metadata_dict.get("shape", []), + start_indices=start_indices, custom_data={"size": metadata_dict.get("size", 0)}, ) except Exception as e: diff --git a/energyml-utils/src/energyml/utils/storage_interface.py b/energyml-utils/src/energyml/utils/storage_interface.py index 45008d4..5994597 100644 --- a/energyml-utils/src/energyml/utils/storage_interface.py +++ b/energyml-utils/src/energyml/utils/storage_interface.py @@ -136,17 +136,30 @@ class DataArrayMetadata: Metadata for a data array in an energyml object. This provides information about arrays stored in HDF5 or other external storage, - similar to ETP DataArrayMetadata. + similar to ETP DataArrayMetadata. Supports RESQML v2.2 ExternalDataArrayPart attributes. + + The dimensions field represents the shape of the array that would be returned: + - For full arrays: the complete array dimensions from the external file + - For sub-selections: the size of the selected portion (determined by start_indices + counts) """ path_in_resource: Optional[str] - """Path to the array within the HDF5 file""" + """Path to the array within the HDF5 file (PathInExternalFile)""" array_type: str """Data type of the array (e.g., 'double', 'int', 'string')""" dimensions: List[int] - """Array dimensions/shape""" + """Array dimensions/shape. For sub-selections, this reflects the selected portion size.""" + + start_indices: Optional[List[int]] = None + """Start index for each dimension (RESQML v2.2 StartIndex). If None, starts at 0.""" + + external_uri: Optional[str] = None + """URI where the DataArrayPart is stored (RESQML v2.2 URI). Can override default file path.""" + + mime_type: Optional[str] = None + """MIME type of the external file (RESQML v2.2 MimeType)""" custom_data: Dict[str, Any] = field(default_factory=dict) """Additional custom metadata""" @@ -267,16 +280,27 @@ def delete_object(self, identifier: Union[str, Uri]) -> bool: pass @abstractmethod - def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Optional[np.ndarray]: + def read_array( + self, + proxy: Union[str, Uri, Any], + path_in_external: str, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, + external_uri: Optional[str] = None, + ) -> Optional[np.ndarray]: """ - Read a data array from external storage (HDF5). + Read a data array from external storage (HDF5) with optional sub-selection. Args: proxy: The object identifier/URI or the object itself that references the array path_in_external: Path within the HDF5 file (e.g., 'values/0') + start_indices: Optional start index for each dimension (RESQML v2.2 StartIndex) + counts: Optional count of elements for each dimension (RESQML v2.2 Count) + external_uri: Optional URI to override default file path (RESQML v2.2 URI) Returns: - The data array as a numpy array, or None if not found + The data array as a numpy array, or None if not found. + If start_indices and counts are provided, returns the sub-selected portion. """ pass @@ -286,14 +310,20 @@ def write_array( proxy: Union[str, Uri, Any], path_in_external: str, array: np.ndarray, + start_indices: Optional[List[int]] = None, + external_uri: Optional[str] = None, + **kwargs, ) -> bool: """ - Write a data array to external storage (HDF5). + Write a data array to external storage (HDF5) with optional offset. Args: proxy: The object identifier/URI or the object itself that references the array path_in_external: Path within the HDF5 file (e.g., 'values/0') array: The numpy array to write + start_indices: Optional start index for each dimension for partial writes + external_uri: Optional URI to override default file path (RESQML v2.2 URI) + **kwargs: Additional format-specific parameters Returns: True if successfully written, False otherwise @@ -302,18 +332,25 @@ def write_array( @abstractmethod def get_array_metadata( - self, proxy: Union[str, Uri, Any], path_in_external: Optional[str] = None + self, + proxy: Union[str, Uri, Any], + path_in_external: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, ) -> Union[DataArrayMetadata, List[DataArrayMetadata], None]: """ - Get metadata for data array(s). + Get metadata for data array(s) with optional sub-selection. Args: proxy: The object identifier/URI or the object itself that references the array path_in_external: Optional specific path. If None, returns all array metadata for the object + start_indices: Optional start index for each dimension (RESQML v2.2 StartIndex) + counts: Optional count of elements for each dimension (RESQML v2.2 Count). + When provided, the returned dimensions will reflect the sub-selected size. Returns: DataArrayMetadata if path specified, List[DataArrayMetadata] if no path, - or None if not found + or None if not found. The dimensions field reflects sub-selection when counts provided. """ pass From 12c15c7e4c6ab59dd63a06925a34a568cb096e0c Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Thu, 12 Feb 2026 02:46:32 +0100 Subject: [PATCH 23/70] working with seg-y and las --- energyml-utils/example/main_stream_sample.py | 406 +++++++++++++- energyml-utils/pyproject.toml | 4 + .../src/energyml/utils/data/datasets_io.py | 507 +++++++++++++++++- 3 files changed, 901 insertions(+), 16 deletions(-) diff --git a/energyml-utils/example/main_stream_sample.py b/energyml-utils/example/main_stream_sample.py index bdaf5c0..8ed39fe 100644 --- a/energyml-utils/example/main_stream_sample.py +++ b/energyml-utils/example/main_stream_sample.py @@ -21,11 +21,15 @@ from energyml.utils.constants import EPCRelsRelationshipType, MimeType from energyml.opc.opc import Relationship +from energyml.utils.data.datasets_io import get_handler_registry import numpy as np CONST_H5_PATH = "external_data.h5" CONST_CSV_PATH = "external_data.csv" +CONST_PARQUET_PATH = "external_data.parquet" +CONST_LAS_PATH = "external_data.las" +CONST_SEGY_PATH = "external_data.sgy" def sample_objects(): @@ -133,6 +137,22 @@ def test_create_epc(path: str): # delete file if exists if os.path.exists(path): os.remove(path) + + # Calculate the EPC directory for cleanup + epc_dir = os.path.dirname(path) if os.path.dirname(path) else "." + + # Clean up old external files if they exist (to avoid stale data) + for old_file in [ + os.path.join(epc_dir, CONST_H5_PATH), + os.path.join(epc_dir, CONST_CSV_PATH), + os.path.join(epc_dir, CONST_PARQUET_PATH), + os.path.join(epc_dir, CONST_LAS_PATH), + os.path.join(epc_dir, CONST_SEGY_PATH), + ]: + if os.path.exists(old_file): + os.remove(old_file) + logging.info(f"Cleaned up old external file: {old_file}") + logging.info(f"==> Creating new EPC at {path}...") epc = EpcStreamReader(epc_file_path=path, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) @@ -230,45 +250,411 @@ def test_create_epc_v2(path: str): def test_create_epc_v3_with_different_external_files(path: str): + # Define interesting test arrays with edge cases: 2D arrays with null values, zeros, negatives, special values + # HDF5 test array: Integer triangles with zeros and varied values (2D: 3 triangles x 3 vertices) + h5_triangles = np.array([[0, 1, 2], [2, 3, 0], [-1, 4, 5]], dtype=np.int32) # Including negative value and zero + + # CSV test array: 3D coordinates with NaN values (2D: 5 points x 3 coords) + csv_points = np.array( + [ + [0.0, 0.0, 0.0], + [1.0, np.nan, 0.0], # NaN value + [1.0, 1.0, 0.0], + [0.0, 1.0, np.nan], # Another NaN + [0.5, 0.5, -1.5], # Negative value + ], + dtype=np.float32, + ) + + # Parquet test array: Normals with special float values (2D: 4 points x 3 components) + parquet_normals = np.array( + [ + [0.0, 0.0, 1.0], + [np.inf, 0.0, 0.0], # Positive infinity + [-np.inf, 0.0, 0.0], # Negative infinity + [0.0, np.nan, 1.0], # NaN value + ], + dtype=np.float32, + ) + + # LAS test array: Well log data with null values (2D: 10 depth points x 3 curves) + las_well_log = np.array( + [ + [1000.0, 75.5, 2.35], + [1001.0, 80.2, 2.40], + [1002.0, np.nan, 2.38], # Missing GR value + [1003.0, 85.1, np.nan], # Missing RHOB value + [1004.0, 90.0, 2.42], + [1005.0, 0.0, 2.45], # Zero GR (valid but unusual) + [1006.0, 95.5, 2.50], + [1007.0, np.nan, np.nan], # Multiple nulls + [1008.0, 100.0, 2.55], + [1009.0, -10.5, 2.60], # Negative value (calibration artifact) + ], + dtype=np.float32, + ) + + # SEG-Y test array: Seismic traces with various edge cases (2D: 5 traces x 8 samples) + segy_seismic = np.array( + [ + [0.0, 0.5, 1.0, 0.5, 0.0, -0.5, -1.0, -0.5], + [1.0, 0.8, 0.6, 0.4, 0.2, 0.0, -0.2, -0.4], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], # Silent trace (all zeros) + [-1.0, -0.8, -0.6, -0.4, -0.2, 0.0, 0.2, 0.4], + [np.nan, 0.1, 0.2, 0.3, np.nan, 0.5, 0.6, np.nan], # Traces with NaN (dead traces) + ], + dtype=np.float32, + ) if os.path.exists(path): os.remove(path) + + # Calculate the EPC directory + epc_dir = os.path.dirname(path) if os.path.dirname(path) else "." + + # Clean up old external files if they exist (to avoid stale data) + for old_file in [ + os.path.join(epc_dir, CONST_H5_PATH), + os.path.join(epc_dir, CONST_CSV_PATH), + os.path.join(epc_dir, CONST_PARQUET_PATH), + os.path.join(epc_dir, CONST_LAS_PATH), + os.path.join(epc_dir, CONST_SEGY_PATH), + ]: + if os.path.exists(old_file): + os.remove(old_file) + logging.info(f"Cleaned up old external file: {old_file}") + logging.info(f"==> Creating new EPC at {path}...") epc = EpcStreamReader(epc_file_path=path, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) data = sample_objects() epc.add_object(data["bf"]) - # epc.add_object(data["bfi"]) epc.add_object(data["horizon_interp"]) tr_set_id = epc.add_object(data["trset"]) hi_rels = epc.get_obj_rels(data["horizon_interp"]) - logging.info(f"Horizon interpretation rels: {hi_rels}") - # Create an h5 file + # ========== HDF5 Test ========== + logging.info("\n" + "=" * 60) + logging.info("==> Testing HDF5 format...") h5_file_path = "wip/notARealFile.h5" + h5_path_in_external = f"/RESQML/{tr_set_id}/triangles" epc.add_rels_for_object( tr_set_id, relationships=[Relationship(type_value=str(EPCRelsRelationshipType.EXTERNAL_RESOURCE), target=h5_file_path)], ) - epc.write_array( proxy=tr_set_id, - path_in_external=f"/RESQML/{tr_set_id}/triangles", - array=np.array([0, 1, 2, 2, 3, 0], dtype=np.int32), + path_in_external=h5_path_in_external, + array=h5_triangles, external_uri=CONST_H5_PATH, ) + logging.info(f"Written HDF5 array shape: {h5_triangles.shape}, dtype: {h5_triangles.dtype}") + logging.info(f"HDF5 test array content:\n{h5_triangles}") + # ========== CSV Test ========== + logging.info("\n" + "=" * 60) + logging.info("==> Testing CSV format...") + csv_path_in_external = f"/RESQML/{tr_set_id}/points" epc.write_array( proxy=tr_set_id, - path_in_external=f"/RESQML/{tr_set_id}/points", - array=np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], dtype=np.float32), + path_in_external=csv_path_in_external, + array=csv_points, external_uri=CONST_CSV_PATH, ) - - # Create an + logging.info(f"Written CSV array shape: {csv_points.shape}, dtype: {csv_points.dtype}") + logging.info(f"CSV test array content:\n{csv_points}") + + # ========== Parquet Test ========== + logging.info("\n" + "=" * 60) + logging.info("==> Testing Parquet format...") + parquet_file_path = "wip/test_data.parquet" + parquet_path_in_external = f"/RESQML/{tr_set_id}/normals" + epc.add_rels_for_object( + tr_set_id, + relationships=[ + Relationship(type_value=str(EPCRelsRelationshipType.EXTERNAL_RESOURCE), target=parquet_file_path) + ], + ) + epc.write_array( + proxy=tr_set_id, + path_in_external=parquet_path_in_external, + array=parquet_normals, + external_uri=CONST_PARQUET_PATH, + ) + logging.info(f"Written Parquet array shape: {parquet_normals.shape}, dtype: {parquet_normals.dtype}") + logging.info(f"Parquet test array content:\n{parquet_normals}") + + # ========== LAS Test ========== + logging.info("\n" + "=" * 60) + logging.info("==> Testing LAS format...") + las_file_path = "wip/test_well_log.las" + las_path_in_external = "DEPTH,GR,RHOB" # LAS mnemonics + epc.add_rels_for_object( + tr_set_id, + relationships=[Relationship(type_value=str(EPCRelsRelationshipType.EXTERNAL_RESOURCE), target=las_file_path)], + ) + epc.write_array( + proxy=tr_set_id, + path_in_external=las_path_in_external, + array=las_well_log, + external_uri=CONST_LAS_PATH, + ) + logging.info(f"Written LAS array shape: {las_well_log.shape}, dtype: {las_well_log.dtype}") + logging.info(f"LAS test array content:\n{las_well_log}") + + # ========== SEG-Y Test ========== + logging.info("\n" + "=" * 60) + logging.info("==> Testing SEG-Y format...") + segy_file_path = "wip/test_seismic.sgy" + segy_path_in_external = "traces" # SEG-Y standard path + epc.add_rels_for_object( + tr_set_id, + relationships=[Relationship(type_value=str(EPCRelsRelationshipType.EXTERNAL_RESOURCE), target=segy_file_path)], + ) + epc.write_array( + proxy=tr_set_id, + path_in_external=segy_path_in_external, + array=segy_seismic, + external_uri=CONST_SEGY_PATH, + ) + logging.info(f"Written SEG-Y array shape: {segy_seismic.shape}, dtype: {segy_seismic.dtype}") + logging.info(f"SEG-Y test array content:\n{segy_seismic}") + + logging.info("\n" + "=" * 60) + logging.info("==> Successfully wrote data to all supported file formats:") + logging.info(f" - HDF5: {CONST_H5_PATH}") + logging.info(f" - CSV: {CONST_CSV_PATH}") + logging.info(f" - Parquet: {CONST_PARQUET_PATH}") + logging.info(f" - LAS: {CONST_LAS_PATH}") + logging.info(f" - SEG-Y: {CONST_SEGY_PATH}") + + # ========== Read Back and Verify ========== + logging.info("\n" + "#" * 60) + logging.info("### VERIFICATION: Reading back arrays and comparing ###") + logging.info("#" * 60) + + registry = get_handler_registry() + verification_passed = True + + # Construct full paths to external files (relative to EPC location) + h5_full_path = os.path.join(epc_dir, CONST_H5_PATH) + csv_full_path = os.path.join(epc_dir, CONST_CSV_PATH) + parquet_full_path = os.path.join(epc_dir, CONST_PARQUET_PATH) + las_full_path = os.path.join(epc_dir, CONST_LAS_PATH) + segy_full_path = os.path.join(epc_dir, CONST_SEGY_PATH) + + logging.info(f"Reading files from EPC directory: {epc_dir}") + logging.info(f" - HDF5: {h5_full_path}") + logging.info(f" - CSV: {csv_full_path}") + logging.info(f" - Parquet: {parquet_full_path}") + logging.info(f" - LAS: {las_full_path}") + logging.info(f" - SEG-Y: {segy_full_path}") + + def arrays_equal(arr1, arr2, name): + """Compare two arrays handling NaN, inf, and other special values.""" + try: + # Check shapes first + if arr1.shape != arr2.shape: + logging.error(f"[{name}] Shape mismatch: {arr1.shape} != {arr2.shape}") + # Try to reshape if total size matches + if arr1.size == arr2.size: + logging.warning(f"[{name}] Arrays have same total size ({arr1.size}), attempting reshape...") + try: + arr2_reshaped = arr2.reshape(arr1.shape) + logging.info(f"[{name}] Reshape successful, comparing reshaped arrays...") + return arrays_equal(arr1, arr2_reshaped, name + " (reshaped)") + except Exception as reshape_err: + logging.error(f"[{name}] Reshape failed: {reshape_err}") + return False + + # Check dtypes + if arr1.dtype != arr2.dtype: + logging.warning( + f"[{name}] Dtype difference: {arr1.dtype} != {arr2.dtype} (attempting comparison anyway)" + ) + + # Use numpy's array_equal which handles NaN properly with equal_nan=True + are_equal = np.array_equal(arr1, arr2, equal_nan=True) + + if not are_equal: + # Provide detailed difference information + try: + diff_mask = ~np.isclose(arr1, arr2, equal_nan=True, rtol=1e-5, atol=1e-8) + n_diff = np.sum(diff_mask) + + if n_diff == 0: + # Arrays are actually equal (dtype conversion issue) + logging.info(f"[{name}] Arrays are equal (dtype conversion handled)") + return True + + logging.error(f"[{name}] Arrays differ in {n_diff} elements") + if n_diff < 20: # Only show details if not too many differences + logging.error( + f"[{name}] Differences:\nExpected:\n{arr1[diff_mask]}\nActual:\n{arr2[diff_mask]}" + ) + except Exception as diff_err: + logging.error(f"[{name}] Could not compute differences: {diff_err}") + return False + + return True + except Exception as e: + logging.error(f"[{name}] Comparison error: {e}") + return False + + # --- HDF5 Verification --- + logging.info("\n" + "=" * 60) + logging.info("==> Verifying HDF5 format...") + h5_handler = registry.get_handler_for_file(h5_full_path) + if h5_handler: + # Get metadata + h5_metadata = h5_handler.get_array_metadata(h5_full_path, h5_path_in_external) + logging.info(f"HDF5 Metadata: {h5_metadata}") + + # Read back + h5_read_back = h5_handler.read_array(h5_full_path, h5_path_in_external) + if h5_read_back is not None: + logging.info(f"Read back HDF5 array shape: {h5_read_back.shape}, dtype: {h5_read_back.dtype}") + logging.info(f"Read back HDF5 content:\n{h5_read_back}") + + # Verify + if arrays_equal(h5_triangles, h5_read_back, "HDF5"): + logging.info("✓ HDF5 verification PASSED") + else: + logging.error("✗ HDF5 verification FAILED") + verification_passed = False + else: + logging.error("✗ HDF5 read returned None") + verification_passed = False + else: + logging.error("✗ HDF5 handler not available") + verification_passed = False + + # --- CSV Verification --- + logging.info("\n" + "=" * 60) + logging.info("==> Verifying CSV format...") + csv_handler = registry.get_handler_for_file(csv_full_path) + if csv_handler: + # Get metadata + csv_metadata = csv_handler.get_array_metadata(csv_full_path) + logging.info(f"CSV Metadata: {csv_metadata}") + + # Read back + csv_read_back = csv_handler.read_array(csv_full_path) + if csv_read_back is not None: + logging.info(f"Read back CSV array shape: {csv_read_back.shape}, dtype: {csv_read_back.dtype}") + logging.info(f"Read back CSV content:\n{csv_read_back}") + + # Verify + if arrays_equal(csv_points, csv_read_back, "CSV"): + logging.info("✓ CSV verification PASSED") + else: + logging.error("✗ CSV verification FAILED") + verification_passed = False + else: + logging.error("✗ CSV read returned None") + verification_passed = False + else: + logging.error("✗ CSV handler not available") + verification_passed = False + + # --- Parquet Verification --- + logging.info("\n" + "=" * 60) + logging.info("==> Verifying Parquet format...") + parquet_handler = registry.get_handler_for_file(parquet_full_path) + if parquet_handler: + # Get metadata + parquet_metadata = parquet_handler.get_array_metadata(parquet_full_path) + logging.info(f"Parquet Metadata: {parquet_metadata}") + + # Read back + parquet_read_back = parquet_handler.read_array(parquet_full_path) + if parquet_read_back is not None: + logging.info(f"Read back Parquet array shape: {parquet_read_back.shape}, dtype: {parquet_read_back.dtype}") + logging.info(f"Read back Parquet content:\n{parquet_read_back}") + + # Verify + if arrays_equal(parquet_normals, parquet_read_back, "Parquet"): + logging.info("✓ Parquet verification PASSED") + else: + logging.error("✗ Parquet verification FAILED") + verification_passed = False + else: + logging.error("✗ Parquet read returned None") + verification_passed = False + else: + logging.error("✗ Parquet handler not available") + verification_passed = False + + # --- LAS Verification --- + logging.info("\n" + "=" * 60) + logging.info("==> Verifying LAS format...") + las_handler = registry.get_handler_for_file(las_full_path) + if las_handler: + # Get metadata + las_metadata = las_handler.get_array_metadata(las_full_path) + logging.info(f"LAS Metadata: {las_metadata}") + + # Read back + las_read_back = las_handler.read_array(las_full_path, las_path_in_external) + if las_read_back is not None: + logging.info(f"Read back LAS array shape: {las_read_back.shape}, dtype: {las_read_back.dtype}") + logging.info(f"Read back LAS content:\n{las_read_back}") + + # Verify + if arrays_equal(las_well_log, las_read_back, "LAS"): + logging.info("✓ LAS verification PASSED") + else: + logging.error("✗ LAS verification FAILED") + verification_passed = False + else: + logging.error("✗ LAS read returned None") + verification_passed = False + else: + logging.error("✗ LAS handler not available") + verification_passed = False + + # --- SEG-Y Verification --- + logging.info("\n" + "=" * 60) + logging.info("==> Verifying SEG-Y format...") + segy_handler = registry.get_handler_for_file(segy_full_path) + if segy_handler: + # Get metadata + segy_metadata = segy_handler.get_array_metadata(segy_full_path) + logging.info(f"SEG-Y Metadata: {segy_metadata}") + + # Read back + segy_read_back = segy_handler.read_array(segy_full_path, segy_path_in_external) + if segy_read_back is not None: + logging.info(f"Read back SEG-Y array shape: {segy_read_back.shape}, dtype: {segy_read_back.dtype}") + logging.info(f"Read back SEG-Y content:\n{segy_read_back}") + + # Verify + if arrays_equal(segy_seismic, segy_read_back, "SEG-Y"): + logging.info("✓ SEG-Y verification PASSED") + else: + logging.error("✗ SEG-Y verification FAILED") + verification_passed = False + else: + logging.error("✗ SEG-Y read returned None") + verification_passed = False + else: + logging.error("✗ SEG-Y handler not available") + verification_passed = False + + # Final summary + logging.info("\n" + "#" * 60) + if verification_passed: + logging.info("### ✓✓✓ ALL VERIFICATIONS PASSED ✓✓✓ ###") + else: + logging.error("### ✗✗✗ SOME VERIFICATIONS FAILED ✗✗✗ ###") + logging.info("#" * 60) + + # Close and verify + epc.close() + logging.info("==> EPC file closed successfully") if __name__ == "__main__": diff --git a/energyml-utils/pyproject.toml b/energyml-utils/pyproject.toml index 4ce977f..07b1b27 100644 --- a/energyml-utils/pyproject.toml +++ b/energyml-utils/pyproject.toml @@ -60,6 +60,8 @@ python_functions = [ "test_*" ] [tool.poetry.extras] parquet = ["pyarrow", "numpy", "pandas"] hdf5 = ["h5py"] +las = ["lasio"] +segy = ["segyio"] [tool.poetry.dependencies] python = "^3.9" @@ -69,6 +71,8 @@ h5py = { version = "^3.7.0", optional = false } pyarrow = { version = "^14.0.1", optional = false } numpy = { version = "^1.16.6", optional = false } flake8 = "^7.3.0" +lasio = { version = "^0.31", optional = true } +segyio = { version = "^1.9", optional = true } [tool.poetry.group.dev.dependencies] pandas = { version = "^1.1.0", optional = false } diff --git a/energyml-utils/src/energyml/utils/data/datasets_io.py b/energyml-utils/src/energyml/utils/data/datasets_io.py index fe33bb3..cbdce63 100644 --- a/energyml-utils/src/energyml/utils/data/datasets_io.py +++ b/energyml-utils/src/energyml/utils/data/datasets_io.py @@ -51,6 +51,22 @@ except Exception: __PARQUET_MODULE_EXISTS__ = False +try: + import lasio + + __LASIO_MODULE_EXISTS__ = True +except Exception: + lasio = None + __LASIO_MODULE_EXISTS__ = False + +try: + import segyio + + __SEGYIO_MODULE_EXISTS__ = True +except Exception: + segyio = None + __SEGYIO_MODULE_EXISTS__ = False + # HDF5 if __H5PY_MODULE_EXISTS__: @@ -903,6 +919,18 @@ def _register_default_handlers(self) -> None: if __CSV_MODULE_EXISTS__: self.register_handler([".csv", ".txt", ".dat"], lambda: CSVArrayHandler()) + # LAS Handler + if __LASIO_MODULE_EXISTS__: + self.register_handler([".las"], lambda: LASArrayHandler()) + else: + self.register_handler([".las"], lambda: MockLASArrayHandler()) + + # SEG-Y Handler + if __SEGYIO_MODULE_EXISTS__: + self.register_handler([".sgy", ".segy"], lambda: SEGYArrayHandler()) + else: + self.register_handler([".sgy", ".segy"], lambda: MockSEGYArrayHandler()) + def register_handler(self, extensions: List[str], handler_factory: Callable[[], ExternalArrayHandler]) -> None: """ Register a handler factory for given file extensions. @@ -1157,12 +1185,27 @@ def write_array( column_titles = kwargs.get("column_titles") try: - if not isinstance(array[0], (list, np.ndarray, pd.Series)): - array = [array] - - array_as_pd_df = pd.DataFrame( - {k: array[idx] for idx, k in enumerate(column_titles or range(len(array)))} - ) + # Convert to numpy array if needed + if not isinstance(array, np.ndarray): + array = np.array(array) + + # Handle 2D arrays properly: rows as rows, columns as columns + if array.ndim == 2: + # Create DataFrame where each column is a dimension + if column_titles is None: + column_titles = [str(i) for i in range(array.shape[1])] + array_as_pd_df = pd.DataFrame(array, columns=column_titles) + elif array.ndim == 1: + # 1D array becomes a single column + col_name = column_titles[0] if column_titles else "0" + array_as_pd_df = pd.DataFrame({col_name: array}) + else: + # For higher dimensions, flatten or handle as needed + logging.warning(f"Parquet writer received {array.ndim}D array, flattening to 2D") + array_2d = array.reshape(array.shape[0], -1) + if column_titles is None: + column_titles = [str(i) for i in range(array_2d.shape[1])] + array_as_pd_df = pd.DataFrame(array_2d, columns=column_titles) pq.write_table( pa.Table.from_pandas(array_as_pd_df), @@ -1349,3 +1392,455 @@ def can_handle_file(self, file_path: str) -> bool: """Check if this handler can process the file.""" ext = os.path.splitext(file_path)[1].lower() return ext in [".csv", ".txt", ".dat"] + + +# LAS Handler +if __LASIO_MODULE_EXISTS__: + + class LASArrayHandler(ExternalArrayHandler): + """Handler for LAS (Log ASCII Standard) files (.las).""" + + def read_array( + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, + ) -> Optional[np.ndarray]: + """ + Read array from LAS file. + + Args: + source: Path to LAS file or BytesIO object + path_in_external_file: Comma-separated list of mnemonics to read from ~A block + start_indices: Starting index for each dimension (optional) + counts: Number of elements to read for each dimension (optional) + + Returns: + NumPy array with requested curves, or None if reading failed + """ + try: + # Load LAS file + las = lasio.read(source) + + if path_in_external_file is None or path_in_external_file.strip() == "": + # Return all curves as 2D array (depth, curves) + data = las.data + else: + # Parse mnemonic list (comma or semicolon separated) + mnemonics = [m.strip() for m in path_in_external_file.replace(";", ",").split(",")] + + # Extract specified curves + curves_data = [] + for mnemonic in mnemonics: + if mnemonic in las.keys(): + curves_data.append(las[mnemonic]) + else: + logging.warning(f"Mnemonic '{mnemonic}' not found in LAS file") + + if not curves_data: + logging.error("No valid mnemonics found in LAS file") + return None + + # Stack curves horizontally + data = np.column_stack(curves_data) if len(curves_data) > 1 else np.array(curves_data[0]) + + # Apply slicing if specified + if start_indices is not None or counts is not None: + slices = [] + for dim in range(len(data.shape)): + start = start_indices[dim] if start_indices and dim < len(start_indices) else 0 + count = counts[dim] if counts and dim < len(counts) else data.shape[dim] - start + slices.append(slice(start, start + count)) + data = data[tuple(slices)] + + return np.array(data) + + except Exception as e: + logging.error(f"Failed to read LAS file: {e}") + return None + + def write_array( + self, + target: Union[str, BytesIO, Any], + array: Union[list, np.ndarray], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + **kwargs, + ) -> bool: + """ + Write array to LAS file. + + Args: + target: Path to LAS file + array: NumPy array or list to write + path_in_external_file: Comma-separated list of mnemonics for curves + start_indices: Not used for LAS files + **kwargs: Additional parameters (well_name, field, etc.) + + Returns: + True if successful, False otherwise + """ + try: + # Convert to numpy array + if not isinstance(array, np.ndarray): + array = np.array(array) + + # Create new LAS file + las = lasio.LASFile() + + # Set well information from kwargs + if "well_name" in kwargs: + las.well.WELL = kwargs["well_name"] + if "field" in kwargs: + las.well.FLD = kwargs["field"] + if "company" in kwargs: + las.well.COMP = kwargs["company"] + + # Parse mnemonics if provided + mnemonics = None + if path_in_external_file: + mnemonics = [m.strip() for m in path_in_external_file.replace(";", ",").split(",")] + + # Add curves + if array.ndim == 1: + # Single curve + mnemonic = mnemonics[0] if mnemonics else "DATA" + las.append_curve(mnemonic, array, unit=kwargs.get("unit", "")) + else: + # Multiple curves + for i in range(array.shape[1]): + mnemonic = mnemonics[i] if mnemonics and i < len(mnemonics) else f"CURVE{i}" + las.append_curve(mnemonic, array[:, i], unit=kwargs.get("unit", "")) + + # Write to file + if isinstance(target, str): + las.write(target) + else: + # For BytesIO, write to string then encode + las_str = las.write(None) # Returns string + target.write(las_str.encode("utf-8")) + + return True + + except Exception as e: + logging.error(f"Failed to write LAS file: {e}") + return False + + def get_array_metadata( + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, + ) -> Optional[Union[dict, List[dict]]]: + """ + Get metadata for LAS file curves. + + Args: + source: Path to LAS file or BytesIO object + path_in_external_file: Comma-separated list of mnemonics + + Returns: + Dictionary with metadata (shape, dtype, curves, well_info) + """ + try: + las = lasio.read(source) + + # Get curve information + curves_info = [] + for curve in las.curves: + curves_info.append( + { + "mnemonic": curve.mnemonic, + "unit": curve.unit, + "descr": curve.descr, + "data_points": len(curve.data), + } + ) + + # Get overall metadata + metadata = { + "shape": las.data.shape, + "dtype": str(las.data.dtype), + "curves": curves_info, + "well_info": { + "well_name": las.well.WELL.value if hasattr(las.well, "WELL") else None, + "field": las.well.FLD.value if hasattr(las.well, "FLD") else None, + "company": las.well.COMP.value if hasattr(las.well, "COMP") else None, + }, + "version": las.version.VERS.value if hasattr(las.version, "VERS") else None, + } + + return metadata + + except Exception as e: + logging.error(f"Failed to get LAS metadata: {e}") + return None + + def list_arrays(self, source: Union[BytesIO, str, Any]) -> List[str]: + """List all curve mnemonics in LAS file.""" + try: + las = lasio.read(source) + return [curve.mnemonic for curve in las.curves] + except Exception as e: + logging.error(f"Failed to list LAS curves: {e}") + return [] + + def can_handle_file(self, file_path: str) -> bool: + """Check if this handler can process the file.""" + ext = os.path.splitext(file_path)[1].lower() + return ext == ".las" + +else: + + class MockLASArrayHandler(ExternalArrayHandler): + """Mock handler when lasio is not installed.""" + + def read_array( + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, + ) -> Optional[np.ndarray]: + raise MissingExtraInstallation(extra_name="las") + + def write_array( + self, + target: Union[str, BytesIO, Any], + array: Union[list, np.ndarray], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + **kwargs, + ) -> bool: + raise MissingExtraInstallation(extra_name="las") + + def get_array_metadata( + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, + ) -> Optional[Union[dict, List[dict]]]: + raise MissingExtraInstallation(extra_name="las") + + def list_arrays(self, source: Union[BytesIO, str, Any]) -> List[str]: + raise MissingExtraInstallation(extra_name="las") + + def can_handle_file(self, file_path: str) -> bool: + """Check if this handler can process the file.""" + ext = os.path.splitext(file_path)[1].lower() + return ext == ".las" + + +# SEG-Y Handler +if __SEGYIO_MODULE_EXISTS__: + + class SEGYArrayHandler(ExternalArrayHandler): + """Handler for SEG-Y seismic files (.sgy, .segy).""" + + def read_array( + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, + ) -> Optional[np.ndarray]: + """ + Read array from SEG-Y file. + + Args: + source: Path to SEG-Y file + path_in_external_file: Comma-separated list of trace headers or 'traces' for trace data + start_indices: Starting index [trace_start, sample_start] + counts: Number of elements [trace_count, sample_count] + + Returns: + NumPy array with requested data + """ + try: + # SEG-Y requires file path, not BytesIO + if not isinstance(source, str): + logging.error("SEG-Y handler requires file path, not BytesIO") + return None + + with segyio.open(source, "r", ignore_geometry=True) as f: + if path_in_external_file is None or path_in_external_file.strip().lower() == "traces": + # Read trace data + trace_start = start_indices[0] if start_indices and len(start_indices) > 0 else 0 + sample_start = start_indices[1] if start_indices and len(start_indices) > 1 else 0 + + trace_count = counts[0] if counts and len(counts) > 0 else len(f.trace) - trace_start + sample_count = counts[1] if counts and len(counts) > 1 else len(f.samples) - sample_start + + # Read traces + traces = [] + for i in range(trace_start, trace_start + trace_count): + if i < len(f.trace): + trace = f.trace[i][sample_start : sample_start + sample_count] + traces.append(trace) + + return np.array(traces) + else: + # Read trace headers + headers = [h.strip() for h in path_in_external_file.replace(";", ",").split(",")] + + trace_start = start_indices[0] if start_indices and len(start_indices) > 0 else 0 + trace_count = counts[0] if counts and len(counts) > 0 else len(f.trace) - trace_start + + # Extract header values + header_data = [] + for i in range(trace_start, trace_start + trace_count): + if i < len(f.trace): + trace_headers = f.header[i] + header_values = [ + trace_headers.get(segyio.TraceField.__dict__.get(h.upper(), 0), 0) for h in headers + ] + header_data.append(header_values) + + return np.array(header_data) + + except Exception as e: + logging.error(f"Failed to read SEG-Y file: {e}") + return None + + def write_array( + self, + target: Union[str, BytesIO, Any], + array: Union[list, np.ndarray], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + **kwargs, + ) -> bool: + """ + Write array to SEG-Y file. + + Args: + target: Path to SEG-Y file + array: NumPy array (traces x samples) + path_in_external_file: Not used (SEG-Y structure is fixed) + **kwargs: Additional parameters (sample_interval, etc.) + + Returns: + True if successful, False otherwise + """ + try: + if not isinstance(target, str): + logging.error("SEG-Y handler requires file path for writing") + return False + + if not isinstance(array, np.ndarray): + array = np.array(array) + + # Ensure 2D array (traces x samples) + if array.ndim == 1: + array = array.reshape(1, -1) + + n_traces, n_samples = array.shape + + # Create SEG-Y file specification + spec = segyio.spec() + spec.format = kwargs.get("format", 1) # 1 = 4-byte IBM float + spec.samples = range(n_samples) + spec.tracecount = n_traces + + # Write SEG-Y file + with segyio.create(target, spec) as f: + for i in range(n_traces): + f.trace[i] = array[i, :] + + # Set sample interval if provided (in microseconds) + if "sample_interval" in kwargs: + f.bin[segyio.BinField.Interval] = kwargs["sample_interval"] + + return True + + except Exception as e: + logging.error(f"Failed to write SEG-Y file: {e}") + return False + + def get_array_metadata( + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, + ) -> Optional[Union[dict, List[dict]]]: + """ + Get metadata for SEG-Y file. + + Returns: + Dictionary with shape, dtype, trace count, sample info + """ + try: + if not isinstance(source, str): + logging.error("SEG-Y handler requires file path") + return None + + with segyio.open(source, "r", ignore_geometry=True) as f: + metadata = { + "shape": (len(f.trace), len(f.samples)), + "dtype": str(f.dtype), + "trace_count": len(f.trace), + "sample_count": len(f.samples), + "sample_interval": f.bin[segyio.BinField.Interval], + "format": f.format, + "samples": f.samples.tolist() if hasattr(f.samples, "tolist") else list(f.samples), + } + + return metadata + + except Exception as e: + logging.error(f"Failed to get SEG-Y metadata: {e}") + return None + + def list_arrays(self, source: Union[BytesIO, str, Any]) -> List[str]: + """List available data in SEG-Y file (always 'traces').""" + return ["traces"] + + def can_handle_file(self, file_path: str) -> bool: + """Check if this handler can process the file.""" + ext = os.path.splitext(file_path)[1].lower() + return ext in [".sgy", ".segy"] + +else: + + class MockSEGYArrayHandler(ExternalArrayHandler): + """Mock handler when segyio is not installed.""" + + def read_array( + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, + ) -> Optional[np.ndarray]: + raise MissingExtraInstallation(extra_name="segy") + + def write_array( + self, + target: Union[str, BytesIO, Any], + array: Union[list, np.ndarray], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + **kwargs, + ) -> bool: + raise MissingExtraInstallation(extra_name="segy") + + def get_array_metadata( + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, + ) -> Optional[Union[dict, List[dict]]]: + raise MissingExtraInstallation(extra_name="segy") + + def list_arrays(self, source: Union[BytesIO, str, Any]) -> List[str]: + raise MissingExtraInstallation(extra_name="segy") + + def can_handle_file(self, file_path: str) -> bool: + """Check if this handler can process the file.""" + ext = os.path.splitext(file_path)[1].lower() + return ext in [".sgy", ".segy"] From 069aefadac76a820e845df9e96fdccbcf47b134a Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Thu, 12 Feb 2026 02:59:23 +0100 Subject: [PATCH 24/70] new epc tests --- energyml-utils/tests/test_epc.py | 835 +++++++++++++++++---- energyml-utils/tests/test_introspection.py | 101 +++ 2 files changed, 782 insertions(+), 154 deletions(-) diff --git a/energyml-utils/tests/test_epc.py b/energyml-utils/tests/test_epc.py index de6ea53..4593ba3 100644 --- a/energyml-utils/tests/test_epc.py +++ b/energyml-utils/tests/test_epc.py @@ -1,197 +1,724 @@ # Copyright (c) 2023-2024 Geosiris. # SPDX-License-Identifier: Apache-2.0 +""" +Comprehensive unit tests for Epc class functionality. + +Tests cover: +1. Object lifecycle (add, get, remove) +2. Export functionality (export_file, export_io) +3. Relationship computation (compute_rels) - only at export time +4. HDF5 array operations (write_array, read_array) +5. DOR creation and handling (as_dor) +6. File path generation (gen_energyml_object_path) +7. External files and raw files handling +""" +import os +import tempfile + +import pytest +import numpy as np + from energyml.eml.v2_0.commonv2 import Citation as Citation20 -from energyml.eml.v2_0.commonv2 import ( - DataObjectReference as DataObjectReference201, -) -from energyml.eml.v2_3.commonv2 import Citation -from energyml.eml.v2_3.commonv2 import DataObjectReference +from energyml.eml.v2_0.commonv2 import DataObjectReference as DataObjectReference201 +from energyml.eml.v2_3.commonv2 import Citation, DataObjectReference from energyml.resqml.v2_0_1.resqmlv2 import FaultInterpretation -from energyml.resqml.v2_2.resqmlv2 import TriangulatedSetRepresentation +from energyml.resqml.v2_2.resqmlv2 import ( + TriangulatedSetRepresentation, + BoundaryFeatureInterpretation, + BoundaryFeature, + HorizonInterpretation, +) from energyml.utils.epc import ( + Epc, as_dor, - get_obj_identifier, - gen_energyml_object_path, EpcExportVersion, ) +from energyml.utils.epc_utils import gen_energyml_object_path from energyml.utils.introspection import ( epoch_to_date, epoch, gen_uuid, get_content_type_from_class, - get_obj_pkg_pkgv_type_uuid_version, - get_obj_uri, get_qualified_type_from_class, - set_attribute_from_path, + get_obj_identifier, ) +from energyml.utils.constants import EPCRelsRelationshipType -fi_cit = Citation20( - title="An interpretation", - originator="Valentin", - creation=epoch_to_date(epoch()), - editor="test", - format="Geosiris", - last_update=epoch_to_date(epoch()), -) -fi = FaultInterpretation( - citation=fi_cit, - uuid=gen_uuid(), - object_version="0", -) +@pytest.fixture +def temp_epc_file(): + """Create a temporary EPC file path for testing.""" + fd, temp_path = tempfile.mkstemp(suffix=".epc") + os.close(fd) + os.unlink(temp_path) -tr_cit = Citation( - title="--", - # title="test title", - originator="Valentin", - creation=epoch_to_date(epoch()), - editor="test", - format="Geosiris", - last_update=epoch_to_date(epoch()), -) + yield temp_path -dor = DataObjectReference( - uuid=fi.uuid, - title="a DOR title", - object_version="0", - qualified_type="a wrong qualified type", -) + if os.path.exists(temp_path): + os.unlink(temp_path) -dor_correct20 = DataObjectReference201( - uuid=fi.uuid, - title="a DOR title", - content_type="application/x-resqml+xml;version=2.0;type=obj_FaultInterpretation", - version_string="0", -) -dor_correct23 = DataObjectReference( - uuid=fi.uuid, - title="a DOR title", - object_version="0", - qualified_type="resqml20.obj_FaultInterpretation", -) +@pytest.fixture +def sample_objects(): + """Create sample EnergyML objects for testing.""" + # Create a BoundaryFeature + bf = BoundaryFeature( + citation=Citation( + title="Test Boundary Feature", + originator="Test", + creation=epoch_to_date(epoch()), + ), + uuid="25773477-ffee-4cc2-867d-000000000001", + object_version="1.0", + ) -tr = TriangulatedSetRepresentation( - citation=tr_cit, - uuid=gen_uuid(), - represented_object=dor_correct23, -) -tr_versioned = TriangulatedSetRepresentation( - citation=tr_cit, - uuid=gen_uuid(), - represented_object=dor_correct23, - object_version="3", -) + # Create a BoundaryFeatureInterpretation + bfi = BoundaryFeatureInterpretation( + citation=Citation( + title="Test Boundary Feature Interpretation", + originator="Test", + creation=epoch_to_date(epoch()), + ), + uuid="25773477-ffee-4cc2-867d-000000000002", + object_version="1.0", + interpreted_feature=as_dor(bf), + ) + # Create a HorizonInterpretation + horizon_interp = HorizonInterpretation( + citation=Citation( + title="Test HorizonInterpretation", + originator="Test", + creation=epoch_to_date(epoch()), + ), + interpreted_feature=as_dor(bf), + uuid="25773477-ffee-4cc2-867d-000000000003", + object_version="1.0", + ) -def test_get_obj_identifier(): - assert get_obj_identifier(tr) == tr.uuid + "." - assert get_obj_identifier(fi) == fi.uuid + ".0" - assert get_obj_identifier(dor_correct20) == dor_correct20.uuid + ".0" - assert get_obj_identifier(dor_correct23) == dor_correct23.uuid + ".0" - - -def test_get_obj_pkg_pkgv_type_uuid_version_obj_201(): - ( - domain, - domain_version, - object_type, - obj_uuid, - obj_version, - ) = get_obj_pkg_pkgv_type_uuid_version(fi) - assert domain == "resqml" - assert domain_version == "20" - assert object_type == "obj_FaultInterpretation" - assert obj_uuid == fi.uuid - assert obj_version == fi.object_version - - -def test_get_obj_pkg_pkgv_type_uuid_version_obj_22(): - ( - domain, - domain_version, - object_type, - obj_uuid, - obj_version, - ) = get_obj_pkg_pkgv_type_uuid_version(tr) - assert domain == "resqml" - assert domain_version == "22" - assert object_type == "TriangulatedSetRepresentation" - assert obj_uuid == tr.uuid - assert obj_version == tr.object_version - - -def test_get_obj_uri(): - assert str(get_obj_uri(tr)) == f"eml:///resqml22.TriangulatedSetRepresentation({tr.uuid})" - assert ( - str(get_obj_uri(tr, "/MyDataspace/")) - == f"eml:///dataspace('/MyDataspace/')/resqml22.TriangulatedSetRepresentation({tr.uuid})" + # Create a TriangulatedSetRepresentation + trset = TriangulatedSetRepresentation( + citation=Citation( + title="Test TriangulatedSetRepresentation", + originator="Test", + creation=epoch_to_date(epoch()), + ), + uuid="25773477-ffee-4cc2-867d-000000000004", + object_version="1.0", + represented_object=as_dor(horizon_interp), ) - assert ( - str(get_obj_uri(fi)) == f"eml:///resqml20.obj_FaultInterpretation(uuid={fi.uuid},version='{fi.object_version}')" + # Resqml 2.0.1 FaultInterpretation for additional tests + fi_cit = Citation20( + title="An interpretation", + originator="Valentin", + creation=epoch_to_date(epoch()), + editor="test", + format="Geosiris", + last_update=epoch_to_date(epoch()), ) - assert ( - str(get_obj_uri(fi, "/MyDataspace/")) - == f"eml:///dataspace('/MyDataspace/')/resqml20.obj_FaultInterpretation(uuid={fi.uuid},version='{fi.object_version}')" + + fi = FaultInterpretation( + citation=fi_cit, + uuid=gen_uuid(), + object_version="0", ) + return { + "bf": bf, + "bfi": bfi, + "trset": trset, + "horizon_interp": horizon_interp, + "fi": fi, + } -def test_gen_energyml_object_path(): - assert gen_energyml_object_path(tr) == f"TriangulatedSetRepresentation_{tr.uuid}.xml" - assert ( - gen_energyml_object_path(tr, EpcExportVersion.EXPANDED) - == f"namespace_resqml22/TriangulatedSetRepresentation_{tr.uuid}.xml" - ) +class TestObjectLifecycle: + """Test basic object lifecycle operations.""" -def test_gen_energyml_object_path_versioned(): - assert gen_energyml_object_path(tr_versioned) == f"TriangulatedSetRepresentation_{tr_versioned.uuid}.xml" - assert ( - gen_energyml_object_path(tr_versioned, EpcExportVersion.EXPANDED) - == f"namespace_resqml22/version_{tr_versioned.object_version}/TriangulatedSetRepresentation_{tr_versioned.uuid}.xml" - ) + def test_add_object(self, sample_objects): + """Test adding objects to Epc.""" + epc = Epc() + bf = sample_objects["bf"] + result = epc.add_object(bf) + assert result is True + assert len(epc.energyml_objects) == 1 + assert bf in epc.energyml_objects -def test_as_dor_object(): - dor_fi = as_dor(fi) + def test_add_multiple_objects(self, sample_objects): + """Test adding multiple objects.""" + epc = Epc() - assert dor_fi.title == fi.citation.title - assert dor_fi.uuid == fi.uuid - assert dor_fi.qualified_type == get_qualified_type_from_class(fi) + epc.add_object(sample_objects["bf"]) + epc.add_object(sample_objects["bfi"]) + epc.add_object(sample_objects["horizon_interp"]) + epc.add_object(sample_objects["trset"]) + assert len(epc) == 4 + assert len(epc.energyml_objects) == 4 -def test_as_dor_another_dor(): - dor_dor20 = as_dor(dor_correct20, "eml20.DataObjectReference") - assert dor_dor20.title == dor_correct20.title - assert dor_dor20.uuid == fi.uuid - assert dor_dor20.content_type == get_content_type_from_class(fi) + def test_get_object_by_identifier(self, sample_objects): + """Test retrieving object by identifier.""" + epc = Epc() + bf = sample_objects["bf"] - dor_dor20_bis = as_dor(dor_correct23, "eml20.DataObjectReference") - assert dor_dor20_bis.title == dor_correct23.title - assert dor_dor20_bis.uuid == fi.uuid - assert dor_dor20_bis.content_type == get_content_type_from_class(fi) + epc.add_object(bf) + identifier = get_obj_identifier(bf) - dor_dor23 = as_dor(dor_correct20, "eml23.DataObjectReference") - assert dor_dor23.title == dor_correct20.title - assert dor_dor23.uuid == fi.uuid - assert dor_dor23.qualified_type == get_qualified_type_from_class(fi) + retrieved = epc.get_object_by_identifier(identifier) + assert retrieved is not None + assert retrieved.uuid == bf.uuid + def test_get_object_by_uuid(self, sample_objects): + """Test retrieving objects by UUID.""" + epc = Epc() + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] -def test_as_dor_uri(): - dor_dor20 = as_dor( - "eml:///dataspace('test')/resqml22.TriangulatedSetRepresentation(0a2ba9e1-1018-4bfd-8fec-1c8cef13fa52)", - "eml20.DataObjectReference", - ) - assert dor_dor20.title is None - assert dor_dor20.uuid == "0a2ba9e1-1018-4bfd-8fec-1c8cef13fa52" - assert dor_dor20.content_type == "application/x-resqml+xml;version=2.2;type=TriangulatedSetRepresentation" + epc.add_object(bf) + epc.add_object(bfi) - dor_dor23 = as_dor( - "eml:///dataspace('test')/resqml22.TriangulatedSetRepresentation(0a2ba9e1-1018-4bfd-8fec-1c8cef13fa52)", - "eml23.DataObjectReference", - ) - assert dor_dor23.title is None - assert dor_dor23.uuid == "0a2ba9e1-1018-4bfd-8fec-1c8cef13fa52" - assert dor_dor23.qualified_type == "resqml22.TriangulatedSetRepresentation" + results = epc.get_object_by_uuid(bf.uuid) + assert len(results) == 1 + assert results[0].uuid == bf.uuid + + def test_remove_object(self, sample_objects): + """Test removing objects from Epc.""" + epc = Epc() + bf = sample_objects["bf"] + + epc.add_object(bf) + assert len(epc) == 1 + + identifier = get_obj_identifier(bf) + epc.remove_object(identifier) + + assert len(epc) == 0 + assert bf not in epc.energyml_objects + + def test_len(self, sample_objects): + """Test __len__ method.""" + epc = Epc() + assert len(epc) == 0 + + epc.add_object(sample_objects["bf"]) + assert len(epc) == 1 + + epc.add_object(sample_objects["bfi"]) + assert len(epc) == 2 + + +class TestExportFunctionality: + """Test export operations.""" + + def test_export_file(self, temp_epc_file, sample_objects): + """Test exporting Epc to file.""" + epc = Epc() + epc.add_object(sample_objects["bf"]) + epc.add_object(sample_objects["bfi"]) + + epc.export_file(temp_epc_file) + + assert os.path.exists(temp_epc_file) + assert os.path.getsize(temp_epc_file) > 0 + + def test_export_and_reload(self, temp_epc_file, sample_objects): + """Test exporting and reloading an Epc file.""" + epc = Epc() + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + + epc.add_object(bf) + epc.add_object(bfi) + epc.export_file(temp_epc_file) + + # Reload + epc2 = Epc.read_file(temp_epc_file) + assert len(epc2) == 2 + + # Verify objects are present + bf_retrieved = epc2.get_object_by_uuid(bf.uuid) + assert len(bf_retrieved) == 1 + assert bf_retrieved[0].citation.title == bf.citation.title + + def test_export_io(self, sample_objects): + """Test exporting to BytesIO.""" + epc = Epc() + epc.add_object(sample_objects["bf"]) + epc.add_object(sample_objects["bfi"]) + + io = epc.export_io() + + assert io is not None + assert io.tell() > 0 # Check that data was written + + # Try to read it back + io.seek(0) + epc2 = Epc.read_stream(io) + assert len(epc2) == 2 + + +class TestRelationships: + """Test relationship computation - Epc only computes rels at export time.""" + + def test_compute_rels_basic(self, sample_objects): + """Test basic relationship computation.""" + epc = Epc() + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + + epc.add_object(bf) + epc.add_object(bfi) + + # Compute relationships + rels_dict = epc.compute_rels() + + assert rels_dict is not None + assert len(rels_dict) > 0 + + # Check that relationships were computed + # compute_rels returns dict with rels paths as keys, not identifiers + assert any("BoundaryFeatureInterpretation" in key for key in rels_dict.keys()) + + def test_compute_rels_complex_chain(self, sample_objects): + """Test relationship computation with object chain.""" + epc = Epc() + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + horizon_interp = sample_objects["horizon_interp"] + trset = sample_objects["trset"] + + epc.add_object(bf) + epc.add_object(bfi) + epc.add_object(horizon_interp) + epc.add_object(trset) + + rels_dict = epc.compute_rels() + + # Verify relationships exist + assert len(rels_dict) >= 3 # At least 3 objects should have rels + + # Check specific relationships + bfi_id = get_obj_identifier(bfi) + if bfi_id in rels_dict: + bfi_rels = rels_dict[bfi_id] + dest_rels = [ + r for r in bfi_rels.relationship if r.type_value == str(EPCRelsRelationshipType.DESTINATION_OBJECT) + ] + assert len(dest_rels) >= 1 + + def test_get_obj_rels_after_compute(self, sample_objects): + """Test get_obj_rels after explicit compute_rels call.""" + epc = Epc() + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + + epc.add_object(bf) + epc.add_object(bfi) + + # Compute rels explicitly + epc.compute_rels() + + # Now we can get rels + bfi_rels = epc.get_obj_rels(bfi) + assert bfi_rels is not None + + def test_relationships_in_exported_file(self, temp_epc_file, sample_objects): + """Test that relationships are correctly written to exported file.""" + epc = Epc() + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + + epc.add_object(bf) + epc.add_object(bfi) + epc.export_file(temp_epc_file) + + # Reload and check relationships + epc2 = Epc.read_file(temp_epc_file) + + # After reload, relationships are stored in additional_rels + assert len(epc2) == 2 + + +class TestDORCreation: + """Test DataObjectReference creation.""" + + def test_as_dor_from_object(self, sample_objects): + """Test creating DOR from energyml object.""" + bf = sample_objects["bf"] + dor = as_dor(bf) + + assert dor.uuid == bf.uuid + assert dor.title == bf.citation.title + assert dor.qualified_type == get_qualified_type_from_class(bf) + + def test_as_dor_v20_from_object(self, sample_objects): + """Test creating v2.0 DOR from energyml object.""" + bf = sample_objects["bf"] + dor = as_dor(bf, "eml20.DataObjectReference") + + assert isinstance(dor, DataObjectReference201) + assert dor.uuid == bf.uuid + assert dor.content_type == get_content_type_from_class(bf) + + def test_as_dor_from_dor(self): + """Test creating DOR from another DOR.""" + dor_correct20 = DataObjectReference201( + uuid="25773477-ffee-4cc2-867d-000000000001", + title="a DOR title", + content_type="application/x-resqml+xml;version=2.2;type=BoundaryFeature", + version_string="1.0", + ) + + dor_23 = as_dor(dor_correct20, "eml23.DataObjectReference") + assert dor_23.uuid == dor_correct20.uuid + assert dor_23.title == dor_correct20.title + assert isinstance(dor_23, DataObjectReference) + + def test_as_dor_from_uri(self): + """Test creating DOR from URI string.""" + uri_str = "eml:///resqml22.TriangulatedSetRepresentation(0a2ba9e1-1018-4bfd-8fec-1c8cef13fa52)" + + dor_20 = as_dor(uri_str, "eml20.DataObjectReference") + assert dor_20.uuid == "0a2ba9e1-1018-4bfd-8fec-1c8cef13fa52" + assert dor_20.content_type == "application/x-resqml+xml;version=2.2;type=TriangulatedSetRepresentation" + + dor_23 = as_dor(uri_str, "eml23.DataObjectReference") + assert dor_23.uuid == "0a2ba9e1-1018-4bfd-8fec-1c8cef13fa52" + assert dor_23.qualified_type == "resqml22.TriangulatedSetRepresentation" + + +class TestFilePathGeneration: + """Test file path generation for objects.""" + + def test_gen_energyml_object_path_classic(self, sample_objects): + """Test path generation with CLASSIC export version.""" + trset = sample_objects["trset"] + + path = gen_energyml_object_path(trset, EpcExportVersion.CLASSIC) + assert path == f"TriangulatedSetRepresentation_{trset.uuid}.xml" + + def test_gen_energyml_object_path_expanded(self, sample_objects): + """Test path generation with EXPANDED export version.""" + trset = sample_objects["trset"] + + path = gen_energyml_object_path(trset, EpcExportVersion.EXPANDED) + expected = f"namespace_resqml22/version_{trset.object_version}/TriangulatedSetRepresentation_{trset.uuid}.xml" + assert path == expected + + def test_gen_energyml_object_path_no_version(self, sample_objects): + """Test path generation for object without explicit version.""" + bf = sample_objects["bf"] + + # For objects with object_version + path = gen_energyml_object_path(bf, EpcExportVersion.CLASSIC) + assert path == f"BoundaryFeature_{bf.uuid}.xml" + + +class TestHDF5Operations: + """Test HDF5 array operations.""" + + def test_write_and_read_array(self, temp_epc_file, sample_objects): + """Test writing and reading arrays.""" + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".h5") as f: + h5_path = f.name + + try: + epc = Epc(force_h5_path=h5_path) + trset = sample_objects["trset"] + + epc.add_object(trset) + + # Write array + test_array = np.arange(20).reshape((4, 5)) + success = epc.write_array(trset, "/test_dataset", test_array) + assert success + + # Read array back + read_array = epc.read_array(trset, "/test_dataset") + assert read_array is not None + assert np.array_equal(read_array, test_array) + + finally: + import time + + time.sleep(0.1) + if os.path.exists(h5_path): + try: + os.unlink(h5_path) + except PermissionError: + pass + + def test_write_array_creates_h5_rel(self, sample_objects): + """Test that writing array creates proper H5 relationship.""" + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".h5") as f: + h5_path = f.name + + try: + epc = Epc(force_h5_path=h5_path) + trset = sample_objects["trset"] + + epc.add_object(trset) + + test_array = np.array([1, 2, 3, 4, 5]) + epc.write_array(trset, "/dataset", test_array) + + # Check H5 file paths + h5_paths = epc.get_h5_file_paths(trset) + assert len(h5_paths) > 0 + + finally: + import time + + time.sleep(0.1) + if os.path.exists(h5_path): + try: + os.unlink(h5_path) + except PermissionError: + pass + + def test_multiple_arrays(self, sample_objects): + """Test writing multiple arrays.""" + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".h5") as f: + h5_path = f.name + + try: + epc = Epc(force_h5_path=h5_path) + trset = sample_objects["trset"] + + epc.add_object(trset) + + array1 = np.arange(10) + array2 = np.arange(20).reshape((4, 5)) + array3 = np.arange(12).reshape((3, 4)) + + epc.write_array(trset, "/array1", array1) + epc.write_array(trset, "/array2", array2) + epc.write_array(trset, "/array3", array3) + + # Read them back + assert np.array_equal(epc.read_array(trset, "/array1"), array1) + assert np.array_equal(epc.read_array(trset, "/array2"), array2) + assert np.array_equal(epc.read_array(trset, "/array3"), array3) + + finally: + import time + + time.sleep(0.1) + if os.path.exists(h5_path): + try: + os.unlink(h5_path) + except PermissionError: + pass + + +class TestExternalFilesHandling: + """Test handling of external files.""" + + def test_add_external_file_path(self): + """Test adding external file paths.""" + epc = Epc() + + epc.external_files_path.append("/path/to/external/file.h5") + epc.external_files_path.append("/path/to/another/file.h5") + + assert len(epc.external_files_path) == 2 + assert "/path/to/external/file.h5" in epc.external_files_path + + def test_force_h5_path(self): + """Test force_h5_path parameter.""" + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".h5") as f: + h5_path = f.name + + try: + epc = Epc(force_h5_path=h5_path) + assert epc.force_h5_path == h5_path + + finally: + if os.path.exists(h5_path): + try: + os.unlink(h5_path) + except PermissionError: + pass + + +class TestExportVersions: + """Test different export versions.""" + + def test_classic_export_version(self, temp_epc_file, sample_objects): + """Test export with CLASSIC version.""" + epc = Epc(export_version=EpcExportVersion.CLASSIC) + epc.add_object(sample_objects["bf"]) + + epc.export_file(temp_epc_file) + + assert os.path.exists(temp_epc_file) + + # Reload and verify + epc2 = Epc.read_file(temp_epc_file) + assert len(epc2) == 1 + + def test_expanded_export_version(self, temp_epc_file, sample_objects): + """Test export with EXPANDED version.""" + epc = Epc(export_version=EpcExportVersion.EXPANDED) + epc.add_object(sample_objects["bf"]) + epc.add_object(sample_objects["bfi"]) + + epc.export_file(temp_epc_file) + + assert os.path.exists(temp_epc_file) + + # Reload and verify + epc2 = Epc.read_file(temp_epc_file) + assert len(epc2) == 2 + + +class TestAdditionalRels: + """Test additional relationships handling.""" + + def test_add_rels_for_object(self, sample_objects): + """Test adding additional relationships for an object.""" + from energyml.opc.opc import Relationship + + epc = Epc() + bf = sample_objects["bf"] + epc.add_object(bf) + + identifier = get_obj_identifier(bf) + + # Add external resource relationship + h5_rel = Relationship( + target="data/external.h5", + type_value=str(EPCRelsRelationshipType.EXTERNAL_RESOURCE), + id=f"_external_{identifier}", + ) + + epc.add_rels_for_object(identifier, [h5_rel]) + + assert identifier in epc.additional_rels + assert len(epc.additional_rels[identifier]) == 1 + + def test_get_h5_file_paths(self, sample_objects): + """Test retrieving H5 file paths from relationships.""" + from energyml.opc.opc import Relationship + + epc = Epc() + trset = sample_objects["trset"] + epc.add_object(trset) + + identifier = get_obj_identifier(trset) + + # Add H5 relationships + h5_rel = Relationship( + target="data/geometry.h5", + type_value=str(EPCRelsRelationshipType.EXTERNAL_RESOURCE), + id=f"_external_{identifier}_1", + ) + + epc.add_rels_for_object(identifier, [h5_rel]) + + h5_paths = epc.get_h5_file_paths(trset) + assert "data/geometry.h5" in h5_paths + + +class TestEdgeCases: + """Test edge cases and error handling.""" + + def test_empty_epc(self): + """Test operations on empty Epc.""" + epc = Epc() + + assert len(epc) == 0 + assert len(epc.energyml_objects) == 0 + + # Should be able to export empty epc + io = epc.export_io() + assert io is not None + + def test_remove_nonexistent_object(self): + """Test removing non-existent object.""" + epc = Epc() + + # Should not raise error + epc.remove_object("nonexistent-uuid.0") + assert len(epc) == 0 + + def test_get_nonexistent_object(self): + """Test getting non-existent object.""" + epc = Epc() + + result = epc.get_object_by_identifier("nonexistent-uuid.0") + assert result is None + + results = epc.get_object_by_uuid("nonexistent-uuid") + assert len(results) == 0 + + def test_duplicate_add(self, sample_objects): + """Test adding the same object multiple times.""" + epc = Epc() + bf = sample_objects["bf"] + + epc.add_object(bf) + epc.add_object(bf) # Add same object again + + # Behavior: object appears only once in the list + assert len(epc.energyml_objects) >= 1 + + +class TestListObjects: + """Test list_objects functionality.""" + + def test_list_objects(self, sample_objects): + """Test listing objects.""" + epc = Epc() + + epc.add_object(sample_objects["bf"]) + epc.add_object(sample_objects["bfi"]) + epc.add_object(sample_objects["trset"]) + + objects_list = epc.list_objects() + assert len(objects_list) == 3 + + def test_list_objects_empty(self): + """Test listing objects from empty Epc.""" + epc = Epc() + + objects_list = epc.list_objects() + assert len(objects_list) == 0 + + +class TestCoreProperties: + """Test core properties handling.""" + + def test_core_props_creation(self, temp_epc_file, sample_objects): + """Test that core properties are created during export.""" + epc = Epc() + epc.add_object(sample_objects["bf"]) + + epc.export_file(temp_epc_file) + + # Verify core props exist after export + assert epc.core_props is not None + + def test_custom_core_props(self, temp_epc_file, sample_objects): + """Test setting custom core properties.""" + from energyml.opc.opc import CoreProperties, Creator + + core_props = CoreProperties( + creator=Creator(any_element="Test Creator"), + ) + + epc = Epc(core_props=core_props) + epc.add_object(sample_objects["bf"]) + + epc.export_file(temp_epc_file) + + # Reload and verify + epc2 = Epc.read_file(temp_epc_file) + assert epc2.core_props is not None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/energyml-utils/tests/test_introspection.py b/energyml-utils/tests/test_introspection.py index 3fcb17f..9c674c8 100644 --- a/energyml-utils/tests/test_introspection.py +++ b/energyml-utils/tests/test_introspection.py @@ -1,6 +1,10 @@ # Copyright (c) 2023-2024 Geosiris. # SPDX-License-Identifier: Apache-2.0 import energyml.resqml.v2_0_1.resqmlv2 +from energyml.eml.v2_0.commonv2 import Citation as Citation20 +from energyml.eml.v2_3.commonv2 import Citation +from energyml.resqml.v2_0_1.resqmlv2 import FaultInterpretation +from energyml.resqml.v2_2.resqmlv2 import TriangulatedSetRepresentation from energyml.opc.opc import Dcmitype1, Contributor from src.energyml.utils.constants import ( @@ -18,6 +22,10 @@ get_object_attribute, set_attribute_from_path, copy_attributes, + get_obj_identifier, + get_obj_pkg_pkgv_type_uuid_version, + get_obj_uri, + gen_uuid, ) @@ -150,3 +158,96 @@ def test_copy_attributes_case_sensitive(): assert data_out["Uuid"] != data_in["uuid"] assert data_out["object_version"] == data_in["objectVersion"] assert data_out["non_existing"] == data_in["non_existing"] + + +# Test fixtures for object identifiers and URIs +fi_cit = Citation20( + title="An interpretation", + originator="Valentin", + creation=epoch_to_date(epoch()), + editor="test", + format="Geosiris", + last_update=epoch_to_date(epoch()), +) + +fi = FaultInterpretation( + citation=fi_cit, + uuid=gen_uuid(), + object_version="0", +) + +tr_cit = Citation( + title="Test TriSet", + originator="Valentin", + creation=epoch_to_date(epoch()), + editor="test", + format="Geosiris", + last_update=epoch_to_date(epoch()), +) + +tr = TriangulatedSetRepresentation( + citation=tr_cit, + uuid=gen_uuid(), +) + +tr_versioned = TriangulatedSetRepresentation( + citation=tr_cit, + uuid=gen_uuid(), + object_version="3", +) + + +def test_get_obj_identifier(): + """Test object identifier generation.""" + assert get_obj_identifier(tr) == tr.uuid + "." + assert get_obj_identifier(fi) == fi.uuid + ".0" + assert get_obj_identifier(tr_versioned) == tr_versioned.uuid + ".3" + + +def test_get_obj_pkg_pkgv_type_uuid_version_obj_201(): + """Test extracting package, version, type, uuid, and version from resqml20 object.""" + ( + domain, + domain_version, + object_type, + obj_uuid, + obj_version, + ) = get_obj_pkg_pkgv_type_uuid_version(fi) + assert domain == "resqml" + assert domain_version == "20" + assert object_type == "obj_FaultInterpretation" + assert obj_uuid == fi.uuid + assert obj_version == fi.object_version + + +def test_get_obj_pkg_pkgv_type_uuid_version_obj_22(): + """Test extracting package, version, type, uuid, and version from resqml22 object.""" + ( + domain, + domain_version, + object_type, + obj_uuid, + obj_version, + ) = get_obj_pkg_pkgv_type_uuid_version(tr) + assert domain == "resqml" + assert domain_version == "22" + assert object_type == "TriangulatedSetRepresentation" + assert obj_uuid == tr.uuid + assert obj_version == tr.object_version + + +def test_get_obj_uri(): + """Test URI generation for energyml objects.""" + assert str(get_obj_uri(tr)) == f"eml:///resqml22.TriangulatedSetRepresentation({tr.uuid})" + assert ( + str(get_obj_uri(tr, "/MyDataspace/")) + == f"eml:///dataspace('/MyDataspace/')/resqml22.TriangulatedSetRepresentation({tr.uuid})" + ) + + assert ( + str(get_obj_uri(fi)) == f"eml:///resqml20.obj_FaultInterpretation(uuid={fi.uuid},version='{fi.object_version}')" + ) + assert ( + str(get_obj_uri(fi, "/MyDataspace/")) + == f"eml:///dataspace('/MyDataspace/')/resqml20.obj_FaultInterpretation(uuid={fi.uuid},version='{fi.object_version}')" + ) From 7644352238ce95ad28682f996d699602fe84207b Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Thu, 12 Feb 2026 03:11:00 +0100 Subject: [PATCH 25/70] using FileHandlerRegistry for epc class --- energyml-utils/src/energyml/utils/epc.py | 228 +++++++++++++++++------ 1 file changed, 171 insertions(+), 57 deletions(-) diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index 215b4b2..25831ec 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -53,8 +53,7 @@ OptimizedRegex, ) from .data.datasets_io import ( - HDF5FileReader, - HDF5FileWriter, + get_handler_registry, read_external_dataset_array, ) from .exception import UnparsableFile @@ -329,20 +328,26 @@ def export_io(self) -> BytesIO: return zip_buffer - def get_obj_rels(self, obj: Any) -> Optional[Relationships]: + def get_obj_rels(self, obj: Union[str, Uri, Any]) -> List[Relationship]: """ - Get the Relationships object for a given energyml object - :param obj: - :return: + Get the relationships for a given energyml object + :param obj: The object identifier/URI or the object itself + :return: List of Relationship objects """ + # Convert identifier to object if needed + if isinstance(obj, str) or isinstance(obj, Uri): + obj = self.get_object_by_identifier(obj) + if obj is None: + return [] + rels_path = gen_rels_path( energyml_object=obj, export_version=self.export_version, ) all_rels = self.compute_rels() if rels_path in all_rels: - return all_rels[rels_path] - return None + return all_rels[rels_path].relationship if all_rels[rels_path].relationship else [] + return [] def compute_rels(self) -> Dict[str, Relationships]: """ @@ -576,65 +581,112 @@ def read_external_array( epc=self, ) - def read_array(self, proxy: Union[str, Uri, Any], path_in_external: str) -> Optional[np.ndarray]: + def read_array( + self, + proxy: Union[str, Uri, Any], + path_in_external: str, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, + external_uri: Optional[str] = None, + ) -> Optional[np.ndarray]: + """ + Read a data array from external storage (HDF5, Parquet, CSV, etc.) with optional sub-selection. + + :param proxy: The object identifier/URI or the object itself that references the array + :param path_in_external: Path within the external file (e.g., 'values/0') + :param start_indices: Optional start index for each dimension (RESQML v2.2 StartIndex) + :param counts: Optional count of elements for each dimension (RESQML v2.2 Count) + :param external_uri: Optional URI to override default file path (RESQML v2.2 URI) + :return: The data array as a numpy array, or None if not found + """ obj = proxy if isinstance(proxy, str) or isinstance(proxy, Uri): obj = self.get_object_by_identifier(proxy) - h5_path = self.get_h5_file_paths(obj) - h5_reader = HDF5FileReader() + # Determine which external files to use + file_paths = [external_uri] if external_uri else self.get_h5_file_paths(obj) + if not file_paths or len(file_paths) == 0: + file_paths = self.external_files_path - if h5_path is None or len(h5_path) == 0: - for h5_path in self.external_files_path: - try: - return h5_reader.read_array(source=h5_path, path_in_external_file=path_in_external) - except Exception: - pass - # logging.error(f"Failed to read HDF5 dataset from {h5_path}: {e}") - else: - for h5p in h5_path: - try: - return h5_reader.read_array(source=h5p, path_in_external_file=path_in_external) - except Exception: - pass - # logging.error(f"Failed to read HDF5 dataset from {h5p}: {e}") + if not file_paths: + logging.warning(f"No external file paths found for proxy: {proxy}") + return None + + # Get the file handler registry + handler_registry = get_handler_registry() + + for file_path in file_paths: + # Get the appropriate handler for this file type + handler = handler_registry.get_handler_for_file(file_path) + if handler is None: + logging.debug(f"No handler found for file: {file_path}") + continue + + try: + # Use handler to read array with sub-selection support + array = handler.read_array(file_path, path_in_external, start_indices, counts) + if array is not None: + return array + except Exception as e: + logging.debug(f"Failed to read dataset from {file_path}: {e}") + pass + + logging.error(f"Failed to read array from any available file paths: {file_paths}") return None def write_array( - self, proxy: Union[str, Uri, Any], path_in_external: str, array: Any, in_memory: bool = False + self, + proxy: Union[str, Uri, Any], + path_in_external: str, + array: np.ndarray, + start_indices: Optional[List[int]] = None, + external_uri: Optional[str] = None, + **kwargs, ) -> bool: """ - Write a dataset in the HDF5 file linked to the proxy object. - :param proxy: the object or its identifier - :param path_in_external: the path in the external file - :param array: the data to write - :param in_memory: if True, write in the in-memory HDF5 files (epc.h5_io_files) - - :return: True if successful + Write a data array to external storage (HDF5, Parquet, CSV, etc.) with optional offset. + + :param proxy: The object identifier/URI or the object itself that references the array + :param path_in_external: Path within the external file (e.g., 'values/0') + :param array: The numpy array to write + :param start_indices: Optional start index for each dimension for partial writes + :param external_uri: Optional URI to override default file path (RESQML v2.2 URI) + :param kwargs: Additional format-specific parameters (e.g., dtype, column_titles) + :return: True if successfully written, False otherwise """ obj = proxy if isinstance(proxy, str) or isinstance(proxy, Uri): obj = self.get_object_by_identifier(proxy) - h5_path = self.get_h5_file_paths(obj) - h5_writer = HDF5FileWriter() + # Determine which external files to use + file_paths = [external_uri] if external_uri else self.get_h5_file_paths(obj) + if not file_paths or len(file_paths) == 0: + file_paths = self.external_files_path - if in_memory or h5_path is None or len(h5_path) == 0: - for h5_path in self.external_files_path: - try: - h5_writer.write_array(target=h5_path, path_in_external_file=path_in_external, array=array) - return True - except Exception: - pass - # logging.error(f"Failed to write HDF5 dataset to {h5_path}: {e}") + if not file_paths: + logging.warning(f"No external file paths found for proxy: {proxy}") + return False + + # Get the file handler registry + handler_registry = get_handler_registry() + + # Try to write to the first available file + for file_path in file_paths: + # Get the appropriate handler for this file type + handler = handler_registry.get_handler_for_file(file_path) + if handler is None: + logging.debug(f"No handler found for file: {file_path}") + continue - for h5p in h5_path: try: - h5_writer.write_array(target=h5p, path_in_external_file=path_in_external, array=array) - return True - except Exception: - pass - # logging.error(f"Failed to write HDF5 dataset to {h5p}: {e}") + # Use handler to write array with optional partial write support + success = handler.write_array(file_path, array, path_in_external, start_indices, **kwargs) + if success: + return True + except Exception as e: + logging.error(f"Failed to write dataset to {file_path}: {e}") + + logging.error(f"Failed to write array to any available file paths: {file_paths}") return False # Class methods @@ -804,14 +856,76 @@ def delete_object(self, identifier: Union[str, Any]) -> bool: return False def get_array_metadata( - self, proxy: str | Uri | Any, path_in_external: str | None = None - ) -> DataArrayMetadata | List[DataArrayMetadata] | None: - array = self.read_array(proxy=proxy, path_in_external=path_in_external) - if array is not None: - if isinstance(array, np.ndarray): - return DataArrayMetadata.from_numpy_array(path_in_resource=path_in_external, array=array) - elif isinstance(array, list): - return DataArrayMetadata.from_list(path_in_resource=path_in_external, data=array) + self, + proxy: Union[str, Uri, Any], + path_in_external: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, + ) -> Union[DataArrayMetadata, List[DataArrayMetadata], None]: + """ + Get metadata for data array(s) without loading the full array data. + Supports RESQML v2.2 sub-array selection metadata. + + :param proxy: The object identifier/URI or the object itself that references the array + :param path_in_external: Optional specific path. If None, returns all array metadata for the object + :param start_indices: Optional start index for each dimension (RESQML v2.2 StartIndex) + :param counts: Optional count of elements for each dimension (RESQML v2.2 Count) + :return: DataArrayMetadata if path specified, List[DataArrayMetadata] if no path, or None if not found + """ + obj = proxy + if isinstance(proxy, str) or isinstance(proxy, Uri): + obj = self.get_object_by_identifier(proxy) + + # Get possible file paths for this object + file_paths = self.get_h5_file_paths(obj) + if not file_paths or len(file_paths) == 0: + file_paths = self.external_files_path + + if not file_paths: + logging.warning(f"No external file paths found for proxy: {proxy}") + return None + + # Get the file handler registry + handler_registry = get_handler_registry() + + for file_path in file_paths: + # Get the appropriate handler for this file type + handler = handler_registry.get_handler_for_file(file_path) + if handler is None: + logging.debug(f"No handler found for file: {file_path}") + continue + + try: + # Use handler to get metadata without loading full array + metadata_dict = handler.get_array_metadata(file_path, path_in_external, start_indices, counts) + + if metadata_dict is None: + continue + + # Convert dict(s) to DataArrayMetadata + if isinstance(metadata_dict, list): + return [ + DataArrayMetadata( + path_in_resource=m.get("path"), + array_type=m.get("dtype", "unknown"), + dimensions=m.get("shape", []), + start_indices=start_indices, + custom_data={"size": m.get("size", 0)}, + ) + for m in metadata_dict + ] + else: + return DataArrayMetadata( + path_in_resource=metadata_dict.get("path"), + array_type=metadata_dict.get("dtype", "unknown"), + dimensions=metadata_dict.get("shape", []), + start_indices=start_indices, + custom_data={"size": metadata_dict.get("size", 0)}, + ) + except Exception as e: + logging.debug(f"Failed to get metadata from file {file_path}: {e}") + + return None def dumps_epc_content_and_files_lists(self) -> str: """ From 7a4b767affb23d9738c08dc440b59501d97527bf Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Thu, 12 Feb 2026 03:24:20 +0100 Subject: [PATCH 26/70] moving functions from epc into epc_utils but keep backward compatibility --- energyml-utils/docs/src/energyml/index.html | 73 - .../docs/src/energyml/utils/data/hdf.html | 621 ----- .../docs/src/energyml/utils/data/helper.html | 1286 ---------- .../docs/src/energyml/utils/data/index.html | 91 - .../docs/src/energyml/utils/data/mesh.html | 1463 ----------- .../docs/src/energyml/utils/epc.html | 1900 --------------- .../docs/src/energyml/utils/index.html | 140 -- .../src/energyml/utils/introspection.html | 2141 ----------------- .../docs/src/energyml/utils/manager.html | 615 ----- .../src/energyml/utils/serialization.html | 305 --- .../docs/src/energyml/utils/validation.html | 984 -------- .../docs/src/energyml/utils/xml.html | 501 ---- energyml-utils/docs/src/index.html | 60 - .../src/energyml/utils/data/mesh.py | 2 +- energyml-utils/src/energyml/utils/epc.py | 316 +-- .../src/energyml/utils/epc_stream.py | 2 +- .../src/energyml/utils/epc_utils.py | 315 ++- .../src/energyml/utils/validation.py | 4 +- energyml-utils/tests/test_epc_stream.py | 2 +- 19 files changed, 355 insertions(+), 10466 deletions(-) delete mode 100644 energyml-utils/docs/src/energyml/index.html delete mode 100644 energyml-utils/docs/src/energyml/utils/data/hdf.html delete mode 100644 energyml-utils/docs/src/energyml/utils/data/helper.html delete mode 100644 energyml-utils/docs/src/energyml/utils/data/index.html delete mode 100644 energyml-utils/docs/src/energyml/utils/data/mesh.html delete mode 100644 energyml-utils/docs/src/energyml/utils/epc.html delete mode 100644 energyml-utils/docs/src/energyml/utils/index.html delete mode 100644 energyml-utils/docs/src/energyml/utils/introspection.html delete mode 100644 energyml-utils/docs/src/energyml/utils/manager.html delete mode 100644 energyml-utils/docs/src/energyml/utils/serialization.html delete mode 100644 energyml-utils/docs/src/energyml/utils/validation.html delete mode 100644 energyml-utils/docs/src/energyml/utils/xml.html delete mode 100644 energyml-utils/docs/src/index.html diff --git a/energyml-utils/docs/src/energyml/index.html b/energyml-utils/docs/src/energyml/index.html deleted file mode 100644 index c188265..0000000 --- a/energyml-utils/docs/src/energyml/index.html +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - -src.energyml API documentation - - - - - - - - - - - -
-
-
-

Module src.energyml

-
-
-
- -Expand source code - -
# Copyright (c) 2023-2024 Geosiris.
-# SPDX-License-Identifier: Apache-2.0
-
-
-
-

Sub-modules

-
-
src.energyml.utils
-
-

The energyml.utils module. -It contains tools for energyml management …

-
-
-
-
-
-
-
-
-
-
- -
- - - \ No newline at end of file diff --git a/energyml-utils/docs/src/energyml/utils/data/hdf.html b/energyml-utils/docs/src/energyml/utils/data/hdf.html deleted file mode 100644 index 08b2205..0000000 --- a/energyml-utils/docs/src/energyml/utils/data/hdf.html +++ /dev/null @@ -1,621 +0,0 @@ - - - - - - -src.energyml.utils.data.hdf API documentation - - - - - - - - - - - -
-
-
-

Module src.energyml.utils.data.hdf

-
-
-
- -Expand source code - -
# Copyright (c) 2023-2024 Geosiris.
-# SPDX-License-Identifier: Apache-2.0
-from dataclasses import dataclass
-from io import BytesIO
-from typing import Optional, List, Tuple, Any, Union
-
-import h5py
-
-from ..epc import Epc, get_obj_identifier, ObjectNotFoundNotException, \
-    EPCRelsRelationshipType
-from ..introspection import search_attribute_matching_name_with_path, search_attribute_matching_name, \
-    get_obj_uuid, get_object_attribute
-
-
-@dataclass
-class DatasetReader:
-    def read_array(self, source: str, path_in_external_file: str) -> Optional[List[Any]]:
-        return None
-
-    def get_array_dimension(self, source: str, path_in_external_file: str) -> Optional[List[Any]]:
-        return None
-
-
-@dataclass
-class ETPReader(DatasetReader):
-    def read_array(self, obj_uri: str, path_in_external_file: str) -> Optional[List[Any]]:
-        return None
-
-    def get_array_dimension(self, source: str, path_in_external_file: str) -> Optional[List[Any]]:
-        return None
-
-
-@dataclass
-class HDF5FileReader(DatasetReader):
-    def read_array(self, source: Union[BytesIO, str], path_in_external_file: str) -> Optional[List[Any]]:
-        with h5py.File(source, "r") as f:
-            d_group = f[path_in_external_file]
-            return d_group[()].tolist()
-
-    def get_array_dimension(self, source: Union[BytesIO, str], path_in_external_file: str) -> Optional[List[Any]]:
-        with h5py.File(source, "r") as f:
-            return list(f[path_in_external_file].shape)
-
-    def extract_h5_datasets(
-            self, input_h5: Union[BytesIO, str], output_h5: Union[BytesIO, str], h5_datasets_paths: List[str]
-    ) -> None:
-        """
-        Copy all dataset from :param input_h5 matching with paths in :param h5_datasets_paths into the :param output
-        :param input_h5:
-        :param output_h5:
-        :param h5_datasets_paths:
-        :return:
-        """
-        if len(h5_datasets_paths) > 0:
-            with h5py.File(output_h5, "w") as f_dest:
-                with h5py.File(input_h5, "r") as f_src:
-                    for dataset in h5_datasets_paths:
-                        f_dest.create_dataset(dataset, data=f_src[dataset])
-
-
-def get_hdf_reference(obj) -> List[Any]:
-    """
-    See :func:`get_hdf_reference_with_path`. Only the value is returned, not the dot path into the object
-    :param obj:
-    :return:
-    """
-    return [
-        val
-        for path, val in get_hdf_reference_with_path(obj=obj)
-    ]
-
-
-def get_hdf_reference_with_path(obj: any) -> List[Tuple[str, Any]]:
-    """
-    See :func:`search_attribute_matching_name_with_path`. Search an attribute with type matching regex
-    "(PathInHdfFile|PathInExternalFile)".
-
-    :param obj:
-    :return: [ (Dot_Path_In_Obj, value), ...]
-    """
-    return search_attribute_matching_name_with_path(
-        obj,
-        "(PathInHdfFile|PathInExternalFile)"
-    )
-
-
-def get_crs_obj(
-        context_obj: Any,
-        path_in_root: Optional[str] = None,
-        root_obj: Optional[Any] = None,
-        epc: Optional[Epc] = None
-) -> Optional[Any]:
-    """
-    Search for the CRS object related to :param:`context_obj` into the :param:`epc`
-    :param context_obj:
-    :param path_in_root:
-    :param root_obj:
-    :param epc:
-    :return:
-    """
-    crs_list = search_attribute_matching_name(context_obj, r"\.*Crs", search_in_sub_obj=True, deep_search=False)
-    if crs_list is not None and len(crs_list) > 0:
-        crs = epc.get_object_by_identifier(get_obj_identifier(crs_list[0]))
-        if crs is None:
-            crs = epc.get_object_by_uuid(get_obj_uuid(crs_list[0]))
-        if crs is None:
-            raise ObjectNotFoundNotException(get_obj_identifier(crs_list[0]))
-        if crs is not None:
-            return crs
-
-    if context_obj != root_obj:
-        upper_path = path_in_root[:path_in_root.rindex(".")]
-        if len(upper_path) > 0:
-            return get_crs_obj(
-                context_obj=get_object_attribute(root_obj, upper_path),
-                path_in_root=upper_path,
-                root_obj=root_obj,
-                epc=epc,
-            )
-
-    return None
-
-
-def get_hdf5_path_from_external_path(
-        external_path_obj: Any,
-        path_in_root: Optional[str] = None,
-        root_obj: Optional[Any] = None,
-        epc: Optional[Epc] = None
-) -> Optional[str]:
-    """
-    Return the hdf5 file path (Searches for "uri" attribute or in :param:`epc` rels files).
-    :param external_path_obj: can be an attribute of an ExternalDataArrayPart
-    :param path_in_root:
-    :param root_obj:
-    :param epc:
-    :return:
-    """
-    if isinstance(external_path_obj, str):
-        # external_path_obj is maybe an attribute of an ExternalDataArrayPart, now search upper in the object
-        upper_path = path_in_root[:path_in_root.rindex(".")]
-        return get_hdf5_path_from_external_path(
-            external_path_obj=get_object_attribute(root_obj, upper_path),
-            path_in_root=upper_path,
-            root_obj=root_obj,
-            epc=epc,
-        )
-    elif type(external_path_obj).__name__ == "ExternalDataArrayPart":
-        epc_folder = epc.get_epc_file_folder()
-        h5_uri = search_attribute_matching_name(external_path_obj, "uri")
-        if h5_uri is not None and len(h5_uri) > 0:
-            return f"{epc_folder}/{h5_uri[0]}"
-    else:
-        epc_folder = epc.get_epc_file_folder()
-        hdf_proxy = search_attribute_matching_name(external_path_obj, "HdfProxy")[0]
-        if hdf_proxy is not None:
-            hdf_proxy_obj = epc.get_object_by_identifier(get_obj_identifier(hdf_proxy))
-            if hdf_proxy_obj is not None:
-                for rel in epc.additional_rels.get(get_obj_identifier(hdf_proxy_obj), []):
-                    # print(f"\trel : {rel}")
-                    if rel.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type():
-                        return f"{epc_folder}/{rel.target}"
-    return None
-
-
-
-
-
-
-
-

Functions

-
-
-def get_crs_obj(context_obj: Any, path_in_root: Optional[str] = None, root_obj: Optional[Any] = None, epc: Optional[Epc] = None) ‑> Optional[Any] -
-
-

Search for the CRS object related to :param:context_obj into the :param:epc -:param context_obj: -:param path_in_root: -:param root_obj: -:param epc: -:return:

-
- -Expand source code - -
def get_crs_obj(
-        context_obj: Any,
-        path_in_root: Optional[str] = None,
-        root_obj: Optional[Any] = None,
-        epc: Optional[Epc] = None
-) -> Optional[Any]:
-    """
-    Search for the CRS object related to :param:`context_obj` into the :param:`epc`
-    :param context_obj:
-    :param path_in_root:
-    :param root_obj:
-    :param epc:
-    :return:
-    """
-    crs_list = search_attribute_matching_name(context_obj, r"\.*Crs", search_in_sub_obj=True, deep_search=False)
-    if crs_list is not None and len(crs_list) > 0:
-        crs = epc.get_object_by_identifier(get_obj_identifier(crs_list[0]))
-        if crs is None:
-            crs = epc.get_object_by_uuid(get_obj_uuid(crs_list[0]))
-        if crs is None:
-            raise ObjectNotFoundNotException(get_obj_identifier(crs_list[0]))
-        if crs is not None:
-            return crs
-
-    if context_obj != root_obj:
-        upper_path = path_in_root[:path_in_root.rindex(".")]
-        if len(upper_path) > 0:
-            return get_crs_obj(
-                context_obj=get_object_attribute(root_obj, upper_path),
-                path_in_root=upper_path,
-                root_obj=root_obj,
-                epc=epc,
-            )
-
-    return None
-
-
-
-def get_hdf5_path_from_external_path(external_path_obj: Any, path_in_root: Optional[str] = None, root_obj: Optional[Any] = None, epc: Optional[Epc] = None) ‑> Optional[str] -
-
-

Return the hdf5 file path (Searches for "uri" attribute or in :param:epc rels files). -:param external_path_obj: can be an attribute of an ExternalDataArrayPart -:param path_in_root: -:param root_obj: -:param epc: -:return:

-
- -Expand source code - -
def get_hdf5_path_from_external_path(
-        external_path_obj: Any,
-        path_in_root: Optional[str] = None,
-        root_obj: Optional[Any] = None,
-        epc: Optional[Epc] = None
-) -> Optional[str]:
-    """
-    Return the hdf5 file path (Searches for "uri" attribute or in :param:`epc` rels files).
-    :param external_path_obj: can be an attribute of an ExternalDataArrayPart
-    :param path_in_root:
-    :param root_obj:
-    :param epc:
-    :return:
-    """
-    if isinstance(external_path_obj, str):
-        # external_path_obj is maybe an attribute of an ExternalDataArrayPart, now search upper in the object
-        upper_path = path_in_root[:path_in_root.rindex(".")]
-        return get_hdf5_path_from_external_path(
-            external_path_obj=get_object_attribute(root_obj, upper_path),
-            path_in_root=upper_path,
-            root_obj=root_obj,
-            epc=epc,
-        )
-    elif type(external_path_obj).__name__ == "ExternalDataArrayPart":
-        epc_folder = epc.get_epc_file_folder()
-        h5_uri = search_attribute_matching_name(external_path_obj, "uri")
-        if h5_uri is not None and len(h5_uri) > 0:
-            return f"{epc_folder}/{h5_uri[0]}"
-    else:
-        epc_folder = epc.get_epc_file_folder()
-        hdf_proxy = search_attribute_matching_name(external_path_obj, "HdfProxy")[0]
-        if hdf_proxy is not None:
-            hdf_proxy_obj = epc.get_object_by_identifier(get_obj_identifier(hdf_proxy))
-            if hdf_proxy_obj is not None:
-                for rel in epc.additional_rels.get(get_obj_identifier(hdf_proxy_obj), []):
-                    # print(f"\trel : {rel}")
-                    if rel.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type():
-                        return f"{epc_folder}/{rel.target}"
-    return None
-
-
-
-def get_hdf_reference(obj) ‑> List[Any] -
-
-

See :func:get_hdf_reference_with_path(). Only the value is returned, not the dot path into the object -:param obj: -:return:

-
- -Expand source code - -
def get_hdf_reference(obj) -> List[Any]:
-    """
-    See :func:`get_hdf_reference_with_path`. Only the value is returned, not the dot path into the object
-    :param obj:
-    :return:
-    """
-    return [
-        val
-        for path, val in get_hdf_reference_with_path(obj=obj)
-    ]
-
-
-
-def get_hdf_reference_with_path(obj: ) ‑> List[Tuple[str, Any]] -
-
-

See :func:search_attribute_matching_name_with_path. Search an attribute with type matching regex -"(PathInHdfFile|PathInExternalFile)".

-

:param obj: -:return: [ (Dot_Path_In_Obj, value), …]

-
- -Expand source code - -
def get_hdf_reference_with_path(obj: any) -> List[Tuple[str, Any]]:
-    """
-    See :func:`search_attribute_matching_name_with_path`. Search an attribute with type matching regex
-    "(PathInHdfFile|PathInExternalFile)".
-
-    :param obj:
-    :return: [ (Dot_Path_In_Obj, value), ...]
-    """
-    return search_attribute_matching_name_with_path(
-        obj,
-        "(PathInHdfFile|PathInExternalFile)"
-    )
-
-
-
-
-
-

Classes

-
-
-class DatasetReader -
-
-

DatasetReader()

-
- -Expand source code - -
@dataclass
-class DatasetReader:
-    def read_array(self, source: str, path_in_external_file: str) -> Optional[List[Any]]:
-        return None
-
-    def get_array_dimension(self, source: str, path_in_external_file: str) -> Optional[List[Any]]:
-        return None
-
-

Subclasses

- -

Methods

-
-
-def get_array_dimension(self, source: str, path_in_external_file: str) ‑> Optional[List[Any]] -
-
-
-
- -Expand source code - -
def get_array_dimension(self, source: str, path_in_external_file: str) -> Optional[List[Any]]:
-    return None
-
-
-
-def read_array(self, source: str, path_in_external_file: str) ‑> Optional[List[Any]] -
-
-
-
- -Expand source code - -
def read_array(self, source: str, path_in_external_file: str) -> Optional[List[Any]]:
-    return None
-
-
-
-
-
-class ETPReader -
-
-

ETPReader()

-
- -Expand source code - -
@dataclass
-class ETPReader(DatasetReader):
-    def read_array(self, obj_uri: str, path_in_external_file: str) -> Optional[List[Any]]:
-        return None
-
-    def get_array_dimension(self, source: str, path_in_external_file: str) -> Optional[List[Any]]:
-        return None
-
-

Ancestors

- -

Methods

-
-
-def get_array_dimension(self, source: str, path_in_external_file: str) ‑> Optional[List[Any]] -
-
-
-
- -Expand source code - -
def get_array_dimension(self, source: str, path_in_external_file: str) -> Optional[List[Any]]:
-    return None
-
-
-
-def read_array(self, obj_uri: str, path_in_external_file: str) ‑> Optional[List[Any]] -
-
-
-
- -Expand source code - -
def read_array(self, obj_uri: str, path_in_external_file: str) -> Optional[List[Any]]:
-    return None
-
-
-
-
-
-class HDF5FileReader -
-
-

HDF5FileReader()

-
- -Expand source code - -
@dataclass
-class HDF5FileReader(DatasetReader):
-    def read_array(self, source: Union[BytesIO, str], path_in_external_file: str) -> Optional[List[Any]]:
-        with h5py.File(source, "r") as f:
-            d_group = f[path_in_external_file]
-            return d_group[()].tolist()
-
-    def get_array_dimension(self, source: Union[BytesIO, str], path_in_external_file: str) -> Optional[List[Any]]:
-        with h5py.File(source, "r") as f:
-            return list(f[path_in_external_file].shape)
-
-    def extract_h5_datasets(
-            self, input_h5: Union[BytesIO, str], output_h5: Union[BytesIO, str], h5_datasets_paths: List[str]
-    ) -> None:
-        """
-        Copy all dataset from :param input_h5 matching with paths in :param h5_datasets_paths into the :param output
-        :param input_h5:
-        :param output_h5:
-        :param h5_datasets_paths:
-        :return:
-        """
-        if len(h5_datasets_paths) > 0:
-            with h5py.File(output_h5, "w") as f_dest:
-                with h5py.File(input_h5, "r") as f_src:
-                    for dataset in h5_datasets_paths:
-                        f_dest.create_dataset(dataset, data=f_src[dataset])
-
-

Ancestors

- -

Methods

-
-
-def extract_h5_datasets(self, input_h5: Union[_io.BytesIO, str], output_h5: Union[_io.BytesIO, str], h5_datasets_paths: List[str]) ‑> None -
-
-

Copy all dataset from :param input_h5 matching with paths in :param h5_datasets_paths into the :param output -:param input_h5: -:param output_h5: -:param h5_datasets_paths: -:return:

-
- -Expand source code - -
def extract_h5_datasets(
-        self, input_h5: Union[BytesIO, str], output_h5: Union[BytesIO, str], h5_datasets_paths: List[str]
-) -> None:
-    """
-    Copy all dataset from :param input_h5 matching with paths in :param h5_datasets_paths into the :param output
-    :param input_h5:
-    :param output_h5:
-    :param h5_datasets_paths:
-    :return:
-    """
-    if len(h5_datasets_paths) > 0:
-        with h5py.File(output_h5, "w") as f_dest:
-            with h5py.File(input_h5, "r") as f_src:
-                for dataset in h5_datasets_paths:
-                    f_dest.create_dataset(dataset, data=f_src[dataset])
-
-
-
-def get_array_dimension(self, source: Union[_io.BytesIO, str], path_in_external_file: str) ‑> Optional[List[Any]] -
-
-
-
- -Expand source code - -
def get_array_dimension(self, source: Union[BytesIO, str], path_in_external_file: str) -> Optional[List[Any]]:
-    with h5py.File(source, "r") as f:
-        return list(f[path_in_external_file].shape)
-
-
-
-def read_array(self, source: Union[_io.BytesIO, str], path_in_external_file: str) ‑> Optional[List[Any]] -
-
-
-
- -Expand source code - -
def read_array(self, source: Union[BytesIO, str], path_in_external_file: str) -> Optional[List[Any]]:
-    with h5py.File(source, "r") as f:
-        d_group = f[path_in_external_file]
-        return d_group[()].tolist()
-
-
-
-
-
-
-
- -
- - - \ No newline at end of file diff --git a/energyml-utils/docs/src/energyml/utils/data/helper.html b/energyml-utils/docs/src/energyml/utils/data/helper.html deleted file mode 100644 index 94a6a65..0000000 --- a/energyml-utils/docs/src/energyml/utils/data/helper.html +++ /dev/null @@ -1,1286 +0,0 @@ - - - - - - -src.energyml.utils.data.helper API documentation - - - - - - - - - - - -
-
-
-

Module src.energyml.utils.data.helper

-
-
-
- -Expand source code - -
# Copyright (c) 2023-2024 Geosiris.
-# SPDX-License-Identifier: Apache-2.0
-import inspect
-import sys
-from typing import Any, Optional, Callable, Literal, List, Union, Tuple
-
-from .hdf import get_hdf5_path_from_external_path, HDF5FileReader, get_hdf_reference, get_crs_obj
-from ..epc import Epc, get_obj_identifier
-from ..introspection import snake_case, get_object_attribute_no_verif, \
-    search_attribute_matching_name_with_path, search_attribute_matching_name, flatten_concatenation, \
-    search_attribute_in_upper_matching_name
-
-_ARRAY_NAMES_ = [
-    "BooleanArrayFromDiscretePropertyArray",
-    "BooleanArrayFromIndexArray",
-    "BooleanConstantArray",
-    "BooleanExternalArray",
-    "BooleanHdf5Array",
-    "BooleanXmlArray",
-    "CompoundExternalArray",
-    "DasTimeArray",
-    "DoubleConstantArray",
-    "DoubleHdf5Array",
-    "DoubleLatticeArray",
-    "ExternalDataArray",
-    "FloatingPointConstantArray",
-    "FloatingPointExternalArray",
-    "FloatingPointLatticeArray",
-    "FloatingPointXmlArray",
-    "IntegerArrayFromBooleanMaskArray",
-    "IntegerConstantArray",
-    "IntegerExternalArray",
-    "IntegerHdf5Array",
-    "IntegerLatticeArray",
-    "IntegerRangeArray",
-    "IntegerXmlArray",
-    "JaggedArray",
-    "ParametricLineArray",
-    "ParametricLineFromRepresentationLatticeArray",
-    "Point2DHdf5Array",
-    "Point3DFromRepresentationLatticeArray",
-    "Point3DHdf5Array",
-    "Point3DLatticeArray",
-    "Point3DParametricArray",
-    "Point3DZvalueArray",
-    "ResqmlJaggedArray",
-    "StringConstantArray",
-    "StringExternalArray",
-    "StringHdf5Array",
-    "StringXmlArray"
-]
-
-
-def get_array_reader_function(array_type_name: str) -> Optional[Callable]:
-    """
-    Returns the name of the potential appropriate function to read an object with type is named :param array_type_name
-    :param array_type_name: the initial type name
-    :return:
-    """
-    for name, obj in inspect.getmembers(sys.modules[__name__]):
-        if name == f"read_{snake_case(array_type_name)}":
-            return obj
-    return None
-
-
-def _array_name_mapping(array_type_name: str) -> str:
-    """
-    Transform the type name to match existing reader function
-    :param array_type_name:
-    :return:
-    """
-    array_type_name = array_type_name.replace("3D", "3d").replace("2D", "2d")
-    if array_type_name.endswith("ConstantArray"):
-        return "ConstantArray"
-    elif "External" in array_type_name or "Hdf5" in array_type_name:
-        return "ExternalArray"
-    elif array_type_name.endswith("XmlArray"):
-        return "XmlArray"
-    elif "Jagged" in array_type_name:
-        return "JaggedArray"
-    elif "Lattice" in array_type_name:
-        if "Integer" in array_type_name or "Double" in array_type_name:
-            return "int_double_lattice_array"
-    return array_type_name
-
-
-def read_array(
-        energyml_array: Any,
-        root_obj: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-) -> List[Any]:
-    """
-    Read an array and return a list. The array is read depending on its type. see. :py:func:`energyml.utils.data.helper.get_supported_array`
-    :param energyml_array:
-    :param root_obj:
-    :param path_in_root:
-    :param epc:
-    :return:
-    """
-    if isinstance(energyml_array, list):
-        return energyml_array
-    array_type_name = _array_name_mapping(type(energyml_array).__name__)
-
-    reader_func = get_array_reader_function(array_type_name)
-    if reader_func is not None:
-        return reader_func(
-            energyml_array=energyml_array,
-            root_obj=root_obj,
-            path_in_root=path_in_root,
-            epc=epc,
-        )
-    else:
-        print(f"Type {array_type_name} is not supported: function read_{snake_case(array_type_name)} not found")
-        raise Exception(f"Type {array_type_name} is not supported\n\t{energyml_array}: \n\tfunction read_{snake_case(array_type_name)} not found")
-
-
-def get_supported_array() -> List[str]:
-    """
-    Return a list of the supported arrays for the use of :py:func:`energyml.utils.data.helper.read_array` function.
-    :return:
-    """
-    return [x for x in _ARRAY_NAMES_ if get_array_reader_function(_array_name_mapping(x)) is not None]
-
-
-def get_not_supported_array():
-    """
-    Return a list of the NOT supported arrays for the use of :py:func:`energyml.utils.data.helper.read_array` function.
-    :return:
-    """
-    return [x for x in _ARRAY_NAMES_ if get_array_reader_function(_array_name_mapping(x)) is None]
-
-
-def read_constant_array(
-        energyml_array: Any,
-        root_obj: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-) -> List[Any]:
-    """
-    Read a constant array ( BooleanConstantArray, DoubleConstantArray, FloatingPointConstantArray, IntegerConstantArray ...)
-    :param energyml_array:
-    :param root_obj:
-    :param path_in_root:
-    :param epc:
-    :return:
-    """
-    # print(f"Reading constant array\n\t{energyml_array}")
-
-    value = get_object_attribute_no_verif(energyml_array, "value")
-    count = get_object_attribute_no_verif(energyml_array, "count")
-
-    # print(f"\tValue : {[value for i in range(0, count)]}")
-
-    return [value for i in range(0, count)]
-
-
-def read_xml_array(
-        energyml_array: Any,
-        root_obj: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-) -> List[Any]:
-    """
-    Read a xml array ( BooleanXmlArray, FloatingPointXmlArray, IntegerXmlArray, StringXmlArray ...)
-    :param energyml_array:
-    :param root_obj:
-    :param path_in_root:
-    :param epc:
-    :return:
-    """
-    values = get_object_attribute_no_verif(energyml_array, "values")
-    # count = get_object_attribute_no_verif(energyml_array, "count_per_value")
-    return values
-
-
-def read_jagged_array(
-        energyml_array: Any,
-        root_obj: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-) -> List[Any]:
-    """
-    Read a jagged array
-    :param energyml_array:
-    :param root_obj:
-    :param path_in_root:
-    :param epc:
-    :return:
-    """
-    elements = read_array(
-        energyml_array=get_object_attribute_no_verif(energyml_array, "elements"),
-        root_obj=root_obj,
-        path_in_root=path_in_root + ".elements",
-        epc=epc,
-    )
-    cumulative_length = read_array(
-        energyml_array=read_array(get_object_attribute_no_verif(energyml_array, "cumulative_length")),
-        root_obj=root_obj,
-        path_in_root=path_in_root + ".cumulative_length",
-        epc=epc,
-    )
-
-    res = []
-    previous = 0
-    for cl in cumulative_length:
-        res.append(elements[previous: cl])
-        previous = cl
-    return res
-
-
-def read_external_array(
-        energyml_array: Any,
-        root_obj: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-) -> List[Any]:
-    """
-    Read an external array (BooleanExternalArray, BooleanHdf5Array, DoubleHdf5Array, IntegerHdf5Array, StringExternalArray ...)
-    :param energyml_array:
-    :param root_obj:
-    :param path_in_root:
-    :param epc:
-    :return:
-    """
-    hdf5_path = get_hdf5_path_from_external_path(
-                external_path_obj=energyml_array,
-                path_in_root=path_in_root,
-                root_obj=root_obj,
-                epc=epc,
-    )
-    h5_reader = HDF5FileReader()
-    path_in_external = get_hdf_reference(energyml_array)[0]
-
-    result_array = h5_reader.read_array(hdf5_path, path_in_external)
-
-    if path_in_root.lower().endswith("points") and len(result_array) > 0 and len(result_array[0]) == 3:
-        crs = get_crs_obj(
-            context_obj=energyml_array,
-            path_in_root=path_in_root,
-            root_obj=root_obj,
-            epc=epc,
-        )
-        zincreasing_downward = is_z_reversed(crs)
-
-        if zincreasing_downward:
-            result_array = list(map(lambda p: [p[0], p[1], -p[2]], result_array))
-
-    return result_array
-
-
-def read_int_double_lattice_array(
-        energyml_array: Any,
-        root_obj: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-):
-    """
-    Read DoubleLatticeArray or IntegerLatticeArray.
-    :param energyml_array:
-    :param root_obj:
-    :param path_in_root:
-    :param epc:
-    :return:
-    """
-    start_value = get_object_attribute_no_verif(energyml_array, "start_value")
-    offset = get_object_attribute_no_verif(energyml_array, "offset")
-
-    result = []
-
-    # if len(offset) == 1:
-    #     pass
-    # elif len(offset) == 2:
-    #     pass
-    # else:
-    raise Exception(f"{type(energyml_array)} read with an offset of length {len(offset)} is not supported")
-
-    # return result
-
-
-def _point_as_array(point: Any) -> List:
-    """
-    Transform a point that has "coordinate1", "coordinate2", "coordinate3" as attributes into a list.
-    :param point:
-    :return:
-    """
-    return [
-        get_object_attribute_no_verif(point, "coordinate1"),
-        get_object_attribute_no_verif(point, "coordinate2"),
-        get_object_attribute_no_verif(point, "coordinate3"),
-    ]
-
-
-def prod_n_tab(val: Union[float, int, str], tab: List[Union[float, int, str]]):
-    """
-    Multiply every value of the list 'tab' by the constant 'val'
-    :param val:
-    :param tab:
-    :return:
-    """
-    return list(map(lambda x: x*val, tab))
-
-
-def sum_lists(l1: List, l2: List):
-    """
-    Sums 2 lists values.
-
-    Example:
-        [1,1,1] and [2,2,3,6] gives : [3,3,4,6]
-
-    :param l1:
-    :param l2:
-    :return:
-    """
-    return [l1[i] + l2[i] for i in range(min(len(l1), len(l2)))]+max(l1, l2, key=len)[min(len(l1), len(l2)):]
-
-
-def read_point3d_zvalue_array(
-        energyml_array: Any,
-        root_obj: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-):
-    """
-    Read a Point3D2ValueArray
-    :param energyml_array:
-    :param root_obj:
-    :param path_in_root:
-    :param epc:
-    :return:
-    """
-    supporting_geometry = get_object_attribute_no_verif(energyml_array, "supporting_geometry")
-    sup_geom_array = read_array(
-        energyml_array=supporting_geometry,
-        root_obj=root_obj,
-        path_in_root=path_in_root + ".SupportingGeometry",
-        epc=epc,
-    )
-
-    zvalues = get_object_attribute_no_verif(energyml_array, "zvalues")
-    zvalues_array = flatten_concatenation(read_array(
-        energyml_array=zvalues,
-        root_obj=root_obj,
-        path_in_root=path_in_root + ".ZValues",
-        epc=epc,
-    ))
-
-    count = 0
-
-    for i in range(len(sup_geom_array)):
-        try:
-            sup_geom_array[i][2] = zvalues_array[i]
-        except Exception as e:
-            if count == 0:
-                print(e, f": {i} is out of bound of {len(zvalues_array)}")
-                count = count + 1
-
-    return sup_geom_array
-
-
-def read_point3d_from_representation_lattice_array(
-        energyml_array: Any,
-        root_obj: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-):
-    """
-    Read a Point3DFromRepresentationLatticeArray.
-
-    Note: Only works for Grid2DRepresentation.
-
-    :param energyml_array:
-    :param root_obj:
-    :param path_in_root:
-    :param epc:
-    :return:
-    """
-    supporting_rep_identifier = get_obj_identifier(get_object_attribute_no_verif(energyml_array, "supporting_representation"))
-    print(f"energyml_array : {energyml_array}\n\t{supporting_rep_identifier}")
-    supporting_rep = epc.get_object_by_identifier(supporting_rep_identifier)
-
-    # TODO chercher un pattern \.*patch\.*.[d]+ pour trouver le numero du patch dans le path_in_root puis lire le patch
-    # print(f"path_in_root {path_in_root}")
-
-    result = []
-    if "grid2d" in str(type(supporting_rep)).lower():
-        patch_path, patch = search_attribute_matching_name_with_path(supporting_rep, "Grid2dPatch")[0]
-        points = read_grid2d_patch(
-            patch=patch,
-            grid2d=supporting_rep,
-            path_in_root=patch_path,
-            epc=epc,
-        )
-        # TODO: take the points by there indices from the NodeIndicesOnSupportingRepresentation
-        result = points
-
-    else:
-        raise Exception(f"Not supported type {type(energyml_array)} for object {type(root_obj)}")
-    # pour trouver les infos qu'il faut
-    return result
-
-
-def read_grid2d_patch(
-        patch: Any,
-        grid2d: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-) -> List:
-    points_path, points_obj = search_attribute_matching_name_with_path(patch, "Geometry.Points")[0]
-
-    return read_array(
-        energyml_array=points_obj,
-        root_obj=grid2d,
-        path_in_root=path_in_root + points_path,
-        epc=epc,
-    )
-
-
-def read_point3d_lattice_array(
-        energyml_array: Any,
-        root_obj: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-) -> List:
-    """
-    Read a Point3DLatticeArray.
-
-    Note: If a CRS is found and its 'ZIncreasingDownward' is set to true or its
-
-    :param energyml_array:
-    :param root_obj:
-    :param path_in_root:
-    :param epc:
-    :return:
-    """
-    result = []
-    origin = _point_as_array(get_object_attribute_no_verif(energyml_array, "origin"))
-    offset = get_object_attribute_no_verif(energyml_array, "offset")
-
-    if len(offset) == 2:
-        slowest = offset[0]
-        fastest = offset[1]
-
-        crs_sa_count = search_attribute_in_upper_matching_name(
-            obj=energyml_array,
-            name_rgx="SlowestAxisCount",
-            root_obj=root_obj,
-            current_path=path_in_root,
-        )
-
-        crs_fa_count = search_attribute_in_upper_matching_name(
-            obj=energyml_array,
-            name_rgx="FastestAxisCount",
-            root_obj=root_obj,
-            current_path=path_in_root,
-        )
-
-        crs = get_crs_obj(
-            context_obj=energyml_array,
-            path_in_root=path_in_root,
-            root_obj=root_obj,
-            epc=epc,
-        )
-        zincreasing_downward = is_z_reversed(crs)
-
-        slowest_vec = _point_as_array(get_object_attribute_no_verif(slowest, "offset"))
-        slowest_spacing = read_array(get_object_attribute_no_verif(slowest, "spacing"))
-        slowest_table = list(map(lambda x: prod_n_tab(x, slowest_vec), slowest_spacing))
-
-        fastest_vec = _point_as_array(get_object_attribute_no_verif(fastest, "offset"))
-        fastest_spacing = read_array(get_object_attribute_no_verif(fastest, "spacing"))
-        fastest_table = list(map(lambda x: prod_n_tab(x, fastest_vec), fastest_spacing))
-
-        slowest_size = len(slowest_table)
-        fastest_size = len(fastest_table)
-
-        if len(crs_sa_count) > 0 and len(crs_fa_count) and crs_sa_count[0] == fastest_size:
-            print("reversing order")
-            # if offset were given in the wrong order
-            tmp_table = slowest_table
-            slowest_table = fastest_table
-            fastest_table = tmp_table
-
-            tmp_size = slowest_size
-            slowest_size = fastest_size
-            fastest_size = tmp_size
-
-        for i in range(slowest_size):
-            for j in range(fastest_size):
-                previous_value = origin
-                # to avoid a sum of the parts of the array at each iteration, I take the previous value in the same line
-                # number i and add the fastest_table[j] value
-
-                if j > 0:
-                    if i > 0:
-                        line_idx = i * fastest_size  # numero de ligne
-                        previous_value = result[line_idx + j - 1]
-                    else:
-                        previous_value = result[j - 1]
-                    if zincreasing_downward:
-                        result.append(sum_lists(previous_value, slowest_table[i - 1]))
-                    else:
-                        result.append(sum_lists(previous_value, fastest_table[j - 1]))
-                else:
-                    if i > 0:
-                        prev_line_idx = (i - 1) * fastest_size  # numero de ligne precedent
-                        previous_value = result[prev_line_idx]
-                        if zincreasing_downward:
-                            result.append(sum_lists(previous_value, fastest_table[j - 1]))
-                        else:
-                            result.append(sum_lists(previous_value, slowest_table[i - 1]))
-                    else:
-                        result.append(previous_value)
-    else:
-        raise Exception(f"{type(energyml_array)} read with an offset of length {len(offset)} is not supported")
-
-    return result
-
-
-def is_z_reversed(crs: Optional[Any]) -> bool:
-    """
-    Returns True if the Z axe is reverse (ZIncreasingDownward=='True' or VerticalAxis.Direction=='down')
-    :param crs:
-    :return: By default, False is returned (if 'crs' is None)
-    """
-    reverse_z_values = False
-    if crs is not None:
-        # resqml 201
-        zincreasing_downward = search_attribute_matching_name(crs, "ZIncreasingDownward")
-        if len(zincreasing_downward) > 0:
-            reverse_z_values = zincreasing_downward[0]
-
-        # resqml >= 22
-        vert_axis = search_attribute_matching_name(crs, "VerticalAxis.Direction")
-        if len(vert_axis) > 0:
-            reverse_z_values = vert_axis[0].lower() == "down"
-
-    return reverse_z_values
-
-
-# def read_boolean_constant_array(
-#         energyml_array: Any,
-#         root_obj: Optional[Any] = None,
-#         path_in_root: Optional[str] = None,
-#         epc: Optional[Epc] = None
-# ):
-#     print(energyml_array)
-
-
-
-
-
-
-
-

Functions

-
-
-def get_array_reader_function(array_type_name: str) ‑> Optional[Callable] -
-
-

Returns the name of the potential appropriate function to read an object with type is named :param array_type_name -:param array_type_name: the initial type name -:return:

-
- -Expand source code - -
def get_array_reader_function(array_type_name: str) -> Optional[Callable]:
-    """
-    Returns the name of the potential appropriate function to read an object with type is named :param array_type_name
-    :param array_type_name: the initial type name
-    :return:
-    """
-    for name, obj in inspect.getmembers(sys.modules[__name__]):
-        if name == f"read_{snake_case(array_type_name)}":
-            return obj
-    return None
-
-
-
-def get_not_supported_array() -
-
-

Return a list of the NOT supported arrays for the use of :py:func:energyml.utils.data.helper.read_array function. -:return:

-
- -Expand source code - -
def get_not_supported_array():
-    """
-    Return a list of the NOT supported arrays for the use of :py:func:`energyml.utils.data.helper.read_array` function.
-    :return:
-    """
-    return [x for x in _ARRAY_NAMES_ if get_array_reader_function(_array_name_mapping(x)) is None]
-
-
-
-def get_supported_array() ‑> List[str] -
-
-

Return a list of the supported arrays for the use of :py:func:energyml.utils.data.helper.read_array function. -:return:

-
- -Expand source code - -
def get_supported_array() -> List[str]:
-    """
-    Return a list of the supported arrays for the use of :py:func:`energyml.utils.data.helper.read_array` function.
-    :return:
-    """
-    return [x for x in _ARRAY_NAMES_ if get_array_reader_function(_array_name_mapping(x)) is not None]
-
-
-
-def is_z_reversed(crs: Optional[Any]) ‑> bool -
-
-

Returns True if the Z axe is reverse (ZIncreasingDownward=='True' or VerticalAxis.Direction=='down') -:param crs: -:return: By default, False is returned (if 'crs' is None)

-
- -Expand source code - -
def is_z_reversed(crs: Optional[Any]) -> bool:
-    """
-    Returns True if the Z axe is reverse (ZIncreasingDownward=='True' or VerticalAxis.Direction=='down')
-    :param crs:
-    :return: By default, False is returned (if 'crs' is None)
-    """
-    reverse_z_values = False
-    if crs is not None:
-        # resqml 201
-        zincreasing_downward = search_attribute_matching_name(crs, "ZIncreasingDownward")
-        if len(zincreasing_downward) > 0:
-            reverse_z_values = zincreasing_downward[0]
-
-        # resqml >= 22
-        vert_axis = search_attribute_matching_name(crs, "VerticalAxis.Direction")
-        if len(vert_axis) > 0:
-            reverse_z_values = vert_axis[0].lower() == "down"
-
-    return reverse_z_values
-
-
-
-def prod_n_tab(val: Union[float, int, str], tab: List[Union[float, int, str]]) -
-
-

Multiply every value of the list 'tab' by the constant 'val' -:param val: -:param tab: -:return:

-
- -Expand source code - -
def prod_n_tab(val: Union[float, int, str], tab: List[Union[float, int, str]]):
-    """
-    Multiply every value of the list 'tab' by the constant 'val'
-    :param val:
-    :param tab:
-    :return:
-    """
-    return list(map(lambda x: x*val, tab))
-
-
-
-def read_array(energyml_array: Any, root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, epc: Optional[Epc] = None) ‑> List[Any] -
-
-

Read an array and return a list. The array is read depending on its type. see. :py:func:energyml.utils.data.helper.get_supported_array -:param energyml_array: -:param root_obj: -:param path_in_root: -:param epc: -:return:

-
- -Expand source code - -
def read_array(
-        energyml_array: Any,
-        root_obj: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-) -> List[Any]:
-    """
-    Read an array and return a list. The array is read depending on its type. see. :py:func:`energyml.utils.data.helper.get_supported_array`
-    :param energyml_array:
-    :param root_obj:
-    :param path_in_root:
-    :param epc:
-    :return:
-    """
-    if isinstance(energyml_array, list):
-        return energyml_array
-    array_type_name = _array_name_mapping(type(energyml_array).__name__)
-
-    reader_func = get_array_reader_function(array_type_name)
-    if reader_func is not None:
-        return reader_func(
-            energyml_array=energyml_array,
-            root_obj=root_obj,
-            path_in_root=path_in_root,
-            epc=epc,
-        )
-    else:
-        print(f"Type {array_type_name} is not supported: function read_{snake_case(array_type_name)} not found")
-        raise Exception(f"Type {array_type_name} is not supported\n\t{energyml_array}: \n\tfunction read_{snake_case(array_type_name)} not found")
-
-
-
-def read_constant_array(energyml_array: Any, root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, epc: Optional[Epc] = None) ‑> List[Any] -
-
-

Read a constant array ( BooleanConstantArray, DoubleConstantArray, FloatingPointConstantArray, IntegerConstantArray …) -:param energyml_array: -:param root_obj: -:param path_in_root: -:param epc: -:return:

-
- -Expand source code - -
def read_constant_array(
-        energyml_array: Any,
-        root_obj: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-) -> List[Any]:
-    """
-    Read a constant array ( BooleanConstantArray, DoubleConstantArray, FloatingPointConstantArray, IntegerConstantArray ...)
-    :param energyml_array:
-    :param root_obj:
-    :param path_in_root:
-    :param epc:
-    :return:
-    """
-    # print(f"Reading constant array\n\t{energyml_array}")
-
-    value = get_object_attribute_no_verif(energyml_array, "value")
-    count = get_object_attribute_no_verif(energyml_array, "count")
-
-    # print(f"\tValue : {[value for i in range(0, count)]}")
-
-    return [value for i in range(0, count)]
-
-
-
-def read_external_array(energyml_array: Any, root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, epc: Optional[Epc] = None) ‑> List[Any] -
-
-

Read an external array (BooleanExternalArray, BooleanHdf5Array, DoubleHdf5Array, IntegerHdf5Array, StringExternalArray …) -:param energyml_array: -:param root_obj: -:param path_in_root: -:param epc: -:return:

-
- -Expand source code - -
def read_external_array(
-        energyml_array: Any,
-        root_obj: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-) -> List[Any]:
-    """
-    Read an external array (BooleanExternalArray, BooleanHdf5Array, DoubleHdf5Array, IntegerHdf5Array, StringExternalArray ...)
-    :param energyml_array:
-    :param root_obj:
-    :param path_in_root:
-    :param epc:
-    :return:
-    """
-    hdf5_path = get_hdf5_path_from_external_path(
-                external_path_obj=energyml_array,
-                path_in_root=path_in_root,
-                root_obj=root_obj,
-                epc=epc,
-    )
-    h5_reader = HDF5FileReader()
-    path_in_external = get_hdf_reference(energyml_array)[0]
-
-    result_array = h5_reader.read_array(hdf5_path, path_in_external)
-
-    if path_in_root.lower().endswith("points") and len(result_array) > 0 and len(result_array[0]) == 3:
-        crs = get_crs_obj(
-            context_obj=energyml_array,
-            path_in_root=path_in_root,
-            root_obj=root_obj,
-            epc=epc,
-        )
-        zincreasing_downward = is_z_reversed(crs)
-
-        if zincreasing_downward:
-            result_array = list(map(lambda p: [p[0], p[1], -p[2]], result_array))
-
-    return result_array
-
-
-
-def read_grid2d_patch(patch: Any, grid2d: Optional[Any] = None, path_in_root: Optional[str] = None, epc: Optional[Epc] = None) ‑> List -
-
-
-
- -Expand source code - -
def read_grid2d_patch(
-        patch: Any,
-        grid2d: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-) -> List:
-    points_path, points_obj = search_attribute_matching_name_with_path(patch, "Geometry.Points")[0]
-
-    return read_array(
-        energyml_array=points_obj,
-        root_obj=grid2d,
-        path_in_root=path_in_root + points_path,
-        epc=epc,
-    )
-
-
-
-def read_int_double_lattice_array(energyml_array: Any, root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, epc: Optional[Epc] = None) -
-
-

Read DoubleLatticeArray or IntegerLatticeArray. -:param energyml_array: -:param root_obj: -:param path_in_root: -:param epc: -:return:

-
- -Expand source code - -
def read_int_double_lattice_array(
-        energyml_array: Any,
-        root_obj: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-):
-    """
-    Read DoubleLatticeArray or IntegerLatticeArray.
-    :param energyml_array:
-    :param root_obj:
-    :param path_in_root:
-    :param epc:
-    :return:
-    """
-    start_value = get_object_attribute_no_verif(energyml_array, "start_value")
-    offset = get_object_attribute_no_verif(energyml_array, "offset")
-
-    result = []
-
-    # if len(offset) == 1:
-    #     pass
-    # elif len(offset) == 2:
-    #     pass
-    # else:
-    raise Exception(f"{type(energyml_array)} read with an offset of length {len(offset)} is not supported")
-
-    # return result
-
-
-
-def read_jagged_array(energyml_array: Any, root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, epc: Optional[Epc] = None) ‑> List[Any] -
-
-

Read a jagged array -:param energyml_array: -:param root_obj: -:param path_in_root: -:param epc: -:return:

-
- -Expand source code - -
def read_jagged_array(
-        energyml_array: Any,
-        root_obj: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-) -> List[Any]:
-    """
-    Read a jagged array
-    :param energyml_array:
-    :param root_obj:
-    :param path_in_root:
-    :param epc:
-    :return:
-    """
-    elements = read_array(
-        energyml_array=get_object_attribute_no_verif(energyml_array, "elements"),
-        root_obj=root_obj,
-        path_in_root=path_in_root + ".elements",
-        epc=epc,
-    )
-    cumulative_length = read_array(
-        energyml_array=read_array(get_object_attribute_no_verif(energyml_array, "cumulative_length")),
-        root_obj=root_obj,
-        path_in_root=path_in_root + ".cumulative_length",
-        epc=epc,
-    )
-
-    res = []
-    previous = 0
-    for cl in cumulative_length:
-        res.append(elements[previous: cl])
-        previous = cl
-    return res
-
-
-
-def read_point3d_from_representation_lattice_array(energyml_array: Any, root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, epc: Optional[Epc] = None) -
-
-

Read a Point3DFromRepresentationLatticeArray.

-

Note: Only works for Grid2DRepresentation.

-

:param energyml_array: -:param root_obj: -:param path_in_root: -:param epc: -:return:

-
- -Expand source code - -
def read_point3d_from_representation_lattice_array(
-        energyml_array: Any,
-        root_obj: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-):
-    """
-    Read a Point3DFromRepresentationLatticeArray.
-
-    Note: Only works for Grid2DRepresentation.
-
-    :param energyml_array:
-    :param root_obj:
-    :param path_in_root:
-    :param epc:
-    :return:
-    """
-    supporting_rep_identifier = get_obj_identifier(get_object_attribute_no_verif(energyml_array, "supporting_representation"))
-    print(f"energyml_array : {energyml_array}\n\t{supporting_rep_identifier}")
-    supporting_rep = epc.get_object_by_identifier(supporting_rep_identifier)
-
-    # TODO chercher un pattern \.*patch\.*.[d]+ pour trouver le numero du patch dans le path_in_root puis lire le patch
-    # print(f"path_in_root {path_in_root}")
-
-    result = []
-    if "grid2d" in str(type(supporting_rep)).lower():
-        patch_path, patch = search_attribute_matching_name_with_path(supporting_rep, "Grid2dPatch")[0]
-        points = read_grid2d_patch(
-            patch=patch,
-            grid2d=supporting_rep,
-            path_in_root=patch_path,
-            epc=epc,
-        )
-        # TODO: take the points by there indices from the NodeIndicesOnSupportingRepresentation
-        result = points
-
-    else:
-        raise Exception(f"Not supported type {type(energyml_array)} for object {type(root_obj)}")
-    # pour trouver les infos qu'il faut
-    return result
-
-
-
-def read_point3d_lattice_array(energyml_array: Any, root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, epc: Optional[Epc] = None) ‑> List -
-
-

Read a Point3DLatticeArray.

-

Note: If a CRS is found and its 'ZIncreasingDownward' is set to true or its

-

:param energyml_array: -:param root_obj: -:param path_in_root: -:param epc: -:return:

-
- -Expand source code - -
def read_point3d_lattice_array(
-        energyml_array: Any,
-        root_obj: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-) -> List:
-    """
-    Read a Point3DLatticeArray.
-
-    Note: If a CRS is found and its 'ZIncreasingDownward' is set to true or its
-
-    :param energyml_array:
-    :param root_obj:
-    :param path_in_root:
-    :param epc:
-    :return:
-    """
-    result = []
-    origin = _point_as_array(get_object_attribute_no_verif(energyml_array, "origin"))
-    offset = get_object_attribute_no_verif(energyml_array, "offset")
-
-    if len(offset) == 2:
-        slowest = offset[0]
-        fastest = offset[1]
-
-        crs_sa_count = search_attribute_in_upper_matching_name(
-            obj=energyml_array,
-            name_rgx="SlowestAxisCount",
-            root_obj=root_obj,
-            current_path=path_in_root,
-        )
-
-        crs_fa_count = search_attribute_in_upper_matching_name(
-            obj=energyml_array,
-            name_rgx="FastestAxisCount",
-            root_obj=root_obj,
-            current_path=path_in_root,
-        )
-
-        crs = get_crs_obj(
-            context_obj=energyml_array,
-            path_in_root=path_in_root,
-            root_obj=root_obj,
-            epc=epc,
-        )
-        zincreasing_downward = is_z_reversed(crs)
-
-        slowest_vec = _point_as_array(get_object_attribute_no_verif(slowest, "offset"))
-        slowest_spacing = read_array(get_object_attribute_no_verif(slowest, "spacing"))
-        slowest_table = list(map(lambda x: prod_n_tab(x, slowest_vec), slowest_spacing))
-
-        fastest_vec = _point_as_array(get_object_attribute_no_verif(fastest, "offset"))
-        fastest_spacing = read_array(get_object_attribute_no_verif(fastest, "spacing"))
-        fastest_table = list(map(lambda x: prod_n_tab(x, fastest_vec), fastest_spacing))
-
-        slowest_size = len(slowest_table)
-        fastest_size = len(fastest_table)
-
-        if len(crs_sa_count) > 0 and len(crs_fa_count) and crs_sa_count[0] == fastest_size:
-            print("reversing order")
-            # if offset were given in the wrong order
-            tmp_table = slowest_table
-            slowest_table = fastest_table
-            fastest_table = tmp_table
-
-            tmp_size = slowest_size
-            slowest_size = fastest_size
-            fastest_size = tmp_size
-
-        for i in range(slowest_size):
-            for j in range(fastest_size):
-                previous_value = origin
-                # to avoid a sum of the parts of the array at each iteration, I take the previous value in the same line
-                # number i and add the fastest_table[j] value
-
-                if j > 0:
-                    if i > 0:
-                        line_idx = i * fastest_size  # numero de ligne
-                        previous_value = result[line_idx + j - 1]
-                    else:
-                        previous_value = result[j - 1]
-                    if zincreasing_downward:
-                        result.append(sum_lists(previous_value, slowest_table[i - 1]))
-                    else:
-                        result.append(sum_lists(previous_value, fastest_table[j - 1]))
-                else:
-                    if i > 0:
-                        prev_line_idx = (i - 1) * fastest_size  # numero de ligne precedent
-                        previous_value = result[prev_line_idx]
-                        if zincreasing_downward:
-                            result.append(sum_lists(previous_value, fastest_table[j - 1]))
-                        else:
-                            result.append(sum_lists(previous_value, slowest_table[i - 1]))
-                    else:
-                        result.append(previous_value)
-    else:
-        raise Exception(f"{type(energyml_array)} read with an offset of length {len(offset)} is not supported")
-
-    return result
-
-
-
-def read_point3d_zvalue_array(energyml_array: Any, root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, epc: Optional[Epc] = None) -
-
-

Read a Point3D2ValueArray -:param energyml_array: -:param root_obj: -:param path_in_root: -:param epc: -:return:

-
- -Expand source code - -
def read_point3d_zvalue_array(
-        energyml_array: Any,
-        root_obj: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-):
-    """
-    Read a Point3D2ValueArray
-    :param energyml_array:
-    :param root_obj:
-    :param path_in_root:
-    :param epc:
-    :return:
-    """
-    supporting_geometry = get_object_attribute_no_verif(energyml_array, "supporting_geometry")
-    sup_geom_array = read_array(
-        energyml_array=supporting_geometry,
-        root_obj=root_obj,
-        path_in_root=path_in_root + ".SupportingGeometry",
-        epc=epc,
-    )
-
-    zvalues = get_object_attribute_no_verif(energyml_array, "zvalues")
-    zvalues_array = flatten_concatenation(read_array(
-        energyml_array=zvalues,
-        root_obj=root_obj,
-        path_in_root=path_in_root + ".ZValues",
-        epc=epc,
-    ))
-
-    count = 0
-
-    for i in range(len(sup_geom_array)):
-        try:
-            sup_geom_array[i][2] = zvalues_array[i]
-        except Exception as e:
-            if count == 0:
-                print(e, f": {i} is out of bound of {len(zvalues_array)}")
-                count = count + 1
-
-    return sup_geom_array
-
-
-
-def read_xml_array(energyml_array: Any, root_obj: Optional[Any] = None, path_in_root: Optional[str] = None, epc: Optional[Epc] = None) ‑> List[Any] -
-
-

Read a xml array ( BooleanXmlArray, FloatingPointXmlArray, IntegerXmlArray, StringXmlArray …) -:param energyml_array: -:param root_obj: -:param path_in_root: -:param epc: -:return:

-
- -Expand source code - -
def read_xml_array(
-        energyml_array: Any,
-        root_obj: Optional[Any] = None,
-        path_in_root: Optional[str] = None,
-        epc: Optional[Epc] = None
-) -> List[Any]:
-    """
-    Read a xml array ( BooleanXmlArray, FloatingPointXmlArray, IntegerXmlArray, StringXmlArray ...)
-    :param energyml_array:
-    :param root_obj:
-    :param path_in_root:
-    :param epc:
-    :return:
-    """
-    values = get_object_attribute_no_verif(energyml_array, "values")
-    # count = get_object_attribute_no_verif(energyml_array, "count_per_value")
-    return values
-
-
-
-def sum_lists(l1: List, l2: List) -
-
-

Sums 2 lists values.

-

Example

-

[1,1,1] and [2,2,3,6] gives : [3,3,4,6]

-

:param l1: -:param l2: -:return:

-
- -Expand source code - -
def sum_lists(l1: List, l2: List):
-    """
-    Sums 2 lists values.
-
-    Example:
-        [1,1,1] and [2,2,3,6] gives : [3,3,4,6]
-
-    :param l1:
-    :param l2:
-    :return:
-    """
-    return [l1[i] + l2[i] for i in range(min(len(l1), len(l2)))]+max(l1, l2, key=len)[min(len(l1), len(l2)):]
-
-
-
-
-
-
-
- -
- - - \ No newline at end of file diff --git a/energyml-utils/docs/src/energyml/utils/data/index.html b/energyml-utils/docs/src/energyml/utils/data/index.html deleted file mode 100644 index 1fb08b3..0000000 --- a/energyml-utils/docs/src/energyml/utils/data/index.html +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - -src.energyml.utils.data API documentation - - - - - - - - - - - -
-
-
-

Module src.energyml.utils.data

-
-
-

The data module.

-

Contains functions to help the read of specific entities like Grid2DRepresentation, TriangulatedSetRepresentation etc. -It also contains functions to export data into OFF/OBJ format.

-
- -Expand source code - -
# Copyright (c) 2023-2024 Geosiris.
-# SPDX-License-Identifier: Apache-2.0
-"""
-The data module.
-
-Contains functions to help the read of specific entities like Grid2DRepresentation, TriangulatedSetRepresentation etc.
-It also contains functions to export data into OFF/OBJ format.
-"""
-
-
-
-

Sub-modules

-
-
src.energyml.utils.data.hdf
-
-
-
-
src.energyml.utils.data.helper
-
-
-
-
src.energyml.utils.data.mesh
-
-
-
-
-
-
-
-
-
-
-
-
- -
- - - \ No newline at end of file diff --git a/energyml-utils/docs/src/energyml/utils/data/mesh.html b/energyml-utils/docs/src/energyml/utils/data/mesh.html deleted file mode 100644 index 6ae6e4b..0000000 --- a/energyml-utils/docs/src/energyml/utils/data/mesh.html +++ /dev/null @@ -1,1463 +0,0 @@ - - - - - - -src.energyml.utils.data.mesh API documentation - - - - - - - - - - - -
-
-
-

Module src.energyml.utils.data.mesh

-
-
-
- -Expand source code - -
# Copyright (c) 2023-2024 Geosiris.
-# SPDX-License-Identifier: Apache-2.0
-import inspect
-import re
-import sys
-from dataclasses import dataclass, field
-from io import BytesIO
-from typing import List, Optional, Any, Callable
-
-from .hdf import get_hdf_reference_with_path, \
-    get_hdf5_path_from_external_path, HDF5FileReader, get_crs_obj
-from .helper import read_array, read_grid2d_patch, is_z_reversed
-from ..epc import Epc, get_obj_identifier
-from ..introspection import search_attribute_matching_name, \
-    search_attribute_matching_type_with_path, \
-    search_attribute_matching_name_with_path, snake_case
-
-_FILE_HEADER: bytes = b"# file exported by energyml-utils python module (Geosiris)\n"
-
-Point = list[float]
-
-
-@dataclass
-class AbstractMesh:
-    energyml_object: Any = field(
-        default=None
-    )
-
-    crs_object: Any = field(
-        default=None
-    )
-
-    point_list: List[Point] = field(
-        default_factory=list,
-    )
-
-    identifier: str = field(
-        default=None,
-    )
-
-    def export_off(self, out: BytesIO) -> None:
-        pass
-
-    def get_nb_edges(self) -> int:
-        return 0
-
-    def get_nb_faces(self) -> int:
-        return 0
-
-    def get_indices(self) -> List[List[int]]:
-        return []
-
-
-@dataclass
-class PointSetMesh(AbstractMesh):
-    pass
-
-
-@dataclass
-class PolylineSetMesh(AbstractMesh):
-    line_indices: List[List[int]] = field(
-        default_factory=list,
-    )
-
-    def get_nb_edges(self) -> int:
-        return sum(list(map(lambda li: len(li) - 1, self.line_indices)))
-
-    def get_nb_faces(self) -> int:
-        return 0
-
-    def get_indices(self) -> List[List[int]]:
-        return self.line_indices
-
-
-@dataclass
-class SurfaceMesh(AbstractMesh):
-    faces_indices: List[List[int]] = field(
-        default_factory=list,
-    )
-
-    def get_nb_edges(self) -> int:
-        return sum(list(map(lambda li: len(li) - 1, self.faces_indices)))
-
-    def get_nb_faces(self) -> int:
-        return len(self.faces_indices)
-
-    def get_indices(self) -> List[List[int]]:
-        return self.faces_indices
-
-
-def get_mesh_reader_function(mesh_type_name: str) -> Optional[Callable]:
-    """
-    Returns the name of the potential appropriate function to read an object with type is named mesh_type_name
-    :param mesh_type_name: the initial type name
-    :return:
-    """
-    for name, obj in inspect.getmembers(sys.modules[__name__]):
-        if name == f"read_{snake_case(mesh_type_name)}":
-            return obj
-    return None
-
-
-def _mesh_name_mapping(array_type_name: str) -> str:
-    """
-    Transform the type name to match existing reader function
-    :param array_type_name:
-    :return:
-    """
-    array_type_name = array_type_name.replace("3D", "3d").replace("2D", "2d")
-    array_type_name = re.sub("^[Oo]bj([A-Z])", r"\1", array_type_name)
-    return array_type_name
-
-
-def read_mesh_object(
-        energyml_object: Any,
-        epc: Optional[Epc] = None
-) -> List[AbstractMesh]:
-    """
-    Read and "meshable" object. If :param:`energyml_object` is not supported, an exception will be raised.
-    :param energyml_object:
-    :param epc:
-    :return:
-    """
-    if isinstance(energyml_object, list):
-        return energyml_object
-    array_type_name = _mesh_name_mapping(type(energyml_object).__name__)
-
-    reader_func = get_mesh_reader_function(array_type_name)
-    if reader_func is not None:
-        return reader_func(
-            energyml_object=energyml_object,
-            epc=epc,
-        )
-    else:
-        print(f"Type {array_type_name} is not supported: function read_{snake_case(array_type_name)} not found")
-        raise Exception(f"Type {array_type_name} is not supported\n\t{energyml_object}: \n\tfunction read_{snake_case(array_type_name)} not found")
-
-
-def read_point_set_representation(energyml_object: Any, epc: Epc) -> List[PointSetMesh]:
-    # pt_geoms = search_attribute_matching_type(point_set, "AbstractGeometry")
-    h5_reader = HDF5FileReader()
-
-    meshes = []
-    for refer_path, refer_value in get_hdf_reference_with_path(energyml_object):
-        try:
-            hdf5_path = get_hdf5_path_from_external_path(
-                external_path_obj=refer_value,
-                path_in_root=refer_path,
-                root_obj=energyml_object,
-                epc=epc,
-            )
-            crs = get_crs_obj(
-                context_obj=refer_value,
-                path_in_root=refer_path,
-                root_obj=energyml_object,
-                epc=epc,
-            )
-            if hdf5_path is not None:
-                print(f"Reading h5 file : {hdf5_path}")
-                meshes.append(PointSetMesh(
-                    identifier=refer_value,
-                    energyml_object=energyml_object,
-                    crs_object=crs,
-                    point_list=h5_reader.read_array(hdf5_path, refer_value)
-                ))
-        except Exception as e:
-            print(f"Error with path {refer_path} -- {energyml_object}")
-            raise e
-    return meshes
-
-
-def read_polyline_set_representation(energyml_object: Any, epc: Epc) -> List[PointSetMesh]:
-    # pt_geoms = search_attribute_matching_type(point_set, "AbstractGeometry")
-    h5_reader = HDF5FileReader()
-
-    meshes = []
-
-    patch_idx = 0
-    for path_path_in_obj, patch in search_attribute_matching_name_with_path(energyml_object, "LinePatch"):
-        print(f"patch {patch}")
-        geometry_path_in_obj, geometry = search_attribute_matching_name_with_path(patch, "geometry")[0]
-        node_count_per_poly_path_in_obj, node_count_per_poly = \
-        search_attribute_matching_name_with_path(patch, "NodeCountPerPolyline")[0]
-        points_ext_array = search_attribute_matching_type_with_path(geometry, "ExternalDataArrayPart|Hdf5Dataset")
-        node_count_ext_array = search_attribute_matching_type_with_path(node_count_per_poly,
-                                                                        "ExternalDataArrayPart|Hdf5Dataset")
-
-        if len(points_ext_array) > 0:
-            point_per_elt = []
-            point_indices = []
-            crs = None
-
-            # Reading points
-            for patch_part_path, patchPart_value in points_ext_array:
-                patch_part_full_path_in_obj = path_path_in_obj + geometry_path_in_obj + patch_part_path
-                for refer_path, refer_value in get_hdf_reference_with_path(patchPart_value):
-                    print(f"refer_path {patch_part_full_path_in_obj}{refer_path} refer_value{refer_value} ")
-                    hdf5_path = get_hdf5_path_from_external_path(
-                        external_path_obj=refer_value,
-                        path_in_root=patch_part_full_path_in_obj + refer_path,
-                        root_obj=energyml_object,
-                        epc=epc,
-                    )
-                    crs = get_crs_obj(
-                        context_obj=refer_value,
-                        path_in_root=patch_part_full_path_in_obj + refer_path,
-                        root_obj=energyml_object,
-                        epc=epc,
-                    )
-                    if hdf5_path is not None:
-                        print(f"Reading h5 file : {hdf5_path}")
-                        point_per_elt = point_per_elt + h5_reader.read_array(hdf5_path, refer_value)
-
-            # Reading polyline indices
-            # for patch_part_path, patchPart_value in node_count_ext_array:
-            #     patch_part_full_path_in_obj = path_path_in_obj + node_count_per_poly_path_in_obj + patch_part_path
-            #     for refer_path, refer_value in get_hdf_reference_with_path(patchPart_value):
-            #         print(f"refer_path: {patch_part_full_path_in_obj}{refer_path} refer_value: {refer_value} ")
-            #         hdf5_path = get_hdf5_path_from_external_path(
-            #                     external_path_obj=refer_value,
-            #                     path_in_root=patch_part_full_path_in_obj + refer_path,
-            #                     root_obj=energyml_object,
-            #                     epc=epc,
-            #         )
-            #         if hdf5_path is not None:
-            #             node_counts_list = h5_reader.read_array(hdf5_path, refer_value)
-            #             idx = 0
-            #             for nb_node in node_counts_list:
-            #                 point_indices.append([x for x in range(idx, idx + nb_node)])
-            #                 idx = idx + nb_node
-
-            node_counts_list = read_array(
-                energyml_array=node_count_per_poly,
-                root_obj=energyml_object,
-                path_in_root=path_path_in_obj + node_count_per_poly_path_in_obj,
-                epc=epc,
-            )
-            idx = 0
-            for nb_node in node_counts_list:
-                point_indices.append([x for x in range(idx, idx + nb_node)])
-                idx = idx + nb_node
-
-            if len(point_per_elt) > 0:
-                # poly_idx = 0
-                # for single_poly_indices in point_indices:
-                meshes.append(PolylineSetMesh(
-                    # identifier=f"{get_obj_identifier(energyml_object)}_patch{patch_idx}_poly{poly_idx}",
-                    identifier=f"{get_obj_identifier(energyml_object)}_patch{patch_idx}",
-                    energyml_object=energyml_object,
-                    crs_object=crs,
-                    point_list=point_per_elt,
-                    line_indices=point_indices
-                ))
-                # poly_idx = poly_idx + 1
-        patch_idx = patch_idx + 1
-
-    return meshes
-
-
-def read_grid2d_representation(energyml_object: Any, epc: Epc, keep_holes=False) -> List[SurfaceMesh]:
-    # h5_reader = HDF5FileReader()
-    meshes = []
-
-    patch_idx = 0
-    for patch_path, patch in search_attribute_matching_name_with_path(energyml_object, "Grid2dPatch"):
-        crs = get_crs_obj(
-            context_obj=patch,
-            path_in_root=patch_path,
-            root_obj=energyml_object,
-            epc=epc,
-        )
-
-        reverse_z_values = is_z_reversed(crs)
-
-        points = read_grid2d_patch(
-            patch=patch,
-            grid2d=energyml_object,
-            path_in_root=patch_path,
-            epc=epc,
-        )
-
-        fa_count = search_attribute_matching_name(patch, "FastestAxisCount")
-        if fa_count is None:
-            fa_count = search_attribute_matching_name(energyml_object, "FastestAxisCount")
-
-        sa_count = search_attribute_matching_name(patch, "SlowestAxisCount")
-        if sa_count is None:
-            sa_count = search_attribute_matching_name(energyml_object, "SlowestAxisCount")
-
-        fa_count = fa_count[0]
-        sa_count = sa_count[0]
-
-        print(f"sa_count {sa_count} fa_count {fa_count}")
-
-        points_no_nan = []
-
-        indice_to_final_indice = {}
-        if keep_holes:
-            for i in range(len(points)):
-                p = points[i]
-                if p[2] != p[2]:  # a NaN
-                    points[i][2] = 0
-                elif reverse_z_values:
-                    points[i][2] = - points[i][2]
-        else:
-            for i in range(len(points)):
-                p = points[i]
-                if p[2] == p[2]:  # not a NaN
-                    if reverse_z_values:
-                        points[i][2] = - points[i][2]
-                    indice_to_final_indice[i] = len(points_no_nan)
-                    points_no_nan.append(p)
-
-        indices = []
-
-        while sa_count*fa_count > len(points):
-            sa_count = sa_count - 1
-            fa_count = fa_count - 1
-
-        while sa_count*fa_count < len(points):
-            sa_count = sa_count + 1
-            fa_count = fa_count + 1
-
-        print(f"sa_count {sa_count} fa_count {fa_count} : {sa_count*fa_count} - {len(points)} ")
-
-        for sa in range(sa_count-1):
-            for fa in range(fa_count-1):
-                line = sa * fa_count
-                if sa+1 == int(sa_count / 2) and fa == int(fa_count / 2):
-                    print(
-                        "\n\t", (line + fa), " : ", (line + fa) in indice_to_final_indice,
-                        "\n\t", (line + fa + 1), " : ", (line + fa + 1) in indice_to_final_indice,
-                        "\n\t", (line + fa_count + fa + 1), " : ", (line + fa_count + fa + 1) in indice_to_final_indice,
-                        "\n\t", (line + fa_count + fa), " : ", (line + fa_count + fa) in indice_to_final_indice,
-                    )
-                if keep_holes:
-                    indices.append(
-                        [
-                            line + fa,
-                            line + fa + 1,
-                            line + fa_count + fa + 1,
-                            line + fa_count + fa,
-                        ]
-                    )
-                elif (
-                    (line + fa) in indice_to_final_indice
-                    and (line + fa + 1) in indice_to_final_indice
-                    and (line + fa_count + fa + 1) in indice_to_final_indice
-                    and (line + fa_count + fa) in indice_to_final_indice
-                ):
-                    indices.append(
-                        [
-                            indice_to_final_indice[line + fa],
-                            indice_to_final_indice[line + fa + 1],
-                            indice_to_final_indice[line + fa_count + fa + 1],
-                            indice_to_final_indice[line + fa_count + fa],
-                        ]
-                    )
-        # print(indices)
-        meshes.append(SurfaceMesh(
-            identifier=f"{get_obj_identifier(energyml_object)}_patch{patch_idx}",
-            energyml_object=energyml_object,
-            crs_object=None,
-            point_list=points if keep_holes else points_no_nan,
-            faces_indices=indices
-        ))
-        patch_idx = patch_idx + 1
-
-    return meshes
-
-
-def read_triangulated_set_representation(energyml_object: Any, epc: Epc) -> List[SurfaceMesh]:
-    meshes = []
-
-    point_offset = 0
-    patch_idx = 0
-    for patch_path, patch in search_attribute_matching_name_with_path(energyml_object, "\\.*Patch"):
-        crs = get_crs_obj(
-            context_obj=patch,
-            path_in_root=patch_path,
-            root_obj=energyml_object,
-            epc=epc,
-        )
-
-        point_list: List[Point] = []
-        for point_path, point_obj in search_attribute_matching_name_with_path(patch, "Geometry.Points"):
-            point_list = point_list + read_array(
-                energyml_array=point_obj,
-                root_obj=energyml_object,
-                path_in_root=patch_path + point_path,
-                epc=epc,
-            )
-
-        triangles_list: List[List[int]] = []
-        for triangles_path, triangles_obj in search_attribute_matching_name_with_path(patch, "Triangles"):
-            triangles_list = triangles_list + read_array(
-                energyml_array=triangles_obj,
-                root_obj=energyml_object,
-                path_in_root=patch_path + triangles_path,
-                epc=epc,
-            )
-        triangles_list = list(map(lambda tr: [ti - point_offset for ti in tr], triangles_list))
-        meshes.append(SurfaceMesh(
-            identifier=f"{get_obj_identifier(energyml_object)}_patch{patch_idx}",
-            energyml_object=energyml_object,
-            crs_object=crs,
-            point_list=point_list,
-            faces_indices=triangles_list
-        ))
-
-        point_offset = point_offset + len(point_list)
-
-    return meshes
-
-
-# MESH FILES
-
-
-def export_off(mesh_list: List[AbstractMesh], out: BytesIO):
-    """
-    Export an :class:`AbstractMesh` into off format.
-    :param mesh_list:
-    :param out:
-    :return:
-    """
-    nb_points = sum(list(map(lambda m: len(m.point_list), mesh_list)))
-    nb_edges = sum(list(map(lambda m: m.get_nb_edges(), mesh_list)))
-    nb_faces = sum(list(map(lambda m: m.get_nb_faces(), mesh_list)))
-
-    out.write(b"OFF\n")
-    out.write(_FILE_HEADER)
-    out.write(f"{nb_points} {nb_faces} {nb_edges}\n".encode('utf-8'))
-
-    points_io = BytesIO()
-    faces_io = BytesIO()
-
-    point_offset = 0
-    for m in mesh_list:
-        export_off_part(
-            off_point_part=points_io,
-            off_face_part=faces_io,
-            points=m.point_list,
-            indices=m.get_indices(),
-            point_offset=point_offset,
-            colors=[],
-        )
-        point_offset = point_offset + len(m.point_list)
-
-    out.write(points_io.getbuffer())
-    out.write(faces_io.getbuffer())
-
-
-def export_off_part(
-        off_point_part: BytesIO,
-        off_face_part: BytesIO,
-        points: List[List[float]],
-        indices: List[List[int]],
-        point_offset: Optional[int] = 0,
-        colors: Optional[List[List[int]]] = None
-) -> None:
-    for p in points:
-        for pi in p:
-            off_point_part.write(f"{pi} ".encode('utf-8'))
-        off_point_part.write(b"\n")
-
-    cpt = 0
-    for face in indices:
-        if len(face) > 1:
-            off_face_part.write(f"{len(face)} ".encode('utf-8'))
-            for pi in face:
-                off_face_part.write(f"{pi + point_offset} ".encode('utf-8'))
-
-            if colors is not None and len(colors) > cpt and colors[cpt] is not None and len(colors[cpt]) > 0:
-                for col in colors[cpt]:
-                    off_face_part.write(f"{col} ".encode('utf-8'))
-
-            off_face_part.write(b"\n")
-
-
-def export_obj(mesh_list: List[AbstractMesh], out: BytesIO, obj_name: Optional[str] = None):
-    """
-    Export an :class:`AbstractMesh` into obj format.
-
-    Each AbstractMesh from the list :param:`mesh_list` will be placed into its own group.
-    :param mesh_list:
-    :param out:
-    :param obj_name:
-    :return:
-    """
-    out.write(f"# Generated by energyml-utils a Geosiris python module\n\n".encode('utf-8'))
-
-    if obj_name is not None:
-        out.write(f"o {obj_name}\n\n".encode('utf-8'))
-
-    point_offset = 0
-    for m in mesh_list:
-        out.write(f"g {m.identifier}\n\n".encode('utf-8'))
-        _export_obj_elt(
-            off_point_part=out,
-            off_face_part=out,
-            points=m.point_list,
-            indices=m.get_indices(),
-            point_offset=point_offset,
-            colors=[],
-            elt_letter="l" if isinstance(m, PolylineSetMesh) else "f"
-        )
-        point_offset = point_offset + len(m.point_list)
-        out.write("\n".encode('utf-8'))
-
-
-def _export_obj_elt(
-        off_point_part: BytesIO,
-        off_face_part: BytesIO,
-        points: List[List[float]],
-        indices: List[List[int]],
-        point_offset: Optional[int] = 0,
-        colors: Optional[List[List[int]]] = None,
-        elt_letter: str = "f",
-) -> None:
-    """
-
-    :param off_point_part:
-    :param off_face_part:
-    :param points:
-    :param indices:
-    :param point_offset:
-    :param colors: currently not supported
-    :param elt_letter: "l" for line and "f" for faces
-    :return:
-    """
-    offset_obj = 1  # OBJ point indices starts at 1 not 0
-    for p in points:
-        if len(p) > 0:
-            off_point_part.write(f"v {' '.join(list(map(lambda xyz: str(xyz), p)))}\n".encode('utf-8'))
-
-    # cpt = 0
-    for face in indices:
-        if len(face) > 1:
-            # off_face_part.write(f"{elt_letter} ".encode('utf-8'))
-            # for pi in face:
-            #     off_face_part.write(f"{pi + point_offset} ".encode('utf-8'))
-            off_point_part.write(
-                f"{elt_letter} {' '.join(list(map(lambda x: str(x + point_offset + offset_obj), face)))}\n".encode(
-                    'utf-8'))
-
-            # if colors is not None and len(colors) > cpt and colors[cpt] is not None and len(colors[cpt]) > 0:
-            #     for col in colors[cpt]:
-            #         off_face_part.write(f"{col} ".encode('utf-8'))
-
-            # off_face_part.write(b"\n")
-
-
-
-
-
-
-
-

Functions

-
-
-def export_obj(mesh_list: List[AbstractMesh], out: _io.BytesIO, obj_name: Optional[str] = None) -
-
-

Export an :class:AbstractMesh into obj format.

-

Each AbstractMesh from the list :param:mesh_list will be placed into its own group. -:param mesh_list: -:param out: -:param obj_name: -:return:

-
- -Expand source code - -
def export_obj(mesh_list: List[AbstractMesh], out: BytesIO, obj_name: Optional[str] = None):
-    """
-    Export an :class:`AbstractMesh` into obj format.
-
-    Each AbstractMesh from the list :param:`mesh_list` will be placed into its own group.
-    :param mesh_list:
-    :param out:
-    :param obj_name:
-    :return:
-    """
-    out.write(f"# Generated by energyml-utils a Geosiris python module\n\n".encode('utf-8'))
-
-    if obj_name is not None:
-        out.write(f"o {obj_name}\n\n".encode('utf-8'))
-
-    point_offset = 0
-    for m in mesh_list:
-        out.write(f"g {m.identifier}\n\n".encode('utf-8'))
-        _export_obj_elt(
-            off_point_part=out,
-            off_face_part=out,
-            points=m.point_list,
-            indices=m.get_indices(),
-            point_offset=point_offset,
-            colors=[],
-            elt_letter="l" if isinstance(m, PolylineSetMesh) else "f"
-        )
-        point_offset = point_offset + len(m.point_list)
-        out.write("\n".encode('utf-8'))
-
-
-
-def export_off(mesh_list: List[AbstractMesh], out: _io.BytesIO) -
-
-

Export an :class:AbstractMesh into off format. -:param mesh_list: -:param out: -:return:

-
- -Expand source code - -
def export_off(mesh_list: List[AbstractMesh], out: BytesIO):
-    """
-    Export an :class:`AbstractMesh` into off format.
-    :param mesh_list:
-    :param out:
-    :return:
-    """
-    nb_points = sum(list(map(lambda m: len(m.point_list), mesh_list)))
-    nb_edges = sum(list(map(lambda m: m.get_nb_edges(), mesh_list)))
-    nb_faces = sum(list(map(lambda m: m.get_nb_faces(), mesh_list)))
-
-    out.write(b"OFF\n")
-    out.write(_FILE_HEADER)
-    out.write(f"{nb_points} {nb_faces} {nb_edges}\n".encode('utf-8'))
-
-    points_io = BytesIO()
-    faces_io = BytesIO()
-
-    point_offset = 0
-    for m in mesh_list:
-        export_off_part(
-            off_point_part=points_io,
-            off_face_part=faces_io,
-            points=m.point_list,
-            indices=m.get_indices(),
-            point_offset=point_offset,
-            colors=[],
-        )
-        point_offset = point_offset + len(m.point_list)
-
-    out.write(points_io.getbuffer())
-    out.write(faces_io.getbuffer())
-
-
-
-def export_off_part(off_point_part: _io.BytesIO, off_face_part: _io.BytesIO, points: List[List[float]], indices: List[List[int]], point_offset: Optional[int] = 0, colors: Optional[List[List[int]]] = None) ‑> None -
-
-
-
- -Expand source code - -
def export_off_part(
-        off_point_part: BytesIO,
-        off_face_part: BytesIO,
-        points: List[List[float]],
-        indices: List[List[int]],
-        point_offset: Optional[int] = 0,
-        colors: Optional[List[List[int]]] = None
-) -> None:
-    for p in points:
-        for pi in p:
-            off_point_part.write(f"{pi} ".encode('utf-8'))
-        off_point_part.write(b"\n")
-
-    cpt = 0
-    for face in indices:
-        if len(face) > 1:
-            off_face_part.write(f"{len(face)} ".encode('utf-8'))
-            for pi in face:
-                off_face_part.write(f"{pi + point_offset} ".encode('utf-8'))
-
-            if colors is not None and len(colors) > cpt and colors[cpt] is not None and len(colors[cpt]) > 0:
-                for col in colors[cpt]:
-                    off_face_part.write(f"{col} ".encode('utf-8'))
-
-            off_face_part.write(b"\n")
-
-
-
-def get_mesh_reader_function(mesh_type_name: str) ‑> Optional[Callable] -
-
-

Returns the name of the potential appropriate function to read an object with type is named mesh_type_name -:param mesh_type_name: the initial type name -:return:

-
- -Expand source code - -
def get_mesh_reader_function(mesh_type_name: str) -> Optional[Callable]:
-    """
-    Returns the name of the potential appropriate function to read an object with type is named mesh_type_name
-    :param mesh_type_name: the initial type name
-    :return:
-    """
-    for name, obj in inspect.getmembers(sys.modules[__name__]):
-        if name == f"read_{snake_case(mesh_type_name)}":
-            return obj
-    return None
-
-
-
-def read_grid2d_representation(energyml_object: Any, epc: Epc, keep_holes=False) ‑> List[SurfaceMesh] -
-
-
-
- -Expand source code - -
def read_grid2d_representation(energyml_object: Any, epc: Epc, keep_holes=False) -> List[SurfaceMesh]:
-    # h5_reader = HDF5FileReader()
-    meshes = []
-
-    patch_idx = 0
-    for patch_path, patch in search_attribute_matching_name_with_path(energyml_object, "Grid2dPatch"):
-        crs = get_crs_obj(
-            context_obj=patch,
-            path_in_root=patch_path,
-            root_obj=energyml_object,
-            epc=epc,
-        )
-
-        reverse_z_values = is_z_reversed(crs)
-
-        points = read_grid2d_patch(
-            patch=patch,
-            grid2d=energyml_object,
-            path_in_root=patch_path,
-            epc=epc,
-        )
-
-        fa_count = search_attribute_matching_name(patch, "FastestAxisCount")
-        if fa_count is None:
-            fa_count = search_attribute_matching_name(energyml_object, "FastestAxisCount")
-
-        sa_count = search_attribute_matching_name(patch, "SlowestAxisCount")
-        if sa_count is None:
-            sa_count = search_attribute_matching_name(energyml_object, "SlowestAxisCount")
-
-        fa_count = fa_count[0]
-        sa_count = sa_count[0]
-
-        print(f"sa_count {sa_count} fa_count {fa_count}")
-
-        points_no_nan = []
-
-        indice_to_final_indice = {}
-        if keep_holes:
-            for i in range(len(points)):
-                p = points[i]
-                if p[2] != p[2]:  # a NaN
-                    points[i][2] = 0
-                elif reverse_z_values:
-                    points[i][2] = - points[i][2]
-        else:
-            for i in range(len(points)):
-                p = points[i]
-                if p[2] == p[2]:  # not a NaN
-                    if reverse_z_values:
-                        points[i][2] = - points[i][2]
-                    indice_to_final_indice[i] = len(points_no_nan)
-                    points_no_nan.append(p)
-
-        indices = []
-
-        while sa_count*fa_count > len(points):
-            sa_count = sa_count - 1
-            fa_count = fa_count - 1
-
-        while sa_count*fa_count < len(points):
-            sa_count = sa_count + 1
-            fa_count = fa_count + 1
-
-        print(f"sa_count {sa_count} fa_count {fa_count} : {sa_count*fa_count} - {len(points)} ")
-
-        for sa in range(sa_count-1):
-            for fa in range(fa_count-1):
-                line = sa * fa_count
-                if sa+1 == int(sa_count / 2) and fa == int(fa_count / 2):
-                    print(
-                        "\n\t", (line + fa), " : ", (line + fa) in indice_to_final_indice,
-                        "\n\t", (line + fa + 1), " : ", (line + fa + 1) in indice_to_final_indice,
-                        "\n\t", (line + fa_count + fa + 1), " : ", (line + fa_count + fa + 1) in indice_to_final_indice,
-                        "\n\t", (line + fa_count + fa), " : ", (line + fa_count + fa) in indice_to_final_indice,
-                    )
-                if keep_holes:
-                    indices.append(
-                        [
-                            line + fa,
-                            line + fa + 1,
-                            line + fa_count + fa + 1,
-                            line + fa_count + fa,
-                        ]
-                    )
-                elif (
-                    (line + fa) in indice_to_final_indice
-                    and (line + fa + 1) in indice_to_final_indice
-                    and (line + fa_count + fa + 1) in indice_to_final_indice
-                    and (line + fa_count + fa) in indice_to_final_indice
-                ):
-                    indices.append(
-                        [
-                            indice_to_final_indice[line + fa],
-                            indice_to_final_indice[line + fa + 1],
-                            indice_to_final_indice[line + fa_count + fa + 1],
-                            indice_to_final_indice[line + fa_count + fa],
-                        ]
-                    )
-        # print(indices)
-        meshes.append(SurfaceMesh(
-            identifier=f"{get_obj_identifier(energyml_object)}_patch{patch_idx}",
-            energyml_object=energyml_object,
-            crs_object=None,
-            point_list=points if keep_holes else points_no_nan,
-            faces_indices=indices
-        ))
-        patch_idx = patch_idx + 1
-
-    return meshes
-
-
-
-def read_mesh_object(energyml_object: Any, epc: Optional[Epc] = None) ‑> List[AbstractMesh] -
-
-

Read and "meshable" object. If :param:energyml_object is not supported, an exception will be raised. -:param energyml_object: -:param epc: -:return:

-
- -Expand source code - -
def read_mesh_object(
-        energyml_object: Any,
-        epc: Optional[Epc] = None
-) -> List[AbstractMesh]:
-    """
-    Read and "meshable" object. If :param:`energyml_object` is not supported, an exception will be raised.
-    :param energyml_object:
-    :param epc:
-    :return:
-    """
-    if isinstance(energyml_object, list):
-        return energyml_object
-    array_type_name = _mesh_name_mapping(type(energyml_object).__name__)
-
-    reader_func = get_mesh_reader_function(array_type_name)
-    if reader_func is not None:
-        return reader_func(
-            energyml_object=energyml_object,
-            epc=epc,
-        )
-    else:
-        print(f"Type {array_type_name} is not supported: function read_{snake_case(array_type_name)} not found")
-        raise Exception(f"Type {array_type_name} is not supported\n\t{energyml_object}: \n\tfunction read_{snake_case(array_type_name)} not found")
-
-
-
-def read_point_set_representation(energyml_object: Any, epc: Epc) ‑> List[PointSetMesh] -
-
-
-
- -Expand source code - -
def read_point_set_representation(energyml_object: Any, epc: Epc) -> List[PointSetMesh]:
-    # pt_geoms = search_attribute_matching_type(point_set, "AbstractGeometry")
-    h5_reader = HDF5FileReader()
-
-    meshes = []
-    for refer_path, refer_value in get_hdf_reference_with_path(energyml_object):
-        try:
-            hdf5_path = get_hdf5_path_from_external_path(
-                external_path_obj=refer_value,
-                path_in_root=refer_path,
-                root_obj=energyml_object,
-                epc=epc,
-            )
-            crs = get_crs_obj(
-                context_obj=refer_value,
-                path_in_root=refer_path,
-                root_obj=energyml_object,
-                epc=epc,
-            )
-            if hdf5_path is not None:
-                print(f"Reading h5 file : {hdf5_path}")
-                meshes.append(PointSetMesh(
-                    identifier=refer_value,
-                    energyml_object=energyml_object,
-                    crs_object=crs,
-                    point_list=h5_reader.read_array(hdf5_path, refer_value)
-                ))
-        except Exception as e:
-            print(f"Error with path {refer_path} -- {energyml_object}")
-            raise e
-    return meshes
-
-
-
-def read_polyline_set_representation(energyml_object: Any, epc: Epc) ‑> List[PointSetMesh] -
-
-
-
- -Expand source code - -
def read_polyline_set_representation(energyml_object: Any, epc: Epc) -> List[PointSetMesh]:
-    # pt_geoms = search_attribute_matching_type(point_set, "AbstractGeometry")
-    h5_reader = HDF5FileReader()
-
-    meshes = []
-
-    patch_idx = 0
-    for path_path_in_obj, patch in search_attribute_matching_name_with_path(energyml_object, "LinePatch"):
-        print(f"patch {patch}")
-        geometry_path_in_obj, geometry = search_attribute_matching_name_with_path(patch, "geometry")[0]
-        node_count_per_poly_path_in_obj, node_count_per_poly = \
-        search_attribute_matching_name_with_path(patch, "NodeCountPerPolyline")[0]
-        points_ext_array = search_attribute_matching_type_with_path(geometry, "ExternalDataArrayPart|Hdf5Dataset")
-        node_count_ext_array = search_attribute_matching_type_with_path(node_count_per_poly,
-                                                                        "ExternalDataArrayPart|Hdf5Dataset")
-
-        if len(points_ext_array) > 0:
-            point_per_elt = []
-            point_indices = []
-            crs = None
-
-            # Reading points
-            for patch_part_path, patchPart_value in points_ext_array:
-                patch_part_full_path_in_obj = path_path_in_obj + geometry_path_in_obj + patch_part_path
-                for refer_path, refer_value in get_hdf_reference_with_path(patchPart_value):
-                    print(f"refer_path {patch_part_full_path_in_obj}{refer_path} refer_value{refer_value} ")
-                    hdf5_path = get_hdf5_path_from_external_path(
-                        external_path_obj=refer_value,
-                        path_in_root=patch_part_full_path_in_obj + refer_path,
-                        root_obj=energyml_object,
-                        epc=epc,
-                    )
-                    crs = get_crs_obj(
-                        context_obj=refer_value,
-                        path_in_root=patch_part_full_path_in_obj + refer_path,
-                        root_obj=energyml_object,
-                        epc=epc,
-                    )
-                    if hdf5_path is not None:
-                        print(f"Reading h5 file : {hdf5_path}")
-                        point_per_elt = point_per_elt + h5_reader.read_array(hdf5_path, refer_value)
-
-            # Reading polyline indices
-            # for patch_part_path, patchPart_value in node_count_ext_array:
-            #     patch_part_full_path_in_obj = path_path_in_obj + node_count_per_poly_path_in_obj + patch_part_path
-            #     for refer_path, refer_value in get_hdf_reference_with_path(patchPart_value):
-            #         print(f"refer_path: {patch_part_full_path_in_obj}{refer_path} refer_value: {refer_value} ")
-            #         hdf5_path = get_hdf5_path_from_external_path(
-            #                     external_path_obj=refer_value,
-            #                     path_in_root=patch_part_full_path_in_obj + refer_path,
-            #                     root_obj=energyml_object,
-            #                     epc=epc,
-            #         )
-            #         if hdf5_path is not None:
-            #             node_counts_list = h5_reader.read_array(hdf5_path, refer_value)
-            #             idx = 0
-            #             for nb_node in node_counts_list:
-            #                 point_indices.append([x for x in range(idx, idx + nb_node)])
-            #                 idx = idx + nb_node
-
-            node_counts_list = read_array(
-                energyml_array=node_count_per_poly,
-                root_obj=energyml_object,
-                path_in_root=path_path_in_obj + node_count_per_poly_path_in_obj,
-                epc=epc,
-            )
-            idx = 0
-            for nb_node in node_counts_list:
-                point_indices.append([x for x in range(idx, idx + nb_node)])
-                idx = idx + nb_node
-
-            if len(point_per_elt) > 0:
-                # poly_idx = 0
-                # for single_poly_indices in point_indices:
-                meshes.append(PolylineSetMesh(
-                    # identifier=f"{get_obj_identifier(energyml_object)}_patch{patch_idx}_poly{poly_idx}",
-                    identifier=f"{get_obj_identifier(energyml_object)}_patch{patch_idx}",
-                    energyml_object=energyml_object,
-                    crs_object=crs,
-                    point_list=point_per_elt,
-                    line_indices=point_indices
-                ))
-                # poly_idx = poly_idx + 1
-        patch_idx = patch_idx + 1
-
-    return meshes
-
-
-
-def read_triangulated_set_representation(energyml_object: Any, epc: Epc) ‑> List[SurfaceMesh] -
-
-
-
- -Expand source code - -
def read_triangulated_set_representation(energyml_object: Any, epc: Epc) -> List[SurfaceMesh]:
-    meshes = []
-
-    point_offset = 0
-    patch_idx = 0
-    for patch_path, patch in search_attribute_matching_name_with_path(energyml_object, "\\.*Patch"):
-        crs = get_crs_obj(
-            context_obj=patch,
-            path_in_root=patch_path,
-            root_obj=energyml_object,
-            epc=epc,
-        )
-
-        point_list: List[Point] = []
-        for point_path, point_obj in search_attribute_matching_name_with_path(patch, "Geometry.Points"):
-            point_list = point_list + read_array(
-                energyml_array=point_obj,
-                root_obj=energyml_object,
-                path_in_root=patch_path + point_path,
-                epc=epc,
-            )
-
-        triangles_list: List[List[int]] = []
-        for triangles_path, triangles_obj in search_attribute_matching_name_with_path(patch, "Triangles"):
-            triangles_list = triangles_list + read_array(
-                energyml_array=triangles_obj,
-                root_obj=energyml_object,
-                path_in_root=patch_path + triangles_path,
-                epc=epc,
-            )
-        triangles_list = list(map(lambda tr: [ti - point_offset for ti in tr], triangles_list))
-        meshes.append(SurfaceMesh(
-            identifier=f"{get_obj_identifier(energyml_object)}_patch{patch_idx}",
-            energyml_object=energyml_object,
-            crs_object=crs,
-            point_list=point_list,
-            faces_indices=triangles_list
-        ))
-
-        point_offset = point_offset + len(point_list)
-
-    return meshes
-
-
-
-
-
-

Classes

-
-
-class AbstractMesh -(energyml_object: Any = None, crs_object: Any = None, point_list: List[list[float]] = <factory>, identifier: str = None) -
-
-

AbstractMesh(energyml_object: Any = None, crs_object: Any = None, point_list: List[list[float]] = , identifier: str = None)

-
- -Expand source code - -
@dataclass
-class AbstractMesh:
-    energyml_object: Any = field(
-        default=None
-    )
-
-    crs_object: Any = field(
-        default=None
-    )
-
-    point_list: List[Point] = field(
-        default_factory=list,
-    )
-
-    identifier: str = field(
-        default=None,
-    )
-
-    def export_off(self, out: BytesIO) -> None:
-        pass
-
-    def get_nb_edges(self) -> int:
-        return 0
-
-    def get_nb_faces(self) -> int:
-        return 0
-
-    def get_indices(self) -> List[List[int]]:
-        return []
-
-

Subclasses

- -

Class variables

-
-
var crs_object : Any
-
-
-
-
var energyml_object : Any
-
-
-
-
var identifier : str
-
-
-
-
var point_list : List[list[float]]
-
-
-
-
-

Methods

-
-
-def export_off(self, out: _io.BytesIO) ‑> None -
-
-
-
- -Expand source code - -
def export_off(self, out: BytesIO) -> None:
-    pass
-
-
-
-def get_indices(self) ‑> List[List[int]] -
-
-
-
- -Expand source code - -
def get_indices(self) -> List[List[int]]:
-    return []
-
-
-
-def get_nb_edges(self) ‑> int -
-
-
-
- -Expand source code - -
def get_nb_edges(self) -> int:
-    return 0
-
-
-
-def get_nb_faces(self) ‑> int -
-
-
-
- -Expand source code - -
def get_nb_faces(self) -> int:
-    return 0
-
-
-
-
-
-class PointSetMesh -(energyml_object: Any = None, crs_object: Any = None, point_list: List[list[float]] = <factory>, identifier: str = None) -
-
-

PointSetMesh(energyml_object: Any = None, crs_object: Any = None, point_list: List[list[float]] = , identifier: str = None)

-
- -Expand source code - -
@dataclass
-class PointSetMesh(AbstractMesh):
-    pass
-
-

Ancestors

- -
-
-class PolylineSetMesh -(energyml_object: Any = None, crs_object: Any = None, point_list: List[list[float]] = <factory>, identifier: str = None, line_indices: List[List[int]] = <factory>) -
-
-

PolylineSetMesh(energyml_object: Any = None, crs_object: Any = None, point_list: List[list[float]] = , identifier: str = None, line_indices: List[List[int]] = )

-
- -Expand source code - -
@dataclass
-class PolylineSetMesh(AbstractMesh):
-    line_indices: List[List[int]] = field(
-        default_factory=list,
-    )
-
-    def get_nb_edges(self) -> int:
-        return sum(list(map(lambda li: len(li) - 1, self.line_indices)))
-
-    def get_nb_faces(self) -> int:
-        return 0
-
-    def get_indices(self) -> List[List[int]]:
-        return self.line_indices
-
-

Ancestors

- -

Class variables

-
-
var line_indices : List[List[int]]
-
-
-
-
-

Methods

-
-
-def get_indices(self) ‑> List[List[int]] -
-
-
-
- -Expand source code - -
def get_indices(self) -> List[List[int]]:
-    return self.line_indices
-
-
-
-def get_nb_edges(self) ‑> int -
-
-
-
- -Expand source code - -
def get_nb_edges(self) -> int:
-    return sum(list(map(lambda li: len(li) - 1, self.line_indices)))
-
-
-
-def get_nb_faces(self) ‑> int -
-
-
-
- -Expand source code - -
def get_nb_faces(self) -> int:
-    return 0
-
-
-
-
-
-class SurfaceMesh -(energyml_object: Any = None, crs_object: Any = None, point_list: List[list[float]] = <factory>, identifier: str = None, faces_indices: List[List[int]] = <factory>) -
-
-

SurfaceMesh(energyml_object: Any = None, crs_object: Any = None, point_list: List[list[float]] = , identifier: str = None, faces_indices: List[List[int]] = )

-
- -Expand source code - -
@dataclass
-class SurfaceMesh(AbstractMesh):
-    faces_indices: List[List[int]] = field(
-        default_factory=list,
-    )
-
-    def get_nb_edges(self) -> int:
-        return sum(list(map(lambda li: len(li) - 1, self.faces_indices)))
-
-    def get_nb_faces(self) -> int:
-        return len(self.faces_indices)
-
-    def get_indices(self) -> List[List[int]]:
-        return self.faces_indices
-
-

Ancestors

- -

Class variables

-
-
var faces_indices : List[List[int]]
-
-
-
-
-

Methods

-
-
-def get_indices(self) ‑> List[List[int]] -
-
-
-
- -Expand source code - -
def get_indices(self) -> List[List[int]]:
-    return self.faces_indices
-
-
-
-def get_nb_edges(self) ‑> int -
-
-
-
- -Expand source code - -
def get_nb_edges(self) -> int:
-    return sum(list(map(lambda li: len(li) - 1, self.faces_indices)))
-
-
-
-def get_nb_faces(self) ‑> int -
-
-
-
- -Expand source code - -
def get_nb_faces(self) -> int:
-    return len(self.faces_indices)
-
-
-
-
-
-
-
- -
- - - \ No newline at end of file diff --git a/energyml-utils/docs/src/energyml/utils/epc.html b/energyml-utils/docs/src/energyml/utils/epc.html deleted file mode 100644 index b0204a4..0000000 --- a/energyml-utils/docs/src/energyml/utils/epc.html +++ /dev/null @@ -1,1900 +0,0 @@ - - - - - - -src.energyml.utils.epc API documentation - - - - - - - - - - - -
-
-
-

Module src.energyml.utils.epc

-
-
-

This example module shows various types of documentation available for use -with pydoc. -To generate HTML documentation for this module issue the -command:

-
pydoc -w foo
-
-
- -Expand source code - -
# Copyright (c) 2023-2024 Geosiris.
-# SPDX-License-Identifier: Apache-2.0
-"""
-This example module shows various types of documentation available for use
-with pydoc.  To generate HTML documentation for this module issue the
-command:
-
-    pydoc -w foo
-
-"""
-
-import datetime
-import re
-import zipfile
-from dataclasses import dataclass, field
-from enum import Enum
-from io import BytesIO
-from typing import List, Any, Union, Dict, Callable, Optional, Tuple
-
-from energyml.opc.opc import CoreProperties, Relationships, Types, Default, Relationship, Override
-from xsdata.exceptions import ParserError
-from xsdata.formats.dataclass.models.generics import DerivedElement
-
-from .introspection import (
-    get_class_from_content_type,
-    get_obj_type, search_attribute_matching_type, get_obj_version, get_obj_uuid,
-    get_object_type_for_file_path_from_class, get_content_type_from_class, get_direct_dor_list
-)
-from .manager import get_class_pkg, get_class_pkg_version
-from .serialization import (
-    serialize_xml, read_energyml_xml_str, read_energyml_xml_bytes, read_energyml_xml_bytes_as_class
-)
-from .xml import is_energyml_content_type
-
-RELS_CONTENT_TYPE = "application/vnd.openxmlformats-package.core-properties+xml"
-RELS_FOLDER_NAME = "_rels"
-
-
-class NoCrsException(Exception):
-    pass
-
-
-@dataclass
-class ObjectNotFoundNotException(Exception):
-    obj_id: str = field(
-        default=None
-    )
-
-
-class EpcExportVersion(Enum):
-    """EPC export version."""
-    #: Classical export
-    CLASSIC = 1
-    #: Export with objet path sorted by package (eml/resqml/witsml/prodml)
-    EXPANDED = 2
-
-
-class EPCRelsRelationshipType(Enum):
-    #: The object in Target is the destination of the relationship.
-    DESTINATION_OBJECT = "destinationObject"
-    #: The current object is the source in the relationship with the target object.
-    SOURCE_OBJECT = "sourceObject"
-    #: The target object is a proxy object for an external data object (HDF5 file).
-    ML_TO_EXTERNAL_PART_PROXY = "mlToExternalPartProxy"
-    #: The current object is used as a proxy object by the target object.
-    EXTERNAL_PART_PROXY_TO_ML = "externalPartProxyToMl"
-    #: The target is a resource outside of the EPC package. Note that TargetMode should be "External"
-    #: for this relationship.
-    EXTERNAL_RESOURCE = "externalResource"
-    #: The object in Target is a media representation for the current object. As a guideline, media files
-    #: should be stored in a "media" folder in the ROOT of the package.
-    DestinationMedia = "destinationMedia"
-    #: The current object is a media representation for the object in Target.
-    SOURCE_MEDIA = "sourceMedia"
-    #: The target is part of a larger data object that has been chunked into several smaller files
-    CHUNKED_PART = "chunkedPart"
-    #: /!\ not in the norm
-    EXTENDED_CORE_PROPERTIES = "extended-core-properties"
-
-    def get_type(self) -> str:
-        match self:
-            case EPCRelsRelationshipType.EXTENDED_CORE_PROPERTIES:
-                return "http://schemas.f2i-consulting.com/package/2014/relationships/" + str(self.value)
-            case (
-            EPCRelsRelationshipType.CHUNKED_PART
-            | EPCRelsRelationshipType.DESTINATION_OBJECT
-            | EPCRelsRelationshipType.SOURCE_OBJECT
-            | EPCRelsRelationshipType.ML_TO_EXTERNAL_PART_PROXY
-            | EPCRelsRelationshipType.EXTERNAL_PART_PROXY_TO_ML
-            | EPCRelsRelationshipType.EXTERNAL_RESOURCE
-            | EPCRelsRelationshipType.DestinationMedia
-            | EPCRelsRelationshipType.SOURCE_MEDIA
-            | _
-            ):
-                return "http://schemas.energistics.org/package/2012/relationships/" + str(self.value)
-
-
-@dataclass
-class RawFile:
-    path: str = field(default="_")
-    content: BytesIO = field(default=None)
-
-
-@dataclass
-class Epc:
-    """
-    A class that represent an EPC file content
-    """
-    # content_type: List[str] = field(
-    #     default_factory=list,
-    # )
-
-    export_version: EpcExportVersion = field(
-        default=EpcExportVersion.CLASSIC
-    )
-
-    core_props: CoreProperties = field(default=None)
-
-    """ xml files refered in the [Content_Types].xml  """
-    energyml_objects: List = field(
-        default_factory=list,
-    )
-
-    """ Other files content like pdf etc """
-    raw_files: List[RawFile] = field(
-        default_factory=list,
-    )
-
-    """ A list of external files. It ca be used to link hdf5 files """
-    external_files_path: List[str] = field(
-        default_factory=list,
-    )
-
-    """ 
-    Additional rels for objects. Key is the object (same than in @energyml_objects) and value is a list of
-    RelationShip. This can be used to link an HDF5 to an ExternalPartReference in resqml 2.0.1
-    Key is a value returned by @get_obj_identifier
-    """
-    additional_rels: Dict[str, List[Relationship]] = field(
-        default_factory=lambda: {}
-    )
-
-    """
-    Epc file path. Used when loaded from a local file or for export
-    """
-    epc_file_path: Optional[str] = field(
-        default=None
-    )
-
-    def __str__(self):
-        return (
-                "EPC file (" + str(self.export_version) + ") "
-                + f"{len(self.energyml_objects)} energyml objects and {len(self.raw_files)} other files {[f.path for f in self.raw_files]}"
-                # + f"\n{[serialize_json(ar) for ar in self.additional_rels]}"
-        )
-
-    # EXPORT functions
-
-    def gen_opc_content_type(self) -> Types:
-        """
-        Generates a :class:`Types` instance and fill it with energyml objects :class:`Override` values
-        :return:
-        """
-        ct = Types()
-        rels_default = Default()
-        rels_default.content_type = RELS_CONTENT_TYPE
-        rels_default.extension = "rels"
-
-        ct.default = [rels_default]
-
-        ct.override = []
-        for e_obj in self.energyml_objects:
-            ct.override.append(Override(
-                content_type=get_content_type_from_class(type(e_obj)),
-                part_name=gen_energyml_object_path(e_obj, self.export_version),
-            ))
-
-        if self.core_props is not None:
-            ct.override.append(Override(
-                content_type=get_content_type_from_class(self.core_props),
-                part_name=gen_core_props_path(self.export_version),
-            ))
-
-        return ct
-
-    def export_file(self, path: Optional[str] = None) -> None:
-        """
-        Export the epc file. If :param:`path` is None, the epc 'self.epc_file_path' is used
-        :param path:
-        :return:
-        """
-        if path is None:
-            path = self.epc_file_path
-        epc_io = self.export_io()
-        with open(path, "wb") as f:
-            f.write(epc_io.getbuffer())
-
-    def export_io(self) -> BytesIO:
-        """
-        Export the epc file into a :class:`BytesIO` instance. The result is an 'in-memory' zip file.
-        :return:
-        """
-        zip_buffer = BytesIO()
-
-        with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
-            #  Energyml objects
-            for e_obj in self.energyml_objects:
-                e_path = gen_energyml_object_path(e_obj, self.export_version)
-                zip_info = zipfile.ZipInfo(filename=e_path, date_time=datetime.datetime.now().timetuple()[:6])
-                data = serialize_xml(e_obj)
-                zip_file.writestr(zip_info, data)
-
-            # Rels
-            for rels_path, rels in self.compute_rels().items():
-                zip_info = zipfile.ZipInfo(filename=rels_path, date_time=datetime.datetime.now().timetuple()[:6])
-                data = serialize_xml(rels)
-                zip_file.writestr(zip_info, data)
-
-            # CoreProps
-            if self.core_props is not None:
-                zip_info = zipfile.ZipInfo(filename=gen_core_props_path(self.export_version),
-                                           date_time=datetime.datetime.now().timetuple()[:6])
-                data = serialize_xml(self.core_props)
-                zip_file.writestr(zip_info, data)
-
-            # ContentType
-            zip_info = zipfile.ZipInfo(filename=get_epc_content_type_path(),
-                                       date_time=datetime.datetime.now().timetuple()[:6])
-            data = serialize_xml(self.gen_opc_content_type())
-            zip_file.writestr(zip_info, data)
-
-        return zip_buffer
-
-    def compute_rels(self) -> Dict[str, Relationships]:
-        """
-        Returns a dict containing for each objet, the rels xml file path as key and the RelationShips object as value
-        :return:
-        """
-        dor_relation = get_reverse_dor_list(self.energyml_objects)
-
-        # destObject
-        rels = {
-            obj_id: [
-                Relationship(
-                    target=gen_energyml_object_path(target_obj, self.export_version),
-                    type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(),
-                    id=f"_{obj_id}_{get_obj_type(target_obj)}_{get_obj_identifier(target_obj)}",
-                ) for target_obj in target_obj_list
-            ]
-            for obj_id, target_obj_list in dor_relation.items()
-        }
-        # sourceObject
-        for obj in self.energyml_objects:
-            obj_id = get_obj_identifier(obj)
-            if obj_id not in rels:
-                rels[obj_id] = []
-            for target_obj in get_direct_dor_list(obj):
-                rels[obj_id].append(Relationship(
-                    target=gen_energyml_object_path(target_obj, self.export_version),
-                    type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(),
-                    id=f"_{obj_id}_{get_obj_type(target_obj)}_{get_obj_identifier(target_obj)}",
-                ))
-
-        map_obj_id_to_obj = {
-            get_obj_identifier(obj): obj
-            for obj in self.energyml_objects
-        }
-
-        obj_rels = {
-            gen_rels_path(energyml_object=map_obj_id_to_obj.get(obj_id), export_version=self.export_version): Relationships(
-                relationship=obj_rels + (self.additional_rels[obj_id] if obj_id in self.additional_rels else []),
-
-            )
-            for obj_id, obj_rels in rels.items()
-        }
-
-        # CoreProps
-        if self.core_props is not None:
-            obj_rels[gen_rels_path(self.core_props)] = Relationships(
-                relationship=[
-                    Relationship(
-                        target=gen_core_props_path(),
-                        type_value=EPCRelsRelationshipType.EXTENDED_CORE_PROPERTIES.get_type(),
-                        id="CoreProperties"
-                    )
-                ]
-            )
-
-        return obj_rels
-
-    # -----------
-
-    def get_object_by_uuid(self, uuid: str) -> List[Any]:
-        """
-        Search all objects with the uuid :param:`uuid`.
-        :param uuid:
-        :return:
-        """
-        return list(filter(lambda o: get_obj_uuid(o) == uuid, self.energyml_objects))
-
-    def get_object_by_identifier(self, identifier: str) -> Optional[Any]:
-        """
-        Search an object by its identifier.
-        :param identifier: given by the function :func:`get_obj_identifier`
-        :return:
-        """
-        for o in self.energyml_objects:
-            if get_obj_identifier(o) == identifier:
-                return o
-        return None
-
-    def get_epc_file_folder(self) -> Optional[str]:
-        if self.epc_file_path is not None and len(self.epc_file_path) > 0:
-            folders_and_name = re.split(r"[\\/]", self.epc_file_path)
-            if len(folders_and_name) > 1:
-                return "/".join(folders_and_name[:-1])
-            else:
-                return ""
-        return None
-
-    # Class methods
-
-    @classmethod
-    def read_file(cls, epc_file_path: str):
-        with open(epc_file_path, "rb") as f:
-            epc = cls.read_stream(BytesIO(f.read()))
-            epc.epc_file_path = epc_file_path
-            return epc
-
-    @classmethod
-    def read_stream(cls, epc_file_io: BytesIO):  # returns an Epc instance
-        """
-        :param epc_file_io:
-        :return: an :class:`EPC` instance
-        """
-        try:
-            _read_files = []
-            obj_list = []
-            raw_file_list = []
-            additional_rels = {}
-            core_props = None
-            with zipfile.ZipFile(epc_file_io, "r", zipfile.ZIP_DEFLATED) as epc_file:
-                content_type_file_name = get_epc_content_type_path()
-                content_type_info = None
-                try:
-                    content_type_info = epc_file.getinfo(content_type_file_name)
-                except KeyError:
-                    for info in epc_file.infolist():
-                        if info.filename.lower() == content_type_file_name.lower():
-                            content_type_info = info
-                            break
-
-                _read_files.append(content_type_file_name)
-
-                if content_type_info is None:
-                    print(f"No {content_type_file_name} file found")
-                else:
-                    content_type_obj: Types = read_energyml_xml_bytes(epc_file.read(content_type_file_name))
-                    path_to_obj = {}
-                    for ov in content_type_obj.override:
-                        ov_ct = ov.content_type
-                        ov_path = ov.part_name
-                        # print(ov_ct)
-                        while ov_path.startswith("/") or ov_path.startswith("\\"):
-                            ov_path = ov_path[1:]
-                        if is_energyml_content_type(ov_ct):
-                            _read_files.append(ov_path)
-                            try:
-                                ov_obj = read_energyml_xml_bytes_as_class(
-                                    epc_file.read(ov_path),
-                                    get_class_from_content_type(ov_ct)
-                                )
-                                if isinstance(ov_obj, DerivedElement):
-                                    ov_obj = ov_obj.value
-                                path_to_obj[ov_path] = ov_obj
-                                obj_list.append(ov_obj)
-                            except ParserError as e:
-                                print(f"Epc.@read_stream failed to parse file {ov_path} for content-type: {ov_ct} => {get_class_from_content_type(ov_ct)}")
-                                raise e
-                        elif get_class_from_content_type(ov_ct) == CoreProperties:
-                            _read_files.append(ov_path)
-                            core_props = read_energyml_xml_bytes_as_class(epc_file.read(ov_path), CoreProperties)
-
-                    for f_info in epc_file.infolist():
-                        if f_info.filename not in _read_files:
-                            _read_files.append(f_info.filename)
-                            if not f_info.filename.lower().endswith(".rels"):
-                                try:
-                                    raw_file_list.append(
-                                        RawFile(
-                                            path=f_info.filename,
-                                            content=BytesIO(epc_file.read(f_info.filename)),
-                                        )
-                                    )
-                                except IOError as e:
-                                    print(e)
-                            else:  # rels
-                                # print(f"reading rels {f_info.filename}")
-                                rels_folder, rels_file_name = get_file_folder_and_name_from_path(f_info.filename)
-                                while rels_folder.endswith("/"):
-                                    rels_folder = rels_folder[:-1]
-                                obj_folder = rels_folder[:rels_folder.rindex("/") + 1] if "/" in rels_folder else ""
-                                obj_file_name = rels_file_name[:-5]  # removing the ".rels"
-                                rels_file: Relationships = read_energyml_xml_bytes_as_class(
-                                    epc_file.read(f_info.filename),
-                                    Relationships
-                                )
-                                obj_path = obj_folder + obj_file_name
-                                if obj_path in path_to_obj:
-                                    try:
-                                        additional_rels_key = get_obj_identifier(path_to_obj[obj_path])
-                                        for rel in rels_file.relationship:
-                                            # print(f"\t\t{rel.type_value}")
-                                            if (rel.type_value != EPCRelsRelationshipType.DESTINATION_OBJECT.get_type()
-                                                    and rel.type_value != EPCRelsRelationshipType.SOURCE_OBJECT.get_type()
-                                                    and rel.type_value != EPCRelsRelationshipType.EXTENDED_CORE_PROPERTIES.get_type()
-                                            ):  # not a computable relation
-                                                if additional_rels_key not in additional_rels:
-                                                    additional_rels[additional_rels_key] = []
-                                                additional_rels[additional_rels_key].append(rel)
-                                    except Exception as e:
-                                        print(f"Error with obj path {obj_path} {path_to_obj[obj_path]}")
-                                        raise e
-                                else:
-                                    print(f"xml file {obj_path} not found in EPC (rels is not associate to any object)")
-
-            return Epc(energyml_objects=obj_list,
-                       raw_files=raw_file_list,
-                       core_props=core_props,
-                       additional_rels=additional_rels
-                       )
-        except zipfile.BadZipFile as error:
-            print(error)
-
-        return None
-
-
-#     ______                                      __   ____                 __  _
-#    / ____/___  ___  _________ ___  ______ ___  / /  / __/_  ______  _____/ /_(_)___  ____  _____
-#   / __/ / __ \/ _ \/ ___/ __ `/ / / / __ `__ \/ /  / /_/ / / / __ \/ ___/ __/ / __ \/ __ \/ ___/
-#  / /___/ / / /  __/ /  / /_/ / /_/ / / / / / / /  / __/ /_/ / / / / /__/ /_/ / /_/ / / / (__  )
-# /_____/_/ /_/\___/_/   \__, /\__, /_/ /_/ /_/_/  /_/  \__,_/_/ /_/\___/\__/_/\____/_/ /_/____/
-#                       /____//____/
-
-
-def get_obj_identifier(obj: Any) -> str:
-    """
-    Generates an objet identifier as : 'OBJ_UUID.OBJ_VERSION'
-    If the object version is None, the result is 'OBJ_UUID.'
-    :param obj:
-    :return: str
-    """
-    obj_obj_version = get_obj_version(obj)
-    if obj_obj_version is None:
-        obj_obj_version = ""
-    obj_uuid = get_obj_uuid(obj)
-    return f"{obj_uuid}.{obj_obj_version}"
-
-
-def get_reverse_dor_list(obj_list: List[Any], key_func: Callable = get_obj_identifier) -> Dict[str, List[Any]]:
-    """
-    Compute a dict with 'OBJ_UUID.OBJ_VERSION' as Key, and list of DOR that reference it.
-    If the object version is None, key is 'OBJ_UUID.'
-    :param obj_list:
-    :param key_func: a callable to create the key of the dict from the object instance
-    :return: str
-    """
-    rels = {}
-    for obj in obj_list:
-        for dor in search_attribute_matching_type(obj, "DataObjectReference", return_self=False):
-            key = key_func(dor)
-            if key not in rels:
-                rels[key] = []
-            rels[key] = rels.get(key, []) + [obj]
-    return rels
-
-
-# PATHS
-
-
-def gen_core_props_path(export_version: EpcExportVersion = EpcExportVersion.CLASSIC):
-    return "docProps/core.xml"
-
-
-def gen_energyml_object_path(energyml_object: Union[str, Any],
-                             export_version: EpcExportVersion = EpcExportVersion.CLASSIC):
-    """
-    Generate a path to store the :param:`energyml_object` into an epc file (depending on the :param:`export_version`)
-    :param energyml_object:
-    :param export_version:
-    :return:
-    """
-    if isinstance(energyml_object, str):
-        energyml_object = read_energyml_xml_str(energyml_object)
-
-    obj_type = get_object_type_for_file_path_from_class(energyml_object.__class__)
-
-    pkg = get_class_pkg(energyml_object)
-    pkg_version = get_class_pkg_version(energyml_object)
-    object_version = get_obj_version(energyml_object)
-    uuid = get_obj_uuid(energyml_object)
-
-    # if object_version is None:
-    #     object_version = "0"
-
-    if export_version == EpcExportVersion.EXPANDED:
-        return f"namespace_{pkg}{pkg_version.replace('.', '')}/{uuid}{('/version_' + object_version) if object_version is not None else ''}/{obj_type}_{uuid}.xml"
-    else:
-        return obj_type + "_" + uuid + ".xml"
-
-
-def get_file_folder_and_name_from_path(path: str) -> Tuple[str, str]:
-    """
-    Returns a tuple (FOLDER_PATH, FILE_NAME)
-    :param path:
-    :return:
-    """
-    obj_folder = path[:path.rindex("/") + 1] if "/" in path else ""
-    obj_file_name = path[path.rindex("/") + 1:] if "/" in path else path
-    return obj_folder, obj_file_name
-
-
-def gen_rels_path(energyml_object: Any,
-                  export_version: EpcExportVersion = EpcExportVersion.CLASSIC
-                  ) -> str:
-    """
-    Generate a path to store the :param:`energyml_object` rels file into an epc file
-    (depending on the :param:`export_version`)
-    :param energyml_object:
-    :param export_version:
-    :return:
-    """
-    if isinstance(obj, CoreProperties):
-        return f"{RELS_FOLDER_NAME}/.rels"
-    else:
-        obj_path = gen_energyml_object_path(obj, export_version)
-        obj_folder, obj_file_name = get_file_folder_and_name_from_path(obj_path, )
-        return f"{obj_folder}{RELS_FOLDER_NAME}/{obj_file_name}.rels"
-
-
-def get_epc_content_type_path(export_version: EpcExportVersion = EpcExportVersion.CLASSIC) -> str:
-    """
-    Generate a path to store the "[Content_Types].xml" file into an epc file
-    (depending on the :param:`export_version`)
-    :return:
-    """
-    return "[Content_Types].xml"
-
-
-
-
-
-
-
-

Functions

-
-
-def gen_core_props_path(export_version: EpcExportVersion = EpcExportVersion.CLASSIC) -
-
-
-
- -Expand source code - -
def gen_core_props_path(export_version: EpcExportVersion = EpcExportVersion.CLASSIC):
-    return "docProps/core.xml"
-
-
-
-def gen_energyml_object_path(energyml_object: Union[str, Any], export_version: EpcExportVersion = EpcExportVersion.CLASSIC) -
-
-

Generate a path to store the :param:energyml_object into an epc file (depending on the :param:export_version) -:param energyml_object: -:param export_version: -:return:

-
- -Expand source code - -
def gen_energyml_object_path(energyml_object: Union[str, Any],
-                             export_version: EpcExportVersion = EpcExportVersion.CLASSIC):
-    """
-    Generate a path to store the :param:`energyml_object` into an epc file (depending on the :param:`export_version`)
-    :param energyml_object:
-    :param export_version:
-    :return:
-    """
-    if isinstance(energyml_object, str):
-        energyml_object = read_energyml_xml_str(energyml_object)
-
-    obj_type = get_object_type_for_file_path_from_class(energyml_object.__class__)
-
-    pkg = get_class_pkg(energyml_object)
-    pkg_version = get_class_pkg_version(energyml_object)
-    object_version = get_obj_version(energyml_object)
-    uuid = get_obj_uuid(energyml_object)
-
-    # if object_version is None:
-    #     object_version = "0"
-
-    if export_version == EpcExportVersion.EXPANDED:
-        return f"namespace_{pkg}{pkg_version.replace('.', '')}/{uuid}{('/version_' + object_version) if object_version is not None else ''}/{obj_type}_{uuid}.xml"
-    else:
-        return obj_type + "_" + uuid + ".xml"
-
-
-
-def gen_rels_path(energyml_object: Any, export_version: EpcExportVersion = EpcExportVersion.CLASSIC) ‑> str -
-
-

Generate a path to store the :param:energyml_object rels file into an epc file -(depending on the :param:export_version) -:param energyml_object: -:param export_version: -:return:

-
- -Expand source code - -
def gen_rels_path(energyml_object: Any,
-                  export_version: EpcExportVersion = EpcExportVersion.CLASSIC
-                  ) -> str:
-    """
-    Generate a path to store the :param:`energyml_object` rels file into an epc file
-    (depending on the :param:`export_version`)
-    :param energyml_object:
-    :param export_version:
-    :return:
-    """
-    if isinstance(obj, CoreProperties):
-        return f"{RELS_FOLDER_NAME}/.rels"
-    else:
-        obj_path = gen_energyml_object_path(obj, export_version)
-        obj_folder, obj_file_name = get_file_folder_and_name_from_path(obj_path, )
-        return f"{obj_folder}{RELS_FOLDER_NAME}/{obj_file_name}.rels"
-
-
-
-def get_epc_content_type_path(export_version: EpcExportVersion = EpcExportVersion.CLASSIC) ‑> str -
-
-

Generate a path to store the "[Content_Types].xml" file into an epc file -(depending on the :param:export_version) -:return:

-
- -Expand source code - -
def get_epc_content_type_path(export_version: EpcExportVersion = EpcExportVersion.CLASSIC) -> str:
-    """
-    Generate a path to store the "[Content_Types].xml" file into an epc file
-    (depending on the :param:`export_version`)
-    :return:
-    """
-    return "[Content_Types].xml"
-
-
-
-def get_file_folder_and_name_from_path(path: str) ‑> Tuple[str, str] -
-
-

Returns a tuple (FOLDER_PATH, FILE_NAME) -:param path: -:return:

-
- -Expand source code - -
def get_file_folder_and_name_from_path(path: str) -> Tuple[str, str]:
-    """
-    Returns a tuple (FOLDER_PATH, FILE_NAME)
-    :param path:
-    :return:
-    """
-    obj_folder = path[:path.rindex("/") + 1] if "/" in path else ""
-    obj_file_name = path[path.rindex("/") + 1:] if "/" in path else path
-    return obj_folder, obj_file_name
-
-
-
-def get_obj_identifier(obj: Any) ‑> str -
-
-

Generates an objet identifier as : 'OBJ_UUID.OBJ_VERSION' -If the object version is None, the result is 'OBJ_UUID.' -:param obj: -:return: str

-
- -Expand source code - -
def get_obj_identifier(obj: Any) -> str:
-    """
-    Generates an objet identifier as : 'OBJ_UUID.OBJ_VERSION'
-    If the object version is None, the result is 'OBJ_UUID.'
-    :param obj:
-    :return: str
-    """
-    obj_obj_version = get_obj_version(obj)
-    if obj_obj_version is None:
-        obj_obj_version = ""
-    obj_uuid = get_obj_uuid(obj)
-    return f"{obj_uuid}.{obj_obj_version}"
-
-
-
-def get_reverse_dor_list(obj_list: List[Any], key_func: Callable = <function get_obj_identifier>) ‑> Dict[str, List[Any]] -
-
-

Compute a dict with 'OBJ_UUID.OBJ_VERSION' as Key, and list of DOR that reference it. -If the object version is None, key is 'OBJ_UUID.' -:param obj_list: -:param key_func: a callable to create the key of the dict from the object instance -:return: str

-
- -Expand source code - -
def get_reverse_dor_list(obj_list: List[Any], key_func: Callable = get_obj_identifier) -> Dict[str, List[Any]]:
-    """
-    Compute a dict with 'OBJ_UUID.OBJ_VERSION' as Key, and list of DOR that reference it.
-    If the object version is None, key is 'OBJ_UUID.'
-    :param obj_list:
-    :param key_func: a callable to create the key of the dict from the object instance
-    :return: str
-    """
-    rels = {}
-    for obj in obj_list:
-        for dor in search_attribute_matching_type(obj, "DataObjectReference", return_self=False):
-            key = key_func(dor)
-            if key not in rels:
-                rels[key] = []
-            rels[key] = rels.get(key, []) + [obj]
-    return rels
-
-
-
-
-
-

Classes

-
-
-class EPCRelsRelationshipType -(*args, **kwds) -
-
-

Create a collection of name/value pairs.

-

Example enumeration:

-
>>> class Color(Enum):
-...     RED = 1
-...     BLUE = 2
-...     GREEN = 3
-
-

Access them by:

-
    -
  • attribute access::
  • -
-
>>> Color.RED
-<Color.RED: 1>
-
-
    -
  • value lookup:
  • -
-
>>> Color(1)
-<Color.RED: 1>
-
-
    -
  • name lookup:
  • -
-
>>> Color['RED']
-<Color.RED: 1>
-
-

Enumerations can be iterated over, and know how many members they have:

-
>>> len(Color)
-3
-
-
>>> list(Color)
-[<Color.RED: 1>, <Color.BLUE: 2>, <Color.GREEN: 3>]
-
-

Methods can be added to enumerations, and members can have their own -attributes – see the documentation for details.

-
- -Expand source code - -
class EPCRelsRelationshipType(Enum):
-    #: The object in Target is the destination of the relationship.
-    DESTINATION_OBJECT = "destinationObject"
-    #: The current object is the source in the relationship with the target object.
-    SOURCE_OBJECT = "sourceObject"
-    #: The target object is a proxy object for an external data object (HDF5 file).
-    ML_TO_EXTERNAL_PART_PROXY = "mlToExternalPartProxy"
-    #: The current object is used as a proxy object by the target object.
-    EXTERNAL_PART_PROXY_TO_ML = "externalPartProxyToMl"
-    #: The target is a resource outside of the EPC package. Note that TargetMode should be "External"
-    #: for this relationship.
-    EXTERNAL_RESOURCE = "externalResource"
-    #: The object in Target is a media representation for the current object. As a guideline, media files
-    #: should be stored in a "media" folder in the ROOT of the package.
-    DestinationMedia = "destinationMedia"
-    #: The current object is a media representation for the object in Target.
-    SOURCE_MEDIA = "sourceMedia"
-    #: The target is part of a larger data object that has been chunked into several smaller files
-    CHUNKED_PART = "chunkedPart"
-    #: /!\ not in the norm
-    EXTENDED_CORE_PROPERTIES = "extended-core-properties"
-
-    def get_type(self) -> str:
-        match self:
-            case EPCRelsRelationshipType.EXTENDED_CORE_PROPERTIES:
-                return "http://schemas.f2i-consulting.com/package/2014/relationships/" + str(self.value)
-            case (
-            EPCRelsRelationshipType.CHUNKED_PART
-            | EPCRelsRelationshipType.DESTINATION_OBJECT
-            | EPCRelsRelationshipType.SOURCE_OBJECT
-            | EPCRelsRelationshipType.ML_TO_EXTERNAL_PART_PROXY
-            | EPCRelsRelationshipType.EXTERNAL_PART_PROXY_TO_ML
-            | EPCRelsRelationshipType.EXTERNAL_RESOURCE
-            | EPCRelsRelationshipType.DestinationMedia
-            | EPCRelsRelationshipType.SOURCE_MEDIA
-            | _
-            ):
-                return "http://schemas.energistics.org/package/2012/relationships/" + str(self.value)
-
-

Ancestors

-
    -
  • enum.Enum
  • -
-

Class variables

-
-
var CHUNKED_PART
-
-

The target is part of a larger data object that has been chunked into several smaller files

-
-
var DESTINATION_OBJECT
-
-

The object in Target is the destination of the relationship.

-
-
var DestinationMedia
-
-

The object in Target is a media representation for the current object. As a guideline, media files -should be stored in a "media" folder in the ROOT of the package.

-
-
var EXTENDED_CORE_PROPERTIES
-
-

/!\ not in the norm

-
-
var EXTERNAL_PART_PROXY_TO_ML
-
-

The current object is used as a proxy object by the target object.

-
-
var EXTERNAL_RESOURCE
-
-

The target is a resource outside of the EPC package. Note that TargetMode should be "External" -for this relationship.

-
-
var ML_TO_EXTERNAL_PART_PROXY
-
-

The target object is a proxy object for an external data object (HDF5 file).

-
-
var SOURCE_MEDIA
-
-

The current object is a media representation for the object in Target.

-
-
var SOURCE_OBJECT
-
-

The current object is the source in the relationship with the target object.

-
-
-

Methods

-
-
-def get_type(self) ‑> str -
-
-
-
- -Expand source code - -
def get_type(self) -> str:
-    match self:
-        case EPCRelsRelationshipType.EXTENDED_CORE_PROPERTIES:
-            return "http://schemas.f2i-consulting.com/package/2014/relationships/" + str(self.value)
-        case (
-        EPCRelsRelationshipType.CHUNKED_PART
-        | EPCRelsRelationshipType.DESTINATION_OBJECT
-        | EPCRelsRelationshipType.SOURCE_OBJECT
-        | EPCRelsRelationshipType.ML_TO_EXTERNAL_PART_PROXY
-        | EPCRelsRelationshipType.EXTERNAL_PART_PROXY_TO_ML
-        | EPCRelsRelationshipType.EXTERNAL_RESOURCE
-        | EPCRelsRelationshipType.DestinationMedia
-        | EPCRelsRelationshipType.SOURCE_MEDIA
-        | _
-        ):
-            return "http://schemas.energistics.org/package/2012/relationships/" + str(self.value)
-
-
-
-
-
-class Epc -(export_version: EpcExportVersion = EpcExportVersion.CLASSIC, core_props: energyml.opc.opc.CoreProperties = None, energyml_objects: List = <factory>, raw_files: List[RawFile] = <factory>, external_files_path: List[str] = <factory>, additional_rels: Dict[str, List[energyml.opc.opc.Relationship]] = <factory>, epc_file_path: Optional[str] = None) -
-
-

A class that represent an EPC file content

-
- -Expand source code - -
@dataclass
-class Epc:
-    """
-    A class that represent an EPC file content
-    """
-    # content_type: List[str] = field(
-    #     default_factory=list,
-    # )
-
-    export_version: EpcExportVersion = field(
-        default=EpcExportVersion.CLASSIC
-    )
-
-    core_props: CoreProperties = field(default=None)
-
-    """ xml files refered in the [Content_Types].xml  """
-    energyml_objects: List = field(
-        default_factory=list,
-    )
-
-    """ Other files content like pdf etc """
-    raw_files: List[RawFile] = field(
-        default_factory=list,
-    )
-
-    """ A list of external files. It ca be used to link hdf5 files """
-    external_files_path: List[str] = field(
-        default_factory=list,
-    )
-
-    """ 
-    Additional rels for objects. Key is the object (same than in @energyml_objects) and value is a list of
-    RelationShip. This can be used to link an HDF5 to an ExternalPartReference in resqml 2.0.1
-    Key is a value returned by @get_obj_identifier
-    """
-    additional_rels: Dict[str, List[Relationship]] = field(
-        default_factory=lambda: {}
-    )
-
-    """
-    Epc file path. Used when loaded from a local file or for export
-    """
-    epc_file_path: Optional[str] = field(
-        default=None
-    )
-
-    def __str__(self):
-        return (
-                "EPC file (" + str(self.export_version) + ") "
-                + f"{len(self.energyml_objects)} energyml objects and {len(self.raw_files)} other files {[f.path for f in self.raw_files]}"
-                # + f"\n{[serialize_json(ar) for ar in self.additional_rels]}"
-        )
-
-    # EXPORT functions
-
-    def gen_opc_content_type(self) -> Types:
-        """
-        Generates a :class:`Types` instance and fill it with energyml objects :class:`Override` values
-        :return:
-        """
-        ct = Types()
-        rels_default = Default()
-        rels_default.content_type = RELS_CONTENT_TYPE
-        rels_default.extension = "rels"
-
-        ct.default = [rels_default]
-
-        ct.override = []
-        for e_obj in self.energyml_objects:
-            ct.override.append(Override(
-                content_type=get_content_type_from_class(type(e_obj)),
-                part_name=gen_energyml_object_path(e_obj, self.export_version),
-            ))
-
-        if self.core_props is not None:
-            ct.override.append(Override(
-                content_type=get_content_type_from_class(self.core_props),
-                part_name=gen_core_props_path(self.export_version),
-            ))
-
-        return ct
-
-    def export_file(self, path: Optional[str] = None) -> None:
-        """
-        Export the epc file. If :param:`path` is None, the epc 'self.epc_file_path' is used
-        :param path:
-        :return:
-        """
-        if path is None:
-            path = self.epc_file_path
-        epc_io = self.export_io()
-        with open(path, "wb") as f:
-            f.write(epc_io.getbuffer())
-
-    def export_io(self) -> BytesIO:
-        """
-        Export the epc file into a :class:`BytesIO` instance. The result is an 'in-memory' zip file.
-        :return:
-        """
-        zip_buffer = BytesIO()
-
-        with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
-            #  Energyml objects
-            for e_obj in self.energyml_objects:
-                e_path = gen_energyml_object_path(e_obj, self.export_version)
-                zip_info = zipfile.ZipInfo(filename=e_path, date_time=datetime.datetime.now().timetuple()[:6])
-                data = serialize_xml(e_obj)
-                zip_file.writestr(zip_info, data)
-
-            # Rels
-            for rels_path, rels in self.compute_rels().items():
-                zip_info = zipfile.ZipInfo(filename=rels_path, date_time=datetime.datetime.now().timetuple()[:6])
-                data = serialize_xml(rels)
-                zip_file.writestr(zip_info, data)
-
-            # CoreProps
-            if self.core_props is not None:
-                zip_info = zipfile.ZipInfo(filename=gen_core_props_path(self.export_version),
-                                           date_time=datetime.datetime.now().timetuple()[:6])
-                data = serialize_xml(self.core_props)
-                zip_file.writestr(zip_info, data)
-
-            # ContentType
-            zip_info = zipfile.ZipInfo(filename=get_epc_content_type_path(),
-                                       date_time=datetime.datetime.now().timetuple()[:6])
-            data = serialize_xml(self.gen_opc_content_type())
-            zip_file.writestr(zip_info, data)
-
-        return zip_buffer
-
-    def compute_rels(self) -> Dict[str, Relationships]:
-        """
-        Returns a dict containing for each objet, the rels xml file path as key and the RelationShips object as value
-        :return:
-        """
-        dor_relation = get_reverse_dor_list(self.energyml_objects)
-
-        # destObject
-        rels = {
-            obj_id: [
-                Relationship(
-                    target=gen_energyml_object_path(target_obj, self.export_version),
-                    type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(),
-                    id=f"_{obj_id}_{get_obj_type(target_obj)}_{get_obj_identifier(target_obj)}",
-                ) for target_obj in target_obj_list
-            ]
-            for obj_id, target_obj_list in dor_relation.items()
-        }
-        # sourceObject
-        for obj in self.energyml_objects:
-            obj_id = get_obj_identifier(obj)
-            if obj_id not in rels:
-                rels[obj_id] = []
-            for target_obj in get_direct_dor_list(obj):
-                rels[obj_id].append(Relationship(
-                    target=gen_energyml_object_path(target_obj, self.export_version),
-                    type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(),
-                    id=f"_{obj_id}_{get_obj_type(target_obj)}_{get_obj_identifier(target_obj)}",
-                ))
-
-        map_obj_id_to_obj = {
-            get_obj_identifier(obj): obj
-            for obj in self.energyml_objects
-        }
-
-        obj_rels = {
-            gen_rels_path(energyml_object=map_obj_id_to_obj.get(obj_id), export_version=self.export_version): Relationships(
-                relationship=obj_rels + (self.additional_rels[obj_id] if obj_id in self.additional_rels else []),
-
-            )
-            for obj_id, obj_rels in rels.items()
-        }
-
-        # CoreProps
-        if self.core_props is not None:
-            obj_rels[gen_rels_path(self.core_props)] = Relationships(
-                relationship=[
-                    Relationship(
-                        target=gen_core_props_path(),
-                        type_value=EPCRelsRelationshipType.EXTENDED_CORE_PROPERTIES.get_type(),
-                        id="CoreProperties"
-                    )
-                ]
-            )
-
-        return obj_rels
-
-    # -----------
-
-    def get_object_by_uuid(self, uuid: str) -> List[Any]:
-        """
-        Search all objects with the uuid :param:`uuid`.
-        :param uuid:
-        :return:
-        """
-        return list(filter(lambda o: get_obj_uuid(o) == uuid, self.energyml_objects))
-
-    def get_object_by_identifier(self, identifier: str) -> Optional[Any]:
-        """
-        Search an object by its identifier.
-        :param identifier: given by the function :func:`get_obj_identifier`
-        :return:
-        """
-        for o in self.energyml_objects:
-            if get_obj_identifier(o) == identifier:
-                return o
-        return None
-
-    def get_epc_file_folder(self) -> Optional[str]:
-        if self.epc_file_path is not None and len(self.epc_file_path) > 0:
-            folders_and_name = re.split(r"[\\/]", self.epc_file_path)
-            if len(folders_and_name) > 1:
-                return "/".join(folders_and_name[:-1])
-            else:
-                return ""
-        return None
-
-    # Class methods
-
-    @classmethod
-    def read_file(cls, epc_file_path: str):
-        with open(epc_file_path, "rb") as f:
-            epc = cls.read_stream(BytesIO(f.read()))
-            epc.epc_file_path = epc_file_path
-            return epc
-
-    @classmethod
-    def read_stream(cls, epc_file_io: BytesIO):  # returns an Epc instance
-        """
-        :param epc_file_io:
-        :return: an :class:`EPC` instance
-        """
-        try:
-            _read_files = []
-            obj_list = []
-            raw_file_list = []
-            additional_rels = {}
-            core_props = None
-            with zipfile.ZipFile(epc_file_io, "r", zipfile.ZIP_DEFLATED) as epc_file:
-                content_type_file_name = get_epc_content_type_path()
-                content_type_info = None
-                try:
-                    content_type_info = epc_file.getinfo(content_type_file_name)
-                except KeyError:
-                    for info in epc_file.infolist():
-                        if info.filename.lower() == content_type_file_name.lower():
-                            content_type_info = info
-                            break
-
-                _read_files.append(content_type_file_name)
-
-                if content_type_info is None:
-                    print(f"No {content_type_file_name} file found")
-                else:
-                    content_type_obj: Types = read_energyml_xml_bytes(epc_file.read(content_type_file_name))
-                    path_to_obj = {}
-                    for ov in content_type_obj.override:
-                        ov_ct = ov.content_type
-                        ov_path = ov.part_name
-                        # print(ov_ct)
-                        while ov_path.startswith("/") or ov_path.startswith("\\"):
-                            ov_path = ov_path[1:]
-                        if is_energyml_content_type(ov_ct):
-                            _read_files.append(ov_path)
-                            try:
-                                ov_obj = read_energyml_xml_bytes_as_class(
-                                    epc_file.read(ov_path),
-                                    get_class_from_content_type(ov_ct)
-                                )
-                                if isinstance(ov_obj, DerivedElement):
-                                    ov_obj = ov_obj.value
-                                path_to_obj[ov_path] = ov_obj
-                                obj_list.append(ov_obj)
-                            except ParserError as e:
-                                print(f"Epc.@read_stream failed to parse file {ov_path} for content-type: {ov_ct} => {get_class_from_content_type(ov_ct)}")
-                                raise e
-                        elif get_class_from_content_type(ov_ct) == CoreProperties:
-                            _read_files.append(ov_path)
-                            core_props = read_energyml_xml_bytes_as_class(epc_file.read(ov_path), CoreProperties)
-
-                    for f_info in epc_file.infolist():
-                        if f_info.filename not in _read_files:
-                            _read_files.append(f_info.filename)
-                            if not f_info.filename.lower().endswith(".rels"):
-                                try:
-                                    raw_file_list.append(
-                                        RawFile(
-                                            path=f_info.filename,
-                                            content=BytesIO(epc_file.read(f_info.filename)),
-                                        )
-                                    )
-                                except IOError as e:
-                                    print(e)
-                            else:  # rels
-                                # print(f"reading rels {f_info.filename}")
-                                rels_folder, rels_file_name = get_file_folder_and_name_from_path(f_info.filename)
-                                while rels_folder.endswith("/"):
-                                    rels_folder = rels_folder[:-1]
-                                obj_folder = rels_folder[:rels_folder.rindex("/") + 1] if "/" in rels_folder else ""
-                                obj_file_name = rels_file_name[:-5]  # removing the ".rels"
-                                rels_file: Relationships = read_energyml_xml_bytes_as_class(
-                                    epc_file.read(f_info.filename),
-                                    Relationships
-                                )
-                                obj_path = obj_folder + obj_file_name
-                                if obj_path in path_to_obj:
-                                    try:
-                                        additional_rels_key = get_obj_identifier(path_to_obj[obj_path])
-                                        for rel in rels_file.relationship:
-                                            # print(f"\t\t{rel.type_value}")
-                                            if (rel.type_value != EPCRelsRelationshipType.DESTINATION_OBJECT.get_type()
-                                                    and rel.type_value != EPCRelsRelationshipType.SOURCE_OBJECT.get_type()
-                                                    and rel.type_value != EPCRelsRelationshipType.EXTENDED_CORE_PROPERTIES.get_type()
-                                            ):  # not a computable relation
-                                                if additional_rels_key not in additional_rels:
-                                                    additional_rels[additional_rels_key] = []
-                                                additional_rels[additional_rels_key].append(rel)
-                                    except Exception as e:
-                                        print(f"Error with obj path {obj_path} {path_to_obj[obj_path]}")
-                                        raise e
-                                else:
-                                    print(f"xml file {obj_path} not found in EPC (rels is not associate to any object)")
-
-            return Epc(energyml_objects=obj_list,
-                       raw_files=raw_file_list,
-                       core_props=core_props,
-                       additional_rels=additional_rels
-                       )
-        except zipfile.BadZipFile as error:
-            print(error)
-
-        return None
-
-

Class variables

-
-
var additional_rels : Dict[str, List[energyml.opc.opc.Relationship]]
-
-

Epc file path. Used when loaded from a local file or for export

-
-
var core_props : energyml.opc.opc.CoreProperties
-
-

xml files refered in the [Content_Types].xml

-
-
var energyml_objects : List
-
-

Other files content like pdf etc

-
-
var epc_file_path : Optional[str]
-
-
-
-
var export_versionEpcExportVersion
-
-
-
-
var external_files_path : List[str]
-
-

Additional rels for objects. Key is the object (same than in @energyml_objects) and value is a list of -RelationShip. This can be used to link an HDF5 to an ExternalPartReference in resqml 2.0.1 -Key is a value returned by @get_obj_identifier

-
-
var raw_files : List[RawFile]
-
-

A list of external files. It ca be used to link hdf5 files

-
-
-

Static methods

-
-
-def read_file(epc_file_path: str) -
-
-
-
- -Expand source code - -
@classmethod
-def read_file(cls, epc_file_path: str):
-    with open(epc_file_path, "rb") as f:
-        epc = cls.read_stream(BytesIO(f.read()))
-        epc.epc_file_path = epc_file_path
-        return epc
-
-
-
-def read_stream(epc_file_io: _io.BytesIO) -
-
-

:param epc_file_io: -:return: an :class:EPC instance

-
- -Expand source code - -
@classmethod
-def read_stream(cls, epc_file_io: BytesIO):  # returns an Epc instance
-    """
-    :param epc_file_io:
-    :return: an :class:`EPC` instance
-    """
-    try:
-        _read_files = []
-        obj_list = []
-        raw_file_list = []
-        additional_rels = {}
-        core_props = None
-        with zipfile.ZipFile(epc_file_io, "r", zipfile.ZIP_DEFLATED) as epc_file:
-            content_type_file_name = get_epc_content_type_path()
-            content_type_info = None
-            try:
-                content_type_info = epc_file.getinfo(content_type_file_name)
-            except KeyError:
-                for info in epc_file.infolist():
-                    if info.filename.lower() == content_type_file_name.lower():
-                        content_type_info = info
-                        break
-
-            _read_files.append(content_type_file_name)
-
-            if content_type_info is None:
-                print(f"No {content_type_file_name} file found")
-            else:
-                content_type_obj: Types = read_energyml_xml_bytes(epc_file.read(content_type_file_name))
-                path_to_obj = {}
-                for ov in content_type_obj.override:
-                    ov_ct = ov.content_type
-                    ov_path = ov.part_name
-                    # print(ov_ct)
-                    while ov_path.startswith("/") or ov_path.startswith("\\"):
-                        ov_path = ov_path[1:]
-                    if is_energyml_content_type(ov_ct):
-                        _read_files.append(ov_path)
-                        try:
-                            ov_obj = read_energyml_xml_bytes_as_class(
-                                epc_file.read(ov_path),
-                                get_class_from_content_type(ov_ct)
-                            )
-                            if isinstance(ov_obj, DerivedElement):
-                                ov_obj = ov_obj.value
-                            path_to_obj[ov_path] = ov_obj
-                            obj_list.append(ov_obj)
-                        except ParserError as e:
-                            print(f"Epc.@read_stream failed to parse file {ov_path} for content-type: {ov_ct} => {get_class_from_content_type(ov_ct)}")
-                            raise e
-                    elif get_class_from_content_type(ov_ct) == CoreProperties:
-                        _read_files.append(ov_path)
-                        core_props = read_energyml_xml_bytes_as_class(epc_file.read(ov_path), CoreProperties)
-
-                for f_info in epc_file.infolist():
-                    if f_info.filename not in _read_files:
-                        _read_files.append(f_info.filename)
-                        if not f_info.filename.lower().endswith(".rels"):
-                            try:
-                                raw_file_list.append(
-                                    RawFile(
-                                        path=f_info.filename,
-                                        content=BytesIO(epc_file.read(f_info.filename)),
-                                    )
-                                )
-                            except IOError as e:
-                                print(e)
-                        else:  # rels
-                            # print(f"reading rels {f_info.filename}")
-                            rels_folder, rels_file_name = get_file_folder_and_name_from_path(f_info.filename)
-                            while rels_folder.endswith("/"):
-                                rels_folder = rels_folder[:-1]
-                            obj_folder = rels_folder[:rels_folder.rindex("/") + 1] if "/" in rels_folder else ""
-                            obj_file_name = rels_file_name[:-5]  # removing the ".rels"
-                            rels_file: Relationships = read_energyml_xml_bytes_as_class(
-                                epc_file.read(f_info.filename),
-                                Relationships
-                            )
-                            obj_path = obj_folder + obj_file_name
-                            if obj_path in path_to_obj:
-                                try:
-                                    additional_rels_key = get_obj_identifier(path_to_obj[obj_path])
-                                    for rel in rels_file.relationship:
-                                        # print(f"\t\t{rel.type_value}")
-                                        if (rel.type_value != EPCRelsRelationshipType.DESTINATION_OBJECT.get_type()
-                                                and rel.type_value != EPCRelsRelationshipType.SOURCE_OBJECT.get_type()
-                                                and rel.type_value != EPCRelsRelationshipType.EXTENDED_CORE_PROPERTIES.get_type()
-                                        ):  # not a computable relation
-                                            if additional_rels_key not in additional_rels:
-                                                additional_rels[additional_rels_key] = []
-                                            additional_rels[additional_rels_key].append(rel)
-                                except Exception as e:
-                                    print(f"Error with obj path {obj_path} {path_to_obj[obj_path]}")
-                                    raise e
-                            else:
-                                print(f"xml file {obj_path} not found in EPC (rels is not associate to any object)")
-
-        return Epc(energyml_objects=obj_list,
-                   raw_files=raw_file_list,
-                   core_props=core_props,
-                   additional_rels=additional_rels
-                   )
-    except zipfile.BadZipFile as error:
-        print(error)
-
-    return None
-
-
-
-

Methods

-
-
-def compute_rels(self) ‑> Dict[str, energyml.opc.opc.Relationships] -
-
-

Returns a dict containing for each objet, the rels xml file path as key and the RelationShips object as value -:return:

-
- -Expand source code - -
def compute_rels(self) -> Dict[str, Relationships]:
-    """
-    Returns a dict containing for each objet, the rels xml file path as key and the RelationShips object as value
-    :return:
-    """
-    dor_relation = get_reverse_dor_list(self.energyml_objects)
-
-    # destObject
-    rels = {
-        obj_id: [
-            Relationship(
-                target=gen_energyml_object_path(target_obj, self.export_version),
-                type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(),
-                id=f"_{obj_id}_{get_obj_type(target_obj)}_{get_obj_identifier(target_obj)}",
-            ) for target_obj in target_obj_list
-        ]
-        for obj_id, target_obj_list in dor_relation.items()
-    }
-    # sourceObject
-    for obj in self.energyml_objects:
-        obj_id = get_obj_identifier(obj)
-        if obj_id not in rels:
-            rels[obj_id] = []
-        for target_obj in get_direct_dor_list(obj):
-            rels[obj_id].append(Relationship(
-                target=gen_energyml_object_path(target_obj, self.export_version),
-                type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(),
-                id=f"_{obj_id}_{get_obj_type(target_obj)}_{get_obj_identifier(target_obj)}",
-            ))
-
-    map_obj_id_to_obj = {
-        get_obj_identifier(obj): obj
-        for obj in self.energyml_objects
-    }
-
-    obj_rels = {
-        gen_rels_path(energyml_object=map_obj_id_to_obj.get(obj_id), export_version=self.export_version): Relationships(
-            relationship=obj_rels + (self.additional_rels[obj_id] if obj_id in self.additional_rels else []),
-
-        )
-        for obj_id, obj_rels in rels.items()
-    }
-
-    # CoreProps
-    if self.core_props is not None:
-        obj_rels[gen_rels_path(self.core_props)] = Relationships(
-            relationship=[
-                Relationship(
-                    target=gen_core_props_path(),
-                    type_value=EPCRelsRelationshipType.EXTENDED_CORE_PROPERTIES.get_type(),
-                    id="CoreProperties"
-                )
-            ]
-        )
-
-    return obj_rels
-
-
-
-def export_file(self, path: Optional[str] = None) ‑> None -
-
-

Export the epc file. If :param:path is None, the epc 'self.epc_file_path' is used -:param path: -:return:

-
- -Expand source code - -
def export_file(self, path: Optional[str] = None) -> None:
-    """
-    Export the epc file. If :param:`path` is None, the epc 'self.epc_file_path' is used
-    :param path:
-    :return:
-    """
-    if path is None:
-        path = self.epc_file_path
-    epc_io = self.export_io()
-    with open(path, "wb") as f:
-        f.write(epc_io.getbuffer())
-
-
-
-def export_io(self) ‑> _io.BytesIO -
-
-

Export the epc file into a :class:BytesIO instance. The result is an 'in-memory' zip file. -:return:

-
- -Expand source code - -
def export_io(self) -> BytesIO:
-    """
-    Export the epc file into a :class:`BytesIO` instance. The result is an 'in-memory' zip file.
-    :return:
-    """
-    zip_buffer = BytesIO()
-
-    with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
-        #  Energyml objects
-        for e_obj in self.energyml_objects:
-            e_path = gen_energyml_object_path(e_obj, self.export_version)
-            zip_info = zipfile.ZipInfo(filename=e_path, date_time=datetime.datetime.now().timetuple()[:6])
-            data = serialize_xml(e_obj)
-            zip_file.writestr(zip_info, data)
-
-        # Rels
-        for rels_path, rels in self.compute_rels().items():
-            zip_info = zipfile.ZipInfo(filename=rels_path, date_time=datetime.datetime.now().timetuple()[:6])
-            data = serialize_xml(rels)
-            zip_file.writestr(zip_info, data)
-
-        # CoreProps
-        if self.core_props is not None:
-            zip_info = zipfile.ZipInfo(filename=gen_core_props_path(self.export_version),
-                                       date_time=datetime.datetime.now().timetuple()[:6])
-            data = serialize_xml(self.core_props)
-            zip_file.writestr(zip_info, data)
-
-        # ContentType
-        zip_info = zipfile.ZipInfo(filename=get_epc_content_type_path(),
-                                   date_time=datetime.datetime.now().timetuple()[:6])
-        data = serialize_xml(self.gen_opc_content_type())
-        zip_file.writestr(zip_info, data)
-
-    return zip_buffer
-
-
-
-def gen_opc_content_type(self) ‑> energyml.opc.opc.Types -
-
-

Generates a :class:Types instance and fill it with energyml objects :class:Override values -:return:

-
- -Expand source code - -
def gen_opc_content_type(self) -> Types:
-    """
-    Generates a :class:`Types` instance and fill it with energyml objects :class:`Override` values
-    :return:
-    """
-    ct = Types()
-    rels_default = Default()
-    rels_default.content_type = RELS_CONTENT_TYPE
-    rels_default.extension = "rels"
-
-    ct.default = [rels_default]
-
-    ct.override = []
-    for e_obj in self.energyml_objects:
-        ct.override.append(Override(
-            content_type=get_content_type_from_class(type(e_obj)),
-            part_name=gen_energyml_object_path(e_obj, self.export_version),
-        ))
-
-    if self.core_props is not None:
-        ct.override.append(Override(
-            content_type=get_content_type_from_class(self.core_props),
-            part_name=gen_core_props_path(self.export_version),
-        ))
-
-    return ct
-
-
-
-def get_epc_file_folder(self) ‑> Optional[str] -
-
-
-
- -Expand source code - -
def get_epc_file_folder(self) -> Optional[str]:
-    if self.epc_file_path is not None and len(self.epc_file_path) > 0:
-        folders_and_name = re.split(r"[\\/]", self.epc_file_path)
-        if len(folders_and_name) > 1:
-            return "/".join(folders_and_name[:-1])
-        else:
-            return ""
-    return None
-
-
-
-def get_object_by_identifier(self, identifier: str) ‑> Optional[Any] -
-
-

Search an object by its identifier. -:param identifier: given by the function :func:get_obj_identifier() -:return:

-
- -Expand source code - -
def get_object_by_identifier(self, identifier: str) -> Optional[Any]:
-    """
-    Search an object by its identifier.
-    :param identifier: given by the function :func:`get_obj_identifier`
-    :return:
-    """
-    for o in self.energyml_objects:
-        if get_obj_identifier(o) == identifier:
-            return o
-    return None
-
-
-
-def get_object_by_uuid(self, uuid: str) ‑> List[Any] -
-
-

Search all objects with the uuid :param:uuid. -:param uuid: -:return:

-
- -Expand source code - -
def get_object_by_uuid(self, uuid: str) -> List[Any]:
-    """
-    Search all objects with the uuid :param:`uuid`.
-    :param uuid:
-    :return:
-    """
-    return list(filter(lambda o: get_obj_uuid(o) == uuid, self.energyml_objects))
-
-
-
-
-
-class EpcExportVersion -(*args, **kwds) -
-
-

EPC export version.

-
- -Expand source code - -
class EpcExportVersion(Enum):
-    """EPC export version."""
-    #: Classical export
-    CLASSIC = 1
-    #: Export with objet path sorted by package (eml/resqml/witsml/prodml)
-    EXPANDED = 2
-
-

Ancestors

-
    -
  • enum.Enum
  • -
-

Class variables

-
-
var CLASSIC
-
-

Classical export

-
-
var EXPANDED
-
-

Export with objet path sorted by package (eml/resqml/witsml/prodml)

-
-
-
-
-class NoCrsException -(*args, **kwargs) -
-
-

Common base class for all non-exit exceptions.

-
- -Expand source code - -
class NoCrsException(Exception):
-    pass
-
-

Ancestors

-
    -
  • builtins.Exception
  • -
  • builtins.BaseException
  • -
-
-
-class ObjectNotFoundNotException -(obj_id: str = None) -
-
-

ObjectNotFoundNotException(obj_id: str = None)

-
- -Expand source code - -
@dataclass
-class ObjectNotFoundNotException(Exception):
-    obj_id: str = field(
-        default=None
-    )
-
-

Ancestors

-
    -
  • builtins.Exception
  • -
  • builtins.BaseException
  • -
-

Class variables

-
-
var obj_id : str
-
-
-
-
-
-
-class RawFile -(path: str = '_', content: _io.BytesIO = None) -
-
-

RawFile(path: str = '_', content: _io.BytesIO = None)

-
- -Expand source code - -
@dataclass
-class RawFile:
-    path: str = field(default="_")
-    content: BytesIO = field(default=None)
-
-

Class variables

-
-
var content : _io.BytesIO
-
-
-
-
var path : str
-
-
-
-
-
-
-
-
- -
- - - \ No newline at end of file diff --git a/energyml-utils/docs/src/energyml/utils/index.html b/energyml-utils/docs/src/energyml/utils/index.html deleted file mode 100644 index b18f86d..0000000 --- a/energyml-utils/docs/src/energyml/utils/index.html +++ /dev/null @@ -1,140 +0,0 @@ - - - - - - -src.energyml.utils API documentation - - - - - - - - - - - -
-
-
-

Module src.energyml.utils

-
-
-

The energyml.utils module. -It contains tools for energyml management.

-

Please check the following module (depending on your need): -- energyml-opc -- energyml-common2-0 -- energyml-common2-1 -- energyml-common2-2 -- energyml-common2-3 -- energyml-resqml2-0-1 -- energyml-resqml2-2-dev3 -- energyml-resqml2-2 -- energyml-witsml2-0 -- energyml-witsml2-1 -- energyml-prodml2-0 -- energyml-prodml2-2

-
- -Expand source code - -
# Copyright (c) 2023-2024 Geosiris.
-# SPDX-License-Identifier: Apache-2.0
-
-"""
-The energyml.utils module.
-It contains tools for energyml management.
-
-Please check the following module (depending on your need):
-    - energyml-opc
-    - energyml-common2-0
-    - energyml-common2-1
-    - energyml-common2-2
-    - energyml-common2-3
-    - energyml-resqml2-0-1
-    - energyml-resqml2-2-dev3
-    - energyml-resqml2-2
-    - energyml-witsml2-0
-    - energyml-witsml2-1
-    - energyml-prodml2-0
-    - energyml-prodml2-2
-"""
-
-
-
-

Sub-modules

-
-
src.energyml.utils.data
-
-

The data module …

-
-
src.energyml.utils.epc
-
-

This example module shows various types of documentation available for use -with pydoc. -To generate HTML documentation for this module issue the -…

-
-
src.energyml.utils.introspection
-
-
-
-
src.energyml.utils.manager
-
-
-
-
src.energyml.utils.serialization
-
-
-
-
src.energyml.utils.validation
-
-
-
-
src.energyml.utils.xml
-
-
-
-
-
-
-
-
-
-
-
-
- -
- - - \ No newline at end of file diff --git a/energyml-utils/docs/src/energyml/utils/introspection.html b/energyml-utils/docs/src/energyml/utils/introspection.html deleted file mode 100644 index 23716e2..0000000 --- a/energyml-utils/docs/src/energyml/utils/introspection.html +++ /dev/null @@ -1,2141 +0,0 @@ - - - - - - -src.energyml.utils.introspection API documentation - - - - - - - - - - - -
-
-
-

Module src.energyml.utils.introspection

-
-
-
- -Expand source code - -
# Copyright (c) 2023-2024 Geosiris.
-# SPDX-License-Identifier: Apache-2.0
-import datetime
-import random
-import re
-import sys
-import typing
-import uuid as uuid_mod
-from dataclasses import Field
-from enum import Enum
-from importlib import import_module
-from typing import Any, List, Optional, Union, Dict, Tuple
-
-from .manager import get_class_pkg, get_class_pkg_version, RELATED_MODULES, \
-    get_related_energyml_modules_name, get_sub_classes, get_classes_matching_name
-from .xml import parse_content_type, ENERGYML_NAMESPACES
-
-
-primitives = (bool, str, int, float, type(None))
-
-
-def is_enum(cls: Union[type, Any]):
-    """
-    Returns True if :param:`cls` is an Enum
-    :param cls:
-    :return:
-    """
-    if isinstance(cls, type):
-        return Enum in cls.__bases__
-    return is_enum(type(cls))
-
-
-def is_primitive(cls: Union[type, Any]) -> bool:
-    """
-    Returns True if :param:`cls` is a primitiv type or extends Enum
-    :param cls:
-    :return: bool
-    """
-    if isinstance(cls, type):
-        return cls in primitives or Enum in cls.__bases__
-    return is_primitive(type(cls))
-
-
-def is_abstract(cls: Union[type, Any]) -> bool:
-    """
-    Returns True if :param:`cls` is an abstract class
-    :param cls:
-    :return: bool
-    """
-    if isinstance(cls, type):
-        return not is_primitive(cls) and (cls.__name__.startswith("Abstract") or (hasattr(cls, "__dataclass_fields__") and len(cls.__dataclass_fields__)) == 0) and len(get_class_methods(cls)) == 0
-    return is_abstract(type(cls))
-
-
-def get_class_methods(cls: Union[type, Any]) -> List[str]:
-    """
-    Returns the list of the methods names for a specific class.
-    :param cls:
-    :return:
-    """
-    return [func for func in dir(cls) if callable(getattr(cls, func)) and not func.startswith("__") and not isinstance(getattr(cls, func), type)]
-
-
-def get_class_from_name(class_name_and_module: str) -> Optional[type]:
-    """
-    Return a :class:`type` object matching with the name :param:`class_name_and_module`.
-    :param class_name_and_module:
-    :return:
-    """
-    module_name = class_name_and_module[: class_name_and_module.rindex(".")]
-    last_ns_part = class_name_and_module[
-                   class_name_and_module.rindex(".") + 1:
-                   ]
-    try:
-        # Required to read "CustomData" on eml objects that may contain resqml values
-        # ==> we need to import all modules related to the same version of the common
-        import_related_module(module_name)
-        return getattr(sys.modules[module_name], last_ns_part)
-    except AttributeError as e:
-        if "2d" in last_ns_part:
-            return get_class_from_name(
-                class_name_and_module.replace("2d", "2D")
-            )
-        elif "3d" in last_ns_part:
-            return get_class_from_name(
-                class_name_and_module.replace("3d", "3D")
-            )
-        elif last_ns_part[0].islower():
-            return get_class_from_name(
-                module_name + "." + last_ns_part[0].upper() + last_ns_part[1:]
-            )
-        else:
-            print(e)
-    return None
-
-
-def get_class_from_content_type(content_type: str) -> Optional[type]:
-    """
-    Return a :class:`type` object matching with the content-type :param:`content_type`.
-    :param content_type:
-    :return:
-    """
-    ct = parse_content_type(content_type)
-    domain = ct.group("domain")
-    if domain is None:
-        domain = "opc"
-    if domain == "opc":
-        xml_domain = ct.group("xmlDomain")
-        if "." in xml_domain:
-            xml_domain = xml_domain[xml_domain.rindex(".") + 1:]
-        if "extended" in xml_domain:
-            xml_domain = xml_domain.replace("extended", "")
-        opc_type = pascal_case(xml_domain)
-        # print("energyml.opc.opc." + opc_type)
-        return get_class_from_name("energyml.opc.opc." + opc_type)
-    else:
-        ns = ENERGYML_NAMESPACES[domain]
-        domain = ct.group("domain")
-        obj_type = ct.group("type")
-        if obj_type.lower().startswith("obj_"):  # for resqml201
-            obj_type = "Obj" + obj_type[4:]
-        version_num = str(ct.group("domainVersion")).replace(".", "_")
-        if domain.lower() == "resqml" and version_num.startswith("2_0"):
-            version_num = "2_0_1"
-        return get_class_from_name(
-            "energyml."
-            + domain
-            + ".v"
-            + version_num
-            + "."
-            + ns[ns.rindex("/") + 1:]
-            + "."
-            + obj_type
-        )
-
-
-def snake_case(s: str) -> str:
-    """ Transform a str into snake case. """
-    s = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', s)
-    s = re.sub('__([A-Z])', r'_\1', s)
-    s = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s)
-    return s.lower()
-
-
-def pascal_case(s: str) -> str:
-    """ Transform a str into pascal case. """
-    return snake_case(s).replace("_", " ").title().replace(" ", "")
-
-
-def flatten_concatenation(matrix) -> List:
-    """
-    Flatten a matrix.
-
-    Example :
-        [ [a,b,c], [d,e,f], [ [x,y,z], [0] ] ]
-        will be translated in: [a, b, c, d, e, f, [x,y,z], [0]]
-    :param matrix:
-    :return:
-    """
-    flat_list = []
-    for row in matrix:
-        flat_list += row
-    return flat_list
-
-
-def import_related_module(energyml_module_name: str) -> None:
-    """
-    Import related modules for a specific energyml module. (See. :const:`RELATED_MODULES`)
-    :param energyml_module_name:
-    :return:
-    """
-    for related in RELATED_MODULES:
-        if energyml_module_name in related:
-            for m in related:
-                try:
-                    import_module(m)
-                except Exception as e:
-                    pass
-                    # print(e)
-
-
-def get_class_fields(cls: Union[type, Any]) -> Dict[str, Field]:
-    """
-    Return all class fields names, mapped to their :class:`Field` value.
-    :param cls:
-    :return:
-    """
-    if not isinstance(cls, type):  # if cls is an instance
-        cls = type(cls)
-    try:
-        return cls.__dataclass_fields__
-    except AttributeError:
-        return {}
-
-
-def get_class_attributes(cls: Union[type, Any]) -> List[str]:
-    """
-    returns a list of attributes (not private ones)
-    """
-    # if not isinstance(cls, type):  # if cls is an instance
-    #     cls = type(cls)
-    # return list(filter(lambda a: not a.startswith("__"), dir(cls)))
-    return list(get_class_fields(cls).keys())
-
-
-def get_matching_class_attribute_name(
-        cls: Union[type, Any], attribute_name: str, re_flags=re.IGNORECASE,
-) -> Optional[str]:
-    """
-    From an object and an attribute name, returns the correct attribute name of the class.
-    Example : "ObjectVersion" --> object_version.
-    This method doesn't only transform to snake case but search into the obj class attributes
-    """
-    class_fields = get_class_fields(cls)
-
-    # a search with the exact value
-    for name, cf in class_fields.items():
-        if (
-                snake_case(name) == snake_case(attribute_name)
-                or ('name' in cf.metadata and cf.metadata['name'] == attribute_name)
-        ):
-            return name
-
-    # search regex after to avoid shadowing perfect match
-    pattern = re.compile(attribute_name, flags=re_flags)
-    for name, cf in class_fields.items():
-        # print(f"\t->{name} : {attribute_name} {pattern.match(name)} {('name' in cf.metadata and pattern.match(cf.metadata['name']))}")
-        if pattern.match(name) or ('name' in cf.metadata and pattern.match(cf.metadata['name'])):
-            return name
-
-    return None
-
-
-def get_object_attribute(
-        obj: Any, attr_dot_path: str, force_snake_case=True
-) -> Any:
-    """
-    returns the value of an attribute given by a dot representation of its path in the object
-    example "Citation.Title"
-    """
-    while attr_dot_path.startswith("."):  # avoid '.Citation.Title' to take an empty attribute name before the first '.'
-        attr_dot_path = attr_dot_path[1:]
-
-    current_attrib_name = attr_dot_path
-
-    if "." in attr_dot_path:
-        current_attrib_name = attr_dot_path.split(".")[0]
-
-    if force_snake_case:
-        current_attrib_name = snake_case(current_attrib_name)
-
-    value = None
-    if isinstance(obj, list):
-        value = obj[int(current_attrib_name)]
-    elif isinstance(obj, dict):
-        value = obj[current_attrib_name]
-    else:
-        value = getattr(obj, current_attrib_name)
-
-    if "." in attr_dot_path:
-        return get_object_attribute(
-            value, attr_dot_path[len(current_attrib_name) + 1:]
-        )
-    else:
-        return value
-
-
-def get_object_attribute_advanced(obj: Any, attr_dot_path: str) -> Any:
-    """
-    see @get_matching_class_attribute_name and @get_object_attribute
-    """
-    current_attrib_name = attr_dot_path
-
-    if "." in attr_dot_path:
-        current_attrib_name = attr_dot_path.split(".")[0]
-
-    current_attrib_name = get_matching_class_attribute_name(
-        obj, current_attrib_name
-    )
-
-    value = None
-    if isinstance(obj, list):
-        value = obj[int(current_attrib_name)]
-    elif isinstance(obj, dict):
-        value = obj[current_attrib_name]
-    else:
-        value = getattr(obj, current_attrib_name)
-
-    if "." in attr_dot_path:
-        return get_object_attribute_advanced(
-            value, attr_dot_path[len(current_attrib_name) + 1:]
-        )
-    else:
-        return value
-
-
-def get_object_attribute_no_verif(obj: Any, attr_name: str) -> Any:
-    """
-    Return the value of the attribute named after param :param:`attr_name` without verification (may raise an exception
-    if it doesn't exists).
-
-    Note: attr_name="0" will work if :param:`obj` is of type :class:`List`
-    :param obj:
-    :param attr_name:
-    :return:
-    """
-    if isinstance(obj, list):
-        return obj[int(attr_name)]
-    elif isinstance(obj, dict):
-        return obj[attr_name]
-    else:
-        return getattr(obj, attr_name)
-
-
-def get_object_attribute_rgx(obj: Any, attr_dot_path_rgx: str) -> Any:
-    """
-    see @get_object_attribute. Search the attribute name using regex for values between dots.
-    Example : [Cc]itation.[Tt]it\\.*
-    """
-    current_attrib_name = attr_dot_path_rgx
-
-    attrib_list = re.split(r"(?<!\\)\.+", attr_dot_path_rgx)
-
-    if len(attrib_list) > 0:
-        current_attrib_name = attrib_list[0]
-
-    # unescape Dot
-    current_attrib_name = current_attrib_name.replace("\\.", ".")
-
-    real_attrib_name = get_matching_class_attribute_name(
-        obj, current_attrib_name
-    )
-    if real_attrib_name is not None:
-        value = get_object_attribute_no_verif(obj, real_attrib_name)
-
-        if len(attrib_list) > 1:
-            return get_object_attribute_rgx(
-                value, attr_dot_path_rgx[len(current_attrib_name) + 1:]
-            )
-        else:
-            return value
-    return None
-
-
-def get_obj_type(obj: Any) -> str:
-    """ Return the type name of an object. If obj is already a :class:`type`, return its __name__"""
-    if isinstance(obj, type):
-        return str(obj.__name__)
-    return get_obj_type(type(obj))
-
-
-def class_match_rgx(
-        cls: Union[type, Any],
-        rgx: str,
-        super_class_search: bool = True,
-        re_flags=re.IGNORECASE,
-):
-    if not isinstance(cls, type):
-        cls = type(cls)
-
-    if re.match(rgx, cls.__name__, re_flags):
-        return True
-
-    if not is_primitive(cls) and super_class_search:
-        for base in cls.__bases__:
-            if class_match_rgx(base, rgx, super_class_search, re_flags):
-                return True
-    return False
-
-
-def search_attribute_matching_type_with_path(
-        obj: Any,
-        type_rgx: str,
-        re_flags=re.IGNORECASE,
-        return_self: bool = True,  # test directly on input object and not only in its attributes
-        deep_search: bool = True,  # Search inside a matching object
-        super_class_search: bool = True,  # Search inside in super classes of the object
-        current_path: str = "",
-) -> List[Tuple[str, Any]]:
-    """
-    Returns a list of tuple (path, value) for each sub attribute with type matching param "type_rgx".
-    The path is a dot-version like ".Citation.Title"
-    :param obj:
-    :param type_rgx:
-    :param re_flags:
-    :param return_self:
-    :param deep_search:
-    :param super_class_search:
-    :param current_path:
-    :return:
-    """
-    res = []
-    if obj is not None:
-        if return_self and class_match_rgx(
-                obj, type_rgx, super_class_search, re_flags
-        ):
-            res.append((current_path, obj))
-            if not deep_search:
-                return res
-
-    if isinstance(obj, list):
-        cpt = 0
-        for s_o in obj:
-            res = res + search_attribute_matching_type_with_path(
-                obj=s_o,
-                type_rgx=type_rgx,
-                re_flags=re_flags,
-                return_self=True,
-                deep_search=deep_search,
-                current_path=f"{current_path}.{cpt}",
-                super_class_search=super_class_search,
-            )
-            cpt = cpt + 1
-    elif isinstance(obj, dict):
-        for k, s_o in obj.items():
-            res = res + search_attribute_matching_type_with_path(
-                obj=s_o,
-                type_rgx=type_rgx,
-                re_flags=re_flags,
-                return_self=True,
-                deep_search=deep_search,
-                current_path=f"{current_path}.{k}",
-                super_class_search=super_class_search,
-            )
-    elif not is_primitive(obj):
-        for att_name in get_class_attributes(obj):
-            res = res + search_attribute_matching_type_with_path(
-                obj=get_object_attribute_rgx(obj, att_name),
-                type_rgx=type_rgx,
-                re_flags=re_flags,
-                return_self=True,
-                deep_search=deep_search,
-                current_path=f"{current_path}.{att_name}",
-                super_class_search=super_class_search,
-            )
-
-    return res
-
-
-def search_attribute_in_upper_matching_name(
-        obj: Any,
-        name_rgx: str,
-        root_obj: Optional[Any] = None,
-        re_flags=re.IGNORECASE,
-        current_path: str = "",
-) -> Optional[Any]:
-    """
-    See :func:`search_attribute_matching_type_with_path`. It only returns the value not the path
-    :param obj:
-    :param name_rgx:
-    :param root_obj:
-    :param re_flags:
-    :param current_path:
-    :return:
-    """
-    elt_list = search_attribute_matching_name(obj, name_rgx, search_in_sub_obj=False, deep_search=False)
-    if elt_list is not None and len(elt_list) > 0:
-        return elt_list
-
-    if obj != root_obj:
-        upper_path = current_path[:current_path.rindex(".")]
-        if len(upper_path) > 0:
-            return search_attribute_in_upper_matching_name(
-                obj=get_object_attribute(root_obj, upper_path),
-                name_rgx=name_rgx,
-                root_obj=root_obj,
-                re_flags=re_flags,
-                current_path=upper_path,
-            )
-
-    return None
-
-
-def search_attribute_matching_type(
-        obj: Any,
-        type_rgx: str,
-        re_flags=re.IGNORECASE,
-        return_self: bool = True,  # test directly on input object and not only in its attributes
-        deep_search: bool = True,  # Search inside a matching object
-        super_class_search: bool = True,  # Search inside in super classes of the object
-) -> List[Any]:
-    """
-    See :func:`search_attribute_matching_type_with_path`. It only returns the value not the path
-    :param obj:
-    :param type_rgx:
-    :param re_flags:
-    :param return_self:
-    :param deep_search:
-    :param super_class_search:
-    :return:
-    """
-    return [
-        val
-        for path, val in search_attribute_matching_type_with_path(
-            obj=obj,
-            type_rgx=type_rgx,
-            re_flags=re_flags,
-            return_self=return_self,
-            deep_search=deep_search,
-            super_class_search=super_class_search,
-        )
-    ]
-
-
-def search_attribute_matching_name_with_path(
-        obj: Any,
-        name_rgx: str,
-        re_flags=re.IGNORECASE,
-        current_path: str = "",
-        deep_search: bool = True,  # Search inside a matching object
-        search_in_sub_obj: bool = True,  # Search in obj attributes
-) -> List[Tuple[str, Any]]:
-    """
-    Returns a list of tuple (path, value) for each sub attribute with type matching param "name_rgx".
-    The path is a dot-version like ".Citation.Title"
-    :param obj:
-    :param name_rgx:
-    :param re_flags:
-    :param current_path:
-    :param deep_search:
-    :param search_in_sub_obj:
-    :return:
-    """
-    while name_rgx.startswith("."):
-        name_rgx = name_rgx[1:]
-    current_match = name_rgx
-    next_match = current_match
-    if '.' in current_match:
-        attrib_list = re.split(r"(?<!\\)\.+", name_rgx)
-        current_match = attrib_list[0]
-        next_match = '.'.join(attrib_list[1:])
-
-    res = []
-
-    match_value = None
-    match_path_and_obj = []
-    not_match_path_and_obj = []
-    if isinstance(obj, list):
-        cpt = 0
-        for s_o in obj:
-            match = re.match(current_match.replace("\\.", "."), str(cpt), flags=re_flags)
-            if match is not None:
-                match_value = match.group(0)
-                match_path_and_obj.append( (f"{current_path}.{cpt}", s_o) )
-            else:
-                not_match_path_and_obj.append( (f"{current_path}.{cpt}", s_o) )
-            cpt = cpt + 1
-    elif isinstance(obj, dict):
-        for k, s_o in obj.items():
-            match = re.match(current_match.replace("\\.", "."), k, flags=re_flags)
-            if match is not None:
-                match_value = match.group(0)
-                match_path_and_obj.append( (f"{current_path}.{k}", s_o) )
-            else:
-                not_match_path_and_obj.append( (f"{current_path}.{k}", s_o) )
-    elif not is_primitive(obj):
-        match_value = get_matching_class_attribute_name(obj, current_match.replace("\\.", "."))
-        if match_value is not None:
-            match_path_and_obj.append( (f"{current_path}.{match_value}", get_object_attribute_no_verif(obj, match_value)) )
-        for att_name in get_class_attributes(obj):
-            if att_name != match_value:
-                not_match_path_and_obj.append( (f"{current_path}.{att_name}", get_object_attribute_no_verif(obj, att_name)) )
-
-    for matched_path, matched in match_path_and_obj:
-        if next_match != current_match and len(next_match) > 0:  # next_match is different, match is not final
-            res = res + search_attribute_matching_name_with_path(
-                obj=matched,
-                name_rgx=next_match,
-                re_flags=re_flags,
-                current_path=matched_path,
-                deep_search=False,  # no deep with partial
-                search_in_sub_obj=False,  # no partial search in sub obj with no match
-            )
-        else:  # a complete match
-            res.append( (matched_path, matched) )
-            if deep_search:
-                res = res + search_attribute_matching_name_with_path(
-                    obj=matched,
-                    name_rgx=name_rgx,
-                    re_flags=re_flags,
-                    current_path=matched_path,
-                    deep_search=deep_search,  # no deep with partial
-                    search_in_sub_obj=True,
-                )
-    if search_in_sub_obj:
-        for not_matched_path, not_matched in not_match_path_and_obj:
-            res = res + search_attribute_matching_name_with_path(
-                obj=not_matched,
-                name_rgx=name_rgx,
-                re_flags=re_flags,
-                current_path=not_matched_path,
-                deep_search=deep_search,
-                search_in_sub_obj=True,
-            )
-
-    return res
-
-
-def search_attribute_matching_name(
-        obj: Any,
-        name_rgx: str,
-        re_flags=re.IGNORECASE,
-        deep_search: bool = True,  # Search inside a matching object
-        search_in_sub_obj: bool = True,  # Search in obj attributes
-) -> List[Any]:
-    """
-    See :func:`search_attribute_matching_name_with_path`. It only returns the value not the path
-
-    :param obj:
-    :param name_rgx:
-    :param re_flags:
-    :param deep_search:
-    :param search_in_sub_obj:
-    :return:
-    """
-    return [
-        val
-        for path, val in search_attribute_matching_name_with_path(
-            obj=obj,
-            name_rgx=name_rgx,
-            re_flags=re_flags,
-            deep_search=deep_search,
-            search_in_sub_obj=search_in_sub_obj
-        )
-    ]
-
-
-# Utility functions
-
-
-def gen_uuid() -> str:
-    """
-    Generate a new uuid.
-    :return:
-    """
-    return str(uuid_mod.uuid4())
-
-
-def get_obj_uuid(obj: Any) -> str:
-    """
-    Return the object uuid (attribute must match the following regex : "[Uu]u?id|UUID").
-    :param obj:
-    :return:
-    """
-    return get_object_attribute_rgx(obj, "[Uu]u?id|UUID")
-
-
-def get_obj_version(obj: Any) -> str:
-    """
-    Return the object version (check for "object_version" or "version_string" attribute).
-    :param obj:
-    :return:
-    """
-    try:
-        return get_object_attribute_no_verif(obj, "object_version")
-    except AttributeError as e:
-        try:
-            return get_object_attribute_no_verif(obj, "version_string")
-        except Exception:
-            print(f"Error with {type(obj)}")
-            raise e
-
-
-def get_direct_dor_list(obj: Any) -> List[Any]:
-    """
-    Search all sub attribute of type "DataObjectreference".
-    :param obj:
-    :return:
-    """
-    return search_attribute_matching_type(obj, "DataObjectreference")
-
-
-def get_data_object_type(cls: Union[type, Any], print_dev_version=True, nb_max_version_digits=2):
-    return get_class_pkg(cls) + "." + get_class_pkg_version(cls, print_dev_version, nb_max_version_digits)
-
-
-def get_qualified_type_from_class(cls: Union[type, Any], print_dev_version=True):
-    return (
-            get_data_object_type(cls, print_dev_version, 2)
-            .replace(".", "") + "." + get_object_type_for_file_path_from_class(cls)
-    )
-
-
-def get_content_type_from_class(cls: Union[type, Any], print_dev_version=True, nb_max_version_digits=2):
-    if not isinstance(cls, type):
-        cls = type(cls)
-
-    if ".opc." in cls.__module__:
-        if cls.__name__.lower() == "coreproperties":
-            return "application/vnd.openxmlformats-package.core-properties+xml"
-    else:
-        return ("application/x-" + get_class_pkg(cls)
-                + "+xml;version=" + get_class_pkg_version(cls, print_dev_version, nb_max_version_digits) + ";type="
-                + get_object_type_for_file_path_from_class(cls))
-
-    print(f"@get_content_type_from_class not supported type : {cls}")
-    return None
-
-
-def get_object_type_for_file_path_from_class(cls) -> str:
-    # obj_type = get_obj_type(cls)
-    # pkg = get_class_pkg(cls)
-    # if re.match(r"Obj[A-Z].*", obj_type) is not None and pkg == "resqml":
-    #     return "obj_" + obj_type[3:]
-    # return obj_type
-
-    try:
-        return cls.Meta.name  # to work with 3d transformed in 3D and Obj[A-Z] in obj_[A-Z]
-    except AttributeError:
-        pkg = get_class_pkg(cls)
-        return get_obj_type(cls)
-
-
-def now(time_zone=datetime.timezone(datetime.timedelta(hours=1), "UTC")) -> int:
-    """ Return an epoch value """
-    return int(datetime.datetime.timestamp(datetime.datetime.now(time_zone)))
-
-
-def epoch(time_zone=datetime.timezone(datetime.timedelta(hours=1), "UTC")) -> int:
-    return int(now(time_zone))
-
-
-def date_to_epoch(date: str) -> int:
-    """
-    Transform a energyml date into an epoch datetime
-    :return: int
-    """
-    return int(datetime.datetime.fromisoformat(date).timestamp())
-
-
-def epoch_to_date(epoch_value: int, time_zone=datetime.timezone(datetime.timedelta(hours=1), "UTC")) -> str:
-    date = datetime.datetime.fromtimestamp(epoch_value / 1e3, time_zone)
-    return date.strftime("%Y-%m-%dT%H:%M:%S%z")
-
-
-#  RANDOM
-
-
-def get_class_from_simple_name(simple_name: str, energyml_module_context=None) -> type:
-    """
-    Search for a :class:`type` depending on the simple class name :param:`simple_name`.
-    :param simple_name:
-    :param energyml_module_context:
-    :return:
-    """
-    if energyml_module_context is None:
-        energyml_module_context = []
-    try:
-        return eval(simple_name)
-    except NameError as e:
-        for mod in energyml_module_context:
-            try:
-                exec(f"from {mod} import *")
-                # required to be able to access to type in
-                # typing values like "List[ObjectAlias]"
-            except ModuleNotFoundError:
-                pass
-        return eval(simple_name)
-
-
-def _gen_str_from_attribute_name(attribute_name: Optional[str], _parent_class: Optional[type]=None) -> str:
-    """
-    Generate a str from the attribute name. The result is not the same for an attribute named "Uuid" than for an
-    attribute named "mime_type" for example.
-    :param attribute_name:
-    :param _parent_class:
-    :return:
-    """
-    attribute_name_lw = attribute_name.lower()
-    if attribute_name is not None:
-        if attribute_name_lw == "uuid" or attribute_name_lw == "uid":
-            return gen_uuid()
-        elif attribute_name_lw == "title":
-            return f"{_parent_class.__name__} title (" + str(random_value_from_class(int)) + ")"
-        elif attribute_name_lw == "schema_version" and get_class_pkg_version(_parent_class) is not None:
-            return get_class_pkg_version(_parent_class)
-        elif re.match(r"\w*version$", attribute_name_lw):
-            return str(random_value_from_class(int))
-        elif re.match(r"\w*date_.*", attribute_name_lw):
-            return epoch_to_date(epoch())
-        elif re.match(r"path_in_.*", attribute_name_lw):
-            return f"/FOLDER/{gen_uuid()}/a_patch{random.randint(0, 30)}"
-        elif "mime_type" in attribute_name_lw and ("external" in _parent_class.__name__.lower() and "part" in _parent_class.__name__.lower()):
-            return f"application/x-hdf5"
-        elif "type" in attribute_name_lw:
-            if attribute_name_lw.startswith("qualified"):
-                return get_qualified_type_from_class(get_classes_matching_name(_parent_class, "Abstract")[0])
-            if attribute_name_lw.startswith("content"):
-                return get_content_type_from_class(get_classes_matching_name(_parent_class, "Abstract")[0])
-    return "A random str " + (f"[{attribute_name}] " if attribute_name is not None else "") + "(" + str(
-        random_value_from_class(int)) + ")"
-
-
-def random_value_from_class(cls: type):
-    """
-    Generate a random value for a :class:`type`. All attributes should be filled with random values.
-    :param cls:
-    :return:
-    """
-    energyml_module_context = []
-    if not is_primitive(cls):
-        # import_related_module(cls.__module__)
-        energyml_module_context = get_related_energyml_modules_name(cls)
-    return _random_value_from_class(cls=cls, energyml_module_context=energyml_module_context, attribute_name=None)
-
-
-def _random_value_from_class(cls: Any, energyml_module_context: List[str], attribute_name: Optional[str] = None, _parent_class: Optional[type]=None):
-    """
-    Generate a random value for a :class:`type`. All attributes should be filled with random values.
-    :param cls:
-    :param energyml_module_context:
-    :param attribute_name:
-    :param _parent_class: the :class:`type`of the parent object
-    :return:
-    """
-
-    try:
-        if isinstance(cls, str) or cls == str:
-            return _gen_str_from_attribute_name(attribute_name, _parent_class)
-        elif isinstance(cls, int) or cls == int:
-            return random.randint(0, 10000)
-        elif isinstance(cls, float) or cls == float:
-            return random.randint(0, 1000000) / 100.
-        elif isinstance(cls, bool) or cls == bool:
-            return random.randint(0, 1) == 1
-        elif is_enum(cls):
-            return cls[cls._member_names_[random.randint(0, len(cls._member_names_) - 1)]]
-        elif isinstance(cls, typing.Union.__class__):
-            type_list = list(cls.__args__)
-            if type(None) in type_list:
-                type_list.remove(type(None))  # we don't want to generate none value
-            chosen_type = type_list[random.randint(0, len(type_list))]
-            return _random_value_from_class(chosen_type, energyml_module_context, attribute_name, cls)
-        elif cls.__module__ == 'typing':
-            nb_value_for_list = random.randint(2, 3)
-            type_list = list(cls.__args__)
-            if type(None) in type_list:
-                type_list.remove(type(None))  # we don't want to generate none value
-
-            if cls._name == "List":
-                lst = []
-                for i in range(nb_value_for_list):
-                    chosen_type = type_list[random.randint(0, len(type_list) - 1)]
-                    lst.append(_random_value_from_class(chosen_type, energyml_module_context, attribute_name, list))
-                return lst
-            else:
-                chosen_type = type_list[random.randint(0, len(type_list) - 1)]
-                return _random_value_from_class(chosen_type, energyml_module_context, attribute_name, _parent_class)
-        else:
-            potential_classes = list(filter(lambda _c: not is_abstract(_c), [cls] + get_sub_classes(cls)))
-            if len(potential_classes) > 0:
-                chosen_type = potential_classes[random.randint(0, len(potential_classes) - 1)]
-                args = {}
-                for k, v in get_class_fields(chosen_type).items():
-                    # print(f"get_class_fields {k} : {v}")
-                    args[k] = _random_value_from_class(
-                        cls=get_class_from_simple_name(simple_name=v.type, energyml_module_context=energyml_module_context),
-                        energyml_module_context=energyml_module_context,
-                        attribute_name=k,
-                        _parent_class=chosen_type)
-
-                if not isinstance(chosen_type, type):
-                    chosen_type = type(chosen_type)
-                return chosen_type(**args)
-
-    except Exception as e:
-        print(f"exception on attribute '{attribute_name}' for class {cls} :")
-        raise e
-
-    print(f"@_random_value_from_class Not supported object type generation {cls}")
-    return None
-
-
-
-
-
-
-
-

Functions

-
-
-def class_match_rgx(cls: Union[type, Any], rgx: str, super_class_search: bool = True, re_flags=re.IGNORECASE) -
-
-
-
- -Expand source code - -
def class_match_rgx(
-        cls: Union[type, Any],
-        rgx: str,
-        super_class_search: bool = True,
-        re_flags=re.IGNORECASE,
-):
-    if not isinstance(cls, type):
-        cls = type(cls)
-
-    if re.match(rgx, cls.__name__, re_flags):
-        return True
-
-    if not is_primitive(cls) and super_class_search:
-        for base in cls.__bases__:
-            if class_match_rgx(base, rgx, super_class_search, re_flags):
-                return True
-    return False
-
-
-
-def date_to_epoch(date: str) ‑> int -
-
-

Transform a energyml date into an epoch datetime -:return: int

-
- -Expand source code - -
def date_to_epoch(date: str) -> int:
-    """
-    Transform a energyml date into an epoch datetime
-    :return: int
-    """
-    return int(datetime.datetime.fromisoformat(date).timestamp())
-
-
-
-def epoch(time_zone=datetime.timezone(datetime.timedelta(seconds=3600), 'UTC')) ‑> int -
-
-
-
- -Expand source code - -
def epoch(time_zone=datetime.timezone(datetime.timedelta(hours=1), "UTC")) -> int:
-    return int(now(time_zone))
-
-
-
-def epoch_to_date(epoch_value: int, time_zone=datetime.timezone(datetime.timedelta(seconds=3600), 'UTC')) ‑> str -
-
-
-
- -Expand source code - -
def epoch_to_date(epoch_value: int, time_zone=datetime.timezone(datetime.timedelta(hours=1), "UTC")) -> str:
-    date = datetime.datetime.fromtimestamp(epoch_value / 1e3, time_zone)
-    return date.strftime("%Y-%m-%dT%H:%M:%S%z")
-
-
-
-def flatten_concatenation(matrix) ‑> List -
-
-

Flatten a matrix.

-

Example : -[ [a,b,c], [d,e,f], [ [x,y,z], [0] ] ] -will be translated in: [a, b, c, d, e, f, [x,y,z], [0]] -:param matrix: -:return:

-
- -Expand source code - -
def flatten_concatenation(matrix) -> List:
-    """
-    Flatten a matrix.
-
-    Example :
-        [ [a,b,c], [d,e,f], [ [x,y,z], [0] ] ]
-        will be translated in: [a, b, c, d, e, f, [x,y,z], [0]]
-    :param matrix:
-    :return:
-    """
-    flat_list = []
-    for row in matrix:
-        flat_list += row
-    return flat_list
-
-
-
-def gen_uuid() ‑> str -
-
-

Generate a new uuid. -:return:

-
- -Expand source code - -
def gen_uuid() -> str:
-    """
-    Generate a new uuid.
-    :return:
-    """
-    return str(uuid_mod.uuid4())
-
-
-
-def get_class_attributes(cls: Union[type, Any]) ‑> List[str] -
-
-

returns a list of attributes (not private ones)

-
- -Expand source code - -
def get_class_attributes(cls: Union[type, Any]) -> List[str]:
-    """
-    returns a list of attributes (not private ones)
-    """
-    # if not isinstance(cls, type):  # if cls is an instance
-    #     cls = type(cls)
-    # return list(filter(lambda a: not a.startswith("__"), dir(cls)))
-    return list(get_class_fields(cls).keys())
-
-
-
-def get_class_fields(cls: Union[type, Any]) ‑> Dict[str, dataclasses.Field] -
-
-

Return all class fields names, mapped to their :class:Field value. -:param cls: -:return:

-
- -Expand source code - -
def get_class_fields(cls: Union[type, Any]) -> Dict[str, Field]:
-    """
-    Return all class fields names, mapped to their :class:`Field` value.
-    :param cls:
-    :return:
-    """
-    if not isinstance(cls, type):  # if cls is an instance
-        cls = type(cls)
-    try:
-        return cls.__dataclass_fields__
-    except AttributeError:
-        return {}
-
-
-
-def get_class_from_content_type(content_type: str) ‑> Optional[type] -
-
-

Return a :class:type object matching with the content-type :param:content_type. -:param content_type: -:return:

-
- -Expand source code - -
def get_class_from_content_type(content_type: str) -> Optional[type]:
-    """
-    Return a :class:`type` object matching with the content-type :param:`content_type`.
-    :param content_type:
-    :return:
-    """
-    ct = parse_content_type(content_type)
-    domain = ct.group("domain")
-    if domain is None:
-        domain = "opc"
-    if domain == "opc":
-        xml_domain = ct.group("xmlDomain")
-        if "." in xml_domain:
-            xml_domain = xml_domain[xml_domain.rindex(".") + 1:]
-        if "extended" in xml_domain:
-            xml_domain = xml_domain.replace("extended", "")
-        opc_type = pascal_case(xml_domain)
-        # print("energyml.opc.opc." + opc_type)
-        return get_class_from_name("energyml.opc.opc." + opc_type)
-    else:
-        ns = ENERGYML_NAMESPACES[domain]
-        domain = ct.group("domain")
-        obj_type = ct.group("type")
-        if obj_type.lower().startswith("obj_"):  # for resqml201
-            obj_type = "Obj" + obj_type[4:]
-        version_num = str(ct.group("domainVersion")).replace(".", "_")
-        if domain.lower() == "resqml" and version_num.startswith("2_0"):
-            version_num = "2_0_1"
-        return get_class_from_name(
-            "energyml."
-            + domain
-            + ".v"
-            + version_num
-            + "."
-            + ns[ns.rindex("/") + 1:]
-            + "."
-            + obj_type
-        )
-
-
-
-def get_class_from_name(class_name_and_module: str) ‑> Optional[type] -
-
-

Return a :class:type object matching with the name :param:class_name_and_module. -:param class_name_and_module: -:return:

-
- -Expand source code - -
def get_class_from_name(class_name_and_module: str) -> Optional[type]:
-    """
-    Return a :class:`type` object matching with the name :param:`class_name_and_module`.
-    :param class_name_and_module:
-    :return:
-    """
-    module_name = class_name_and_module[: class_name_and_module.rindex(".")]
-    last_ns_part = class_name_and_module[
-                   class_name_and_module.rindex(".") + 1:
-                   ]
-    try:
-        # Required to read "CustomData" on eml objects that may contain resqml values
-        # ==> we need to import all modules related to the same version of the common
-        import_related_module(module_name)
-        return getattr(sys.modules[module_name], last_ns_part)
-    except AttributeError as e:
-        if "2d" in last_ns_part:
-            return get_class_from_name(
-                class_name_and_module.replace("2d", "2D")
-            )
-        elif "3d" in last_ns_part:
-            return get_class_from_name(
-                class_name_and_module.replace("3d", "3D")
-            )
-        elif last_ns_part[0].islower():
-            return get_class_from_name(
-                module_name + "." + last_ns_part[0].upper() + last_ns_part[1:]
-            )
-        else:
-            print(e)
-    return None
-
-
-
-def get_class_from_simple_name(simple_name: str, energyml_module_context=None) ‑> type -
-
-

Search for a :class:type depending on the simple class name :param:simple_name. -:param simple_name: -:param energyml_module_context: -:return:

-
- -Expand source code - -
def get_class_from_simple_name(simple_name: str, energyml_module_context=None) -> type:
-    """
-    Search for a :class:`type` depending on the simple class name :param:`simple_name`.
-    :param simple_name:
-    :param energyml_module_context:
-    :return:
-    """
-    if energyml_module_context is None:
-        energyml_module_context = []
-    try:
-        return eval(simple_name)
-    except NameError as e:
-        for mod in energyml_module_context:
-            try:
-                exec(f"from {mod} import *")
-                # required to be able to access to type in
-                # typing values like "List[ObjectAlias]"
-            except ModuleNotFoundError:
-                pass
-        return eval(simple_name)
-
-
-
-def get_class_methods(cls: Union[type, Any]) ‑> List[str] -
-
-

Returns the list of the methods names for a specific class. -:param cls: -:return:

-
- -Expand source code - -
def get_class_methods(cls: Union[type, Any]) -> List[str]:
-    """
-    Returns the list of the methods names for a specific class.
-    :param cls:
-    :return:
-    """
-    return [func for func in dir(cls) if callable(getattr(cls, func)) and not func.startswith("__") and not isinstance(getattr(cls, func), type)]
-
-
-
-def get_content_type_from_class(cls: Union[type, Any], print_dev_version=True, nb_max_version_digits=2) -
-
-
-
- -Expand source code - -
def get_content_type_from_class(cls: Union[type, Any], print_dev_version=True, nb_max_version_digits=2):
-    if not isinstance(cls, type):
-        cls = type(cls)
-
-    if ".opc." in cls.__module__:
-        if cls.__name__.lower() == "coreproperties":
-            return "application/vnd.openxmlformats-package.core-properties+xml"
-    else:
-        return ("application/x-" + get_class_pkg(cls)
-                + "+xml;version=" + get_class_pkg_version(cls, print_dev_version, nb_max_version_digits) + ";type="
-                + get_object_type_for_file_path_from_class(cls))
-
-    print(f"@get_content_type_from_class not supported type : {cls}")
-    return None
-
-
-
-def get_data_object_type(cls: Union[type, Any], print_dev_version=True, nb_max_version_digits=2) -
-
-
-
- -Expand source code - -
def get_data_object_type(cls: Union[type, Any], print_dev_version=True, nb_max_version_digits=2):
-    return get_class_pkg(cls) + "." + get_class_pkg_version(cls, print_dev_version, nb_max_version_digits)
-
-
-
-def get_direct_dor_list(obj: Any) ‑> List[Any] -
-
-

Search all sub attribute of type "DataObjectreference". -:param obj: -:return:

-
- -Expand source code - -
def get_direct_dor_list(obj: Any) -> List[Any]:
-    """
-    Search all sub attribute of type "DataObjectreference".
-    :param obj:
-    :return:
-    """
-    return search_attribute_matching_type(obj, "DataObjectreference")
-
-
-
-def get_matching_class_attribute_name(cls: Union[type, Any], attribute_name: str, re_flags=re.IGNORECASE) ‑> Optional[str] -
-
-

From an object and an attribute name, returns the correct attribute name of the class. -Example : "ObjectVersion" –> object_version. -This method doesn't only transform to snake case but search into the obj class attributes

-
- -Expand source code - -
def get_matching_class_attribute_name(
-        cls: Union[type, Any], attribute_name: str, re_flags=re.IGNORECASE,
-) -> Optional[str]:
-    """
-    From an object and an attribute name, returns the correct attribute name of the class.
-    Example : "ObjectVersion" --> object_version.
-    This method doesn't only transform to snake case but search into the obj class attributes
-    """
-    class_fields = get_class_fields(cls)
-
-    # a search with the exact value
-    for name, cf in class_fields.items():
-        if (
-                snake_case(name) == snake_case(attribute_name)
-                or ('name' in cf.metadata and cf.metadata['name'] == attribute_name)
-        ):
-            return name
-
-    # search regex after to avoid shadowing perfect match
-    pattern = re.compile(attribute_name, flags=re_flags)
-    for name, cf in class_fields.items():
-        # print(f"\t->{name} : {attribute_name} {pattern.match(name)} {('name' in cf.metadata and pattern.match(cf.metadata['name']))}")
-        if pattern.match(name) or ('name' in cf.metadata and pattern.match(cf.metadata['name'])):
-            return name
-
-    return None
-
-
-
-def get_obj_type(obj: Any) ‑> str -
-
-

Return the type name of an object. If obj is already a :class:type, return its name

-
- -Expand source code - -
def get_obj_type(obj: Any) -> str:
-    """ Return the type name of an object. If obj is already a :class:`type`, return its __name__"""
-    if isinstance(obj, type):
-        return str(obj.__name__)
-    return get_obj_type(type(obj))
-
-
-
-def get_obj_uuid(obj: Any) ‑> str -
-
-

Return the object uuid (attribute must match the following regex : "[Uu]u?id|UUID"). -:param obj: -:return:

-
- -Expand source code - -
def get_obj_uuid(obj: Any) -> str:
-    """
-    Return the object uuid (attribute must match the following regex : "[Uu]u?id|UUID").
-    :param obj:
-    :return:
-    """
-    return get_object_attribute_rgx(obj, "[Uu]u?id|UUID")
-
-
-
-def get_obj_version(obj: Any) ‑> str -
-
-

Return the object version (check for "object_version" or "version_string" attribute). -:param obj: -:return:

-
- -Expand source code - -
def get_obj_version(obj: Any) -> str:
-    """
-    Return the object version (check for "object_version" or "version_string" attribute).
-    :param obj:
-    :return:
-    """
-    try:
-        return get_object_attribute_no_verif(obj, "object_version")
-    except AttributeError as e:
-        try:
-            return get_object_attribute_no_verif(obj, "version_string")
-        except Exception:
-            print(f"Error with {type(obj)}")
-            raise e
-
-
-
-def get_object_attribute(obj: Any, attr_dot_path: str, force_snake_case=True) ‑> Any -
-
-

returns the value of an attribute given by a dot representation of its path in the object -example "Citation.Title"

-
- -Expand source code - -
def get_object_attribute(
-        obj: Any, attr_dot_path: str, force_snake_case=True
-) -> Any:
-    """
-    returns the value of an attribute given by a dot representation of its path in the object
-    example "Citation.Title"
-    """
-    while attr_dot_path.startswith("."):  # avoid '.Citation.Title' to take an empty attribute name before the first '.'
-        attr_dot_path = attr_dot_path[1:]
-
-    current_attrib_name = attr_dot_path
-
-    if "." in attr_dot_path:
-        current_attrib_name = attr_dot_path.split(".")[0]
-
-    if force_snake_case:
-        current_attrib_name = snake_case(current_attrib_name)
-
-    value = None
-    if isinstance(obj, list):
-        value = obj[int(current_attrib_name)]
-    elif isinstance(obj, dict):
-        value = obj[current_attrib_name]
-    else:
-        value = getattr(obj, current_attrib_name)
-
-    if "." in attr_dot_path:
-        return get_object_attribute(
-            value, attr_dot_path[len(current_attrib_name) + 1:]
-        )
-    else:
-        return value
-
-
-
-def get_object_attribute_advanced(obj: Any, attr_dot_path: str) ‑> Any -
-
-

see @get_matching_class_attribute_name and @get_object_attribute

-
- -Expand source code - -
def get_object_attribute_advanced(obj: Any, attr_dot_path: str) -> Any:
-    """
-    see @get_matching_class_attribute_name and @get_object_attribute
-    """
-    current_attrib_name = attr_dot_path
-
-    if "." in attr_dot_path:
-        current_attrib_name = attr_dot_path.split(".")[0]
-
-    current_attrib_name = get_matching_class_attribute_name(
-        obj, current_attrib_name
-    )
-
-    value = None
-    if isinstance(obj, list):
-        value = obj[int(current_attrib_name)]
-    elif isinstance(obj, dict):
-        value = obj[current_attrib_name]
-    else:
-        value = getattr(obj, current_attrib_name)
-
-    if "." in attr_dot_path:
-        return get_object_attribute_advanced(
-            value, attr_dot_path[len(current_attrib_name) + 1:]
-        )
-    else:
-        return value
-
-
-
-def get_object_attribute_no_verif(obj: Any, attr_name: str) ‑> Any -
-
-

Return the value of the attribute named after param :param:attr_name without verification (may raise an exception -if it doesn't exists).

-

Note: attr_name="0" will work if :param:obj is of type :class:List -:param obj: -:param attr_name: -:return:

-
- -Expand source code - -
def get_object_attribute_no_verif(obj: Any, attr_name: str) -> Any:
-    """
-    Return the value of the attribute named after param :param:`attr_name` without verification (may raise an exception
-    if it doesn't exists).
-
-    Note: attr_name="0" will work if :param:`obj` is of type :class:`List`
-    :param obj:
-    :param attr_name:
-    :return:
-    """
-    if isinstance(obj, list):
-        return obj[int(attr_name)]
-    elif isinstance(obj, dict):
-        return obj[attr_name]
-    else:
-        return getattr(obj, attr_name)
-
-
-
-def get_object_attribute_rgx(obj: Any, attr_dot_path_rgx: str) ‑> Any -
-
-

see @get_object_attribute. Search the attribute name using regex for values between dots. -Example : [Cc]itation.[Tt]it.*

-
- -Expand source code - -
def get_object_attribute_rgx(obj: Any, attr_dot_path_rgx: str) -> Any:
-    """
-    see @get_object_attribute. Search the attribute name using regex for values between dots.
-    Example : [Cc]itation.[Tt]it\\.*
-    """
-    current_attrib_name = attr_dot_path_rgx
-
-    attrib_list = re.split(r"(?<!\\)\.+", attr_dot_path_rgx)
-
-    if len(attrib_list) > 0:
-        current_attrib_name = attrib_list[0]
-
-    # unescape Dot
-    current_attrib_name = current_attrib_name.replace("\\.", ".")
-
-    real_attrib_name = get_matching_class_attribute_name(
-        obj, current_attrib_name
-    )
-    if real_attrib_name is not None:
-        value = get_object_attribute_no_verif(obj, real_attrib_name)
-
-        if len(attrib_list) > 1:
-            return get_object_attribute_rgx(
-                value, attr_dot_path_rgx[len(current_attrib_name) + 1:]
-            )
-        else:
-            return value
-    return None
-
-
-
-def get_object_type_for_file_path_from_class(cls) ‑> str -
-
-
-
- -Expand source code - -
def get_object_type_for_file_path_from_class(cls) -> str:
-    # obj_type = get_obj_type(cls)
-    # pkg = get_class_pkg(cls)
-    # if re.match(r"Obj[A-Z].*", obj_type) is not None and pkg == "resqml":
-    #     return "obj_" + obj_type[3:]
-    # return obj_type
-
-    try:
-        return cls.Meta.name  # to work with 3d transformed in 3D and Obj[A-Z] in obj_[A-Z]
-    except AttributeError:
-        pkg = get_class_pkg(cls)
-        return get_obj_type(cls)
-
-
-
-def get_qualified_type_from_class(cls: Union[type, Any], print_dev_version=True) -
-
-
-
- -Expand source code - -
def get_qualified_type_from_class(cls: Union[type, Any], print_dev_version=True):
-    return (
-            get_data_object_type(cls, print_dev_version, 2)
-            .replace(".", "") + "." + get_object_type_for_file_path_from_class(cls)
-    )
-
-
- -
-

Import related modules for a specific energyml module. (See. :const:RELATED_MODULES) -:param energyml_module_name: -:return:

-
- -Expand source code - -
def import_related_module(energyml_module_name: str) -> None:
-    """
-    Import related modules for a specific energyml module. (See. :const:`RELATED_MODULES`)
-    :param energyml_module_name:
-    :return:
-    """
-    for related in RELATED_MODULES:
-        if energyml_module_name in related:
-            for m in related:
-                try:
-                    import_module(m)
-                except Exception as e:
-                    pass
-                    # print(e)
-
-
-
-def is_abstract(cls: Union[type, Any]) ‑> bool -
-
-

Returns True if :param:cls is an abstract class -:param cls: -:return: bool

-
- -Expand source code - -
def is_abstract(cls: Union[type, Any]) -> bool:
-    """
-    Returns True if :param:`cls` is an abstract class
-    :param cls:
-    :return: bool
-    """
-    if isinstance(cls, type):
-        return not is_primitive(cls) and (cls.__name__.startswith("Abstract") or (hasattr(cls, "__dataclass_fields__") and len(cls.__dataclass_fields__)) == 0) and len(get_class_methods(cls)) == 0
-    return is_abstract(type(cls))
-
-
-
-def is_enum(cls: Union[type, Any]) -
-
-

Returns True if :param:cls is an Enum -:param cls: -:return:

-
- -Expand source code - -
def is_enum(cls: Union[type, Any]):
-    """
-    Returns True if :param:`cls` is an Enum
-    :param cls:
-    :return:
-    """
-    if isinstance(cls, type):
-        return Enum in cls.__bases__
-    return is_enum(type(cls))
-
-
-
-def is_primitive(cls: Union[type, Any]) ‑> bool -
-
-

Returns True if :param:cls is a primitiv type or extends Enum -:param cls: -:return: bool

-
- -Expand source code - -
def is_primitive(cls: Union[type, Any]) -> bool:
-    """
-    Returns True if :param:`cls` is a primitiv type or extends Enum
-    :param cls:
-    :return: bool
-    """
-    if isinstance(cls, type):
-        return cls in primitives or Enum in cls.__bases__
-    return is_primitive(type(cls))
-
-
-
-def now(time_zone=datetime.timezone(datetime.timedelta(seconds=3600), 'UTC')) ‑> int -
-
-

Return an epoch value

-
- -Expand source code - -
def now(time_zone=datetime.timezone(datetime.timedelta(hours=1), "UTC")) -> int:
-    """ Return an epoch value """
-    return int(datetime.datetime.timestamp(datetime.datetime.now(time_zone)))
-
-
-
-def pascal_case(s: str) ‑> str -
-
-

Transform a str into pascal case.

-
- -Expand source code - -
def pascal_case(s: str) -> str:
-    """ Transform a str into pascal case. """
-    return snake_case(s).replace("_", " ").title().replace(" ", "")
-
-
-
-def random_value_from_class(cls: type) -
-
-

Generate a random value for a :class:type. All attributes should be filled with random values. -:param cls: -:return:

-
- -Expand source code - -
def random_value_from_class(cls: type):
-    """
-    Generate a random value for a :class:`type`. All attributes should be filled with random values.
-    :param cls:
-    :return:
-    """
-    energyml_module_context = []
-    if not is_primitive(cls):
-        # import_related_module(cls.__module__)
-        energyml_module_context = get_related_energyml_modules_name(cls)
-    return _random_value_from_class(cls=cls, energyml_module_context=energyml_module_context, attribute_name=None)
-
-
-
-def search_attribute_in_upper_matching_name(obj: Any, name_rgx: str, root_obj: Optional[Any] = None, re_flags=re.IGNORECASE, current_path: str = '') ‑> Optional[Any] -
-
-

See :func:search_attribute_matching_type_with_path(). It only returns the value not the path -:param obj: -:param name_rgx: -:param root_obj: -:param re_flags: -:param current_path: -:return:

-
- -Expand source code - -
def search_attribute_in_upper_matching_name(
-        obj: Any,
-        name_rgx: str,
-        root_obj: Optional[Any] = None,
-        re_flags=re.IGNORECASE,
-        current_path: str = "",
-) -> Optional[Any]:
-    """
-    See :func:`search_attribute_matching_type_with_path`. It only returns the value not the path
-    :param obj:
-    :param name_rgx:
-    :param root_obj:
-    :param re_flags:
-    :param current_path:
-    :return:
-    """
-    elt_list = search_attribute_matching_name(obj, name_rgx, search_in_sub_obj=False, deep_search=False)
-    if elt_list is not None and len(elt_list) > 0:
-        return elt_list
-
-    if obj != root_obj:
-        upper_path = current_path[:current_path.rindex(".")]
-        if len(upper_path) > 0:
-            return search_attribute_in_upper_matching_name(
-                obj=get_object_attribute(root_obj, upper_path),
-                name_rgx=name_rgx,
-                root_obj=root_obj,
-                re_flags=re_flags,
-                current_path=upper_path,
-            )
-
-    return None
-
-
-
-def search_attribute_matching_name(obj: Any, name_rgx: str, re_flags=re.IGNORECASE, deep_search: bool = True, search_in_sub_obj: bool = True) ‑> List[Any] -
-
-

See :func:search_attribute_matching_name_with_path(). It only returns the value not the path

-

:param obj: -:param name_rgx: -:param re_flags: -:param deep_search: -:param search_in_sub_obj: -:return:

-
- -Expand source code - -
def search_attribute_matching_name(
-        obj: Any,
-        name_rgx: str,
-        re_flags=re.IGNORECASE,
-        deep_search: bool = True,  # Search inside a matching object
-        search_in_sub_obj: bool = True,  # Search in obj attributes
-) -> List[Any]:
-    """
-    See :func:`search_attribute_matching_name_with_path`. It only returns the value not the path
-
-    :param obj:
-    :param name_rgx:
-    :param re_flags:
-    :param deep_search:
-    :param search_in_sub_obj:
-    :return:
-    """
-    return [
-        val
-        for path, val in search_attribute_matching_name_with_path(
-            obj=obj,
-            name_rgx=name_rgx,
-            re_flags=re_flags,
-            deep_search=deep_search,
-            search_in_sub_obj=search_in_sub_obj
-        )
-    ]
-
-
-
-def search_attribute_matching_name_with_path(obj: Any, name_rgx: str, re_flags=re.IGNORECASE, current_path: str = '', deep_search: bool = True, search_in_sub_obj: bool = True) ‑> List[Tuple[str, Any]] -
-
-

Returns a list of tuple (path, value) for each sub attribute with type matching param "name_rgx". -The path is a dot-version like ".Citation.Title" -:param obj: -:param name_rgx: -:param re_flags: -:param current_path: -:param deep_search: -:param search_in_sub_obj: -:return:

-
- -Expand source code - -
def search_attribute_matching_name_with_path(
-        obj: Any,
-        name_rgx: str,
-        re_flags=re.IGNORECASE,
-        current_path: str = "",
-        deep_search: bool = True,  # Search inside a matching object
-        search_in_sub_obj: bool = True,  # Search in obj attributes
-) -> List[Tuple[str, Any]]:
-    """
-    Returns a list of tuple (path, value) for each sub attribute with type matching param "name_rgx".
-    The path is a dot-version like ".Citation.Title"
-    :param obj:
-    :param name_rgx:
-    :param re_flags:
-    :param current_path:
-    :param deep_search:
-    :param search_in_sub_obj:
-    :return:
-    """
-    while name_rgx.startswith("."):
-        name_rgx = name_rgx[1:]
-    current_match = name_rgx
-    next_match = current_match
-    if '.' in current_match:
-        attrib_list = re.split(r"(?<!\\)\.+", name_rgx)
-        current_match = attrib_list[0]
-        next_match = '.'.join(attrib_list[1:])
-
-    res = []
-
-    match_value = None
-    match_path_and_obj = []
-    not_match_path_and_obj = []
-    if isinstance(obj, list):
-        cpt = 0
-        for s_o in obj:
-            match = re.match(current_match.replace("\\.", "."), str(cpt), flags=re_flags)
-            if match is not None:
-                match_value = match.group(0)
-                match_path_and_obj.append( (f"{current_path}.{cpt}", s_o) )
-            else:
-                not_match_path_and_obj.append( (f"{current_path}.{cpt}", s_o) )
-            cpt = cpt + 1
-    elif isinstance(obj, dict):
-        for k, s_o in obj.items():
-            match = re.match(current_match.replace("\\.", "."), k, flags=re_flags)
-            if match is not None:
-                match_value = match.group(0)
-                match_path_and_obj.append( (f"{current_path}.{k}", s_o) )
-            else:
-                not_match_path_and_obj.append( (f"{current_path}.{k}", s_o) )
-    elif not is_primitive(obj):
-        match_value = get_matching_class_attribute_name(obj, current_match.replace("\\.", "."))
-        if match_value is not None:
-            match_path_and_obj.append( (f"{current_path}.{match_value}", get_object_attribute_no_verif(obj, match_value)) )
-        for att_name in get_class_attributes(obj):
-            if att_name != match_value:
-                not_match_path_and_obj.append( (f"{current_path}.{att_name}", get_object_attribute_no_verif(obj, att_name)) )
-
-    for matched_path, matched in match_path_and_obj:
-        if next_match != current_match and len(next_match) > 0:  # next_match is different, match is not final
-            res = res + search_attribute_matching_name_with_path(
-                obj=matched,
-                name_rgx=next_match,
-                re_flags=re_flags,
-                current_path=matched_path,
-                deep_search=False,  # no deep with partial
-                search_in_sub_obj=False,  # no partial search in sub obj with no match
-            )
-        else:  # a complete match
-            res.append( (matched_path, matched) )
-            if deep_search:
-                res = res + search_attribute_matching_name_with_path(
-                    obj=matched,
-                    name_rgx=name_rgx,
-                    re_flags=re_flags,
-                    current_path=matched_path,
-                    deep_search=deep_search,  # no deep with partial
-                    search_in_sub_obj=True,
-                )
-    if search_in_sub_obj:
-        for not_matched_path, not_matched in not_match_path_and_obj:
-            res = res + search_attribute_matching_name_with_path(
-                obj=not_matched,
-                name_rgx=name_rgx,
-                re_flags=re_flags,
-                current_path=not_matched_path,
-                deep_search=deep_search,
-                search_in_sub_obj=True,
-            )
-
-    return res
-
-
-
-def search_attribute_matching_type(obj: Any, type_rgx: str, re_flags=re.IGNORECASE, return_self: bool = True, deep_search: bool = True, super_class_search: bool = True) ‑> List[Any] -
-
-

See :func:search_attribute_matching_type_with_path(). It only returns the value not the path -:param obj: -:param type_rgx: -:param re_flags: -:param return_self: -:param deep_search: -:param super_class_search: -:return:

-
- -Expand source code - -
def search_attribute_matching_type(
-        obj: Any,
-        type_rgx: str,
-        re_flags=re.IGNORECASE,
-        return_self: bool = True,  # test directly on input object and not only in its attributes
-        deep_search: bool = True,  # Search inside a matching object
-        super_class_search: bool = True,  # Search inside in super classes of the object
-) -> List[Any]:
-    """
-    See :func:`search_attribute_matching_type_with_path`. It only returns the value not the path
-    :param obj:
-    :param type_rgx:
-    :param re_flags:
-    :param return_self:
-    :param deep_search:
-    :param super_class_search:
-    :return:
-    """
-    return [
-        val
-        for path, val in search_attribute_matching_type_with_path(
-            obj=obj,
-            type_rgx=type_rgx,
-            re_flags=re_flags,
-            return_self=return_self,
-            deep_search=deep_search,
-            super_class_search=super_class_search,
-        )
-    ]
-
-
-
-def search_attribute_matching_type_with_path(obj: Any, type_rgx: str, re_flags=re.IGNORECASE, return_self: bool = True, deep_search: bool = True, super_class_search: bool = True, current_path: str = '') ‑> List[Tuple[str, Any]] -
-
-

Returns a list of tuple (path, value) for each sub attribute with type matching param "type_rgx". -The path is a dot-version like ".Citation.Title" -:param obj: -:param type_rgx: -:param re_flags: -:param return_self: -:param deep_search: -:param super_class_search: -:param current_path: -:return:

-
- -Expand source code - -
def search_attribute_matching_type_with_path(
-        obj: Any,
-        type_rgx: str,
-        re_flags=re.IGNORECASE,
-        return_self: bool = True,  # test directly on input object and not only in its attributes
-        deep_search: bool = True,  # Search inside a matching object
-        super_class_search: bool = True,  # Search inside in super classes of the object
-        current_path: str = "",
-) -> List[Tuple[str, Any]]:
-    """
-    Returns a list of tuple (path, value) for each sub attribute with type matching param "type_rgx".
-    The path is a dot-version like ".Citation.Title"
-    :param obj:
-    :param type_rgx:
-    :param re_flags:
-    :param return_self:
-    :param deep_search:
-    :param super_class_search:
-    :param current_path:
-    :return:
-    """
-    res = []
-    if obj is not None:
-        if return_self and class_match_rgx(
-                obj, type_rgx, super_class_search, re_flags
-        ):
-            res.append((current_path, obj))
-            if not deep_search:
-                return res
-
-    if isinstance(obj, list):
-        cpt = 0
-        for s_o in obj:
-            res = res + search_attribute_matching_type_with_path(
-                obj=s_o,
-                type_rgx=type_rgx,
-                re_flags=re_flags,
-                return_self=True,
-                deep_search=deep_search,
-                current_path=f"{current_path}.{cpt}",
-                super_class_search=super_class_search,
-            )
-            cpt = cpt + 1
-    elif isinstance(obj, dict):
-        for k, s_o in obj.items():
-            res = res + search_attribute_matching_type_with_path(
-                obj=s_o,
-                type_rgx=type_rgx,
-                re_flags=re_flags,
-                return_self=True,
-                deep_search=deep_search,
-                current_path=f"{current_path}.{k}",
-                super_class_search=super_class_search,
-            )
-    elif not is_primitive(obj):
-        for att_name in get_class_attributes(obj):
-            res = res + search_attribute_matching_type_with_path(
-                obj=get_object_attribute_rgx(obj, att_name),
-                type_rgx=type_rgx,
-                re_flags=re_flags,
-                return_self=True,
-                deep_search=deep_search,
-                current_path=f"{current_path}.{att_name}",
-                super_class_search=super_class_search,
-            )
-
-    return res
-
-
-
-def snake_case(s: str) ‑> str -
-
-

Transform a str into snake case.

-
- -Expand source code - -
def snake_case(s: str) -> str:
-    """ Transform a str into snake case. """
-    s = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', s)
-    s = re.sub('__([A-Z])', r'_\1', s)
-    s = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s)
-    return s.lower()
-
-
-
-
-
-
-
- -
- - - \ No newline at end of file diff --git a/energyml-utils/docs/src/energyml/utils/manager.html b/energyml-utils/docs/src/energyml/utils/manager.html deleted file mode 100644 index 6dbad1a..0000000 --- a/energyml-utils/docs/src/energyml/utils/manager.html +++ /dev/null @@ -1,615 +0,0 @@ - - - - - - -src.energyml.utils.manager API documentation - - - - - - - - - - - -
-
-
-

Module src.energyml.utils.manager

-
-
-
- -Expand source code - -
# Copyright (c) 2023-2024 Geosiris.
-# SPDX-License-Identifier: Apache-2.0
-import importlib
-import inspect
-import pkgutil
-import re
-from typing import List, Union, Any
-
-RGX_ENERGYML_MODULE_NAME = r"energyml\.(?P<pkg>.*)\.v(?P<version>(?P<versionNumber>\d+(_\d+)*)(_dev(?P<versionDev>.*))?)\..*"
-RGX_PROJECT_VERSION = r"(?P<n0>[\d]+)(.(?P<n1>[\d]+)(.(?P<n2>[\d]+))?)?"
-
-ENERGYML_MODULES_NAMES = ["eml", "prodml", "witsml", "resqml"]
-
-RELATED_MODULES = [
-    ["energyml.eml.v2_0.commonv2", "energyml.resqml.v2_0_1.resqmlv2"],
-    [
-        "energyml.eml.v2_1.commonv2",
-        "energyml.prodml.v2_0.prodmlv2",
-        "energyml.witsml.v2_0.witsmlv2",
-    ],
-    ["energyml.eml.v2_2.commonv2", "energyml.resqml.v2_2_dev3.resqmlv2"],
-    [
-        "energyml.eml.v2_3.commonv2",
-        "energyml.resqml.v2_2.resqmlv2",
-        "energyml.prodml.v2_2.prodmlv2",
-        "energyml.witsml.v2_1.witsmlv2",
-    ],
-]
-
-
-def get_related_energyml_modules_name(cls: Union[type, Any]) -> List[str]:
-    """
-    Return the list of all energyml modules related to another one.
-    For example resqml 2.0.1 is related to common 2.0
-    :param cls:
-    :return:
-    """
-    if isinstance(cls, type):
-        for related in RELATED_MODULES:
-            if cls.__module__ in related:
-                return related
-    else:
-        return get_related_energyml_modules_name(type(cls))
-    return []
-
-
-def dict_energyml_modules() -> List:
-    """
-    List all accessible energyml python modules
-    :return:
-    """
-    modules = {}
-
-    energyml_module = importlib.import_module("energyml")
-    # print("> energyml")
-
-    for mod in pkgutil.iter_modules(energyml_module.__path__):
-        # print(f"{mod.name}")
-        if mod.name in ENERGYML_MODULES_NAMES:
-            energyml_sub_module = importlib.import_module(
-                f"energyml.{mod.name}"
-            )
-            if mod.name not in modules:
-                modules[mod.name] = []
-            for sub_mod in pkgutil.iter_modules(energyml_sub_module.__path__):
-                modules[mod.name].append(sub_mod.name)
-                # modules[mod.name].append(re.sub(r"^\D*(?P<number>\d+(.\d+)*$)",
-                # r"\g<number>", sub_mod.name).replace("_", "."))
-    return modules
-
-
-def list_energyml_modules():
-    try:
-        energyml_module = importlib.import_module("energyml")
-        modules = []
-        for obj in pkgutil.iter_modules(energyml_module.__path__):
-            # print(f"{obj.name}")
-            if obj.name in ENERGYML_MODULES_NAMES:
-                modules.append(obj.name)
-        return modules
-    except ModuleNotFoundError:
-        return []
-
-
-def list_classes(module_path: str) -> List:
-    """
-    List all accessible classes from a specific module
-    :param module_path:
-    :return:
-    """
-    try:
-        module = importlib.import_module(module_path)
-        class_list = []
-        for _, obj in inspect.getmembers(module):
-            if inspect.isclass(obj):
-                class_list.append(obj)
-        return class_list
-    except ModuleNotFoundError:
-        print(f"Err : module {module_path} not found")
-        return []
-
-
-def get_sub_classes(cls: type) -> List[type]:
-    """
-    Return all classes that extends the class :param:`cls`.
-    :param cls:
-    :return:
-    """
-    sub_classes = []
-    for related in get_related_energyml_modules_name(cls):
-        try:
-            module = importlib.import_module(related)
-            for _, obj in inspect.getmembers(module):
-                if inspect.isclass(obj) and cls in obj.__bases__:
-                    sub_classes.append(obj)
-                    sub_classes = sub_classes + get_sub_classes(obj)
-        except ModuleNotFoundError:
-            pass
-    return list(dict.fromkeys(sub_classes))
-
-
-def get_classes_matching_name(cls: type, name_rgx: str, re_flags=re.IGNORECASE,) -> List[type]:
-    """
-    Search a class matching the regex @re_flags. The search is the energyml packages related to the objet type @cls.
-    :param cls:
-    :param name_rgx:
-    :param re_flags:
-    :return:
-    """
-    match_classes = []
-    for related in get_related_energyml_modules_name(cls):
-        try:
-            module = importlib.import_module(related)
-            for _, obj in inspect.getmembers(module):
-                if inspect.isclass(obj) and re.match(name_rgx, obj.__name__, re_flags):
-                    match_classes.append(obj)
-        except ModuleNotFoundError:
-            pass
-    return list(dict.fromkeys(match_classes))
-
-
-def get_all_energyml_classes() -> dict:
-    result = {}
-    for mod_name, versions in dict_energyml_modules().items():
-        for version in versions:
-            result = result | get_all_classes(mod_name, version)
-    return result
-
-
-def get_all_classes(module_name: str, version: str) -> dict:
-    result = {}
-    pkg_path = f"energyml.{module_name}.{version}"
-    package = importlib.import_module(pkg_path)
-    for _, modname, _ in pkgutil.walk_packages(
-        path=getattr(package, "__path__"),
-        prefix=package.__name__ + ".",
-        onerror=lambda x: None,
-    ):
-        result[pkg_path] = []
-        for classFound in list_classes(modname):
-            try:
-                result[pkg_path].append(classFound)
-            except Exception:
-                pass
-
-    return result
-
-
-def get_class_pkg(cls):
-    try:
-        p = re.compile(RGX_ENERGYML_MODULE_NAME)
-        m = p.search(cls.__module__)
-        return m.group("pkg")
-    except AttributeError as e:
-        print(f"Exception to get class package for '{cls}'")
-        raise e
-
-
-def reshape_version(version: str, nb_digit: int) -> str:
-    """
-    Reshape a project version to have only specific number of digits. If 0 < nbDigit < 4 then the reshape is done,
-    else, the original version is returned.
-    Example : reshapeVersion("v2.0.1", 2) ==> "2.0" and reshapeVersion("version2.0.1.3.2.5", 4) ==> "version2.0.1.3.2.5"
-    """
-    p = re.compile(RGX_PROJECT_VERSION)
-    m = p.search(version)
-    if m is not None:
-        n0 = m.group("n0")
-        n1 = m.group("n1")
-        n2 = m.group("n2")
-        if nb_digit == 1:
-            return n0
-        elif nb_digit == 2:
-            return n0 + ("." + n1 if n1 is not None else "")
-        elif nb_digit == 3:
-            return n0 + (
-                "." + n1 + ("." + n2 if n2 is not None else "")
-                if n1 is not None
-                else ""
-            )
-
-    return version
-
-
-def get_class_pkg_version(
-    cls, print_dev_version: bool = True, nb_max_version_digits: int = 2
-):
-    p = re.compile(RGX_ENERGYML_MODULE_NAME)
-    m = p.search(
-        cls.__module__ if isinstance(cls, type) else type(cls).__module__
-    )
-    return reshape_version(m.group("versionNumber"), nb_max_version_digits) + (
-        m.group("versionDev")
-        if m.group("versionDev") is not None and print_dev_version
-        else ""
-    )
-
-
-# ProtocolDict = DefaultDict[str, MessageDict]
-# def get_all__classes() -> ProtocolDict:
-#     protocolDict: ProtocolDict = defaultdict(
-#         lambda: defaultdict(type(ETPModel))
-#     )
-#     package = energyml
-#     for _, modname, _ in pkgutil.walk_packages(
-#         path=getattr(package, "__path__"),
-#         prefix=package.__name__ + ".",
-#         onerror=lambda x: None,
-#     ):
-#         for classFound in list_classes(modname):
-#             try:
-#                 schem = json.loads(avro_schema(classFound))
-#                 protocolId = schem["protocol"]
-#                 messageType = schem["messageType"]
-#                 protocolDict[protocolId][messageType] = classFound
-#             except Exception:
-#                 pass
-#     return protocolDict
-
-
-
-
-
-
-
-

Functions

-
-
-def dict_energyml_modules() ‑> List -
-
-

List all accessible energyml python modules -:return:

-
- -Expand source code - -
def dict_energyml_modules() -> List:
-    """
-    List all accessible energyml python modules
-    :return:
-    """
-    modules = {}
-
-    energyml_module = importlib.import_module("energyml")
-    # print("> energyml")
-
-    for mod in pkgutil.iter_modules(energyml_module.__path__):
-        # print(f"{mod.name}")
-        if mod.name in ENERGYML_MODULES_NAMES:
-            energyml_sub_module = importlib.import_module(
-                f"energyml.{mod.name}"
-            )
-            if mod.name not in modules:
-                modules[mod.name] = []
-            for sub_mod in pkgutil.iter_modules(energyml_sub_module.__path__):
-                modules[mod.name].append(sub_mod.name)
-                # modules[mod.name].append(re.sub(r"^\D*(?P<number>\d+(.\d+)*$)",
-                # r"\g<number>", sub_mod.name).replace("_", "."))
-    return modules
-
-
-
-def get_all_classes(module_name: str, version: str) ‑> dict -
-
-
-
- -Expand source code - -
def get_all_classes(module_name: str, version: str) -> dict:
-    result = {}
-    pkg_path = f"energyml.{module_name}.{version}"
-    package = importlib.import_module(pkg_path)
-    for _, modname, _ in pkgutil.walk_packages(
-        path=getattr(package, "__path__"),
-        prefix=package.__name__ + ".",
-        onerror=lambda x: None,
-    ):
-        result[pkg_path] = []
-        for classFound in list_classes(modname):
-            try:
-                result[pkg_path].append(classFound)
-            except Exception:
-                pass
-
-    return result
-
-
-
-def get_all_energyml_classes() ‑> dict -
-
-
-
- -Expand source code - -
def get_all_energyml_classes() -> dict:
-    result = {}
-    for mod_name, versions in dict_energyml_modules().items():
-        for version in versions:
-            result = result | get_all_classes(mod_name, version)
-    return result
-
-
-
-def get_class_pkg(cls) -
-
-
-
- -Expand source code - -
def get_class_pkg(cls):
-    try:
-        p = re.compile(RGX_ENERGYML_MODULE_NAME)
-        m = p.search(cls.__module__)
-        return m.group("pkg")
-    except AttributeError as e:
-        print(f"Exception to get class package for '{cls}'")
-        raise e
-
-
-
-def get_class_pkg_version(cls, print_dev_version: bool = True, nb_max_version_digits: int = 2) -
-
-
-
- -Expand source code - -
def get_class_pkg_version(
-    cls, print_dev_version: bool = True, nb_max_version_digits: int = 2
-):
-    p = re.compile(RGX_ENERGYML_MODULE_NAME)
-    m = p.search(
-        cls.__module__ if isinstance(cls, type) else type(cls).__module__
-    )
-    return reshape_version(m.group("versionNumber"), nb_max_version_digits) + (
-        m.group("versionDev")
-        if m.group("versionDev") is not None and print_dev_version
-        else ""
-    )
-
-
-
-def get_classes_matching_name(cls: type, name_rgx: str, re_flags=re.IGNORECASE) ‑> List[type] -
-
-

Search a class matching the regex @re_flags. The search is the energyml packages related to the objet type @cls. -:param cls: -:param name_rgx: -:param re_flags: -:return:

-
- -Expand source code - -
def get_classes_matching_name(cls: type, name_rgx: str, re_flags=re.IGNORECASE,) -> List[type]:
-    """
-    Search a class matching the regex @re_flags. The search is the energyml packages related to the objet type @cls.
-    :param cls:
-    :param name_rgx:
-    :param re_flags:
-    :return:
-    """
-    match_classes = []
-    for related in get_related_energyml_modules_name(cls):
-        try:
-            module = importlib.import_module(related)
-            for _, obj in inspect.getmembers(module):
-                if inspect.isclass(obj) and re.match(name_rgx, obj.__name__, re_flags):
-                    match_classes.append(obj)
-        except ModuleNotFoundError:
-            pass
-    return list(dict.fromkeys(match_classes))
-
-
- -
-

Return the list of all energyml modules related to another one. -For example resqml 2.0.1 is related to common 2.0 -:param cls: -:return:

-
- -Expand source code - -
def get_related_energyml_modules_name(cls: Union[type, Any]) -> List[str]:
-    """
-    Return the list of all energyml modules related to another one.
-    For example resqml 2.0.1 is related to common 2.0
-    :param cls:
-    :return:
-    """
-    if isinstance(cls, type):
-        for related in RELATED_MODULES:
-            if cls.__module__ in related:
-                return related
-    else:
-        return get_related_energyml_modules_name(type(cls))
-    return []
-
-
-
-def get_sub_classes(cls: type) ‑> List[type] -
-
-

Return all classes that extends the class :param:cls. -:param cls: -:return:

-
- -Expand source code - -
def get_sub_classes(cls: type) -> List[type]:
-    """
-    Return all classes that extends the class :param:`cls`.
-    :param cls:
-    :return:
-    """
-    sub_classes = []
-    for related in get_related_energyml_modules_name(cls):
-        try:
-            module = importlib.import_module(related)
-            for _, obj in inspect.getmembers(module):
-                if inspect.isclass(obj) and cls in obj.__bases__:
-                    sub_classes.append(obj)
-                    sub_classes = sub_classes + get_sub_classes(obj)
-        except ModuleNotFoundError:
-            pass
-    return list(dict.fromkeys(sub_classes))
-
-
-
-def list_classes(module_path: str) ‑> List -
-
-

List all accessible classes from a specific module -:param module_path: -:return:

-
- -Expand source code - -
def list_classes(module_path: str) -> List:
-    """
-    List all accessible classes from a specific module
-    :param module_path:
-    :return:
-    """
-    try:
-        module = importlib.import_module(module_path)
-        class_list = []
-        for _, obj in inspect.getmembers(module):
-            if inspect.isclass(obj):
-                class_list.append(obj)
-        return class_list
-    except ModuleNotFoundError:
-        print(f"Err : module {module_path} not found")
-        return []
-
-
-
-def list_energyml_modules() -
-
-
-
- -Expand source code - -
def list_energyml_modules():
-    try:
-        energyml_module = importlib.import_module("energyml")
-        modules = []
-        for obj in pkgutil.iter_modules(energyml_module.__path__):
-            # print(f"{obj.name}")
-            if obj.name in ENERGYML_MODULES_NAMES:
-                modules.append(obj.name)
-        return modules
-    except ModuleNotFoundError:
-        return []
-
-
-
-def reshape_version(version: str, nb_digit: int) ‑> str -
-
-

Reshape a project version to have only specific number of digits. If 0 < nbDigit < 4 then the reshape is done, -else, the original version is returned. -Example : reshapeVersion("v2.0.1", 2) ==> "2.0" and reshapeVersion("version2.0.1.3.2.5", 4) ==> "version2.0.1.3.2.5"

-
- -Expand source code - -
def reshape_version(version: str, nb_digit: int) -> str:
-    """
-    Reshape a project version to have only specific number of digits. If 0 < nbDigit < 4 then the reshape is done,
-    else, the original version is returned.
-    Example : reshapeVersion("v2.0.1", 2) ==> "2.0" and reshapeVersion("version2.0.1.3.2.5", 4) ==> "version2.0.1.3.2.5"
-    """
-    p = re.compile(RGX_PROJECT_VERSION)
-    m = p.search(version)
-    if m is not None:
-        n0 = m.group("n0")
-        n1 = m.group("n1")
-        n2 = m.group("n2")
-        if nb_digit == 1:
-            return n0
-        elif nb_digit == 2:
-            return n0 + ("." + n1 if n1 is not None else "")
-        elif nb_digit == 3:
-            return n0 + (
-                "." + n1 + ("." + n2 if n2 is not None else "")
-                if n1 is not None
-                else ""
-            )
-
-    return version
-
-
-
-
-
-
-
- -
- - - \ No newline at end of file diff --git a/energyml-utils/docs/src/energyml/utils/serialization.html b/energyml-utils/docs/src/energyml/utils/serialization.html deleted file mode 100644 index bad0235..0000000 --- a/energyml-utils/docs/src/energyml/utils/serialization.html +++ /dev/null @@ -1,305 +0,0 @@ - - - - - - -src.energyml.utils.serialization API documentation - - - - - - - - - - - -
-
-
-

Module src.energyml.utils.serialization

-
-
-
- -Expand source code - -
# Copyright (c) 2023-2024 Geosiris.
-# SPDX-License-Identifier: Apache-2.0
-from io import BytesIO
-from typing import Optional, Any
-
-import energyml
-from xsdata.exceptions import ParserError
-from xsdata.formats.dataclass.context import XmlContext
-from xsdata.formats.dataclass.parsers import XmlParser
-from xsdata.formats.dataclass.serializers import JsonSerializer
-from xsdata.formats.dataclass.serializers import XmlSerializer
-from xsdata.formats.dataclass.serializers.config import SerializerConfig
-
-from .introspection import get_class_from_name
-from .xml import get_class_name_from_xml, get_tree
-
-
-def read_energyml_xml_bytes_as_class(file: bytes, obj_class: type) -> Any:
-    """
-    Read an xml file into the instance of type :param:`obj_class`.
-    :param file:
-    :param obj_class:
-    :return:
-    """
-    parser = XmlParser()
-    try:
-        return parser.from_bytes(file, obj_class)
-    except ParserError as e:
-        print(f"Failed to parse file {file} as class {obj_class}")
-        raise e
-
-
-def read_energyml_xml_bytes(file: bytes) -> Any:
-    """
-    Read an xml file. The type of object is searched from the xml root name.
-    :param file:
-    :return:
-    """
-    return read_energyml_xml_bytes_as_class(
-        file, get_class_from_name(get_class_name_from_xml(get_tree(file)))
-    )
-
-
-def read_energyml_xml_io(file: BytesIO, obj_class: Optional[type] = None) -> Any:
-    if obj_class is not None:
-        return read_energyml_xml_bytes_as_class(file.getbuffer(), obj_class)
-    else:
-        return read_energyml_xml_bytes(file.getbuffer())
-
-
-def read_energyml_xml_str(file_content: str) -> Any:
-    parser = XmlParser()
-    # from energyml.resqml.v2_2.resqmlv2 import TriangulatedSetRepresentation
-    return parser.from_string(
-        file_content,
-        get_class_from_name(get_class_name_from_xml(get_tree(file_content))),
-    )  # , TriangulatedSetRepresentation)
-
-
-def read_energyml_xml_file(file_path: str) -> Any:
-    xml_content = ""
-    with open(file_path, "r") as f:
-        xml_content = f.read()
-    parser = XmlParser()
-    # from energyml.resqml.v2_2.resqmlv2 import TriangulatedSetRepresentation
-    # return parser.parse(file_path)  # , TriangulatedSetRepresentation)
-    return parser.parse(
-        file_path,
-        get_class_from_name(get_class_name_from_xml(get_tree(xml_content))),
-    )
-
-
-def serialize_xml(obj) -> str:
-    context = XmlContext(
-        # element_name_generator=text.camel_case,
-        # attribute_name_generator=text.kebab_case
-    )
-    serializer_config = SerializerConfig(indent="  ")
-    serializer = XmlSerializer(context=context, config=serializer_config)
-    return serializer.render(obj)
-
-
-def serialize_json(obj) -> str:
-    context = XmlContext(
-        # element_name_generator=text.camel_case,
-        # attribute_name_generator=text.kebab_case
-    )
-    serializer_config = SerializerConfig(indent="  ")
-    serializer = JsonSerializer(context=context, config=serializer_config)
-    return serializer.render(obj)
-
-
-
-
-
-
-
-

Functions

-
-
-def read_energyml_xml_bytes(file: bytes) ‑> Any -
-
-

Read an xml file. The type of object is searched from the xml root name. -:param file: -:return:

-
- -Expand source code - -
def read_energyml_xml_bytes(file: bytes) -> Any:
-    """
-    Read an xml file. The type of object is searched from the xml root name.
-    :param file:
-    :return:
-    """
-    return read_energyml_xml_bytes_as_class(
-        file, get_class_from_name(get_class_name_from_xml(get_tree(file)))
-    )
-
-
-
-def read_energyml_xml_bytes_as_class(file: bytes, obj_class: type) ‑> Any -
-
-

Read an xml file into the instance of type :param:obj_class. -:param file: -:param obj_class: -:return:

-
- -Expand source code - -
def read_energyml_xml_bytes_as_class(file: bytes, obj_class: type) -> Any:
-    """
-    Read an xml file into the instance of type :param:`obj_class`.
-    :param file:
-    :param obj_class:
-    :return:
-    """
-    parser = XmlParser()
-    try:
-        return parser.from_bytes(file, obj_class)
-    except ParserError as e:
-        print(f"Failed to parse file {file} as class {obj_class}")
-        raise e
-
-
-
-def read_energyml_xml_file(file_path: str) ‑> Any -
-
-
-
- -Expand source code - -
def read_energyml_xml_file(file_path: str) -> Any:
-    xml_content = ""
-    with open(file_path, "r") as f:
-        xml_content = f.read()
-    parser = XmlParser()
-    # from energyml.resqml.v2_2.resqmlv2 import TriangulatedSetRepresentation
-    # return parser.parse(file_path)  # , TriangulatedSetRepresentation)
-    return parser.parse(
-        file_path,
-        get_class_from_name(get_class_name_from_xml(get_tree(xml_content))),
-    )
-
-
-
-def read_energyml_xml_io(file: _io.BytesIO, obj_class: Optional[type] = None) ‑> Any -
-
-
-
- -Expand source code - -
def read_energyml_xml_io(file: BytesIO, obj_class: Optional[type] = None) -> Any:
-    if obj_class is not None:
-        return read_energyml_xml_bytes_as_class(file.getbuffer(), obj_class)
-    else:
-        return read_energyml_xml_bytes(file.getbuffer())
-
-
-
-def read_energyml_xml_str(file_content: str) ‑> Any -
-
-
-
- -Expand source code - -
def read_energyml_xml_str(file_content: str) -> Any:
-    parser = XmlParser()
-    # from energyml.resqml.v2_2.resqmlv2 import TriangulatedSetRepresentation
-    return parser.from_string(
-        file_content,
-        get_class_from_name(get_class_name_from_xml(get_tree(file_content))),
-    )  # , TriangulatedSetRepresentation)
-
-
-
-def serialize_json(obj) ‑> str -
-
-
-
- -Expand source code - -
def serialize_json(obj) -> str:
-    context = XmlContext(
-        # element_name_generator=text.camel_case,
-        # attribute_name_generator=text.kebab_case
-    )
-    serializer_config = SerializerConfig(indent="  ")
-    serializer = JsonSerializer(context=context, config=serializer_config)
-    return serializer.render(obj)
-
-
-
-def serialize_xml(obj) ‑> str -
-
-
-
- -Expand source code - -
def serialize_xml(obj) -> str:
-    context = XmlContext(
-        # element_name_generator=text.camel_case,
-        # attribute_name_generator=text.kebab_case
-    )
-    serializer_config = SerializerConfig(indent="  ")
-    serializer = XmlSerializer(context=context, config=serializer_config)
-    return serializer.render(obj)
-
-
-
-
-
-
-
- -
- - - \ No newline at end of file diff --git a/energyml-utils/docs/src/energyml/utils/validation.html b/energyml-utils/docs/src/energyml/utils/validation.html deleted file mode 100644 index e5a5fca..0000000 --- a/energyml-utils/docs/src/energyml/utils/validation.html +++ /dev/null @@ -1,984 +0,0 @@ - - - - - - -src.energyml.utils.validation API documentation - - - - - - - - - - - -
-
-
-

Module src.energyml.utils.validation

-
-
-
- -Expand source code - -
# Copyright (c) 2023-2024 Geosiris.
-# SPDX-License-Identifier: Apache-2.0
-import re
-from dataclasses import dataclass, field, Field
-from enum import Enum
-from typing import Any, List
-
-from .epc import (
-    get_obj_identifier, Epc,
-)
-from .introspection import (
-    get_class_fields,
-    get_object_attribute,
-    search_attribute_matching_type_with_path,
-    get_object_attribute_no_verif,
-    get_object_attribute_rgx,
-    get_matching_class_attribute_name, get_obj_uuid, get_obj_version, get_content_type_from_class,
-    get_qualified_type_from_class,
-)
-
-
-class ErrorType(Enum):
-    CRITICAL = "critical"
-    DEBUG = "debug"
-    INFO = "info"
-    WARNING = "warning"
-
-
-@dataclass
-class ValidationError:
-
-    msg: str = field(default="Validation error")
-
-    error_type: ErrorType = field(default=ErrorType.INFO)
-
-    def __str__(self):
-        return f"[{str(self.error_type).upper()}] : {self.msg}"
-
-
-@dataclass
-class ValidationObjectError(ValidationError):
-
-    target_obj: Any = field(default=None)
-
-    attribute_dot_path: str = field(default=None)
-
-    def __str__(self):
-        return f"{ValidationError.__str__(self)}\n\t{get_obj_identifier(self.target_obj)} : '{self.attribute_dot_path}'"
-
-
-@dataclass
-class MandatoryError(ValidationObjectError):
-    def __str__(self):
-        return f"{ValidationError.__str__(self)}\n\tMandatory value is None for {get_obj_identifier(self.target_obj)} : '{self.attribute_dot_path}'"
-
-
-def validate_epc(epc: Epc) -> List[ValidationError]:
-    """
-    Verify if all :param:`epc`'s objects are valid.
-    :param epc:
-    :return:
-    """
-    errs = []
-    for obj in epc.energyml_objects:
-        errs = errs + patterns_verification(obj)
-
-    errs = errs + dor_verification(epc.energyml_objects)
-
-    return errs
-
-
-def dor_verification(energyml_objects: List[Any]) -> List[ValidationError]:
-    """
-    Verification for DOR. An error is raised if DORs contains wrong information, or if a referenced object is unknown
-    in the :param:`epc`.
-    :param energyml_objects:
-    :return:
-    """
-    errs = []
-
-    dict_obj_identifier = {
-        get_obj_identifier(obj): obj for obj in energyml_objects
-    }
-    dict_obj_uuid = {}
-    for obj in energyml_objects:
-        uuid = get_obj_uuid(obj)
-        if uuid not in dict_obj_uuid:
-            dict_obj_uuid[uuid] = []
-        dict_obj_uuid[uuid].append(obj)
-
-    # TODO: chercher dans les objets les AbstractObject (en Witsml des sous objet peuvent etre aussi references)
-
-    for obj in energyml_objects:
-        dor_list = search_attribute_matching_type_with_path(
-            obj, "DataObjectReference"
-        )
-        for dor_path, dor in dor_list:
-            dor_target_id = get_obj_identifier(dor)
-            if dor_target_id not in dict_obj_identifier:
-                dor_uuid = get_obj_uuid(dor)
-                dor_version = get_obj_version(dor)
-                if dor_uuid not in dict_obj_uuid:
-                    errs.append(
-                        ValidationObjectError(
-                            error_type=ErrorType.CRITICAL,
-                            target_obj=obj,
-                            attribute_dot_path=dor_path,
-                            msg=f"[DOR ERR] has wrong information. Unkown object with uuid '{dor_uuid}'",
-                        )
-                    )
-                else:
-                    accessible_version = [
-                        get_obj_version(ref_obj)
-                        for ref_obj in dict_obj_uuid[dor_uuid]
-                    ]
-                    errs.append(
-                        ValidationObjectError(
-                            error_type=ErrorType.CRITICAL,
-                            target_obj=obj,
-                            attribute_dot_path=dor_path,
-                            msg=f"[DOR ERR] has wrong information. Unkown object version '{dor_version}'. "
-                            f"Version must be one of {accessible_version}",
-                        )
-                    )
-            else:
-                target = dict_obj_identifier[dor_target_id]
-                target_title = get_object_attribute_rgx(
-                    target, "citation.title"
-                )
-                target_content_type = get_content_type_from_class(target)
-                target_qualified_type = get_qualified_type_from_class(target)
-
-                dor_title = get_object_attribute_rgx(dor, "title")
-
-                if dor_title != target_title:
-                    errs.append(
-                        ValidationObjectError(
-                            error_type=ErrorType.CRITICAL,
-                            target_obj=obj,
-                            attribute_dot_path=dor_path,
-                            msg=f"[DOR ERR] has wrong information. Title should be '{target_title}' and not '{dor_title}'",
-                        )
-                    )
-
-                if (
-                    get_matching_class_attribute_name(dor, "content_type")
-                    is not None
-                ):
-                    dor_content_type = get_object_attribute_no_verif(
-                        dor, "content_type"
-                    )
-                    if dor_content_type != target_content_type:
-                        errs.append(
-                            ValidationObjectError(
-                                error_type=ErrorType.CRITICAL,
-                                target_obj=obj,
-                                attribute_dot_path=dor_path,
-                                msg=f"[DOR ERR] has wrong information. ContentType should be '{target_content_type}' and not '{dor_content_type}'",
-                            )
-                        )
-
-                if (
-                    get_matching_class_attribute_name(dor, "qualified_type")
-                    is not None
-                ):
-                    dor_qualified_type = get_object_attribute_no_verif(
-                        dor, "qualified_type"
-                    )
-                    if dor_qualified_type != target_qualified_type:
-                        errs.append(
-                            ValidationObjectError(
-                                error_type=ErrorType.CRITICAL,
-                                target_obj=obj,
-                                attribute_dot_path=dor_path,
-                                msg=f"[DOR ERR] has wrong information. QualifiedType should be '{target_qualified_type}' and not '{dor_qualified_type}'",
-                            )
-                        )
-
-    return errs
-
-
-def patterns_verification(obj: Any) -> List[ValidationError]:
-    """
-    Verification on object values, using the patterns defined in the original energyml xsd files.
-    :param obj:
-    :return:
-    """
-    return _patterns_verification(obj, obj, "")
-
-
-def _patterns_verification(
-    obj: Any, root_obj: Any, current_attribute_dot_path: str = ""
-) -> List[ValidationError]:
-    """
-    Verification on object values, using the patterns defined in the original energyml xsd files.
-    :param obj:
-    :param root_obj:
-    :param current_attribute_dot_path:
-    :return:
-    """
-    error_list = []
-
-    if isinstance(obj, list):
-        cpt = 0
-        for val in obj:
-            error_list = error_list + _patterns_verification(
-                val, root_obj, f"{current_attribute_dot_path}.{cpt}"
-            )
-            cpt = cpt + 1
-    elif isinstance(obj, dict):
-        for k, val in obj.items():
-            error_list = error_list + _patterns_verification(
-                val, root_obj, f"{current_attribute_dot_path}.{k}"
-            )
-    else:
-        # print(get_class_fields(obj))
-        for att_name, att_field in get_class_fields(obj).items():
-            # print(f"att_name : {att_field.metadata}")
-            error_list = error_list + validate_attribute(
-                get_object_attribute(obj, att_name, False),
-                root_obj,
-                att_field,
-                f"{current_attribute_dot_path}.{att_name}",
-            )
-
-    return error_list
-
-
-def validate_attribute(
-    value: Any, root_obj: Any, att_field: Field, path: str
-) -> List[ValidationError]:
-    errs = []
-
-    if value is None:
-        if att_field.metadata.get("required", False):
-            errs.append(
-                MandatoryError(
-                    error_type=ErrorType.CRITICAL,
-                    target_obj=root_obj,
-                    attribute_dot_path=path,
-                )
-            )
-    else:
-        min_length = att_field.metadata.get("min_length", None)
-        max_length = att_field.metadata.get("max_length", None)
-        pattern = att_field.metadata.get("pattern", None)
-        min_occurs = att_field.metadata.get("pattern", None)
-        min_inclusive = att_field.metadata.get("pattern", None)
-        # white_space
-
-        if max_length is not None:
-            length = len(value)
-            if length > max_length:
-                errs.append(
-                    ValidationObjectError(
-                        error_type=ErrorType.CRITICAL,
-                        target_obj=root_obj,
-                        attribute_dot_path=path,
-                        msg=f"Max length was {max_length} but found {length}",
-                    )
-                )
-
-        if min_length is not None:
-            length = len(value)
-            if length < min_length:
-                errs.append(
-                    ValidationObjectError(
-                        error_type=ErrorType.CRITICAL,
-                        target_obj=root_obj,
-                        attribute_dot_path=path,
-                        msg=f"Max length was {min_length} but found {length}",
-                    )
-                )
-
-        if min_occurs is not None:
-            if isinstance(value, list) and min_occurs > len(value):
-                errs.append(
-                    ValidationObjectError(
-                        error_type=ErrorType.CRITICAL,
-                        target_obj=root_obj,
-                        attribute_dot_path=path,
-                        msg=f"Min occurs was {min_occurs} but found {len(value)}",
-                    )
-                )
-
-        if min_inclusive is not None:
-            potential_err = ValidationObjectError(
-                error_type=ErrorType.CRITICAL,
-                target_obj=root_obj,
-                attribute_dot_path=path,
-                msg=f"Min occurs was {min_inclusive} but found {len(value)}",
-            )
-            if isinstance(value, list):
-                for val in value:
-                    if (
-                            (isinstance(val, str) and len(val) > min_inclusive)
-                            or ((isinstance(val, int) or isinstance(val, float)) and val > min_inclusive)
-                    ):
-                        errs.append(potential_err)
-
-        if pattern is not None:
-            if re.match(pattern, value) is None:
-                errs.append(
-                    ValidationObjectError(
-                        error_type=ErrorType.CRITICAL,
-                        target_obj=root_obj,
-                        attribute_dot_path=path,
-                        msg=f"Pattern error. Value '{value}' was supposed to respect pattern '{pattern}'",
-                    )
-                )
-
-    return errs + _patterns_verification(
-        obj=value,
-        root_obj=root_obj,
-        current_attribute_dot_path=path,
-    )
-
-
-def correct_dor(energyml_objects: List[Any]) -> None:
-    """
-    Fix DOR errors (missing object_version, wrong title, wrong content-type/qualified-type ...)
-    :param energyml_objects:
-    :return:
-    """
-    dict_obj_identifier = {
-        get_obj_identifier(obj): obj for obj in energyml_objects
-    }
-    dict_obj_uuid = {}
-    for obj in energyml_objects:
-        uuid = get_obj_uuid(obj)
-        if uuid not in dict_obj_uuid:
-            dict_obj_uuid[uuid] = []
-        dict_obj_uuid[uuid].append(obj)
-
-    # TODO: chercher dans les objets les AbstractObject (en Witsml des sous objet peuvent etre aussi references)
-
-    for obj in energyml_objects:
-        dor_list = search_attribute_matching_type_with_path(
-            obj, "DataObjectReference"
-        )
-        for dor_path, dor in dor_list:
-            dor_target_id = get_obj_identifier(dor)
-            if dor_target_id in dict_obj_identifier:
-                target = dict_obj_identifier[dor_target_id]
-                target_title = get_object_attribute_rgx(
-                    target, "citation.title"
-                )
-                target_content_type = get_content_type_from_class(target)
-                target_qualified_type = get_qualified_type_from_class(target)
-
-                dor_title = get_object_attribute_rgx(dor, "title")
-
-                if dor_title != target_title:
-                    dor.title = target_title
-
-                if (
-                    get_matching_class_attribute_name(dor, "content_type")
-                    is not None
-                ):
-                    dor_content_type = get_object_attribute_no_verif(
-                        dor, "content_type"
-                    )
-                    if dor_content_type != target_content_type:
-                        dor.content_type = target_content_type
-
-                if (
-                    get_matching_class_attribute_name(dor, "qualified_type")
-                    is not None
-                ):
-                    dor_qualified_type = get_object_attribute_no_verif(
-                        dor, "qualified_type"
-                    )
-                    if dor_qualified_type != target_qualified_type:
-                        dor.qualified_type = target_qualified_type
-
-
-
-
-
-
-
-

Functions

-
-
-def correct_dor(energyml_objects: List[Any]) ‑> None -
-
-

Fix DOR errors (missing object_version, wrong title, wrong content-type/qualified-type …) -:param energyml_objects: -:return:

-
- -Expand source code - -
def correct_dor(energyml_objects: List[Any]) -> None:
-    """
-    Fix DOR errors (missing object_version, wrong title, wrong content-type/qualified-type ...)
-    :param energyml_objects:
-    :return:
-    """
-    dict_obj_identifier = {
-        get_obj_identifier(obj): obj for obj in energyml_objects
-    }
-    dict_obj_uuid = {}
-    for obj in energyml_objects:
-        uuid = get_obj_uuid(obj)
-        if uuid not in dict_obj_uuid:
-            dict_obj_uuid[uuid] = []
-        dict_obj_uuid[uuid].append(obj)
-
-    # TODO: chercher dans les objets les AbstractObject (en Witsml des sous objet peuvent etre aussi references)
-
-    for obj in energyml_objects:
-        dor_list = search_attribute_matching_type_with_path(
-            obj, "DataObjectReference"
-        )
-        for dor_path, dor in dor_list:
-            dor_target_id = get_obj_identifier(dor)
-            if dor_target_id in dict_obj_identifier:
-                target = dict_obj_identifier[dor_target_id]
-                target_title = get_object_attribute_rgx(
-                    target, "citation.title"
-                )
-                target_content_type = get_content_type_from_class(target)
-                target_qualified_type = get_qualified_type_from_class(target)
-
-                dor_title = get_object_attribute_rgx(dor, "title")
-
-                if dor_title != target_title:
-                    dor.title = target_title
-
-                if (
-                    get_matching_class_attribute_name(dor, "content_type")
-                    is not None
-                ):
-                    dor_content_type = get_object_attribute_no_verif(
-                        dor, "content_type"
-                    )
-                    if dor_content_type != target_content_type:
-                        dor.content_type = target_content_type
-
-                if (
-                    get_matching_class_attribute_name(dor, "qualified_type")
-                    is not None
-                ):
-                    dor_qualified_type = get_object_attribute_no_verif(
-                        dor, "qualified_type"
-                    )
-                    if dor_qualified_type != target_qualified_type:
-                        dor.qualified_type = target_qualified_type
-
-
-
-def dor_verification(energyml_objects: List[Any]) ‑> List[ValidationError] -
-
-

Verification for DOR. An error is raised if DORs contains wrong information, or if a referenced object is unknown -in the :param:epc. -:param energyml_objects: -:return:

-
- -Expand source code - -
def dor_verification(energyml_objects: List[Any]) -> List[ValidationError]:
-    """
-    Verification for DOR. An error is raised if DORs contains wrong information, or if a referenced object is unknown
-    in the :param:`epc`.
-    :param energyml_objects:
-    :return:
-    """
-    errs = []
-
-    dict_obj_identifier = {
-        get_obj_identifier(obj): obj for obj in energyml_objects
-    }
-    dict_obj_uuid = {}
-    for obj in energyml_objects:
-        uuid = get_obj_uuid(obj)
-        if uuid not in dict_obj_uuid:
-            dict_obj_uuid[uuid] = []
-        dict_obj_uuid[uuid].append(obj)
-
-    # TODO: chercher dans les objets les AbstractObject (en Witsml des sous objet peuvent etre aussi references)
-
-    for obj in energyml_objects:
-        dor_list = search_attribute_matching_type_with_path(
-            obj, "DataObjectReference"
-        )
-        for dor_path, dor in dor_list:
-            dor_target_id = get_obj_identifier(dor)
-            if dor_target_id not in dict_obj_identifier:
-                dor_uuid = get_obj_uuid(dor)
-                dor_version = get_obj_version(dor)
-                if dor_uuid not in dict_obj_uuid:
-                    errs.append(
-                        ValidationObjectError(
-                            error_type=ErrorType.CRITICAL,
-                            target_obj=obj,
-                            attribute_dot_path=dor_path,
-                            msg=f"[DOR ERR] has wrong information. Unkown object with uuid '{dor_uuid}'",
-                        )
-                    )
-                else:
-                    accessible_version = [
-                        get_obj_version(ref_obj)
-                        for ref_obj in dict_obj_uuid[dor_uuid]
-                    ]
-                    errs.append(
-                        ValidationObjectError(
-                            error_type=ErrorType.CRITICAL,
-                            target_obj=obj,
-                            attribute_dot_path=dor_path,
-                            msg=f"[DOR ERR] has wrong information. Unkown object version '{dor_version}'. "
-                            f"Version must be one of {accessible_version}",
-                        )
-                    )
-            else:
-                target = dict_obj_identifier[dor_target_id]
-                target_title = get_object_attribute_rgx(
-                    target, "citation.title"
-                )
-                target_content_type = get_content_type_from_class(target)
-                target_qualified_type = get_qualified_type_from_class(target)
-
-                dor_title = get_object_attribute_rgx(dor, "title")
-
-                if dor_title != target_title:
-                    errs.append(
-                        ValidationObjectError(
-                            error_type=ErrorType.CRITICAL,
-                            target_obj=obj,
-                            attribute_dot_path=dor_path,
-                            msg=f"[DOR ERR] has wrong information. Title should be '{target_title}' and not '{dor_title}'",
-                        )
-                    )
-
-                if (
-                    get_matching_class_attribute_name(dor, "content_type")
-                    is not None
-                ):
-                    dor_content_type = get_object_attribute_no_verif(
-                        dor, "content_type"
-                    )
-                    if dor_content_type != target_content_type:
-                        errs.append(
-                            ValidationObjectError(
-                                error_type=ErrorType.CRITICAL,
-                                target_obj=obj,
-                                attribute_dot_path=dor_path,
-                                msg=f"[DOR ERR] has wrong information. ContentType should be '{target_content_type}' and not '{dor_content_type}'",
-                            )
-                        )
-
-                if (
-                    get_matching_class_attribute_name(dor, "qualified_type")
-                    is not None
-                ):
-                    dor_qualified_type = get_object_attribute_no_verif(
-                        dor, "qualified_type"
-                    )
-                    if dor_qualified_type != target_qualified_type:
-                        errs.append(
-                            ValidationObjectError(
-                                error_type=ErrorType.CRITICAL,
-                                target_obj=obj,
-                                attribute_dot_path=dor_path,
-                                msg=f"[DOR ERR] has wrong information. QualifiedType should be '{target_qualified_type}' and not '{dor_qualified_type}'",
-                            )
-                        )
-
-    return errs
-
-
-
-def patterns_verification(obj: Any) ‑> List[ValidationError] -
-
-

Verification on object values, using the patterns defined in the original energyml xsd files. -:param obj: -:return:

-
- -Expand source code - -
def patterns_verification(obj: Any) -> List[ValidationError]:
-    """
-    Verification on object values, using the patterns defined in the original energyml xsd files.
-    :param obj:
-    :return:
-    """
-    return _patterns_verification(obj, obj, "")
-
-
-
-def validate_attribute(value: Any, root_obj: Any, att_field: dataclasses.Field, path: str) ‑> List[ValidationError] -
-
-
-
- -Expand source code - -
def validate_attribute(
-    value: Any, root_obj: Any, att_field: Field, path: str
-) -> List[ValidationError]:
-    errs = []
-
-    if value is None:
-        if att_field.metadata.get("required", False):
-            errs.append(
-                MandatoryError(
-                    error_type=ErrorType.CRITICAL,
-                    target_obj=root_obj,
-                    attribute_dot_path=path,
-                )
-            )
-    else:
-        min_length = att_field.metadata.get("min_length", None)
-        max_length = att_field.metadata.get("max_length", None)
-        pattern = att_field.metadata.get("pattern", None)
-        min_occurs = att_field.metadata.get("pattern", None)
-        min_inclusive = att_field.metadata.get("pattern", None)
-        # white_space
-
-        if max_length is not None:
-            length = len(value)
-            if length > max_length:
-                errs.append(
-                    ValidationObjectError(
-                        error_type=ErrorType.CRITICAL,
-                        target_obj=root_obj,
-                        attribute_dot_path=path,
-                        msg=f"Max length was {max_length} but found {length}",
-                    )
-                )
-
-        if min_length is not None:
-            length = len(value)
-            if length < min_length:
-                errs.append(
-                    ValidationObjectError(
-                        error_type=ErrorType.CRITICAL,
-                        target_obj=root_obj,
-                        attribute_dot_path=path,
-                        msg=f"Max length was {min_length} but found {length}",
-                    )
-                )
-
-        if min_occurs is not None:
-            if isinstance(value, list) and min_occurs > len(value):
-                errs.append(
-                    ValidationObjectError(
-                        error_type=ErrorType.CRITICAL,
-                        target_obj=root_obj,
-                        attribute_dot_path=path,
-                        msg=f"Min occurs was {min_occurs} but found {len(value)}",
-                    )
-                )
-
-        if min_inclusive is not None:
-            potential_err = ValidationObjectError(
-                error_type=ErrorType.CRITICAL,
-                target_obj=root_obj,
-                attribute_dot_path=path,
-                msg=f"Min occurs was {min_inclusive} but found {len(value)}",
-            )
-            if isinstance(value, list):
-                for val in value:
-                    if (
-                            (isinstance(val, str) and len(val) > min_inclusive)
-                            or ((isinstance(val, int) or isinstance(val, float)) and val > min_inclusive)
-                    ):
-                        errs.append(potential_err)
-
-        if pattern is not None:
-            if re.match(pattern, value) is None:
-                errs.append(
-                    ValidationObjectError(
-                        error_type=ErrorType.CRITICAL,
-                        target_obj=root_obj,
-                        attribute_dot_path=path,
-                        msg=f"Pattern error. Value '{value}' was supposed to respect pattern '{pattern}'",
-                    )
-                )
-
-    return errs + _patterns_verification(
-        obj=value,
-        root_obj=root_obj,
-        current_attribute_dot_path=path,
-    )
-
-
-
-def validate_epc(epc: Epc) ‑> List[ValidationError] -
-
-

Verify if all :param:epc's objects are valid. -:param epc: -:return:

-
- -Expand source code - -
def validate_epc(epc: Epc) -> List[ValidationError]:
-    """
-    Verify if all :param:`epc`'s objects are valid.
-    :param epc:
-    :return:
-    """
-    errs = []
-    for obj in epc.energyml_objects:
-        errs = errs + patterns_verification(obj)
-
-    errs = errs + dor_verification(epc.energyml_objects)
-
-    return errs
-
-
-
-
-
-

Classes

-
-
-class ErrorType -(*args, **kwds) -
-
-

Create a collection of name/value pairs.

-

Example enumeration:

-
>>> class Color(Enum):
-...     RED = 1
-...     BLUE = 2
-...     GREEN = 3
-
-

Access them by:

-
    -
  • attribute access::
  • -
-
>>> Color.RED
-<Color.RED: 1>
-
-
    -
  • value lookup:
  • -
-
>>> Color(1)
-<Color.RED: 1>
-
-
    -
  • name lookup:
  • -
-
>>> Color['RED']
-<Color.RED: 1>
-
-

Enumerations can be iterated over, and know how many members they have:

-
>>> len(Color)
-3
-
-
>>> list(Color)
-[<Color.RED: 1>, <Color.BLUE: 2>, <Color.GREEN: 3>]
-
-

Methods can be added to enumerations, and members can have their own -attributes – see the documentation for details.

-
- -Expand source code - -
class ErrorType(Enum):
-    CRITICAL = "critical"
-    DEBUG = "debug"
-    INFO = "info"
-    WARNING = "warning"
-
-

Ancestors

-
    -
  • enum.Enum
  • -
-

Class variables

-
-
var CRITICAL
-
-
-
-
var DEBUG
-
-
-
-
var INFO
-
-
-
-
var WARNING
-
-
-
-
-
-
-class MandatoryError -(msg: str = 'Validation error', error_type: ErrorType = ErrorType.INFO, target_obj: Any = None, attribute_dot_path: str = None) -
-
-

MandatoryError(msg: str = 'Validation error', error_type: src.energyml.utils.validation.ErrorType = , target_obj: Any = None, attribute_dot_path: str = None)

-
- -Expand source code - -
@dataclass
-class MandatoryError(ValidationObjectError):
-    def __str__(self):
-        return f"{ValidationError.__str__(self)}\n\tMandatory value is None for {get_obj_identifier(self.target_obj)} : '{self.attribute_dot_path}'"
-
-

Ancestors

- -
-
-class ValidationError -(msg: str = 'Validation error', error_type: ErrorType = ErrorType.INFO) -
-
-

ValidationError(msg: str = 'Validation error', error_type: src.energyml.utils.validation.ErrorType = )

-
- -Expand source code - -
@dataclass
-class ValidationError:
-
-    msg: str = field(default="Validation error")
-
-    error_type: ErrorType = field(default=ErrorType.INFO)
-
-    def __str__(self):
-        return f"[{str(self.error_type).upper()}] : {self.msg}"
-
-

Subclasses

- -

Class variables

-
-
var error_typeErrorType
-
-
-
-
var msg : str
-
-
-
-
-
-
-class ValidationObjectError -(msg: str = 'Validation error', error_type: ErrorType = ErrorType.INFO, target_obj: Any = None, attribute_dot_path: str = None) -
-
-

ValidationObjectError(msg: str = 'Validation error', error_type: src.energyml.utils.validation.ErrorType = , target_obj: Any = None, attribute_dot_path: str = None)

-
- -Expand source code - -
@dataclass
-class ValidationObjectError(ValidationError):
-
-    target_obj: Any = field(default=None)
-
-    attribute_dot_path: str = field(default=None)
-
-    def __str__(self):
-        return f"{ValidationError.__str__(self)}\n\t{get_obj_identifier(self.target_obj)} : '{self.attribute_dot_path}'"
-
-

Ancestors

- -

Subclasses

- -

Class variables

-
-
var attribute_dot_path : str
-
-
-
-
var target_obj : Any
-
-
-
-
-
-
-
-
- -
- - - \ No newline at end of file diff --git a/energyml-utils/docs/src/energyml/utils/xml.html b/energyml-utils/docs/src/energyml/utils/xml.html deleted file mode 100644 index a2100c4..0000000 --- a/energyml-utils/docs/src/energyml/utils/xml.html +++ /dev/null @@ -1,501 +0,0 @@ - - - - - - -src.energyml.utils.xml API documentation - - - - - - - - - - - -
-
-
-

Module src.energyml.utils.xml

-
-
-
- -Expand source code - -
# Copyright (c) 2023-2024 Geosiris.
-# SPDX-License-Identifier: Apache-2.0
-import re
-from io import BytesIO
-from typing import Optional, Any, Union
-
-from lxml import etree as ETREE  # type: Any
-
-ENERGYML_NAMESPACES = {
-    "eml": "http://www.energistics.org/energyml/data/commonv2",
-    "prodml": "http://www.energistics.org/energyml/data/prodmlv2",
-    "witsml": "http://www.energistics.org/energyml/data/witsmlv2",
-    "resqml": "http://www.energistics.org/energyml/data/resqmlv2",
-}
-"""
-dict of all energyml namespaces
-"""  # pylint: disable=W0105
-
-ENERGYML_NAMESPACES_PACKAGE = {
-    "eml": ["http://www.energistics.org/energyml/data/commonv2"],
-    "prodml": ["http://www.energistics.org/energyml/data/prodmlv2"],
-    "witsml": ["http://www.energistics.org/energyml/data/witsmlv2"],
-    "resqml": ["http://www.energistics.org/energyml/data/resqmlv2"],
-    "opc": [
-        "http://schemas.openxmlformats.org/package/2006/content-types",
-        "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
-    ],
-}
-"""
-dict of all energyml namespace packages
-"""  # pylint: disable=W0105
-
-RGX_UUID_NO_GRP = (
-    r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
-)
-RGX_UUID = r"(?P<uuid>" + RGX_UUID_NO_GRP + ")"
-RGX_DOMAIN_VERSION = r"(?P<domainVersion>(?P<versionNum>([\d]+[\._])*\d)\s*(?P<dev>dev\s*(?P<devNum>[\d]+))?)"
-RGX_DOMAIN_VERSION_FLAT = r"(?P<domainVersion>(?P<versionNumFlat>([\d]+)*\d)\s*(?P<dev>dev\s*(?P<devNum>[\d]+))?)"
-
-
-# ContentType
-RGX_MIME_TYPE_MEDIA = r"(?P<media>application|audio|font|example|image|message|model|multipart|text|video)"
-RGX_CT_ENERGYML_DOMAIN = r"(?P<energymlDomain>x-(?P<domain>[\w]+)\+xml)"
-RGX_CT_XML_DOMAIN = r"(?P<xmlRawDomain>(x\-)?(?P<xmlDomain>.+)\+xml)"
-RGX_CT_TOKEN_VERSION = r"version=" + RGX_DOMAIN_VERSION
-RGX_CT_TOKEN_TYPE = r"type=(?P<type>[\w\_]+)"
-
-RGX_CONTENT_TYPE = (
-        RGX_MIME_TYPE_MEDIA + "/"
-        + "(?P<rawDomain>(" + RGX_CT_ENERGYML_DOMAIN + ")|(" + RGX_CT_XML_DOMAIN + r")|([\w-]+\.?)+)"
-        + "(;((" + RGX_CT_TOKEN_VERSION + ")|(" + RGX_CT_TOKEN_TYPE + ")))*"
-)
-RGX_QUALIFIED_TYPE = (
-        r"(?P<domain>[a-zA-Z]+)" + RGX_DOMAIN_VERSION_FLAT + r"\.(?P<type>[\w_]+)"
-)
-# =========
-
-RGX_SCHEMA_VERSION = (
-        r"(?P<name>[eE]ml|[cC]ommon|[rR]esqml|[wW]itsml|[pP]rodml)?\s*v?"
-        + RGX_DOMAIN_VERSION
-        + r"\s*$"
-)
-
-RGX_ENERGYML_FILE_NAME_OLD = r"(?P<type>[\w]+)_" + RGX_UUID_NO_GRP + r"\.xml$"
-RGX_ENERGYML_FILE_NAME_NEW = (
-        RGX_UUID_NO_GRP + r"\.(?P<objectVersion>\d+(\.\d+)*)\.xml$"
-)
-RGX_ENERGYML_FILE_NAME = (
-    rf"^(.*/)?({RGX_ENERGYML_FILE_NAME_OLD})|({RGX_ENERGYML_FILE_NAME_NEW})"
-)
-
-RGX_XML_HEADER = r"^\s*\<\?xml\s+((encoding\s*=\s*\"(?P<encoding>[^\"]+)\"|version\s*=\s*\"(?P<version>[^\"]+)\"|standalone\s*=\s*\"(?P<standalone>[^\"]+)\")\s+)+"
-
-
-def get_pkg_from_namespace(namespace: str) -> Optional[str]:
-    for (k, v) in ENERGYML_NAMESPACES_PACKAGE.items():
-        if namespace in v:
-            return k
-    return None
-
-
-def is_energyml_content_type(content_type: str) -> bool:
-    ct = parse_content_type(content_type)
-    return ct.group("domain") is not None
-
-
-def get_root_namespace(tree: ETREE.Element) -> str:
-    return tree.nsmap[tree.prefix]
-
-
-def get_class_name_from_xml(tree: ETREE.Element) -> str:
-    root_namespace = get_root_namespace(tree)
-    pkg = get_pkg_from_namespace(root_namespace)
-    if pkg is None:
-        print(f"No pkg found for elt {tree}")
-    else:
-        if pkg == "opc":
-            return "energyml.opc.opc." + get_root_type(tree)
-        else:
-            schema_version = find_schema_version_in_element(tree).replace(".", "_").replace("-", "_")
-            if pkg == "resqml" and schema_version == "2_0":
-                schema_version = "2_0_1"
-            return ("energyml." + pkg
-                    + ".v" + schema_version
-                    + "."
-                    + root_namespace[root_namespace.rindex("/") + 1:]
-                    + "." + get_root_type(tree)
-                    )
-
-
-def get_xml_encoding(xml_content: str) -> Optional[str]:
-    try:
-        m = re.search(RGX_XML_HEADER, xml_content)
-        return m.group("encoding")
-    except AttributeError:
-        return "utf-8"
-
-
-def get_tree(xml_content: Union[bytes, str]) -> ETREE.Element:
-    xml_bytes = xml_content
-    if isinstance(xml_bytes, str):
-        xml_bytes = xml_content.encode(encoding=get_xml_encoding(xml_content).strip().lower())
-
-    return ETREE.parse(BytesIO(xml_bytes)).getroot()
-
-
-def energyml_xpath(tree: ETREE.Element, xpath: str) -> Optional[list]:
-    """A xpath research that knows energyml namespaces"""
-    try:
-        return ETREE.XPath(xpath, namespaces=ENERGYML_NAMESPACES)(tree)
-    except TypeError:
-        return None
-
-
-def search_element_has_child_xpath(tree: ETREE.Element, child_name: str) -> list:
-    """
-    Search elements that has a child named (xml tag) as 'child_name'.
-    Warning : child_name must contain the namespace (see. ENERGYML_NAMESPACES)
-    """
-    return list(x for x in energyml_xpath(tree, f"//{child_name}/.."))
-
-
-def get_uuid(tree: ETREE.Element) -> str:
-    _uuids = tree.xpath("@uuid")
-    if len(_uuids) <= 0:
-        _uuids = tree.xpath("@UUID")
-    if len(_uuids) <= 0:
-        _uuids = tree.xpath("@uid")
-    if len(_uuids) <= 0:
-        _uuids = tree.xpath("@UID")
-    return _uuids[0]
-
-
-def get_root_type(tree: ETREE.Element) -> str:
-    """ Returns the type (xml tag) of the element without the namespace """
-    return tree.xpath("local-name()")
-
-
-def find_schema_version_in_element(tree: ETREE.ElementTree) -> str:
-    """Find the "SchemaVersion" inside an xml content of a energyml file
-
-    :param tree: An energyml xml file content.
-    :type tree: bytes
-
-    :returns: The SchemaVersion that contains only the version number. For example, if the xml
-        file contains : SchemaVersion="Resqml 2.0.1"
-            the result will be : "2.0.1"
-    :rtype: str
-    """
-    _schema_version = tree.xpath("@schemaVersion")
-    if _schema_version is None:
-        _schema_version = tree.xpath("@SchemaVersion")
-
-    if _schema_version is not None:
-        match_version = re.search(r"\d+(\.\d+)*", _schema_version[0])
-        if match_version is not None:
-            return match_version.group(0)
-    return ""
-
-
-def parse_content_type(ct: str):
-    return re.search(RGX_CONTENT_TYPE, ct)
-
-
-
-
-
-

Global variables

-
-
var ENERGYML_NAMESPACES
-
-

dict of all energyml namespaces

-
-
var ENERGYML_NAMESPACES_PACKAGE
-
-

dict of all energyml namespace packages

-
-
-
-
-

Functions

-
-
-def energyml_xpath(tree: , xpath: str) ‑> Optional[list] -
-
-

A xpath research that knows energyml namespaces

-
- -Expand source code - -
def energyml_xpath(tree: ETREE.Element, xpath: str) -> Optional[list]:
-    """A xpath research that knows energyml namespaces"""
-    try:
-        return ETREE.XPath(xpath, namespaces=ENERGYML_NAMESPACES)(tree)
-    except TypeError:
-        return None
-
-
-
-def find_schema_version_in_element(tree: ) ‑> str -
-
-

Find the "SchemaVersion" inside an xml content of a energyml file

-

:param tree: An energyml xml file content. -:type tree: bytes

-

:returns: The SchemaVersion that contains only the version number. For example, if the xml -file contains : SchemaVersion="Resqml 2.0.1" -the result will be : "2.0.1" -:rtype: str

-
- -Expand source code - -
def find_schema_version_in_element(tree: ETREE.ElementTree) -> str:
-    """Find the "SchemaVersion" inside an xml content of a energyml file
-
-    :param tree: An energyml xml file content.
-    :type tree: bytes
-
-    :returns: The SchemaVersion that contains only the version number. For example, if the xml
-        file contains : SchemaVersion="Resqml 2.0.1"
-            the result will be : "2.0.1"
-    :rtype: str
-    """
-    _schema_version = tree.xpath("@schemaVersion")
-    if _schema_version is None:
-        _schema_version = tree.xpath("@SchemaVersion")
-
-    if _schema_version is not None:
-        match_version = re.search(r"\d+(\.\d+)*", _schema_version[0])
-        if match_version is not None:
-            return match_version.group(0)
-    return ""
-
-
-
-def get_class_name_from_xml(tree: ) ‑> str -
-
-
-
- -Expand source code - -
def get_class_name_from_xml(tree: ETREE.Element) -> str:
-    root_namespace = get_root_namespace(tree)
-    pkg = get_pkg_from_namespace(root_namespace)
-    if pkg is None:
-        print(f"No pkg found for elt {tree}")
-    else:
-        if pkg == "opc":
-            return "energyml.opc.opc." + get_root_type(tree)
-        else:
-            schema_version = find_schema_version_in_element(tree).replace(".", "_").replace("-", "_")
-            if pkg == "resqml" and schema_version == "2_0":
-                schema_version = "2_0_1"
-            return ("energyml." + pkg
-                    + ".v" + schema_version
-                    + "."
-                    + root_namespace[root_namespace.rindex("/") + 1:]
-                    + "." + get_root_type(tree)
-                    )
-
-
-
-def get_pkg_from_namespace(namespace: str) ‑> Optional[str] -
-
-
-
- -Expand source code - -
def get_pkg_from_namespace(namespace: str) -> Optional[str]:
-    for (k, v) in ENERGYML_NAMESPACES_PACKAGE.items():
-        if namespace in v:
-            return k
-    return None
-
-
-
-def get_root_namespace(tree: ) ‑> str -
-
-
-
- -Expand source code - -
def get_root_namespace(tree: ETREE.Element) -> str:
-    return tree.nsmap[tree.prefix]
-
-
-
-def get_root_type(tree: ) ‑> str -
-
-

Returns the type (xml tag) of the element without the namespace

-
- -Expand source code - -
def get_root_type(tree: ETREE.Element) -> str:
-    """ Returns the type (xml tag) of the element without the namespace """
-    return tree.xpath("local-name()")
-
-
-
-def get_tree(xml_content: Union[bytes, str]) ‑>  -
-
-
-
- -Expand source code - -
def get_tree(xml_content: Union[bytes, str]) -> ETREE.Element:
-    xml_bytes = xml_content
-    if isinstance(xml_bytes, str):
-        xml_bytes = xml_content.encode(encoding=get_xml_encoding(xml_content).strip().lower())
-
-    return ETREE.parse(BytesIO(xml_bytes)).getroot()
-
-
-
-def get_uuid(tree: ) ‑> str -
-
-
-
- -Expand source code - -
def get_uuid(tree: ETREE.Element) -> str:
-    _uuids = tree.xpath("@uuid")
-    if len(_uuids) <= 0:
-        _uuids = tree.xpath("@UUID")
-    if len(_uuids) <= 0:
-        _uuids = tree.xpath("@uid")
-    if len(_uuids) <= 0:
-        _uuids = tree.xpath("@UID")
-    return _uuids[0]
-
-
-
-def get_xml_encoding(xml_content: str) ‑> Optional[str] -
-
-
-
- -Expand source code - -
def get_xml_encoding(xml_content: str) -> Optional[str]:
-    try:
-        m = re.search(RGX_XML_HEADER, xml_content)
-        return m.group("encoding")
-    except AttributeError:
-        return "utf-8"
-
-
-
-def is_energyml_content_type(content_type: str) ‑> bool -
-
-
-
- -Expand source code - -
def is_energyml_content_type(content_type: str) -> bool:
-    ct = parse_content_type(content_type)
-    return ct.group("domain") is not None
-
-
-
-def parse_content_type(ct: str) -
-
-
-
- -Expand source code - -
def parse_content_type(ct: str):
-    return re.search(RGX_CONTENT_TYPE, ct)
-
-
-
-def search_element_has_child_xpath(tree: , child_name: str) ‑> list -
-
-

Search elements that has a child named (xml tag) as 'child_name'. -Warning : child_name must contain the namespace (see. ENERGYML_NAMESPACES)

-
- -Expand source code - -
def search_element_has_child_xpath(tree: ETREE.Element, child_name: str) -> list:
-    """
-    Search elements that has a child named (xml tag) as 'child_name'.
-    Warning : child_name must contain the namespace (see. ENERGYML_NAMESPACES)
-    """
-    return list(x for x in energyml_xpath(tree, f"//{child_name}/.."))
-
-
-
-
-
-
-
- -
- - - \ No newline at end of file diff --git a/energyml-utils/docs/src/index.html b/energyml-utils/docs/src/index.html deleted file mode 100644 index 50b221a..0000000 --- a/energyml-utils/docs/src/index.html +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - -src API documentation - - - - - - - - - - - -
-
-
-

Package src

-
-
-
-
-

Sub-modules

-
-
src.energyml
-
-
-
-
-
-
-
-
-
-
-
-
- -
- - - \ No newline at end of file diff --git a/energyml-utils/src/energyml/utils/data/mesh.py b/energyml-utils/src/energyml/utils/data/mesh.py index 108da7e..de1e6f9 100644 --- a/energyml-utils/src/energyml/utils/data/mesh.py +++ b/energyml-utils/src/energyml/utils/data/mesh.py @@ -20,7 +20,7 @@ get_crs_origin_offset, is_z_reversed, ) -from energyml.utils.epc import gen_energyml_object_path +from energyml.utils.epc_utils import gen_energyml_object_path from energyml.utils.epc_stream import EpcStreamReader from energyml.utils.exception import NotSupportedError, ObjectNotFoundNotError from energyml.utils.introspection import ( diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index 25831ec..69380d4 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -5,7 +5,6 @@ """ import datetime -import json import logging import os from pathlib import Path @@ -15,7 +14,7 @@ import zipfile from dataclasses import dataclass, field from io import BytesIO -from typing import List, Any, Union, Dict, Callable, Optional, Tuple +from typing import List, Any, Union, Dict, Optional from energyml.opc.opc import ( CoreProperties, @@ -34,36 +33,29 @@ gen_energyml_object_path, gen_rels_path, get_epc_content_type_path, + create_h5_external_relationship, ) from energyml.utils.storage_interface import DataArrayMetadata, EnergymlStorageInterface, ResourceMetadata import numpy as np -from .uri import Uri, parse_uri +from energyml.utils.uri import Uri, parse_uri from xsdata.formats.dataclass.models.generics import DerivedElement -from .constants import ( +from energyml.utils.constants import ( RELS_CONTENT_TYPE, EpcExportVersion, RawFile, EPCRelsRelationshipType, - MimeType, - content_type_to_qualified_type, - qualified_type_to_content_type, - split_identifier, - get_property_kind_dict_path_as_dict, - OptimizedRegex, ) -from .data.datasets_io import ( +from energyml.utils.data.datasets_io import ( get_handler_registry, read_external_dataset_array, ) -from .exception import UnparsableFile -from .introspection import ( +from energyml.utils.exception import UnparsableFile +from energyml.utils.introspection import ( get_class_from_content_type, get_obj_type, get_obj_uri, get_obj_usable_class, - is_dor, - search_attribute_matching_type, get_obj_version, get_obj_uuid, get_content_type_from_class, @@ -72,16 +64,10 @@ epoch, gen_uuid, get_obj_identifier, - get_class_from_qualified_type, - copy_attributes, - get_obj_attribute_class, - set_attribute_from_path, - set_attribute_value, get_object_attribute, get_qualified_type_from_class, ) -from .manager import get_class_pkg, get_class_pkg_version -from .serialization import ( +from energyml.utils.serialization import ( serialize_xml, read_energyml_xml_str, read_energyml_xml_bytes, @@ -89,7 +75,7 @@ read_energyml_json_bytes, JSON_VERSION, ) -from .xml import is_energyml_content_type +from energyml.utils.xml import is_energyml_content_type @dataclass @@ -954,264 +940,34 @@ def close(self) -> None: # /_____/_/ /_/\___/_/ \__, /\__, /_/ /_/ /_/_/ /_/ \__,_/_/ /_/\___/\__/_/\____/_/ /_/____/ # /____//____/ -""" -PropertyKind list: a list of Pre-defined properties -""" -__CACHE_PROP_KIND_DICT__ = {} - - -def update_prop_kind_dict_cache(): - prop_kind = get_property_kind_dict_path_as_dict() - - for prop in prop_kind["PropertyKind"]: - __CACHE_PROP_KIND_DICT__[prop["Uuid"]] = read_energyml_json_str(json.dumps(prop))[0] - - -def get_property_kind_by_uuid(uuid: str) -> Optional[Any]: - """ - Get a property kind by its uuid. - :param uuid: the uuid of the property kind - :return: the property kind or None if not found - """ - if len(__CACHE_PROP_KIND_DICT__) == 0: - # update the cache to check if it is a - try: - update_prop_kind_dict_cache() - except FileNotFoundError as e: - logging.error(f"Failed to parse propertykind dict {e}") - return __CACHE_PROP_KIND_DICT__.get(uuid, None) - - -def get_property_kind_and_parents(uuids: list) -> Dict[str, Any]: - """Get PropertyKind objects and their parents from a list of UUIDs. - - Args: - uuids (list): List of PropertyKind UUIDs. - - Returns: - Dict[str, Any]: A dictionary mapping UUIDs to PropertyKind objects and their parents. - """ - dict_props: Dict[str, Any] = {} - - for prop_uuid in uuids: - prop = get_property_kind_by_uuid(prop_uuid) - if prop is not None: - dict_props[prop_uuid] = prop - parent_uuid = get_object_attribute(prop, "parent.uuid") - if parent_uuid is not None and parent_uuid not in dict_props: - dict_props = get_property_kind_and_parents([parent_uuid]) | dict_props - else: - logging.warning(f"PropertyKind with UUID {prop_uuid} not found.") - continue - return dict_props - - -def as_dor(obj_or_identifier: Any, dor_qualified_type: str = "eml23.DataObjectReference"): - """ - Create an DOR from an object to target the latter. - :param obj_or_identifier: - :param dor_qualified_type: the qualified type of the DOR (e.g. "eml23.DataObjectReference" is the default value) - :return: - """ - dor = None - if obj_or_identifier is not None: - cls = get_class_from_qualified_type(dor_qualified_type) - dor = cls() - if isinstance(obj_or_identifier, str): # is an identifier or uri - parsed_uri = parse_uri(obj_or_identifier) - if parsed_uri is not None: - logging.debug(f"====> parsed uri {parsed_uri} : uuid is {parsed_uri.uuid}") - if hasattr(dor, "qualified_type"): - set_attribute_from_path(dor, "qualified_type", parsed_uri.get_qualified_type()) - if hasattr(dor, "content_type"): - set_attribute_from_path( - dor, "content_type", qualified_type_to_content_type(parsed_uri.get_qualified_type()) - ) - set_attribute_from_path(dor, "uuid", parsed_uri.uuid) - set_attribute_from_path(dor, "uid", parsed_uri.uuid) - if hasattr(dor, "object_version"): - set_attribute_from_path(dor, "object_version", parsed_uri.version) - if hasattr(dor, "version_string"): - set_attribute_from_path(dor, "version_string", parsed_uri.version) - if hasattr(dor, "energistics_uri"): - set_attribute_from_path(dor, "energistics_uri", obj_or_identifier) - - else: # identifier - if len(__CACHE_PROP_KIND_DICT__) == 0: - # update the cache to check if it is a - try: - update_prop_kind_dict_cache() - except FileNotFoundError as e: - logging.error(f"Failed to parse propertykind dict {e}") - try: - uuid, version = split_identifier(obj_or_identifier) - if uuid in __CACHE_PROP_KIND_DICT__: - return as_dor(__CACHE_PROP_KIND_DICT__[uuid]) - else: - set_attribute_from_path(dor, "uuid", uuid) - set_attribute_from_path(dor, "uid", uuid) - set_attribute_from_path(dor, "ObjectVersion", version) - except AttributeError: - logging.error(f"Failed to parse identifier {obj_or_identifier}. DOR will be empty") - else: - if is_dor(obj_or_identifier): - # If it is a dor, we create a dor conversionif hasattr(dor, "qualified_type"): - if hasattr(dor, "qualified_type"): - if hasattr(obj_or_identifier, "qualified_type"): - dor.qualified_type = get_object_attribute(obj_or_identifier, "qualified_type") - elif hasattr(obj_or_identifier, "content_type"): - dor.qualified_type = content_type_to_qualified_type( - get_object_attribute(obj_or_identifier, "content_type") - ) - - if hasattr(dor, "content_type"): - if hasattr(obj_or_identifier, "qualified_type"): - dor.content_type = qualified_type_to_content_type( - get_object_attribute(obj_or_identifier, "qualified_type") - ) - elif hasattr(obj_or_identifier, "content_type"): - dor.content_type = get_object_attribute(obj_or_identifier, "content_type") - - set_attribute_from_path(dor, "title", get_object_attribute(obj_or_identifier, "Title")) - obj_uuid = get_obj_uuid(obj_or_identifier) - set_attribute_from_path(dor, "uuid", obj_uuid) - set_attribute_from_path(dor, "uid", obj_uuid) - if hasattr(dor, "object_version"): - set_attribute_from_path(dor, "object_version", get_obj_version(obj_or_identifier)) - if hasattr(dor, "version_string"): - set_attribute_from_path(dor, "version_string", get_obj_version(obj_or_identifier)) - - else: - - # for etp Resource object: - if hasattr(obj_or_identifier, "uri"): - dor = as_dor(obj_or_identifier.uri, dor_qualified_type) - if hasattr(obj_or_identifier, "name"): - set_attribute_from_path(dor, "title", getattr(obj_or_identifier, "name")) - else: - if hasattr(dor, "qualified_type"): - try: - set_attribute_from_path( - dor, "qualified_type", get_qualified_type_from_class(obj_or_identifier) - ) - except Exception as e: - logging.error(f"Failed to set qualified_type for DOR {e}") - if hasattr(dor, "content_type"): - try: - set_attribute_from_path(dor, "content_type", get_content_type_from_class(obj_or_identifier)) - except Exception as e: - logging.error(f"Failed to set content_type for DOR {e}") - - set_attribute_from_path(dor, "title", get_object_attribute(obj_or_identifier, "Citation.Title")) - obj_uuid = get_obj_uuid(obj_or_identifier) - # logging.debug(f"====> obj uuid is {obj_uuid}") - set_attribute_from_path(dor, "uid", obj_uuid) - set_attribute_from_path(dor, "uuid", obj_uuid) - if hasattr(dor, "object_version"): - set_attribute_from_path(dor, "object_version", get_obj_version(obj_or_identifier)) - if hasattr(dor, "version_string"): - set_attribute_from_path(dor, "version_string", get_obj_version(obj_or_identifier)) - - return dor - - -def create_energyml_object( - content_or_qualified_type: str, - citation: Optional[Any] = None, - uuid: Optional[str] = None, -): - """ - Create an energyml object instance depending on the content-type or qualified-type given in parameter. - The SchemaVersion is automatically assigned. - If no citation is given default one will be used. - If no uuid is given, a random uuid will be used. - :param content_or_qualified_type: - :param citation: - :param uuid: - :return: - """ - if citation is None: - citation = { - "title": "New_Object", - "Creation": epoch_to_date(epoch()), - "LastUpdate": epoch_to_date(epoch()), - "Format": "energyml-utils", - "Originator": "energyml-utils python module", - } - cls = get_class_from_qualified_type(content_or_qualified_type) - obj = cls() - cit = get_obj_attribute_class(cls, "citation")() - copy_attributes( - obj_in=citation, - obj_out=cit, - only_existing_attributes=True, - ignore_case=True, - ) - set_attribute_from_path(obj, "citation", cit) - set_attribute_value(obj, "uuid", uuid or gen_uuid()) - set_attribute_value(obj, "SchemaVersion", get_class_pkg_version(obj)) - - return obj - - -def create_external_part_reference( - eml_version: str, - h5_file_path: str, - citation: Optional[Any] = None, - uuid: Optional[str] = None, -): - """ - Create an EpcExternalPartReference depending on the energyml version (should be ["2.0", "2.1", "2.2"]). - The MimeType, ExistenceKind and Filename will be automatically filled. - :param eml_version: - :param h5_file_path: - :param citation: - :param uuid: - :return: - """ - version_flat = OptimizedRegex.DOMAIN_VERSION.findall(eml_version)[0][0].replace(".", "").replace("_", "") - obj = create_energyml_object( - content_or_qualified_type="eml" + version_flat + ".EpcExternalPartReference", - citation=citation, - uuid=uuid, - ) - set_attribute_value(obj, "MimeType", MimeType.HDF5.value) - set_attribute_value(obj, "ExistenceKind", "Actual") - set_attribute_value(obj, "Filename", h5_file_path) - - return obj - - -def get_reverse_dor_list(obj_list: List[Any], key_func: Callable = get_obj_identifier) -> Dict[str, List[Any]]: - """ - Compute a dict with 'OBJ_UUID.OBJ_VERSION' as Key, and list of DOR that reference it. - If the object version is None, key is 'OBJ_UUID.' - :param obj_list: - :param key_func: a callable to create the key of the dict from the object instance - :return: str - """ - rels = {} - for obj in obj_list: - for dor in search_attribute_matching_type(obj, "DataObjectReference", return_self=False): - key = key_func(dor) - if key not in rels: - rels[key] = [] - rels[key] = rels.get(key, []) + [obj] - return rels - - -# PATHS - +# Backward compatibility: re-export functions that were moved to epc_utils +# This allows existing code that imports these functions from epc.py to continue working +from .epc_utils import ( + update_prop_kind_dict_cache, + get_property_kind_by_uuid, + get_property_kind_and_parents, + as_dor, + create_energyml_object, + create_external_part_reference, + get_reverse_dor_list, + get_file_folder_and_name_from_path, +) -def get_file_folder_and_name_from_path(path: str) -> Tuple[str, str]: - """ - Returns a tuple (FOLDER_PATH, FILE_NAME) - :param path: - :return: - """ - obj_folder = path[: path.rindex("/") + 1] if "/" in path else "" - obj_file_name = path[path.rindex("/") + 1 :] if "/" in path else path - return obj_folder, obj_file_name +# Also export the cache dict for backward compatibility +from .epc_utils import __CACHE_PROP_KIND_DICT__ + +__all__ = [ + "Epc", + "update_prop_kind_dict_cache", + "get_property_kind_by_uuid", + "get_property_kind_and_parents", + "as_dor", + "create_energyml_object", + "create_external_part_reference", + "get_reverse_dor_list", + "get_file_folder_and_name_from_path", + "__CACHE_PROP_KIND_DICT__", +] # def gen_rels_path_from_dor(dor: Any, export_version: EpcExportVersion = EpcExportVersion.CLASSIC) -> str: diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index 06767b8..32fb425 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -60,7 +60,7 @@ file_extension_to_mime_type, date_to_datetime, ) -from energyml.utils.epc import ( +from energyml.utils.epc_utils import ( gen_energyml_object_path, get_epc_content_type_path, gen_core_props_path, diff --git a/energyml-utils/src/energyml/utils/epc_utils.py b/energyml-utils/src/energyml/utils/epc_utils.py index 6df94fb..c19cf75 100644 --- a/energyml-utils/src/energyml/utils/epc_utils.py +++ b/energyml-utils/src/energyml/utils/epc_utils.py @@ -3,8 +3,9 @@ from io import BytesIO +import json import logging -from typing import Optional, Tuple, Union, Any, List, Dict +from typing import Optional, Tuple, Union, Any, List, Dict, Callable from pathlib import Path import zipfile @@ -29,6 +30,11 @@ extract_uuid_from_string, gen_uuid, MimeType, + OptimizedRegex, + split_identifier, + content_type_to_qualified_type, + qualified_type_to_content_type, + get_property_kind_dict_path_as_dict, ) from energyml.utils.introspection import ( get_dor_obj_info, @@ -37,9 +43,19 @@ get_class_pkg_version, get_obj_version, get_obj_uuid, + get_obj_identifier, + get_object_attribute, + search_attribute_matching_type, + get_class_from_qualified_type, + set_attribute_from_path, + set_attribute_value, + get_obj_attribute_class, + copy_attributes, + get_content_type_from_class, + get_qualified_type_from_class, ) from energyml.utils.manager import get_class_pkg -from energyml.utils.serialization import read_energyml_xml_str, serialize_xml +from energyml.utils.serialization import read_energyml_xml_str, serialize_xml, read_energyml_json_str from energyml.utils.uri import Uri, parse_uri @@ -304,3 +320,298 @@ def repair_epc_structure_if_not_valid(epc: Union[str, Path, zipfile.ZipFile, Byt if not valdiate_basic_epc_structure(epc): logging.warning("EPC structure validation failed. Attempting auto-repair.") create_mandatory_structure_epc(epc) + + +# ____ __ __ __ _ __ +# / __ \_________ ____ ___ ____/ /___ __ / //_/(_)___ ____/ /____ +# / /_/ / ___/ __ \/ __ \/ _ \/ __ / __ \/ / / ,< / / __ \/ __ / ___/ +# / ____/ / / /_/ / /_/ / __/ /_/ / /_/ / / / /| |/ / / / / /_/ (__ ) +# /_/ /_/ \____/ .___/\___/\__,_/\__, /_/ /_/ |_/_/_/ /_/\__,_/____/ +# /_/ /____/ + +""" +PropertyKind list: a list of Pre-defined properties +""" +__CACHE_PROP_KIND_DICT__ = {} + + +def update_prop_kind_dict_cache(): + """Update the property kind dictionary cache from the standard property kinds file.""" + prop_kind = get_property_kind_dict_path_as_dict() + + for prop in prop_kind["PropertyKind"]: + __CACHE_PROP_KIND_DICT__[prop["Uuid"]] = read_energyml_json_str(json.dumps(prop))[0] + + +def get_property_kind_by_uuid(uuid: str) -> Optional[Any]: + """ + Get a property kind by its uuid. + :param uuid: the uuid of the property kind + :return: the property kind or None if not found + """ + if len(__CACHE_PROP_KIND_DICT__) == 0: + # update the cache to check if it is a + try: + update_prop_kind_dict_cache() + except FileNotFoundError as e: + logging.error(f"Failed to parse propertykind dict {e}") + return __CACHE_PROP_KIND_DICT__.get(uuid, None) + + +def get_property_kind_and_parents(uuids: list) -> Dict[str, Any]: + """Get PropertyKind objects and their parents from a list of UUIDs. + + Args: + uuids (list): List of PropertyKind UUIDs. + + Returns: + Dict[str, Any]: A dictionary mapping UUIDs to PropertyKind objects and their parents. + """ + dict_props: Dict[str, Any] = {} + + for prop_uuid in uuids: + prop = get_property_kind_by_uuid(prop_uuid) + if prop is not None: + dict_props[prop_uuid] = prop + parent_uuid = get_object_attribute(prop, "parent.uuid") + if parent_uuid is not None and parent_uuid not in dict_props: + dict_props = get_property_kind_and_parents([parent_uuid]) | dict_props + else: + logging.warning(f"PropertyKind with UUID {prop_uuid} not found.") + continue + return dict_props + + +# ____ ____ ____ ______ __ _ +# / __ \/ __ \/ __ \ / ____/_______ ____ _/ /_(_)___ ____ +# / / / / / / / /_/ / / / / ___/ _ \/ __ `/ __/ / __ \/ __ \ +# / /_/ / /_/ / _, _/ / /___/ / / __/ /_/ / /_/ / /_/ / / / / +# /_____/\____/_/ |_| \____/_/ \___/\__,_/\__/_/\____/_/ /_/ + + +def as_dor(obj_or_identifier: Any, dor_qualified_type: str = "eml23.DataObjectReference"): + """ + Create a DOR (Data Object Reference) from an object to target the latter. + :param obj_or_identifier: an energyml object, identifier string, or URI + :param dor_qualified_type: the qualified type of the DOR (e.g. "eml23.DataObjectReference" is the default value) + :return: a DOR object + """ + dor = None + if obj_or_identifier is not None: + cls = get_class_from_qualified_type(dor_qualified_type) + dor = cls() + if isinstance(obj_or_identifier, str): # is an identifier or uri + parsed_uri = parse_uri(obj_or_identifier) + if parsed_uri is not None: + logging.debug(f"====> parsed uri {parsed_uri} : uuid is {parsed_uri.uuid}") + if hasattr(dor, "qualified_type"): + set_attribute_from_path(dor, "qualified_type", parsed_uri.get_qualified_type()) + if hasattr(dor, "content_type"): + set_attribute_from_path( + dor, "content_type", qualified_type_to_content_type(parsed_uri.get_qualified_type()) + ) + set_attribute_from_path(dor, "uuid", parsed_uri.uuid) + set_attribute_from_path(dor, "uid", parsed_uri.uuid) + if hasattr(dor, "object_version"): + set_attribute_from_path(dor, "object_version", parsed_uri.version) + if hasattr(dor, "version_string"): + set_attribute_from_path(dor, "version_string", parsed_uri.version) + if hasattr(dor, "energistics_uri"): + set_attribute_from_path(dor, "energistics_uri", obj_or_identifier) + + else: # identifier + if len(__CACHE_PROP_KIND_DICT__) == 0: + # update the cache to check if it is a + try: + update_prop_kind_dict_cache() + except FileNotFoundError as e: + logging.error(f"Failed to parse propertykind dict {e}") + try: + uuid, version = split_identifier(obj_or_identifier) + if uuid in __CACHE_PROP_KIND_DICT__: + return as_dor(__CACHE_PROP_KIND_DICT__[uuid]) + else: + set_attribute_from_path(dor, "uuid", uuid) + set_attribute_from_path(dor, "uid", uuid) + set_attribute_from_path(dor, "ObjectVersion", version) + except AttributeError: + logging.error(f"Failed to parse identifier {obj_or_identifier}. DOR will be empty") + else: + if is_dor(obj_or_identifier): + # If it is a dor, we create a dor conversion + if hasattr(dor, "qualified_type"): + if hasattr(obj_or_identifier, "qualified_type"): + dor.qualified_type = get_object_attribute(obj_or_identifier, "qualified_type") + elif hasattr(obj_or_identifier, "content_type"): + dor.qualified_type = content_type_to_qualified_type( + get_object_attribute(obj_or_identifier, "content_type") + ) + + if hasattr(dor, "content_type"): + if hasattr(obj_or_identifier, "qualified_type"): + dor.content_type = qualified_type_to_content_type( + get_object_attribute(obj_or_identifier, "qualified_type") + ) + elif hasattr(obj_or_identifier, "content_type"): + dor.content_type = get_object_attribute(obj_or_identifier, "content_type") + + set_attribute_from_path(dor, "title", get_object_attribute(obj_or_identifier, "Title")) + obj_uuid = get_obj_uuid(obj_or_identifier) + set_attribute_from_path(dor, "uuid", obj_uuid) + set_attribute_from_path(dor, "uid", obj_uuid) + if hasattr(dor, "object_version"): + set_attribute_from_path(dor, "object_version", get_obj_version(obj_or_identifier)) + if hasattr(dor, "version_string"): + set_attribute_from_path(dor, "version_string", get_obj_version(obj_or_identifier)) + + else: + + # for etp Resource object: + if hasattr(obj_or_identifier, "uri"): + dor = as_dor(obj_or_identifier.uri, dor_qualified_type) + if hasattr(obj_or_identifier, "name"): + set_attribute_from_path(dor, "title", getattr(obj_or_identifier, "name")) + else: + if hasattr(dor, "qualified_type"): + try: + set_attribute_from_path( + dor, "qualified_type", get_qualified_type_from_class(obj_or_identifier) + ) + except Exception as e: + logging.error(f"Failed to set qualified_type for DOR {e}") + if hasattr(dor, "content_type"): + try: + set_attribute_from_path(dor, "content_type", get_content_type_from_class(obj_or_identifier)) + except Exception as e: + logging.error(f"Failed to set content_type for DOR {e}") + + set_attribute_from_path(dor, "title", get_object_attribute(obj_or_identifier, "Citation.Title")) + obj_uuid = get_obj_uuid(obj_or_identifier) + # logging.debug(f"====> obj uuid is {obj_uuid}") + set_attribute_from_path(dor, "uid", obj_uuid) + set_attribute_from_path(dor, "uuid", obj_uuid) + if hasattr(dor, "object_version"): + set_attribute_from_path(dor, "object_version", get_obj_version(obj_or_identifier)) + if hasattr(dor, "version_string"): + set_attribute_from_path(dor, "version_string", get_obj_version(obj_or_identifier)) + + return dor + + +# ____ __ _ __ ______ __ _ +# / __ \/ /_ (_)__ _____/ /_ / ____/_______ ____ _/ /_(_)___ ____ +# / / / / __ \ / / _ \/ ___/ __/ / / / ___/ _ \/ __ `/ __/ / __ \/ __ \ +# / /_/ / /_/ // / __/ /__/ /_ / /___/ / / __/ /_/ / /_/ / /_/ / / / / +# \____/_.___// /\___/\___/\__/ \____/_/ \___/\__,_/\__/_/\____/_/ /_/ +# /___/ + + +def create_energyml_object( + content_or_qualified_type: str, + citation: Optional[Any] = None, + uuid: Optional[str] = None, +): + """ + Create an energyml object instance depending on the content-type or qualified-type given in parameter. + The SchemaVersion is automatically assigned. + If no citation is given default one will be used. + If no uuid is given, a random uuid will be used. + :param content_or_qualified_type: + :param citation: + :param uuid: + :return: + """ + if citation is None: + citation = { + "title": "New_Object", + "Creation": epoch_to_date(epoch()), + "LastUpdate": epoch_to_date(epoch()), + "Format": "energyml-utils", + "Originator": "energyml-utils python module", + } + cls = get_class_from_qualified_type(content_or_qualified_type) + obj = cls() + cit = get_obj_attribute_class(cls, "citation")() + copy_attributes( + obj_in=citation, + obj_out=cit, + only_existing_attributes=True, + ignore_case=True, + ) + set_attribute_from_path(obj, "citation", cit) + set_attribute_value(obj, "uuid", uuid or gen_uuid()) + set_attribute_value(obj, "SchemaVersion", get_class_pkg_version(obj)) + + return obj + + +def create_external_part_reference( + eml_version: str, + h5_file_path: str, + citation: Optional[Any] = None, + uuid: Optional[str] = None, +): + """ + Create an EpcExternalPartReference depending on the energyml version (should be ["2.0", "2.1", "2.2"]). + The MimeType, ExistenceKind and Filename will be automatically filled. + :param eml_version: + :param h5_file_path: + :param citation: + :param uuid: + :return: + """ + version_flat = OptimizedRegex.DOMAIN_VERSION.findall(eml_version)[0][0].replace(".", "").replace("_", "") + obj = create_energyml_object( + content_or_qualified_type="eml" + version_flat + ".EpcExternalPartReference", + citation=citation, + uuid=uuid, + ) + set_attribute_value(obj, "MimeType", MimeType.HDF5.value) + set_attribute_value(obj, "ExistenceKind", "Actual") + set_attribute_value(obj, "Filename", h5_file_path) + + return obj + + +# ____ __ __ _ __ _ +# / __ \___ / /___ _/ /_(_)___ ____ ___/ /_ (_)___ _____ +# / /_/ / _ \/ / __ `/ __/ / __ \/ __ \/ __ / / / / __ \/ ___/ +# / _, _/ __/ / /_/ / /_/ / /_/ / / / / /_/ / /_/ / /_/ (__ ) +# /_/ |_|\___/_/\__,_/\__/_/\____/_/ /_/\__,_/\__,_/ .___/____/ +# /_/ + + +def get_reverse_dor_list(obj_list: List[Any], key_func: Callable = get_obj_identifier) -> Dict[str, List[Any]]: + """ + Compute a dict with 'OBJ_UUID.OBJ_VERSION' as Key, and list of DOR that reference it. + If the object version is None, key is 'OBJ_UUID.' + :param obj_list: + :param key_func: a callable to create the key of the dict from the object instance + :return: str + """ + rels = {} + for obj in obj_list: + for dor in search_attribute_matching_type(obj, "DataObjectReference", return_self=False): + key = key_func(dor) + if key not in rels: + rels[key] = [] + rels[key] = rels.get(key, []) + [obj] + return rels + + +# ____ ___ ________ ______ +# / __ \/ |/_ __/ / / / ___/ +# / /_/ / /| | / / / /_/ /\__ \ +# / ____/ ___ |/ / / __ /___/ / +# /_/ /_/ |_/_/ /_/ /_//____/ + + +def get_file_folder_and_name_from_path(path: str) -> Tuple[str, str]: + """ + Returns a tuple (FOLDER_PATH, FILE_NAME) + :param path: + :return: + """ + obj_folder = path[: path.rindex("/") + 1] if "/" in path else "" + obj_file_name = path[path.rindex("/") + 1 :] if "/" in path else path + return obj_folder, obj_file_name diff --git a/energyml-utils/src/energyml/utils/validation.py b/energyml-utils/src/energyml/utils/validation.py index 6420573..dad4341 100644 --- a/energyml-utils/src/energyml/utils/validation.py +++ b/energyml-utils/src/energyml/utils/validation.py @@ -7,8 +7,10 @@ from typing import Any, Dict, List, Optional, Union from .epc import ( - get_obj_identifier, Epc, +) +from .epc_utils import ( + get_obj_identifier, get_property_kind_by_uuid, ) from .introspection import ( diff --git a/energyml-utils/tests/test_epc_stream.py b/energyml-utils/tests/test_epc_stream.py index e93dcbf..39f41ca 100644 --- a/energyml-utils/tests/test_epc_stream.py +++ b/energyml-utils/tests/test_epc_stream.py @@ -27,7 +27,7 @@ from energyml.opc.opc import Relationships from energyml.utils.epc_stream import EpcStreamReader, RelsUpdateMode -from energyml.utils.epc import create_energyml_object, as_dor, get_obj_identifier +from energyml.utils.epc_utils import as_dor, get_obj_identifier from energyml.utils.introspection import ( epoch_to_date, epoch, From 52a0b1855e58679b8cda485fe74eeae6d46dfe38 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Thu, 12 Feb 2026 04:26:46 +0100 Subject: [PATCH 27/70] bugfix for validation --- .../src/energyml/utils/introspection.py | 38 +++++++++++-------- energyml-utils/src/energyml/utils/manager.py | 5 +++ .../src/energyml/utils/validation.py | 33 +++++++++++++++- 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/energyml-utils/src/energyml/utils/introspection.py b/energyml-utils/src/energyml/utils/introspection.py index f962ebd..c165a81 100644 --- a/energyml-utils/src/energyml/utils/introspection.py +++ b/energyml-utils/src/energyml/utils/introspection.py @@ -1130,13 +1130,14 @@ def copy_attributes( # Utility functions -def get_obj_uuid(obj: Any) -> str: +def get_obj_uuid(obj: Any) -> Optional[str]: """ Return the object uuid (attribute must match the following regex : "[Uu]u?id|UUID"). :param obj: :return: """ - return get_object_attribute_rgx(obj, "[Uu]u?id|UUID") + return getattr(obj, "uuid", None) or getattr(obj, "uid", None) + # return get_object_attribute_rgx(obj, "[Uu]u?id|UUID") def get_obj_version(obj: Any) -> Optional[str]: @@ -1146,20 +1147,21 @@ def get_obj_version(obj: Any) -> Optional[str]: :return: """ try: - return get_object_attribute_no_verif(obj, "object_version") + return ( + getattr(obj, "object_version", None) + or getattr(obj, "version_string", None) + or (getattr(obj, "citation", None) and getattr(obj.citation, "version_string", None)) + ) except AttributeError: - # AttributeError is expected when attribute doesn't exist - try alternative - try: - return get_object_attribute_no_verif(obj, "version_string") - except Exception: - # Log with full call stack to see WHO called this function - logging.error( - f"Error getting version for {type(obj)} -- {obj}", - exc_info=True, - stack_info=True, # This shows the full call stack including caller - ) - return None - # raise e + # Log with full call stack to see WHO called this function + # logging.error( + # f"Error getting version for {type(obj)} -- {obj}", + # exc_info=True, + # stack_info=True, # This shows the full call stack including caller + # ) + pass + return None + # raise e def get_obj_title(obj: Any) -> Optional[str]: @@ -1169,7 +1171,8 @@ def get_obj_title(obj: Any) -> Optional[str]: :return: """ try: - return get_object_attribute_advanced(obj, "citation.title") + return getattr(obj, "citation", None) and getattr(obj.citation, "title", None) + # return get_object_attribute_advanced(obj, "citation.title") except AttributeError: return None @@ -1430,6 +1433,9 @@ def get_content_type_from_class(cls: Union[type, Any], print_dev_version=True, n def get_object_type_for_file_path_from_class(cls) -> str: + """ + Return the object type to use in file path or content type. It is not always the same as the class name, for example for resqml201, the class "TriangulatedSetRepresentation" has to be written "obj_TriangulatedSetRepresentation" in file path and content type. + """ if not isinstance(cls, type): cls = type(cls) classic_type = get_obj_type(cls) diff --git a/energyml-utils/src/energyml/utils/manager.py b/energyml-utils/src/energyml/utils/manager.py index 10644ad..51e20bb 100644 --- a/energyml-utils/src/energyml/utils/manager.py +++ b/energyml-utils/src/energyml/utils/manager.py @@ -182,6 +182,11 @@ def get_class_pkg(cls): return match.group("pkg") # type: ignore except AttributeError as e: logging.error(f"Exception to get class package for '{cls}'") + logging.error( + f"Error getting package for {type(cls)} -- {cls}", + exc_info=True, + stack_info=True, # This shows the full call stack including caller + ) raise e diff --git a/energyml-utils/src/energyml/utils/validation.py b/energyml-utils/src/energyml/utils/validation.py index dad4341..a7a1884 100644 --- a/energyml-utils/src/energyml/utils/validation.py +++ b/energyml-utils/src/energyml/utils/validation.py @@ -1,5 +1,6 @@ # Copyright (c) 2023-2024 Geosiris. # SPDX-License-Identifier: Apache-2.0 +import logging import re from dataclasses import dataclass, field, Field from enum import Enum @@ -203,6 +204,8 @@ def dor_validation_object( _msg=f"[DOR ERR] has wrong information. Unknown object with uuid '{dor_uuid}'", ) ) + + object_version_list_failed = False if target_uuid is not None and target_identifier is None: accessible_version = [get_obj_version(ref_obj) for ref_obj in dict_obj_uuid[dor_uuid]] errs.append( @@ -214,6 +217,7 @@ def dor_validation_object( f"Version must be one of {accessible_version}", ) ) + object_version_list_failed = True if target_prop is not None and target_uuid is None: errs.append( @@ -226,6 +230,32 @@ def dor_validation_object( ) target = target_identifier or target_uuid or target_prop + + # debug + if isinstance(target, list): + # logging.error( + # f"Multiple objects found with uuid '{dor_uuid}' for DOR in object '{get_obj_identifier(obj)}' at path '{dor_path}'. This should not happen and can lead to wrong validation results.", + # exc_info=True, + # stack_info=True, # This shows the full call stack including caller + # ) + # logging.error( + # f'\t{target} => Object ct and qt {get_object_attribute_rgx(dor, "content_type")} : {get_object_attribute_rgx(dor, "qualified_type")}' + # ) + if len(target) == 0: + target = None + else: + if len(target) > 1: + errs.append( + ValidationObjectError( + error_type=ErrorType.WARNING, + target_obj=obj, + attribute_dot_path=dor_path, + _msg=f"[DOR ERR] Multiple objects found with uuid '{dor_uuid}' for DOR in object '{get_obj_identifier(obj)}' at path '{dor_path}'. This should not happen and can lead to wrong validation results.", + ) + ) + target = target[0] + + # ==== if target is not None: # target = dict_obj_identifier[dor_target_id] target_title = get_object_attribute_rgx(target, "citation.title") @@ -267,7 +297,8 @@ def dor_validation_object( ) ) - if target_version != dor_version: + if not object_version_list_failed and target_version != dor_version: + # checking object_version_list_failed to avoid multiple version errors errs.append( ValidationObjectError( error_type=ErrorType.WARNING, From fe988856f543fc7400cc808cb56bd20dca229395 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Thu, 12 Feb 2026 05:59:12 +0100 Subject: [PATCH 28/70] better instrospection unit tests --- .../src/energyml/utils/introspection.py | 49 +- energyml-utils/tests/test_introspection.py | 779 +++++++++++++++--- 2 files changed, 709 insertions(+), 119 deletions(-) diff --git a/energyml-utils/src/energyml/utils/introspection.py b/energyml-utils/src/energyml/utils/introspection.py index c165a81..9eca2a4 100644 --- a/energyml-utils/src/energyml/utils/introspection.py +++ b/energyml-utils/src/energyml/utils/introspection.py @@ -281,6 +281,9 @@ def get_module_name(domain: str, domain_version: str): ns = ENERGYML_NAMESPACES[domain] if not domain_version.startswith("v"): domain_version = "v" + domain_version + + if "." in domain_version: + domain_version = domain_version.replace(".", "_") return f"energyml.{domain}.{domain_version}.{ns[ns.rindex('/') + 1:]}" @@ -368,6 +371,9 @@ def get_class_attributes(cls: Union[type, Any]) -> List[str]: def get_class_attribute_type(cls: Union[type, Any], attribute_name: str): + """ + Return the type of an attribute of a class. + """ fields = get_class_fields(cls) try: return fields[attribute_name].type @@ -430,7 +436,7 @@ def get_object_attribute(obj: Any, attr_dot_path: str, force_snake_case=True) -> :param obj: :param attr_dot_path: - :param force_snake_case: + :param force_snake_case: if True, the method will try to find the attribute name in snake case (only for class attribute, not for dict keys nor list index) :return: """ current_attrib_name, path_next = path_next_attribute(attr_dot_path) @@ -439,9 +445,6 @@ def get_object_attribute(obj: Any, attr_dot_path: str, force_snake_case=True) -> logging.error(f"Attribute path '{attr_dot_path}' is invalid.") return None - if force_snake_case: - current_attrib_name = snake_case(current_attrib_name) - value = None if isinstance(obj, list): value = obj[int(current_attrib_name)] @@ -449,6 +452,8 @@ def get_object_attribute(obj: Any, attr_dot_path: str, force_snake_case=True) -> value = obj.get(current_attrib_name, None) else: try: + if force_snake_case: + current_attrib_name = snake_case(current_attrib_name) value = getattr(obj, current_attrib_name) except AttributeError: return None @@ -960,7 +965,7 @@ def search_attribute_matching_name_with_path( re_flags=re_flags, current_path=matched_path, deep_search=deep_search, # no deep with partial - search_in_sub_obj=True, + search_in_sub_obj=search_in_sub_obj, ) if search_in_sub_obj: for not_matched_path, not_matched in not_match_path_and_obj: @@ -989,8 +994,8 @@ def search_attribute_matching_name( :param obj: :param name_rgx: :param re_flags: - :param deep_search: - :param search_in_sub_obj: + :param deep_search: if True, the method will search for matching attribute in the sub attributes of a matching attribute (recursive search). If False, only the first level of attributes will be searched for a match. + :param search_in_sub_obj: if True, the method will search for matching attribute in the sub attributes of a non-matching attribute (recursive search). If False, only the first level of attributes will be searched for a match. :return: """ return [ @@ -1136,7 +1141,14 @@ def get_obj_uuid(obj: Any) -> Optional[str]: :param obj: :return: """ - return getattr(obj, "uuid", None) or getattr(obj, "uid", None) + try: + return getattr(obj, "uuid", None) or getattr(obj, "uid") + except AttributeError: + if isinstance(obj, dict): + for k in obj.keys(): + if re.match(r"[Uu]u?id|UUID", k): + return obj[k] + return None # return get_object_attribute_rgx(obj, "[Uu]u?id|UUID") @@ -1150,7 +1162,7 @@ def get_obj_version(obj: Any) -> Optional[str]: return ( getattr(obj, "object_version", None) or getattr(obj, "version_string", None) - or (getattr(obj, "citation", None) and getattr(obj.citation, "version_string", None)) + or getattr(getattr(obj, "citation"), "version_string", None) ) except AttributeError: # Log with full call stack to see WHO called this function @@ -1159,6 +1171,14 @@ def get_obj_version(obj: Any) -> Optional[str]: # exc_info=True, # stack_info=True, # This shows the full call stack including caller # ) + if isinstance(obj, dict): + for k in obj.keys(): + if re.match(r"object_version|version_string", k, re.IGNORECASE): + return obj[k] + elif re.match(r"citation", k, re.IGNORECASE) and isinstance(obj[k], dict): + for ck in obj[k].keys(): + if re.match(r"version_string", ck, re.IGNORECASE): + return obj[k][ck] pass return None # raise e @@ -1171,10 +1191,15 @@ def get_obj_title(obj: Any) -> Optional[str]: :return: """ try: - return getattr(obj, "citation", None) and getattr(obj.citation, "title", None) - # return get_object_attribute_advanced(obj, "citation.title") + return getattr(getattr(obj, "citation"), "title", None) except AttributeError: - return None + if isinstance(obj, dict): + for k in obj.keys(): + if re.match(r"citation", k, re.IGNORECASE) and isinstance(obj[k], dict): + for ck in obj[k].keys(): + if re.match(r"title", ck, re.IGNORECASE): + return obj[k][ck] + return None def get_obj_pkg_pkgv_type_uuid_version( diff --git a/energyml-utils/tests/test_introspection.py b/energyml-utils/tests/test_introspection.py index 9c674c8..8659634 100644 --- a/energyml-utils/tests/test_introspection.py +++ b/energyml-utils/tests/test_introspection.py @@ -1,10 +1,30 @@ # Copyright (c) 2023-2024 Geosiris. # SPDX-License-Identifier: Apache-2.0 +""" +Test Suite for energyml.utils.introspection module + +This module contains comprehensive tests for introspection utilities used to +inspect, manipulate, and extract information from Energyml objects. +""" +from dataclasses import dataclass +from energyml.utils.epc_utils import MimeType, as_dor +import pytest +from typing import Any + import energyml.resqml.v2_0_1.resqmlv2 from energyml.eml.v2_0.commonv2 import Citation as Citation20 -from energyml.eml.v2_3.commonv2 import Citation +from energyml.eml.v2_3.commonv2 import Citation, ExternalDataArrayPart, DataObjectReference from energyml.resqml.v2_0_1.resqmlv2 import FaultInterpretation -from energyml.resqml.v2_2.resqmlv2 import TriangulatedSetRepresentation +from energyml.resqml.v2_2.resqmlv2 import ( + TriangulatedSetRepresentation, + TrianglePatch, + ContactElement, + IntegerExternalArray, + ExternalDataArray, + PointGeometry, + Point3DExternalArray, + AbstractPoint3DArray, +) from energyml.opc.opc import Dcmitype1, Contributor from src.energyml.utils.constants import ( @@ -13,23 +33,197 @@ epoch, epoch_to_date, snake_case, + gen_uuid, ) from src.energyml.utils.introspection import ( is_primitive, is_enum, + is_abstract, get_class_from_name, get_class_from_content_type, + get_class_from_qualified_type, get_object_attribute, + get_object_attribute_no_verif, + get_object_attribute_rgx, + get_object_attribute_advanced, set_attribute_from_path, copy_attributes, get_obj_identifier, + get_obj_uuid, + get_obj_version, + get_obj_title, get_obj_pkg_pkgv_type_uuid_version, get_obj_uri, - gen_uuid, + get_obj_type, + get_obj_qualified_type, + get_obj_content_type, + get_class_methods, + get_class_fields, + get_class_attributes, + get_class_attribute_type, + get_matching_class_attribute_name, + search_attribute_matching_name, + search_attribute_matching_type, + set_attribute_from_json_str, + set_attribute_from_dict, + get_module_name, + class_match_rgx, + is_dor, + get_dor_obj_info, + get_direct_dor_list, ) +# ============================================================================= +# TEST FIXTURES - Reusable test data +# ============================================================================= + +# Sample nested dictionary for attribute access tests +SAMPLE_NESTED_DICT = {"a": {"b": ["v_x", {"c": "v_test"}]}} + +# Sample data for copy_attributes tests +SAMPLE_DATA_IN = { + "a": {"b": "v_0", "c": "v_1"}, + "uuid": "215f8219-cabd-4e24-9e4f-e371cabc9622", + "objectVersion": "Resqml 2.0", + "non_existing": 42, +} + +SAMPLE_DATA_OUT_TEMPLATE = { + "a": None, + "Uuid": "8291afd6-ae01-49f5-bc96-267e7b27450d", + "object_version": "Resqml 2.0", +} + + +@pytest.fixture +def citation_v20(): + """Create a Citation v2.0 object for testing.""" + return Citation20( + title="Test Citation v2.0", + originator="Valentin", + creation=epoch_to_date(epoch()), + editor="test", + format="Geosiris", + last_update=epoch_to_date(epoch()), + ) + + +@pytest.fixture +def citation_v23(): + """Create a Citation v2.3 object for testing.""" + return Citation( + title="Test Citation v2.3", + originator="Valentin", + creation=epoch_to_date(epoch()), + editor="test", + format="Geosiris", + last_update=epoch_to_date(epoch()), + ) + + +@pytest.fixture +def fault_interpretation(citation_v20): + """Create a FaultInterpretation (resqml 2.0.1) object for testing.""" + return FaultInterpretation( + citation=citation_v20, + uuid=gen_uuid(), + object_version="0", + ) + + +@pytest.fixture +def triangulated_set_no_version(citation_v23, fault_interpretation): + """Create a TriangulatedSetRepresentation (resqml 2.2) without version.""" + trset_uuid = gen_uuid() + return TriangulatedSetRepresentation( + citation=citation_v23, + uuid=trset_uuid, + represented_object=as_dor(fault_interpretation), + triangle_patch=[ + TrianglePatch( + node_count=3, + triangles=IntegerExternalArray( + values=ExternalDataArray( + external_data_array_part=[ + ExternalDataArrayPart( + count=[6], + path_in_external_file=f"/RESQML/{trset_uuid}/triangles", + uri="samplefile_uri.h5", + mime_type=str(MimeType.HDF5), + ) + ] + ) + ), + geometry=PointGeometry( + points=Point3DExternalArray( + coordinates=ExternalDataArray( + external_data_array_part=[ + ExternalDataArrayPart( + count=[9], + path_in_external_file=f"/RESQML/{trset_uuid}/points", + uri="samplefile_uri.h5", + mime_type=str(MimeType.HDF5), + ) + ] + ) + ), + ), + ) + ], + ) + + +@pytest.fixture +def triangulated_set_versioned(citation_v23, fault_interpretation): + """Create a TriangulatedSetRepresentation (resqml 2.2) with version.""" + trset_uuid = gen_uuid() + return TriangulatedSetRepresentation( + citation=citation_v23, + uuid=trset_uuid, + represented_object=as_dor(fault_interpretation), + object_version="3", + triangle_patch=[ + TrianglePatch( + node_count=3, + triangles=IntegerExternalArray( + values=ExternalDataArray( + external_data_array_part=[ + ExternalDataArrayPart( + count=[6], + path_in_external_file=f"/RESQML/{trset_uuid}/triangles", + uri="samplefile_uri.h5", + mime_type=str(MimeType.HDF5), + ) + ] + ) + ), + geometry=PointGeometry( + points=Point3DExternalArray( + coordinates=ExternalDataArray( + external_data_array_part=[ + ExternalDataArrayPart( + count=[9], + path_in_external_file=f"/RESQML/{trset_uuid}/points", + uri="samplefile_uri.h5", + mime_type=str(MimeType.HDF5), + ) + ] + ) + ), + ), + ) + ], + ) + + +# ============================================================================= +# TYPE CHECKING TESTS +# ============================================================================= + + def test_is_primitive(): + """Test identification of primitive types.""" assert is_primitive(1) assert is_primitive(int) assert is_primitive(float) @@ -41,22 +235,33 @@ def test_is_primitive(): def test_is_enum(): + """Test identification of Enum types.""" assert is_enum(Dcmitype1) assert not is_enum(Contributor) assert not is_enum(int) -def test_get_class_from_name(): - assert get_class_from_name("energyml.opc.opc.Dcmitype1") == Dcmitype1 +def test_is_abstract(): + """Test identification of abstract classes.""" + + assert is_abstract(AbstractPoint3DArray) + assert not is_abstract(Point3DExternalArray) + + +# ============================================================================= +# STRING CASE CONVERSION TESTS +# ============================================================================= def test_snake_case(): + """Test conversion to snake_case.""" assert snake_case("ThisIsASnakecase") == "this_is_a_snakecase" assert snake_case("This_IsASnakecase") == "this_is_a_snakecase" assert snake_case("This_isASnakecase") == "this_is_a_snakecase" def test_pascal_case(): + """Test conversion to PascalCase.""" assert pascal_case("ThisIsASnakecase") == "ThisIsASnakecase" assert pascal_case("This_IsASnakecase") == "ThisIsASnakecase" assert pascal_case("This_isASnakecase") == "ThisIsASnakecase" @@ -64,23 +269,206 @@ def test_pascal_case(): def test_epoch(): + """Test epoch time conversion utilities.""" now = epoch() assert date_to_epoch(epoch_to_date(now)) == now +# ============================================================================= +# CLASS RESOLUTION TESTS +# ============================================================================= + + +def test_get_class_from_name(): + """Test class resolution from fully qualified name.""" + assert get_class_from_name("energyml.opc.opc.Dcmitype1") == Dcmitype1 + + def test_get_class_from_content_type(): + """Test class resolution from content type string.""" found_type = get_class_from_content_type("resqml20.obj_Grid2dRepresentation") assert found_type is not None assert found_type == energyml.resqml.v2_0_1.resqmlv2.Grid2DRepresentation +def test_get_class_from_qualified_type(): + """Test class resolution from qualified type string. + + According to the docstring: Return a type object matching with the qualified-type. + This is similar to get_class_from_content_type. + """ + assert get_class_from_qualified_type("resqml22.TriangulatedSetRepresentation") == TriangulatedSetRepresentation + assert get_class_from_qualified_type("resqml20.obj_FaultInterpretation") == FaultInterpretation + + +def test_get_module_name(): + """Test module name generation from domain and version. + + According to the function signature: get_module_name(domain: str, domain_version: str) + """ + assert get_module_name("resqml", "2.0") == "energyml.resqml.v2_0.resqmlv2" + assert get_module_name("eml", "2.3") == "energyml.eml.v2_3.commonv2" + assert get_module_name("eml", "2.0") == "energyml.eml.v2_0.commonv2" + assert get_module_name("witsml", "1.0") == "energyml.witsml.v1_0.witsmlv2" + + +# ============================================================================= +# CLASS INTROSPECTION TESTS +# ============================================================================= + + +def test_get_class_methods(): + """Test retrieval of class methods. + + According to the docstring: Returns the list of the methods names for a specific class. + """ + + class SampleClass: + def method_one(self): + pass + + def method_two(self): + pass + + def __str__(self): + return "SampleClass" + + methods = get_class_methods(SampleClass) + assert isinstance(methods, list) + # Methods should not include dunder methods or types + for method in methods: + assert not method.startswith("__") + + assert len(methods) == 2 + assert "method_one" in methods + assert "method_two" in methods + + +def test_get_class_fields(): + """Test retrieval of class fields. + + According to the docstring: Return all class fields names, mapped to their Field value. + If a dict is given, this function is the identity. + """ + # Test with dict (identity function) + test_dict = {"a": 1, "b": 2} + assert get_class_fields(test_dict) == test_dict + + # Test with actual class + fields = get_class_fields(Citation) + + official_fields = { + "title", + "originator", + "creation", + "format", + "editor", + "last_update", + "description", + "editor_history", + "descriptive_keywords", + } + + assert isinstance(fields, dict) + # Should contain expected fields assert "title" in fields + assert len(fields) == len(official_fields) + assert set(fields.keys()) == official_fields + + +def test_get_class_attributes(): + """Test retrieval of class attributes. + + According to the docstring: returns a list of attributes (not private ones). + """ + + class SampleClass: + class_attr = "value" + _private_attr = "private" + + def __init__(self): + self.additional_attr = "additional" + + def method_one(self): + pass + + attributes = get_class_attributes(SampleClass) + assert isinstance(attributes, list) + + assert len(attributes) == 1 + assert "class_attr" in attributes + + +def test_get_class_attribute_type(): + """Test retrieval of attribute type from class.""" + citation_title_type = get_class_attribute_type(Citation, "title") + assert str(citation_title_type) == "Optional[str]" + + citation_editor_history_type = get_class_attribute_type(Citation, "editor_history") + assert str(citation_editor_history_type) == "List[str]" + + +def test_get_matching_class_attribute_name(citation_v23): + """Test finding correct attribute name from class.""" + # Test with case-insensitive matching + result = get_matching_class_attribute_name(citation_v23, "Title") + assert result == "title" + + result = get_matching_class_attribute_name(citation_v23, "ORIGINATOR") + assert result == "originator" + + +# ============================================================================= +# OBJECT ATTRIBUTE ACCESS TESTS +# ============================================================================= + + def test_get_object_attribute(): - data = {"a": {"b": ["v_x", {"c": "v_test"}]}} + """Test attribute access via dot-notation path.""" + data = SAMPLE_NESTED_DICT.copy() assert get_object_attribute(data, "a.b.1.c") == "v_test" +def test_get_object_attribute_no_verif(): + """Test attribute access without verification.""" + data = SAMPLE_NESTED_DICT.copy() + + # Test with dict + assert get_object_attribute_no_verif(data, "a") is not None + + # Test with list indexing + assert get_object_attribute_no_verif(data["a"]["b"], "0") == "v_x" + + # Test that non-existent attribute raises exception (no verification) + with pytest.raises(AttributeError): + get_object_attribute_no_verif(data, "non_existent") + + +def test_get_object_attribute_rgx(triangulated_set_versioned): + """Test attribute access using regex patterns. + + According to the docstring: Search the attribute name using regex for values between dots. + Example: [Cc]itation.[Tt]it\\.* + """ + + assert get_object_attribute_rgx(triangulated_set_versioned, "Citation.Title") == "Test Citation v2.3" + assert get_object_attribute_rgx(triangulated_set_versioned, "[Cc]itation.[Tt]it\\.*") == "Test Citation v2.3" + assert get_object_attribute_rgx(triangulated_set_versioned, "[Cc]itation.[Oo]rigin\\.*") == "Valentin" + + +def test_get_object_attribute_advanced(triangulated_set_versioned): + """Test advanced attribute access with matching.""" + assert get_object_attribute_advanced(triangulated_set_versioned, "citation.title") == "Test Citation v2.3" + assert get_object_attribute_advanced(triangulated_set_versioned, "citation.originator") == "Valentin" + + +# ============================================================================= +# OBJECT ATTRIBUTE MODIFICATION TESTS +# ============================================================================= + + def test_set_attribute_from_path(): - data = {"a": {"b": ["v_x", {"c": "v_test"}]}} + """Test setting attribute value via dot-notation path.""" + data = SAMPLE_NESTED_DICT.copy() assert get_object_attribute(data, "a.b.1.c") == "v_test" set_attribute_from_path(data, "a.b.1.c", "v_new") assert get_object_attribute(data, "a.b.1.c") == "v_new" @@ -88,18 +476,40 @@ def test_set_attribute_from_path(): assert get_object_attribute(data, "a") == "v_new" +def test_set_attribute_from_json_str(): + """Test setting attributes from JSON string. + + According to signature: set_attribute_from_json_str(obj: Any, json_input: str) -> None + """ + d_0 = {"a": "v_0", "b": {"c": "v_1"}} + d_1 = '{"a": "coucou"}' + + set_attribute_from_json_str(d_0, d_1) + assert d_0["a"] == "coucou" + + d_3 = '{"b": {"c": "v_2"}}' + set_attribute_from_json_str(d_0, d_3) + assert d_0["b"]["c"] == "v_2" + + +def test_set_attribute_from_dict(): + """Test setting attributes from dictionary.""" + d_0 = {"a": "v_0", "b": {"c": "v_1"}} + d_1 = {"a": "coucou"} + + set_attribute_from_dict(d_0, d_1) + assert d_0["a"] == "coucou" + + d_3 = {"b": {"c": "v_2"}} + set_attribute_from_dict(d_0, d_3) + assert d_0["b"]["c"] == "v_2" + + def test_copy_attributes_existing_ignore_case(): - data_in = { - "a": {"b": "v_0", "c": "v_1"}, - "uuid": "215f8219-cabd-4e24-9e4f-e371cabc9622", - "objectVersion": "Resqml 2.0", - "non_existing": 42, - } - data_out = { - "a": None, - "Uuid": "8291afd6-ae01-49f5-bc96-267e7b27450d", - "object_version": "Resqml 2.0", - } + """Test copying only existing attributes with case-insensitive matching.""" + data_in = SAMPLE_DATA_IN.copy() + data_out = SAMPLE_DATA_OUT_TEMPLATE.copy() + copy_attributes( obj_in=data_in, obj_out=data_out, @@ -113,17 +523,10 @@ def test_copy_attributes_existing_ignore_case(): def test_copy_attributes_ignore_case(): - data_in = { - "a": {"b": "v_0", "c": "v_1"}, - "uuid": "215f8219-cabd-4e24-9e4f-e371cabc9622", - "objectVersion": "Resqml 2.0", - "non_existing": 42, - } - data_out = { - "a": None, - "Uuid": "8291afd6-ae01-49f5-bc96-267e7b27450d", - "object_version": "Resqml 2.0", - } + """Test copying all attributes with case-insensitive matching.""" + data_in = SAMPLE_DATA_IN.copy() + data_out = SAMPLE_DATA_OUT_TEMPLATE.copy() + copy_attributes( obj_in=data_in, obj_out=data_out, @@ -137,17 +540,10 @@ def test_copy_attributes_ignore_case(): def test_copy_attributes_case_sensitive(): - data_in = { - "a": {"b": "v_0", "c": "v_1"}, - "uuid": "215f8219-cabd-4e24-9e4f-e371cabc9622", - "objectVersion": "Resqml 2.0", - "non_existing": 42, - } - data_out = { - "a": None, - "Uuid": "8291afd6-ae01-49f5-bc96-267e7b27450d", - "object_version": "Resqml 2.0", - } + """Test copying attributes with case-sensitive matching.""" + data_in = SAMPLE_DATA_IN.copy() + data_out = SAMPLE_DATA_OUT_TEMPLATE.copy() + copy_attributes( obj_in=data_in, obj_out=data_out, @@ -160,94 +556,263 @@ def test_copy_attributes_case_sensitive(): assert data_out["non_existing"] == data_in["non_existing"] -# Test fixtures for object identifiers and URIs -fi_cit = Citation20( - title="An interpretation", - originator="Valentin", - creation=epoch_to_date(epoch()), - editor="test", - format="Geosiris", - last_update=epoch_to_date(epoch()), -) +# ============================================================================= +# ATTRIBUTE SEARCH TESTS +# ============================================================================= -fi = FaultInterpretation( - citation=fi_cit, - uuid=gen_uuid(), - object_version="0", -) -tr_cit = Citation( - title="Test TriSet", - originator="Valentin", - creation=epoch_to_date(epoch()), - editor="test", - format="Geosiris", - last_update=epoch_to_date(epoch()), -) +def test_search_attribute_matching_name(triangulated_set_versioned): + """Test searching attributes by name pattern.""" + assert len(search_attribute_matching_name(triangulated_set_versioned, "title", search_in_sub_obj=False)) == 0 + title_deep = search_attribute_matching_name(triangulated_set_versioned, "title", search_in_sub_obj=True) + assert len(title_deep) == 2 + assert triangulated_set_versioned.citation.title in title_deep + assert triangulated_set_versioned.represented_object.title in title_deep -tr = TriangulatedSetRepresentation( - citation=tr_cit, - uuid=gen_uuid(), -) -tr_versioned = TriangulatedSetRepresentation( - citation=tr_cit, - uuid=gen_uuid(), - object_version="3", -) +def test_search_attribute_matching_type(triangulated_set_versioned): + """Test searching attributes by type pattern.""" + search_results_deep = search_attribute_matching_type( + triangulated_set_versioned, type_rgx="ExternalDataArrayPart", deep_search=True + ) + assert len(search_results_deep) == 2 + + @dataclass + class SampleClass: + ci: ContactElement + dor: DataObjectReference + + s = SampleClass( + ci=ContactElement(uuid="007"), + dor=DataObjectReference(uuid="008"), + ) + + search_result_citation = search_attribute_matching_type(s, type_rgx="DataObjectReference", super_class_search=False) + assert len(search_result_citation) == 1 + search_result_citation_deep = search_attribute_matching_type( + s, type_rgx="DataObjectReference", super_class_search=True + ) + assert len(search_result_citation_deep) == 2 + + assert len(search_attribute_matching_type(s, type_rgx="SampleClass", return_self=True)) == 1 + assert len(search_attribute_matching_type(s, type_rgx="SampleClass", return_self=False)) == 0 + + +# ============================================================================= +# OBJECT METADATA EXTRACTION TESTS +# ============================================================================= + + +def test_get_obj_uuid(triangulated_set_no_version, fault_interpretation): + """Test extracting object UUID. + + According to the docstring: Return the object uuid (attribute must match + the following regex: "[Uu]u?id|UUID"). + """ + assert get_obj_uuid(triangulated_set_no_version) == triangulated_set_no_version.uuid + assert get_obj_uuid(fault_interpretation) == fault_interpretation.uuid + + +def test_get_obj_version(triangulated_set_versioned, triangulated_set_no_version, fault_interpretation): + """Test extracting object version. + + According to the docstring: Return the object version (check for "object_version" + or "version_string" attribute). + """ + # Test object with explicit version + assert get_obj_version(triangulated_set_versioned) == "3" + assert get_obj_version(fault_interpretation) == "0" + + # Test object without version + version = get_obj_version(triangulated_set_no_version) + assert version is None or version == "" + + +def test_get_obj_version_edge_cases(): + """Test get_obj_version handles missing attributes gracefully.""" + + # Create object with only some version attributes + class MockObjWithVersionString: + version_string = "1.0" + + class MockObjWithCitationVersion: + class Citation: + version_string = "2.0" + + citation = Citation() + class MockObjNoVersion: + some_other_attr = "value" -def test_get_obj_identifier(): - """Test object identifier generation.""" - assert get_obj_identifier(tr) == tr.uuid + "." - assert get_obj_identifier(fi) == fi.uuid + ".0" - assert get_obj_identifier(tr_versioned) == tr_versioned.uuid + ".3" + # Should find version_string + assert get_obj_version(MockObjWithVersionString()) == "1.0" + # Should find citation.version_string when object_version missing + assert get_obj_version(MockObjWithCitationVersion()) == "2.0" + + # Should return None when no version found + assert get_obj_version(MockObjNoVersion()) is None + + +def test_get_obj_title(triangulated_set_no_version, fault_interpretation): + """Test extracting object title.""" + assert get_obj_title(triangulated_set_no_version) == "Test Citation v2.3" + assert get_obj_title(fault_interpretation) == "Test Citation v2.0" + + +def test_get_obj_type(triangulated_set_no_version, fault_interpretation): + """Test extracting object type name.""" + assert get_obj_type(triangulated_set_no_version) == "TriangulatedSetRepresentation" + assert get_obj_type(fault_interpretation) == "FaultInterpretation" + + # Test with type itself + assert get_obj_type(TriangulatedSetRepresentation) == "TriangulatedSetRepresentation" + + +def test_get_obj_identifier(triangulated_set_no_version, triangulated_set_versioned, fault_interpretation): + """Test object identifier generation (UUID.VERSION format).""" + assert get_obj_identifier(triangulated_set_no_version) == triangulated_set_no_version.uuid + "." + assert get_obj_identifier(fault_interpretation) == fault_interpretation.uuid + ".0" + assert get_obj_identifier(triangulated_set_versioned) == triangulated_set_versioned.uuid + ".3" + + +def test_get_obj_pkg_pkgv_type_uuid_version_obj_201(fault_interpretation): + """Test metadata extraction from resqml20 object.""" + domain, domain_version, object_type, obj_uuid, obj_version = get_obj_pkg_pkgv_type_uuid_version( + fault_interpretation + ) -def test_get_obj_pkg_pkgv_type_uuid_version_obj_201(): - """Test extracting package, version, type, uuid, and version from resqml20 object.""" - ( - domain, - domain_version, - object_type, - obj_uuid, - obj_version, - ) = get_obj_pkg_pkgv_type_uuid_version(fi) assert domain == "resqml" assert domain_version == "20" assert object_type == "obj_FaultInterpretation" - assert obj_uuid == fi.uuid - assert obj_version == fi.object_version - - -def test_get_obj_pkg_pkgv_type_uuid_version_obj_22(): - """Test extracting package, version, type, uuid, and version from resqml22 object.""" - ( - domain, - domain_version, - object_type, - obj_uuid, - obj_version, - ) = get_obj_pkg_pkgv_type_uuid_version(tr) + assert obj_uuid == fault_interpretation.uuid + assert obj_version == fault_interpretation.object_version + + +def test_get_obj_pkg_pkgv_type_uuid_version_obj_22(triangulated_set_no_version): + """Test metadata extraction from resqml22 object.""" + domain, domain_version, object_type, obj_uuid, obj_version = get_obj_pkg_pkgv_type_uuid_version( + triangulated_set_no_version + ) + assert domain == "resqml" assert domain_version == "22" assert object_type == "TriangulatedSetRepresentation" - assert obj_uuid == tr.uuid - assert obj_version == tr.object_version + assert obj_uuid == triangulated_set_no_version.uuid + assert obj_version == triangulated_set_no_version.object_version + + +def test_get_obj_qualified_type(triangulated_set_no_version, fault_interpretation): + """Test qualified type generation. + According to the docstring: Generates an object qualified type as: 'PKG.PKG_VERSION.OBJ_TYPE'. + """ + assert "resqml22.TriangulatedSetRepresentation" == get_obj_qualified_type(triangulated_set_no_version) + assert "resqml20.obj_FaultInterpretation" == get_obj_qualified_type(fault_interpretation) -def test_get_obj_uri(): + +def test_get_obj_content_type(triangulated_set_no_version, fault_interpretation): + """Test content type generation from object.""" + expected_content_type = "application/x-resqml+xml;version=2.2;type=TriangulatedSetRepresentation" + assert get_obj_content_type(triangulated_set_no_version) == expected_content_type + + expected_content_type_fi = "application/x-resqml+xml;version=2.0;type=obj_FaultInterpretation" + assert get_obj_content_type(fault_interpretation) == expected_content_type_fi + + +def test_get_obj_uri(triangulated_set_no_version, fault_interpretation): """Test URI generation for energyml objects.""" - assert str(get_obj_uri(tr)) == f"eml:///resqml22.TriangulatedSetRepresentation({tr.uuid})" + uri_str = str(get_obj_uri(triangulated_set_no_version)) + assert uri_str == f"eml:///resqml22.TriangulatedSetRepresentation({triangulated_set_no_version.uuid})" + + uri_str_with_dataspace = str(get_obj_uri(triangulated_set_no_version, "/MyDataspace/")) assert ( - str(get_obj_uri(tr, "/MyDataspace/")) - == f"eml:///dataspace('/MyDataspace/')/resqml22.TriangulatedSetRepresentation({tr.uuid})" + uri_str_with_dataspace + == f"eml:///dataspace('/MyDataspace/')/resqml22.TriangulatedSetRepresentation({triangulated_set_no_version.uuid})" ) + uri_str_fi = str(get_obj_uri(fault_interpretation)) assert ( - str(get_obj_uri(fi)) == f"eml:///resqml20.obj_FaultInterpretation(uuid={fi.uuid},version='{fi.object_version}')" + uri_str_fi + == f"eml:///resqml20.obj_FaultInterpretation(uuid={fault_interpretation.uuid},version='{fault_interpretation.object_version}')" ) + + uri_str_fi_dataspace = str(get_obj_uri(fault_interpretation, "/MyDataspace/")) assert ( - str(get_obj_uri(fi, "/MyDataspace/")) - == f"eml:///dataspace('/MyDataspace/')/resqml20.obj_FaultInterpretation(uuid={fi.uuid},version='{fi.object_version}')" + uri_str_fi_dataspace + == f"eml:///dataspace('/MyDataspace/')/resqml20.obj_FaultInterpretation(uuid={fault_interpretation.uuid},version='{fault_interpretation.object_version}')" ) + + +# ============================================================================= +# DATA OBJECT REFERENCE (DOR) TESTS +# ============================================================================= + + +def test_is_dor(triangulated_set_versioned): + """Test identification of Data Object Reference objects. + + According to the docstring: Returns True if the object is a DataObjectReference or + has ContentType/QualifiedType attributes. + """ + assert is_dor(as_dor(triangulated_set_versioned)) + assert not is_dor(triangulated_set_versioned) + assert is_dor( + { + "ContentType": "application/x-resqml+xml;version=2.2;type=RockVolumeFeature", + } + ) + assert is_dor( + { + "QualifiedType": "resqml22.TriangulatedSetRepresentation", + } + ) + assert not is_dor( + { + "what": 42, + } + ) + + +def test_get_dor_obj_info(triangulated_set_versioned): + """Test extracting information from DOR objects. + + According to the docstring: From a DOR object, return a tuple + (uuid, package name, package version, object_type, object_version). + """ + dor = as_dor(triangulated_set_versioned) + uuid, pkg_name, pkg_version, obj_type, obj_version = get_dor_obj_info(dor) + assert uuid == triangulated_set_versioned.uuid + assert pkg_name == "resqml" + assert pkg_version == "2.2" + assert obj_type == type(triangulated_set_versioned) + assert obj_version == triangulated_set_versioned.object_version + + +def test_get_direct_dor_list(triangulated_set_no_version): + """Test finding all DataObjectReference attributes. + + According to the docstring: Search all sub attribute of type "DataObjectReference". + """ + dor_list = get_direct_dor_list(triangulated_set_no_version) + assert isinstance(dor_list, list) + assert len(dor_list) == 1 + + +# ============================================================================= +# PATTERN MATCHING TESTS +# ============================================================================= + + +def test_class_match_rgx(): + """Test class name matching with regex. + + According to signature: class_match_rgx(cls, rgx, super_class_search, re_flags) + Tests if a class name matches a regex pattern. + """ + # Test simple class name matching + assert class_match_rgx(Contributor, "Contributor") + assert class_match_rgx(Contributor, "contrib.*") + + # Test case-insensitive matching (default behavior) + assert class_match_rgx(Contributor, "contributor") From 5eb6b40bb19511563335271d7f95765a5a8b299b Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Thu, 12 Feb 2026 06:03:59 +0100 Subject: [PATCH 29/70] move not test file --- .../{tests => example}/test_parallel_rels_performance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename energyml-utils/{tests => example}/test_parallel_rels_performance.py (99%) diff --git a/energyml-utils/tests/test_parallel_rels_performance.py b/energyml-utils/example/test_parallel_rels_performance.py similarity index 99% rename from energyml-utils/tests/test_parallel_rels_performance.py rename to energyml-utils/example/test_parallel_rels_performance.py index 27b70f6..2e1b6fa 100644 --- a/energyml-utils/tests/test_parallel_rels_performance.py +++ b/energyml-utils/example/test_parallel_rels_performance.py @@ -12,7 +12,7 @@ from pathlib import Path import pytest -from energyml.utils.epc_stream_old import EpcStreamReader +from energyml.utils.epc_stream import EpcStreamReader # Default test file path - can be overridden via environment variable From 882b515cdbed6c2c7a0fc41b89c946413076b376 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Fri, 13 Feb 2026 00:34:36 +0100 Subject: [PATCH 30/70] rels fixed for ExternalPartRef proxy --- energyml-utils/example/main_stream_sample.py | 9 +- energyml-utils/src/energyml/utils/epc.py | 10 +- .../src/energyml/utils/epc_stream.py | 113 ++-- .../src/energyml/utils/epc_utils.py | 269 +++++--- energyml-utils/tests/test_epc.py | 215 +++++- energyml-utils/tests/test_epc_stream.py | 133 ++++ energyml-utils/tests/test_epc_utils.py | 630 ++++++++++++++++++ 7 files changed, 1231 insertions(+), 148 deletions(-) create mode 100644 energyml-utils/tests/test_epc_utils.py diff --git a/energyml-utils/example/main_stream_sample.py b/energyml-utils/example/main_stream_sample.py index 8ed39fe..1227caa 100644 --- a/energyml-utils/example/main_stream_sample.py +++ b/energyml-utils/example/main_stream_sample.py @@ -657,6 +657,10 @@ def arrays_equal(arr1, arr2, name): logging.info("==> EPC file closed successfully") +def recompute_rels(path: str): + EpcStreamReader(epc_file_path=path, enable_parallel_rels=True, rels_update_mode=RelsUpdateMode.UPDATE_ON_CLOSE) + + if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) @@ -664,4 +668,7 @@ def arrays_equal(arr1, arr2, name): # test_create_epc("wip/test_create.epc") # test_create_epc_v2("wip/test_create.epc") - test_create_epc_v3_with_different_external_files("wip/test_create_v3.epc") + # test_create_epc_v3_with_different_external_files("wip/test_create_v3.epc") + + recompute_rels(sys.argv[1] if len(sys.argv) > 1 else "wip/failingData/fix/S-PASS-1-EARTHMODEL_ONLY.epc") + recompute_rels(sys.argv[1] if len(sys.argv) > 1 else "wip/failingData/fix/S-PASS-1-GEOMODEL.epc") diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index 69380d4..c4b7dda 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -347,7 +347,10 @@ def compute_rels(self) -> Dict[str, Relationships]: obj_id: [ Relationship( target=gen_energyml_object_path(target_obj, self.export_version), - type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + type_value=get_rels_dor_type( + gen_energyml_object_path(self.get_object(obj_id), self.export_version), + in_dor_owner_rels_file=False, + ), id=f"_{obj_id}_{get_obj_type(get_obj_usable_class(target_obj))}_{get_obj_identifier(target_obj)}", ) for target_obj in target_obj_list @@ -364,7 +367,9 @@ def compute_rels(self) -> Dict[str, Relationships]: rels[obj_id].append( Relationship( target=gen_energyml_object_path(target_obj, self.export_version), - type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + type_value=get_rels_dor_type( + gen_energyml_object_path(target_obj, self.export_version), in_dor_owner_rels_file=True + ), id=f"_{obj_id}_{get_obj_type(get_obj_usable_class(target_obj))}_{get_obj_identifier(target_obj)}", ) ) @@ -943,6 +948,7 @@ def close(self) -> None: # Backward compatibility: re-export functions that were moved to epc_utils # This allows existing code that imports these functions from epc.py to continue working from .epc_utils import ( + get_rels_dor_type, update_prop_kind_dict_cache, get_property_kind_by_uuid, get_property_kind_and_parents, diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index 32fb425..b32d94e 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -43,6 +43,7 @@ create_mandatory_structure_epc, extract_uuid_and_version_from_obj_path, gen_rels_path_from_obj_path, + get_rels_dor_type, repair_epc_structure_if_not_valid, ) from energyml.utils.storage_interface import ( @@ -535,7 +536,8 @@ def gen_rels_path_from_identifier(self, identifier: str) -> Optional[str]: return None return self.gen_rels_path_from_metadata(metadata) - def get_core_properties(self) -> Optional[CoreProperties]: + @property + def core_properties(self) -> Optional[CoreProperties]: """Get core properties (loaded lazily).""" if self._core_props is None and self._core_props_path: try: @@ -549,6 +551,56 @@ def get_core_properties(self) -> Optional[CoreProperties]: return self._core_props + @core_properties.setter + def core_properties(self, core_props: CoreProperties) -> None: + """Set core properties (updates immediately in the EPC zip file).""" + self._core_props = core_props + self._write_core_properties_to_zip(core_props) + + def _write_core_properties_to_zip(self, core_props: CoreProperties) -> None: + """Write core properties to the EPC zip file, replacing existing ones.""" + core_props_path = gen_core_props_path() + temp_path = None + + try: + # Create a temporary file for the new zip + with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: + temp_path = temp_file.name + + # Create new zip and copy all content except old core properties + with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zf: + with self.zip_accessor.get_zip_file() as source_zf: + # Copy all files except the core properties + for item in source_zf.infolist(): + if item.filename != core_props_path: + data = source_zf.read(item.filename) + target_zf.writestr(item, data) + + # Write new core properties + core_props_xml = serialize_xml(core_props) + zip_info = zipfile.ZipInfo( + filename=core_props_path, + date_time=datetime.now().timetuple()[:6], + ) + target_zf.writestr(zip_info, core_props_xml) + + # Replace the original file + shutil.move(temp_path, self.zip_accessor.epc_file_path) + + # Reopen the zip file to reflect changes + self.zip_accessor.reopen_persistent_zip() + + logging.info(f"Successfully updated core properties in {self.zip_accessor.epc_file_path}") + + except Exception as e: + # Clean up temp file if it exists + if temp_path and os.path.exists(temp_path): + try: + os.remove(temp_path) + except Exception: + pass + raise IOError(f"Failed to write core properties to EPC: {e}") + def detect_epc_version(self) -> EpcExportVersion: """Detect EPC packaging version based on file structure.""" try: @@ -580,37 +632,6 @@ def detect_epc_version(self) -> EpcExportVersion: logging.warning(f"Failed to detect EPC version, defaulting to CLASSIC: {e}") return EpcExportVersion.CLASSIC - # def update_content_types_xml( - # self, source_zip: zipfile.ZipFile, metadata: EpcObjectMetadata, add: bool = True - # ) -> str: - # """Update [Content_Types].xml to add or remove object entry. - - # Args: - # source_zip: Open ZIP file to read from - # metadata: Object metadata - # add: If True, add entry; if False, remove entry - - # Returns: - # Updated [Content_Types].xml as string - # """ - # # Read existing content types - # content_types = self._read_content_types(source_zip) - - # if add: - # # Add new override entry - # new_override = Override() - # new_override.part_name = f"/{metadata.file_path}" - # new_override.content_type = metadata.content_type - # content_types.override.append(new_override) - # else: - # # Remove existing override entry - # content_types.override = [ - # o for o in content_types.override if o.part_name and o.part_name.lstrip("/") != metadata.file_path - # ] - - # # Serialize back to XML - # return serialize_xml(content_types) - def get_content_type(self, zf: zipfile.ZipFile) -> Types: meta_dict_key_path = { @@ -822,14 +843,14 @@ def update_rels_for_new_object(self, obj: Any, obj_identifier: str) -> None: dest_rel = Relationship( target=target_path, - type_value=str(EPCRelsRelationshipType.DESTINATION_OBJECT), + type_value=get_rels_dor_type(dor_target=target_path, in_dor_owner_rels_file=True), id=f"_{gen_uuid()}", ) dest_rels.append(dest_rel) source_relationships[target_path] = Relationship( target=obj_file_path, - type_value=str(EPCRelsRelationshipType.SOURCE_OBJECT), + type_value=get_rels_dor_type(dor_target=target_path, in_dor_owner_rels_file=False), id=f"_{gen_uuid()}", ) @@ -869,7 +890,7 @@ def update_rels_for_modified_object(self, obj: Any, obj_identifier: str) -> None # DESTINATION relationship : current is referenced by dest_rel = Relationship( target=target_path, - type_value=str(EPCRelsRelationshipType.DESTINATION_OBJECT), + type_value=get_rels_dor_type(dor_target=target_path, in_dor_owner_rels_file=True), id=f"_{gen_uuid()}", ) current_rels_additions.append(dest_rel) @@ -878,7 +899,7 @@ def update_rels_for_modified_object(self, obj: Any, obj_identifier: str) -> None # REVERSED SOURCE relationship : target references current, if not already existing (to avoid duplicates if DORs are not changed for this target) source_rel = Relationship( target=obj_path, - type_value=str(EPCRelsRelationshipType.SOURCE_OBJECT), + type_value=get_rels_dor_type(dor_target=target_path, in_dor_owner_rels_file=False), id=f"_{gen_uuid()}", ) reversed_source_relationships[target_path] = source_rel @@ -2136,9 +2157,6 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in - 'source_relationships': Number of SOURCE relationships created - 'destination_relationships': Number of DESTINATION relationships created """ - import tempfile - import shutil - stats = { "objects_processed": 0, "rels_files_created": 0, @@ -2207,10 +2225,12 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in if target_identifier in self._metadata: target_metadata = self._metadata[target_identifier] target_type = get_obj_type(get_obj_usable_class(dor)) - + target_path = target_metadata.file_path( + export_version=self._metadata_mgr._export_version + ) rel = Relationship( - target=target_metadata.file_path(export_version=self._metadata_mgr._export_version), - type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + target=target_path, + type_value=get_rels_dor_type(dor_target=target_path, in_dor_owner_rels_file=True), id=f"_{identifier}_{target_type}_{target_identifier}", ) relationships.append(rel) @@ -2246,7 +2266,7 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in rel = Relationship( target=source_metadata.file_path(export_version=self._metadata_mgr._export_version), - type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + type_value=get_rels_dor_type(dor_target=target_rels_path, in_dor_owner_rels_file=False), id=f"_{target_identifier}_{source_type}_{source_identifier}", ) @@ -2413,11 +2433,12 @@ def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int] continue target_metadata = self._metadata[target_identifier] + target_path = target_metadata.file_path(export_version=export_version) # Create DESTINATION relationship (this object -> target) rel = Relationship( - target=target_metadata.file_path(export_version=export_version), - type_value=EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + target=target_path, + type_value=get_rels_dor_type(dor_target=target_path, in_dor_owner_rels_file=True), id=f"_{identifier}_{target_type}_{target_identifier}", ) rels_files[obj_rels_path].relationship.append(rel) @@ -2448,7 +2469,7 @@ def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int] # Create SOURCE relationship (source object -> this target object) rel = Relationship( target=source_metadata.file_path(export_version=export_version), - type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), + type_value=get_rels_dor_type(dor_target=target_rels_path, in_dor_owner_rels_file=False), id=f"_{target_identifier}_{source_type}_{source_identifier}", ) diff --git a/energyml-utils/src/energyml/utils/epc_utils.py b/energyml-utils/src/energyml/utils/epc_utils.py index c19cf75..911101e 100644 --- a/energyml-utils/src/energyml/utils/epc_utils.py +++ b/energyml-utils/src/energyml/utils/epc_utils.py @@ -12,6 +12,7 @@ from energyml.opc.opc import ( CoreProperties, Relationship, + Relationships, TargetMode, Created, Creator, @@ -116,6 +117,8 @@ def gen_energyml_object_path( obj_type = get_object_type_for_file_path_from_class(obj_cls) elif isinstance(energyml_object, CoreProperties): return gen_core_props_path(export_version) + elif isinstance(energyml_object, Types): + return get_epc_content_type_path() else: obj_type = get_object_type_for_file_path_from_class(energyml_object.__class__) # logging.debug("is_dor: ", str(is_dor(energyml_object)), "object type : " + str(obj_type)) @@ -155,6 +158,8 @@ def gen_rels_path( """ if isinstance(energyml_object, CoreProperties): return gen_core_props_rels_path() + elif isinstance(energyml_object, Types): + return get_epc_content_type_rels_path() else: obj_path = Path(gen_energyml_object_path(energyml_object, export_version)) return gen_rels_path_from_obj_path(obj_path=obj_path) @@ -182,6 +187,11 @@ def get_epc_content_type_path( return "[Content_Types].xml" +def get_epc_content_type_rels_path() -> str: + """Generate a path to store the rels file for "[Content_Types].xml" into an epc file :return:""" + return f"{RELS_FOLDER_NAME}/.rels" + + def extract_uuid_and_version_from_obj_path(obj_path: Union[str, Path]) -> Tuple[str, Optional[str]]: """ Extract the uuid and version of an object from its path in the epc file @@ -242,6 +252,51 @@ def create_default_types() -> Types: ) +def match_external_proxy_type(obj_or_path_or_type: Union[str, Uri, Any]) -> bool: + """Check if the given object, path or type string matches the pattern of an external proxy reference.""" + if isinstance(obj_or_path_or_type, str): + # for a classname, a filepath or a content-type, we check if it contains "external" and "reference" + obj_or_path_or_type_lw = obj_or_path_or_type.lower() + return "external" in obj_or_path_or_type_lw and "reference" in obj_or_path_or_type_lw + elif isinstance(obj_or_path_or_type, Uri): + return match_external_proxy_type(obj_or_path_or_type.get_qualified_type()) + else: + return match_external_proxy_type(str(type(obj_or_path_or_type))) + + +def get_rels_dor_type(dor_target: Union[str, Uri, Any], in_dor_owner_rels_file: bool) -> str: + """ + Determine the appropriate EPC relationship type for a DOR based on its target and rels file context. + + :param dor_target: The target object/type that the DOR references. Can be a string (qualified type), + a Uri object, or an EnergyML object. Used to determine if it's an external proxy. + :param in_dor_owner_rels_file: Boolean indicating which rels file perspective: + - True: We're in the rels file of the object that OWNS/CONTAINS the DOR + - False: We're in the rels file of the object that is TARGETED by the DOR + :return: The appropriate EPCRelsRelationshipType as a string for the relationship + + The function handles four scenarios: + - External proxy from owner's perspective -> ML_TO_EXTERNAL_PART_PROXY + - External proxy from target's perspective -> EXTERNAL_PART_PROXY_TO_ML + - Regular object from owner's perspective -> DESTINATION_OBJECT + - Regular object from target's perspective -> SOURCE_OBJECT + """ + if match_external_proxy_type(dor_target): + if in_dor_owner_rels_file: + # in the rels file of the Representation that points to the proxy + return str(EPCRelsRelationshipType.ML_TO_EXTERNAL_PART_PROXY) + else: + # in the EpcExternalPartReference rels file + return str(EPCRelsRelationshipType.EXTERNAL_PART_PROXY_TO_ML) + else: + if in_dor_owner_rels_file: + # in the rels file of the object that contains the DOR + return str(EPCRelsRelationshipType.DESTINATION_OBJECT) + else: + # in the DOR target rels file + return str(EPCRelsRelationshipType.SOURCE_OBJECT) + + # _ __ ___ __ __ _ # | | / /___ _/ (_)___/ /___ _/ /_(_)___ ____ # | | / / __ `/ / / __ / __ `/ __/ / __ \/ __ \ @@ -266,6 +321,7 @@ def valdiate_basic_epc_structure(epc: Union[str, Path, zipfile.ZipFile, BytesIO] required_files = { get_epc_content_type_path(), gen_core_props_rels_path(), + get_epc_content_type_rels_path(), gen_core_props_path(), } @@ -301,8 +357,19 @@ def create_mandatory_structure_epc(epc: Union[str, Path, zipfile.ZipFile, BytesI core_props = create_default_core_properties() empty_epc_structure = { get_epc_content_type_path(): serialize_xml(Types()), - gen_core_props_rels_path(): serialize_xml(Relationship()), + gen_core_props_rels_path(): serialize_xml(Relationships()), gen_core_props_path(): serialize_xml(core_props), + get_epc_content_type_rels_path(): serialize_xml( + Relationships( + relationship=[ + Relationship( + id="CoreProperties", + type_value=str(EPCRelsRelationshipType.CORE_PROPERTIES), + target=gen_core_props_path(), + ) + ] + ) + ), } # print(f"Current files in the EPC: {epc_io.namelist()}") @@ -389,111 +456,119 @@ def get_property_kind_and_parents(uuids: list) -> Dict[str, Any]: # /_____/\____/_/ |_| \____/_/ \___/\__,_/\__/_/\____/_/ /_/ -def as_dor(obj_or_identifier: Any, dor_qualified_type: str = "eml23.DataObjectReference"): +def as_dor(obj_or_identifier: Union[str, Uri, Any], dor_qualified_type: str = "eml23.DataObjectReference"): """ Create a DOR (Data Object Reference) from an object to target the latter. :param obj_or_identifier: an energyml object, identifier string, or URI :param dor_qualified_type: the qualified type of the DOR (e.g. "eml23.DataObjectReference" is the default value) :return: a DOR object """ - dor = None - if obj_or_identifier is not None: - cls = get_class_from_qualified_type(dor_qualified_type) - dor = cls() - if isinstance(obj_or_identifier, str): # is an identifier or uri - parsed_uri = parse_uri(obj_or_identifier) - if parsed_uri is not None: - logging.debug(f"====> parsed uri {parsed_uri} : uuid is {parsed_uri.uuid}") - if hasattr(dor, "qualified_type"): - set_attribute_from_path(dor, "qualified_type", parsed_uri.get_qualified_type()) - if hasattr(dor, "content_type"): - set_attribute_from_path( - dor, "content_type", qualified_type_to_content_type(parsed_uri.get_qualified_type()) - ) - set_attribute_from_path(dor, "uuid", parsed_uri.uuid) - set_attribute_from_path(dor, "uid", parsed_uri.uuid) - if hasattr(dor, "object_version"): - set_attribute_from_path(dor, "object_version", parsed_uri.version) - if hasattr(dor, "version_string"): - set_attribute_from_path(dor, "version_string", parsed_uri.version) - if hasattr(dor, "energistics_uri"): - set_attribute_from_path(dor, "energistics_uri", obj_or_identifier) - - else: # identifier - if len(__CACHE_PROP_KIND_DICT__) == 0: - # update the cache to check if it is a - try: - update_prop_kind_dict_cache() - except FileNotFoundError as e: - logging.error(f"Failed to parse propertykind dict {e}") + if obj_or_identifier is None: + return None + + cls = get_class_from_qualified_type(dor_qualified_type) + dor = cls() + + # Variables to collect data from different sources + dor_uuid = None + dor_title = None + dor_version = None + dor_qualified_type_str = None + dor_content_type_str = None + dor_energistics_uri = None + + if isinstance(obj_or_identifier, str) or isinstance(obj_or_identifier, Uri): # is an identifier or uri + parsed_uri = obj_or_identifier if isinstance(obj_or_identifier, Uri) else parse_uri(obj_or_identifier) + if parsed_uri is not None: + # From URI + logging.debug(f"====> parsed uri {parsed_uri} : uuid is {parsed_uri.uuid}") + dor_uuid = parsed_uri.uuid + dor_version = parsed_uri.version + dor_qualified_type_str = parsed_uri.get_qualified_type() + dor_content_type_str = qualified_type_to_content_type(parsed_uri.get_qualified_type()) + dor_energistics_uri = str(obj_or_identifier) + elif isinstance(obj_or_identifier, str): # identifier + if len(__CACHE_PROP_KIND_DICT__) == 0: try: - uuid, version = split_identifier(obj_or_identifier) - if uuid in __CACHE_PROP_KIND_DICT__: - return as_dor(__CACHE_PROP_KIND_DICT__[uuid]) - else: - set_attribute_from_path(dor, "uuid", uuid) - set_attribute_from_path(dor, "uid", uuid) - set_attribute_from_path(dor, "ObjectVersion", version) - except AttributeError: - logging.error(f"Failed to parse identifier {obj_or_identifier}. DOR will be empty") + update_prop_kind_dict_cache() + except FileNotFoundError as e: + logging.error(f"Failed to parse propertykind dict {e}") + try: + uuid, version = split_identifier(obj_or_identifier) + if uuid in __CACHE_PROP_KIND_DICT__: + return as_dor(__CACHE_PROP_KIND_DICT__[uuid], dor_qualified_type) + else: + dor_uuid = uuid + dor_version = version + except AttributeError: + logging.error(f"Failed to parse identifier {obj_or_identifier}. DOR will be empty") + else: + if is_dor(obj_or_identifier): + # DOR conversion + if hasattr(obj_or_identifier, "qualified_type"): + dor_qualified_type_str = get_object_attribute(obj_or_identifier, "qualified_type") + elif hasattr(obj_or_identifier, "content_type"): + dor_qualified_type_str = content_type_to_qualified_type( + get_object_attribute(obj_or_identifier, "content_type") + ) + + if hasattr(obj_or_identifier, "qualified_type"): + dor_content_type_str = qualified_type_to_content_type( + get_object_attribute(obj_or_identifier, "qualified_type") + ) + elif hasattr(obj_or_identifier, "content_type"): + dor_content_type_str = get_object_attribute(obj_or_identifier, "content_type") + + dor_title = get_object_attribute(obj_or_identifier, "Title") + dor_uuid = get_obj_uuid(obj_or_identifier) + dor_version = get_obj_version(obj_or_identifier) else: - if is_dor(obj_or_identifier): - # If it is a dor, we create a dor conversion - if hasattr(dor, "qualified_type"): - if hasattr(obj_or_identifier, "qualified_type"): - dor.qualified_type = get_object_attribute(obj_or_identifier, "qualified_type") - elif hasattr(obj_or_identifier, "content_type"): - dor.qualified_type = content_type_to_qualified_type( - get_object_attribute(obj_or_identifier, "content_type") - ) - - if hasattr(dor, "content_type"): - if hasattr(obj_or_identifier, "qualified_type"): - dor.content_type = qualified_type_to_content_type( - get_object_attribute(obj_or_identifier, "qualified_type") - ) - elif hasattr(obj_or_identifier, "content_type"): - dor.content_type = get_object_attribute(obj_or_identifier, "content_type") - - set_attribute_from_path(dor, "title", get_object_attribute(obj_or_identifier, "Title")) - obj_uuid = get_obj_uuid(obj_or_identifier) - set_attribute_from_path(dor, "uuid", obj_uuid) - set_attribute_from_path(dor, "uid", obj_uuid) - if hasattr(dor, "object_version"): - set_attribute_from_path(dor, "object_version", get_obj_version(obj_or_identifier)) - if hasattr(dor, "version_string"): - set_attribute_from_path(dor, "version_string", get_obj_version(obj_or_identifier)) - + # For etp Resource object + if hasattr(obj_or_identifier, "uri"): + dor = as_dor(obj_or_identifier.uri, dor_qualified_type) + if hasattr(obj_or_identifier, "name") and hasattr(dor, "title"): + setattr(dor, "title", getattr(obj_or_identifier, "name")) + return dor else: + # Regular EnergyML object + try: + dor_qualified_type_str = get_qualified_type_from_class(obj_or_identifier) + except Exception as e: + logging.error(f"Failed to set qualified_type for DOR {e}") - # for etp Resource object: - if hasattr(obj_or_identifier, "uri"): - dor = as_dor(obj_or_identifier.uri, dor_qualified_type) - if hasattr(obj_or_identifier, "name"): - set_attribute_from_path(dor, "title", getattr(obj_or_identifier, "name")) - else: - if hasattr(dor, "qualified_type"): - try: - set_attribute_from_path( - dor, "qualified_type", get_qualified_type_from_class(obj_or_identifier) - ) - except Exception as e: - logging.error(f"Failed to set qualified_type for DOR {e}") - if hasattr(dor, "content_type"): - try: - set_attribute_from_path(dor, "content_type", get_content_type_from_class(obj_or_identifier)) - except Exception as e: - logging.error(f"Failed to set content_type for DOR {e}") - - set_attribute_from_path(dor, "title", get_object_attribute(obj_or_identifier, "Citation.Title")) - obj_uuid = get_obj_uuid(obj_or_identifier) - # logging.debug(f"====> obj uuid is {obj_uuid}") - set_attribute_from_path(dor, "uid", obj_uuid) - set_attribute_from_path(dor, "uuid", obj_uuid) - if hasattr(dor, "object_version"): - set_attribute_from_path(dor, "object_version", get_obj_version(obj_or_identifier)) - if hasattr(dor, "version_string"): - set_attribute_from_path(dor, "version_string", get_obj_version(obj_or_identifier)) + try: + dor_content_type_str = get_content_type_from_class(obj_or_identifier) + except Exception as e: + logging.error(f"Failed to set content_type for DOR {e}") + + dor_title = get_object_attribute(obj_or_identifier, "Citation.Title") + dor_uuid = get_obj_uuid(obj_or_identifier) + dor_version = get_obj_version(obj_or_identifier) + + # Unified attribute setting section - applies collected data to DOR + if dor_qualified_type_str and hasattr(dor, "qualified_type"): + dor.qualified_type = dor_qualified_type_str + + if dor_content_type_str and hasattr(dor, "content_type"): + dor.content_type = dor_content_type_str + + if dor_title and hasattr(dor, "title"): + setattr(dor, "title", dor_title) + + if dor_uuid: + if hasattr(dor, "uuid"): + setattr(dor, "uuid", dor_uuid) + if hasattr(dor, "uid"): + setattr(dor, "uid", dor_uuid) + + if dor_version: + if hasattr(dor, "object_version"): + setattr(dor, "object_version", dor_version) + if hasattr(dor, "version_string"): + setattr(dor, "version_string", dor_version) + + if dor_energistics_uri and hasattr(dor, "energistics_uri"): + setattr(dor, "energistics_uri", dor_energistics_uri) return dor @@ -573,7 +648,7 @@ def create_external_part_reference( return obj -# ____ __ __ _ __ _ +# ____ __ __ _ __ _ # / __ \___ / /___ _/ /_(_)___ ____ ___/ /_ (_)___ _____ # / /_/ / _ \/ / __ `/ __/ / __ \/ __ \/ __ / / / / __ \/ ___/ # / _, _/ __/ / /_/ / /_/ / /_/ / / / / /_/ / /_/ / /_/ (__ ) diff --git a/energyml-utils/tests/test_epc.py b/energyml-utils/tests/test_epc.py index 4593ba3..da626f3 100644 --- a/energyml-utils/tests/test_epc.py +++ b/energyml-utils/tests/test_epc.py @@ -19,9 +19,14 @@ import numpy as np from energyml.eml.v2_0.commonv2 import Citation as Citation20 -from energyml.eml.v2_0.commonv2 import DataObjectReference as DataObjectReference201 +from energyml.eml.v2_0.commonv2 import DataObjectReference as DataObjectReference201, EpcExternalPartReference from energyml.eml.v2_3.commonv2 import Citation, DataObjectReference -from energyml.resqml.v2_0_1.resqmlv2 import FaultInterpretation +from energyml.resqml.v2_0_1.resqmlv2 import ( + FaultInterpretation, + TriangulatedSetRepresentation as TriangulatedSetRepresentation20, + TrianglePatch as TrianglePatch20, + PointGeometry as PointGeometry20, +) from energyml.resqml.v2_2.resqmlv2 import ( TriangulatedSetRepresentation, BoundaryFeatureInterpretation, @@ -125,10 +130,30 @@ def sample_objects(): object_version="0", ) + # 201 + external_ref = EpcExternalPartReference( + uuid="25773477-ffee-4cc2-867d-000000000005", + citation=Citation20(title="An external reference", originator="Valentin", creation=epoch_to_date(epoch())), + ) + + tr_set_20 = TriangulatedSetRepresentation20( + citation=Citation20( + title="Test TriangulatedSetRepresentation 2.0", originator="Test", creation=epoch_to_date(epoch()) + ), + uuid="25773477-ffee-4cc2-867d-000000000006", + object_version="1.0", + represented_interpretation=as_dor(horizon_interp, "eml20.DataObjectReference"), + triangle_patch=[ + TrianglePatch20(geometry=PointGeometry20(local_crs=as_dor(external_ref, "eml20.DataObjectReference"))) + ], + ) + return { "bf": bf, "bfi": bfi, "trset": trset, + "trset20": tr_set_20, + "external_ref": external_ref, "horizon_interp": horizon_interp, "fi": fi, } @@ -309,6 +334,192 @@ def test_compute_rels_complex_chain(self, sample_objects): ] assert len(dest_rels) >= 1 + def test_trset_to_interpretation_destination_relationship(self, sample_objects): + """Test that TriangulatedSetRepresentation has DESTINATION_OBJECT relationship to interpretation.""" + epc = Epc() + bf = sample_objects["bf"] + horizon_interp = sample_objects["horizon_interp"] + trset = sample_objects["trset"] + + epc.add_object(bf) + epc.add_object(horizon_interp) + epc.add_object(trset) + + rels_dict = epc.compute_rels() + + # Get trset rels path + trset_path = gen_energyml_object_path(trset, epc.export_version) + trset_rels_path = f"_rels/{trset_path}.rels" + + assert trset_rels_path in rels_dict + trset_rels = rels_dict[trset_rels_path] + + # Check for DESTINATION_OBJECT relationship to horizon_interp + dest_rels = [ + r for r in trset_rels.relationship if r.type_value == str(EPCRelsRelationshipType.DESTINATION_OBJECT) + ] + assert len(dest_rels) >= 1 + + # Verify target points to horizon_interp + horizon_interp_path = gen_energyml_object_path(horizon_interp, epc.export_version) + assert any(horizon_interp_path in r.target for r in dest_rels) + + def test_interpretation_has_source_and_destination_relationships(self, sample_objects): + """Test that interpretation has SOURCE_OBJECT from trset and DESTINATION_OBJECT to feature.""" + epc = Epc() + bf = sample_objects["bf"] + horizon_interp = sample_objects["horizon_interp"] + trset = sample_objects["trset"] + + epc.add_object(bf) + epc.add_object(horizon_interp) + epc.add_object(trset) + + rels_dict = epc.compute_rels() + + # Get interpretation rels path + interp_path = gen_energyml_object_path(horizon_interp, epc.export_version) + interp_rels_path = f"_rels/{interp_path}.rels" + + assert interp_rels_path in rels_dict + interp_rels = rels_dict[interp_rels_path] + + # Check for SOURCE_OBJECT relationship from trset + source_rels = [ + r for r in interp_rels.relationship if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT) + ] + assert len(source_rels) >= 1 + + # Verify source points to trset + trset_path = gen_energyml_object_path(trset, epc.export_version) + assert any(trset_path in r.target for r in source_rels) + + # Check for DESTINATION_OBJECT relationship to feature + dest_rels = [ + r for r in interp_rels.relationship if r.type_value == str(EPCRelsRelationshipType.DESTINATION_OBJECT) + ] + assert len(dest_rels) >= 1 + + # Verify target points to bf + bf_path = gen_energyml_object_path(bf, epc.export_version) + assert any(bf_path in r.target for r in dest_rels) + + def test_feature_has_source_relationship_from_interpretation(self, sample_objects): + """Test that feature has SOURCE_OBJECT relationship from interpretation.""" + epc = Epc() + bf = sample_objects["bf"] + horizon_interp = sample_objects["horizon_interp"] + + epc.add_object(bf) + epc.add_object(horizon_interp) + + rels_dict = epc.compute_rels() + + # Get feature rels path + bf_path = gen_energyml_object_path(bf, epc.export_version) + bf_rels_path = f"_rels/{bf_path}.rels" + + assert bf_rels_path in rels_dict + bf_rels = rels_dict[bf_rels_path] + + # Check for SOURCE_OBJECT relationship from interpretation + source_rels = [r for r in bf_rels.relationship if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT)] + assert len(source_rels) >= 1 + + # Verify source points to horizon_interp + interp_path = gen_energyml_object_path(horizon_interp, epc.export_version) + assert any(interp_path in r.target for r in source_rels) + + def test_external_part_reference_relationships(self, sample_objects): + """Test external part reference has EXTERNAL_PART_PROXY_TO_ML to trset20.""" + epc = Epc() + external_ref = sample_objects["external_ref"] + trset20 = sample_objects["trset20"] + horizon_interp = sample_objects["horizon_interp"] + bf = sample_objects["bf"] + + epc.add_object(bf) + epc.add_object(horizon_interp) + epc.add_object(external_ref) + epc.add_object(trset20) + + rels_dict = epc.compute_rels() + + # Get external_ref rels path + external_ref_path = gen_energyml_object_path(external_ref, epc.export_version) + external_ref_rels_path = f"_rels/{external_ref_path}.rels" + + assert external_ref_rels_path in rels_dict + external_ref_rels = rels_dict[external_ref_rels_path] + + # Check for EXTERNAL_PART_PROXY_TO_ML relationship + proxy_to_ml_rels = [ + r + for r in external_ref_rels.relationship + if r.type_value == str(EPCRelsRelationshipType.EXTERNAL_PART_PROXY_TO_ML) + ] + assert len(proxy_to_ml_rels) >= 1 + + # Verify target points to trset20 + trset20_path = gen_energyml_object_path(trset20, epc.export_version) + assert any(trset20_path in r.target for r in proxy_to_ml_rels) + + def test_trset20_has_ml_to_external_part_proxy_relationship(self, sample_objects): + """Test that trset20 has ML_TO_EXTERNAL_PART_PROXY relationship to external_ref.""" + epc = Epc() + external_ref = sample_objects["external_ref"] + trset20 = sample_objects["trset20"] + horizon_interp = sample_objects["horizon_interp"] + bf = sample_objects["bf"] + + epc.add_object(bf) + epc.add_object(horizon_interp) + epc.add_object(external_ref) + epc.add_object(trset20) + + rels_dict = epc.compute_rels() + + # Get trset20 rels path + trset20_path = gen_energyml_object_path(trset20, epc.export_version) + trset20_rels_path = f"_rels/{trset20_path}.rels" + + assert trset20_rels_path in rels_dict + trset20_rels = rels_dict[trset20_rels_path] + + # Check for ML_TO_EXTERNAL_PART_PROXY relationship + ml_to_proxy_rels = [ + r + for r in trset20_rels.relationship + if r.type_value == str(EPCRelsRelationshipType.ML_TO_EXTERNAL_PART_PROXY) + ] + assert len(ml_to_proxy_rels) >= 1 + + # Verify target points to external_ref + external_ref_path = gen_energyml_object_path(external_ref, epc.export_version) + assert any(external_ref_path in r.target for r in ml_to_proxy_rels) + + def test_complete_relationship_chain(self, sample_objects): + """Test complete relationship chain: trset -> interp -> feature.""" + epc = Epc() + bf = sample_objects["bf"] + horizon_interp = sample_objects["horizon_interp"] + trset = sample_objects["trset"] + + epc.add_object(bf) + epc.add_object(horizon_interp) + epc.add_object(trset) + + rels_dict = epc.compute_rels() + + # Verify all three objects have relationship files + trset_path = gen_energyml_object_path(trset, epc.export_version) + interp_path = gen_energyml_object_path(horizon_interp, epc.export_version) + bf_path = gen_energyml_object_path(bf, epc.export_version) + + assert f"_rels/{trset_path}.rels" in rels_dict + assert f"_rels/{interp_path}.rels" in rels_dict + assert f"_rels/{bf_path}.rels" in rels_dict + def test_get_obj_rels_after_compute(self, sample_objects): """Test get_obj_rels after explicit compute_rels call.""" epc = Epc() diff --git a/energyml-utils/tests/test_epc_stream.py b/energyml-utils/tests/test_epc_stream.py index 39f41ca..5a11282 100644 --- a/energyml-utils/tests/test_epc_stream.py +++ b/energyml-utils/tests/test_epc_stream.py @@ -17,7 +17,13 @@ import pytest import numpy as np +from energyml.eml.v2_0.commonv2 import Citation as Citation20, EpcExternalPartReference from energyml.eml.v2_3.commonv2 import Citation +from energyml.resqml.v2_0_1.resqmlv2 import ( + TriangulatedSetRepresentation as TriangulatedSetRepresentation20, + TrianglePatch as TrianglePatch20, + PointGeometry as PointGeometry20, +) from energyml.resqml.v2_2.resqmlv2 import ( TriangulatedSetRepresentation, BoundaryFeatureInterpretation, @@ -105,10 +111,37 @@ def sample_objects(): represented_object=as_dor(horizon_interp), ) + # Create an EpcExternalPartReference (RESQML 2.0.1) + external_ref = EpcExternalPartReference( + uuid="25773477-ffee-4cc2-867d-000000000005", + citation=Citation20( + title="An external reference", + originator="Test", + creation=epoch_to_date(epoch()), + ), + ) + + # Create a TriangulatedSetRepresentation 2.0.1 that references the external part + trset20 = TriangulatedSetRepresentation20( + citation=Citation20( + title="Test TriangulatedSetRepresentation 2.0", + originator="Test", + creation=epoch_to_date(epoch()), + ), + uuid="25773477-ffee-4cc2-867d-000000000006", + object_version="1.0", + represented_interpretation=as_dor(horizon_interp, "eml20.DataObjectReference"), + triangle_patch=[ + TrianglePatch20(geometry=PointGeometry20(local_crs=as_dor(external_ref, "eml20.DataObjectReference"))) + ], + ) + return { "bf": bf, "bfi": bfi, "trset": trset, + "trset20": trset20, + "external_ref": external_ref, "horizon_interp": horizon_interp, } @@ -656,6 +689,106 @@ def test_independent_objects_no_rels(self, temp_epc_file, sample_objects): reader.close() + def test_external_part_reference_relationships(self, temp_epc_file, sample_objects): + """Test external part reference has EXTERNAL_PART_PROXY_TO_ML to trset20.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + external_ref = sample_objects["external_ref"] + trset20 = sample_objects["trset20"] + horizon_interp = sample_objects["horizon_interp"] + bf = sample_objects["bf"] + + reader.add_object(bf) + reader.add_object(horizon_interp) + reader.add_object(external_ref) + reader.add_object(trset20) + + # Get external_ref rels + external_ref_rels = reader.get_obj_rels(get_obj_identifier(external_ref)) + + # Check for EXTERNAL_PART_PROXY_TO_ML relationship + proxy_to_ml_rels = [ + r for r in external_ref_rels if r.type_value == str(EPCRelsRelationshipType.EXTERNAL_PART_PROXY_TO_ML) + ] + assert len(proxy_to_ml_rels) >= 1, "Expected EXTERNAL_PART_PROXY_TO_ML relationship from external_ref" + + # Verify target points to trset20 + trset20_path = gen_energyml_object_path(trset20, reader.export_version) + assert any( + trset20_path in r.target for r in proxy_to_ml_rels + ), "Expected relationship target to point to trset20" + + reader.close() + + def test_trset20_has_ml_to_external_part_proxy_relationship(self, temp_epc_file, sample_objects): + """Test that trset20 has ML_TO_EXTERNAL_PART_PROXY relationship to external_ref.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + external_ref = sample_objects["external_ref"] + trset20 = sample_objects["trset20"] + horizon_interp = sample_objects["horizon_interp"] + bf = sample_objects["bf"] + + reader.add_object(bf) + reader.add_object(horizon_interp) + reader.add_object(external_ref) + reader.add_object(trset20) + + # Get trset20 rels + trset20_rels = reader.get_obj_rels(get_obj_identifier(trset20)) + + # Check for ML_TO_EXTERNAL_PART_PROXY relationship + ml_to_proxy_rels = [ + r for r in trset20_rels if r.type_value == str(EPCRelsRelationshipType.ML_TO_EXTERNAL_PART_PROXY) + ] + assert len(ml_to_proxy_rels) >= 1, "Expected ML_TO_EXTERNAL_PART_PROXY relationship from trset20" + + # Verify target points to external_ref + external_ref_path = gen_energyml_object_path(external_ref, reader.export_version) + assert any( + external_ref_path in r.target for r in ml_to_proxy_rels + ), "Expected relationship target to point to external_ref" + + reader.close() + + def test_complete_external_ref_bidirectional_relationships(self, temp_epc_file, sample_objects): + """Test complete bidirectional relationships between trset20 and external_ref.""" + reader = EpcStreamReader(temp_epc_file, rels_update_mode=RelsUpdateMode.UPDATE_AT_MODIFICATION) + + external_ref = sample_objects["external_ref"] + trset20 = sample_objects["trset20"] + horizon_interp = sample_objects["horizon_interp"] + bf = sample_objects["bf"] + + reader.add_object(bf) + reader.add_object(horizon_interp) + reader.add_object(external_ref) + reader.add_object(trset20) + + # Check trset20 -> external_ref (ML_TO_EXTERNAL_PART_PROXY) + trset20_rels = reader.get_obj_rels(get_obj_identifier(trset20)) + external_ref_path = gen_energyml_object_path(external_ref, reader.export_version) + + ml_to_proxy = [ + r + for r in trset20_rels + if r.type_value == str(EPCRelsRelationshipType.ML_TO_EXTERNAL_PART_PROXY) and external_ref_path in r.target + ] + assert len(ml_to_proxy) >= 1, "Expected ML_TO_EXTERNAL_PART_PROXY from trset20 to external_ref" + + # Check external_ref -> trset20 (EXTERNAL_PART_PROXY_TO_ML) + external_ref_rels = reader.get_obj_rels(get_obj_identifier(external_ref)) + trset20_path = gen_energyml_object_path(trset20, reader.export_version) + + proxy_to_ml = [ + r + for r in external_ref_rels + if r.type_value == str(EPCRelsRelationshipType.EXTERNAL_PART_PROXY_TO_ML) and trset20_path in r.target + ] + assert len(proxy_to_ml) >= 1, "Expected EXTERNAL_PART_PROXY_TO_ML from external_ref to trset20" + + reader.close() + class TestCachingAndPerformance: """Test caching functionality and performance optimizations.""" diff --git a/energyml-utils/tests/test_epc_utils.py b/energyml-utils/tests/test_epc_utils.py new file mode 100644 index 0000000..05c88f1 --- /dev/null +++ b/energyml-utils/tests/test_epc_utils.py @@ -0,0 +1,630 @@ +# Copyright (c) 2023-2024 Geosiris. +# SPDX-License-Identifier: Apache-2.0 + +""" +Comprehensive unit tests for epc_utils module. +Excludes EPC structure validation and property kind functions as per requirements. +""" + +import pytest +from pathlib import Path + +from energyml.utils.epc_utils import ( + gen_core_props_rels_path, + gen_core_props_path, + gen_energyml_object_path, + gen_rels_path, + gen_rels_path_from_obj_path, + get_epc_content_type_path, + extract_uuid_and_version_from_obj_path, + create_h5_external_relationship, + create_default_core_properties, + create_default_types, + match_external_proxy_type, + get_rels_dor_type, + as_dor, + create_energyml_object, + create_external_part_reference, + get_reverse_dor_list, + get_file_folder_and_name_from_path, +) +from energyml.utils.constants import EpcExportVersion, EPCRelsRelationshipType, MimeType, gen_uuid +from energyml.opc.opc import Relationship, TargetMode, CoreProperties, Types +from energyml.utils.uri import Uri, parse_uri +from energyml.utils.introspection import get_obj_uuid, get_obj_version +from energyml.eml.v2_3.commonv2 import Citation, DataObjectReference +from energyml.resqml.v2_2.resqmlv2 import ( + TriangulatedSetRepresentation, + TrianglePatch, + PointGeometry, + Point3DExternalArray, +) +from energyml.resqml.v2_0_1.resqmlv2 import ObjTriangulatedSetRepresentation as TriangulatedSetRepresentation201 + + +# ============================================================================= +# TEST FIXTURES - Reusable test data +# ============================================================================= + +TEST_UUID = "12345678-1234-1234-1234-123456789abc" +TEST_UUID_2 = "abcd5678-90ef-1234-5678-abcdef123456" + + +@pytest.fixture +def sample_citation(): + """Create a sample Citation object for testing.""" + return Citation( + title="Test Object", + originator="Test Originator", + creation="2024-01-01T00:00:00Z", + format="energyml-utils test", + ) + + +@pytest.fixture +def sample_triangulated_set_22(sample_citation): + """Create a sample TriangulatedSetRepresentation (RESQML 2.2) for testing.""" + obj = TriangulatedSetRepresentation( + uuid=TEST_UUID, + citation=sample_citation, + schema_version="2.2", + ) + return obj + + +@pytest.fixture +def sample_triangulated_set_201(): + """Create a sample TriangulatedSetRepresentation (RESQML 2.0.1) for testing.""" + citation = Citation( + title="Test Object 201", + originator="Test", + creation="2024-01-01T00:00:00Z", + ) + obj = TriangulatedSetRepresentation201( + uuid=TEST_UUID_2, + citation=citation, + schema_version="2.0.1", + ) + return obj + + +# ============================================================================= +# TEST CLASSES +# ============================================================================= + + +class TestPathGenerationFunctions: + """Test suite for EPC path generation utility functions.""" + + def test_gen_core_props_rels_path(self): + """Test generation of core properties rels file path.""" + result = gen_core_props_rels_path() + assert isinstance(result, str) + assert result == "docProps/_rels/core.xml.rels" + + def test_gen_core_props_path_classic(self): + """Test core properties path generation for classic export.""" + result = gen_core_props_path(EpcExportVersion.CLASSIC) + assert result == "docProps/core.xml" + + def test_gen_core_props_path_expanded(self): + """Test core properties path generation for expanded export.""" + result = gen_core_props_path(EpcExportVersion.EXPANDED) + assert result == "docProps/core.xml" + + def test_gen_core_props_path_default(self): + """Test core properties path generation with default export version.""" + result = gen_core_props_path() + assert result == "docProps/core.xml" + + def test_get_epc_content_type_path(self): + """Test content types file path generation.""" + result = get_epc_content_type_path() + assert result == "[Content_Types].xml" + + def test_gen_rels_path_from_obj_path_simple(self): + """Test rels path generation from simple object path.""" + obj_path = "ObjType_12345678-1234-1234-1234-123456789abc.xml" + result = gen_rels_path_from_obj_path(obj_path) + assert result == "_rels/ObjType_12345678-1234-1234-1234-123456789abc.xml.rels" + + def test_gen_rels_path_from_obj_path_with_folder(self): + """Test rels path generation from path with folders.""" + obj_path = "namespace_resqml22/version_1.0/Grid2dRepresentation_abc-123.xml" + result = gen_rels_path_from_obj_path(obj_path) + assert result == "namespace_resqml22/version_1.0/_rels/Grid2dRepresentation_abc-123.xml.rels" + + def test_gen_rels_path_from_obj_path_with_path_object(self): + """Test rels path generation with Path object input.""" + obj_path = Path("folder/subfolder/Object_uuid.xml") + result = gen_rels_path_from_obj_path(obj_path) + assert result == "folder/subfolder/_rels/Object_uuid.xml.rels" + + def test_gen_rels_path_from_obj_path_raises_error_for_rels_folder(self): + """Test that error is raised when object path is in _rels folder.""" + obj_path = "_rels/Object_uuid.xml.rels" + with pytest.raises(ValueError, match="cannot be in the '_rels' folder"): + gen_rels_path_from_obj_path(obj_path) + + def test_extract_uuid_and_version_from_obj_path_simple(self): + """Test UUID and version extraction from simple path.""" + obj_path = "Grid2dRepresentation_12345678-1234-1234-1234-123456789abc.xml" + uuid, version = extract_uuid_and_version_from_obj_path(obj_path) + assert uuid == "12345678-1234-1234-1234-123456789abc" + assert version is None + + def test_extract_uuid_and_version_from_obj_path_with_version(self): + """Test UUID and version extraction from versioned path.""" + obj_path = "namespace_resqml22/version_2.5/Grid_abcd1234-5678-90ab-cdef-123456789012.xml" + uuid, version = extract_uuid_and_version_from_obj_path(obj_path) + assert uuid == "abcd1234-5678-90ab-cdef-123456789012" + assert version == "2.5" + + def test_extract_uuid_and_version_from_obj_path_invalid(self): + """Test error when no UUID found in path.""" + obj_path = "invalid_path_without_uuid.xml" + with pytest.raises(ValueError, match="Cannot extract uuid"): + extract_uuid_and_version_from_obj_path(obj_path) + + def test_get_file_folder_and_name_from_path_with_folder(self): + """Test folder and filename extraction from path with folder.""" + path = "folder/subfolder/file.xml" + folder, filename = get_file_folder_and_name_from_path(path) + assert folder == "folder/subfolder/" + assert filename == "file.xml" + + def test_get_file_folder_and_name_from_path_without_folder(self): + """Test folder and filename extraction from path without folder.""" + path = "file.xml" + folder, filename = get_file_folder_and_name_from_path(path) + assert folder == "" + assert filename == "file.xml" + + def test_get_file_folder_and_name_from_path_multiple_levels(self): + """Test folder and filename extraction from deeply nested path.""" + path = "level1/level2/level3/level4/data.xml" + folder, filename = get_file_folder_and_name_from_path(path) + assert folder == "level1/level2/level3/level4/" + assert filename == "data.xml" + + +class TestEnergyMLObjectPathGeneration: + """Test suite for EnergyML object path generation.""" + + def test_gen_energyml_object_path_classic_resqml22(self, sample_triangulated_set_22): + """Test classic EPC path generation for RESQML 2.2 object.""" + result = gen_energyml_object_path(sample_triangulated_set_22, EpcExportVersion.CLASSIC) + assert result == f"TriangulatedSetRepresentation_{TEST_UUID}.xml" + + def test_gen_energyml_object_path_classic_resqml201(self, sample_triangulated_set_201): + """Test classic EPC path generation for RESQML 2.0.1 object.""" + result = gen_energyml_object_path(sample_triangulated_set_201, EpcExportVersion.CLASSIC) + assert result == f"obj_TriangulatedSetRepresentation_{TEST_UUID_2}.xml" + + def test_gen_energyml_object_path_expanded_without_version(self, sample_triangulated_set_22): + """Test expanded EPC path generation without object version.""" + result = gen_energyml_object_path(sample_triangulated_set_22, EpcExportVersion.EXPANDED) + assert f"namespace_resqml22/TriangulatedSetRepresentation_{TEST_UUID}.xml" == result + + def test_gen_energyml_object_path_expanded_with_version(self, sample_triangulated_set_22): + """Test expanded EPC path generation with object version.""" + sample_triangulated_set_22.object_version = "3.1" + result = gen_energyml_object_path(sample_triangulated_set_22, EpcExportVersion.EXPANDED) + assert ( + f"namespace_resqml22/version_{sample_triangulated_set_22.object_version}/TriangulatedSetRepresentation_{TEST_UUID}.xml" + == result + ) + + def test_gen_energyml_object_path_from_uri_string(self): + """Test path generation from URI string.""" + uri_str = f"eml:///resqml22.TriangulatedSetRepresentation({TEST_UUID})" + result = gen_energyml_object_path(uri_str, EpcExportVersion.CLASSIC) + assert f"TriangulatedSetRepresentation_{TEST_UUID}.xml" in result + + def test_gen_energyml_object_path_from_uri_object(self): + """Test path generation from Uri object.""" + uri = Uri( + domain="resqml", + domain_version="22", + object_type="TriangulatedSetRepresentation", + uuid=TEST_UUID, + ) + result = gen_energyml_object_path(uri, EpcExportVersion.CLASSIC) + assert f"TriangulatedSetRepresentation_{TEST_UUID}.xml" in result + + def test_gen_energyml_object_path_raises_error_no_uuid(self, sample_citation): + """Test error is raised when object has no UUID.""" + obj = TriangulatedSetRepresentation( + citation=sample_citation, + schema_version="2.2", + ) + # Don't set UUID + with pytest.raises(ValueError, match="must have a valid uuid"): + gen_energyml_object_path(obj, EpcExportVersion.CLASSIC) + + def test_gen_energyml_object_path_types(self): + """Test path generation for Types object.""" + types_ = Types() + result = gen_energyml_object_path(types_) + assert result == "[Content_Types].xml" + + def test_gen_rels_path_with_types(self): + """Test rels path generation for Types object.""" + types_ = Types() + result = gen_rels_path(types_) + assert result == "_rels/.rels" + + def test_gen_energyml_object_path_core_properties(self): + """Test path generation for CoreProperties object.""" + core_props = CoreProperties() + result = gen_energyml_object_path(core_props) + assert result == "docProps/core.xml" + + def test_gen_rels_path_with_core_properties(self): + """Test rels path generation for CoreProperties object.""" + core_props = CoreProperties() + result = gen_rels_path(core_props) + assert result == "docProps/_rels/core.xml.rels" + + def test_gen_rels_path_with_energyml_object_classic(self, sample_triangulated_set_22): + """Test rels path generation for EnergyML object in classic mode.""" + result = gen_rels_path(sample_triangulated_set_22, EpcExportVersion.CLASSIC) + assert result == f"_rels/TriangulatedSetRepresentation_{TEST_UUID}.xml.rels" + + def test_gen_rels_path_with_energyml_object_expanded(self, sample_triangulated_set_22): + """Test rels path generation for EnergyML object in expanded mode.""" + result = gen_rels_path(sample_triangulated_set_22, EpcExportVersion.EXPANDED) + assert "_rels/" in result + assert f"TriangulatedSetRepresentation_{TEST_UUID}.xml.rels" in result + + +class TestRelationshipFunctions: + """Test suite for relationship creation and management.""" + + def test_create_h5_external_relationship_default_index(self): + """Test HDF5 external relationship creation with default index.""" + h5_path = "external_data.h5" + result = create_h5_external_relationship(h5_path) + + assert isinstance(result, Relationship) + assert result.target == h5_path + assert result.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type() + assert result.id == "Hdf5File" + assert result.target_mode == TargetMode.EXTERNAL + + def test_create_h5_external_relationship_custom_index(self): + """Test HDF5 external relationship creation with custom index.""" + h5_path = "data/measurements.h5" + result = create_h5_external_relationship(h5_path, current_idx=2) + + assert result.target == h5_path + assert result.id == "Hdf5File3" + assert result.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type() + + def test_create_h5_external_relationship_zero_index(self): + """Test HDF5 external relationship creation with zero index.""" + h5_path = "test.h5" + result = create_h5_external_relationship(h5_path, current_idx=0) + + assert result.id == "Hdf5File" + + def test_get_reverse_dor_list_empty(self): + """Test reverse DOR list with empty object list.""" + result = get_reverse_dor_list([]) + assert result == {} + + def test_get_reverse_dor_list_with_objects(self, sample_triangulated_set_22, sample_citation): + """Test reverse DOR list with objects containing DORs.""" + # Create object with DOR + dor = as_dor(create_energyml_object("eml23.ProjectedCrs")) + + # Create triangulated set with patches that might have DORs + obj_with_dor = TriangulatedSetRepresentation( + uuid=gen_uuid(), + citation=sample_citation, + schema_version="2.2", + ) + # Adding a patch with contact element that has DOR + patch = TrianglePatch( + geometry=PointGeometry(points=Point3DExternalArray(), local_crs=dor) + ) # Reference to another object + obj_with_dor.triangle_patch = [patch] + + result = get_reverse_dor_list([obj_with_dor]) + + # Verify the result format - should have entries + assert isinstance(result, dict) + + +class TestDefaultObjectCreation: + """Test suite for default object creation functions.""" + + def test_create_default_core_properties_no_creator(self): + """Test default core properties creation without custom creator.""" + result = create_default_core_properties() + + assert isinstance(result, CoreProperties) + assert result.created is not None + assert result.creator is not None + assert "energyml-utils" in result.creator.any_element + assert result.identifier is not None + assert result.version == "1.0" + # Verify the identifier is a valid UUID format + assert "urn:uuid:" in result.identifier.any_element + + def test_create_default_core_properties_custom_creator(self): + """Test default core properties creation with custom creator.""" + custom_creator = "TestOrganization" + result = create_default_core_properties(creator=custom_creator) + + assert result.creator.any_element == custom_creator + assert result.version == "1.0" + assert result.created is not None + + def test_create_default_types(self): + """Test default Types object creation.""" + result = create_default_types() + + assert isinstance(result, Types) + assert len(result.default) == 1 + assert result.default[0].extension == "rels" + assert result.default[0].content_type == str(MimeType.RELS) + assert len(result.override) == 1 + assert result.override[0].content_type == str(MimeType.CORE_PROPERTIES) + assert result.override[0].part_name == "docProps/core.xml" + + +class TestExternalProxyMatching: + """Test suite for external proxy type matching.""" + + def test_match_external_proxy_type_with_valid_strings(self): + """Test matching external proxy type with valid strings.""" + assert match_external_proxy_type("EpcExternalPartReference") is True + assert match_external_proxy_type("eml23.EpcExternalPartReference") is True + assert match_external_proxy_type("external_reference") is True + assert match_external_proxy_type("EXTERNAL_REFERENCE") is True + + def test_match_external_proxy_type_with_invalid_strings(self): + """Test matching external proxy type with invalid strings.""" + assert match_external_proxy_type("Grid2dRepresentation") is False + assert match_external_proxy_type("WellboreTrajectory") is False + assert match_external_proxy_type("random_type") is False + assert match_external_proxy_type("TriangulatedSetRepresentation") is False + + def test_match_external_proxy_type_case_insensitive(self): + """Test that matching is case-insensitive.""" + assert match_external_proxy_type("External_Reference") is True + assert match_external_proxy_type("EXTERNAL_PART_REFERENCE") is True + + def test_match_external_proxy_type_with_path(self): + """Test matching external proxy type from file path.""" + assert match_external_proxy_type("path/to/obj_EpcExternalPartReference_uuid.xml") is True + + +class TestRelsDorType: + """Test suite for get_rels_dor_type function.""" + + def test_external_proxy_in_owner_rels_file(self): + """Test external proxy reference from the owner's rels file perspective.""" + # When we're in the rels file of an object that references an external proxy + result = get_rels_dor_type("EpcExternalPartReference", in_dor_owner_rels_file=True) + assert result == str(EPCRelsRelationshipType.ML_TO_EXTERNAL_PART_PROXY) + + def test_external_proxy_in_target_rels_file(self): + """Test external proxy reference from the target's rels file perspective.""" + # When we're in the rels file of the external proxy itself + result = get_rels_dor_type("eml23.EpcExternalPartReference", in_dor_owner_rels_file=False) + assert result == str(EPCRelsRelationshipType.EXTERNAL_PART_PROXY_TO_ML) + + def test_regular_object_in_owner_rels_file(self): + """Test regular object reference from the owner's rels file perspective.""" + # When we're in the rels file of an object that references a regular EnergyML object + result = get_rels_dor_type("resqml22.TriangulatedSetRepresentation", in_dor_owner_rels_file=True) + assert result == str(EPCRelsRelationshipType.DESTINATION_OBJECT) + + def test_regular_object_in_target_rels_file(self): + """Test regular object reference from the target's rels file perspective.""" + # When we're in the rels file of the referenced object + result = get_rels_dor_type("resqml22.Grid2dRepresentation", in_dor_owner_rels_file=False) + assert result == str(EPCRelsRelationshipType.SOURCE_OBJECT) + + def test_with_uri_object_external_proxy(self): + """Test with Uri object pointing to external proxy.""" + uri = Uri( + domain="eml", + domain_version="23", + object_type="EpcExternalPartReference", + uuid=TEST_UUID, + ) + result = get_rels_dor_type(uri, in_dor_owner_rels_file=True) + assert result == str(EPCRelsRelationshipType.ML_TO_EXTERNAL_PART_PROXY) + + def test_with_uri_object_regular_object(self): + """Test with Uri object pointing to regular EnergyML object.""" + uri = Uri( + domain="resqml", + domain_version="22", + object_type="TriangulatedSetRepresentation", + uuid=TEST_UUID, + ) + result = get_rels_dor_type(uri, in_dor_owner_rels_file=False) + assert result == str(EPCRelsRelationshipType.SOURCE_OBJECT) + + def test_with_energyml_object(self, sample_triangulated_set_22): + """Test with actual EnergyML object.""" + # Regular EnergyML object from owner perspective + result = get_rels_dor_type(sample_triangulated_set_22, in_dor_owner_rels_file=True) + assert result == str(EPCRelsRelationshipType.DESTINATION_OBJECT) + + def test_all_four_scenarios(self): + """Test all four possible combinations of external/regular × owner/target.""" + # Scenario 1: External proxy, owner's perspective + result1 = get_rels_dor_type("external_reference", True) + assert result1 == str(EPCRelsRelationshipType.ML_TO_EXTERNAL_PART_PROXY) + + # Scenario 2: External proxy, target's perspective + result2 = get_rels_dor_type("external_reference", False) + assert result2 == str(EPCRelsRelationshipType.EXTERNAL_PART_PROXY_TO_ML) + + # Scenario 3: Regular object, owner's perspective + result3 = get_rels_dor_type("WellboreTrajectory", True) + assert result3 == str(EPCRelsRelationshipType.DESTINATION_OBJECT) + + # Scenario 4: Regular object, target's perspective + result4 = get_rels_dor_type("WellboreTrajectory", False) + assert result4 == str(EPCRelsRelationshipType.SOURCE_OBJECT) + + +class TestDORCreation: + """Test suite for Data Object Reference (DOR) creation.""" + + def test_as_dor_with_none(self): + """Test DOR creation with None input returns None.""" + result = as_dor(None) + assert result is None + + def test_as_dor_from_uri_string(self): + """Test DOR creation from URI string.""" + uri_string = f"eml:///resqml22.TriangulatedSetRepresentation({TEST_UUID})" + result = as_dor(uri_string, dor_qualified_type="eml23.DataObjectReference") + + assert isinstance(result, DataObjectReference) + assert get_obj_uuid(result) == TEST_UUID + + def test_as_dor_from_energyml_object(self, sample_triangulated_set_22): + """Test DOR creation from EnergyML object.""" + result = as_dor(sample_triangulated_set_22, dor_qualified_type="eml23.DataObjectReference") + + assert isinstance(result, DataObjectReference) + assert get_obj_uuid(result) == TEST_UUID + assert result.title == "Test Object" + + def test_as_dor_from_existing_dor(self): + """Test DOR conversion from one DOR type to another.""" + source_dor = DataObjectReference( + uuid=TEST_UUID, + title="Original DOR", + qualified_type="resqml22.TriangulatedSetRepresentation", + ) + + result = as_dor(source_dor, dor_qualified_type="eml23.DataObjectReference") + + assert isinstance(result, DataObjectReference) + assert get_obj_uuid(result) == TEST_UUID + assert result.qualified_type == "resqml22.TriangulatedSetRepresentation" + assert result.title == "Original DOR" + + def test_as_dor_with_version(self, sample_triangulated_set_22): + """Test DOR creation preserves object version.""" + sample_triangulated_set_22.object_version = "2.5" + result = as_dor(sample_triangulated_set_22, dor_qualified_type="eml23.DataObjectReference") + + assert get_obj_version(result) == "2.5" + + def test_as_dor_from_uri_object(self): + """Test DOR creation from Uri object.""" + uri = Uri( + domain="resqml", + domain_version="22", + object_type="TriangulatedSetRepresentation", + uuid=TEST_UUID, + version="1.0", + ) + + result = as_dor(uri, dor_qualified_type="eml23.DataObjectReference") + + assert isinstance(result, DataObjectReference) + assert get_obj_uuid(result) == TEST_UUID + assert get_obj_version(result) == "1.0" + + +class TestObjectCreation: + """Test suite for EnergyML object creation functions.""" + + def test_create_energyml_object_with_defaults(self): + """Test EnergyML object creation with default parameters.""" + result = create_energyml_object("resqml22.TriangulatedSetRepresentation") + + assert isinstance(result, TriangulatedSetRepresentation) + assert result.citation is not None + assert result.citation.title == "New_Object" + assert get_obj_uuid(result) is not None + assert result.schema_version == "2.2" + + def test_create_energyml_object_with_custom_citation(self): + """Test EnergyML object creation with custom citation.""" + custom_citation = { + "title": "Custom Test Object", + "originator": "Test Organization", + } + + result = create_energyml_object( + "resqml22.TriangulatedSetRepresentation", + citation=custom_citation, + ) + + assert result.citation.title == "Custom Test Object" + assert result.citation.originator == "Test Organization" + + def test_create_energyml_object_with_custom_uuid(self): + """Test EnergyML object creation with custom UUID.""" + custom_uuid = TEST_UUID + + result = create_energyml_object( + "resqml22.TriangulatedSetRepresentation", + uuid=custom_uuid, + ) + + assert get_obj_uuid(result) == custom_uuid + + def test_create_energyml_object_resqml201(self): + """Test EnergyML object creation for RESQML 2.0.1.""" + result = create_energyml_object("resqml20.obj_TriangulatedSetRepresentation") + + assert isinstance(result, TriangulatedSetRepresentation201) + assert result.schema_version == "2.0" + + def test_create_external_part_reference_22(self): + """Test external part reference creation for EML 2.2.""" + h5_path = "data/external.h5" + result = create_external_part_reference("2.2", h5_path) + + assert result is not None + assert get_obj_uuid(result) is not None + # Note: The actual attributes depend on the EpcExternalPartReference schema + + def test_create_external_part_reference_20(self): + """Test external part reference creation for EML 2.0.""" + h5_path = "test.h5" + result = create_external_part_reference("2.0", h5_path) + + assert result is not None + assert get_obj_uuid(result) is not None + + def test_create_external_part_reference_with_custom_params(self): + """Test external part reference creation with custom citation and UUID.""" + custom_citation = {"title": "External Data Reference"} + custom_uuid = TEST_UUID_2 + + result = create_external_part_reference( + "2.1", + "external.h5", + citation=custom_citation, + uuid=custom_uuid, + ) + + assert get_obj_uuid(result) == custom_uuid + + def test_create_external_part_reference_version_formats(self): + """Test external part reference creation with different version formats.""" + # Test with dotted version + result1 = create_external_part_reference("2.2", "test1.h5") + assert result1 is not None + + # Test with underscore version + result2 = create_external_part_reference("2_1", "test2.h5") + assert result2 is not None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From db1991688be3a6a25376090be63aa77d7f8c5b51 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Fri, 13 Feb 2026 02:18:14 +0100 Subject: [PATCH 31/70] fix computeRels in updateOnClose for epc_stream. Now supports internal media detection in content_type --- energyml-utils/example/main_stream_sample.py | 21 ++- .../src/energyml/utils/constants.py | 177 +++++++++++++----- .../src/energyml/utils/epc_stream.py | 119 ++++++++++-- .../src/energyml/utils/epc_utils.py | 37 +++- 4 files changed, 288 insertions(+), 66 deletions(-) diff --git a/energyml-utils/example/main_stream_sample.py b/energyml-utils/example/main_stream_sample.py index 1227caa..7afc13f 100644 --- a/energyml-utils/example/main_stream_sample.py +++ b/energyml-utils/example/main_stream_sample.py @@ -1,4 +1,5 @@ import os +import shutil import sys import logging from energyml.utils.epc_stream import EpcStreamReader, RelsUpdateMode @@ -661,6 +662,19 @@ def recompute_rels(path: str): EpcStreamReader(epc_file_path=path, enable_parallel_rels=True, rels_update_mode=RelsUpdateMode.UPDATE_ON_CLOSE) +def recompute_rels_change_name(path: str): + path_reshaped = path.replace(".epc", "_reshaped.epc") + path_reshaped_seq = path.replace(".epc", "_reshaped_seq.epc") + shutil.copy(path, path_reshaped) + shutil.copy(path, path_reshaped_seq) + EpcStreamReader( + epc_file_path=path_reshaped, enable_parallel_rels=True, rels_update_mode=RelsUpdateMode.UPDATE_ON_CLOSE + ) + EpcStreamReader( + epc_file_path=path_reshaped_seq, enable_parallel_rels=False, rels_update_mode=RelsUpdateMode.UPDATE_ON_CLOSE + ) + + if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) @@ -670,5 +684,8 @@ def recompute_rels(path: str): # test_create_epc_v2("wip/test_create.epc") # test_create_epc_v3_with_different_external_files("wip/test_create_v3.epc") - recompute_rels(sys.argv[1] if len(sys.argv) > 1 else "wip/failingData/fix/S-PASS-1-EARTHMODEL_ONLY.epc") - recompute_rels(sys.argv[1] if len(sys.argv) > 1 else "wip/failingData/fix/S-PASS-1-GEOMODEL.epc") + # recompute_rels_change_name(sys.argv[1] if len(sys.argv) > 1 else "wip/failingData/fix/S-PASS-1-EARTHMODEL_ONLY.epc") + # recompute_rels_change_name(sys.argv[1] if len(sys.argv) > 1 else "wip/failingData/fix/S-PASS-1-GEOMODEL.epc") + recompute_rels_change_name( + sys.argv[1] if len(sys.argv) > 1 else "wip/failingData/fix/sample_mini_firp_201_norels_with_media.epc" + ) diff --git a/energyml-utils/src/energyml/utils/constants.py b/energyml-utils/src/energyml/utils/constants.py index 30bfdbf..96aa9f3 100644 --- a/energyml-utils/src/energyml/utils/constants.py +++ b/energyml-utils/src/energyml/utils/constants.py @@ -211,6 +211,7 @@ class OptimizedRegex: # TODO: RELS_CONTENT_TYPE may be incorrect or not well named, needs review RELS_CONTENT_TYPE = "application/vnd.openxmlformats-package.core-properties+xml" RELS_FOLDER_NAME = "_rels" +CORE_PROPERTIES_FOLDER_NAME = "docProps" primitives = (bool, str, int, float, type(None)) @@ -225,6 +226,20 @@ class MimeType(Enum): RELS = "application/vnd.openxmlformats-package.relationships+xml" CORE_PROPERTIES = "application/vnd.openxmlformats-package.core-properties+xml" EXTENDED_CORE_PROPERTIES = "application/x-extended-core-properties+xml" + JPEG = "image/jpeg" + PNG = "image/png" + TIFF = "image/tiff" + GIF = "image/gif" + SVG = "image/svg+xml" + DOC = "application/msword" + DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + XML = "application/xml" + JSON = "application/json" + TXT = "text/plain" + MARKDOWN = "text/markdown" + HTML = "text/html" + ZIP = "application/zip" def __str__(self): return self.value @@ -282,6 +297,120 @@ class RawFile: content: Optional[BytesIO] = field(default=None) +# =================================== +# MIME TYPE MAPPINGS +# =================================== + +# Primary mapping: MimeType enum → file extension +MIME_TYPE_TO_EXTENSION: dict[MimeType, str] = { + MimeType.CSV: "csv", + MimeType.HDF5: "h5", + MimeType.PARQUET: "parquet", + MimeType.PDF: "pdf", + MimeType.RELS: "rels", + MimeType.CORE_PROPERTIES: "xml", + MimeType.EXTENDED_CORE_PROPERTIES: "xml", + MimeType.JPEG: "jpg", + MimeType.PNG: "png", + MimeType.TIFF: "tiff", + MimeType.GIF: "gif", + MimeType.SVG: "svg", + MimeType.DOC: "doc", + MimeType.DOCX: "docx", + MimeType.XLSX: "xlsx", + MimeType.XML: "xml", + MimeType.JSON: "json", + MimeType.TXT: "txt", + MimeType.MARKDOWN: "md", + MimeType.HTML: "html", + MimeType.ZIP: "zip", +} + +# Alternative MIME type strings (aliases and variants) +MIME_TYPE_ALIASES: dict[str, MimeType] = { + "application/parquet": MimeType.PARQUET, + "application/vnd.apache.parquet": MimeType.PARQUET, + "text/xml": MimeType.XML, + "image/jpg": MimeType.JPEG, +} + +# Alternative file extensions +EXTENSION_ALIASES: dict[str, str] = { + "hdf5": "h5", + "jpeg": "jpg", + "tif": "tiff", + "markdown": "md", + "htm": "html", +} + + +def mime_type_to_file_extension(mime_type: str) -> Optional[str]: + """ + Convert MIME type to file extension using the MimeType enum and aliases. + + Args: + mime_type: MIME type string (case-insensitive) + + Returns: + File extension without leading dot, or None if not found + + Examples: + >>> mime_type_to_file_extension("text/csv") + 'csv' + >>> mime_type_to_file_extension("application/parquet") + 'parquet' + """ + if not mime_type: + return None + + mime_type_lower = mime_type.lower() + + # Try to find in MimeType enum + for mime_enum in MimeType: + if mime_enum.value.lower() == mime_type_lower: + return MIME_TYPE_TO_EXTENSION.get(mime_enum) + + # Try aliases + mime_enum = MIME_TYPE_ALIASES.get(mime_type_lower) + if mime_enum: + return MIME_TYPE_TO_EXTENSION.get(mime_enum) + + return None + + +def file_extension_to_mime_type(extension: str) -> Optional[str]: + """ + Convert file extension to MIME type using the MimeType enum. + + Args: + extension: File extension with or without leading dot (case-insensitive) + + Returns: + MIME type string, or None if not found + + Examples: + >>> file_extension_to_mime_type("csv") + 'text/csv' + >>> file_extension_to_mime_type(".json") + 'application/json' + """ + if not extension: + return None + + # Remove leading dot if present + ext_lower = extension.lstrip(".").lower() + + # Normalize through aliases first + ext_normalized = EXTENSION_ALIASES.get(ext_lower, ext_lower) + + # Find the MimeType that matches this extension + for mime_enum, ext in MIME_TYPE_TO_EXTENSION.items(): + if ext == ext_normalized: + return mime_enum.value + + return None + + # =================================== # OPTIMIZED UTILITY FUNCTIONS # =================================== @@ -499,54 +628,6 @@ def extract_uuid_from_string(s: str) -> Optional[str]: return None -def mime_type_to_file_extension(mime_type: str) -> Optional[str]: - """Convert MIME type to file extension""" - if not mime_type: - return None - - mime_type_lower = mime_type.lower() - - # Use dict for faster lookup than if/elif chain - mime_to_ext = { - "application/x-parquet": "parquet", - "application/parquet": "parquet", - "application/vnd.apache.parquet": "parquet", - "application/x-hdf5": "h5", - "text/csv": "csv", - "application/vnd.openxmlformats-package.relationships+xml": "rels", - "application/pdf": "pdf", - "application/xml": "xml", - "text/xml": "xml", - "application/json": "json", - "application/vnd.openxmlformats-package.core-properties+xml": "xml", - "application/x-extended-core-properties+xml": "xml", - } - - return mime_to_ext.get(mime_type_lower) - - -def file_extension_to_mime_type(extension: str) -> Optional[str]: - """Convert file extension to MIME type""" - if not extension: - return None - - ext_lower = extension.lower() - - # Use dict for faster lookup than if/elif chain - ext_to_mime = { - "parquet": "application/x-parquet", - "h5": "application/x-hdf5", - "hdf5": "application/x-hdf5", - "csv": "text/csv", - "rels": "application/vnd.openxmlformats-package.relationships+xml", - "pdf": "application/pdf", - "xml": "application/xml", - "json": "application/json", - } - - return ext_to_mime.get(ext_lower) - - # =================================== # PATH UTILITIES # =================================== diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index b32d94e..33fc282 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -42,8 +42,11 @@ create_default_types, create_mandatory_structure_epc, extract_uuid_and_version_from_obj_path, + gen_core_props_rels_path, gen_rels_path_from_obj_path, get_rels_dor_type, + in_epc_file_path_to_mime_type, + is_core_prop_or_extension_path, repair_epc_structure_if_not_valid, ) from energyml.utils.storage_interface import ( @@ -54,6 +57,7 @@ ) from energyml.utils.uri import Uri, create_uri_from_content_type_or_qualified_type from energyml.utils.constants import ( + CORE_PROPERTIES_FOLDER_NAME, EPCRelsRelationshipType, EpcExportVersion, MimeType, @@ -639,12 +643,7 @@ def get_content_type(self, zf: zipfile.ZipFile) -> Types: } other_files_in_epc = set() for name in zf.namelist(): - if ( - name not in meta_dict_key_path - and not name.endswith("rels") - and not name == get_epc_content_type_path() - and not name == gen_core_props_path() - ): + if name not in meta_dict_key_path and not name.endswith("rels") and not name == get_epc_content_type_path(): other_files_in_epc.add(name) content_types = create_default_types() @@ -656,8 +655,8 @@ def get_content_type(self, zf: zipfile.ZipFile) -> Types: # Add overrides for other files in EPC that are not in metadata (to preserve them) for file_path in other_files_in_epc: - file_extension = os.path.splitext(file_path)[1].lstrip(".").lower() - mime_type = file_extension_to_mime_type(file_extension) + # file_extension = os.path.splitext(file_path)[1].lstrip(".").lower() + mime_type = in_epc_file_path_to_mime_type(file_path) if mime_type: override = Override(content_type=mime_type, part_name=f"/{file_path}") content_types.override.append(override) @@ -2285,10 +2284,20 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in # Before writing, preserve EXTERNAL_RESOURCE and other non-SOURCE/DESTINATION relationships # This includes rels files that may not be in rels_files yet + # Also collect content types and core property extended files + core_prop_extended_files = set() + core_prop_rels = None + c_types: Optional[Types] = None + with self._zip_accessor.get_zip_file() as zf: + c_types = self._metadata_mgr.get_content_type(zf) # Check all existing .rels files for filename in zf.namelist(): if not filename.endswith(".rels"): + if is_core_prop_or_extension_path(filename) and not filename.endswith(gen_core_props_path()): + core_prop_extended_files.add(filename) + if filename == gen_core_props_rels_path(): + core_prop_rels = read_energyml_xml_bytes(zf.read(filename), Relationships) continue try: @@ -2301,8 +2310,8 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in for r in existing_rels_obj.relationship if r.type_value not in ( - EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), - EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + str(EPCRelsRelationshipType.SOURCE_OBJECT), + str(EPCRelsRelationshipType.DESTINATION_OBJECT), ) ] if preserved_rels: @@ -2315,6 +2324,31 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in except Exception as e: logging.debug(f"Could not preserve existing rels from {filename}: {e}") + # Update core_prop_rels with extended props if needed + new_core_prop_rels = Relationships( + relationship=[ + Relationship( + target=e_path, + type_value=str(EPCRelsRelationshipType.EXTENDED_CORE_PROPERTIES), + ) + for e_path in core_prop_extended_files + ] + ) + if core_prop_rels is None: + core_prop_rels = new_core_prop_rels + else: + for new_rel in new_core_prop_rels.relationship: + found = False + for existing_rel in core_prop_rels.relationship: + if existing_rel.target == new_rel.target and existing_rel.type_value == new_rel.type_value: + found = True + break + + if not found: + core_prop_rels.relationship.append(new_rel) + + rels_files[gen_core_props_rels_path()] = core_prop_rels + # Third pass: write the new EPC with updated .rels files with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: temp_path = temp_file.name @@ -2322,9 +2356,11 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in try: with self._zip_accessor.get_zip_file() as source_zip: with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zip: - # Copy all non-.rels files + # Copy all non-.rels files (excluding [Content_Types].xml which will be regenerated) for item in source_zip.infolist(): - if not (item.filename.endswith(".rels") and clean_first): + if not (item.filename.endswith(".rels") and clean_first) and ( + c_types is None or item.filename != get_epc_content_type_path() + ): data = source_zip.read(item.filename) target_zip.writestr(item, data) @@ -2333,6 +2369,11 @@ def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, in rels_xml = serialize_xml(rels_obj) target_zip.writestr(rels_path, rels_xml) + if c_types is not None: + # Write the new generated [Content_Types].xml + c_types_xml = serialize_xml(c_types) + target_zip.writestr(get_epc_content_type_path(), c_types_xml) + # Replace original file shutil.move(temp_path, self.epc_file_path) self._zip_accessor.reopen_persistent_zip() @@ -2490,9 +2531,23 @@ def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int] # PHASE 4: SEQUENTIAL - Preserve non-object relationships # ============================================================================ # Preserve EXTERNAL_RESOURCE and other non-standard relationship types + + # media_files = set() + core_prop_extended_files = set() + core_prop_rels = None + c_types: Optional[Types] = None + with self._zip_accessor.get_zip_file() as zf: + c_types = self._metadata_mgr.get_content_type(zf) for filename in zf.namelist(): + # if not filename.endswith(".rels") and not filename.endswith(".xml"): + # media_files.add(filename) + if not filename.endswith(".rels"): + if is_core_prop_or_extension_path(filename) and not filename.endswith(gen_core_props_path()): + core_prop_extended_files.add(filename) + if filename == gen_core_props_rels_path(): + core_prop_rels = read_energyml_xml_bytes(zf.read(filename), Relationships) continue try: @@ -2504,8 +2559,8 @@ def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int] for r in existing_rels_obj.relationship if r.type_value not in ( - EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), - EPCRelsRelationshipType.DESTINATION_OBJECT.get_type(), + str(EPCRelsRelationshipType.SOURCE_OBJECT), + str(EPCRelsRelationshipType.DESTINATION_OBJECT), ) ] if preserved_rels: @@ -2516,9 +2571,36 @@ def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int] except Exception as e: logging.debug(f"Could not preserve existing rels from {filename}: {e}") + # update core_prop_rels with extended props if needed + new_core_prop_rels = Relationships( + relationship=[ + Relationship( + target=e_path, + type_value=str(EPCRelsRelationshipType.EXTENDED_CORE_PROPERTIES), + ) + for e_path in core_prop_extended_files + ] + ) + if core_prop_rels is None: + core_prop_rels = new_core_prop_rels + else: + for new_rel in new_core_prop_rels.relationship: + found = False + for existing_rel in core_prop_rels.relationship: + if existing_rel.target == new_rel.target and existing_rel.type_value == new_rel.type_value: + found = True + break + + if not found: + core_prop_rels.relationship.append(new_rel) + + rels_files[gen_core_props_rels_path()] = core_prop_rels + print(f"Coreprops : {core_prop_rels}") + # ============================================================================ # PHASE 5: SEQUENTIAL - Write all relationships to ZIP file # ============================================================================ + # ZIP file writing must be sequential (file format limitation) with tempfile.NamedTemporaryFile(delete=False, suffix=".epc") as temp_file: temp_path = temp_file.name @@ -2528,7 +2610,9 @@ def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int] with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target_zip: # Copy all non-.rels files for item in source_zip.infolist(): - if not (item.filename.endswith(".rels") and clean_first): + if not (item.filename.endswith(".rels") and clean_first) and ( + c_types is None or item.filename != get_epc_content_type_path() + ): data = source_zip.read(item.filename) target_zip.writestr(item, data) @@ -2537,6 +2621,11 @@ def _rebuild_all_rels_parallel(self, clean_first: bool = True) -> Dict[str, int] rels_xml = serialize_xml(rels_obj) target_zip.writestr(rels_path, rels_xml) + if c_types is not None: + # writing the new new generated [Content_Types].xml with the new media files if any + c_types_xml = serialize_xml(c_types) + target_zip.writestr(get_epc_content_type_path(), c_types_xml) + # Replace original file shutil.move(temp_path, self.epc_file_path) self._zip_accessor.reopen_persistent_zip() diff --git a/energyml-utils/src/energyml/utils/epc_utils.py b/energyml-utils/src/energyml/utils/epc_utils.py index 911101e..516234c 100644 --- a/energyml-utils/src/energyml/utils/epc_utils.py +++ b/energyml-utils/src/energyml/utils/epc_utils.py @@ -23,12 +23,14 @@ ) from energyml.utils.constants import ( + CORE_PROPERTIES_FOLDER_NAME, EPCRelsRelationshipType, EpcExportVersion, RELS_FOLDER_NAME, epoch, epoch_to_date, extract_uuid_from_string, + file_extension_to_mime_type, gen_uuid, MimeType, OptimizedRegex, @@ -80,6 +82,21 @@ def gen_core_props_rels_path() -> str: return (core_path.parent / RELS_FOLDER_NAME / f"{core_path.name}.rels").as_posix() +def is_core_prop_or_extension_path(path: Union[str, Path]) -> bool: + """ + Check if the given path is the one for core properties or its rels file in an epc file + :param path: + :return: + """ + _path = Path(path) if not isinstance(path, Path) else path + return ( + _path.as_posix() == gen_core_props_path() + or _path.as_posix() == gen_core_props_rels_path() + or _path.as_posix().startswith(f"/{CORE_PROPERTIES_FOLDER_NAME}/") + or _path.as_posix().startswith(f"{CORE_PROPERTIES_FOLDER_NAME}/") + ) + + def gen_core_props_path( export_version: EpcExportVersion = EpcExportVersion.CLASSIC, ) -> str: @@ -88,7 +105,7 @@ def gen_core_props_path( :param export_version: the version of the EPC export to use (classic or expanded) :return: """ - return "docProps/core.xml" + return f"{CORE_PROPERTIES_FOLDER_NAME}/core.xml" def gen_energyml_object_path( @@ -213,6 +230,24 @@ def extract_uuid_and_version_from_obj_path(obj_path: Union[str, Path]) -> Tuple[ return uuid_match, version +def in_epc_file_path_to_mime_type(path: str) -> Optional[str]: + """Infer MIME type from in-EPC file path""" + if not path: + return None + + # Check for specific EPC file types first + if path.endswith("rels"): + return MimeType.RELS.value + elif path in (gen_core_props_path(), f"/{gen_core_props_path()}"): + return MimeType.CORE_PROPERTIES.value + elif path.startswith((f"/{CORE_PROPERTIES_FOLDER_NAME}/", f"{CORE_PROPERTIES_FOLDER_NAME}/")): + return MimeType.EXTENDED_CORE_PROPERTIES.value + + # Fallback to inferring from file extension + ext = path.split(".")[-1] + return file_extension_to_mime_type(ext) + + # __ ____________ ______ # / |/ / _/ ___// ____/ # / /|_/ // / \__ \/ / From cdc3b52c676a82cb7df15ac0b4f09b3f6fc83b7b Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Fri, 13 Feb 2026 02:30:41 +0100 Subject: [PATCH 32/70] README --- energyml-utils/README.md | 221 +++++++++++++++--- energyml-utils/example/attic/__init__.py | 0 .../epc_rels_management_example.py | 0 .../epc_stream_keep_open_example.py | 0 energyml-utils/example/{ => attic}/main.py | 0 energyml-utils/example/{ => attic}/main201.py | 0 .../example/{ => attic}/main_data.py | 0 .../example/{ => attic}/main_datasets.py | 0 .../example/{ => attic}/main_stream.py | 0 .../example/{ => attic}/main_stream_sample.py | 0 .../example/{ => attic}/main_test_3D.py | 0 .../example/{ => attic}/mainjson.py | 0 .../test_parallel_rels_performance.py | 0 .../{ => attic}/validate_epc_example.py | 0 14 files changed, 194 insertions(+), 27 deletions(-) create mode 100644 energyml-utils/example/attic/__init__.py rename energyml-utils/example/{ => attic}/epc_rels_management_example.py (100%) rename energyml-utils/example/{ => attic}/epc_stream_keep_open_example.py (100%) rename energyml-utils/example/{ => attic}/main.py (100%) rename energyml-utils/example/{ => attic}/main201.py (100%) rename energyml-utils/example/{ => attic}/main_data.py (100%) rename energyml-utils/example/{ => attic}/main_datasets.py (100%) rename energyml-utils/example/{ => attic}/main_stream.py (100%) rename energyml-utils/example/{ => attic}/main_stream_sample.py (100%) rename energyml-utils/example/{ => attic}/main_test_3D.py (100%) rename energyml-utils/example/{ => attic}/mainjson.py (100%) rename energyml-utils/example/{ => attic}/test_parallel_rels_performance.py (100%) rename energyml-utils/example/{ => attic}/validate_epc_example.py (100%) diff --git a/energyml-utils/README.md b/energyml-utils/README.md index b29c45c..f753d93 100644 --- a/energyml-utils/README.md +++ b/energyml-utils/README.md @@ -86,27 +86,32 @@ The **EpcStreamReader** provides memory-efficient handling of large EPC files th - **Smart Caching**: LRU (Least Recently Used) cache with configurable size - **Automatic EPC Version Detection**: Supports both CLASSIC and EXPANDED EPC formats - **Add/Remove/Update Operations**: Full CRUD operations with automatic file structure maintenance +- **Relationship Management**: Automatic or manual .rels file updates with parallel processing support +- **External Data Arrays**: Read/write HDF5, Parquet, CSV arrays with intelligent file caching - **Context Management**: Automatic resource cleanup with `with` statements - **Memory Monitoring**: Track cache efficiency and memory usage statistics ### Basic Usage ```python -from energyml.utils.epc_stream import EpcStreamReader +from energyml.utils.epc_stream import EpcStreamReader, RelsUpdateMode # Open EPC file with context manager (recommended) -with EpcStreamReader('large_file.epc', cache_size=50) as reader: +with EpcStreamReader('large_file.epc', + cache_size=50, + rels_update_mode=RelsUpdateMode.UPDATE_ON_CLOSE) as reader: # List all objects without loading them - print(f"Total objects: {reader.stats.total_objects}") + print(f"Total objects: {len(reader)}") # Get object by identifier - obj: Any = reader.get_object_by_identifier("uuid.version") + obj = reader.get_object("uuid.version") - # Get objects by type - features: List[Any] = reader.get_objects_by_type("BoundaryFeature") + # List objects by type (returns metadata, not full objects) + features = reader.list_objects(object_type="BoundaryFeature") + print(f"Found {len(features)} features") # Get all objects with same UUID - versions: List[Any] = reader.get_object_by_uuid("12345678-1234-1234-1234-123456789abc") + versions = reader.get_object_by_uuid("12345678-1234-1234-1234-123456789abc") ``` ### Adding Objects @@ -135,31 +140,31 @@ with EpcStreamReader('my_file.epc') as reader: ```python with EpcStreamReader('my_file.epc') as reader: - # Remove specific version by full identifier - success = reader.remove_object("uuid.version") + # Remove by full identifier + success = reader.delete_object("uuid.version") - # Remove ALL versions by UUID only - success = reader.remove_object("12345678-1234-1234-1234-123456789abc") + # Or use the alias + success = reader.remove_object("uuid.version") if success: - print("Object(s) removed successfully") + print("Object removed successfully") ``` ### Updating Objects ```python -... +from energyml.utils.epc_stream import EpcStreamReader from energyml.utils.introspection import set_attribute_from_path with EpcStreamReader('my_file.epc') as reader: # Get existing object - obj = reader.get_object_by_identifier("uuid.version") + obj = reader.get_object("uuid.version") # Modify the object set_attribute_from_path(obj, "citation.title", "Updated Title") # Update in EPC file - new_identifier = reader.update_object(obj) + new_identifier = reader.put_object(obj) print(f"Updated object: {new_identifier}") ``` @@ -190,23 +195,71 @@ with EpcStreamReader('my_file.epc') as reader: # Objects added will use the same format as the existing EPC file ``` +### Relationship Management + +```python +from energyml.utils.epc_stream import EpcStreamReader, RelsUpdateMode + +# Choose relationship update strategy +with EpcStreamReader('my_file.epc', + rels_update_mode=RelsUpdateMode.UPDATE_ON_CLOSE, + enable_parallel_rels=True) as reader: + + # Add/modify objects - rels updated automatically based on mode + reader.add_object(my_object) + + # Manual rebuild of all relationships (e.g., after bulk operations) + stats = reader.rebuild_all_rels(clean_first=True) + print(f"Rebuilt {stats['rels_files_created']} .rels files") +``` + +### External Data Arrays + +```python +import numpy as np + +with EpcStreamReader('my_file.epc') as reader: + # Read array from HDF5/Parquet/CSV + data = reader.read_array( + proxy=my_representation, + path_in_external="/geometry/points" + ) + + # Write array to external file + new_data = np.array([[1, 2, 3], [4, 5, 6]]) + success = reader.write_array( + proxy=my_representation, + path_in_external="/geometry/points", + array=new_data + ) + + # Get metadata without loading full array + metadata = reader.get_array_metadata(my_representation) + print(f"Array shape: {metadata.dimensions}, dtype: {metadata.array_type}") +``` + ### Advanced Usage ```python -# Initialize without preloading metadata for faster startup -reader = EpcStreamReader('huge_file.epc', preload_metadata=False, cache_size=200) +# Initialize with persistent ZIP connection for better performance +reader = EpcStreamReader('huge_file.epc', + keep_open=True, + cache_size=200, + enable_parallel_rels=True, + parallel_worker_ratio=10) try: - # Manual metadata loading when needed - reader._load_metadata() - # Get object dependencies deps = reader.get_object_dependencies("uuid.version") # Batch processing with memory monitoring for obj_type in ["BoundaryFeature", "PropertyKind"]: - objects = reader.get_objects_by_type(obj_type) - print(f"Processing {len(objects)} {obj_type} objects") + obj_list = reader.list_objects(object_type=obj_type) + print(f"Processing {len(obj_list)} {obj_type} objects") + + for metadata in obj_list: + obj = reader.get_object(metadata.identifier) + # Process object... finally: reader.close() # Manual cleanup if not using context manager @@ -240,25 +293,139 @@ $env:PYTHONPATH="src" ``` -## Validation examples : -An epc file: +## Poetry Script Examples : + +### Validation + +Validate an EPC file: ```bash poetry run validate --file "path/to/your/energyml/object.epc" *> output_logs.json ``` -An xml file: +Validate an XML file: ```bash poetry run validate --file "path/to/your/energyml/object.xml" *> output_logs.json ``` -A json file: +Validate a JSON file: ```bash poetry run validate --file "path/to/your/energyml/object.json" *> output_logs.json ``` -A folder containing Epc/xml/json files: +Validate a folder containing EPC/XML/JSON files: ```bash poetry run validate --file "path/to/your/folder" *> output_logs.json ``` +### Extract 3D Representations + +Extract all representations from an EPC to OBJ files: +```bash +poetry run extract_3d --epc "path/to/file.epc" --output "output_folder" +``` + +Extract specific representations by UUID: +```bash +poetry run extract_3d --epc "path/to/file.epc" --output "output_folder" --uuid "uuid1" "uuid2" +``` + +Extract to OFF format without CRS displacement: +```bash +poetry run extract_3d --epc "path/to/file.epc" --output "output_folder" --file-format OFF --no-crs +``` + +### CSV to Dataset + +Convert CSV to HDF5: +```bash +poetry run csv_to_dataset --csv "data.csv" --output "output.h5" +``` + +Convert CSV to Parquet with custom delimiter: +```bash +poetry run csv_to_dataset --csv "data.csv" --output "output.parquet" --csv-delimiter ";" +``` + +With dataset name prefix: +```bash +poetry run csv_to_dataset --csv "data.csv" --output "output.h5" --prefix "/my/path/" +``` + +With column mapping (JSON file): +```bash +poetry run csv_to_dataset --csv "data.csv" --output "output.h5" --mapping "mapping.json" +``` + +With inline column mapping: +```bash +poetry run csv_to_dataset --csv "data.csv" --output "output.h5" --mapping-line '{"DATASET_A": ["COL1", "COL2"], "DATASET_B": ["COL3"]}' +``` + +### Generate Random Data + +Generate a random RESQML object in JSON: +```bash +poetry run generate_data --type "energyml.resqml.v2_2.resqmlv2.TriangulatedSetRepresentation" --file-format json +``` + +Generate a random object in XML: +```bash +poetry run generate_data --type "energyml.resqml.v2_0_1.resqmlv2.Grid2dRepresentation" --file-format xml +``` + +Using qualified type: +```bash +poetry run generate_data --type "resqml22.WellboreFeature" --file-format json +``` + +### XML to JSON Conversion + +Convert an XML file to JSON: +```bash +poetry run xml_to_json --file "path/to/object.xml" +``` + +Convert with custom output path: +```bash +poetry run xml_to_json --file "path/to/object.xml" --out "output.json" +``` + +Convert entire EPC to JSON array: +```bash +poetry run xml_to_json --file "path/to/file.epc" --out "output.json" +``` + +### JSON to XML Conversion + +Convert a JSON file to XML: +```bash +poetry run json_to_xml --file "path/to/object.json" +``` + +Convert with custom output directory: +```bash +poetry run json_to_xml --file "path/to/object.json" --out "output_folder/" +``` + +### Describe as CSV + +Generate a CSV description of all objects in a folder: +```bash +poetry run describe_as_csv --folder "path/to/folder" +``` + +With custom columns: +```bash +poetry run describe_as_csv --folder "path/to/folder" \ + --columnsNames "Title" "Type" "UUID" \ + --columnsValues "citation.title" "$qualifiedType" "Uuid" +``` + +Available special values for columnsValues: +- `$type`: Object Python type +- `$qualifiedType`: EnergyML qualified type +- `$contentType`: EnergyML content type +- `$path`: File path +- `$dor`: UUIDs of referenced objects + diff --git a/energyml-utils/example/attic/__init__.py b/energyml-utils/example/attic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/energyml-utils/example/epc_rels_management_example.py b/energyml-utils/example/attic/epc_rels_management_example.py similarity index 100% rename from energyml-utils/example/epc_rels_management_example.py rename to energyml-utils/example/attic/epc_rels_management_example.py diff --git a/energyml-utils/example/epc_stream_keep_open_example.py b/energyml-utils/example/attic/epc_stream_keep_open_example.py similarity index 100% rename from energyml-utils/example/epc_stream_keep_open_example.py rename to energyml-utils/example/attic/epc_stream_keep_open_example.py diff --git a/energyml-utils/example/main.py b/energyml-utils/example/attic/main.py similarity index 100% rename from energyml-utils/example/main.py rename to energyml-utils/example/attic/main.py diff --git a/energyml-utils/example/main201.py b/energyml-utils/example/attic/main201.py similarity index 100% rename from energyml-utils/example/main201.py rename to energyml-utils/example/attic/main201.py diff --git a/energyml-utils/example/main_data.py b/energyml-utils/example/attic/main_data.py similarity index 100% rename from energyml-utils/example/main_data.py rename to energyml-utils/example/attic/main_data.py diff --git a/energyml-utils/example/main_datasets.py b/energyml-utils/example/attic/main_datasets.py similarity index 100% rename from energyml-utils/example/main_datasets.py rename to energyml-utils/example/attic/main_datasets.py diff --git a/energyml-utils/example/main_stream.py b/energyml-utils/example/attic/main_stream.py similarity index 100% rename from energyml-utils/example/main_stream.py rename to energyml-utils/example/attic/main_stream.py diff --git a/energyml-utils/example/main_stream_sample.py b/energyml-utils/example/attic/main_stream_sample.py similarity index 100% rename from energyml-utils/example/main_stream_sample.py rename to energyml-utils/example/attic/main_stream_sample.py diff --git a/energyml-utils/example/main_test_3D.py b/energyml-utils/example/attic/main_test_3D.py similarity index 100% rename from energyml-utils/example/main_test_3D.py rename to energyml-utils/example/attic/main_test_3D.py diff --git a/energyml-utils/example/mainjson.py b/energyml-utils/example/attic/mainjson.py similarity index 100% rename from energyml-utils/example/mainjson.py rename to energyml-utils/example/attic/mainjson.py diff --git a/energyml-utils/example/test_parallel_rels_performance.py b/energyml-utils/example/attic/test_parallel_rels_performance.py similarity index 100% rename from energyml-utils/example/test_parallel_rels_performance.py rename to energyml-utils/example/attic/test_parallel_rels_performance.py diff --git a/energyml-utils/example/validate_epc_example.py b/energyml-utils/example/attic/validate_epc_example.py similarity index 100% rename from energyml-utils/example/validate_epc_example.py rename to energyml-utils/example/attic/validate_epc_example.py From e0eb1957f9820b82a661a7460b7aa8a464024673 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Fri, 13 Feb 2026 04:29:07 +0100 Subject: [PATCH 33/70] fix epc in memory --- .../example/attic/compare_inmem_n_stream.py | 129 +++++++++ energyml-utils/src/energyml/utils/epc.py | 248 ++++++++++++------ .../src/energyml/utils/epc_stream.py | 18 +- .../src/energyml/utils/epc_utils.py | 25 +- 4 files changed, 326 insertions(+), 94 deletions(-) create mode 100644 energyml-utils/example/attic/compare_inmem_n_stream.py diff --git a/energyml-utils/example/attic/compare_inmem_n_stream.py b/energyml-utils/example/attic/compare_inmem_n_stream.py new file mode 100644 index 0000000..b9e7723 --- /dev/null +++ b/energyml-utils/example/attic/compare_inmem_n_stream.py @@ -0,0 +1,129 @@ +import logging +import os +import shutil +import sys +import time +from typing import Optional + +from energyml.utils.epc_stream import EpcStreamReader, RelsUpdateMode +from energyml.utils.epc import Epc +from energyml.utils.epc_utils import update_prop_kind_dict_cache + + +def reexport_stream_seq(filepath: str, output_folder: Optional[str] = None): + path_seq = filepath.replace(".epc", "_stream_seq.epc") + if output_folder: + os.makedirs(output_folder, exist_ok=True) + path_seq = f"{output_folder}/{path_seq.split('/')[-1]}" + shutil.copy(filepath, path_seq) + with EpcStreamReader( + epc_file_path=path_seq, enable_parallel_rels=False, rels_update_mode=RelsUpdateMode.UPDATE_ON_CLOSE + ) as reader: + pass # Just open and close to trigger rels computation on close + + +def reexport_stream_parallel(filepath: str, output_folder: Optional[str] = None): + path_parallel = filepath.replace(".epc", "_stream_parallel.epc") + if output_folder: + os.makedirs(output_folder, exist_ok=True) + path_parallel = f"{output_folder}/{path_parallel.split('/')[-1]}" + shutil.copy(filepath, path_parallel) + with EpcStreamReader( + epc_file_path=path_parallel, enable_parallel_rels=True, rels_update_mode=RelsUpdateMode.UPDATE_ON_CLOSE + ) as reader: + pass # Just open and close to trigger rels computation on close + + +def reexport_in_memory(filepath: str, output_folder: Optional[str] = None): + path_in_memory = filepath.replace(".epc", "_in_memory.epc") + if output_folder: + os.makedirs(output_folder, exist_ok=True) + path_in_memory = f"{output_folder}/{path_in_memory.split('/')[-1]}" + epc = Epc.read_file(filepath) + + if os.path.exists(path_in_memory): + os.remove(path_in_memory) + epc.export_file(path_in_memory) + + +def time_comparison(filepath: str, output_folder: Optional[str] = None, skip_sequential: bool = True): + """Compare performance of different EPC reexport methods.""" + print(f"\n{'=' * 70}") + print(f"Performance Comparison: {filepath.split('/')[-1]}") + print(f"{'=' * 70}\n") + + results = [] + + # Test 1: In-Memory + print("⏳ Testing In-Memory EPC processing...") + start = time.perf_counter() + reexport_in_memory(filepath, output_folder) + elapsed_inmem = time.perf_counter() - start + results.append(("In-Memory (Epc)", elapsed_inmem)) + print(f" ✓ Completed in {elapsed_inmem:.3f}s\n") + + if not skip_sequential: + # Test 2: Streaming Sequential + print("⏳ Testing Streaming Sequential processing...") + start = time.perf_counter() + reexport_stream_seq(filepath, output_folder) + elapsed_seq = time.perf_counter() - start + results.append(("Stream Sequential", elapsed_seq)) + print(f" ✓ Completed in {elapsed_seq:.3f}s\n") + + # Test 3: Streaming Parallel + print("⏳ Testing Streaming Parallel processing...") + start = time.perf_counter() + reexport_stream_parallel(filepath, output_folder) + elapsed_parallel = time.perf_counter() - start + results.append(("Stream Parallel", elapsed_parallel)) + print(f" ✓ Completed in {elapsed_parallel:.3f}s\n") + + # Calculate speedups + results_sorted = sorted(results, key=lambda x: x[1]) + fastest_time = results_sorted[0][1] + + # Print fancy table + print(f"\n{'=' * 70}") + print(f"{'PERFORMANCE RESULTS':^70}") + print(f"{'=' * 70}") + print(f"{'Method':<25} {'Time (s)':>12} {'Speedup':>12} {'Status':>15}") + print(f"{'-' * 70}") + + for method, elapsed in results_sorted: + speedup = fastest_time / elapsed + if speedup >= 0.95: # Fastest + status = "🏆 FASTEST" + elif speedup >= 0.8: + status = "✓ Good" + else: + status = "○ Slower" + + print(f"{method:<25} {elapsed:>12.3f} {speedup:>12.2f}x {status:>15}") + + print(f"{'=' * 70}") + + # Summary + fastest_method = results_sorted[0][0] + slowest_method = results_sorted[-1][0] + speedup_factor = results_sorted[-1][1] / fastest_time + + print(f"\n📊 Summary:") + print(f" • Fastest: {fastest_method} ({fastest_time:.3f}s)") + print(f" • Slowest: {slowest_method} ({results_sorted[-1][1]:.3f}s)") + print(f" • Overall speedup: {speedup_factor:.2f}x faster\n") + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + + update_prop_kind_dict_cache() + + time_comparison( + filepath=sys.argv[1] if len(sys.argv) > 1 else "rc/epc/80wells_surf.epc", output_folder="rc/performance_results" + ) + + # time_comparison( + # filepath=sys.argv[1] if len(sys.argv) > 1 else "wip/failingData/fix/sample_mini_firp_201_norels_with_media.epc", + # output_folder="rc/performance_results", + # ) diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index c4b7dda..45268c2 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -10,9 +10,11 @@ from pathlib import Path import random import re +import time import traceback import zipfile from dataclasses import dataclass, field +from functools import wraps from io import BytesIO from typing import List, Any, Union, Dict, Optional @@ -78,6 +80,45 @@ from energyml.utils.xml import is_energyml_content_type +def log_timestamp(func): + """Decorator to log timestamps for function execution.""" + + @wraps(func) + def wrapper(*args, **kwargs): + func_name = func.__name__ + start_time = time.perf_counter() + timestamp_start = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + + # Get file path from arguments if available + file_path = None + if args: + if isinstance(args[0], str) and (args[0].endswith(".epc") or "/" in args[0] or "\\" in args[0]): + file_path = args[0] + elif hasattr(args[0], "epc_file_path"): + file_path = args[0].epc_file_path + if "path" in kwargs: + file_path = kwargs["path"] + elif "epc_file_path" in kwargs: + file_path = kwargs["epc_file_path"] + + path_info = f" [{file_path}]" if file_path else "" + print(f"⏱️ [{timestamp_start}] Starting {func_name}{path_info}") + + try: + result = func(*args, **kwargs) + elapsed = time.perf_counter() - start_time + timestamp_end = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + print(f"✅ [{timestamp_end}] Completed {func_name} in {elapsed:.3f}s{path_info}") + return result + except Exception as e: + elapsed = time.perf_counter() - start_time + timestamp_end = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + print(f"❌ [{timestamp_end}] Failed {func_name} after {elapsed:.3f}s{path_info}: {e}") + raise + + return wrapper + + @dataclass class Epc(EnergymlStorageInterface): """ @@ -207,14 +248,8 @@ def gen_opc_content_type(self) -> Types: Generates a :class:`Types` instance and fill it with energyml objects :class:`Override` values :return: """ - ct = Types() - rels_default = Default() - rels_default.content_type = RELS_CONTENT_TYPE - rels_default.extension = "rels" + ct = create_default_types() - ct.default = [rels_default] - - ct.override = [] for e_obj in self.energyml_objects: ct.override.append( Override( @@ -223,17 +258,17 @@ def gen_opc_content_type(self) -> Types: ) ) - if self.core_props is not None: - ct.override.append( - Override( - content_type=get_content_type_from_class(self.core_props), - part_name=gen_core_props_path(self.export_version), - ) - ) + for rf in self.raw_files: + # file_extension = os.path.splitext(file_path)[1].lstrip(".").lower() + mime_type = in_epc_file_path_to_mime_type(rf.path) + if mime_type: + override = Override(content_type=mime_type, part_name=f"{rf.path}") + ct.override.append(override) return ct - def export_file(self, path: Optional[str] = None) -> None: + @log_timestamp + def export_file(self, path: Optional[str] = None, allowZip64: bool = True) -> None: """ Export the epc file. If :param:`path` is None, the epc 'self.epc_file_path' is used :param path: @@ -242,77 +277,53 @@ def export_file(self, path: Optional[str] = None) -> None: if path is None: path = self.epc_file_path + if path is None: + raise ValueError("No path provided and epc_file_path is not set") + # Ensure directory exists - if path is not None: - Path(path).parent.mkdir(parents=True, exist_ok=True) - epc_io = self.export_io() - with open(path, "wb") as f: - f.write(epc_io.getbuffer()) + Path(path).parent.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED, allowZip64=allowZip64) as zip_file: + self._export_io(zip_file=zip_file, allowZip64=allowZip64) - def export_io(self) -> BytesIO: + def export_io(self, allowZip64: bool = True) -> BytesIO: """ Export the epc file into a :class:`BytesIO` instance. The result is an 'in-memory' zip file. :return: """ zip_buffer = BytesIO() - with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file: - # CoreProps - if self.core_props is None: - self.core_props = CoreProperties( - created=Created(any_element=epoch_to_date(epoch())), - creator=Creator(any_element="energyml-utils python module (Geosiris)"), - identifier=Identifier(any_element=f"urn:uuid:{gen_uuid()}"), - keywords=Keywords1( - lang="en", - content=["generated;Geosiris;python;energyml-utils"], - ), - version="1.0", - ) + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED, allowZip64=allowZip64) as zip_file: + self._export_io(zip_file=zip_file, allowZip64=allowZip64) - zip_info_core = zipfile.ZipInfo( - filename=gen_core_props_path(self.export_version), - date_time=datetime.datetime.now().timetuple()[:6], - ) - data = serialize_xml(self.core_props) - zip_file.writestr(zip_info_core, data) - - # Energyml objects - for e_obj in self.energyml_objects: - e_path = gen_energyml_object_path(e_obj, self.export_version) - zip_info = zipfile.ZipInfo( - filename=e_path, - date_time=datetime.datetime.now().timetuple()[:6], - ) - data = serialize_xml(e_obj) - zip_file.writestr(zip_info, data) - - # Rels - for rels_path, rels in self.compute_rels().items(): - zip_info = zipfile.ZipInfo( - filename=rels_path, - date_time=datetime.datetime.now().timetuple()[:6], - ) - data = serialize_xml(rels) - zip_file.writestr(zip_info, data) - - # Other files: - for raw in self.raw_files: - zip_info = zipfile.ZipInfo( - filename=raw.path, - date_time=datetime.datetime.now().timetuple()[:6], - ) - zip_file.writestr(zip_info, raw.content.read()) + return zip_buffer - # ContentType - zip_info_ct = zipfile.ZipInfo( - filename=get_epc_content_type_path(), - date_time=datetime.datetime.now().timetuple()[:6], - ) - data = serialize_xml(self.gen_opc_content_type()) - zip_file.writestr(zip_info_ct, data) + def _export_io(self, zip_file: zipfile.ZipFile, allowZip64: bool = True) -> None: + """ + Export the epc file into a :class:`BytesIO` instance. The result is an 'in-memory' zip file. + :return: + """ + # CoreProps + if self.core_props is None: + self.core_props = create_default_core_properties() - return zip_buffer + zip_file.writestr(gen_core_props_path(self.export_version), serialize_xml(self.core_props)) + + # Energyml objects + for e_obj in self.energyml_objects: + e_path = gen_energyml_object_path(e_obj, self.export_version) + zip_file.writestr(e_path, serialize_xml(e_obj)) + + # Rels + for rels_path, rels in self.compute_rels().items(): + zip_file.writestr(rels_path, serialize_xml(rels)) + + # Other files: + for raw in self.raw_files: + zip_file.writestr(raw.path, raw.content.read()) + + # ContentType + zip_file.writestr(get_epc_content_type_path(), serialize_xml(self.gen_opc_content_type())) def get_obj_rels(self, obj: Union[str, Uri, Any]) -> List[Relationship]: """ @@ -336,6 +347,84 @@ def get_obj_rels(self, obj: Union[str, Uri, Any]) -> List[Relationship]: return [] def compute_rels(self) -> Dict[str, Relationships]: + result = {} + + # all energyml objects + for obj in self.energyml_objects: + obj_file_path = gen_energyml_object_path(obj, export_version=self.export_version) + obj_file_rels_path = gen_rels_path(obj, export_version=self.export_version) + obj_id = get_obj_identifier(obj) + obj_rels = Relationships(relationship=[]) + + dor_uris = get_dor_uris_from_obj(obj) + + for target_uri in dor_uris: + # if "propertykind" in target_uri.object_type.lower(): + # if get_property_kind_by_uuid(target_uri.uuid) is not None: + # # we can ignore prop kind from official dictionary + # continue + + # if self.get_object(target_uri) is None: + # logging.warning( + # f"Object with identifier {target_uri} is referenced in DOR but not found in energyml_objects." + # ) + + target_path = gen_energyml_object_path(target_uri, export_version=self.export_version) + target_rels_path = gen_rels_path(target_uri, export_version=self.export_version) + if target_path != obj_file_path: + dest_rel = Relationship( + target=target_path, + type_value=get_rels_dor_type(dor_target=target_path, in_dor_owner_rels_file=True), + id=f"_{gen_uuid()}", + ) + obj_rels.relationship.append(dest_rel) + + if target_rels_path not in result: + result[target_rels_path] = Relationships(relationship=[]) + + result[target_rels_path].relationship.append( + Relationship( + target=obj_file_path, + type_value=get_rels_dor_type(dor_target=target_path, in_dor_owner_rels_file=False), + id=f"_{gen_uuid()}", + ) + ) + + # additional rels + for supplemental_rels in self.additional_rels.get(obj_id, []): + obj_rels.relationship.append(supplemental_rels) + + result[obj_file_rels_path] = obj_rels + + # CoreProps + core_props = self.core_props or create_default_core_properties() + core_props_rels_path = gen_rels_path(core_props, self.export_version) + result[core_props_rels_path] = Relationships(relationship=[]) + for rf in self.raw_files: + if is_core_prop_or_extension_path(rf.path): + result[core_props_rels_path].relationship.append( + Relationship( + target=rf.path, + type_value=str(EPCRelsRelationshipType.EXTENDED_CORE_PROPERTIES), + id=f"_{gen_uuid()}", + ) + ) + + # ContentType + content_type_path_rels = get_epc_content_type_rels_path() + result[content_type_path_rels] = Relationships( + relationship=[ + Relationship( + id="CoreProperties", + type_value=str(EPCRelsRelationshipType.CORE_PROPERTIES), + target=gen_core_props_path(), + ) + ] + ) + + return result + + def compute_rels_old(self) -> Dict[str, Relationships]: """ Returns a dict containing for each objet, the rels xml file path as key and the RelationShips object as value :return: @@ -354,6 +443,7 @@ def compute_rels(self) -> Dict[str, Relationships]: id=f"_{obj_id}_{get_obj_type(get_obj_usable_class(target_obj))}_{get_obj_identifier(target_obj)}", ) for target_obj in target_obj_list + if self.get_object(obj_id) is not None ] for obj_id, target_obj_list in dor_relation.items() } @@ -683,6 +773,7 @@ def write_array( # Class methods @classmethod + @log_timestamp def read_file(cls, epc_file_path: str) -> "Epc": with open(epc_file_path, "rb") as f: epc = cls.read_stream(BytesIO(f.read())) @@ -948,7 +1039,14 @@ def close(self) -> None: # Backward compatibility: re-export functions that were moved to epc_utils # This allows existing code that imports these functions from epc.py to continue working from .epc_utils import ( + create_default_core_properties, + create_default_types, + gen_rels_path_from_obj_path, + get_dor_uris_from_obj, + get_epc_content_type_rels_path, get_rels_dor_type, + in_epc_file_path_to_mime_type, + is_core_prop_or_extension_path, update_prop_kind_dict_cache, get_property_kind_by_uuid, get_property_kind_and_parents, diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index 33fc282..a0d9e91 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -44,6 +44,7 @@ extract_uuid_and_version_from_obj_path, gen_core_props_rels_path, gen_rels_path_from_obj_path, + get_dor_uris_from_obj, get_rels_dor_type, in_epc_file_path_to_mime_type, is_core_prop_or_extension_path, @@ -105,23 +106,6 @@ def get_dor_identifiers_from_obj(obj: Any) -> Set[str]: return identifiers -def get_dor_uris_from_obj(obj: Any) -> Set[Uri]: - """Get uri of all Data Object References (DORs) directly referenced by the given object.""" - uri_set = set() - try: - dor_list = get_direct_dor_list(obj) - for dor in dor_list: - try: - uri = get_obj_uri(dor) - if uri: - uri_set.add(uri) - except Exception as e: - logging.warning(f"Failed to extract uri from DOR: {e}") - except Exception as e: - logging.warning(f"Failed to get DOR list from object: {e}") - return uri_set - - class RelsUpdateMode(Enum): """ Relationship update modes for EPC file management. diff --git a/energyml-utils/src/energyml/utils/epc_utils.py b/energyml-utils/src/energyml/utils/epc_utils.py index 516234c..ce8b95a 100644 --- a/energyml-utils/src/energyml/utils/epc_utils.py +++ b/energyml-utils/src/energyml/utils/epc_utils.py @@ -5,7 +5,7 @@ from io import BytesIO import json import logging -from typing import Optional, Tuple, Union, Any, List, Dict, Callable +from typing import Optional, Set, Tuple, Union, Any, List, Dict, Callable from pathlib import Path import zipfile @@ -40,6 +40,8 @@ get_property_kind_dict_path_as_dict, ) from energyml.utils.introspection import ( + get_direct_dor_list, + get_obj_uri, get_dor_obj_info, get_object_type_for_file_path_from_class, is_dor, @@ -283,7 +285,9 @@ def create_default_types() -> Types: """Create default Types object.""" return Types( default=[Default(extension="rels", content_type=str(MimeType.RELS))], - override=[Override(content_type=str(MimeType.CORE_PROPERTIES), part_name=gen_core_props_path())], + override=[ + Override(content_type=str(MimeType.CORE_PROPERTIES), part_name=gen_core_props_path()), + ], ) @@ -709,6 +713,23 @@ def get_reverse_dor_list(obj_list: List[Any], key_func: Callable = get_obj_ident return rels +def get_dor_uris_from_obj(obj: Any) -> Set[Uri]: + """Get uri of all Data Object References (DORs) directly referenced by the given object.""" + uri_set = set() + try: + dor_list = get_direct_dor_list(obj) + for dor in dor_list: + try: + uri = get_obj_uri(dor) + if uri: + uri_set.add(uri) + except Exception as e: + logging.warning(f"Failed to extract uri from DOR: {e}") + except Exception as e: + logging.warning(f"Failed to get DOR list from object: {e}") + return uri_set + + # ____ ___ ________ ______ # / __ \/ |/_ __/ / / / ___/ # / /_/ / /| | / / / /_/ /\__ \ From c5f11cfc6f5cd2ba8c20624d70c0934c22229c2c Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Fri, 13 Feb 2026 05:46:17 +0100 Subject: [PATCH 34/70] security added for get_dor_uris_from_obj --- energyml-utils/example/attic/misc_test.py | 32 +++++ energyml-utils/src/energyml/utils/epc.py | 136 +++++++++++++++--- .../src/energyml/utils/epc_utils.py | 2 +- energyml-utils/tests/test_introspection.py | 4 + 4 files changed, 153 insertions(+), 21 deletions(-) create mode 100644 energyml-utils/example/attic/misc_test.py diff --git a/energyml-utils/example/attic/misc_test.py b/energyml-utils/example/attic/misc_test.py new file mode 100644 index 0000000..2a3988c --- /dev/null +++ b/energyml-utils/example/attic/misc_test.py @@ -0,0 +1,32 @@ +from energyml.utils.epc_utils import get_dor_uris_from_obj +from energyml.utils.introspection import get_obj_uri, search_attribute_matching_type_with_path +from energyml.utils.serialization import ( + serialize_xml, + read_energyml_xml_str, + read_energyml_xml_file, + read_energyml_xml_bytes, + read_energyml_json_str, + read_energyml_json_bytes, + JSON_VERSION, +) + + +def test_as_uri(xml_path: str): + obj = read_energyml_xml_file(xml_path) + + # print(obj) + + for uri in get_dor_uris_from_obj(obj): + print(uri) + print("=" * 40) + print(obj.category_lookup) + print(get_obj_uri(obj.category_lookup)) + + print("=" * 40) + for p, o in search_attribute_matching_type_with_path(obj, "DataObjectreference"): + print(f"{p}: {o} ({get_obj_uri(o)})\n") + + +if __name__ == "__main__": + # test_as_uri("rc/ContinuousProperty_1d34249c-4c4f-4705-870e-b5dea9c0d78e.xml") + test_as_uri("rc/DiscreteProperty.xml") diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index 45268c2..5a55da1 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -17,18 +17,15 @@ from functools import wraps from io import BytesIO from typing import List, Any, Union, Dict, Optional +import numpy as np +from xsdata.formats.dataclass.models.generics import DerivedElement from energyml.opc.opc import ( CoreProperties, Relationships, Types, - Default, Relationship, Override, - Created, - Creator, - Identifier, - Keywords1, ) from energyml.utils.epc_utils import ( gen_core_props_path, @@ -38,12 +35,9 @@ create_h5_external_relationship, ) from energyml.utils.storage_interface import DataArrayMetadata, EnergymlStorageInterface, ResourceMetadata -import numpy as np from energyml.utils.uri import Uri, parse_uri -from xsdata.formats.dataclass.models.generics import DerivedElement from energyml.utils.constants import ( - RELS_CONTENT_TYPE, EpcExportVersion, RawFile, EPCRelsRelationshipType, @@ -62,8 +56,6 @@ get_obj_uuid, get_content_type_from_class, get_direct_dor_list, - epoch_to_date, - epoch, gen_uuid, get_obj_identifier, get_object_attribute, @@ -80,6 +72,114 @@ from energyml.utils.xml import is_energyml_content_type +class EnergymlObjectCollection: + """ + A collection that maintains both list semantics (for backward compatibility) + and dict-based lookups (for O(1) performance) for energyml objects. + + This allows existing code using .append() to work while providing efficient + get_object_by_identifier() and get_object_by_uuid() operations. + """ + + def __init__(self, objects: Optional[List[Any]] = None): + self._by_identifier: Dict[str, Any] = {} + self._by_uri: Dict[str, Any] = {} + self._by_uuid: Dict[str, List[Any]] = {} + self._objects_list: List[Any] = [] + + if objects: + for obj in objects: + self.append(obj) + + def append(self, obj: Any) -> None: + """Add an object to the collection (list-compatible method).""" + identifier = get_obj_identifier(obj) + uri = str(get_obj_uri(obj)) + uuid = get_obj_uuid(obj) + + # Check if object already exists by identifier + if identifier in self._by_identifier: + # Replace existing object + existing = self._by_identifier[identifier] + idx = self._objects_list.index(existing) + self._objects_list[idx] = obj + + # Clean up old URI mapping + old_uri = str(get_obj_uri(existing)) + if old_uri in self._by_uri: + del self._by_uri[old_uri] + + # Clean up old UUID mapping + old_uuid = get_obj_uuid(existing) + if old_uuid in self._by_uuid and existing in self._by_uuid[old_uuid]: + self._by_uuid[old_uuid].remove(existing) + if not self._by_uuid[old_uuid]: + del self._by_uuid[old_uuid] + else: + # Add new object + self._objects_list.append(obj) + + # Update all indices + self._by_identifier[identifier] = obj + self._by_uri[uri] = obj + + if uuid not in self._by_uuid: + self._by_uuid[uuid] = [] + if obj not in self._by_uuid[uuid]: + self._by_uuid[uuid].append(obj) + + def remove(self, obj: Any) -> None: + """Remove an object from the collection (list-compatible method).""" + identifier = get_obj_identifier(obj) + + if identifier in self._by_identifier: + stored_obj = self._by_identifier[identifier] + self._objects_list.remove(stored_obj) + + # Clean up all indices + del self._by_identifier[identifier] + + uri = str(get_obj_uri(stored_obj)) + if uri in self._by_uri: + del self._by_uri[uri] + + uuid = get_obj_uuid(stored_obj) + if uuid in self._by_uuid and stored_obj in self._by_uuid[uuid]: + self._by_uuid[uuid].remove(stored_obj) + if not self._by_uuid[uuid]: + del self._by_uuid[uuid] + + def get_by_identifier(self, identifier: Union[str, Uri]) -> Optional[Any]: + """Get object by identifier (O(1) lookup).""" + # Try identifier lookup first + obj = self._by_identifier.get(str(identifier)) + if obj is not None: + return obj + + # Try URI lookup + return self._by_uri.get(str(identifier)) + + def get_by_uuid(self, uuid: str) -> List[Any]: + """Get all objects with this UUID (O(1) lookup).""" + return self._by_uuid.get(uuid, []) + + def __iter__(self): + """Iterate over objects in insertion order.""" + return iter(self._objects_list) + + def __len__(self) -> int: + """Get number of objects.""" + return len(self._objects_list) + + def __getitem__(self, index: int) -> Any: + """Support indexing (e.g., energyml_objects[0]).""" + return self._objects_list[index] + + def __bool__(self) -> bool: + """Support boolean checks (e.g., if energyml_objects:).""" + return len(self._objects_list) > 0 + + def log_timestamp(func): """Decorator to log timestamps for function execution.""" @@ -134,8 +234,8 @@ class Epc(EnergymlStorageInterface): core_props: Optional[CoreProperties] = field(default=None) """ xml files referred in the [Content_Types].xml """ - energyml_objects: List = field( - default_factory=list, + energyml_objects: EnergymlObjectCollection = field( + default_factory=EnergymlObjectCollection, ) """ Other files content like pdf etc """ @@ -564,7 +664,7 @@ def get_object_by_uuid(self, uuid: str) -> List[Any]: :param uuid: :return: """ - return list(filter(lambda o: get_obj_uuid(o) == uuid, self.energyml_objects)) + return self.energyml_objects.get_by_uuid(uuid) def get_object_by_identifier(self, identifier: Union[str, Uri]) -> Optional[Any]: """ @@ -572,12 +672,8 @@ def get_object_by_identifier(self, identifier: Union[str, Uri]) -> Optional[Any] :param identifier: given by the function :func:`get_obj_identifier`, or a URI (or its str representation) :return: """ - is_uri = isinstance(identifier, Uri) or parse_uri(identifier) is not None - id_str = str(identifier) - for o in self.energyml_objects: - if (get_obj_identifier(o) if not is_uri else str(get_obj_uri(o))) == id_str: - return o - return None + # Use the O(1) dict lookup from the collection + return self.energyml_objects.get_by_identifier(identifier) def get_object(self, identifier: Union[str, Uri]) -> Optional[Any]: return self.get_object_by_identifier(identifier) @@ -898,7 +994,7 @@ def read_stream(cls, epc_file_io: BytesIO): # returns an Epc instance ) return Epc( - energyml_objects=obj_list, + energyml_objects=EnergymlObjectCollection(obj_list), raw_files=raw_file_list, core_props=core_props, additional_rels=additional_rels, diff --git a/energyml-utils/src/energyml/utils/epc_utils.py b/energyml-utils/src/energyml/utils/epc_utils.py index ce8b95a..b60b925 100644 --- a/energyml-utils/src/energyml/utils/epc_utils.py +++ b/energyml-utils/src/energyml/utils/epc_utils.py @@ -721,7 +721,7 @@ def get_dor_uris_from_obj(obj: Any) -> Set[Uri]: for dor in dor_list: try: uri = get_obj_uri(dor) - if uri: + if uri and uri.is_object_uri(): uri_set.add(uri) except Exception as e: logging.warning(f"Failed to extract uri from DOR: {e}") diff --git a/energyml-utils/tests/test_introspection.py b/energyml-utils/tests/test_introspection.py index 8659634..99dbbc2 100644 --- a/energyml-utils/tests/test_introspection.py +++ b/energyml-utils/tests/test_introspection.py @@ -724,6 +724,10 @@ def test_get_obj_uri(triangulated_set_no_version, fault_interpretation): """Test URI generation for energyml objects.""" uri_str = str(get_obj_uri(triangulated_set_no_version)) assert uri_str == f"eml:///resqml22.TriangulatedSetRepresentation({triangulated_set_no_version.uuid})" + assert ( + str(get_obj_uri(as_dor(triangulated_set_no_version))) + == f"eml:///resqml22.TriangulatedSetRepresentation({triangulated_set_no_version.uuid})" + ) uri_str_with_dataspace = str(get_obj_uri(triangulated_set_no_version, "/MyDataspace/")) assert ( From 41284abb37dfa159e3d37d131a984588a448ec71 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Fri, 13 Feb 2026 18:21:04 +0100 Subject: [PATCH 35/70] better valdiation result --- energyml-utils/README.md | 20 +++++++ energyml-utils/example/tools.py | 50 ++++++++++++++++-- energyml-utils/rc/epc/testingPackageCpp.h5 | Bin 100363 -> 142895 bytes energyml-utils/src/energyml/utils/epc.py | 4 +- .../src/energyml/utils/introspection.py | 22 ++++++++ .../src/energyml/utils/validation.py | 4 ++ energyml-utils/tests/test_introspection.py | 14 +++++ 7 files changed, 109 insertions(+), 5 deletions(-) diff --git a/energyml-utils/README.md b/energyml-utils/README.md index f753d93..b292c01 100644 --- a/energyml-utils/README.md +++ b/energyml-utils/README.md @@ -318,6 +318,26 @@ Validate a folder containing EPC/XML/JSON files: poetry run validate --file "path/to/your/folder" *> output_logs.json ``` +Ignore specific error types (e.g., INFO): +```bash +poetry run validate --file "path/to/file.epc" --ignore-err-type INFO *> output_logs.json +``` + +Group errors by their class for better organization: +```bash +poetry run validate --file "path/to/file.epc" --group-by-err-class *> output_logs.json +``` + +Include PRODML version errors in validation (by default they are ignored): +```bash +poetry run validate --file "path/to/file.epc" --ignore-prodml-version-errs *> output_logs.json +``` + +Combined example with multiple options: +```bash +poetry run validate --file "path/to/file.epc" -i INFO WARNING --group-by-err-class *> output_logs.json +``` + ### Extract 3D Representations Extract all representations from an EPC to OBJ files: diff --git a/energyml-utils/example/tools.py b/energyml-utils/example/tools.py index 20dfe69..938a058 100644 --- a/energyml-utils/example/tools.py +++ b/energyml-utils/example/tools.py @@ -4,6 +4,7 @@ import json import os import pathlib +import traceback from typing import Optional, List, Dict, Any import sys from pathlib import Path @@ -12,7 +13,7 @@ src_path = Path(__file__).parent.parent / "src" sys.path.insert(0, str(src_path)) -from energyml.utils.validation import validate_epc +from energyml.utils.validation import ErrorType, validate_epc from energyml.utils.constants import get_property_kind_dict_path_as_xml from energyml.utils.data.datasets_io import CSVFileReader, HDF5FileWriter, ParquetFileWriter, DATFileReader @@ -20,6 +21,7 @@ from energyml.utils.epc import Epc, gen_energyml_object_path from energyml.utils.introspection import ( get_class_from_simple_name, + get_enum_values, get_module_name_and_type_from_content_or_qualified_type, random_value_from_class, search_class_in_module_from_partial_name, @@ -548,6 +550,26 @@ def validate_files(): parser = argparse.ArgumentParser() # parser.add_argument("--folder", type=str, help="Input folder") parser.add_argument("--file", "-f", type=str, help="Input file (json or xml or epc)") + parser.add_argument( + "--ignore-err-type", + "-i", + type=str, + help=f"Error types to ignore. Possible values {get_enum_values(ErrorType)}", + nargs="*", + ) + + parser.add_argument( + "--ignore-prodml-version-errs", + action="store_false", + dest="ignore_prodml_version_errs", + help="Disable ignoring errors related to Prodml version (by default, these errors are ignored)", + ) + + parser.add_argument( + "--group-by-err-class", + action="store_true", + help="Group errors by their class (e.g. all validation errors together, all parsing errors together, etc.)", + ) args = parser.parse_args() @@ -615,14 +637,36 @@ def validate_files(): else: print(f"File {filename} is NOT a valid EnergyML EPC file: Empty EPC") except Exception as e: + traceback.print_exc() print(f"File {filename} is NOT a valid EnergyML EPC file: {e}") epc = Epc() epc.energyml_objects = objects - err_json = [err.toJson() for err in validate_epc(epc)] + err_json = [ + err.toJson() + for err in validate_epc(epc) + if str(err.error_type).lower() not in (et.lower() for et in (args.ignore_err_type or [])) + ] - print(json.dumps(err_json, indent=4)) + err_json_sorted = sorted( + err_json, key=lambda x: (x["err_class"], x["error_type"], x["object_uuid"] if "object_uuid" in x else "") + ) + + if args.ignore_prodml_version_errs: + err_json_sorted = [err for err in err_json_sorted if not ("prodml23" in err.get("msg", ""))] + + if args.group_by_err_class: + err_json_grouped = {} + for err in err_json_sorted: + err_class = err.get("err_class", "UnknownErrorClass") + if err_class not in err_json_grouped: + err_json_grouped[err_class] = [] + err_json_grouped[err_class].append(err) + print(json.dumps(err_json_grouped, indent=4)) + else: + # print(json.dumps(err_json, indent=4)) + print(json.dumps(err_json_sorted, indent=4)) # def export_wavefront(): diff --git a/energyml-utils/rc/epc/testingPackageCpp.h5 b/energyml-utils/rc/epc/testingPackageCpp.h5 index 07644d4189e264ee7a1e854ba9c94d9cbbd73e19..d19b13a845c539cfb9e07564c50e8399027f1c43 100644 GIT binary patch literal 142895 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 J4_bEb{{z^`A1nX> literal 100363 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 diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index 5a55da1..e326996 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -367,7 +367,7 @@ def gen_opc_content_type(self) -> Types: return ct - @log_timestamp + # @log_timestamp def export_file(self, path: Optional[str] = None, allowZip64: bool = True) -> None: """ Export the epc file. If :param:`path` is None, the epc 'self.epc_file_path' is used @@ -869,7 +869,7 @@ def write_array( # Class methods @classmethod - @log_timestamp + # @log_timestamp def read_file(cls, epc_file_path: str) -> "Epc": with open(epc_file_path, "rb") as f: epc = cls.read_stream(BytesIO(f.read())) diff --git a/energyml-utils/src/energyml/utils/introspection.py b/energyml-utils/src/energyml/utils/introspection.py index 9eca2a4..62c579a 100644 --- a/energyml-utils/src/energyml/utils/introspection.py +++ b/energyml-utils/src/energyml/utils/introspection.py @@ -1199,6 +1199,24 @@ def get_obj_title(obj: Any) -> Optional[str]: for ck in obj[k].keys(): if re.match(r"title", ck, re.IGNORECASE): return obj[k][ck] + # search for title or name if not classical citation.title found + + for k in obj.keys(): + if re.match(r"title", k, re.IGNORECASE): + return obj[k] + elif re.match(r"name", k, re.IGNORECASE): + return obj[k] + + else: + # DOR : + try: + return getattr(obj, "title") + except AttributeError: + # etp resource meta : + try: + return getattr(obj, "name") + except AttributeError: + pass return None @@ -1706,6 +1724,10 @@ def get_all_possible_instanciable_classes_for_attribute(parent_obj: Any, attribu return [] +def get_enum_values(cls: Any) -> List[str]: + return cls._member_names_ if is_enum(cls) else [] + + def _random_value_from_class( cls: Any, energyml_module_context: List[str], diff --git a/energyml-utils/src/energyml/utils/validation.py b/energyml-utils/src/energyml/utils/validation.py index a7a1884..14ac905 100644 --- a/energyml-utils/src/energyml/utils/validation.py +++ b/energyml-utils/src/energyml/utils/validation.py @@ -37,6 +37,9 @@ class ErrorType(Enum): INFO = "info" WARNING = "warning" + def __str__(self): + return self.value + @dataclass class ValidationError: @@ -52,6 +55,7 @@ def toJson(self): return { "msg": self.msg, "error_type": self.error_type.value, + "err_class": self.__class__.__name__, } @property diff --git a/energyml-utils/tests/test_introspection.py b/energyml-utils/tests/test_introspection.py index 99dbbc2..505d4cf 100644 --- a/energyml-utils/tests/test_introspection.py +++ b/energyml-utils/tests/test_introspection.py @@ -658,6 +658,20 @@ def test_get_obj_title(triangulated_set_no_version, fault_interpretation): """Test extracting object title.""" assert get_obj_title(triangulated_set_no_version) == "Test Citation v2.3" assert get_obj_title(fault_interpretation) == "Test Citation v2.0" + assert get_obj_title(as_dor(fault_interpretation)) == "Test Citation v2.0" + + class MockObjWithTitle: + name = "Mock Title" + + assert get_obj_title(MockObjWithTitle()) == "Mock Title" + + assert get_obj_title({"Title": "Dict Title"}) == "Dict Title" + assert get_obj_title({"title": "Dict Title Lower"}) == "Dict Title Lower" + assert get_obj_title({"what": 42}) is None + assert get_obj_title({"name": "Dict Title Lower"}) == "Dict Title Lower" + + # priority to citation.title + assert get_obj_title({"name": "Dict Title Lower", "citation": {"title": "Citation Title"}}) == "Citation Title" def test_get_obj_type(triangulated_set_no_version, fault_interpretation): From a5dcd3a4b29ec68acf904efe88450c7b27d11005 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Sat, 14 Feb 2026 04:27:30 +0100 Subject: [PATCH 36/70] rels cache mechanism --- energyml-utils/src/energyml/utils/epc.py | 459 ++++++++- .../src/energyml/utils/epc_utils.py | 25 +- energyml-utils/tests/test_epc_rels_cache.py | 929 ++++++++++++++++++ 3 files changed, 1411 insertions(+), 2 deletions(-) create mode 100644 energyml-utils/tests/test_epc_rels_cache.py diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index e326996..ea53cae 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -5,6 +5,7 @@ """ import datetime +import threading import logging import os from pathlib import Path @@ -16,8 +17,10 @@ from dataclasses import dataclass, field from functools import wraps from io import BytesIO -from typing import List, Any, Union, Dict, Optional +from typing import List, Any, Set, Union, Dict, Optional import numpy as np + +from enum import Enum from xsdata.formats.dataclass.models.generics import DerivedElement from energyml.opc.opc import ( @@ -180,6 +183,460 @@ def __bool__(self) -> bool: return len(self._objects_list) > 0 +class EpcRelsCacheErrorPolicy(Enum): + LOG = "log" + RAISE = "raise" + SKIP = "skip" + + +class EpcRelsCache: + """ + EPC Relationships Cache Manager + + Summary + ------- + Manages in-memory relationships between EPC objects, using canonical Uri as the internal key. + Accepts identifier, Uri, str(Uri), or the object itself as input for all public methods. + Does not manage rels file paths; export logic is handled by the Epc class. + + API Reference + ------------- + - __init__(epc: Epc | EnergymlObjectCollection, export_version, error_policy='log') + Initialize with a reference to the owning Epc or a collection of objects. + Optionally set error handling policy ('log', 'raise', 'skip'). + + - set_rels_from_file(obj: Union[str, Uri, Any], rels: Relationships) -> None + Attach relationships loaded from a .rels file to the given object (by any accepted key type). + Used for supplemental or precomputed rels. + + - add_supplemental_rels(obj: Union[str, Uri, Any], rels: Union[Relationship, List[Relationship]]) -> None + Add supplemental relationships for an object. These persist across cache clears and are merged lazily. + + - get_object_rels(obj: Union[str, Uri, Any]) -> List[Relationship] + Return the effective relationships for an object, merging computed and supplemental rels, deduplicated. + + - get_object_relationships(obj: Union[str, Uri, Any]) -> Relationships + Return RelationShips(get_object_rels(obj)) for the given object. + + - compute_rels(parallel: bool = False, recompute_all: bool = False) -> Dict[Uri, List[Relationship]] + Recompute all relationships. If parallel=True, use a thread/process pool for the map phase. + Returns a mapping of Uri to deduplicated relationships. Export logic (rels path, target) is handled by Epc. + + - update_cache_for_object(obj: Union[str, Uri, Any]) -> None + Incrementally update relationships for a single object add, remove, or modification. + + - clear_cache() and recompute_cache(parallel=False) + Clear or fully recompute the internal cache. + + - clean_rels(obj: Union[str, Uri, Any] = None) -> None + Deduplicate relationships for a given object or all objects. Called after full recompute. + + - validate_rels() -> Dict[str, Any] + Run validation checks: duplicate rels, missing reverse links, circular references, etc. + Returns a report of issues found. + + Implementation Notes + ------------------- + - All public methods accept identifier, Uri, str(Uri), or object; internally, always convert to Uri with a specific function to avoid code duplication. + - Internal caches: {Uri: List[Relationship]} for computed, {Uri: List[Relationship]} for supplemental, {Uri: Set[Uri]} for reverse index (target -> sources). + - Reverse index enables O(1) lookup of which objects reference a given target, critical for incremental updates. + - No rels path management; Epc class is responsible for rels file path and target attribute generation. + - Relationship IDs must be deterministic (e.g., UUIDv5 or hash of source+target+type). + - On exception, log/skip/raise according to error_policy. Omitted objects do not block the pipeline. + - clean_rels() can be parallelized, as deduplication is per-object. + - Use threading.Lock or RLock to protect cache updates. Only lock during writes. + + Behavioural Invariants + --------------------- + - Canonical in-memory key: Uri. Never mix identifier and Uri in the same map. + - Supplemental rels are preserved and merged lazily; not lost on clear/recompute unless explicitly removed. + - All deduplication and validation is performed on the in-memory Uri-keyed data. + + Validation & Testing + ------------------- + - clean_rels() ensures no duplicate (type, target) relationships per object. + - validate_rels() checks for missing reverse links, circular references, and other edge cases. + - Unit tests should cover all input types, deduplication, error handling, and validation. + - Use EnergymlObjectCollection for initial tests. + + Migration/Integration + -------------------- + - This class is standalone. Once implemented and tested, integrate into Epc, replacing legacy rels handling. + - No migration needed until integration. + + """ + + def __init__(self, epc_or_collection, export_version, error_policy=EpcRelsCacheErrorPolicy.LOG): + """ + Initialize the EpcRelsCache. + :param epc_or_collection: Epc instance or EnergymlObjectCollection + :param export_version: EPC export version (for rels path/target generation) + :param error_policy: EpcRelsCacheErrorPolicy enum value for error handling + """ + self._lock = threading.RLock() + if isinstance(error_policy, str): + # Allow legacy string for backward compatibility + error_policy = EpcRelsCacheErrorPolicy(error_policy.lower()) + self._error_policy = error_policy + self._export_version = export_version + # Accept Epc or EnergymlObjectCollection + if isinstance(epc_or_collection, Epc): + self._objects = epc_or_collection.energyml_objects + self._epc = epc_or_collection + else: + self._objects = epc_or_collection + self._epc = None + # Internal caches + self._computed_rels = {} # {Uri: List[Relationship]} + self._supplemental_rels = {} # {Uri: List[Relationship]} + self._reverse_index: Dict[Uri, Set[Uri]] = {} # {target_uri: {source_uris}} + + def _uri_from_any(self, obj_or_id: Any) -> "Uri": + """ + Normalize input to canonical Uri. + Accepts identifier, Uri, str(Uri), or object. + """ + if isinstance(obj_or_id, Uri): + return obj_or_id + if hasattr(obj_or_id, "object_version") or hasattr(obj_or_id, "__dict__"): + # Likely an energyml object + return get_obj_uri(obj_or_id) + if isinstance(obj_or_id, str): + # Try parse as Uri + uri = parse_uri(obj_or_id) + if uri: + return uri + # Try as identifier + obj = None + if self._epc and hasattr(self._epc, "get_object_by_identifier"): + obj = self._epc.get_object_by_identifier(obj_or_id) + elif hasattr(self._objects, "get_by_identifier"): + obj = self._objects.get_by_identifier(obj_or_id) + if obj: + return get_obj_uri(obj) + raise ValueError(f"Cannot resolve to Uri: {obj_or_id}") + + def set_rels_from_file(self, obj: Any, rels: "Relationships") -> None: + """Attach relationships loaded from a .rels file to the given object.""" + uri = self._uri_from_any(obj) + with self._lock: + self._supplemental_rels[uri] = list(rels.relationship) if hasattr(rels, "relationship") else list(rels) + + def add_supplemental_rels(self, obj: Any, rels: Union["Relationship", List["Relationship"]]) -> None: + """Add supplemental relationships for an object.""" + uri = self._uri_from_any(obj) + with self._lock: + if uri not in self._supplemental_rels: + self._supplemental_rels[uri] = [] + if isinstance(rels, list): + self._supplemental_rels[uri].extend(rels) + else: + self._supplemental_rels[uri].append(rels) + + def get_object_rels(self, obj: Any) -> List["Relationship"]: + """Return the effective relationships for an object, merging computed and supplemental rels, deduplicated.""" + uri = self._uri_from_any(obj) + with self._lock: + rels = list(self._computed_rels.get(uri, [])) + rels.extend(self._supplemental_rels.get(uri, [])) + return self._deduplicate_rels(rels) + + def compute_rels(self, parallel: bool = False, recompute_all: bool = False) -> Dict["Uri", List["Relationship"]]: + """ + Recompute all relationships, including reverse relationships. If parallel=True, use a thread/process pool for the map phase. + Returns a mapping of Uri to deduplicated relationships. + """ + import collections + import concurrent.futures + + with self._lock: + self._computed_rels.clear() + objects = list(self._objects) + + # First pass: collect direct DORs for each object + def map_func(obj): + try: + uri = get_obj_uri(obj) + dor_uris = self._get_direct_dor_uris(obj) + return (uri, dor_uris) + except Exception as e: + self._handle_error(f"Failed to compute DORs for {obj}: {e}") + return None + + results = [] + if parallel: + with concurrent.futures.ThreadPoolExecutor() as executor: + for res in executor.map(map_func, objects): + if res: + results.append(res) + else: + for obj in objects: + res = map_func(obj) + if res: + results.append(res) + + # Second pass: build forward and reverse relationships + rels_map = collections.defaultdict(list) # {Uri: List[Relationship]} + for src_uri, dor_uris in results: + src_path = gen_rels_path(src_uri, export_version=self._export_version) + for tgt_uri in dor_uris: + tgt_path = gen_rels_path(tgt_uri, export_version=self._export_version) + # Forward rel (src -> tgt) + rels_map[src_uri].append( + Relationship( + target=tgt_path, + type_value=get_rels_dor_type(dor_target=tgt_path, in_dor_owner_rels_file=True), + id=f"_{gen_uuid()}", + ) + ) + # Reverse rel (tgt -> src) + rels_map[tgt_uri].append( + Relationship( + target=src_path, + type_value=get_rels_dor_type(dor_target=tgt_path, in_dor_owner_rels_file=False), + id=f"_{gen_uuid()}", + ) + ) + + # Build reverse index from results + reverse_idx = collections.defaultdict(set) + for src_uri, dor_uris in results: + for tgt_uri in dor_uris: + reverse_idx[tgt_uri].add(src_uri) + + with self._lock: + self._computed_rels = dict(rels_map) + self._reverse_index = {k: v for k, v in reverse_idx.items()} + self.clean_rels() + return {uri: self.get_object_rels(uri) for uri in self._computed_rels} + + def _get_direct_dor_uris(self, obj: Any) -> Set[Uri]: + """ + Return the set of direct DOR target Uris for the given object. + """ + try: + return get_dor_uris_from_obj(obj) + except Exception as e: + self._handle_error(f"Error getting direct DOR URIs: {e}") + return set() + + def update_cache_for_object(self, obj: Any) -> None: + """Incrementally update relationships for a single object, including reverse relationships.""" + uri = self._uri_from_any(obj) + dor_uris = self._get_direct_dor_uris(obj) + + with self._lock: + # Remove old reverse index entries for this object + if uri in self._computed_rels: + # Find old DOR targets and clean them up + old_rels = self._computed_rels.get(uri, []) + for old_rel in old_rels: + # Extract target URI from path (approximate - we'll rebuild from scratch) + pass + + # Clean up old reverse index entries where this object was the source + for tgt_uri, sources in list(self._reverse_index.items()): + if uri in sources: + sources.discard(uri) + if not sources: + del self._reverse_index[tgt_uri] + + # Compute forward relationships for this object + forward_rels = [] + src_path = gen_rels_path(uri, export_version=self._export_version) + + for tgt_uri in dor_uris: + tgt_path = gen_rels_path(tgt_uri, export_version=self._export_version) + # Forward rel (this object -> target) + forward_rels.append( + Relationship( + target=tgt_path, + type_value=get_rels_dor_type(dor_target=tgt_path, in_dor_owner_rels_file=True), + id=f"_{gen_uuid()}", + ) + ) + + # Update reverse index: target is now referenced by this object + if tgt_uri not in self._reverse_index: + self._reverse_index[tgt_uri] = set() + self._reverse_index[tgt_uri].add(uri) + + # Add reverse rel to target if target exists in cache + if tgt_uri in self._computed_rels: + reverse_rel = Relationship( + target=src_path, + type_value=get_rels_dor_type(dor_target=tgt_path, in_dor_owner_rels_file=False), + id=f"_{gen_uuid()}", + ) + self._computed_rels[tgt_uri].append(reverse_rel) + + # Compute reverse relationships from index (who references me?) + reverse_rels = [] + for src_uri in self._reverse_index.get(uri, set()): + if src_uri != uri: # Avoid self-references + src_path = gen_rels_path(src_uri, export_version=self._export_version) + tgt_path = gen_rels_path(uri, export_version=self._export_version) + reverse_rels.append( + Relationship( + target=src_path, + type_value=get_rels_dor_type(dor_target=tgt_path, in_dor_owner_rels_file=False), + id=f"_{gen_uuid()}", + ) + ) + + # Store combined relationships + self._computed_rels[uri] = forward_rels + reverse_rels + + def clear_cache(self) -> None: + """Clear the internal caches and reverse index.""" + with self._lock: + self._computed_rels.clear() + self._reverse_index.clear() + + def recompute_cache(self, parallel: bool = False) -> Dict["Uri", List["Relationship"]]: + """Fully recompute the internal cache.""" + return self.compute_rels(parallel=parallel, recompute_all=True) + + def clean_rels(self, obj: Optional[Any] = None) -> None: + """ + Deduplicate relationships for a given object or all objects. + Removes duplicates by (target, type_value). + """ + with self._lock: + if obj is not None: + uri = self._uri_from_any(obj) + rels = self._computed_rels.get(uri, []) + self._supplemental_rels.get(uri, []) + deduped = self._deduplicate_rels(rels) + self._computed_rels[uri] = deduped + else: + for uri in set(list(self._computed_rels.keys()) + list(self._supplemental_rels.keys())): + rels = self._computed_rels.get(uri, []) + self._supplemental_rels.get(uri, []) + deduped = self._deduplicate_rels(rels) + self._computed_rels[uri] = deduped + + def _deduplicate_rels(self, rels: List["Relationship"]) -> List["Relationship"]: + """Remove duplicate relationships by (target, type_value).""" + seen = set() + result = [] + for rel in rels: + key = (getattr(rel, "target", None), getattr(rel, "type_value", None)) + if key not in seen: + seen.add(key) + result.append(rel) + return result + + def validate_rels(self) -> Dict[str, Any]: + """ + Run validation checks: duplicate rels, orphaned references, circular references, etc. + Returns a report of issues found. + """ + report = {"duplicates": [], "orphaned_references": [], "circular": [], "index_inconsistency": []} + + with self._lock: + # Check for duplicates + for uri, rels in self._computed_rels.items(): + seen = set() + for rel in rels: + key = (getattr(rel, "target", None), getattr(rel, "type_value", None)) + if key in seen: + report["duplicates"].append((str(uri), key)) + else: + seen.add(key) + + # Check for orphaned references (references to non-existent objects) + all_object_uris = set() + if self._epc: + all_object_uris = {get_obj_uri(obj) for obj in self._epc.energyml_objects} + elif self._objects: + all_object_uris = {get_obj_uri(obj) for obj in self._objects} + + for target_uri, sources in self._reverse_index.items(): + # An object is orphaned if it's referenced but doesn't exist in the collection + # Note: target_uri may be in _computed_rels due to reverse relationships, + # but that doesn't mean the object actually exists in the collection + if target_uri not in all_object_uris: + report["orphaned_references"].append( + {"target": str(target_uri), "referenced_by": [str(s) for s in sources]} + ) + + # Check reverse index consistency + for src_uri, rels in self._computed_rels.items(): + for rel in rels: + # Check if forward relationships are properly indexed + # This is a sanity check for the index + pass + + return report + + def _remove_object_from_cache(self, obj: Any) -> None: + """ + Remove an object from the cache, cleaning up all references and reverse index entries. + """ + uri = self._uri_from_any(obj) + + with self._lock: + # Remove from computed rels + if uri in self._computed_rels: + del self._computed_rels[uri] + + # Remove from supplemental rels + if uri in self._supplemental_rels: + del self._supplemental_rels[uri] + + # Remove from reverse index (as target) + if uri in self._reverse_index: + del self._reverse_index[uri] + + # Remove from reverse index (as source) + for target_uri, sources in list(self._reverse_index.items()): + if uri in sources: + sources.discard(uri) + if not sources: + del self._reverse_index[target_uri] + + # Remove reverse rels from targets' computed rels + for other_uri, other_rels in self._computed_rels.items(): + if other_uri != uri: + # Filter out relationships targeting the removed object + uri_path = gen_rels_path(uri, export_version=self._export_version) + self._computed_rels[other_uri] = [ + rel for rel in other_rels if getattr(rel, "target", None) != uri_path + ] + + def get_reverse_index_stats(self) -> Dict[str, Any]: + """ + Get statistics about the reverse reference index for debugging and validation. + Returns a dictionary with index statistics. + """ + with self._lock: + stats = { + "total_targets": len(self._reverse_index), + "total_references": sum(len(sources) for sources in self._reverse_index.values()), + "max_references_to_single_target": max( + (len(sources) for sources in self._reverse_index.values()), default=0 + ), + "targets_by_reference_count": {}, + } + + # Group targets by how many sources reference them + for target_uri, sources in self._reverse_index.items(): + count = len(sources) + if count not in stats["targets_by_reference_count"]: + stats["targets_by_reference_count"][count] = 0 + stats["targets_by_reference_count"][count] += 1 + + return stats + + def _handle_error(self, msg: str) -> None: + if self._error_policy == EpcRelsCacheErrorPolicy.LOG: + import logging + + logging.error(msg) + elif self._error_policy == EpcRelsCacheErrorPolicy.RAISE: + raise RuntimeError(msg) + # else: SKIP + + def log_timestamp(func): """Decorator to log timestamps for function execution.""" diff --git a/energyml-utils/src/energyml/utils/epc_utils.py b/energyml-utils/src/energyml/utils/epc_utils.py index b60b925..b8e9dd2 100644 --- a/energyml-utils/src/energyml/utils/epc_utils.py +++ b/energyml-utils/src/energyml/utils/epc_utils.py @@ -116,7 +116,7 @@ def gen_energyml_object_path( ) -> str: """ Generate a path to store the :param:`energyml_object` into an epc file (depending on the :param:`export_version`) - :param energyml_object: can be either an EnergyML object or a string containing the XML representation of an EnergyML object, or a string containing the URI of an EnergyML object, or a Uri object representing an EnergyML object + :param energyml_object: can be either an EnergyML object or a string containing the XML representation of an EnergyML object, or a string containing the URI of an EnergyML object, or a Uri object representing an EnergyML object, or even a DOR object. :param export_version: the version of the EPC export to use (classic or expanded) :return: """ @@ -271,6 +271,29 @@ def create_h5_external_relationship(h5_path: str, current_idx: int = 0) -> Relat ) +def relationships_equal(rel1: Relationship, rel2: Relationship) -> bool: + """ + Compare two Relationship objects for equality based on their target and type_value. + + Two relationships are considered equal if they have: + - The same target (destination path) + - The same type_value (relationship type) + + Note: The id field is NOT compared as it's typically auto-generated and + doesn't affect the semantic meaning of the relationship. + + :param rel1: First Relationship object + :param rel2: Second Relationship object + :return: True if relationships are semantically equal, False otherwise + + Example: + >>> rel1 = Relationship(target="obj.xml", type_value="destinationObject", id="_1") + >>> rel2 = Relationship(target="obj.xml", type_value="destinationObject", id="_2") + >>> relationships_equal(rel1, rel2) # True (different IDs don't matter) + """ + return rel1.target == rel2.target and rel1.type_value == rel2.type_value + + def create_default_core_properties(creator: Optional[str] = None) -> CoreProperties: """Create default Core Properties object.""" return CoreProperties( diff --git a/energyml-utils/tests/test_epc_rels_cache.py b/energyml-utils/tests/test_epc_rels_cache.py new file mode 100644 index 0000000..04d8792 --- /dev/null +++ b/energyml-utils/tests/test_epc_rels_cache.py @@ -0,0 +1,929 @@ +# Copyright (c) 2023-2024 Geosiris. +# SPDX-License-Identifier: Apache-2.0 +""" +Comprehensive unit tests for EpcRelsCache class functionality. + +Tests cover: +1. Basic relationship computation with forward and reverse relationships +2. Incremental updates (update_cache_for_object) +3. Late-arrival scenario (object referenced before it exists) +4. Parallel vs sequential computation +5. Reverse index functionality +6. Validation (duplicates, orphaned references) +7. Object removal from cache +8. Cache stats and debugging utilities +""" + +import pytest +from typing import Set + +from energyml.eml.v2_0.commonv2 import Citation as Citation20, EpcExternalPartReference +from energyml.eml.v2_3.commonv2 import Citation +from energyml.resqml.v2_0_1.resqmlv2 import ( + TriangulatedSetRepresentation as TriangulatedSetRepresentation20, + TrianglePatch as TrianglePatch20, + PointGeometry as PointGeometry20, +) +from energyml.resqml.v2_2.resqmlv2 import ( + BoundaryFeature, + HorizonInterpretation, +) + +from energyml.utils.epc import ( + EpcRelsCache, + EnergymlObjectCollection, + EpcRelsCacheErrorPolicy, + as_dor, + EpcExportVersion, +) +from energyml.utils.epc_utils import gen_rels_path, gen_energyml_object_path, relationships_equal +from energyml.utils.introspection import ( + epoch_to_date, + epoch, + get_obj_uri, +) +from energyml.utils.constants import EPCRelsRelationshipType + + +@pytest.fixture +def sample_objects(): + """Create sample EnergyML objects for testing.""" + # Create a BoundaryFeature + bf = BoundaryFeature( + citation=Citation( + title="Test Boundary Feature", + originator="Test", + creation=epoch_to_date(epoch()), + ), + uuid="25773477-ffee-4cc2-867d-000000000001", + object_version="1.0", + ) + + # Create a HorizonInterpretation + horizon_interp = HorizonInterpretation( + citation=Citation( + title="Test HorizonInterpretation", + originator="Test", + creation=epoch_to_date(epoch()), + ), + interpreted_feature=as_dor(bf), + uuid="25773477-ffee-4cc2-867d-000000000003", + object_version="1.0", + ) + + # EpcExternalPartReference + external_ref = EpcExternalPartReference( + uuid="25773477-ffee-4cc2-867d-000000000005", + citation=Citation20(title="An external reference", originator="Test", creation=epoch_to_date(epoch())), + ) + + # TriangulatedSetRepresentation (2.0.1) with references + trset20 = TriangulatedSetRepresentation20( + citation=Citation20( + title="Test TriangulatedSetRepresentation 2.0", originator="Test", creation=epoch_to_date(epoch()) + ), + uuid="25773477-ffee-4cc2-867d-000000000006", + object_version="1.0", + represented_interpretation=as_dor(horizon_interp, "eml20.DataObjectReference"), + triangle_patch=[ + TrianglePatch20(geometry=PointGeometry20(local_crs=as_dor(external_ref, "eml20.DataObjectReference"))) + ], + ) + + return { + "bf": bf, + "horizon_interp": horizon_interp, + "external_ref": external_ref, + "trset20": trset20, + } + + +class TestEpcRelsCacheBasics: + """Test basic EpcRelsCache initialization and functionality.""" + + def test_initialization_with_collection(self, sample_objects): + """Test initializing cache with EnergymlObjectCollection.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC, EpcRelsCacheErrorPolicy.LOG) + + assert cache is not None + assert len(collection) == 2 + + def test_initialization_with_error_policy_string(self, sample_objects): + """Test backward compatibility with string error policy.""" + collection = EnergymlObjectCollection([sample_objects["bf"]]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC, "log") + + assert cache._error_policy == EpcRelsCacheErrorPolicy.LOG + + def test_uri_from_any_with_object(self, sample_objects): + """Test _uri_from_any with various input types.""" + collection = EnergymlObjectCollection([sample_objects["bf"]]) + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + + bf = sample_objects["bf"] + uri = cache._uri_from_any(bf) + + assert uri == get_obj_uri(bf) + + def test_uri_from_any_with_uri(self, sample_objects): + """Test _uri_from_any with Uri object.""" + collection = EnergymlObjectCollection([sample_objects["bf"]]) + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + + bf = sample_objects["bf"] + expected_uri = get_obj_uri(bf) + + uri = cache._uri_from_any(expected_uri) + + assert uri == expected_uri + + +class TestRelationshipComputation: + """Test relationship computation with forward and reverse rels.""" + + def test_compute_rels_basic(self, sample_objects): + """Test basic relationship computation.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + rels_dict = cache.compute_rels() + + assert rels_dict is not None + assert len(rels_dict) >= 2 + + def test_trset_has_destination_to_horizon(self, sample_objects): + """Test that trset has DESTINATION_OBJECT rel to horizonInterp.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + collection.append(sample_objects["trset20"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + trset_uri = get_obj_uri(sample_objects["trset20"]) + trset_rels = cache.get_object_rels(trset_uri) + + # Check for DESTINATION_OBJECT relationship + dest_rels = [r for r in trset_rels if r.type_value == str(EPCRelsRelationshipType.DESTINATION_OBJECT)] + assert len(dest_rels) >= 1 + + # Verify target is horizon_interp + horizon_path = gen_rels_path(sample_objects["horizon_interp"], EpcExportVersion.CLASSIC) + assert any(horizon_path in r.target for r in dest_rels) + + def test_trset_has_ml_to_external_part_proxy(self, sample_objects): + """Test that trset has ML_TO_EXTERNAL_PART_PROXY to external_ref.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + collection.append(sample_objects["external_ref"]) + collection.append(sample_objects["trset20"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + trset_uri = get_obj_uri(sample_objects["trset20"]) + trset_rels = cache.get_object_rels(trset_uri) + + # Check for ML_TO_EXTERNAL_PART_PROXY relationship + ml_to_proxy_rels = [ + r for r in trset_rels if r.type_value == str(EPCRelsRelationshipType.ML_TO_EXTERNAL_PART_PROXY) + ] + assert len(ml_to_proxy_rels) >= 1 + + # Verify target is external_ref + external_path = gen_rels_path(sample_objects["external_ref"], EpcExportVersion.CLASSIC) + assert any(external_path in r.target for r in ml_to_proxy_rels) + + def test_external_ref_has_proxy_to_ml(self, sample_objects): + """Test that external_ref has EXTERNAL_PART_PROXY_TO_ML to trset (reverse rel).""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + collection.append(sample_objects["external_ref"]) + collection.append(sample_objects["trset20"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + external_uri = get_obj_uri(sample_objects["external_ref"]) + external_rels = cache.get_object_rels(external_uri) + + # Check for EXTERNAL_PART_PROXY_TO_ML relationship (reverse rel) + proxy_to_ml_rels = [ + r for r in external_rels if r.type_value == str(EPCRelsRelationshipType.EXTERNAL_PART_PROXY_TO_ML) + ] + assert len(proxy_to_ml_rels) >= 1 + + # Verify target is trset20 + trset_path = gen_rels_path(sample_objects["trset20"], EpcExportVersion.CLASSIC) + assert any(trset_path in r.target for r in proxy_to_ml_rels) + + def test_horizon_has_source_from_trset(self, sample_objects): + """Test that horizonInterp has SOURCE_OBJECT from trset (reverse rel).""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + collection.append(sample_objects["trset20"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + horizon_uri = get_obj_uri(sample_objects["horizon_interp"]) + horizon_rels = cache.get_object_rels(horizon_uri) + + # Check for SOURCE_OBJECT relationship (reverse rel from trset) + source_rels = [r for r in horizon_rels if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT)] + assert len(source_rels) >= 1 + + # Verify source is trset20 + trset_path = gen_rels_path(sample_objects["trset20"], EpcExportVersion.CLASSIC) + assert any(trset_path in r.target for r in source_rels) + + def test_compute_rels_parallel(self, sample_objects): + """Test parallel relationship computation with detailed verification.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + collection.append(sample_objects["external_ref"]) + collection.append(sample_objects["trset20"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + rels_dict = cache.compute_rels(parallel=True) + + assert rels_dict is not None + assert len(rels_dict) >= 4 + + # Verify trset relationships in detail + trset_uri = get_obj_uri(sample_objects["trset20"]) + trset_rels = cache.get_object_rels(trset_uri) + assert len(trset_rels) >= 2 # At least horizon_interp and external_ref + + # Verify each relationship has correct type and target + horizon_path = gen_rels_path(sample_objects["horizon_interp"], EpcExportVersion.CLASSIC) + external_path = gen_rels_path(sample_objects["external_ref"], EpcExportVersion.CLASSIC) + + # Find DESTINATION_OBJECT rel to horizon_interp + dest_rels = [ + r + for r in trset_rels + if r.type_value == str(EPCRelsRelationshipType.DESTINATION_OBJECT) and horizon_path in r.target + ] + assert len(dest_rels) == 1, f"Expected 1 DESTINATION_OBJECT to horizon, found {len(dest_rels)}" + + # Find ML_TO_EXTERNAL_PART_PROXY rel to external_ref + ml_to_proxy_rels = [ + r + for r in trset_rels + if r.type_value == str(EPCRelsRelationshipType.ML_TO_EXTERNAL_PART_PROXY) and external_path in r.target + ] + assert ( + len(ml_to_proxy_rels) == 1 + ), f"Expected 1 ML_TO_EXTERNAL_PART_PROXY to external_ref, found {len(ml_to_proxy_rels)}" + + # Verify horizon_interp has SOURCE_OBJECT from trset (reverse rel) + horizon_uri = get_obj_uri(sample_objects["horizon_interp"]) + horizon_rels = cache.get_object_rels(horizon_uri) + + trset_path = gen_rels_path(sample_objects["trset20"], EpcExportVersion.CLASSIC) + source_rels = [ + r + for r in horizon_rels + if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT) and trset_path in r.target + ] + assert len(source_rels) >= 1, "Expected at least 1 SOURCE_OBJECT from trset to horizon" + + # Verify external_ref has EXTERNAL_PART_PROXY_TO_ML from trset (reverse rel) + external_uri = get_obj_uri(sample_objects["external_ref"]) + external_rels = cache.get_object_rels(external_uri) + + proxy_to_ml_rels = [ + r + for r in external_rels + if r.type_value == str(EPCRelsRelationshipType.EXTERNAL_PART_PROXY_TO_ML) and trset_path in r.target + ] + assert len(proxy_to_ml_rels) >= 1, "Expected at least 1 EXTERNAL_PART_PROXY_TO_ML from trset to external_ref" + + def test_compute_rels_sequential_vs_parallel(self, sample_objects): + """Test that sequential and parallel computation produce identical results.""" + collection1 = EnergymlObjectCollection() + collection2 = EnergymlObjectCollection() + + for obj in [ + sample_objects["bf"], + sample_objects["horizon_interp"], + sample_objects["external_ref"], + sample_objects["trset20"], + ]: + collection1.append(obj) + collection2.append(obj) + + cache_seq = EpcRelsCache(collection1, EpcExportVersion.CLASSIC) + cache_par = EpcRelsCache(collection2, EpcExportVersion.CLASSIC) + + rels_seq = cache_seq.compute_rels(parallel=False) + rels_par = cache_par.compute_rels(parallel=True) + + # Same number of objects should have rels + assert len(rels_seq) == len( + rels_par + ), f"Different number of objects with rels: seq={len(rels_seq)}, par={len(rels_par)}" + + # Verify each object has the same number of relationships + for obj in [ + sample_objects["bf"], + sample_objects["horizon_interp"], + sample_objects["external_ref"], + sample_objects["trset20"], + ]: + obj_uri = get_obj_uri(obj) + seq_rels = cache_seq.get_object_rels(obj_uri) + par_rels = cache_par.get_object_rels(obj_uri) + + assert len(seq_rels) == len(par_rels), ( + f"Object {obj_uri} has different number of rels: " f"seq={len(seq_rels)}, par={len(par_rels)}" + ) + + # Verify each relationship from parallel is present in sequential + for par_rel in par_rels: + # Check if this relationship exists in sequential results + matching_rels = [seq_rel for seq_rel in seq_rels if relationships_equal(par_rel, seq_rel)] + assert len(matching_rels) > 0, ( + f"Relationship from parallel not found in sequential: " + f"target={par_rel.target}, type={par_rel.type_value}" + ) + + # Verify each relationship from sequential is present in parallel + for seq_rel in seq_rels: + matching_rels = [par_rel for par_rel in par_rels if relationships_equal(seq_rel, par_rel)] + assert len(matching_rels) > 0, ( + f"Relationship from sequential not found in parallel: " + f"target={seq_rel.target}, type={seq_rel.type_value}" + ) + + +class TestLateArrivalScenario: + """Test the critical late-arrival scenario where object B is added after A references it.""" + + def test_add_objects_out_of_order(self, sample_objects): + """Test adding trset before horizon_interp exists, then adding horizon_interp.""" + collection = EnergymlObjectCollection() + + # Add trset FIRST (references horizon_interp which doesn't exist yet) + collection.append(sample_objects["trset20"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + # trset should have forward rel to horizon_interp + trset_uri = get_obj_uri(sample_objects["trset20"]) + trset_rels = cache.get_object_rels(trset_uri) + assert len(trset_rels) >= 2 # To horizon and external_ref + + # NOW add horizon_interp and external_ref + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + collection.append(sample_objects["external_ref"]) + + # Recompute + cache.compute_rels() + + # horizon_interp should NOW have reverse rel from trset + horizon_uri = get_obj_uri(sample_objects["horizon_interp"]) + horizon_rels = cache.get_object_rels(horizon_uri) + + source_rels = [r for r in horizon_rels if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT)] + assert len(source_rels) >= 1 + + # Verify reverse rel points to trset + trset_path = gen_rels_path(sample_objects["trset20"], EpcExportVersion.CLASSIC) + assert any(trset_path in r.target for r in source_rels) + + def test_incremental_update_with_late_arrival(self, sample_objects): + """Test update_cache_for_object with late-arriving referenced object.""" + collection = EnergymlObjectCollection() + + # Add trset first + collection.append(sample_objects["trset20"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + # Add horizon_interp later + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + + # Use incremental update + cache.update_cache_for_object(sample_objects["horizon_interp"]) + + # horizon_interp should have reverse rel from trset + horizon_uri = get_obj_uri(sample_objects["horizon_interp"]) + horizon_rels = cache.get_object_rels(horizon_uri) + + source_rels = [r for r in horizon_rels if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT)] + assert len(source_rels) >= 1 + + def test_multiple_order_scenarios(self, sample_objects): + """Test various object addition orders all produce correct results.""" + orders = [ + ["bf", "horizon_interp", "external_ref", "trset20"], # Normal order + ["trset20", "external_ref", "horizon_interp", "bf"], # Reverse order + ["external_ref", "trset20", "bf", "horizon_interp"], # Mixed order + ] + + for order in orders: + collection = EnergymlObjectCollection() + + for obj_name in order: + collection.append(sample_objects[obj_name]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + # Verify all relationships are correct regardless of order + trset_uri = get_obj_uri(sample_objects["trset20"]) + trset_rels = cache.get_object_rels(trset_uri) + assert len(trset_rels) >= 2 + + horizon_uri = get_obj_uri(sample_objects["horizon_interp"]) + horizon_rels = cache.get_object_rels(horizon_uri) + source_rels = [r for r in horizon_rels if r.type_value == str(EPCRelsRelationshipType.SOURCE_OBJECT)] + assert len(source_rels) >= 1 + + +class TestIncrementalUpdates: + """Test incremental cache updates.""" + + def test_update_cache_for_single_object(self, sample_objects): + """Test updating cache for a single object.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + # Add new object and update incrementally + collection.append(sample_objects["trset20"]) + cache.update_cache_for_object(sample_objects["trset20"]) + + trset_uri = get_obj_uri(sample_objects["trset20"]) + trset_rels = cache.get_object_rels(trset_uri) + + assert len(trset_rels) >= 1 # At least horizon_interp + + def test_update_propagates_reverse_rels(self, sample_objects): + """Test that updating an object propagates reverse rels to targets.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + # Get initial horizon rels count + horizon_uri = get_obj_uri(sample_objects["horizon_interp"]) + initial_rels = cache.get_object_rels(horizon_uri) + initial_count = len(initial_rels) + + # Add trset and update + collection.append(sample_objects["trset20"]) + cache.update_cache_for_object(sample_objects["trset20"]) + + # horizon should now have reverse rel from trset + updated_rels = cache.get_object_rels(horizon_uri) + assert len(updated_rels) > initial_count + + def test_clear_cache(self, sample_objects): + """Test clearing the cache.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + # Verify cache has data + horizon_uri = get_obj_uri(sample_objects["horizon_interp"]) + assert len(cache.get_object_rels(horizon_uri)) > 0 + + # Clear cache + cache.clear_cache() + + # Cache should be empty + assert len(cache.get_object_rels(horizon_uri)) == 0 + + def test_recompute_cache(self, sample_objects): + """Test recomputing the entire cache.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + # Add new object + collection.append(sample_objects["trset20"]) + + # Recompute entire cache + cache.recompute_cache() + + trset_uri = get_obj_uri(sample_objects["trset20"]) + trset_rels = cache.get_object_rels(trset_uri) + assert len(trset_rels) >= 1 + + +class TestReverseIndex: + """Test reverse reference index functionality.""" + + def test_reverse_index_built_during_compute(self, sample_objects): + """Test that reverse index is built during compute_rels.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + collection.append(sample_objects["trset20"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + # Check reverse index exists + assert cache._reverse_index is not None + assert len(cache._reverse_index) > 0 + + # horizon_interp should be in reverse index (referenced by trset) + horizon_uri = get_obj_uri(sample_objects["horizon_interp"]) + assert horizon_uri in cache._reverse_index + + # trset should be in the sources for horizon + trset_uri = get_obj_uri(sample_objects["trset20"]) + assert trset_uri in cache._reverse_index[horizon_uri] + + def test_reverse_index_stats(self, sample_objects): + """Test get_reverse_index_stats method.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + collection.append(sample_objects["external_ref"]) + collection.append(sample_objects["trset20"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + stats = cache.get_reverse_index_stats() + + assert stats is not None + assert "total_targets" in stats + assert "total_references" in stats + assert "max_references_to_single_target" in stats + assert stats["total_targets"] > 0 + assert stats["total_references"] > 0 + + def test_reverse_index_cleared(self, sample_objects): + """Test that reverse index is cleared with cache.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + assert len(cache._reverse_index) > 0 + + cache.clear_cache() + + assert len(cache._reverse_index) == 0 + + +class TestSupplementalRels: + """Test supplemental relationships functionality.""" + + def test_add_supplemental_rels(self, sample_objects): + """Test adding supplemental relationships.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + # Create and add supplemental rel + from energyml.opc.opc import Relationship + + supplemental_rel = Relationship(target="test_target.xml", type_value="test_type", id="test_id") + + bf_uri = get_obj_uri(sample_objects["bf"]) + cache.add_supplemental_rels(sample_objects["bf"], supplemental_rel) + + # Get rels should include supplemental + rels = cache.get_object_rels(bf_uri) + assert any(r.target == "test_target.xml" for r in rels) + + def test_supplemental_rels_persist_across_clear(self, sample_objects): + """Test that supplemental rels persist across clear_cache.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + # Add supplemental rel + from energyml.opc.opc import Relationship + + supplemental_rel = Relationship(target="test_target.xml", type_value="test_type", id="test_id") + + cache.add_supplemental_rels(sample_objects["bf"], supplemental_rel) + + # Clear computed rels + cache.clear_cache() + + # Supplemental should still be there + bf_uri = get_obj_uri(sample_objects["bf"]) + rels = cache.get_object_rels(bf_uri) + assert any(r.target == "test_target.xml" for r in rels) + + +class TestValidation: + """Test validation functionality.""" + + def test_validate_rels_no_issues(self, sample_objects): + """Test validation with no issues.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + report = cache.validate_rels() + + assert report is not None + assert "duplicates" in report + assert "orphaned_references" in report + assert len(report["duplicates"]) == 0 + + def test_validate_detects_orphaned_references(self, sample_objects): + """Test that validation detects orphaned references.""" + collection = EnergymlObjectCollection() + # Only add trset (references horizon which doesn't exist) + collection.append(sample_objects["trset20"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + report = cache.validate_rels() + + # Should detect orphaned references to horizon_interp and external_ref + assert len(report["orphaned_references"]) > 0 + + def test_clean_rels_removes_duplicates(self, sample_objects): + """Test that clean_rels removes duplicate relationships.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + + # Manually add duplicate rels + bf_uri = get_obj_uri(sample_objects["bf"]) + from energyml.opc.opc import Relationship + + cache._computed_rels[bf_uri] = [ + Relationship(target="test.xml", type_value="type1", id="id1"), + Relationship(target="test.xml", type_value="type1", id="id2"), # Duplicate + ] + + # Clean should remove duplicate + cache.clean_rels(sample_objects["bf"]) + + rels = cache.get_object_rels(bf_uri) + assert len(rels) == 1 + + +class TestObjectRemoval: + """Test object removal from cache.""" + + def test_remove_object_from_cache(self, sample_objects): + """Test _remove_object_from_cache method.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + collection.append(sample_objects["trset20"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + # Verify horizon has rels + horizon_uri = get_obj_uri(sample_objects["horizon_interp"]) + assert len(cache.get_object_rels(horizon_uri)) > 0 + + # Remove horizon from cache + cache._remove_object_from_cache(sample_objects["horizon_interp"]) + + # horizon should have no rels + assert len(cache.get_object_rels(horizon_uri)) == 0 + + # Reverse index should not have horizon + assert horizon_uri not in cache._reverse_index + + def test_remove_cleans_reverse_index(self, sample_objects): + """Test that removal cleans up reverse index entries.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + collection.append(sample_objects["trset20"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + # trset should be in reverse index for horizon + horizon_uri = get_obj_uri(sample_objects["horizon_interp"]) + trset_uri = get_obj_uri(sample_objects["trset20"]) + assert trset_uri in cache._reverse_index.get(horizon_uri, set()) + + # Remove trset + cache._remove_object_from_cache(sample_objects["trset20"]) + + # trset should no longer be in reverse index for horizon + if horizon_uri in cache._reverse_index: + assert trset_uri not in cache._reverse_index[horizon_uri] + + +class TestErrorHandling: + """Test error handling with different policies.""" + + def test_error_policy_log(self, sample_objects): + """Test LOG error policy (should not raise).""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC, EpcRelsCacheErrorPolicy.LOG) + + # This should not raise even with invalid input + try: + cache._handle_error("Test error") + # Should not raise + except: + pytest.fail("LOG policy should not raise exceptions") + + def test_error_policy_raise(self, sample_objects): + """Test RAISE error policy (should raise).""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC, EpcRelsCacheErrorPolicy.RAISE) + + # This should raise + with pytest.raises(RuntimeError): + cache._handle_error("Test error") + + def test_error_policy_skip(self, sample_objects): + """Test SKIP error policy (should do nothing).""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC, EpcRelsCacheErrorPolicy.SKIP) + + # This should do nothing + try: + cache._handle_error("Test error") + # Should not raise + except: + pytest.fail("SKIP policy should not raise exceptions") + + +class TestDeduplication: + """Test relationship deduplication.""" + + def test_deduplicate_rels(self, sample_objects): + """Test _deduplicate_rels method.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + + from energyml.opc.opc import Relationship + + rels = [ + Relationship(target="test1.xml", type_value="type1", id="id1"), + Relationship(target="test1.xml", type_value="type1", id="id2"), # Duplicate + Relationship(target="test2.xml", type_value="type2", id="id3"), + ] + + deduped = cache._deduplicate_rels(rels) + + assert len(deduped) == 2 # Only 2 unique rels + + def test_get_object_rels_returns_deduplicated(self, sample_objects): + """Test that get_object_rels returns deduplicated results.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + horizon_uri = get_obj_uri(sample_objects["horizon_interp"]) + rels = cache.get_object_rels(horizon_uri) + + # Check no duplicates + seen = set() + for rel in rels: + key = (rel.target, rel.type_value) + assert key not in seen, "Found duplicate relationship" + seen.add(key) + + +class TestComplexScenarios: + """Test complex real-world scenarios.""" + + def test_full_workflow(self, sample_objects): + """Test complete workflow: add, compute, update, validate.""" + collection = EnergymlObjectCollection() + + # Step 1: Add initial objects + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + + # Step 2: Compute rels + cache.compute_rels() + + # Step 3: Add more objects incrementally + collection.append(sample_objects["external_ref"]) + cache.update_cache_for_object(sample_objects["external_ref"]) + + collection.append(sample_objects["trset20"]) + cache.update_cache_for_object(sample_objects["trset20"]) + + # Step 4: Validate + report = cache.validate_rels() + assert len(report["duplicates"]) == 0 + + # Step 5: Get stats + stats = cache.get_reverse_index_stats() + assert stats["total_targets"] > 0 + + # Step 6: Verify all relationships are correct + trset_uri = get_obj_uri(sample_objects["trset20"]) + trset_rels = cache.get_object_rels(trset_uri) + assert len(trset_rels) >= 2 + + def test_parallel_then_incremental(self, sample_objects): + """Test parallel compute followed by incremental updates.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + collection.append(sample_objects["horizon_interp"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + + # Parallel compute + cache.compute_rels(parallel=True) + + # Add object and update incrementally + collection.append(sample_objects["trset20"]) + cache.update_cache_for_object(sample_objects["trset20"]) + + # Verify correct + trset_uri = get_obj_uri(sample_objects["trset20"]) + horizon_uri = get_obj_uri(sample_objects["horizon_interp"]) + + trset_rels = cache.get_object_rels(trset_uri) + horizon_rels = cache.get_object_rels(horizon_uri) + + assert len(trset_rels) >= 1 + assert len(horizon_rels) >= 1 + + def test_recompute_after_many_updates(self, sample_objects): + """Test full recompute after many incremental updates.""" + collection = EnergymlObjectCollection() + collection.append(sample_objects["bf"]) + + cache = EpcRelsCache(collection, EpcExportVersion.CLASSIC) + cache.compute_rels() + + # Many incremental updates + collection.append(sample_objects["horizon_interp"]) + cache.update_cache_for_object(sample_objects["horizon_interp"]) + + collection.append(sample_objects["external_ref"]) + cache.update_cache_for_object(sample_objects["external_ref"]) + + collection.append(sample_objects["trset20"]) + cache.update_cache_for_object(sample_objects["trset20"]) + + # Full recompute + cache.recompute_cache(parallel=False) + + # Verify still correct + report = cache.validate_rels() + assert len(report["duplicates"]) == 0 + + stats = cache.get_reverse_index_stats() + assert stats["total_targets"] > 0 From bdd6351050e0eda29b8bfb3a9f0a8464cc7733a9 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Sat, 14 Feb 2026 05:29:27 +0100 Subject: [PATCH 37/70] using epcRelsCache in Epc class --- energyml-utils/src/energyml/utils/epc.py | 307 ++++++++++---------- energyml-utils/tests/test_epc_rels_cache.py | 18 +- 2 files changed, 160 insertions(+), 165 deletions(-) diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index ea53cae..2d3c25b 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -266,10 +266,11 @@ class EpcRelsCache: """ - def __init__(self, epc_or_collection, export_version, error_policy=EpcRelsCacheErrorPolicy.LOG): + def __init__(self, epc_or_collection, export_version=None, error_policy=EpcRelsCacheErrorPolicy.LOG): """ Initialize the EpcRelsCache. :param epc_or_collection: Epc instance or EnergymlObjectCollection + :param export_version: EPC export version. If None and epc_or_collection is Epc, uses epc.export_version :param export_version: EPC export version (for rels path/target generation) :param error_policy: EpcRelsCacheErrorPolicy enum value for error handling """ @@ -278,19 +279,27 @@ def __init__(self, epc_or_collection, export_version, error_policy=EpcRelsCacheE # Allow legacy string for backward compatibility error_policy = EpcRelsCacheErrorPolicy(error_policy.lower()) self._error_policy = error_policy - self._export_version = export_version # Accept Epc or EnergymlObjectCollection if isinstance(epc_or_collection, Epc): self._objects = epc_or_collection.energyml_objects self._epc = epc_or_collection + self._export_version_fallback = export_version or epc_or_collection.export_version else: self._objects = epc_or_collection self._epc = None + self._export_version_fallback = export_version or EpcExportVersion.CLASSIC # Internal caches self._computed_rels = {} # {Uri: List[Relationship]} self._supplemental_rels = {} # {Uri: List[Relationship]} self._reverse_index: Dict[Uri, Set[Uri]] = {} # {target_uri: {source_uris}} + @property + def export_version(self) -> EpcExportVersion: + """Get the current export version, using Epc's version if available.""" + if self._epc is not None: + return self._epc.export_version + return self._export_version_fallback + def _uri_from_any(self, obj_or_id: Any) -> "Uri": """ Normalize input to canonical Uri. @@ -320,7 +329,8 @@ def set_rels_from_file(self, obj: Any, rels: "Relationships") -> None: """Attach relationships loaded from a .rels file to the given object.""" uri = self._uri_from_any(obj) with self._lock: - self._supplemental_rels[uri] = list(rels.relationship) if hasattr(rels, "relationship") else list(rels) + self._computed_rels[uri] = list(rels.relationship) if hasattr(rels, "relationship") else list(rels) + # self._supplemental_rels[uri] = list(rels.relationship) if hasattr(rels, "relationship") else list(rels) def add_supplemental_rels(self, obj: Any, rels: Union["Relationship", List["Relationship"]]) -> None: """Add supplemental relationships for an object.""" @@ -378,9 +388,9 @@ def map_func(obj): # Second pass: build forward and reverse relationships rels_map = collections.defaultdict(list) # {Uri: List[Relationship]} for src_uri, dor_uris in results: - src_path = gen_rels_path(src_uri, export_version=self._export_version) + src_path = gen_energyml_object_path(src_uri, export_version=self.export_version) for tgt_uri in dor_uris: - tgt_path = gen_rels_path(tgt_uri, export_version=self._export_version) + tgt_path = gen_energyml_object_path(tgt_uri, export_version=self.export_version) # Forward rel (src -> tgt) rels_map[src_uri].append( Relationship( @@ -443,10 +453,10 @@ def update_cache_for_object(self, obj: Any) -> None: # Compute forward relationships for this object forward_rels = [] - src_path = gen_rels_path(uri, export_version=self._export_version) + src_path = gen_energyml_object_path(uri, export_version=self.export_version) for tgt_uri in dor_uris: - tgt_path = gen_rels_path(tgt_uri, export_version=self._export_version) + tgt_path = gen_energyml_object_path(tgt_uri, export_version=self.export_version) # Forward rel (this object -> target) forward_rels.append( Relationship( @@ -474,8 +484,8 @@ def update_cache_for_object(self, obj: Any) -> None: reverse_rels = [] for src_uri in self._reverse_index.get(uri, set()): if src_uri != uri: # Avoid self-references - src_path = gen_rels_path(src_uri, export_version=self._export_version) - tgt_path = gen_rels_path(uri, export_version=self._export_version) + src_path = gen_energyml_object_path(src_uri, export_version=self.export_version) + tgt_path = gen_energyml_object_path(uri, export_version=self.export_version) reverse_rels.append( Relationship( target=src_path, @@ -598,7 +608,7 @@ def _remove_object_from_cache(self, obj: Any) -> None: for other_uri, other_rels in self._computed_rels.items(): if other_uri != uri: # Filter out relationships targeting the removed object - uri_path = gen_rels_path(uri, export_version=self._export_version) + uri_path = gen_energyml_object_path(uri, export_version=self.export_version) self._computed_rels[other_uri] = [ rel for rel in other_rels if getattr(rel, "target", None) != uri_path ] @@ -679,7 +689,9 @@ def wrapper(*args, **kwargs): @dataclass class Epc(EnergymlStorageInterface): """ - A class that represent an EPC file content + A class that represent an EPC file content. Creating an isntance of this class with a file path will not directly load the file content if it exists. + To read an existing file, use the @read_file or @read_stream functions. + Moreover, you must explicitly call @export_file or @export_io functions to save the content of the instance. """ # content_type: List[str] = field( @@ -712,8 +724,12 @@ class Epc(EnergymlStorageInterface): force_h5_path: Optional[str] = field(default=None) + """ Relationships cache for efficient rels computation and management """ + _rels_cache: Optional[EpcRelsCache] = field(default=None, init=False, repr=False) + """ - Additional rels for objects. Key is the object (same than in @energyml_objects) and value is a list of + Additional rels for objects (DEPRECATED - use _rels_cache.add_supplemental_rels instead). + Key is the object (same than in @energyml_objects) and value is a list of RelationShip. This can be used to link an HDF5 to an ExternalPartReference in resqml 2.0.1 Key is a value returned by @get_obj_identifier """ @@ -724,6 +740,11 @@ class Epc(EnergymlStorageInterface): """ epc_file_path: Optional[str] = field(default=None) + def __post_init__(self): + """Initialize the relationships cache after dataclass initialization.""" + if self._rels_cache is None: + self._rels_cache = EpcRelsCache(self, export_version=self.export_version) + def __str__(self): return ( "EPC file (" @@ -825,7 +846,9 @@ def gen_opc_content_type(self) -> Types: return ct # @log_timestamp - def export_file(self, path: Optional[str] = None, allowZip64: bool = True) -> None: + def export_file( + self, path: Optional[str] = None, allowZip64: bool = True, force_recompute_object_rels: bool = True + ) -> None: """ Export the epc file. If :param:`path` is None, the epc 'self.epc_file_path' is used :param path: @@ -841,9 +864,11 @@ def export_file(self, path: Optional[str] = None, allowZip64: bool = True) -> No Path(path).parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED, allowZip64=allowZip64) as zip_file: - self._export_io(zip_file=zip_file, allowZip64=allowZip64) + self._export_io( + zip_file=zip_file, allowZip64=allowZip64, force_recompute_object_rels=force_recompute_object_rels + ) - def export_io(self, allowZip64: bool = True) -> BytesIO: + def export_io(self, allowZip64: bool = True, force_recompute_object_rels: bool = True) -> BytesIO: """ Export the epc file into a :class:`BytesIO` instance. The result is an 'in-memory' zip file. :return: @@ -851,11 +876,15 @@ def export_io(self, allowZip64: bool = True) -> BytesIO: zip_buffer = BytesIO() with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED, allowZip64=allowZip64) as zip_file: - self._export_io(zip_file=zip_file, allowZip64=allowZip64) + self._export_io( + zip_file=zip_file, allowZip64=allowZip64, force_recompute_object_rels=force_recompute_object_rels + ) return zip_buffer - def _export_io(self, zip_file: zipfile.ZipFile, allowZip64: bool = True) -> None: + def _export_io( + self, zip_file: zipfile.ZipFile, allowZip64: bool = True, force_recompute_object_rels: bool = True + ) -> None: """ Export the epc file into a :class:`BytesIO` instance. The result is an 'in-memory' zip file. :return: @@ -872,7 +901,7 @@ def _export_io(self, zip_file: zipfile.ZipFile, allowZip64: bool = True) -> None zip_file.writestr(e_path, serialize_xml(e_obj)) # Rels - for rels_path, rels in self.compute_rels().items(): + for rels_path, rels in self.compute_rels(force_recompute_object_rels=force_recompute_object_rels).items(): zip_file.writestr(rels_path, serialize_xml(rels)) # Other files: @@ -884,74 +913,53 @@ def _export_io(self, zip_file: zipfile.ZipFile, allowZip64: bool = True) -> None def get_obj_rels(self, obj: Union[str, Uri, Any]) -> List[Relationship]: """ - Get the relationships for a given energyml object + Get the relationships for a given energyml object using the cache. :param obj: The object identifier/URI or the object itself :return: List of Relationship objects """ + # Ensure cache is initialized + if self._rels_cache is None: + self._rels_cache = EpcRelsCache(self, export_version=self.export_version) + # Convert identifier to object if needed if isinstance(obj, str) or isinstance(obj, Uri): obj = self.get_object_by_identifier(obj) if obj is None: return [] - rels_path = gen_rels_path( - energyml_object=obj, - export_version=self.export_version, - ) - all_rels = self.compute_rels() - if rels_path in all_rels: - return all_rels[rels_path].relationship if all_rels[rels_path].relationship else [] - return [] + # Get relationships from cache (includes computed + supplemental rels) + return self._rels_cache.get_object_rels(obj) + + def compute_rels(self, force_recompute_object_rels: bool = False) -> Dict[str, Relationships]: + """ + Compute all relationships in the EPC file. + :param force_recompute_object_rels: If True, recompute all object relationships from scratch + :return: Dictionary mapping rels file paths to Relationships objects + """ + # Ensure cache is initialized + if self._rels_cache is None: + self._rels_cache = EpcRelsCache(self, export_version=self.export_version) + + # Recompute cache if requested + if force_recompute_object_rels: + self._rels_cache.recompute_cache() - def compute_rels(self) -> Dict[str, Relationships]: result = {} - # all energyml objects + # all energyml objects - get relationships from cache for obj in self.energyml_objects: - obj_file_path = gen_energyml_object_path(obj, export_version=self.export_version) obj_file_rels_path = gen_rels_path(obj, export_version=self.export_version) obj_id = get_obj_identifier(obj) - obj_rels = Relationships(relationship=[]) - - dor_uris = get_dor_uris_from_obj(obj) - - for target_uri in dor_uris: - # if "propertykind" in target_uri.object_type.lower(): - # if get_property_kind_by_uuid(target_uri.uuid) is not None: - # # we can ignore prop kind from official dictionary - # continue - - # if self.get_object(target_uri) is None: - # logging.warning( - # f"Object with identifier {target_uri} is referenced in DOR but not found in energyml_objects." - # ) - - target_path = gen_energyml_object_path(target_uri, export_version=self.export_version) - target_rels_path = gen_rels_path(target_uri, export_version=self.export_version) - if target_path != obj_file_path: - dest_rel = Relationship( - target=target_path, - type_value=get_rels_dor_type(dor_target=target_path, in_dor_owner_rels_file=True), - id=f"_{gen_uuid()}", - ) - obj_rels.relationship.append(dest_rel) - if target_rels_path not in result: - result[target_rels_path] = Relationships(relationship=[]) + # Get relationships from cache (includes computed + supplemental) + cached_rels = self._rels_cache.get_object_rels(obj) - result[target_rels_path].relationship.append( - Relationship( - target=obj_file_path, - type_value=get_rels_dor_type(dor_target=target_path, in_dor_owner_rels_file=False), - id=f"_{gen_uuid()}", - ) - ) - - # additional rels - for supplemental_rels in self.additional_rels.get(obj_id, []): - obj_rels.relationship.append(supplemental_rels) + # Add legacy additional_rels if any (for backward compatibility) + for supplemental_rel in self.additional_rels.get(obj_id, []): + if supplemental_rel not in cached_rels: + cached_rels.append(supplemental_rel) - result[obj_file_rels_path] = obj_rels + result[obj_file_rels_path] = Relationships(relationship=cached_rels) # CoreProps core_props = self.core_props or create_default_core_properties() @@ -981,77 +989,6 @@ def compute_rels(self) -> Dict[str, Relationships]: return result - def compute_rels_old(self) -> Dict[str, Relationships]: - """ - Returns a dict containing for each objet, the rels xml file path as key and the RelationShips object as value - :return: - """ - dor_relation = get_reverse_dor_list(self.energyml_objects) - - # destObject - rels = { - obj_id: [ - Relationship( - target=gen_energyml_object_path(target_obj, self.export_version), - type_value=get_rels_dor_type( - gen_energyml_object_path(self.get_object(obj_id), self.export_version), - in_dor_owner_rels_file=False, - ), - id=f"_{obj_id}_{get_obj_type(get_obj_usable_class(target_obj))}_{get_obj_identifier(target_obj)}", - ) - for target_obj in target_obj_list - if self.get_object(obj_id) is not None - ] - for obj_id, target_obj_list in dor_relation.items() - } - # sourceObject - for obj in self.energyml_objects: - obj_id = get_obj_identifier(obj) - if obj_id not in rels: - rels[obj_id] = [] - for target_obj in get_direct_dor_list(obj): - try: - rels[obj_id].append( - Relationship( - target=gen_energyml_object_path(target_obj, self.export_version), - type_value=get_rels_dor_type( - gen_energyml_object_path(target_obj, self.export_version), in_dor_owner_rels_file=True - ), - id=f"_{obj_id}_{get_obj_type(get_obj_usable_class(target_obj))}_{get_obj_identifier(target_obj)}", - ) - ) - except Exception: - logging.error(f'Failed to create rels for "{obj_id}" with target {target_obj}') - - # filtering non-accessible objects from DOR - rels = {k: v for k, v in rels.items() if self.get_object_by_identifier(k) is not None} - - map_obj_id_to_obj = {get_obj_identifier(obj): obj for obj in self.energyml_objects} - - obj_rels = { - gen_rels_path( - energyml_object=map_obj_id_to_obj.get(obj_id), - export_version=self.export_version, - ): Relationships( - relationship=obj_rels + (self.additional_rels[obj_id] if obj_id in self.additional_rels else []), - ) - for obj_id, obj_rels in rels.items() - } - - # CoreProps - if self.core_props is not None: - obj_rels[gen_rels_path(self.core_props)] = Relationships( - relationship=[ - Relationship( - target=gen_core_props_path(), - type_value=EPCRelsRelationshipType.CORE_PROPERTIES.get_type(), - id="CoreProperties", - ) - ] - ) - - return obj_rels - def rels_to_h5_file(self, obj: Any, h5_path: str) -> Relationship: """ Creates in the epc file, a Relation (in the object .rels file) to link a h5 external file. @@ -1137,22 +1074,19 @@ def get_object(self, identifier: Union[str, Uri]) -> Optional[Any]: def add_object(self, obj: Any) -> bool: """ - Add an energyml object to the EPC stream + Add an energyml object to the EPC stream (calls put_object for consistency) :param obj: :return: """ - self.energyml_objects.append(obj) - return True + return self.put_object(obj) is not None def remove_object(self, identifier: Union[str, Uri]) -> None: """ - Remove an energyml object from the EPC stream by its identifier + Remove an energyml object from the EPC stream by its identifier (calls delete_object for consistency) :param identifier: :return: """ - obj = self.get_object_by_identifier(identifier) - if obj is not None: - self.energyml_objects.remove(obj) + self.delete_object(identifier) def __len__(self) -> int: return len(self.energyml_objects) @@ -1327,17 +1261,31 @@ def write_array( @classmethod # @log_timestamp - def read_file(cls, epc_file_path: str) -> "Epc": + def read_file(cls, epc_file_path: str, read_rels_from_files: bool = True, recompute_rels: bool = False) -> "Epc": + """ + Read an EPC file from disk. + :param epc_file_path: Path to the EPC file + :param read_rels_from_files: If True, populate cache from .rels files in the EPC + :param recompute_rels: If True, recompute all relationships after loading + :return: Epc instance + """ with open(epc_file_path, "rb") as f: - epc = cls.read_stream(BytesIO(f.read())) + epc = cls.read_stream( + BytesIO(f.read()), read_rels_from_files=read_rels_from_files, recompute_rels=recompute_rels + ) epc.epc_file_path = epc_file_path return epc raise IOError(f"Failed to open EPC file {epc_file_path}") @classmethod - def read_stream(cls, epc_file_io: BytesIO): # returns an Epc instance + def read_stream( + cls, epc_file_io: BytesIO, read_rels_from_files: bool = True, recompute_rels: bool = False + ): # returns an Epc instance """ - :param epc_file_io: + Read an EPC file from a BytesIO stream. + :param epc_file_io: BytesIO containing the EPC file + :param read_rels_from_files: If True, populate cache from .rels files in the EPC + :param recompute_rels: If True, recompute all relationships after loading :return: an :class:`EPC` instance """ try: @@ -1346,6 +1294,10 @@ def read_stream(cls, epc_file_io: BytesIO): # returns an Epc instance raw_file_list = [] additional_rels = {} core_props = None + # Store rels files separately for potential cache population + rels_files_to_load = {} # {obj_path: Relationships} + path_to_obj = {} + with zipfile.ZipFile(epc_file_io, "r", zipfile.ZIP_DEFLATED) as epc_file: content_type_file_name = get_epc_content_type_path() content_type_info = None @@ -1363,7 +1315,6 @@ def read_stream(cls, epc_file_io: BytesIO): # returns an Epc instance logging.error(f"No {content_type_file_name} file found") else: content_type_obj: Types = read_energyml_xml_bytes(epc_file.read(content_type_file_name)) - path_to_obj = {} for ov in content_type_obj.override: ov_ct = ov.content_type ov_path = ov.part_name @@ -1426,6 +1377,12 @@ def read_stream(cls, epc_file_io: BytesIO): # returns an Epc instance if obj_path in path_to_obj: try: additional_rels_key = get_obj_identifier(path_to_obj[obj_path]) + + # Store all rels for potential cache population + if read_rels_from_files: + rels_files_to_load[obj_path] = rels_file + + # Keep only non-computable rels in additional_rels (legacy support) for rel in rels_file.relationship: # logging.debug(f"\t\t{rel.type_value}") if ( @@ -1450,12 +1407,27 @@ def read_stream(cls, epc_file_io: BytesIO): # returns an Epc instance f" of a lack of a dependency module) " ) - return Epc( + epc = Epc( energyml_objects=EnergymlObjectCollection(obj_list), raw_files=raw_file_list, core_props=core_props, additional_rels=additional_rels, ) + + # Populate rels cache from loaded rels files if requested + if read_rels_from_files and rels_files_to_load: + for obj_path, rels_file in rels_files_to_load.items(): + if obj_path in path_to_obj: + obj = path_to_obj[obj_path] + # Only set rels for energyml objects (skip CoreProperties and other OPC objects) + if obj in obj_list: + epc._rels_cache.set_rels_from_file(obj, rels_file) + + # Recompute relationships if requested + if recompute_rels: + epc._rels_cache.recompute_cache() + + return epc except zipfile.BadZipFile as error: logging.error(error) @@ -1479,14 +1451,37 @@ def list_objects(self, dataspace: str | None = None, object_type: str | None = N return result def put_object(self, obj: Any, dataspace: str | None = None) -> str | None: - if self.add_object(obj): - return str(get_obj_uri(obj)) - return None + """ + Add or update an energyml object in the EPC stream. + :param obj: The energyml object to add + :param dataspace: Optional dataspace parameter (for interface compatibility) + :return: The URI of the added object, or None if failed + """ + self.energyml_objects.append(obj) + + # Update relationships cache + if self._rels_cache is None: + self._rels_cache = EpcRelsCache(self, export_version=self.export_version) + self._rels_cache.update_cache_for_object(obj) + + return str(get_obj_uri(obj)) def delete_object(self, identifier: Union[str, Any]) -> bool: + """ + Delete an energyml object from the EPC stream. + :param identifier: The object identifier/URI or the object itself + :return: True if object was deleted, False otherwise + """ obj = self.get_object_by_identifier(identifier) if obj is not None: - self.remove_object(identifier) + # Remove from collection + self.energyml_objects.remove(obj) + + # Update relationships cache + if self._rels_cache is None: + self._rels_cache = EpcRelsCache(self, export_version=self.export_version) + self._rels_cache._remove_object_from_cache(obj) + return True return False diff --git a/energyml-utils/tests/test_epc_rels_cache.py b/energyml-utils/tests/test_epc_rels_cache.py index 04d8792..51fcaae 100644 --- a/energyml-utils/tests/test_epc_rels_cache.py +++ b/energyml-utils/tests/test_epc_rels_cache.py @@ -36,7 +36,7 @@ as_dor, EpcExportVersion, ) -from energyml.utils.epc_utils import gen_rels_path, gen_energyml_object_path, relationships_equal +from energyml.utils.epc_utils import gen_energyml_object_path, gen_energyml_object_path, relationships_equal from energyml.utils.introspection import ( epoch_to_date, epoch, @@ -176,7 +176,7 @@ def test_trset_has_destination_to_horizon(self, sample_objects): assert len(dest_rels) >= 1 # Verify target is horizon_interp - horizon_path = gen_rels_path(sample_objects["horizon_interp"], EpcExportVersion.CLASSIC) + horizon_path = gen_energyml_object_path(sample_objects["horizon_interp"], EpcExportVersion.CLASSIC) assert any(horizon_path in r.target for r in dest_rels) def test_trset_has_ml_to_external_part_proxy(self, sample_objects): @@ -200,7 +200,7 @@ def test_trset_has_ml_to_external_part_proxy(self, sample_objects): assert len(ml_to_proxy_rels) >= 1 # Verify target is external_ref - external_path = gen_rels_path(sample_objects["external_ref"], EpcExportVersion.CLASSIC) + external_path = gen_energyml_object_path(sample_objects["external_ref"], EpcExportVersion.CLASSIC) assert any(external_path in r.target for r in ml_to_proxy_rels) def test_external_ref_has_proxy_to_ml(self, sample_objects): @@ -224,7 +224,7 @@ def test_external_ref_has_proxy_to_ml(self, sample_objects): assert len(proxy_to_ml_rels) >= 1 # Verify target is trset20 - trset_path = gen_rels_path(sample_objects["trset20"], EpcExportVersion.CLASSIC) + trset_path = gen_energyml_object_path(sample_objects["trset20"], EpcExportVersion.CLASSIC) assert any(trset_path in r.target for r in proxy_to_ml_rels) def test_horizon_has_source_from_trset(self, sample_objects): @@ -245,7 +245,7 @@ def test_horizon_has_source_from_trset(self, sample_objects): assert len(source_rels) >= 1 # Verify source is trset20 - trset_path = gen_rels_path(sample_objects["trset20"], EpcExportVersion.CLASSIC) + trset_path = gen_energyml_object_path(sample_objects["trset20"], EpcExportVersion.CLASSIC) assert any(trset_path in r.target for r in source_rels) def test_compute_rels_parallel(self, sample_objects): @@ -268,8 +268,8 @@ def test_compute_rels_parallel(self, sample_objects): assert len(trset_rels) >= 2 # At least horizon_interp and external_ref # Verify each relationship has correct type and target - horizon_path = gen_rels_path(sample_objects["horizon_interp"], EpcExportVersion.CLASSIC) - external_path = gen_rels_path(sample_objects["external_ref"], EpcExportVersion.CLASSIC) + horizon_path = gen_energyml_object_path(sample_objects["horizon_interp"], EpcExportVersion.CLASSIC) + external_path = gen_energyml_object_path(sample_objects["external_ref"], EpcExportVersion.CLASSIC) # Find DESTINATION_OBJECT rel to horizon_interp dest_rels = [ @@ -293,7 +293,7 @@ def test_compute_rels_parallel(self, sample_objects): horizon_uri = get_obj_uri(sample_objects["horizon_interp"]) horizon_rels = cache.get_object_rels(horizon_uri) - trset_path = gen_rels_path(sample_objects["trset20"], EpcExportVersion.CLASSIC) + trset_path = gen_energyml_object_path(sample_objects["trset20"], EpcExportVersion.CLASSIC) source_rels = [ r for r in horizon_rels @@ -404,7 +404,7 @@ def test_add_objects_out_of_order(self, sample_objects): assert len(source_rels) >= 1 # Verify reverse rel points to trset - trset_path = gen_rels_path(sample_objects["trset20"], EpcExportVersion.CLASSIC) + trset_path = gen_energyml_object_path(sample_objects["trset20"], EpcExportVersion.CLASSIC) assert any(trset_path in r.target for r in source_rels) def test_incremental_update_with_late_arrival(self, sample_objects): From 3b2e342d437e73214d574a8309586711344f247b Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Sat, 14 Feb 2026 06:55:27 +0100 Subject: [PATCH 38/70] externaDataArrayPArt detection for external rels --- .../example/attic/compare_inmem_n_stream.py | 9 +- energyml-utils/src/energyml/utils/epc.py | 884 +++++++++--------- .../src/energyml/utils/epc_utils.py | 83 ++ energyml-utils/tests/test_epc.py | 49 +- 4 files changed, 595 insertions(+), 430 deletions(-) diff --git a/energyml-utils/example/attic/compare_inmem_n_stream.py b/energyml-utils/example/attic/compare_inmem_n_stream.py index b9e7723..7ddc78f 100644 --- a/energyml-utils/example/attic/compare_inmem_n_stream.py +++ b/energyml-utils/example/attic/compare_inmem_n_stream.py @@ -39,7 +39,7 @@ def reexport_in_memory(filepath: str, output_folder: Optional[str] = None): if output_folder: os.makedirs(output_folder, exist_ok=True) path_in_memory = f"{output_folder}/{path_in_memory.split('/')[-1]}" - epc = Epc.read_file(filepath) + epc = Epc.read_file(epc_file_path=filepath, read_rels_from_files=False) if os.path.exists(path_in_memory): os.remove(path_in_memory) @@ -120,9 +120,14 @@ def time_comparison(filepath: str, output_folder: Optional[str] = None, skip_seq update_prop_kind_dict_cache() time_comparison( - filepath=sys.argv[1] if len(sys.argv) > 1 else "rc/epc/80wells_surf.epc", output_folder="rc/performance_results" + filepath=sys.argv[1] if len(sys.argv) > 1 else "rc/epc/testingPackageCpp22.epc", + output_folder="rc/performance_results", ) + # time_comparison( + # filepath=sys.argv[1] if len(sys.argv) > 1 else "rc/epc/80wells_surf.epc", output_folder="rc/performance_results" + # ) + # time_comparison( # filepath=sys.argv[1] if len(sys.argv) > 1 else "wip/failingData/fix/sample_mini_firp_201_norels_with_media.epc", # output_folder="rc/performance_results", diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index 2d3c25b..21dc1e3 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -17,26 +17,13 @@ from dataclasses import dataclass, field from functools import wraps from io import BytesIO -from typing import List, Any, Set, Union, Dict, Optional +from typing import List, Any, Set, Tuple, Union, Dict, Optional import numpy as np from enum import Enum from xsdata.formats.dataclass.models.generics import DerivedElement -from energyml.opc.opc import ( - CoreProperties, - Relationships, - Types, - Relationship, - Override, -) -from energyml.utils.epc_utils import ( - gen_core_props_path, - gen_energyml_object_path, - gen_rels_path, - get_epc_content_type_path, - create_h5_external_relationship, -) +from energyml.opc.opc import CoreProperties, Relationships, Types, Relationship, Override, TargetMode from energyml.utils.storage_interface import DataArrayMetadata, EnergymlStorageInterface, ResourceMetadata from energyml.utils.uri import Uri, parse_uri @@ -58,7 +45,6 @@ get_obj_version, get_obj_uuid, get_content_type_from_class, - get_direct_dor_list, gen_uuid, get_obj_identifier, get_object_attribute, @@ -73,6 +59,13 @@ JSON_VERSION, ) from energyml.utils.xml import is_energyml_content_type +from energyml.utils.epc_utils import ( + gen_core_props_path, + gen_energyml_object_path, + gen_rels_path, + get_epc_content_type_path, + create_h5_external_relationship, +) class EnergymlObjectCollection: @@ -330,6 +323,19 @@ def set_rels_from_file(self, obj: Any, rels: "Relationships") -> None: uri = self._uri_from_any(obj) with self._lock: self._computed_rels[uri] = list(rels.relationship) if hasattr(rels, "relationship") else list(rels) + + # check supplemental to keep : + for r in rels.relationship or []: + if r.type_value not in ( + str(EPCRelsRelationshipType.DESTINATION_OBJECT), + str(EPCRelsRelationshipType.SOURCE_OBJECT), + str(EPCRelsRelationshipType.ML_TO_EXTERNAL_PART_PROXY), + str(EPCRelsRelationshipType.EXTERNAL_PART_PROXY_TO_ML), + ): + if uri not in self._supplemental_rels: + self._supplemental_rels[uri] = [] + self._supplemental_rels[uri].append(r) + # self._supplemental_rels[uri] = list(rels.relationship) if hasattr(rels, "relationship") else list(rels) def add_supplemental_rels(self, obj: Any, rels: Union["Relationship", List["Relationship"]]) -> None: @@ -343,6 +349,12 @@ def add_supplemental_rels(self, obj: Any, rels: Union["Relationship", List["Rela else: self._supplemental_rels[uri].append(rels) + def get_supplemental_rels(self, obj: Any, default=None) -> List["Relationship"]: + """Get supplemental relationships for an object.""" + uri = self._uri_from_any(obj) + with self._lock: + return self._supplemental_rels.get(uri, default if default is not None else []) + def get_object_rels(self, obj: Any) -> List["Relationship"]: """Return the effective relationships for an object, merging computed and supplemental rels, deduplicated.""" uri = self._uri_from_any(obj) @@ -351,7 +363,7 @@ def get_object_rels(self, obj: Any) -> List["Relationship"]: rels.extend(self._supplemental_rels.get(uri, [])) return self._deduplicate_rels(rels) - def compute_rels(self, parallel: bool = False, recompute_all: bool = False) -> Dict["Uri", List["Relationship"]]: + def compute_rels(self, parallel: bool = False) -> Dict["Uri", List["Relationship"]]: """ Recompute all relationships, including reverse relationships. If parallel=True, use a thread/process pool for the map phase. Returns a mapping of Uri to deduplicated relationships. @@ -364,11 +376,11 @@ def compute_rels(self, parallel: bool = False, recompute_all: bool = False) -> D objects = list(self._objects) # First pass: collect direct DORs for each object - def map_func(obj): + def map_func(obj) -> Optional[Tuple[Uri, Set[Uri], Set[Tuple[str, str]]]]: try: uri = get_obj_uri(obj) - dor_uris = self._get_direct_dor_uris(obj) - return (uri, dor_uris) + dor_uris, external_uris = self._get_direct_dor_uris(obj) + return (uri, dor_uris, external_uris) except Exception as e: self._handle_error(f"Failed to compute DORs for {obj}: {e}") return None @@ -387,7 +399,7 @@ def map_func(obj): # Second pass: build forward and reverse relationships rels_map = collections.defaultdict(list) # {Uri: List[Relationship]} - for src_uri, dor_uris in results: + for src_uri, dor_uris, external_uris in results: src_path = gen_energyml_object_path(src_uri, export_version=self.export_version) for tgt_uri in dor_uris: tgt_path = gen_energyml_object_path(tgt_uri, export_version=self.export_version) @@ -407,10 +419,12 @@ def map_func(obj): id=f"_{gen_uuid()}", ) ) + for ext_uri, _ in external_uris: + rels_map[src_uri].append(create_external_relationship(ext_uri)) # Build reverse index from results reverse_idx = collections.defaultdict(set) - for src_uri, dor_uris in results: + for src_uri, dor_uris, external_uris in results: for tgt_uri in dor_uris: reverse_idx[tgt_uri].add(src_uri) @@ -420,20 +434,10 @@ def map_func(obj): self.clean_rels() return {uri: self.get_object_rels(uri) for uri in self._computed_rels} - def _get_direct_dor_uris(self, obj: Any) -> Set[Uri]: - """ - Return the set of direct DOR target Uris for the given object. - """ - try: - return get_dor_uris_from_obj(obj) - except Exception as e: - self._handle_error(f"Error getting direct DOR URIs: {e}") - return set() - def update_cache_for_object(self, obj: Any) -> None: """Incrementally update relationships for a single object, including reverse relationships.""" uri = self._uri_from_any(obj) - dor_uris = self._get_direct_dor_uris(obj) + dor_uris, external_uris = self._get_direct_dor_uris(obj) with self._lock: # Remove old reverse index entries for this object @@ -494,6 +498,9 @@ def update_cache_for_object(self, obj: Any) -> None: ) ) + for ext_uri, _ in external_uris: + forward_rels.append(create_external_relationship(ext_uri)) + # Store combined relationships self._computed_rels[uri] = forward_rels + reverse_rels @@ -505,7 +512,7 @@ def clear_cache(self) -> None: def recompute_cache(self, parallel: bool = False) -> Dict["Uri", List["Relationship"]]: """Fully recompute the internal cache.""" - return self.compute_rels(parallel=parallel, recompute_all=True) + return self.compute_rels(parallel=parallel) def clean_rels(self, obj: Optional[Any] = None) -> None: """ @@ -524,17 +531,6 @@ def clean_rels(self, obj: Optional[Any] = None) -> None: deduped = self._deduplicate_rels(rels) self._computed_rels[uri] = deduped - def _deduplicate_rels(self, rels: List["Relationship"]) -> List["Relationship"]: - """Remove duplicate relationships by (target, type_value).""" - seen = set() - result = [] - for rel in rels: - key = (getattr(rel, "target", None), getattr(rel, "type_value", None)) - if key not in seen: - seen.add(key) - result.append(rel) - return result - def validate_rels(self) -> Dict[str, Any]: """ Run validation checks: duplicate rels, orphaned references, circular references, etc. @@ -578,6 +574,50 @@ def validate_rels(self) -> Dict[str, Any]: return report + def get_reverse_index_stats(self) -> Dict[str, Any]: + """ + Get statistics about the reverse reference index for debugging and validation. + Returns a dictionary with index statistics. + """ + with self._lock: + stats = { + "total_targets": len(self._reverse_index), + "total_references": sum(len(sources) for sources in self._reverse_index.values()), + "max_references_to_single_target": max( + (len(sources) for sources in self._reverse_index.values()), default=0 + ), + "targets_by_reference_count": {}, + } + + # Group targets by how many sources reference them + for target_uri, sources in self._reverse_index.items(): + count = len(sources) + if count not in stats["targets_by_reference_count"]: + stats["targets_by_reference_count"][count] = 0 + stats["targets_by_reference_count"][count] += 1 + + return stats + + def _handle_error(self, msg: str) -> None: + if self._error_policy == EpcRelsCacheErrorPolicy.LOG: + import logging + + logging.error(msg) + elif self._error_policy == EpcRelsCacheErrorPolicy.RAISE: + raise RuntimeError(msg) + # else: SKIP + + def _deduplicate_rels(self, rels: List["Relationship"]) -> List["Relationship"]: + """Remove duplicate relationships by (target, type_value).""" + seen = set() + result = [] + for rel in rels: + key = (getattr(rel, "target", None), getattr(rel, "type_value", None)) + if key not in seen: + seen.add(key) + result.append(rel) + return result + def _remove_object_from_cache(self, obj: Any) -> None: """ Remove an object from the cache, cleaning up all references and reverse index entries. @@ -613,38 +653,15 @@ def _remove_object_from_cache(self, obj: Any) -> None: rel for rel in other_rels if getattr(rel, "target", None) != uri_path ] - def get_reverse_index_stats(self) -> Dict[str, Any]: + def _get_direct_dor_uris(self, obj: Any) -> Tuple[Set[Uri], Set[Tuple[str, str]]]: """ - Get statistics about the reverse reference index for debugging and validation. - Returns a dictionary with index statistics. + Return the set of direct DOR target Uris for the given object and Tuple[filepath, mimetype] for external references. """ - with self._lock: - stats = { - "total_targets": len(self._reverse_index), - "total_references": sum(len(sources) for sources in self._reverse_index.values()), - "max_references_to_single_target": max( - (len(sources) for sources in self._reverse_index.values()), default=0 - ), - "targets_by_reference_count": {}, - } - - # Group targets by how many sources reference them - for target_uri, sources in self._reverse_index.items(): - count = len(sources) - if count not in stats["targets_by_reference_count"]: - stats["targets_by_reference_count"][count] = 0 - stats["targets_by_reference_count"][count] += 1 - - return stats - - def _handle_error(self, msg: str) -> None: - if self._error_policy == EpcRelsCacheErrorPolicy.LOG: - import logging - - logging.error(msg) - elif self._error_policy == EpcRelsCacheErrorPolicy.RAISE: - raise RuntimeError(msg) - # else: SKIP + try: + return get_dor_or_external_uris_from_obj(obj) + except Exception as e: + self._handle_error(f"Error getting direct DOR URIs: {e}") + return set(), set() def log_timestamp(func): @@ -733,7 +750,7 @@ class Epc(EnergymlStorageInterface): RelationShip. This can be used to link an HDF5 to an ExternalPartReference in resqml 2.0.1 Key is a value returned by @get_obj_identifier """ - additional_rels: Dict[str, List[Relationship]] = field(default_factory=lambda: {}) + # additional_rels: Dict[str, List[Relationship]] = field(default_factory=lambda: {}) """ Epc file path. Used when loaded from a local file or for export @@ -911,6 +928,51 @@ def _export_io( # ContentType zip_file.writestr(get_epc_content_type_path(), serialize_xml(self.gen_opc_content_type())) + # === Relationships management functions === + + def add_rels_for_object( + self, + obj: Any, + relationships: List[Relationship], + ) -> None: + """ + Add relationships to an object in the EPC stream + :param obj: + :param relationships: + :return: + """ + + self._rels_cache.add_supplemental_rels(obj, relationships) + + # if isinstance(obj, str) or isinstance(obj, Uri): + # obj = self.get_object_by_identifier(obj) + # obj_ident = get_obj_identifier(obj) + # else: + # obj_ident = get_obj_identifier(obj) + # if obj_ident not in self.additional_rels: + # self.additional_rels[obj_ident] = [] + + # self.additional_rels[obj_ident] = self.additional_rels[obj_ident] + relationships + + def rels_to_h5_file(self, obj: Any, h5_path: str) -> Relationship: + """ + Creates in the epc file, a Relation (in the object .rels file) to link a h5 external file. + Usually this function is used to link an ExternalPartReference to a h5 file. + :param obj: + :param h5_path: + :return: the Relationship added to the rels cache + """ + # obj_ident = get_obj_identifier(obj) + # if obj_ident not in self.additional_rels: + # self.additional_rels[obj_ident] = [] + + nb_current_file = len(self.get_h5_file_paths(obj)) + + rel = create_h5_external_relationship(h5_path=h5_path, current_idx=nb_current_file) + # self.additional_rels[obj_ident].append(rel) + self._rels_cache.add_supplemental_rels(obj, rel) + return rel + def get_obj_rels(self, obj: Union[str, Uri, Any]) -> List[Relationship]: """ Get the relationships for a given energyml object using the cache. @@ -930,6 +992,22 @@ def get_obj_rels(self, obj: Union[str, Uri, Any]) -> List[Relationship]: # Get relationships from cache (includes computed + supplemental rels) return self._rels_cache.get_object_rels(obj) + def update_rels_cache(self) -> None: + """Update the relationships cache for all objects. This should be called after any modification to the energyml objects to keep the cache consistent.""" + if self._rels_cache is None: + self._rels_cache = EpcRelsCache(self, export_version=self.export_version) + self._rels_cache.recompute_cache() + + def clean_rels_cache(self, obj: Any = None) -> None: + """Clean relationships for a specific object in the cache. If no object is provided, clean all relationships in the cache. This will remove duplicates and ensure consistency between computed and supplemental relationships.""" + if self._rels_cache is not None: + self._rels_cache.clean_rels(obj) + + def clear_rels_cache(self) -> None: + """Clear the relationships cache. This will remove all computed and supplemental relationships, forcing a full recomputation on next access.""" + if self._rels_cache is not None: + self._rels_cache.clear_cache() + def compute_rels(self, force_recompute_object_rels: bool = False) -> Dict[str, Relationships]: """ Compute all relationships in the EPC file. @@ -949,16 +1027,10 @@ def compute_rels(self, force_recompute_object_rels: bool = False) -> Dict[str, R # all energyml objects - get relationships from cache for obj in self.energyml_objects: obj_file_rels_path = gen_rels_path(obj, export_version=self.export_version) - obj_id = get_obj_identifier(obj) # Get relationships from cache (includes computed + supplemental) cached_rels = self._rels_cache.get_object_rels(obj) - # Add legacy additional_rels if any (for backward compatibility) - for supplemental_rel in self.additional_rels.get(obj_id, []): - if supplemental_rel not in cached_rels: - cached_rels.append(supplemental_rel) - result[obj_file_rels_path] = Relationships(relationship=cached_rels) # CoreProps @@ -989,199 +1061,77 @@ def compute_rels(self, force_recompute_object_rels: bool = False) -> Dict[str, R return result - def rels_to_h5_file(self, obj: Any, h5_path: str) -> Relationship: - """ - Creates in the epc file, a Relation (in the object .rels file) to link a h5 external file. - Usually this function is used to link an ExternalPartReference to a h5 file. - In practice, the Relation object is added to the "additional_rels" of the current epc file. - :param obj: - :param h5_path: - :return: the Relationship added to the epc.additional_rels dict - """ - obj_ident = get_obj_identifier(obj) - if obj_ident not in self.additional_rels: - self.additional_rels[obj_ident] = [] + # === Array functions === - nb_current_file = len(self.get_h5_file_paths(obj)) + def get_epc_file_folder(self) -> Optional[str]: + if self.epc_file_path is not None and len(self.epc_file_path) > 0: + folders_and_name = re.split(r"[\\/]", self.epc_file_path) + if len(folders_and_name) > 1: + return "/".join(folders_and_name[:-1]) + else: + return "" + return None - rel = create_h5_external_relationship(h5_path=h5_path, current_idx=nb_current_file) - self.additional_rels[obj_ident].append(rel) - return rel + def read_external_array( + self, + energyml_array: Any, + root_obj: Optional[Any] = None, + path_in_root: Optional[str] = None, + use_epc_io_h5: bool = True, + ) -> List[Any]: + """Read an external array from HDF5 files linked to the EPC file. + :param energyml_array: the energyml array object (e.g. FloatingPointExternalArray) + :param root_obj: the root object containing the energyml_array + :param path_in_root: the path in the root object to the energyml_array + :param use_epc_io_h5: if True, use also the in-memory HDF5 files stored in epc.h5_io_files - def get_h5_file_paths(self, obj: Any) -> List[str]: - """ - Get all HDF5 file paths referenced in the EPC file (from rels to external resources) - :return: list of HDF5 file paths + :return: the array read from the external datasets """ + sources = [] + if self is not None and use_epc_io_h5 and self.h5_io_files is not None and len(self.h5_io_files): + sources = sources + self.h5_io_files - if self.force_h5_path is not None: - return [self.force_h5_path] - - is_uri = (isinstance(obj, str) and parse_uri(obj) is not None) or isinstance(obj, Uri) - if is_uri: - obj = self.get_object_by_identifier(obj) + return read_external_dataset_array( + energyml_array=energyml_array, + root_obj=root_obj, + path_in_root=path_in_root, + additional_sources=sources, + epc=self, + ) - h5_paths = set() + def read_array( + self, + proxy: Union[str, Uri, Any], + path_in_external: str, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, + external_uri: Optional[str] = None, + ) -> Optional[np.ndarray]: + """ + Read a data array from external storage (HDF5, Parquet, CSV, etc.) with optional sub-selection. - if isinstance(obj, str): - obj = self.get_object_by_identifier(obj) - for rels in self.additional_rels.get(get_obj_identifier(obj), []): - if rels.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(): - h5_paths.add(rels.target) + :param proxy: The object identifier/URI or the object itself that references the array + :param path_in_external: Path within the external file (e.g., 'values/0') + :param start_indices: Optional start index for each dimension (RESQML v2.2 StartIndex) + :param counts: Optional count of elements for each dimension (RESQML v2.2 Count) + :param external_uri: Optional URI to override default file path (RESQML v2.2 URI) + :return: The data array as a numpy array, or None if not found + """ + obj = proxy + if isinstance(proxy, str) or isinstance(proxy, Uri): + obj = self.get_object_by_identifier(proxy) - if len(h5_paths) == 0: - # search if an h5 file has the same name than the epc file - epc_folder = self.get_epc_file_folder() - if epc_folder is not None and self.epc_file_path is not None: - epc_file_name = os.path.basename(self.epc_file_path) - epc_file_base, _ = os.path.splitext(epc_file_name) - possible_h5_path = os.path.join(epc_folder, epc_file_base + ".h5") - if os.path.exists(possible_h5_path): - h5_paths.add(possible_h5_path) - return list(h5_paths) + # Determine which external files to use + file_paths = [external_uri] if external_uri else self.get_h5_file_paths(obj) + if not file_paths or len(file_paths) == 0: + file_paths = self.external_files_path - def get_object_as_dor(self, identifier: str, dor_qualified_type) -> Optional[Any]: - """ - Search an object by its identifier and returns a DOR - :param identifier: - :param dor_qualified_type: the qualified type of the DOR (e.g. resqml22.DataObjectReference) - :return: - """ - obj = self.get_object_by_identifier(identifier=identifier) - # if obj is None: + if not file_paths: + logging.warning(f"No external file paths found for proxy: {proxy}") + return None - return as_dor(obj_or_identifier=obj or identifier, dor_qualified_type=dor_qualified_type) - - def get_object_by_uuid(self, uuid: str) -> List[Any]: - """ - Search all objects with the uuid :param:`uuid`. - :param uuid: - :return: - """ - return self.energyml_objects.get_by_uuid(uuid) - - def get_object_by_identifier(self, identifier: Union[str, Uri]) -> Optional[Any]: - """ - Search an object by its identifier. - :param identifier: given by the function :func:`get_obj_identifier`, or a URI (or its str representation) - :return: - """ - # Use the O(1) dict lookup from the collection - return self.energyml_objects.get_by_identifier(identifier) - - def get_object(self, identifier: Union[str, Uri]) -> Optional[Any]: - return self.get_object_by_identifier(identifier) - - def add_object(self, obj: Any) -> bool: - """ - Add an energyml object to the EPC stream (calls put_object for consistency) - :param obj: - :return: - """ - return self.put_object(obj) is not None - - def remove_object(self, identifier: Union[str, Uri]) -> None: - """ - Remove an energyml object from the EPC stream by its identifier (calls delete_object for consistency) - :param identifier: - :return: - """ - self.delete_object(identifier) - - def __len__(self) -> int: - return len(self.energyml_objects) - - def add_rels_for_object( - self, - obj: Any, - relationships: List[Relationship], - ) -> None: - """ - Add relationships to an object in the EPC stream - :param obj: - :param relationships: - :return: - """ - - if isinstance(obj, str) or isinstance(obj, Uri): - obj = self.get_object_by_identifier(obj) - obj_ident = get_obj_identifier(obj) - else: - obj_ident = get_obj_identifier(obj) - if obj_ident not in self.additional_rels: - self.additional_rels[obj_ident] = [] - - self.additional_rels[obj_ident] = self.additional_rels[obj_ident] + relationships - - def get_epc_file_folder(self) -> Optional[str]: - if self.epc_file_path is not None and len(self.epc_file_path) > 0: - folders_and_name = re.split(r"[\\/]", self.epc_file_path) - if len(folders_and_name) > 1: - return "/".join(folders_and_name[:-1]) - else: - return "" - return None - - def read_external_array( - self, - energyml_array: Any, - root_obj: Optional[Any] = None, - path_in_root: Optional[str] = None, - use_epc_io_h5: bool = True, - ) -> List[Any]: - """Read an external array from HDF5 files linked to the EPC file. - :param energyml_array: the energyml array object (e.g. FloatingPointExternalArray) - :param root_obj: the root object containing the energyml_array - :param path_in_root: the path in the root object to the energyml_array - :param use_epc_io_h5: if True, use also the in-memory HDF5 files stored in epc.h5_io_files - - :return: the array read from the external datasets - """ - sources = [] - if self is not None and use_epc_io_h5 and self.h5_io_files is not None and len(self.h5_io_files): - sources = sources + self.h5_io_files - - return read_external_dataset_array( - energyml_array=energyml_array, - root_obj=root_obj, - path_in_root=path_in_root, - additional_sources=sources, - epc=self, - ) - - def read_array( - self, - proxy: Union[str, Uri, Any], - path_in_external: str, - start_indices: Optional[List[int]] = None, - counts: Optional[List[int]] = None, - external_uri: Optional[str] = None, - ) -> Optional[np.ndarray]: - """ - Read a data array from external storage (HDF5, Parquet, CSV, etc.) with optional sub-selection. - - :param proxy: The object identifier/URI or the object itself that references the array - :param path_in_external: Path within the external file (e.g., 'values/0') - :param start_indices: Optional start index for each dimension (RESQML v2.2 StartIndex) - :param counts: Optional count of elements for each dimension (RESQML v2.2 Count) - :param external_uri: Optional URI to override default file path (RESQML v2.2 URI) - :return: The data array as a numpy array, or None if not found - """ - obj = proxy - if isinstance(proxy, str) or isinstance(proxy, Uri): - obj = self.get_object_by_identifier(proxy) - - # Determine which external files to use - file_paths = [external_uri] if external_uri else self.get_h5_file_paths(obj) - if not file_paths or len(file_paths) == 0: - file_paths = self.external_files_path - - if not file_paths: - logging.warning(f"No external file paths found for proxy: {proxy}") - return None - - # Get the file handler registry - handler_registry = get_handler_registry() + # Get the file handler registry + handler_registry = get_handler_registry() for file_path in file_paths: # Get the appropriate handler for this file type @@ -1257,7 +1207,236 @@ def write_array( logging.error(f"Failed to write array to any available file paths: {file_paths}") return False + def get_array_metadata( + self, + proxy: Union[str, Uri, Any], + path_in_external: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, + ) -> Union[DataArrayMetadata, List[DataArrayMetadata], None]: + """ + Get metadata for data array(s) without loading the full array data. + Supports RESQML v2.2 sub-array selection metadata. + + :param proxy: The object identifier/URI or the object itself that references the array + :param path_in_external: Optional specific path. If None, returns all array metadata for the object + :param start_indices: Optional start index for each dimension (RESQML v2.2 StartIndex) + :param counts: Optional count of elements for each dimension (RESQML v2.2 Count) + :return: DataArrayMetadata if path specified, List[DataArrayMetadata] if no path, or None if not found + """ + obj = proxy + if isinstance(proxy, str) or isinstance(proxy, Uri): + obj = self.get_object_by_identifier(proxy) + + # Get possible file paths for this object + file_paths = self.get_h5_file_paths(obj) + if not file_paths or len(file_paths) == 0: + file_paths = self.external_files_path + + if not file_paths: + logging.warning(f"No external file paths found for proxy: {proxy}") + return None + + # Get the file handler registry + handler_registry = get_handler_registry() + + for file_path in file_paths: + # Get the appropriate handler for this file type + handler = handler_registry.get_handler_for_file(file_path) + if handler is None: + logging.debug(f"No handler found for file: {file_path}") + continue + + try: + # Use handler to get metadata without loading full array + metadata_dict = handler.get_array_metadata(file_path, path_in_external, start_indices, counts) + + if metadata_dict is None: + continue + + # Convert dict(s) to DataArrayMetadata + if isinstance(metadata_dict, list): + return [ + DataArrayMetadata( + path_in_resource=m.get("path"), + array_type=m.get("dtype", "unknown"), + dimensions=m.get("shape", []), + start_indices=start_indices, + custom_data={"size": m.get("size", 0)}, + ) + for m in metadata_dict + ] + else: + return DataArrayMetadata( + path_in_resource=metadata_dict.get("path"), + array_type=metadata_dict.get("dtype", "unknown"), + dimensions=metadata_dict.get("shape", []), + start_indices=start_indices, + custom_data={"size": metadata_dict.get("size", 0)}, + ) + except Exception as e: + logging.debug(f"Failed to get metadata from file {file_path}: {e}") + + return None + + def get_h5_file_paths(self, obj: Any) -> List[str]: + """ + Get all HDF5 file paths referenced in the EPC file (from rels to external resources) + :return: list of HDF5 file paths + """ + + if self.force_h5_path is not None: + return [self.force_h5_path] + + is_uri = (isinstance(obj, str) and parse_uri(obj) is not None) or isinstance(obj, Uri) + if is_uri: + obj = self.get_object_by_identifier(obj) + + h5_paths = set() + + if isinstance(obj, str): + obj = self.get_object_by_identifier(obj) + # for rels in self.additional_rels.get(get_obj_identifier(obj), []): + for rels in self._rels_cache.get_supplemental_rels(obj): + if rels.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(): + h5_paths.add(rels.target) + + if len(h5_paths) == 0: + # search if an h5 file has the same name than the epc file + epc_folder = self.get_epc_file_folder() + if epc_folder is not None and self.epc_file_path is not None: + epc_file_name = os.path.basename(self.epc_file_path) + epc_file_base, _ = os.path.splitext(epc_file_name) + possible_h5_path = os.path.join(epc_folder, epc_file_base + ".h5") + if os.path.exists(possible_h5_path): + h5_paths.add(possible_h5_path) + return list(h5_paths) + + def get_object_as_dor(self, identifier: str, dor_qualified_type) -> Optional[Any]: + """ + Search an object by its identifier and returns a DOR + :param identifier: + :param dor_qualified_type: the qualified type of the DOR (e.g. resqml22.DataObjectReference) + :return: + """ + obj = self.get_object_by_identifier(identifier=identifier) + # if obj is None: + + return as_dor(obj_or_identifier=obj or identifier, dor_qualified_type=dor_qualified_type) + + def get_object_by_uuid(self, uuid: str) -> List[Any]: + """ + Search all objects with the uuid :param:`uuid`. + :param uuid: + :return: + """ + return self.energyml_objects.get_by_uuid(uuid) + + def get_object_by_identifier(self, identifier: Union[str, Uri]) -> Optional[Any]: + """ + Search an object by its identifier. + :param identifier: given by the function :func:`get_obj_identifier`, or a URI (or its str representation) + :return: + """ + # Use the O(1) dict lookup from the collection + return self.energyml_objects.get_by_identifier(identifier) + + def get_object(self, identifier: Union[str, Uri]) -> Optional[Any]: + return self.get_object_by_identifier(identifier) + + def add_object(self, obj: Any) -> bool: + """ + Add an energyml object to the EPC stream (calls put_object for consistency) + :param obj: + :return: + """ + return self.put_object(obj) is not None + + def remove_object(self, identifier: Union[str, Uri]) -> None: + """ + Remove an energyml object from the EPC stream by its identifier (calls delete_object for consistency) + :param identifier: + :return: + """ + self.delete_object(identifier) + + def __len__(self) -> int: + return len(self.energyml_objects) + + def list_objects(self, dataspace: str | None = None, object_type: str | None = None) -> List[ResourceMetadata]: + result = [] + for obj in self.energyml_objects: + if (dataspace is None or get_obj_type(get_obj_usable_class(obj)) == dataspace) and ( + object_type is None or get_qualified_type_from_class(type(obj)) == object_type + ): + res_meta = ResourceMetadata( + uri=str(get_obj_uri(obj)), + uuid=get_obj_uuid(obj), + title=get_object_attribute(obj, "citation.title") or "", + object_type=type(obj).__name__, + version=get_obj_version(obj), + content_type=get_content_type_from_class(type(obj)) or "", + ) + result.append(res_meta) + return result + + def put_object(self, obj: Any, dataspace: str | None = None) -> str | None: + """ + Add or update an energyml object in the EPC stream. + :param obj: The energyml object to add + :param dataspace: Optional dataspace parameter (for interface compatibility) + :return: The URI of the added object, or None if failed + """ + self.energyml_objects.append(obj) + + # Update relationships cache + if self._rels_cache is None: + self._rels_cache = EpcRelsCache(self, export_version=self.export_version) + self._rels_cache.update_cache_for_object(obj) + + return str(get_obj_uri(obj)) + + def delete_object(self, identifier: Union[str, Any]) -> bool: + """ + Delete an energyml object from the EPC stream. + :param identifier: The object identifier/URI or the object itself + :return: True if object was deleted, False otherwise + """ + obj = self.get_object_by_identifier(identifier) + if obj is not None: + # Remove from collection + self.energyml_objects.remove(obj) + + # Update relationships cache + if self._rels_cache is None: + self._rels_cache = EpcRelsCache(self, export_version=self.export_version) + self._rels_cache._remove_object_from_cache(obj) + + return True + return False + + def dumps_epc_content_and_files_lists(self) -> str: + """ + Dumps the EPC content and files lists for debugging purposes. + :return: A string representation of the EPC content and files lists. + """ + content_list = [ + f"{get_obj_identifier(obj)} ({get_qualified_type_from_class(type(obj))})" for obj in self.energyml_objects + ] + raw_files_list = [raw_file.path for raw_file in self.raw_files] + + return "EPC Content:\n" + "\n".join(content_list) + "\n\nRaw Files:\n" + "\n".join(raw_files_list) + + def close(self) -> None: + """ + Close the EPC file and release any resources. + :return: + """ + pass + + # ============== # Class methods + # ============== @classmethod # @log_timestamp @@ -1292,7 +1471,7 @@ def read_stream( _read_files = [] obj_list = [] raw_file_list = [] - additional_rels = {} + # additional_rels = {} core_props = None # Store rels files separately for potential cache population rels_files_to_load = {} # {obj_path: Relationships} @@ -1376,24 +1555,24 @@ def read_stream( obj_path = obj_folder + obj_file_name if obj_path in path_to_obj: try: - additional_rels_key = get_obj_identifier(path_to_obj[obj_path]) # Store all rels for potential cache population if read_rels_from_files: rels_files_to_load[obj_path] = rels_file - # Keep only non-computable rels in additional_rels (legacy support) - for rel in rels_file.relationship: - # logging.debug(f"\t\t{rel.type_value}") - if ( - rel.type_value != EPCRelsRelationshipType.DESTINATION_OBJECT.get_type() - and rel.type_value != EPCRelsRelationshipType.SOURCE_OBJECT.get_type() - and rel.type_value - != EPCRelsRelationshipType.EXTENDED_CORE_PROPERTIES.get_type() - ): # not a computable relation - if additional_rels_key not in additional_rels: - additional_rels[additional_rels_key] = [] - additional_rels[additional_rels_key].append(rel) + # additional_rels_key = get_obj_identifier(path_to_obj[obj_path]) + # # Keep only non-computable rels in additional_rels (legacy support) + # for rel in rels_file.relationship: + # # logging.debug(f"\t\t{rel.type_value}") + # if ( + # rel.type_value != EPCRelsRelationshipType.DESTINATION_OBJECT.get_type() + # and rel.type_value != EPCRelsRelationshipType.SOURCE_OBJECT.get_type() + # and rel.type_value + # != EPCRelsRelationshipType.EXTENDED_CORE_PROPERTIES.get_type() + # ): # not a computable relation + # if additional_rels_key not in additional_rels: + # additional_rels[additional_rels_key] = [] + # additional_rels[additional_rels_key].append(rel) except AttributeError: logging.error(traceback.format_exc()) pass # 'CoreProperties' object has no attribute 'object_version' @@ -1411,7 +1590,7 @@ def read_stream( energyml_objects=EnergymlObjectCollection(obj_list), raw_files=raw_file_list, core_props=core_props, - additional_rels=additional_rels, + # additional_rels=additional_rels, ) # Populate rels cache from loaded rels files if requested @@ -1433,149 +1612,6 @@ def read_stream( return None - def list_objects(self, dataspace: str | None = None, object_type: str | None = None) -> List[ResourceMetadata]: - result = [] - for obj in self.energyml_objects: - if (dataspace is None or get_obj_type(get_obj_usable_class(obj)) == dataspace) and ( - object_type is None or get_qualified_type_from_class(type(obj)) == object_type - ): - res_meta = ResourceMetadata( - uri=str(get_obj_uri(obj)), - uuid=get_obj_uuid(obj), - title=get_object_attribute(obj, "citation.title") or "", - object_type=type(obj).__name__, - version=get_obj_version(obj), - content_type=get_content_type_from_class(type(obj)) or "", - ) - result.append(res_meta) - return result - - def put_object(self, obj: Any, dataspace: str | None = None) -> str | None: - """ - Add or update an energyml object in the EPC stream. - :param obj: The energyml object to add - :param dataspace: Optional dataspace parameter (for interface compatibility) - :return: The URI of the added object, or None if failed - """ - self.energyml_objects.append(obj) - - # Update relationships cache - if self._rels_cache is None: - self._rels_cache = EpcRelsCache(self, export_version=self.export_version) - self._rels_cache.update_cache_for_object(obj) - - return str(get_obj_uri(obj)) - - def delete_object(self, identifier: Union[str, Any]) -> bool: - """ - Delete an energyml object from the EPC stream. - :param identifier: The object identifier/URI or the object itself - :return: True if object was deleted, False otherwise - """ - obj = self.get_object_by_identifier(identifier) - if obj is not None: - # Remove from collection - self.energyml_objects.remove(obj) - - # Update relationships cache - if self._rels_cache is None: - self._rels_cache = EpcRelsCache(self, export_version=self.export_version) - self._rels_cache._remove_object_from_cache(obj) - - return True - return False - - def get_array_metadata( - self, - proxy: Union[str, Uri, Any], - path_in_external: Optional[str] = None, - start_indices: Optional[List[int]] = None, - counts: Optional[List[int]] = None, - ) -> Union[DataArrayMetadata, List[DataArrayMetadata], None]: - """ - Get metadata for data array(s) without loading the full array data. - Supports RESQML v2.2 sub-array selection metadata. - - :param proxy: The object identifier/URI or the object itself that references the array - :param path_in_external: Optional specific path. If None, returns all array metadata for the object - :param start_indices: Optional start index for each dimension (RESQML v2.2 StartIndex) - :param counts: Optional count of elements for each dimension (RESQML v2.2 Count) - :return: DataArrayMetadata if path specified, List[DataArrayMetadata] if no path, or None if not found - """ - obj = proxy - if isinstance(proxy, str) or isinstance(proxy, Uri): - obj = self.get_object_by_identifier(proxy) - - # Get possible file paths for this object - file_paths = self.get_h5_file_paths(obj) - if not file_paths or len(file_paths) == 0: - file_paths = self.external_files_path - - if not file_paths: - logging.warning(f"No external file paths found for proxy: {proxy}") - return None - - # Get the file handler registry - handler_registry = get_handler_registry() - - for file_path in file_paths: - # Get the appropriate handler for this file type - handler = handler_registry.get_handler_for_file(file_path) - if handler is None: - logging.debug(f"No handler found for file: {file_path}") - continue - - try: - # Use handler to get metadata without loading full array - metadata_dict = handler.get_array_metadata(file_path, path_in_external, start_indices, counts) - - if metadata_dict is None: - continue - - # Convert dict(s) to DataArrayMetadata - if isinstance(metadata_dict, list): - return [ - DataArrayMetadata( - path_in_resource=m.get("path"), - array_type=m.get("dtype", "unknown"), - dimensions=m.get("shape", []), - start_indices=start_indices, - custom_data={"size": m.get("size", 0)}, - ) - for m in metadata_dict - ] - else: - return DataArrayMetadata( - path_in_resource=metadata_dict.get("path"), - array_type=metadata_dict.get("dtype", "unknown"), - dimensions=metadata_dict.get("shape", []), - start_indices=start_indices, - custom_data={"size": metadata_dict.get("size", 0)}, - ) - except Exception as e: - logging.debug(f"Failed to get metadata from file {file_path}: {e}") - - return None - - def dumps_epc_content_and_files_lists(self) -> str: - """ - Dumps the EPC content and files lists for debugging purposes. - :return: A string representation of the EPC content and files lists. - """ - content_list = [ - f"{get_obj_identifier(obj)} ({get_qualified_type_from_class(type(obj))})" for obj in self.energyml_objects - ] - raw_files_list = [raw_file.path for raw_file in self.raw_files] - - return "EPC Content:\n" + "\n".join(content_list) + "\n\nRaw Files:\n" + "\n".join(raw_files_list) - - def close(self) -> None: - """ - Close the EPC file and release any resources. - :return: - """ - pass - # ______ __ ____ __ _ # / ____/___ ___ _________ ___ ______ ___ / / / __/_ ______ _____/ /_(_)___ ____ _____ @@ -1589,7 +1625,9 @@ def close(self) -> None: from .epc_utils import ( create_default_core_properties, create_default_types, + create_external_relationship, gen_rels_path_from_obj_path, + get_dor_or_external_uris_from_obj, get_dor_uris_from_obj, get_epc_content_type_rels_path, get_rels_dor_type, diff --git a/energyml-utils/src/energyml/utils/epc_utils.py b/energyml-utils/src/energyml/utils/epc_utils.py index b8e9dd2..2ad223e 100644 --- a/energyml-utils/src/energyml/utils/epc_utils.py +++ b/energyml-utils/src/energyml/utils/epc_utils.py @@ -257,6 +257,15 @@ def in_epc_file_path_to_mime_type(path: str) -> Optional[str]: # /_/ /_/___//____/\____/ +def create_external_relationship(path: str, _id: Optional[str] = None) -> Relationship: + return Relationship( + target=path, + type_value=str(EPCRelsRelationshipType.EXTERNAL_RESOURCE), + target_mode=TargetMode.EXTERNAL, + id=_id or f"_ext_{gen_uuid()}", + ) + + def create_h5_external_relationship(h5_path: str, current_idx: int = 0) -> Relationship: """ Create a Relationship object to link an external HDF5 file. @@ -753,6 +762,80 @@ def get_dor_uris_from_obj(obj: Any) -> Set[Uri]: return uri_set +def get_dor_or_external_uris_from_obj(obj: Any) -> Tuple[Set[Uri], Set[Tuple[str, str]]]: + """ + Extract all URIs from Data Object References (DORs) and external data references in an EnergyML object. + + This function performs a comprehensive scan of an EnergyML object to find: + 1. **Data Object References (DORs)**: Internal references to other EnergyML objects within the EPC + (e.g., a TriangulatedSetRepresentation pointing to a HorizonInterpretation) + 2. **External Data References**: References to external data files, typically HDF5 arrays + (e.g., ExternalDataArrayPart.uri for array storage outside the EPC) + + Unlike `get_dor_uris_from_obj()` which only returns DORs, this function captures both internal + object references AND external file references, making it suitable for complete dependency analysis. + + :param obj: Any EnergyML object (e.g., Representation, Property, Interpretation, etc.) + The function will recursively search all attributes matching DOR or external reference patterns. + + :return: A tuple containing: + - A set of URIs for all DORs found (internal references to other EnergyML objects) + - A set of tuples for external references, where each tuple contains (external URI, MIME type) + + :raises: Does not raise exceptions. Logs warnings for any extraction failures and continues processing. + + Example: + >>> from energyml.resqml.v2_2.resqmlv2 import TriangulatedSetRepresentation + >>> trset = load_triangulated_set() # Has DOR to interpretation + external HDF5 arrays + >>> dor_uris, external_uris = get_dor_or_external_uris_from_obj(trset) + >>> for uri in dor_uris: + ... print(f"Internal reference: {uri}") + >>> for ext_uri, mime_type in external_uris: + ... print(f"External file: {ext_uri} (type: {mime_type})") + + ... print(f"Internal reference: {uri}") + ... else: + ... print(f"External file: {uri[0]} (type: {uri[1]})") + Internal reference: eml:///resqml22.HorizonInterpretation(abc-123-def) + External file: my_hdf5_file.h5 (type: application/x-hdf5) + + Note: + - The search pattern matches both 'DataObjectReference' and 'ExternalDataArrayPart' types + - DORs are identified by having 'uid' or 'uuid' attributes + - External references are identified by having 'uri' and optionally 'mime_type' attributes + - For complete relationship analysis including reverse relationships, use EpcRelsCache instead + + See Also: + - `get_dor_uris_from_obj()`: Similar function but only returns internal DOR references + - `get_direct_dor_list()`: Returns the actual DOR objects rather than their URIs + """ + dor_uris = set() + external_uris = set() + try: + dor_list = search_attribute_matching_type(obj, "DataObjectReference|ExternalDataArrayPart") + for dor_or_ext in dor_list: + if hasattr(dor_or_ext, "uid") or hasattr(dor_or_ext, "uuid"): + # DOR case + try: + uri = get_obj_uri(dor_or_ext) + if uri and uri.is_object_uri(): + dor_uris.add(uri) + except Exception as e: + logging.warning(f"Failed to extract uri from DOR: {e}") + else: + # External reference case (e.g. ExternalDataArrayPart) + try: + ext_uri = getattr(dor_or_ext, "uri", None) + ext_mime_type = getattr(dor_or_ext, "mime_type", None) + if ext_uri: + external_uris.add((ext_uri, ext_mime_type)) + except Exception as e: + logging.warning(f"Failed to extract uri from external reference: {e}") + except Exception as e: + logging.warning(f"Failed to get DOR list from object: {e}") + return dor_uris, external_uris + + # ____ ___ ________ ______ # / __ \/ |/_ __/ / / / ___/ # / /_/ / /| | / / / /_/ /\__ \ diff --git a/energyml-utils/tests/test_epc.py b/energyml-utils/tests/test_epc.py index da626f3..688dd16 100644 --- a/energyml-utils/tests/test_epc.py +++ b/energyml-utils/tests/test_epc.py @@ -20,7 +20,7 @@ from energyml.eml.v2_0.commonv2 import Citation as Citation20 from energyml.eml.v2_0.commonv2 import DataObjectReference as DataObjectReference201, EpcExternalPartReference -from energyml.eml.v2_3.commonv2 import Citation, DataObjectReference +from energyml.eml.v2_3.commonv2 import Citation, DataObjectReference, ExternalDataArray, ExternalDataArrayPart from energyml.resqml.v2_0_1.resqmlv2 import ( FaultInterpretation, TriangulatedSetRepresentation as TriangulatedSetRepresentation20, @@ -32,6 +32,9 @@ BoundaryFeatureInterpretation, BoundaryFeature, HorizonInterpretation, + TrianglePatch, + PointGeometry, + Point3DExternalArray, ) from energyml.utils.epc import ( @@ -45,10 +48,13 @@ epoch, gen_uuid, get_content_type_from_class, + get_obj_uri, get_qualified_type_from_class, get_obj_identifier, ) -from energyml.utils.constants import EPCRelsRelationshipType +from energyml.utils.constants import EPCRelsRelationshipType, MimeType + +CST_H5_PATH = "my_h5_filepath.h5" @pytest.fixture @@ -112,6 +118,21 @@ def sample_objects(): uuid="25773477-ffee-4cc2-867d-000000000004", object_version="1.0", represented_object=as_dor(horizon_interp), + triangle_patch=[ + TrianglePatch( + geometry=PointGeometry( + points=Point3DExternalArray( + coordinates=ExternalDataArray( + external_data_array_part=[ + ExternalDataArrayPart( + path_in_external_file="/points", uri=CST_H5_PATH, mime_type=MimeType.HDF5.value + ) + ] + ) + ) + ) + ) + ], ) # Resqml 2.0.1 FaultInterpretation for additional tests @@ -464,6 +485,24 @@ def test_external_part_reference_relationships(self, sample_objects): trset20_path = gen_energyml_object_path(trset20, epc.export_version) assert any(trset20_path in r.target for r in proxy_to_ml_rels) + def test_external_data_array_part_rels_detection(self, sample_objects): + """Test that ExternalDataArrayPart relationships are detected.""" + from energyml.opc.opc import TargetMode + + epc = Epc() + trset22 = sample_objects["trset"] + horizon_interp = sample_objects["horizon_interp"] + + epc.add_object(horizon_interp) + epc.add_object(trset22) + + trset22_external_rels = [ + r for r in epc.get_obj_rels(trset22) if r.type_value == str(EPCRelsRelationshipType.EXTERNAL_RESOURCE) + ] + assert len(trset22_external_rels) == 1 + assert trset22_external_rels[0].target == CST_H5_PATH + assert trset22_external_rels[0].target_mode == TargetMode.EXTERNAL + def test_trset20_has_ml_to_external_part_proxy_relationship(self, sample_objects): """Test that trset20 has ML_TO_EXTERNAL_PART_PROXY relationship to external_ref.""" epc = Epc() @@ -797,7 +836,7 @@ def test_add_rels_for_object(self, sample_objects): bf = sample_objects["bf"] epc.add_object(bf) - identifier = get_obj_identifier(bf) + identifier = get_obj_uri(bf) # Add external resource relationship h5_rel = Relationship( @@ -808,8 +847,8 @@ def test_add_rels_for_object(self, sample_objects): epc.add_rels_for_object(identifier, [h5_rel]) - assert identifier in epc.additional_rels - assert len(epc.additional_rels[identifier]) == 1 + assert identifier in epc._rels_cache._supplemental_rels + assert len(epc._rels_cache._supplemental_rels[identifier]) == 1 def test_get_h5_file_paths(self, sample_objects): """Test retrieving H5 file paths from relationships.""" From 0feb081c1e7bd73ad31560737d7889af5bace66a Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Sun, 15 Feb 2026 05:54:11 +0100 Subject: [PATCH 39/70] support for wellboreFrame representation in 3D reading --- energyml-utils/example/attic/arrays_test.py | 258 ++++++++++ .../src/energyml/utils/constants.py | 4 + .../src/energyml/utils/data/helper.py | 459 +++++++++++++++++- .../src/energyml/utils/data/mesh.py | 286 +++++++---- energyml-utils/src/energyml/utils/epc.py | 25 +- .../src/energyml/utils/introspection.py | 6 +- 6 files changed, 927 insertions(+), 111 deletions(-) create mode 100644 energyml-utils/example/attic/arrays_test.py diff --git a/energyml-utils/example/attic/arrays_test.py b/energyml-utils/example/attic/arrays_test.py new file mode 100644 index 0000000..2a495f0 --- /dev/null +++ b/energyml-utils/example/attic/arrays_test.py @@ -0,0 +1,258 @@ +import logging +from typing import List, Optional +import numpy as np +from energyml.utils.data.helper import _ARRAY_NAMES_, read_array +from energyml.utils.data.mesh import AbstractMesh, SurfaceMesh, PolylineSetMesh, read_mesh_object +from energyml.utils.storage_interface import EnergymlStorageInterface +from energyml.utils.epc import Epc + +from energyml.resqml.v2_2.resqmlv2 import Point3DLatticeArray + +from energyml.utils.serialization import read_energyml_xml_str, serialize_json + + +xml_Point3DLatticeArray = """ + + + 0.0 + 0.0 + 0.0 + + + + 0.0 + 1.0 + 0.0 + + + 1.0 + 99 + + + + + 1.0 + 0.0 + 0.0 + + + 1.0 + 49 + + + +""" + + +grid_2D = """ + + + 100x10 grid 2d for continuous color map + phili + 2026-02-13T16:55:42Z + F2I-CONSULTING:FESAPI Example:2.14.1.0 + + + 34b69c81-6cfa-4531-be5b-f6bd9b74802f + resqml22.HorizonInterpretation + Horizon interpretation for continuous color map + + map + 50 + 100 + + + 5c0703c5-3806-424e-86cf-8f59c8bb39fa + eml23.LocalEngineeringCompoundCrs + Default local CRS + + + + 0.0 + 0.0 + 0.0 + + + + 0.0 + 1.0 + 0.0 + + + 1.0 + 99 + + + + + 1.0 + 0.0 + 0.0 + + + 1.0 + 49 + + + + + +""" + +polyline_rep = """ + + + Horizon1 Interp1 SinglePolylineRep + phili + 2026-02-13T16:55:39Z + F2I-CONSULTING:FESAPI Example:2.14.1.0 + + + ac12dc12-4951-459b-b585-90f48aa88a5a + resqml22.HorizonInterpretation + Horizon1 Interp1 + + false + + + 5c0703c5-3806-424e-86cf-8f59c8bb39fa + eml23.LocalEngineeringCompoundCrs + Default local CRS + + + + + 12 + /resqml22/47f86668-27c4-4b28-a19e-bd0355321ecc/points_patch0 + 0 + testingPackageCpp22.h5 + application/x-hdf5 + + + + + + 5a371b9e-7202-42de-83a0-1b996d20586b + resqml22.PolylineRepresentation + Seismic line Rep + + + arrayOfFloat32LE + 1 + + + 4 + /resqml22/47f86668-27c4-4b28-a19e-bd0355321ecc/lineAbscissa_patch0 + 0 + testingPackageCpp22.h5 + application/x-hdf5 + + + + + + +""" + + +def read_grid() -> List[AbstractMesh]: + point3d_lattice_array = read_energyml_xml_str(xml_Point3DLatticeArray) + # print(point3d_lattice_array) + # point3d_lattice_array.value + if "DerivedElement" in str(type(point3d_lattice_array)): + point3d_lattice_array = point3d_lattice_array.value + print(serialize_json(point3d_lattice_array, check_obj_prefixed_classes=False)) + + print(np.array(read_array(point3d_lattice_array, None))) + + grid_2d = read_energyml_xml_str(grid_2D) + if "DerivedElement" in str(type(grid_2d)): + grid_2d = grid_2d.value + + meshes = read_mesh_object(grid_2d) + return meshes + + +def read_polyline() -> List[AbstractMesh]: + # polyline_representation = read_energyml_xml_str(polyline_rep) + # if "DerivedElement" in str(type(polyline_representation)): + # polyline_representation = polyline_representation.value + + # meshes = read_mesh_object(polyline_representation) + # return meshes + + epc = Epc.read_file("rc/epc/testingPackageCpp22.epc", read_rels_from_files=False, recompute_rels=False) + + polyline0 = epc.get_object_by_uuid("a54b8399-d3ba-4d4b-b215-8d4f8f537e66")[0] + # polyline0 = epc.get_object_by_uuid("65c59595-bf48-451e-94aa-120ebdf28d8b")[0] + # polyline0 = epc.get_object_by_uuid("47f86668-27c4-4b28-a19e-bd0355321ecc")[0] + print(polyline0) + print(epc.get_h5_file_paths(polyline0)) + + meshes = read_mesh_object(energyml_object=polyline0, workspace=epc) + + return meshes + + +def read_wellbore_frame_repr( + epc_path: str = "rc/epc/testingPackageCpp22.epc", + well_uuid: str = "d873e243-d893-41ab-9a3e-d20b851c099f", +) -> List[AbstractMesh]: + epc = Epc.read_file(f"{epc_path}", read_rels_from_files=False, recompute_rels=False) + + frame_repr = epc.get_object_by_uuid(well_uuid)[0] + # print(frame_repr) + # print(epc.get_h5_file_paths(frame_repr)) + + meshes = read_mesh_object(energyml_object=frame_repr, workspace=epc) + + # Previous result : + # points: + # [[ 0. 0. 0.] + # [ 0. 0. 250.] + # [ 0. 0. 500.] + # [ 0. 0. 750.] + # [ 0. 0. 1000.]] + # line indices: + # [[0 1] + # [1 2] + # [2 3] + # [3 4]] + + return meshes + + +def read_representation_set_representation() -> List[AbstractMesh]: + epc = Epc.read_file("rc/epc/testingPackageCpp22.epc", read_rels_from_files=False, recompute_rels=False) + + rep_set_rep = epc.get_object_by_uuid("6b992199-5b47-4624-a62c-b70857133cda")[0] + # print(rep_set_rep) + print(epc.get_h5_file_paths(rep_set_rep)) + + return read_mesh_object(energyml_object=rep_set_rep, workspace=epc) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + + # meshes = read_grid() + # meshes = read_polyline() + # meshes = read_wellbore_frame_repr() + meshes = read_representation_set_representation() + + for m in meshes: + print("=" * 40) + print(f"Mesh identifier: {m.identifier}") + print("points:") + print(np.array(m.point_list)) + + if isinstance(m, SurfaceMesh): + print("face indices:") + print(np.array(m.faces_indices)) + elif isinstance(m, PolylineSetMesh): + print("line indices:") + try: + print(np.array(m.line_indices)) + except Exception as e: + print(m.line_indices) + raise e diff --git a/energyml-utils/src/energyml/utils/constants.py b/energyml-utils/src/energyml/utils/constants.py index 96aa9f3..e37a919 100644 --- a/energyml-utils/src/energyml/utils/constants.py +++ b/energyml-utils/src/energyml/utils/constants.py @@ -675,6 +675,10 @@ def path_iter(dot_path: str) -> List[str]: return [] +def path_parent_attribute(dot_path: str) -> Optional[str]: + return ".".join(path_iter(dot_path)[:-1]) if dot_path else None + + # =================================== # RESOURCE ACCESS UTILITIES # =================================== diff --git a/energyml-utils/src/energyml/utils/data/helper.py b/energyml-utils/src/energyml/utils/data/helper.py index c5ab57b..e9780be 100644 --- a/energyml-utils/src/energyml/utils/data/helper.py +++ b/energyml-utils/src/energyml/utils/data/helper.py @@ -3,13 +3,13 @@ import inspect import logging import sys -from typing import Any, Optional, Callable, List, Union +from typing import Any, Literal, Optional, Callable, List, Tuple, Union from energyml.utils.storage_interface import EnergymlStorageInterface import numpy as np from .datasets_io import read_external_dataset_array -from ..constants import flatten_concatenation, path_last_attribute +from ..constants import flatten_concatenation, path_last_attribute, path_parent_attribute from ..exception import ObjectNotFoundNotError from energyml.utils.introspection import ( get_obj_uri, @@ -123,10 +123,12 @@ def get_vertical_epsg_code(crs_object: Any): vertical_epsg_code = get_object_attribute_rgx( crs_object, "OriginProjectedCrs.AbstractProjectedCrs.EpsgCode" ) + if vertical_epsg_code is None: + vertical_epsg_code = get_object_attribute_rgx(crs_object, "abstract_vertical_crs.epsg_code") return vertical_epsg_code -def get_projected_epsg_code(crs_object: Any, workspace: Optional[EnergymlStorageInterface] = None): +def get_projected_epsg_code(crs_object: Any, workspace: Optional[EnergymlStorageInterface] = None) -> Optional[str]: if crs_object is not None: # LocalDepth3dCRS projected_epsg_code = get_object_attribute_rgx(crs_object, "ProjectedCrs.EpsgCode") if projected_epsg_code is None: # LocalEngineering2DCrs @@ -156,6 +158,125 @@ def get_projected_uom(crs_object: Any, workspace: Optional[EnergymlStorageInterf return None +def get_crs_offsets_and_angle( + crs_object: Any, workspace: Optional[EnergymlStorageInterface] = None +) -> Tuple[float, float, float, Tuple[float, str]]: + """Return the CRS offsets (X, Y, Z) and the areal rotation angle (value and uom) if they exist in the CRS object.""" + if crs_object is None: + return 0.0, 0.0, 0.0, (0.0, "rad") + + # eml23.LocalEngineering2DCrs + _tmpx = get_object_attribute_rgx(crs_object, "OriginProjectedCoordinate1") + _tmpy = get_object_attribute_rgx(crs_object, "OriginProjectedCoordinate2") + _tmp_azimuth = get_object_attribute_rgx(crs_object, "azimuth.value") + _tmp_azimuth_uom = str(get_object_attribute_rgx(crs_object, "azimuth.uom") or "") + if _tmpx is not None and _tmpy is not None: + try: + return ( + float(_tmpx), + float(_tmpy), + 0.0, + (float(_tmp_azimuth) if _tmp_azimuth is not None else 0.0, _tmp_azimuth_uom), + ) # Z offset is not defined in 2D CRS, it is defined in eml23.LocalEngineeringCompoundCrs + except Exception as e: + logging.info(f"ERR reading crs offset {e}") + + # resqml20.ObjLocalDepth3DCrs + _tmpx = get_object_attribute_rgx(crs_object, "XOffset") + _tmpy = get_object_attribute_rgx(crs_object, "YOffset") + _tmpz = get_object_attribute_rgx(crs_object, "ZOffset") + _tmp_azimuth = get_object_attribute_rgx(crs_object, "ArealRotation.value") + _tmp_azimuth_uom = str(get_object_attribute_rgx(crs_object, "ArealRotation.uom") or "") + if _tmpx is not None and _tmpy is not None: + try: + return ( + float(_tmpx), + float(_tmpy), + float(_tmpz), + (float(_tmp_azimuth) if _tmp_azimuth is not None else 0.0, _tmp_azimuth_uom), + ) + except Exception as e: + logging.info(f"ERR reading crs offset {e}") + + # eml23.LocalEngineeringCompoundCrs + _tmp_z = get_object_attribute_rgx(crs_object, "OriginVerticalCoordinate") + + local_engineering2d_crs_dor = get_object_attribute_rgx(crs_object, "localEngineering2DCrs") + if local_engineering2d_crs_dor is not None and workspace is not None: + local_engineering2d_crs_uri = get_obj_uri(local_engineering2d_crs_dor) + _tmp_x, _tmp_y, _, (azimuth, azimuth_uom) = get_crs_offsets_and_angle( + workspace.get_object(local_engineering2d_crs_uri), workspace + ) + return _tmp_x, _tmp_y, float(_tmp_z) if _tmp_z is not None else 0.0, (azimuth, azimuth_uom) + + if _tmp_z is not None: + try: + return 0.0, 0.0, float(_tmp_z), (0.0, "rad") + except Exception as e: + logging.info(f"ERR reading crs offset {e}") + + return 0.0, 0.0, 0.0, (0.0, "rad") + + +def apply_crs_transform( + well_points: np.ndarray, + x_offset: float = 0.0, + y_offset: float = 0.0, + z_offset: float = 0.0, + areal_rotation: float = 0.0, + rotation_uom: str = "rad", + z_is_up: bool = True, +) -> np.ndarray: + """ + Transforms interpolated wellbore points from Local CRS to Global/Project coordinates. + + Args: + well_points: A (N, 3) numpy array of interpolated [X, Y, Z] points. + x_offset: The X translation value (resqml:XOffset). + y_offset: The Y translation value (resqml:YOffset). + z_offset: The Z translation value (resqml:ZOffset). + areal_rotation: The rotation angle (azimuth of the local CRS grid). + rotation_uom: The unit of measure for the rotation ('rad' or 'degr'). + z_is_up: If True, converts Z values to 'Up is Positive' (negates RESQML Z). + + Returns: + A (N, 3) numpy array of transformed coordinates. + """ + # Create a copy to avoid mutating the original input array + transformed: np.ndarray = well_points.copy().astype(np.float64) + + # 1. Convert rotation to radians if necessary + angle_rad: float = areal_rotation + if rotation_uom == "degr": + angle_rad = np.radians(areal_rotation) + + # 2. Handle Areal Rotation (Rotation around the Z axis) + # Applied before translation as per Energistics standards. + # Note: RESQML rotation is typically clockwise. + if angle_rad != 0.0: + cos_theta = np.cos(angle_rad) + sin_theta = np.sin(angle_rad) + + x_orig = transformed[:, 0].copy() + y_orig = transformed[:, 1].copy() + + # Standard 2D rotation matrix + transformed[:, 0] = x_orig * cos_theta - y_orig * sin_theta + transformed[:, 1] = x_orig * sin_theta + y_orig * cos_theta + + # 3. Apply Translation (Offsets) + transformed[:, 0] += x_offset + transformed[:, 1] += y_offset + transformed[:, 2] += z_offset + + # 4. Final Vertical Orientation + # Negate Z if the target system is Z-Up (RESQML is natively Z-Down). + if z_is_up: + transformed[:, 2] = -transformed[:, 2] + + return transformed + + def get_crs_origin_offset(crs_obj: Any) -> List[float | int]: """ Return a list [X,Y,Z] corresponding to the crs Offset [XOffset/OriginProjectedCoordinate1, ... ] depending on the @@ -188,6 +309,70 @@ def get_crs_origin_offset(crs_obj: Any) -> List[float | int]: return crs_point_offset +def get_datum_information(datum_obj: Any, workspace: Optional[EnergymlStorageInterface] = None): + "From a ObjMdDatum or a ReferencePointInACrs, return x, y, z, z_increas_downward, projected_epsg_code, vertical_epsg_code" + if datum_obj is None: + return 0.0, 0.0, 0.0, False, None, None + + t_lw = type(datum_obj).__name__.lower() + + # resqml20.LocalDepth3dCrs + if "localdepth3dcrs" in t_lw: + x = get_object_attribute_rgx(datum_obj, "XOffset.value") + y = get_object_attribute_rgx(datum_obj, "YOffset.value") + z = get_object_attribute_rgx(datum_obj, "ZOffset.value") + z_increasing_downward = get_object_attribute(datum_obj, "ZIncreasingDownward") or False + projected_epsg_code = get_projected_epsg_code(datum_obj, workspace) + vertical_epsg_code = get_vertical_epsg_code(datum_obj) + return ( + float(x) if x is not None else 0.0, + float(y) if y is not None else 0.0, + float(z) if z is not None else 0.0, + z_increasing_downward, + projected_epsg_code, + vertical_epsg_code, + ) + elif "referencepointinacrs" in t_lw: + x = get_object_attribute_rgx(datum_obj, "horizontal_coordinates.coordinate1") + y = get_object_attribute_rgx(datum_obj, "horizontal_coordinates.coordinate2") + z = get_object_attribute_rgx(datum_obj, "vertical_coordinate") + z_increasing_downward = get_object_attribute(datum_obj, "ZIncreasingDownward") or False + p_crs = get_object_attribute(datum_obj, "horizontal_coordinates.crs") + projected_epsg_code = ( + get_projected_epsg_code(workspace.get_object(get_obj_uri(p_crs)), workspace) + if p_crs is not None and workspace is not None + else None + ) + v_crs = get_object_attribute(datum_obj, "vertical_crs") + vertical_epsg_code = get_vertical_epsg_code(v_crs) if v_crs is not None else None + return ( + float(x) if x is not None else 0.0, + float(y) if y is not None else 0.0, + float(z) if z is not None else 0.0, + z_increasing_downward, + projected_epsg_code, + vertical_epsg_code, + ) + elif "mddatum" in t_lw: + x = get_object_attribute_rgx(datum_obj, "location.coordinate1") + y = get_object_attribute_rgx(datum_obj, "location.coordinate2") + z = get_object_attribute_rgx(datum_obj, "location.coordinate3") + crs = get_object_attribute(datum_obj, "LocalCrs") + _, _, _, z_increasing_downward, projected_epsg_code, vertical_epsg_code = get_datum_information(crs, workspace) + return ( + float(x) if x is not None else 0.0, + float(y) if y is not None else 0.0, + float(z) if z is not None else 0.0, + z_increasing_downward, + projected_epsg_code, + vertical_epsg_code, + ) + return 0.0, 0.0, 0.0, False, None, None + + +# ================================================== + + def prod_n_tab(val: Union[float, int, str], tab: List[Union[float, int, str]]): """ Multiply every value of the list 'tab' by the constant 'val' @@ -280,7 +465,8 @@ def get_crs_obj( return crs if context_obj != root_obj: - upper_path = path_in_root[: path_in_root.rindex(".")] + upper_path = path_parent_attribute(path_in_root) + # upper_path = path_in_root[: path_in_root.rindex(".")] if len(upper_path) > 0: return get_crs_obj( context_obj=get_object_attribute(root_obj, upper_path), @@ -292,6 +478,269 @@ def get_crs_obj( return None +def linear_interpolation(md_target, md_start, md_end, p_start, p_end): + """ + Calcule la position 3D par interpolation linéaire simple. + Utilisé quand Continuity = 0 ou quand les TangentVectors sont absents. + """ + # Calcul du ratio de progression (0 à 1) + h = md_end - md_start + if h == 0: + return p_start + + t = (md_target - md_start) / h + + # Formule : P = P_start + t * (P_end - p_start) + p_target = p_start + t * (p_end - p_start) + + return p_target + + +def hermite_interpolation(md_target, md_start, md_end, p_start, p_end, v_start, v_end): + """ + Calcule la position 3D d'un point sur une trajectoire de puits via une Spline d'Hermite. + + Cette fonction est particulièrement adaptée aux objets RESQML de type + 'ParametricLineGeometry' avec une continuité C1. + + Args: + md_target (float): La profondeur mesurée (Measured Depth) cible à interpoler. + md_start (float): MD du point de contrôle précédent (Knot i). + md_end (float): MD du point de contrôle suivant (Knot i+1). + p_start (np.array): Coordonnées [X, Y, Z] au point md_start. + p_end (np.array): Coordonnées [X, Y, Z] au point md_end. + v_start (np.array): Vecteur tangente unitaire [dx, dy, dz] au point md_start. + v_end (np.array): Vecteur tangente unitaire [dx, dy, dz] au point md_end. + + Returns: + np.array: Un tableau numpy [X, Y, Z] représentant la position interpolée. + + Raises: + ValueError: Si md_start et md_end sont identiques (division par zéro). + AssertionError: Si md_target n'est pas compris dans l'intervalle [md_start, md_end]. + """ + + # 1. Vérification de l'intervalle + if not (md_start <= md_target <= md_end): + # Note : Dans certains cas de forage réel, on peut extrapoler, + # mais pour un WellboreFrame, on reste normalement dans les clous. + raise AssertionError("Le MD cible doit être compris entre md_start et md_end.") + + # Distance entre les deux points de contrôle + h = md_end - md_start + if h == 0: + raise ValueError("md_start et md_end ne peuvent pas être identiques.") + + # 2. Normalisation du paramètre t (0 <= t <= 1) + t = (md_target - md_start) / h + t2 = t * t + t3 = t2 * t + + # 3. Mise à l'échelle des vecteurs tangentes (scaling par la distance) + # En RESQML, les TangentVectors sont souvent unitaires ou normalisés. + # Pour l'interpolation cubique, ils doivent représenter la dérivée par rapport à t. + T_start = v_start * h + T_end = v_end * h + + # 4. Calcul des polynômes de base d'Hermite + h00 = 2 * t3 - 3 * t2 + 1 # Coefficient pour p_start + h10 = t3 - 2 * t2 + t # Coefficient pour T_start + h01 = -2 * t3 + 3 * t2 # Coefficient pour p_end + h11 = t3 - t2 # Coefficient pour T_end + + # 5. Combinaison linéaire pour obtenir la position P(t) + p_target = (h00 * p_start) + (h10 * T_start) + (h01 * p_end) + (h11 * T_end) + + return p_target + + +def get_wellbore_points( + mds: Optional[np.ndarray], + traj_mds: Optional[np.ndarray], + traj_points: Optional[np.ndarray], + traj_tangents: Optional[np.ndarray], + step_meters: float = 5.0, +) -> np.ndarray: + """ + mds : MDs du WellboreFrame + traj_mds : MDs de la trajectoire (ControlPointParameters) + traj_points : Points XYZ de la trajectoire + traj_tangents : Tangentes XYZ (Optionnel) + step_meters : Distance entre chaque point de la trajectoire lisse (Optionnel) + """ + if mds is None or len(mds) == 0: + if traj_mds is None or traj_points is None or traj_tangents is None: + raise ValueError( + "To generate a smooth trajectory, traj_mds, traj_points and traj_tangents must be provided." + ) + return generate_smooth_trajectory( + traj_mds=traj_mds, traj_points=traj_points, traj_tangents=traj_tangents, step_meters=step_meters + ) + + results = [] + + for m in mds: + # 1. Trouver l'intervalle + idx = np.searchsorted(traj_mds, m) - 1 + + # Gestion des bords + if idx < 0: + results.append(traj_points[0]) + continue + if idx >= len(traj_mds) - 1: + results.append(traj_points[-1]) + continue + + # 2. Extraire les bornes + p_s, p_e = traj_points[idx], traj_points[idx + 1] + m_s, m_e = traj_mds[idx], traj_mds[idx + 1] + + # 3. Choisir la méthode + if traj_tangents is not None: + # Cas ParametricLineGeometry C1+ + v_s, v_e = traj_tangents[idx], traj_tangents[idx + 1] + p_3d = hermite_interpolation(m, m_s, m_e, p_s, p_e, v_s, v_e) + else: + # Cas Linear ou PointGeometry + p_3d = linear_interpolation(m, m_s, m_e, p_s, p_e) + + results.append(p_3d) + + return np.array(results) + + +def generate_smooth_trajectory( + traj_mds: np.ndarray, traj_points: np.ndarray, traj_tangents: np.ndarray, step_meters: float = 5.0 +) -> np.ndarray: + """ + Generates a high-resolution polyline for the trajectory by sampling + it at a regular interval. + + Args: + traj_mds: MDs of control points from HDF5. + traj_points: Control points (N, 3) from HDF5. + traj_tangents: Tangent vectors (N, 3) from HDF5. + step_meters: Desired distance between each point of the final polyline. + + Returns: + A (M, 3) numpy array representing the smooth 3D polyline. + """ + # 1. Create a regular MD sampling from min to max MD + md_min, md_max = traj_mds[0], traj_mds[-1] + # We create a new set of MDs every 'step_meters' + sampled_mds = np.arange(md_min, md_max, step_meters) + + # Ensure the last point of the trajectory is included + if sampled_mds[-1] < md_max: + sampled_mds = np.append(sampled_mds, md_max) + + # 2. Reuse our interpolation logic + smooth_points = [] + for m in sampled_mds: + # Find the interval in the original control points + idx = np.searchsorted(traj_mds, m) - 1 + idx = max(0, min(idx, len(traj_mds) - 2)) + + p_3d = hermite_interpolation( + m, + traj_mds[idx], + traj_mds[idx + 1], + traj_points[idx], + traj_points[idx + 1], + traj_tangents[idx], + traj_tangents[idx + 1], + ) + smooth_points.append(p_3d) + + return np.array(smooth_points) + + +def generate_vertical_well_points(wellbore_mds: np.ndarray, head_x: float, head_y: float, head_z: float) -> np.ndarray: + """ + Generates local 3D coordinates for a perfectly vertical wellbore. + + Args: + wellbore_mds: (N,) array of Measured Depths from the WellboreFrame. + head_x: The X coordinate of the MdDatum (well head) in Local CRS. + head_y: The Y coordinate of the MdDatum (well head) in Local CRS. + head_z: The Z coordinate of the MdDatum (well head) in Local CRS. + + Returns: + (N, 3) numpy array of points [X, Y, Z] in Local CRS. + """ + num_points = len(wellbore_mds) + # Initialize the array with (N, 3) + local_points = np.zeros((num_points, 3)) + + # In a vertical well, X and Y are constant and equal to the head position + local_points[:, 0] = head_x + local_points[:, 1] = head_y + + # The MD (Measured Depth) represents the distance traveled from MD 0. + # In a vertical well, Z_point = Z_datum + (MD_point - MD_datum_at_0) + # Most of the time, MD at head is 0. + # If wellbore_mds start at 0, Z starts at head_z. + md_start = wellbore_mds[0] + local_points[:, 2] = head_z + (wellbore_mds - md_start) + + return local_points + + +def read_parametric_geometry( + geometry: Any, workspace: Optional[EnergymlStorageInterface] = None +) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]: + """Read a ParametricLineGeometry and return the controle point parameters, control points, and tangents.""" + if geometry is None: + raise ValueError("Geometry object is None") + + knot_count = getattr(geometry, "knot_count", None) + + traj_mds = read_array( + energyml_array=getattr(geometry, "control_point_parameters"), + root_obj=geometry, + workspace=workspace, + ) + if not isinstance(traj_mds, np.ndarray): + traj_mds = np.array(traj_mds) + + traj_points = read_array( + energyml_array=getattr(geometry, "control_points"), + root_obj=geometry, + workspace=workspace, + ) + if not isinstance(traj_points, np.ndarray): + traj_points = np.array(traj_points) + traj_points = traj_points.reshape(-1, 3) + + traj_tangents = None + try: + traj_tangents = read_array( + energyml_array=getattr(geometry, "tangent_vectors"), + root_obj=geometry, + workspace=workspace, + ) + except Exception as e: + logging.debug(f"No tangent vectors found for {geometry}, fallback to linear interpolation: {e}") + + if traj_tangents is not None: + if not isinstance(traj_tangents, np.ndarray): + traj_tangents = np.array(traj_tangents) + traj_tangents = traj_tangents.reshape(-1, 3) + + # verif with knot_count if exists + if knot_count is not None: + if ( + len(traj_mds) != knot_count + or len(traj_points) != knot_count + or (traj_tangents is not None and len(traj_tangents) != knot_count) + ): + logging.warning( + f"Mismatch between knot_count ({knot_count}) and actual control points count (mds: {len(traj_mds)}, points: {len(traj_points)}, tangents: {len(traj_tangents) if traj_tangents is not None else 'N/A'})" + ) + + return traj_mds, traj_points, traj_tangents + + # ___ # / | ______________ ___ _______ # / /| | / ___/ ___/ __ `/ / / / ___/ @@ -316,7 +765,7 @@ def _array_name_mapping(array_type_name: str) -> str: elif "Jagged" in array_type_name: return "JaggedArray" elif "Lattice" in array_type_name: - if "Integer" in array_type_name or "Double" in array_type_name: + if "Integer" in array_type_name or "Double" in array_type_name or "FloatingPoint" in array_type_name: return "int_double_lattice_array" return array_type_name diff --git a/energyml-utils/src/energyml/utils/data/mesh.py b/energyml-utils/src/energyml/utils/data/mesh.py index de1e6f9..e8d7ac3 100644 --- a/energyml-utils/src/energyml/utils/data/mesh.py +++ b/energyml-utils/src/energyml/utils/data/mesh.py @@ -14,11 +14,18 @@ from .helper import ( + apply_crs_transform, + generate_vertical_well_points, + get_crs_offsets_and_angle, + get_datum_information, + get_wellbore_points, + hermite_interpolation, read_array, read_grid2d_patch, get_crs_obj, get_crs_origin_offset, is_z_reversed, + read_parametric_geometry, ) from energyml.utils.epc_utils import gen_energyml_object_path from energyml.utils.epc_stream import EpcStreamReader @@ -324,7 +331,18 @@ def read_polyline_representation( for patch_path_in_obj, patch in search_attribute_matching_name_with_path( energyml_object, "NodePatch" ) + search_attribute_matching_name_with_path(energyml_object, r"LinePatch.[\d]+"): - points_path, points_obj = search_attribute_matching_name_with_path(patch, "Geometry.Points")[0] + + pts = search_attribute_matching_name_with_path(patch, "Geometry.Points") + if pts is None or len(pts) == 0: + pts = search_attribute_matching_name_with_path(patch, "Points") + + try: + points_path, points_obj = pts[0] + except Exception as e: + logging.error(f"Cannot find points for patch {patch_path_in_obj} : {e}") + logging.error(patch) + raise e + points = read_array( energyml_array=points_obj, root_obj=energyml_object, @@ -705,138 +723,176 @@ def read_wellbore_frame_representation( :param sub_indices: Optional list of indices to filter specific nodes :return: List containing a single PolylineSetMesh representing the wellbore """ + meshes = [] try: # Read measured depths (NodeMd) - md_array = [] + wellbore_frame_mds = None try: node_md_path, node_md_obj = search_attribute_matching_name_with_path(energyml_object, "NodeMd")[0] - md_array = read_array( + wellbore_frame_mds = read_array( energyml_array=node_md_obj, root_obj=energyml_object, path_in_root=node_md_path, workspace=workspace, ) - if not isinstance(md_array, list): - md_array = md_array.tolist() if hasattr(md_array, "tolist") else list(md_array) + # Ensure wellbore_frame_mds is a numpy array for filtering operations + if not isinstance(wellbore_frame_mds, np.ndarray): + wellbore_frame_mds = np.array(wellbore_frame_mds) except (IndexError, AttributeError) as e: logging.warning(f"Could not read NodeMd from wellbore frame: {e}") return meshes - # Get trajectory reference - trajectory_dor = search_attribute_matching_name(obj=energyml_object, name_rgx="Trajectory")[0] - trajectory_identifier = get_obj_uri(trajectory_dor) - trajectory_obj = workspace.get_object(trajectory_identifier) + # Get reference point (wellhead location) - try different attribute paths for different versions + md_min = np.min(wellbore_frame_mds) if len(wellbore_frame_mds) > 0 else 0.0 + md_max = np.max(wellbore_frame_mds) if len(wellbore_frame_mds) > 0 else 0.0 - if trajectory_obj is None: - logging.error(f"Trajectory {trajectory_identifier} not found") - return meshes + try: + # Only works for RESQML 2.2+ + _md_min = get_object_attribute(energyml_object, "md_interval.md_min") + if _md_min is not None: + md_min = _md_min + _md_max = get_object_attribute(energyml_object, "md_interval.md_max") + if _md_max is not None: + md_max = _md_max + except AttributeError: + # logging.debug( + # "Could not get md_interval.md_min or md_interval.md_max, using NodeMd min/max instead" + # ) + pass - # CRS - crs = None + # remove md values from array if outside of md_min/md_max range (can happen if md_interval is used and NodeMd contains values outside of the interval) + wellbore_frame_mds = wellbore_frame_mds[(wellbore_frame_mds >= md_min) & (wellbore_frame_mds <= md_max)] - # Get reference point (wellhead location) - try different attribute paths for different versions - head_x, head_y, head_z = 0.0, 0.0, 0.0 - z_is_up = True # Default assumption + # Get trajectory reference + trajectory_dor = search_attribute_matching_name(obj=energyml_object, name_rgx="Trajectory")[0] + trajectory_obj = workspace.get_object(get_obj_uri(trajectory_dor)) - try: - # Try to get MdDatum (RESQML 2.0.1) or MdInterval.Datum (RESQML 2.2+) - md_datum_dor = None - try: - md_datum_dor = search_attribute_matching_name(obj=trajectory_obj, name_rgx=r"MdDatum")[0] - except IndexError: - try: - md_datum_dor = search_attribute_matching_name(obj=trajectory_obj, name_rgx=r"MdInterval.Datum")[0] - except IndexError: - pass - - if md_datum_dor is not None: - md_datum_identifier = get_obj_uri(md_datum_dor) - md_datum_obj = workspace.get_object(md_datum_identifier) - - if md_datum_obj is not None: - # Try to get coordinates from ReferencePointInACrs - try: - head_x = get_object_attribute_rgx(md_datum_obj, r"HorizontalCoordinates.Coordinate1") or 0.0 - head_y = get_object_attribute_rgx(md_datum_obj, r"HorizontalCoordinates.Coordinate2") or 0.0 - head_z = get_object_attribute_rgx(md_datum_obj, "VerticalCoordinate") or 0.0 - - # Get vertical CRS to determine z direction - try: - vcrs_dor = search_attribute_matching_name(obj=md_datum_obj, name_rgx="VerticalCrs")[0] - vcrs_identifier = get_obj_uri(vcrs_dor) - vcrs_obj = workspace.get_object(vcrs_identifier) - - if vcrs_obj is not None: - z_is_up = not is_z_reversed(vcrs_obj) - except (IndexError, AttributeError): - pass - except AttributeError: - pass - # Get CRS from trajectory geometry if available - try: - geometry_paths = search_attribute_matching_name_with_path(md_datum_obj, r"VerticalCrs") - if len(geometry_paths) > 0: - crs_dor_path, crs_dor = geometry_paths[0] - crs_identifier = get_obj_uri(crs_dor) - crs = workspace.get_object(crs_identifier) - except Exception as e: - logging.debug(f"Could not get CRS from trajectory: {e}") - except Exception as e: - logging.debug(f"Could not get reference point from trajectory: {e}") + meshes = read_wellbore_trajectory_representation( + energyml_object=trajectory_obj, + workspace=workspace, + sub_indices=sub_indices, + wellbore_frame_mds=wellbore_frame_mds, + ) + for mesh in meshes: + mesh.identifier = f"{get_obj_uri(energyml_object)}" + return meshes + except Exception as e: + logging.error(f"Failed to read wellbore frame representation: {e}") + import traceback - # Build wellbore path points - simple vertical projection from measured depths - # Note: This is a simplified representation. For accurate 3D trajectory, - # you would need to interpolate along the trajectory's control points. - points = [] - line_indices = [] + traceback.print_exc() - for i, md in enumerate(md_array): - # Create point at (head_x, head_y, head_z +/- md) - # Apply z direction based on CRS - z_offset = md if z_is_up else -md - points.append([head_x, head_y, head_z + z_offset]) + return meshes - # Connect consecutive points - if i > 0: - line_indices.append([i - 1, i]) - # Apply sub_indices filter if provided - if sub_indices is not None and len(sub_indices) > 0: - filtered_points = [] - filtered_indices = [] - index_map = {} +def read_wellbore_trajectory_representation( + energyml_object: Any, + workspace: EnergymlStorageInterface, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, + wellbore_frame_mds: Optional[Union[List[float], np.ndarray]] = None, + step_meter: float = 5.0, +) -> List[PolylineSetMesh]: + if energyml_object is None: + return [] - for new_idx, old_idx in enumerate(sub_indices): - if 0 <= old_idx < len(points): - filtered_points.append(points[old_idx]) - index_map[old_idx] = new_idx + if isinstance(energyml_object, list): + return [ + mesh + for obj in energyml_object + for mesh in read_wellbore_trajectory_representation( + obj, workspace, sub_indices, wellbore_frame_mds, step_meter + ) + ] + + # CRS + crs = None + head_x, head_y, head_z, z_increasing_downward, projected_epsg_code, vertical_epsg_code = ( + 0.0, + 0.0, + 0.0, + False, + None, + None, + ) - for line in line_indices: - if line[0] in index_map and line[1] in index_map: - filtered_indices.append([index_map[line[0]], index_map[line[1]]]) + # Get CRS from trajectory geometry if available + try: + crs = workspace.get_object(get_obj_uri(get_object_attribute(energyml_object, "geometry.LocalCrs"))) + except Exception as e: + logging.debug(f"Could not get CRS from trajectory geometry") - points = filtered_points - line_indices = filtered_indices + # ========== + # MD Datum + # ========== + try: + # Try to get MdDatum (RESQML 2.0.1) or MdInterval.Datum (RESQML 2.2+) + md_datum_dor = None + try: + md_datum_dor = search_attribute_matching_name(obj=energyml_object, name_rgx=r"MdDatum")[0] + except IndexError: + try: + md_datum_dor = search_attribute_matching_name(obj=energyml_object, name_rgx=r"MdInterval.Datum")[0] + except IndexError: + pass - if len(points) > 0: - meshes.append( - PolylineSetMesh( - identifier=f"{get_obj_uri(energyml_object)}_wellbore", - energyml_object=energyml_object, - crs_object=crs, - point_list=points, - line_indices=line_indices, + if md_datum_dor is not None: + md_datum_identifier = get_obj_uri(md_datum_dor) + md_datum_obj = workspace.get_object(md_datum_identifier) + + if md_datum_obj is not None: + head_x, head_y, head_z, z_increasing_downward, projected_epsg_code, vertical_epsg_code = ( + get_datum_information(md_datum_obj, workspace) ) - ) + except Exception as e: + logging.debug(f"Could not get reference point / Datum from trajectory: {e}") + # ========== + well_points = None + try: + x_offset, y_offset, z_offset, (azimuth, azimuth_uom) = get_crs_offsets_and_angle(crs, workspace) + # Try to read parametric Geometry from the trajectory. + traj_mds, traj_points, traj_tangents = read_parametric_geometry( + getattr(energyml_object, "geometry", None), workspace + ) + well_points = get_wellbore_points(wellbore_frame_mds, traj_mds, traj_points, traj_tangents, step_meter) + + well_points = apply_crs_transform( + well_points, + x_offset=x_offset, + y_offset=y_offset, + z_offset=z_offset, + z_is_up=not z_increasing_downward, + areal_rotation=azimuth, + rotation_uom=azimuth_uom, + ) except Exception as e: - logging.error(f"Failed to read wellbore frame representation: {e}") - import traceback + if wellbore_frame_mds is not None: + logging.debug(f"Could not read parametric geometry from trajectory. Well is interpreted as vertical: {e}") + well_points = generate_vertical_well_points( + head_x=head_x, + head_y=head_y, + head_z=head_z, + wellbore_mds=wellbore_frame_mds, + ) + else: + raise ValueError( + "Cannot read wellbore trajectory representation: no parametric geometry and no measured depth information available to generate points" + ) from e - traceback.print_exc() + meshes = [] + if well_points is not None and len(well_points) > 0: + meshes.append( + PolylineSetMesh( + identifier=f"{get_obj_uri(energyml_object)}", + energyml_object=energyml_object, + crs_object=crs, + point_list=well_points, + line_indices=[[i, i + 1] for i in range(len(well_points) - 1)], + ) + ) return meshes @@ -896,6 +952,34 @@ def read_sub_representation( return meshes +def read_representation_set_representation( + energyml_object: Any, + workspace: EnergymlStorageInterface, + use_crs_displacement: bool = True, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, +) -> List[AbstractMesh]: + + repr_list = get_object_attribute(energyml_object, "representation") + if repr_list is None or not isinstance(repr_list, list): + logging.error( + f"RepresentationSetRepresentation {get_obj_uri(energyml_object)} has no 'representation' list attribute" + ) + return [] + + meshes = [] + for repr_dor in repr_list: + rpr_uri = get_obj_uri(repr_dor) + repr_obj = workspace.get_object(rpr_uri) + if repr_obj is None: + logging.error(f"Representation {rpr_uri} in RepresentationSetRepresentation not found") + continue + meshes.extend( + read_mesh_object(energyml_object=repr_obj, workspace=workspace, use_crs_displacement=use_crs_displacement) + ) + + return meshes + + # MESH FILES diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index 21dc1e3..3ab3205 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -1122,7 +1122,10 @@ def read_array( obj = self.get_object_by_identifier(proxy) # Determine which external files to use - file_paths = [external_uri] if external_uri else self.get_h5_file_paths(obj) + file_paths = self.get_h5_file_paths(obj) + if external_uri: + file_paths.insert(0, self.make_path_relative_to_epc(external_uri)) + if not file_paths or len(file_paths) == 0: file_paths = self.external_files_path @@ -1177,7 +1180,7 @@ def write_array( obj = self.get_object_by_identifier(proxy) # Determine which external files to use - file_paths = [external_uri] if external_uri else self.get_h5_file_paths(obj) + file_paths = [self.make_path_relative_to_epc(external_uri)] if external_uri else self.get_h5_file_paths(obj) if not file_paths or len(file_paths) == 0: file_paths = self.external_files_path @@ -1310,7 +1313,23 @@ def get_h5_file_paths(self, obj: Any) -> List[str]: possible_h5_path = os.path.join(epc_folder, epc_file_base + ".h5") if os.path.exists(possible_h5_path): h5_paths.add(possible_h5_path) - return list(h5_paths) + + return self.make_path_relative_to_epc_list(list(h5_paths)) + + def make_path_relative_to_epc(self, path: str) -> str: + # make the relative path absolute regarding to the epc file path + if self.epc_file_path is not None: + if isinstance(path, str): + epc_folder = self.get_epc_file_folder() or "" + if not os.path.isabs(path): + return os.path.normpath(os.path.join(epc_folder, path)) + else: + return path + else: + return path + + def make_path_relative_to_epc_list(self, paths: List[str]) -> List[str]: + return [self.make_path_relative_to_epc(path) for path in paths] def get_object_as_dor(self, identifier: str, dor_qualified_type) -> Optional[Any]: """ diff --git a/energyml-utils/src/energyml/utils/introspection.py b/energyml-utils/src/energyml/utils/introspection.py index 62c579a..df2176f 100644 --- a/energyml-utils/src/energyml/utils/introspection.py +++ b/energyml-utils/src/energyml/utils/introspection.py @@ -15,6 +15,7 @@ from typing import Any, List, Optional, Union, Dict, Tuple from .constants import ( + path_parent_attribute, primitives, epoch_to_date, epoch, @@ -817,9 +818,10 @@ def search_attribute_in_upper_matching_name( return elt_list if len(current_path) != 0: # obj != root_obj: - upper_path = current_path[: current_path.rindex(".")] + upper_path = path_parent_attribute(current_path) + # upper_path = current_path[: current_path.rindex(".")] # print(f"\t {upper_path} ") - if len(upper_path) > 0: + if upper_path is not None and len(upper_path) > 0: return search_attribute_in_upper_matching_name( obj=get_object_attribute(root_obj, upper_path), name_rgx=name_rgx, From 3d9770060395673aa14fc1396d866f59ed475706 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Sun, 15 Feb 2026 17:11:23 +0100 Subject: [PATCH 40/70] support to read properties, columnBasedTable, and timeSeries as arrays/dict --- energyml-utils/example/attic/arrays_test.py | 134 ++++++-- .../src/energyml/utils/data/helper.py | 56 ++- .../src/energyml/utils/data/mesh.py | 319 +++++++++++++++++- energyml-utils/src/energyml/utils/epc.py | 39 +-- .../src/energyml/utils/epc_stream.py | 31 +- .../src/energyml/utils/epc_utils.py | 28 ++ .../src/energyml/utils/introspection.py | 2 +- energyml-utils/tests/test_epc_stream.py | 22 +- 8 files changed, 537 insertions(+), 94 deletions(-) diff --git a/energyml-utils/example/attic/arrays_test.py b/energyml-utils/example/attic/arrays_test.py index 2a495f0..34bae2d 100644 --- a/energyml-utils/example/attic/arrays_test.py +++ b/energyml-utils/example/attic/arrays_test.py @@ -1,12 +1,26 @@ import logging +from sqlite3 import NotSupportedError +import traceback from typing import List, Optional import numpy as np from energyml.utils.data.helper import _ARRAY_NAMES_, read_array -from energyml.utils.data.mesh import AbstractMesh, SurfaceMesh, PolylineSetMesh, read_mesh_object +from energyml.utils.data.mesh import ( + AbstractMesh, + SurfaceMesh, + PolylineSetMesh, + read_column_based_table, + read_mesh_object, + read_property_interpreted_with_cbt, + read_property, + read_time_series, +) from energyml.utils.storage_interface import EnergymlStorageInterface from energyml.utils.epc import Epc - +from energyml.utils.epc_stream import EpcStreamReader, RelsUpdateMode +from energyml.utils.introspection import get_obj_title from energyml.resqml.v2_2.resqmlv2 import Point3DLatticeArray +from energyml.eml.v2_3.commonv2 import TimeSeries +from energyml.eml.v2_1.commonv2 import TimeSeries as TimeSeries21 from energyml.utils.serialization import read_energyml_xml_str, serialize_json @@ -232,27 +246,107 @@ def read_representation_set_representation() -> List[AbstractMesh]: return read_mesh_object(energyml_object=rep_set_rep, workspace=epc) +def read_props_and_cbt( + epc_path: List[str] = [ + "rc/epc/testingPackageCpp22.epc", + "D:/Geosiris/Clients/BRGM/git/csv-to-energyml/rc/output/full-local/attic/result-out-EpcStream-egis-full.epc", + ], + p_or_cbt_uuids: List = [ + "1c5a3e99-e997-4bd7-a94d-c45d7b7405ce", + "be17c053-9189-4bc0-9db1-75aa51a026cd", + "da73937c-2c60-4e10-8917-5154fde4ded5", + "6561b499-82ed-4233-8a83-ea5d5aaf56a9", + "0d6aba60-b37e-498c-aedc-334561eb0749", + "d64d0ed0-72fa-4495-8e3a-a01175194e25", + "5abecfe6-b951-4802-9002-e597169a9923", + "49207072-563b-404a-9707-9a9b70168d33", + ], +) -> None: + + epcs = [] + for path in epc_path: + epc = EpcStreamReader( + epc_file_path=path, + rels_update_mode=RelsUpdateMode.MANUAL, + ) + # epc = Epc.read_file(f"{path}", read_rels_from_files=False, recompute_rels=False) + epcs.append(epc) + + for uuid in p_or_cbt_uuids: + read = False + prop_or_cbt = None + for epc in epcs: + try: + prop_or_cbt_lst = epc.get_object_by_uuid(uuid) + if not prop_or_cbt_lst: + continue + prop_or_cbt = prop_or_cbt_lst[0] + array = None + reshaped_array = None + if "column" in str(type(prop_or_cbt)).lower(): + array = read_column_based_table(prop_or_cbt, workspace=epc) + elif "time" in str(type(prop_or_cbt)).lower(): + array = read_time_series(prop_or_cbt, workspace=epc) + else: + array = read_property( + prop_or_cbt, + workspace=epc, + ) + reshaped_array = read_property_interpreted_with_cbt( + prop_or_cbt, + workspace=epc, + _cache_property_arrays=array, + _return_none_if_no_category_lookup=True, + ) + print("=" * 40) + print(f"{type(prop_or_cbt)} : {get_obj_title(prop_or_cbt)} - uuid: {uuid}") + print(array) + + if reshaped_array is not None: + print(" # => interpreted array:") + print(reshaped_array) + + print("\n") + read = True + break + # except NotSupportedError as e: + # print(f"Object with uuid {uuid} found but not supported: {e}") + except Exception as e: + traceback.print_exc() + print(f"Error reading object with uuid {uuid}: {e}") + pass + if not read: + print("[E]" + "=" * 40) + if prop_or_cbt is not None: + print(f"Object with uuid {get_obj_title(prop_or_cbt)} found but could not be read.") + else: + print(f"Object with uuid {uuid} not found in any EPC file.") + print("\n") + + if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) # meshes = read_grid() # meshes = read_polyline() # meshes = read_wellbore_frame_repr() - meshes = read_representation_set_representation() - - for m in meshes: - print("=" * 40) - print(f"Mesh identifier: {m.identifier}") - print("points:") - print(np.array(m.point_list)) - - if isinstance(m, SurfaceMesh): - print("face indices:") - print(np.array(m.faces_indices)) - elif isinstance(m, PolylineSetMesh): - print("line indices:") - try: - print(np.array(m.line_indices)) - except Exception as e: - print(m.line_indices) - raise e + # meshes = read_representation_set_representation() + + # for m in meshes: + # print("=" * 40) + # print(f"Mesh identifier: {m.identifier}") + # print("points:") + # print(np.array(m.point_list)) + + # if isinstance(m, SurfaceMesh): + # print("face indices:") + # print(np.array(m.faces_indices)) + # elif isinstance(m, PolylineSetMesh): + # print("line indices:") + # try: + # print(np.array(m.line_indices)) + # except Exception as e: + # print(m.line_indices) + # raise e + + read_props_and_cbt() diff --git a/energyml-utils/src/energyml/utils/data/helper.py b/energyml-utils/src/energyml/utils/data/helper.py index e9780be..84fb3b2 100644 --- a/energyml-utils/src/energyml/utils/data/helper.py +++ b/energyml-utils/src/energyml/utils/data/helper.py @@ -23,6 +23,8 @@ get_object_attribute, get_object_attribute_rgx, get_object_attribute_advanced, + is_primitive, + get_obj_title, ) from .datasets_io import get_path_in_external_with_path @@ -382,18 +384,18 @@ def prod_n_tab(val: Union[float, int, str], tab: List[Union[float, int, str]]): """ if val is None: return [None] * len(tab) - logging.debug(f"Multiplying list by {val}: {tab}") + # logging.debug(f"Multiplying list by {val}: {tab}") # Convert to numpy array for vectorized operations, handling None values arr = np.array(tab, dtype=object) - logging.debug(f"arr: {arr}") + # logging.debug(f"arr: {arr}") # Create mask for non-None values mask = arr != None # noqa: E711 # Create result array filled with None result = np.full(len(tab), None, dtype=object) - logging.debug(f"result before multiplication: {result}") + # logging.debug(f"result before multiplication: {result}") # Multiply only non-None values result[mask] = arr[mask].astype(float) * val - logging.debug(f"result after multiplication: {result}") + # logging.debug(f"result after multiplication: {result}") return result.tolist() @@ -453,10 +455,12 @@ def get_crs_obj( logging.error("@get_crs_obj no Epc file given") else: crs_list = search_attribute_matching_name(context_obj, r"\.*Crs", search_in_sub_obj=True, deep_search=False) - if crs_list is not None and len(crs_list) > 0: + if crs_list is not None and len(crs_list) > 0 and crs_list[0] is not None: # logging.debug(crs_list[0]) + # logging.debug(f"CRS found for {get_obj_title(context_obj)} : {crs_list[0]}") crs = workspace.get_object(get_obj_uri(crs_list[0])) if crs is None: + # logging.debug(f"CRS {crs_list[0]} not found (or not read correctly)") crs = workspace.get_object_by_uuid(get_obj_uuid(crs_list[0])) if crs is None: logging.error(f"CRS {crs_list[0]} not found (or not read correctly)") @@ -755,17 +759,19 @@ def _array_name_mapping(array_type_name: str) -> str: :param array_type_name: :return: """ - array_type_name = array_type_name.replace("3D", "3d").replace("2D", "2d") - if array_type_name.endswith("ConstantArray"): + array_type_name = array_type_name.replace("3D", "3d").replace("2D", "2d").lower() + # logging.debug(f"=============> Mapping array type name '{array_type_name}' to reader function name...") + if array_type_name.endswith("constantarray"): return "ConstantArray" - elif "External" in array_type_name or "Hdf5" in array_type_name: + elif "external" in array_type_name or "hdf5" in array_type_name: return "ExternalArray" - elif array_type_name.endswith("XmlArray"): + elif "xml" in array_type_name: + # logging.debug("=============> XML array detected, be careful with the performance !") return "XmlArray" - elif "Jagged" in array_type_name: + elif "jagged" in array_type_name: return "JaggedArray" - elif "Lattice" in array_type_name: - if "Integer" in array_type_name or "Double" in array_type_name or "FloatingPoint" in array_type_name: + elif "lattice" in array_type_name: + if "integer" in array_type_name or "double" in array_type_name or "floatingpoint" in array_type_name: return "int_double_lattice_array" return array_type_name @@ -879,7 +885,7 @@ def read_external_array( for ext_part in external_parts: start_indices, counts, external_uri = _extract_external_data_array_part_params(ext_part) pief_list = get_path_in_external_with_path(obj=ext_part) - + # logging.debug(f"Pief : {pief_list}") for pief_path_in_obj, pief in pief_list: arr = workspace.read_array( proxy=crs or root_obj, @@ -890,6 +896,7 @@ def read_external_array( ) if arr is not None: array = arr if array is None else np.concatenate((array, arr)) + # logging.debug(f"\t ExternalDataArrayPart read successfully. arr : {arr} : array : {array}") else: # RESQML v2.0.1: Extract count from parent object, no StartIndex or URI counts = None @@ -933,6 +940,7 @@ def read_external_array( # Fallback for non-numpy arrays array = [array[idx] for idx in sub_indices] + # logging.debug(f"External array read successfully. => {array}") return array @@ -964,8 +972,26 @@ def read_array( :param sub_indices: for SubRepresentation :return: """ - if isinstance(energyml_array, list): + if isinstance(energyml_array, np.ndarray): + # if isinstance(energyml_array, list): return energyml_array + elif isinstance(energyml_array, list): + # logging.debug("Warning: the array is a list, not a numpy array, be careful with the performance !") + # logging.debug(energyml_array) + if len(energyml_array) > 0 and is_primitive(energyml_array[0]): + return energyml_array + else: + return [ + read_array( + energyml_array=elem, + root_obj=root_obj, + path_in_root=path_in_root, + workspace=workspace, + sub_indices=sub_indices, + ) + for elem in energyml_array + if elem is not None + ] array_type_name = _array_name_mapping(type(energyml_array).__name__) reader_func = get_array_reader_function(array_type_name) @@ -1030,8 +1056,10 @@ def read_xml_array( :param sub_indices: :return: """ + values = get_object_attribute_no_verif(energyml_array, "values") # count = get_object_attribute_no_verif(energyml_array, "count_per_value") + # logging.debug("values: ", values) if sub_indices is not None and len(sub_indices) > 0: if isinstance(values, np.ndarray): diff --git a/energyml-utils/src/energyml/utils/data/mesh.py b/energyml-utils/src/energyml/utils/data/mesh.py index e8d7ac3..4f2d9a9 100644 --- a/energyml-utils/src/energyml/utils/data/mesh.py +++ b/energyml-utils/src/energyml/utils/data/mesh.py @@ -181,7 +181,7 @@ def crs_displacement(points: List[Point], crs_obj: Any) -> Tuple[List[Point], Po return points, crs_point_offset -def get_mesh_reader_function(mesh_type_name: str) -> Optional[Callable]: +def get_object_reader_function(mesh_type_name: str) -> Optional[Callable]: """ Returns the name of the potential appropriate function to read an object with type is named mesh_type_name :param mesh_type_name: the initial type name @@ -193,6 +193,11 @@ def get_mesh_reader_function(mesh_type_name: str) -> Optional[Callable]: return None +def get_mesh_reader_function(mesh_type_name: str) -> Optional[Callable]: + """@deprecated use get_object_reader_function instead""" + return get_object_reader_function(mesh_type_name) + + def _mesh_name_mapping(array_type_name: str) -> str: """ Transform the type name to match existing reader function @@ -224,7 +229,7 @@ def read_mesh_object( return energyml_object array_type_name = _mesh_name_mapping(type(energyml_object).__name__) - reader_func = get_mesh_reader_function(array_type_name) + reader_func = get_object_reader_function(array_type_name) if reader_func is not None: # logging.info(f"using function {reader_func} to read type {array_type_name}") surfaces: List[AbstractMesh] = reader_func( @@ -980,7 +985,315 @@ def read_representation_set_representation( return meshes -# MESH FILES +def read_property( + energyml_object: Any, + workspace: EnergymlStorageInterface, +) -> np.ndarray: + """ + Read a property or column-based table from an Energyml object. + + Dispatches to the appropriate reader function based on the object's type name. + If no specific reader is found, raises a NotSupportedError. + + Args: + energyml_object: The Energyml object to read from. + workspace: The storage interface for accessing related objects. + + Returns: + np.ndarray: The read property or table data. + + Raises: + NotSupportedError: If the object type is not supported. + """ + property_type = type(energyml_object).__name__ + reader_func = get_object_reader_function(property_type) + if reader_func is not None: + return reader_func(energyml_object=energyml_object, workspace=workspace) + else: + # logging.error(f"Type {array_type_name} is not supported: function read_{snake_case(array_type_name)} not found") + raise NotSupportedError( + f"Type {property_type} is not supported\n\tfunction read_{snake_case(property_type)} not found" + ) + + +def read_property_interpreted_with_cbt( + energyml_object: Any, + workspace: EnergymlStorageInterface, + _cache_property_arrays: Optional[np.ndarray] = None, + _return_none_if_no_category_lookup: bool = False, +) -> Optional[np.ndarray]: + """ + Read a property with category lookup interpretation. + + Reads property arrays and applies category lookup mapping if available. + Supports both array and dictionary-based category lookups. + + Args: + energyml_object: The Energyml property object. + workspace: The storage interface for accessing related objects. + _cache_property_arrays: Optional cached property arrays to avoid re-reading. + _return_none_if_no_category_lookup: If True, return None when no category lookup is found. + + Returns: + Optional[np.ndarray]: The interpreted property values, or None if no lookup and flag is set. + """ + + result = None + + prop_arrays = ( + read_property(energyml_object, workspace) if _cache_property_arrays is None else _cache_property_arrays + ) + + category_lookup_dor = get_object_attribute(energyml_object, "category_lookup") + if category_lookup_dor is not None: + category_lookup_obj = workspace.get_object(get_obj_uri(category_lookup_dor)) + if category_lookup_obj is not None: + category_lookup_data = read_column_based_table(category_lookup_obj, workspace) + + # print(f"category_lookup_array : {category_lookup_data}") + if isinstance(category_lookup_data, list): + category_lookup_data = np.array(category_lookup_data) + if isinstance(category_lookup_data, np.ndarray): + # map props values to category lookup values using prop value as index in category lookup array + result = ( + np.array( + [ + ( + category_lookup_data[prop] + if prop is not None and prop < len(category_lookup_data) + else None + ) + for prop in prop_arrays + ] + ) + if prop_arrays is not None + else None + ) + elif isinstance(category_lookup_data, dict): + # Transpose so that each index corresponds to a category (column), not a row + category_lookup_matrice = np.array(list(category_lookup_data.values())).T + print(f"category_lookup_matrice : {category_lookup_matrice}") + # return a matrice with the same shape as prop_arrays but with the values from the category lookup array using the prop value as key in the category lookup array + result = ( + np.array( + [ + [ + ( + category_lookup_matrice[prop].tolist() + if prop is not None and 0 <= prop < len(category_lookup_matrice) + else None + ) + for prop in prop_row + ] + for prop_row in prop_arrays + ] + ) + if prop_arrays is not None + else None + ) + else: + raise NotSupportedError( + f"Category lookup array type {type(category_lookup_matrice)} is not supported, expected list or dict" + ) + + return prop_arrays if result is None and not _return_none_if_no_category_lookup else result + + +def read_abstract_values_property( + energyml_object: Any, + workspace: EnergymlStorageInterface, +) -> np.ndarray: + """ + Read abstract values property from patches. + + Extracts and concatenates arrays from all 'values_for_patch' attributes. + + Args: + energyml_object: The Energyml object containing the property. + workspace: The storage interface for accessing arrays. + + Returns: + np.ndarray: The concatenated array of property values. + """ + arrays = [] + for values_for_patch in search_attribute_matching_name_with_path(energyml_object, "values_for_patch"): + array = read_array( + energyml_array=values_for_patch[1], + root_obj=energyml_object, + path_in_root=".", + workspace=workspace, + ) + if isinstance(array, list): + array = np.array(array) + arrays.append(array) + if len(arrays) == 1: + return arrays[0] + else: + return np.concatenate(arrays) + + +def read_discrete_property( + energyml_object: Any, + workspace: EnergymlStorageInterface, +) -> np.ndarray: + """ + Read a discrete property. + + Delegates to read_abstract_values_property for implementation. + + Args: + energyml_object: The discrete property object. + workspace: The storage interface. + + Returns: + np.ndarray: The property values. + """ + + return read_abstract_values_property(energyml_object, workspace) + + +def read_continuous_property( + energyml_object: Any, + workspace: EnergymlStorageInterface, +) -> np.ndarray: + """ + Read a continuous property. + + Delegates to read_abstract_values_property for implementation. + + Args: + energyml_object: The continuous property object. + workspace: The storage interface. + + Returns: + np.ndarray: The property values. + """ + + return read_abstract_values_property(energyml_object, workspace) + + +def read_categorical_property( + energyml_object: Any, + workspace: EnergymlStorageInterface, +) -> np.ndarray: + """ + Read a categorical property. + + Note: Categorical values are returned as integers. Use the property's + 'code_list' attribute to map to string values. + + Args: + energyml_object: The categorical property object. + workspace: The storage interface. + + Returns: + np.ndarray: The integer-coded property values. + """ + # TODO: the categorical values should be converted to strings using the code list of the property, but for now we keep the integer values and let the user manage the conversion if needed. + logging.warning( + "CategoricalProperty is read as a continuous property, the categorical values are not converted to strings but kept as integers. Use the 'code_list' attribute of the property to get the list of possible string values corresponding to the integer values in the array" + ) + return read_abstract_values_property(energyml_object, workspace) + + +def read_comment_property( + energyml_object: Any, + workspace: EnergymlStorageInterface, +) -> np.ndarray: + """ + Read a comment property. + + Delegates to read_abstract_values_property for implementation. + + Args: + energyml_object: The comment property object. + workspace: The storage interface. + + Returns: + np.ndarray: The comment values. + """ + return read_abstract_values_property(energyml_object, workspace) + + +def read_column_based_table( + energyml_object: Any, + workspace: EnergymlStorageInterface, +) -> Dict[str, np.ndarray]: + """ + Read a column-based table. + + Extracts column data into a dictionary keyed by column titles. + + Args: + energyml_object: The table object with 'column' attributes. + workspace: The storage interface for accessing arrays. + + Returns: + Dict[str, np.ndarray]: Dictionary of column names to arrays. + """ + columns = {} + for column in get_object_attribute(energyml_object, "column"): + column_name = getattr(column, "title", "_") + # print(f"Reading column: {column_name} : {column}") + # print(f"getattr(column_array, 'values', None): {getattr(column, 'values', None)}") + array = read_array( + energyml_array=getattr(column, "values", None), + root_obj=energyml_object, + path_in_root=".", + workspace=workspace, + ) + if isinstance(array, list): + array = np.array(array) + columns[column_name] = array + return columns + + +def read_time_series( + energyml_object: Any, + workspace: EnergymlStorageInterface, +) -> List[Dict[str, Tuple[str, int]]]: + """ + Read a time series from an Energyml object. + + Extracts date-time values and time step indices, constructing a normalized + list of (step_index, datetime) tuples for each time step. + + Args: + energyml_object: The Energyml time series object. + workspace: The storage interface for accessing related objects. + + Returns: + List[Tuple[str, int]]: List of tuples containing (step_index, datetime_string). + """ + + # 1. Extraction des DateTime + times_iso = search_attribute_matching_name(energyml_object, "date_time") + + # 2. Extraction des TimeSteps (v2.2+) + steps_indices = [] + time_step_obj = get_object_attribute(energyml_object, "time_step") + if time_step_obj is not None: + steps_indices = read_array(time_step_obj, energyml_object, ".", workspace, sub_indices=None) + else: + # Fallback : on utilise l'index de la liste + steps_indices = list(range(len(times_iso))) + + # 3. Construction de la structure normalisée + steps_data = [] + for i in range(len(times_iso)): + steps_data.append( + (steps_indices[i], times_iso[i]) + # {"index": i, "datetime": times_iso[i], "step_val": steps_indices[i]} # L'index utilisé par les propriétés + ) + + return steps_data + + +# __ ______________ __ __ _____ __ ____ __ +# / |/ / ____/ ___// / / / / __(_) /__ _____ / __/___ _________ ___ ____ _/ /_ +# / /|_/ / __/ \__ \/ /_/ / / /_/ / / _ \/ ___/ / /_/ __ \/ ___/ __ `__ \/ __ `/ __/ +# / / / / /___ ___/ / __ / / __/ / / __(__ ) / __/ /_/ / / / / / / / / /_/ / /_ +# /_/ /_/_____//____/_/ /_/ /_/ /_/_/\___/____/ /_/ \____/_/ /_/ /_/ /_/\__,_/\__/ def _recompute_min_max( diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index 3ab3205..c16082d 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -10,7 +10,6 @@ import os from pathlib import Path import random -import re import time import traceback import zipfile @@ -23,7 +22,7 @@ from enum import Enum from xsdata.formats.dataclass.models.generics import DerivedElement -from energyml.opc.opc import CoreProperties, Relationships, Types, Relationship, Override, TargetMode +from energyml.opc.opc import CoreProperties, Relationships, Types, Relationship, Override from energyml.utils.storage_interface import DataArrayMetadata, EnergymlStorageInterface, ResourceMetadata from energyml.utils.uri import Uri, parse_uri @@ -65,6 +64,9 @@ gen_rels_path, get_epc_content_type_path, create_h5_external_relationship, + get_file_folder, + make_path_relative_to_other_file, + make_path_relative_to_filepath_list, ) @@ -1064,13 +1066,7 @@ def compute_rels(self, force_recompute_object_rels: bool = False) -> Dict[str, R # === Array functions === def get_epc_file_folder(self) -> Optional[str]: - if self.epc_file_path is not None and len(self.epc_file_path) > 0: - folders_and_name = re.split(r"[\\/]", self.epc_file_path) - if len(folders_and_name) > 1: - return "/".join(folders_and_name[:-1]) - else: - return "" - return None + return get_file_folder(self.epc_file_path) if self.epc_file_path else None def read_external_array( self, @@ -1124,7 +1120,7 @@ def read_array( # Determine which external files to use file_paths = self.get_h5_file_paths(obj) if external_uri: - file_paths.insert(0, self.make_path_relative_to_epc(external_uri)) + file_paths.insert(0, make_path_relative_to_other_file(external_uri, self.epc_file_path)) if not file_paths or len(file_paths) == 0: file_paths = self.external_files_path @@ -1180,7 +1176,11 @@ def write_array( obj = self.get_object_by_identifier(proxy) # Determine which external files to use - file_paths = [self.make_path_relative_to_epc(external_uri)] if external_uri else self.get_h5_file_paths(obj) + file_paths = ( + [make_path_relative_to_other_file(external_uri, self.epc_file_path)] + if external_uri + else self.get_h5_file_paths(obj) + ) if not file_paths or len(file_paths) == 0: file_paths = self.external_files_path @@ -1314,22 +1314,7 @@ def get_h5_file_paths(self, obj: Any) -> List[str]: if os.path.exists(possible_h5_path): h5_paths.add(possible_h5_path) - return self.make_path_relative_to_epc_list(list(h5_paths)) - - def make_path_relative_to_epc(self, path: str) -> str: - # make the relative path absolute regarding to the epc file path - if self.epc_file_path is not None: - if isinstance(path, str): - epc_folder = self.get_epc_file_folder() or "" - if not os.path.isabs(path): - return os.path.normpath(os.path.join(epc_folder, path)) - else: - return path - else: - return path - - def make_path_relative_to_epc_list(self, paths: List[str]) -> List[str]: - return [self.make_path_relative_to_epc(path) for path in paths] + return make_path_relative_to_filepath_list(list(h5_paths), self.epc_file_path) def get_object_as_dor(self, identifier: str, dor_qualified_type) -> Optional[Any]: """ diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index a0d9e91..63bb222 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -70,6 +70,8 @@ gen_energyml_object_path, get_epc_content_type_path, gen_core_props_path, + make_path_relative_to_filepath_list, + make_path_relative_to_other_file, ) from energyml.utils.introspection import ( @@ -1353,12 +1355,13 @@ def get_statistics(self) -> EpcStreamingStats: """Get current statistics about the EPC streaming operations.""" return self.stats - def get_h5_file_paths(self, obj: Union[str, Uri, Any]) -> List[str]: + def get_h5_file_paths(self, obj: Union[str, Uri, Any], make_path_absolute_from_epc_path: bool = True) -> List[str]: """ Get all HDF5 file paths referenced in the EPC file (from rels to external resources). Optimized to avoid loading the object when identifier/URI is provided. :param obj: the object or its identifier/URI + :param make_path_absolute_from_epc_path: If True, return paths absolute from the EPC file path, otherwise return relative paths :return: list of HDF5 file paths """ if self.force_h5_path is not None: @@ -1398,7 +1401,11 @@ def get_h5_file_paths(self, obj: Union[str, Uri, Any]) -> List[str]: possible_h5_path = os.path.join(epc_folder, epc_file_base + ".h5") if os.path.exists(possible_h5_path): h5_paths.add(possible_h5_path) - return list(h5_paths) + + if make_path_absolute_from_epc_path: + return make_path_relative_to_filepath_list(list(h5_paths), self.epc_file_path) + else: + return list(h5_paths) # ________ ___ __________ __ _________________ ______ ____ _____ # / ____/ / / | / ___/ ___/ / |/ / ____/_ __/ / / / __ \/ __ \/ ___/ @@ -1532,7 +1539,7 @@ def get_object_by_uuid(self, uuid: str) -> List[Any]: return [] if len(identifiers) == 0: - logging.debug(f"No objects found with UUID: {uuid}") + # logging.debug(f"No objects found with UUID: {uuid}") return [] # Phase 1: Collect cached objects and prepare list of non-cached identifiers @@ -1735,22 +1742,10 @@ def read_array( Numpy array if successful, None otherwise. Returns sub-selected portion if start_indices/counts provided. """ # Get possible file paths for this object - file_paths = [] + file_paths = self.get_h5_file_paths(proxy) - if external_uri is not None: - # Use external_uri if provided (RESQML v2.2) - # May need to resolve relative to EPC folder - epc_folder = os.path.dirname(self.epc_file_path) if self.epc_file_path else "." - if os.path.isabs(external_uri): - file_paths = [external_uri] - else: - file_paths = [os.path.join(epc_folder, external_uri), external_uri] - elif self.force_h5_path is not None: - # Use forced path if specified - file_paths = [self.force_h5_path] - else: - # Get file paths from relationships - file_paths = self.get_h5_file_paths(proxy) + if external_uri: + file_paths.insert(0, make_path_relative_to_other_file(external_uri, self.epc_file_path)) if not file_paths: logging.warning(f"No external file paths found for proxy: {proxy}") diff --git a/energyml-utils/src/energyml/utils/epc_utils.py b/energyml-utils/src/energyml/utils/epc_utils.py index 2ad223e..6df1f17 100644 --- a/energyml-utils/src/energyml/utils/epc_utils.py +++ b/energyml-utils/src/energyml/utils/epc_utils.py @@ -5,6 +5,9 @@ from io import BytesIO import json import logging +import os +import os +import re from typing import Optional, Set, Tuple, Union, Any, List, Dict, Callable from pathlib import Path import zipfile @@ -250,6 +253,31 @@ def in_epc_file_path_to_mime_type(path: str) -> Optional[str]: return file_extension_to_mime_type(ext) +def get_file_folder(path) -> Optional[str]: + """Get the folder path from a given file path.""" + if path is None: + return None + _path = Path(path) if not isinstance(path, Path) else path + return _path.parent.as_posix() if _path.parent != Path(".") else "" + + +def make_path_relative_to_other_file(path: str, ref_path: Optional[Union[str, Path]]) -> str: + # make the relative path absolute regarding to the epc file path + if ref_path is not None: + if isinstance(ref_path, (str, Path)): + epc_folder = get_file_folder(ref_path) or "" + if not os.path.isabs(path): + return os.path.normpath(os.path.join(epc_folder, path)) + else: + return path + else: + return path + + +def make_path_relative_to_filepath_list(paths: List[str], ref_path: Optional[Union[str, Path]] = None) -> List[str]: + return [make_path_relative_to_other_file(path, ref_path) for path in paths] + + # __ ____________ ______ # / |/ / _/ ___// ____/ # / /|_/ // / \__ \/ / diff --git a/energyml-utils/src/energyml/utils/introspection.py b/energyml-utils/src/energyml/utils/introspection.py index df2176f..4ff0f75 100644 --- a/energyml-utils/src/energyml/utils/introspection.py +++ b/energyml-utils/src/energyml/utils/introspection.py @@ -889,7 +889,7 @@ def search_attribute_matching_name_with_path( :param current_path: :param deep_search: :param search_in_sub_obj: - :return: + :return: a list of tuple (path, value) for each sub attribute with type matching param "name_rgx". The path is a dot-version like ".Citation.Title" """ # while name_rgx.startswith("."): # name_rgx = name_rgx[1:] diff --git a/energyml-utils/tests/test_epc_stream.py b/energyml-utils/tests/test_epc_stream.py index 5a11282..2233de3 100644 --- a/energyml-utils/tests/test_epc_stream.py +++ b/energyml-utils/tests/test_epc_stream.py @@ -1046,7 +1046,7 @@ def test_external_resource_preserved_on_object_update(self, temp_epc_file, sampl reader.add_rels_for_object(identifier, [h5_rel]) # Verify the HDF5 path is returned - h5_paths_before = reader.get_h5_file_paths(identifier) + h5_paths_before = reader.get_h5_file_paths(identifier, False) assert "data/test_data.h5" in h5_paths_before # Update the object (modify its title) @@ -1054,7 +1054,7 @@ def test_external_resource_preserved_on_object_update(self, temp_epc_file, sampl reader.update_object(trset) # Verify EXTERNAL_RESOURCE relationship is still present - h5_paths_after = reader.get_h5_file_paths(identifier) + h5_paths_after = reader.get_h5_file_paths(identifier, False) assert "data/test_data.h5" in h5_paths_after, "EXTERNAL_RESOURCE relationship was lost after update" # Also verify by checking rels directly @@ -1084,7 +1084,7 @@ def test_external_resource_preserved_when_referenced_by_other(self, temp_epc_fil reader.add_rels_for_object(bf_id, [h5_rel]) # Verify initial state - h5_paths_initial = reader.get_h5_file_paths(bf_id) + h5_paths_initial = reader.get_h5_file_paths(bf_id, False) assert "data/boundary_data.h5" in h5_paths_initial # Add BoundaryFeatureInterpretation that references the BoundaryFeature @@ -1093,7 +1093,7 @@ def test_external_resource_preserved_when_referenced_by_other(self, temp_epc_fil reader.add_object(bfi) # Verify EXTERNAL_RESOURCE is still present after adding referencing object - h5_paths_after = reader.get_h5_file_paths(bf_id) + h5_paths_after = reader.get_h5_file_paths(bf_id, False) assert "data/boundary_data.h5" in h5_paths_after, "EXTERNAL_RESOURCE lost after adding referencing object" # Verify rels directly @@ -1131,7 +1131,7 @@ def test_external_resource_preserved_update_on_close_mode(self, temp_epc_file, s # Reopen and verify reader2 = EpcStreamReader(temp_epc_file) - h5_paths = reader2.get_h5_file_paths(identifier) + h5_paths = reader2.get_h5_file_paths(identifier, False) assert "data/test_data.h5" in h5_paths, "EXTERNAL_RESOURCE lost after close in UPDATE_ON_CLOSE mode" reader2.close() @@ -1166,7 +1166,7 @@ def test_multiple_external_resources_preserved(self, temp_epc_file, sample_objec reader.add_rels_for_object(identifier, h5_rels) # Verify all are present - h5_paths_before = reader.get_h5_file_paths(identifier) + h5_paths_before = reader.get_h5_file_paths(identifier, False) assert "data/geometry.h5" in h5_paths_before assert "data/properties.h5" in h5_paths_before assert "data/metadata.h5" in h5_paths_before @@ -1176,7 +1176,7 @@ def test_multiple_external_resources_preserved(self, temp_epc_file, sample_objec reader.update_object(trset) # Verify all EXTERNAL_RESOURCE relationships are still present - h5_paths_after = reader.get_h5_file_paths(identifier) + h5_paths_after = reader.get_h5_file_paths(identifier, False) assert "data/geometry.h5" in h5_paths_after assert "data/properties.h5" in h5_paths_after assert "data/metadata.h5" in h5_paths_after @@ -1208,7 +1208,7 @@ def test_external_resource_preserved_cascade_updates(self, temp_epc_file, sample reader.add_rels_for_object(bf_id, [h5_rel]) # Verify initial state - h5_paths = reader.get_h5_file_paths(bf_id) + h5_paths = reader.get_h5_file_paths(bf_id, False) assert "data/bf_data.h5" in h5_paths # Update intermediate object (bfi) @@ -1220,7 +1220,7 @@ def test_external_resource_preserved_cascade_updates(self, temp_epc_file, sample reader.update_object(trset) # Verify EXTERNAL_RESOURCE still present after cascade of updates - h5_paths_final = reader.get_h5_file_paths(bf_id) + h5_paths_final = reader.get_h5_file_paths(bf_id, False) assert "data/bf_data.h5" in h5_paths_final, "EXTERNAL_RESOURCE lost after cascade updates" reader.close() @@ -1247,7 +1247,7 @@ def test_external_resource_with_object_removal(self, temp_epc_file, sample_objec reader.add_rels_for_object(bfi_id, [h5_rel]) # Verify it exists - h5_paths = reader.get_h5_file_paths(bfi_id) + h5_paths = reader.get_h5_file_paths(bfi_id, False) assert "data/bfi_data.h5" in h5_paths # Remove bf (which bfi references) @@ -1258,7 +1258,7 @@ def test_external_resource_with_object_removal(self, temp_epc_file, sample_objec reader.update_object(bfi) # Verify EXTERNAL_RESOURCE is still there - h5_paths_after = reader.get_h5_file_paths(bfi_id) + h5_paths_after = reader.get_h5_file_paths(bfi_id, False) assert "data/bfi_data.h5" in h5_paths_after, "EXTERNAL_RESOURCE lost after referenced object removal" reader.close() From 035b71927b9e7f196b637adddba4ed32b1fa8494 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Mon, 16 Feb 2026 09:46:31 +0100 Subject: [PATCH 41/70] -- --- energyml-utils/example/attic/arrays_test.py | 4 +- .../src/energyml/utils/data/helper.py | 16 +++---- .../src/energyml/utils/data/mesh.py | 4 +- .../src/energyml/utils/introspection.py | 44 ++++++++++++++----- 4 files changed, 44 insertions(+), 24 deletions(-) diff --git a/energyml-utils/example/attic/arrays_test.py b/energyml-utils/example/attic/arrays_test.py index 34bae2d..c5190c1 100644 --- a/energyml-utils/example/attic/arrays_test.py +++ b/energyml-utils/example/attic/arrays_test.py @@ -17,7 +17,7 @@ from energyml.utils.storage_interface import EnergymlStorageInterface from energyml.utils.epc import Epc from energyml.utils.epc_stream import EpcStreamReader, RelsUpdateMode -from energyml.utils.introspection import get_obj_title +from energyml.utils.introspection import get_obj_title, search_attribute_matching_name, get_object_attribute from energyml.resqml.v2_2.resqmlv2 import Point3DLatticeArray from energyml.eml.v2_3.commonv2 import TimeSeries from energyml.eml.v2_1.commonv2 import TimeSeries as TimeSeries21 @@ -299,6 +299,8 @@ def read_props_and_cbt( _return_none_if_no_category_lookup=True, ) print("=" * 40) + # print("TS: ", search_attribute_matching_name(prop_or_cbt, "\\w*.time_series")) + # print(f"\t {get_object_attribute(prop_or_cbt, 'time_or_interval_series.time_series')}") print(f"{type(prop_or_cbt)} : {get_obj_title(prop_or_cbt)} - uuid: {uuid}") print(array) diff --git a/energyml-utils/src/energyml/utils/data/helper.py b/energyml-utils/src/energyml/utils/data/helper.py index 84fb3b2..c5a1412 100644 --- a/energyml-utils/src/energyml/utils/data/helper.py +++ b/energyml-utils/src/energyml/utils/data/helper.py @@ -759,19 +759,17 @@ def _array_name_mapping(array_type_name: str) -> str: :param array_type_name: :return: """ - array_type_name = array_type_name.replace("3D", "3d").replace("2D", "2d").lower() - # logging.debug(f"=============> Mapping array type name '{array_type_name}' to reader function name...") - if array_type_name.endswith("constantarray"): + array_type_name = array_type_name.replace("3D", "3d").replace("2D", "2d") + if array_type_name.endswith("ConstantArray"): return "ConstantArray" - elif "external" in array_type_name or "hdf5" in array_type_name: + elif "External" in array_type_name or "Hdf5" in array_type_name: return "ExternalArray" - elif "xml" in array_type_name: - # logging.debug("=============> XML array detected, be careful with the performance !") + elif "Xml" in array_type_name: return "XmlArray" - elif "jagged" in array_type_name: + elif "Jagged" in array_type_name: return "JaggedArray" - elif "lattice" in array_type_name: - if "integer" in array_type_name or "double" in array_type_name or "floatingpoint" in array_type_name: + elif "Lattice" in array_type_name: + if "Integer" in array_type_name or "Double" in array_type_name or "Floating" in array_type_name: return "int_double_lattice_array" return array_type_name diff --git a/energyml-utils/src/energyml/utils/data/mesh.py b/energyml-utils/src/energyml/utils/data/mesh.py index 4f2d9a9..946850d 100644 --- a/energyml-utils/src/energyml/utils/data/mesh.py +++ b/energyml-utils/src/energyml/utils/data/mesh.py @@ -1072,7 +1072,7 @@ def read_property_interpreted_with_cbt( elif isinstance(category_lookup_data, dict): # Transpose so that each index corresponds to a category (column), not a row category_lookup_matrice = np.array(list(category_lookup_data.values())).T - print(f"category_lookup_matrice : {category_lookup_matrice}") + # logging.debug(f"category_lookup_matrice : {category_lookup_matrice}") # return a matrice with the same shape as prop_arrays but with the values from the category lookup array using the prop value as key in the category lookup array result = ( np.array( @@ -1251,7 +1251,7 @@ def read_column_based_table( def read_time_series( energyml_object: Any, workspace: EnergymlStorageInterface, -) -> List[Dict[str, Tuple[str, int]]]: +) -> List[Tuple[str, int]]: """ Read a time series from an Energyml object. diff --git a/energyml-utils/src/energyml/utils/introspection.py b/energyml-utils/src/energyml/utils/introspection.py index 4ff0f75..8cd2202 100644 --- a/energyml-utils/src/energyml/utils/introspection.py +++ b/energyml-utils/src/energyml/utils/introspection.py @@ -387,24 +387,26 @@ def get_class_attribute_type(cls: Union[type, Any], attribute_name: str): return None -def get_matching_class_attribute_name( +def get_all_matching_class_attribute_name( cls: Union[type, Any], attribute_name: str, re_flags=re.IGNORECASE, -) -> Optional[str]: +) -> List[str]: """ - From an object and an attribute name, returns the correct attribute name of the class. - Example : "ObjectVersion" --> object_version. - This method doesn't only transform to snake case but search into the obj class attributes (or dict keys) + From an object and an attribute name, returns all the correct attribute names of the class matching with the attribute_name. + Example : "\\w*.Version" --> ["object_version", "ObjectVersion", "obj_version", ...] + This method doesn't only transform to snake case but search into the obj class attributes (or dict keys) """ + matching_names = [] if isinstance(cls, dict): for name in cls.keys(): if snake_case(name) == snake_case(attribute_name): - return name + matching_names.append(name) pattern = re.compile(attribute_name, flags=re_flags) for name in cls.keys(): if pattern.match(name): - return name + matching_names.append(name) + return matching_names else: class_fields = get_class_fields(cls) try: @@ -413,7 +415,7 @@ def get_matching_class_attribute_name( if snake_case(name) == snake_case(attribute_name) or ( hasattr(cf, "metadata") and "name" in cf.metadata and cf.metadata["name"] == attribute_name ): - return name + matching_names.append(name) # search regex after to avoid shadowing perfect match pattern = re.compile(attribute_name, flags=re_flags) @@ -422,11 +424,27 @@ def get_matching_class_attribute_name( if pattern.match(name) or ( hasattr(cf, "metadata") and "name" in cf.metadata and pattern.match(cf.metadata["name"]) ): - return name + matching_names.append(name) except Exception as e: logging.error(f"Failed to get attribute {attribute_name} from class {cls}") logging.error(e) + return matching_names + + +def get_matching_class_attribute_name( + cls: Union[type, Any], + attribute_name: str, + re_flags=re.IGNORECASE, +) -> Optional[str]: + """ + From an object and an attribute name, returns the correct attribute name of the class. + Example : "ObjectVersion" --> object_version. + This method doesn't only transform to snake case but search into the obj class attributes (or dict keys) + """ + matched = get_all_matching_class_attribute_name(cls, attribute_name, re_flags) + if len(matched) > 0: + return matched[0] return None @@ -931,8 +949,9 @@ def search_attribute_matching_name_with_path( else: not_match_path_and_obj.append((f"{current_path}{k}", s_o)) elif not is_primitive(obj): - match_value = get_matching_class_attribute_name(obj, current_match.replace("\\.", ".")) - if match_value is not None: + # logging.debug(f"searching {current_match} in {type(obj)} with path {current_path} and next match {next_match}") + match_values = get_all_matching_class_attribute_name(obj, current_match, re_flags) + for match_value in match_values: match_path_and_obj.append( ( f"{current_path}{match_value}", @@ -940,13 +959,14 @@ def search_attribute_matching_name_with_path( ) ) for att_name in get_class_attributes(obj): - if att_name != match_value: + if att_name not in match_values: not_match_path_and_obj.append( ( f"{current_path}{att_name}", get_object_attribute_no_verif(obj, att_name), ) ) + # logging.debug(f"\tmatch_path_and_obj: {match_path_and_obj}") for matched_path, matched in match_path_and_obj: if next_match is not None: # next_match is different, match is not final From 31c873e4d1d1eee527d15a9fd952e801c1670f35 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Mon, 16 Feb 2026 12:26:25 +0100 Subject: [PATCH 42/70] bugfix for search_attribute_matching_name --- energyml-utils/example/attic/arrays_test.py | 95 +++++++++++++++---- .../src/energyml/utils/data/mesh.py | 22 +++-- .../src/energyml/utils/introspection.py | 19 ++-- 3 files changed, 100 insertions(+), 36 deletions(-) diff --git a/energyml-utils/example/attic/arrays_test.py b/energyml-utils/example/attic/arrays_test.py index c5190c1..e42038d 100644 --- a/energyml-utils/example/attic/arrays_test.py +++ b/energyml-utils/example/attic/arrays_test.py @@ -17,7 +17,12 @@ from energyml.utils.storage_interface import EnergymlStorageInterface from energyml.utils.epc import Epc from energyml.utils.epc_stream import EpcStreamReader, RelsUpdateMode -from energyml.utils.introspection import get_obj_title, search_attribute_matching_name, get_object_attribute +from energyml.utils.introspection import ( + get_obj_title, + search_attribute_matching_name, + get_object_attribute, + search_attribute_matching_name_with_path, +) from energyml.resqml.v2_2.resqmlv2 import Point3DLatticeArray from energyml.eml.v2_3.commonv2 import TimeSeries from energyml.eml.v2_1.commonv2 import TimeSeries as TimeSeries21 @@ -326,6 +331,51 @@ def read_props_and_cbt( print("\n") +def read_trset( + epc_path: str = "rc/epc/testingPackageCpp22.epc", trset_uuid: str = "6e678338-3b53-49b6-8801-faee493e0c42" +) -> List[AbstractMesh]: + epc = Epc.read_file(f"{epc_path}", read_rels_from_files=False, recompute_rels=False) + + trset = epc.get_object_by_uuid(trset_uuid)[0] + # print(trset) + # print(epc.get_h5_file_paths(trset)) + + meshes = read_mesh_object(energyml_object=trset, workspace=epc) + + return meshes + + +def print_tuple_list(tuple_list: List[tuple]) -> None: + for t in tuple_list: + print(t) + + +def read_pointset( + epc_path: str = "rc/epc/testingPackageCpp22.epc", pointset_uuid: str = "fbc5466c-94cd-46ab-8b48-2ae2162b372f" +) -> List[AbstractMesh]: + # epc = Epc.read_file(f"{epc_path}", read_rels_from_files=False, recompute_rels=False) + epc = EpcStreamReader( + epc_file_path=epc_path, + rels_update_mode=RelsUpdateMode.MANUAL, + ) + + pointset = epc.get_object_by_uuid(pointset_uuid)[0] + # print(pointset) + # print(epc.get_h5_file_paths(pointset)) + # meshes = [] + meshes = read_mesh_object(energyml_object=pointset, workspace=epc) + + # logging.debug("=" * 40) + # print_tuple_list(search_attribute_matching_name_with_path(pointset, r"NodePatch.[\d]+.Geometry.Points")) + # logging.debug("=" * 40) + # print_tuple_list( + # search_attribute_matching_name_with_path(pointset, r"NodePatchGeometry.[\d]+.Points") + # ) # resqml 2.0.1 + # logging.debug("=" * 40) + + return meshes + + if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) @@ -333,22 +383,27 @@ def read_props_and_cbt( # meshes = read_polyline() # meshes = read_wellbore_frame_repr() # meshes = read_representation_set_representation() - - # for m in meshes: - # print("=" * 40) - # print(f"Mesh identifier: {m.identifier}") - # print("points:") - # print(np.array(m.point_list)) - - # if isinstance(m, SurfaceMesh): - # print("face indices:") - # print(np.array(m.faces_indices)) - # elif isinstance(m, PolylineSetMesh): - # print("line indices:") - # try: - # print(np.array(m.line_indices)) - # except Exception as e: - # print(m.line_indices) - # raise e - - read_props_and_cbt() + # meshes = read_trset() + meshes = read_pointset() + + print(f"Number of meshes read: {len(meshes)}") + + if meshes: + for m in meshes: + print("=" * 40) + print(f"Mesh identifier: {m.identifier}") + print("points:") + print(np.array(m.point_list)) + + if isinstance(m, SurfaceMesh): + print("face indices:") + print(np.array(m.faces_indices)) + elif isinstance(m, PolylineSetMesh): + print("line indices:") + try: + print(np.array(m.line_indices)) + except Exception as e: + print(m.line_indices) + raise e + + # read_props_and_cbt() diff --git a/energyml-utils/src/energyml/utils/data/mesh.py b/energyml-utils/src/energyml/utils/data/mesh.py index 946850d..0406532 100644 --- a/energyml-utils/src/energyml/utils/data/mesh.py +++ b/energyml-utils/src/energyml/utils/data/mesh.py @@ -269,14 +269,16 @@ def read_point_representation( patch_idx = 0 total_size = 0 - for ( - points_path_in_obj, - points_obj, - ) in search_attribute_matching_name_with_path( + + patches_geom = search_attribute_matching_name_with_path( energyml_object, r"NodePatch.[\d]+.Geometry.Points" ) + search_attribute_matching_name_with_path( # resqml 2.0.1 energyml_object, r"NodePatchGeometry.[\d]+.Points" - ): # resqml 2.2 + ) + logging.debug(f"Found {len(patches_geom)} patches for point representation") + logging.debug(f"\t=> {patches_geom}") + + for points_path_in_obj, points_obj in patches_geom: points = read_array( energyml_array=points_obj, root_obj=energyml_object, @@ -644,12 +646,16 @@ def read_triangulated_set_representation( point_offset = 0 patch_idx = 0 total_size = 0 - for patch_path, patch in search_attribute_matching_name_with_path( + + patches = search_attribute_matching_name_with_path( energyml_object, - "\\.*Patch.\\d+", + "\\w*Patch.\\d+", deep_search=False, search_in_sub_obj=False, - ): + ) + # logging.debug(f"Found {len(patches)} patches for triangulated set representation") + + for patch_path, patch in patches: crs = None try: crs = get_crs_obj( diff --git a/energyml-utils/src/energyml/utils/introspection.py b/energyml-utils/src/energyml/utils/introspection.py index 8cd2202..460ad55 100644 --- a/energyml-utils/src/energyml/utils/introspection.py +++ b/energyml-utils/src/energyml/utils/introspection.py @@ -397,16 +397,16 @@ def get_all_matching_class_attribute_name( Example : "\\w*.Version" --> ["object_version", "ObjectVersion", "obj_version", ...] This method doesn't only transform to snake case but search into the obj class attributes (or dict keys) """ - matching_names = [] + matching_names = set() if isinstance(cls, dict): for name in cls.keys(): if snake_case(name) == snake_case(attribute_name): - matching_names.append(name) + matching_names.add(name) pattern = re.compile(attribute_name, flags=re_flags) for name in cls.keys(): if pattern.match(name): - matching_names.append(name) - return matching_names + matching_names.add(name) + return list(matching_names) else: class_fields = get_class_fields(cls) try: @@ -415,7 +415,7 @@ def get_all_matching_class_attribute_name( if snake_case(name) == snake_case(attribute_name) or ( hasattr(cf, "metadata") and "name" in cf.metadata and cf.metadata["name"] == attribute_name ): - matching_names.append(name) + matching_names.add(name) # search regex after to avoid shadowing perfect match pattern = re.compile(attribute_name, flags=re_flags) @@ -424,12 +424,12 @@ def get_all_matching_class_attribute_name( if pattern.match(name) or ( hasattr(cf, "metadata") and "name" in cf.metadata and pattern.match(cf.metadata["name"]) ): - matching_names.append(name) + matching_names.add(name) except Exception as e: logging.error(f"Failed to get attribute {attribute_name} from class {cls}") logging.error(e) - return matching_names + return list(matching_names) def get_matching_class_attribute_name( @@ -919,7 +919,7 @@ def search_attribute_matching_name_with_path( # next_match = ".".join(attrib_list[1:]) current_match, next_match = path_next_attribute(name_rgx) if current_match is None: - logging.error(f"Attribute name regex '{name_rgx}' is invalid.") + # logging.error(f"Attribute name regex '{name_rgx}' is invalid.") return [] res = [] @@ -949,15 +949,18 @@ def search_attribute_matching_name_with_path( else: not_match_path_and_obj.append((f"{current_path}{k}", s_o)) elif not is_primitive(obj): + current_match = current_match.replace("\\.", ".") # logging.debug(f"searching {current_match} in {type(obj)} with path {current_path} and next match {next_match}") match_values = get_all_matching_class_attribute_name(obj, current_match, re_flags) for match_value in match_values: + # logging.debug(f"\tmatch found : {match_value}") match_path_and_obj.append( ( f"{current_path}{match_value}", get_object_attribute_no_verif(obj, match_value), ) ) + # logging.debug("f------") for att_name in get_class_attributes(obj): if att_name not in match_values: not_match_path_and_obj.append( From ca0065fffa7104c733e182af43a7d3084f4c9edb Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Mon, 16 Feb 2026 14:38:37 +0100 Subject: [PATCH 43/70] bugfix for epcStream id use --- energyml-utils/example/attic/arrays_test.py | 2 + .../src/energyml/utils/data/mesh.py | 4 +- energyml-utils/src/energyml/utils/epc.py | 4 +- .../src/energyml/utils/epc_stream.py | 52 +++++++------------ .../src/energyml/utils/epc_utils.py | 31 ++++++++++- .../src/energyml/utils/exception.py | 7 +++ 6 files changed, 61 insertions(+), 39 deletions(-) diff --git a/energyml-utils/example/attic/arrays_test.py b/energyml-utils/example/attic/arrays_test.py index e42038d..1efa973 100644 --- a/energyml-utils/example/attic/arrays_test.py +++ b/energyml-utils/example/attic/arrays_test.py @@ -365,6 +365,8 @@ def read_pointset( # meshes = [] meshes = read_mesh_object(energyml_object=pointset, workspace=epc) + print(epc.get_obj_rels(pointset)) + # logging.debug("=" * 40) # print_tuple_list(search_attribute_matching_name_with_path(pointset, r"NodePatch.[\d]+.Geometry.Points")) # logging.debug("=" * 40) diff --git a/energyml-utils/src/energyml/utils/data/mesh.py b/energyml-utils/src/energyml/utils/data/mesh.py index 0406532..efa9564 100644 --- a/energyml-utils/src/energyml/utils/data/mesh.py +++ b/energyml-utils/src/energyml/utils/data/mesh.py @@ -275,8 +275,8 @@ def read_point_representation( ) + search_attribute_matching_name_with_path( # resqml 2.0.1 energyml_object, r"NodePatchGeometry.[\d]+.Points" ) - logging.debug(f"Found {len(patches_geom)} patches for point representation") - logging.debug(f"\t=> {patches_geom}") + # logging.debug(f"Found {len(patches_geom)} patches for point representation") + # logging.debug(f"\t=> {patches_geom}") for points_path_in_obj, points_obj in patches_geom: points = read_array( diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index c16082d..d5761eb 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -67,6 +67,7 @@ get_file_folder, make_path_relative_to_other_file, make_path_relative_to_filepath_list, + as_identifier, ) @@ -150,7 +151,8 @@ def remove(self, obj: Any) -> None: def get_by_identifier(self, identifier: Union[str, Uri]) -> Optional[Any]: """Get object by identifier (O(1) lookup).""" # Try identifier lookup first - obj = self._by_identifier.get(str(identifier)) + # obj = self._by_identifier.get(str(identifier)) + obj = self._by_identifier.get(as_identifier(identifier)) if obj is not None: return obj diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index 63bb222..313ade9 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -58,12 +58,10 @@ ) from energyml.utils.uri import Uri, create_uri_from_content_type_or_qualified_type from energyml.utils.constants import ( - CORE_PROPERTIES_FOLDER_NAME, EPCRelsRelationshipType, EpcExportVersion, MimeType, OptimizedRegex, - file_extension_to_mime_type, date_to_datetime, ) from energyml.utils.epc_utils import ( @@ -72,6 +70,7 @@ gen_core_props_path, make_path_relative_to_filepath_list, make_path_relative_to_other_file, + as_identifier, ) from energyml.utils.introspection import ( @@ -1623,7 +1622,7 @@ def put_object(self, obj: Any, dataspace: Optional[str] = None) -> Optional[str] identifier = uri.as_identifier() existing_metadata = self._metadata_mgr.get_metadata(identifier) file_path = gen_energyml_object_path(obj, self._metadata_mgr._export_version) - is_update = existing_metadata is not None + # is_update = existing_metadata is not None # Write object data and metadata to EPC try: @@ -2082,38 +2081,23 @@ def _update_access_order(self, identifier: str) -> None: def _id_from_uri_or_identifier( self, identifier: Union[str, Uri, Any], get_first_if_simple_uuid: bool = True ) -> Optional[str]: - if identifier is None: - return None - elif isinstance(identifier, str): - if OptimizedRegex.UUID.fullmatch(identifier) is not None: - if not get_first_if_simple_uuid: - logging.warning( - f"Identifier {identifier} is a simple UUID, but get_first_if_simple_uuid is False, cannot resolve to full identifier" - ) - return None - # If it's a simple UUID, we need to find the corresponding identifier from metadata - t_metadata_identifiers = self._metadata_mgr.get_uuid_identifiers(identifier) - if t_metadata_identifiers is not None and len(t_metadata_identifiers) > 0: - return t_metadata_identifiers[ - 0 - ] # If multiple metadata entries for the same UUID, we take the first one (this should not happen in a well-formed EPC file) - else: - logging.warning(f"No metadata found for UUID {identifier}, cannot get relationships") - return None + try: + return as_identifier(identifier) + except Exception: + if not get_first_if_simple_uuid: + logging.warning( + f"Identifier {identifier} is a simple UUID, but get_first_if_simple_uuid is False, cannot resolve to full identifier" + ) + return None + # If it's a simple UUID, we need to find the corresponding identifier from metadata + t_metadata_identifiers = self._metadata_mgr.get_uuid_identifiers(identifier) + if t_metadata_identifiers is not None and len(t_metadata_identifiers) > 0: + return t_metadata_identifiers[ + 0 + ] # If multiple metadata entries for the same UUID, we take the first one (this should not happen in a well-formed EPC file) else: - return identifier - elif isinstance(identifier, Uri): - return identifier.as_identifier() - elif isinstance(identifier, ResourceMetadata): - return self._id_from_uri_or_identifier(identifier.identifier) - elif isinstance(identifier, EpcObjectMetadata): - return self._id_from_uri_or_identifier(identifier.uri) - else: - # Try to get URI from object - obj_uri = get_obj_uri(obj=identifier, dataspace=None) - if obj_uri is not None: - return obj_uri.as_identifier() - return str(identifier) + logging.warning(f"No metadata found for UUID {identifier}, cannot get relationships") + return None def _rebuild_all_rels_sequential(self, clean_first: bool = True) -> Dict[str, int]: """ diff --git a/energyml-utils/src/energyml/utils/epc_utils.py b/energyml-utils/src/energyml/utils/epc_utils.py index 6df1f17..1a0a90b 100644 --- a/energyml-utils/src/energyml/utils/epc_utils.py +++ b/energyml-utils/src/energyml/utils/epc_utils.py @@ -7,7 +7,6 @@ import logging import os import os -import re from typing import Optional, Set, Tuple, Union, Any, List, Dict, Callable from pathlib import Path import zipfile @@ -25,6 +24,8 @@ Override, ) +from energyml.utils.exception import NotEnoughInformationError + from energyml.utils.constants import ( CORE_PROPERTIES_FOLDER_NAME, EPCRelsRelationshipType, @@ -65,7 +66,7 @@ from energyml.utils.manager import get_class_pkg from energyml.utils.serialization import read_energyml_xml_str, serialize_xml, read_energyml_json_str from energyml.utils.uri import Uri, parse_uri - +from energyml.utils.storage_interface import ResourceMetadata # ____ ___ ________ __ # / __ \/ |/_ __/ / / / @@ -285,6 +286,32 @@ def make_path_relative_to_filepath_list(paths: List[str], ref_path: Optional[Uni # /_/ /_/___//____/\____/ +def as_identifier(identifier: Union[str, Uri, Any]) -> Optional[str]: + if identifier is None: + return None + elif isinstance(identifier, str): + if identifier.startswith("eml:///"): + return as_identifier(parse_uri(identifier)) + if OptimizedRegex.UUID.fullmatch(identifier) is not None: + raise NotEnoughInformationError( + "Simple uuid is not enough to be used as an identifier, please provide a full URI or an object with a valid URI or identifier that contains the version : 'UUID.VERSION' even if VERSION can be an empty string" + ) + else: + return identifier + elif isinstance(identifier, Uri): + return identifier.as_identifier() + elif isinstance(identifier, ResourceMetadata): + return as_identifier(identifier.identifier) + elif hasattr(identifier, "uri"): # EpcObjectMetadata + return as_identifier(identifier.uri) + else: + # Try to get URI from object + obj_uri = get_obj_uri(obj=identifier, dataspace=None) + if obj_uri is not None: + return obj_uri.as_identifier() + return str(identifier) + + def create_external_relationship(path: str, _id: Optional[str] = None) -> Relationship: return Relationship( target=path, diff --git a/energyml-utils/src/energyml/utils/exception.py b/energyml-utils/src/energyml/utils/exception.py index 31638ec..a3cfe72 100644 --- a/energyml-utils/src/energyml/utils/exception.py +++ b/energyml-utils/src/energyml/utils/exception.py @@ -48,6 +48,13 @@ def __init__(self, msg): super().__init__(msg) +class NotEnoughInformationError(Exception): + """Exception for not enough information to perform an operation""" + + def __init__(self, msg): + super().__init__(msg) + + # EPC Validation Exceptions From 07c816e20bc8204a85d7a4758ba6a5e73188c512 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Tue, 17 Feb 2026 00:00:29 +0100 Subject: [PATCH 44/70] bugfix : crs for vertical wellbore --- energyml-utils/example/attic/arrays_test.py | 37 +++++++++++++++++- .../src/energyml/utils/data/helper.py | 34 ++++++++++++---- .../src/energyml/utils/data/mesh.py | 23 +++++++++-- energyml-utils/src/energyml/utils/epc.py | 39 +++++++++---------- .../src/energyml/utils/epc_stream.py | 25 ++++++------ 5 files changed, 111 insertions(+), 47 deletions(-) diff --git a/energyml-utils/example/attic/arrays_test.py b/energyml-utils/example/attic/arrays_test.py index 1efa973..39c22aa 100644 --- a/energyml-utils/example/attic/arrays_test.py +++ b/energyml-utils/example/attic/arrays_test.py @@ -378,6 +378,40 @@ def read_pointset( return meshes +def read_wellbore_frame_repr_demo_jfr_02_26( + epc_path: str = r"rc/epc/out-galaxy-12-pts.epc", + well_uuid: str = "cfad9cb6-99fe-4172-b560-d2feca75dd9f", +) -> List[AbstractMesh]: + epc = Epc.read_file(f"{epc_path}", read_rels_from_files=False, recompute_rels=False) + + frame_repr = epc.get_object_by_uuid(well_uuid)[0] + # print(frame_repr) + # print(epc.get_h5_file_paths(frame_repr)) + + print(epc.get_h5_file_paths()) + + print(epc.get_h5_file_paths(frame_repr)) + + print("Object type: ", type(frame_repr)) + + meshes = read_mesh_object(energyml_object=frame_repr, workspace=epc) + + # Previous result : + # points: + # [[ 0. 0. 0.] + # [ 0. 0. 250.] + # [ 0. 0. 500.] + # [ 0. 0. 750.] + # [ 0. 0. 1000.]] + # line indices: + # [[0 1] + # [1 2] + # [2 3] + # [3 4]] + + return meshes + + if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) @@ -386,7 +420,8 @@ def read_pointset( # meshes = read_wellbore_frame_repr() # meshes = read_representation_set_representation() # meshes = read_trset() - meshes = read_pointset() + # meshes = read_pointset() + meshes = read_wellbore_frame_repr_demo_jfr_02_26() print(f"Number of meshes read: {len(meshes)}") diff --git a/energyml-utils/src/energyml/utils/data/helper.py b/energyml-utils/src/energyml/utils/data/helper.py index c5a1412..388f14c 100644 --- a/energyml-utils/src/energyml/utils/data/helper.py +++ b/energyml-utils/src/energyml/utils/data/helper.py @@ -311,10 +311,12 @@ def get_crs_origin_offset(crs_obj: Any) -> List[float | int]: return crs_point_offset -def get_datum_information(datum_obj: Any, workspace: Optional[EnergymlStorageInterface] = None): - "From a ObjMdDatum or a ReferencePointInACrs, return x, y, z, z_increas_downward, projected_epsg_code, vertical_epsg_code" +def get_datum_information( + datum_obj: Any, workspace: Optional[EnergymlStorageInterface] = None +) -> Tuple[float, float, float, bool, Optional[str], Optional[str], Optional[Any]]: + "From a ObjMdDatum or a ReferencePointInACrs, return x, y, z, z_increas_downward, projected_epsg_code, vertical_epsg_code, crs object" if datum_obj is None: - return 0.0, 0.0, 0.0, False, None, None + return 0.0, 0.0, 0.0, False, None, None, None t_lw = type(datum_obj).__name__.lower() @@ -333,12 +335,18 @@ def get_datum_information(datum_obj: Any, workspace: Optional[EnergymlStorageInt z_increasing_downward, projected_epsg_code, vertical_epsg_code, + datum_obj, ) elif "referencepointinacrs" in t_lw: x = get_object_attribute_rgx(datum_obj, "horizontal_coordinates.coordinate1") y = get_object_attribute_rgx(datum_obj, "horizontal_coordinates.coordinate2") z = get_object_attribute_rgx(datum_obj, "vertical_coordinate") - z_increasing_downward = get_object_attribute(datum_obj, "ZIncreasingDownward") or False + z_increasing_downward = False + v_crs_dor = get_object_attribute_rgx(datum_obj, "vertical_crs") + if v_crs_dor is not None and workspace is not None: + v_crs = workspace.get_object(get_obj_uri(v_crs_dor)) + if v_crs is not None: + z_increasing_downward = is_z_reversed(v_crs) p_crs = get_object_attribute(datum_obj, "horizontal_coordinates.crs") projected_epsg_code = ( get_projected_epsg_code(workspace.get_object(get_obj_uri(p_crs)), workspace) @@ -354,13 +362,16 @@ def get_datum_information(datum_obj: Any, workspace: Optional[EnergymlStorageInt z_increasing_downward, projected_epsg_code, vertical_epsg_code, + p_crs, ) elif "mddatum" in t_lw: x = get_object_attribute_rgx(datum_obj, "location.coordinate1") y = get_object_attribute_rgx(datum_obj, "location.coordinate2") z = get_object_attribute_rgx(datum_obj, "location.coordinate3") crs = get_object_attribute(datum_obj, "LocalCrs") - _, _, _, z_increasing_downward, projected_epsg_code, vertical_epsg_code = get_datum_information(crs, workspace) + _, _, _, z_increasing_downward, projected_epsg_code, vertical_epsg_code, _ = get_datum_information( + crs, workspace + ) return ( float(x) if x is not None else 0.0, float(y) if y is not None else 0.0, @@ -368,8 +379,9 @@ def get_datum_information(datum_obj: Any, workspace: Optional[EnergymlStorageInt z_increasing_downward, projected_epsg_code, vertical_epsg_code, + crs, ) - return 0.0, 0.0, 0.0, False, None, None + return 0.0, 0.0, 0.0, False, None, None, None # ================================================== @@ -659,7 +671,9 @@ def generate_smooth_trajectory( return np.array(smooth_points) -def generate_vertical_well_points(wellbore_mds: np.ndarray, head_x: float, head_y: float, head_z: float) -> np.ndarray: +def generate_vertical_well_points( + wellbore_mds: np.ndarray, head_x: float, head_y: float, head_z: float, z_increasing_downward: bool = False +) -> np.ndarray: """ Generates local 3D coordinates for a perfectly vertical wellbore. @@ -684,8 +698,12 @@ def generate_vertical_well_points(wellbore_mds: np.ndarray, head_x: float, head_ # In a vertical well, Z_point = Z_datum + (MD_point - MD_datum_at_0) # Most of the time, MD at head is 0. # If wellbore_mds start at 0, Z starts at head_z. + # if z_increasing_downward is False, we add the MD to head_z, otherwise we subtract it. md_start = wellbore_mds[0] - local_points[:, 2] = head_z + (wellbore_mds - md_start) + if z_increasing_downward: + local_points[:, 2] = head_z - (wellbore_mds - md_start) + else: + local_points[:, 2] = head_z + (wellbore_mds - md_start) return local_points diff --git a/energyml-utils/src/energyml/utils/data/mesh.py b/energyml-utils/src/energyml/utils/data/mesh.py index efa9564..bf25a64 100644 --- a/energyml-utils/src/energyml/utils/data/mesh.py +++ b/energyml-utils/src/energyml/utils/data/mesh.py @@ -6,6 +6,7 @@ import os import re import sys +import traceback import numpy as np from dataclasses import dataclass, field from enum import Enum @@ -780,6 +781,8 @@ def read_wellbore_frame_representation( trajectory_dor = search_attribute_matching_name(obj=energyml_object, name_rgx="Trajectory")[0] trajectory_obj = workspace.get_object(get_obj_uri(trajectory_dor)) + print(f"Mds {wellbore_frame_mds}") + meshes = read_wellbore_trajectory_representation( energyml_object=trajectory_obj, workspace=workspace, @@ -831,8 +834,8 @@ def read_wellbore_trajectory_representation( # Get CRS from trajectory geometry if available try: crs = workspace.get_object(get_obj_uri(get_object_attribute(energyml_object, "geometry.LocalCrs"))) - except Exception as e: - logging.debug(f"Could not get CRS from trajectory geometry") + except Exception: + logging.debug("Could not get CRS from trajectory geometry") # ========== # MD Datum @@ -853,14 +856,24 @@ def read_wellbore_trajectory_representation( md_datum_obj = workspace.get_object(md_datum_identifier) if md_datum_obj is not None: - head_x, head_y, head_z, z_increasing_downward, projected_epsg_code, vertical_epsg_code = ( + head_x, head_y, head_z, z_increasing_downward, projected_epsg_code, vertical_epsg_code, crs = ( get_datum_information(md_datum_obj, workspace) ) + # if crs is None: + # crs = get_crs_obj( + # context_obj=md_datum_obj, + # path_in_root=".", + # root_obj=energyml_object, + # workspace=workspace, + # ) except Exception as e: logging.debug(f"Could not get reference point / Datum from trajectory: {e}") # ========== well_points = None + logging.debug( + f"wellbore mds : {wellbore_frame_mds}\n\tCRs : {crs}\n\thead x,y,z : {head_x}, {head_y}, {head_z}\n\tz increasing downward : {z_increasing_downward}" + ) try: x_offset, y_offset, z_offset, (azimuth, azimuth_uom) = get_crs_offsets_and_angle(crs, workspace) # Try to read parametric Geometry from the trajectory. @@ -886,11 +899,13 @@ def read_wellbore_trajectory_representation( head_y=head_y, head_z=head_z, wellbore_mds=wellbore_frame_mds, + z_increasing_downward=z_increasing_downward, ) else: + traceback.print_exc() raise ValueError( "Cannot read wellbore trajectory representation: no parametric geometry and no measured depth information available to generate points" - ) from e + ) meshes = [] if well_points is not None and len(well_points) > 0: diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index d5761eb..34b8282 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -1178,11 +1178,10 @@ def write_array( obj = self.get_object_by_identifier(proxy) # Determine which external files to use - file_paths = ( - [make_path_relative_to_other_file(external_uri, self.epc_file_path)] - if external_uri - else self.get_h5_file_paths(obj) - ) + file_paths = self.get_h5_file_paths(obj) + if external_uri: + file_paths.insert(0, make_path_relative_to_other_file(external_uri, self.epc_file_path)) + if not file_paths or len(file_paths) == 0: file_paths = self.external_files_path @@ -1284,7 +1283,7 @@ def get_array_metadata( return None - def get_h5_file_paths(self, obj: Any) -> List[str]: + def get_h5_file_paths(self, obj_or_id: Optional[Any] = None) -> List[str]: """ Get all HDF5 file paths referenced in the EPC file (from rels to external resources) :return: list of HDF5 file paths @@ -1292,31 +1291,29 @@ def get_h5_file_paths(self, obj: Any) -> List[str]: if self.force_h5_path is not None: return [self.force_h5_path] + h5_paths = set() - is_uri = (isinstance(obj, str) and parse_uri(obj) is not None) or isinstance(obj, Uri) - if is_uri: - obj = self.get_object_by_identifier(obj) + if obj_or_id is None: + return [self.epc_file_path.replace(".epc", ".h5")] if self.epc_file_path else [] - h5_paths = set() + obj = self.get_object(obj_or_id) if isinstance(obj_or_id, (str, Uri)) else obj_or_id - if isinstance(obj, str): - obj = self.get_object_by_identifier(obj) # for rels in self.additional_rels.get(get_obj_identifier(obj), []): for rels in self._rels_cache.get_supplemental_rels(obj): if rels.type_value == EPCRelsRelationshipType.EXTERNAL_RESOURCE.get_type(): h5_paths.add(rels.target) + h5_paths = set(make_path_relative_to_filepath_list(list(h5_paths), self.epc_file_path)) + if len(h5_paths) == 0: - # search if an h5 file has the same name than the epc file + # Collect all .h5 files in the EPC file's folder epc_folder = self.get_epc_file_folder() - if epc_folder is not None and self.epc_file_path is not None: - epc_file_name = os.path.basename(self.epc_file_path) - epc_file_base, _ = os.path.splitext(epc_file_name) - possible_h5_path = os.path.join(epc_folder, epc_file_base + ".h5") - if os.path.exists(possible_h5_path): - h5_paths.add(possible_h5_path) - - return make_path_relative_to_filepath_list(list(h5_paths), self.epc_file_path) + if epc_folder is not None and os.path.isdir(epc_folder): + for fname in os.listdir(epc_folder): + if fname.lower().endswith(".h5"): + h5_paths.add(os.path.join(epc_folder, fname)) + + return list(h5_paths) def get_object_as_dor(self, identifier: str, dor_qualified_type) -> Optional[Any]: """ diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index 313ade9..3570803 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -49,6 +49,7 @@ in_epc_file_path_to_mime_type, is_core_prop_or_extension_path, repair_epc_structure_if_not_valid, + get_file_folder, ) from energyml.utils.storage_interface import ( DataArrayMetadata, @@ -1391,20 +1392,18 @@ def get_h5_file_paths(self, obj: Union[str, Uri, Any], make_path_absolute_from_e except KeyError: pass - if len(h5_paths) == 0: - # search if an h5 file has the same name than the epc file - epc_folder = os.path.dirname(self.epc_file_path) - if epc_folder is not None and self.epc_file_path is not None: - epc_file_name = os.path.basename(self.epc_file_path) - epc_file_base, _ = os.path.splitext(epc_file_name) - possible_h5_path = os.path.join(epc_folder, epc_file_base + ".h5") - if os.path.exists(possible_h5_path): - h5_paths.add(possible_h5_path) - if make_path_absolute_from_epc_path: - return make_path_relative_to_filepath_list(list(h5_paths), self.epc_file_path) - else: - return list(h5_paths) + h5_paths = set(make_path_relative_to_filepath_list(list(h5_paths), self.epc_file_path)) + + if len(h5_paths) == 0: + # Collect all .h5 files in the EPC file's folder + epc_folder = get_file_folder(self.epc_file_path) + if epc_folder is not None and os.path.isdir(epc_folder): + for fname in os.listdir(epc_folder): + if fname.lower().endswith(".h5"): + h5_paths.add(os.path.join(epc_folder, fname)) + + return list(h5_paths) # ________ ___ __________ __ _________________ ______ ____ _____ # / ____/ / / | / ___/ ___/ / |/ / ____/_ __/ / / / __ \/ __ \/ ___/ From ae0be3230d28273e18a6dbb948ca269c3873ee8a Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Tue, 17 Feb 2026 02:02:48 +0100 Subject: [PATCH 45/70] auto-cache file for externalFiles + unit tests --- energyml-utils/example/attic/arrays_test.py | 55 +++- .../src/energyml/utils/data/datasets_io.py | 261 +++++++----------- .../src/energyml/utils/data/model.py | 190 ++++++++++++- .../src/energyml/utils/epc_stream.py | 254 +++++++++++------ energyml-utils/tests/test_array_handlers.py | 133 +++++++++ 5 files changed, 647 insertions(+), 246 deletions(-) create mode 100644 energyml-utils/tests/test_array_handlers.py diff --git a/energyml-utils/example/attic/arrays_test.py b/energyml-utils/example/attic/arrays_test.py index 39c22aa..7b087cc 100644 --- a/energyml-utils/example/attic/arrays_test.py +++ b/energyml-utils/example/attic/arrays_test.py @@ -2,6 +2,7 @@ from sqlite3 import NotSupportedError import traceback from typing import List, Optional +from energyml.utils.data.datasets_io import get_handler_registry import numpy as np from energyml.utils.data.helper import _ARRAY_NAMES_, read_array from energyml.utils.data.mesh import ( @@ -412,16 +413,65 @@ def read_wellbore_frame_repr_demo_jfr_02_26( return meshes +def test_read_write_array(h5_path): + + handler_registry = get_handler_registry() + + h5_handler = handler_registry.get_handler_for_file(h5_path) + if h5_handler is None: + print(f"No handler found for file {h5_path}") + return + h5_handler.write_array( + array=np.array([[1, 2, 3], [4, 5, 6]]), + target=h5_path, + path_in_external_file="/test_array", + ) + + h5_handler.file_cache.close_all() + + print( + h5_handler.read_array( + source=h5_path, + path_in_external_file="/test_array", + ) + ) + + success = h5_handler.write_array( + array=np.array([[7, 8, 9], [10, 11, 12]]), + target=h5_path, + path_in_external_file="/test_array2", + ) + print(f"Write success: {success}") + + cached = h5_handler.file_cache.get_or_open(h5_path, h5_handler, "a") + # print if file is still opened : + print(f"File still opened after write: {cached} is open: {hasattr(cached, 'id') and cached.id.valid}") + + success = h5_handler.write_array( + array=np.array([[13, 14, 15], [16, 17, 18]]), + target=h5_path, + path_in_external_file="/test_array3", + ) + print(f"Write success: {success}") + + print( + h5_handler.read_array( + source=h5_path, + path_in_external_file="/test_array2", + ) + ) + + if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) - + meshes = [] # meshes = read_grid() # meshes = read_polyline() # meshes = read_wellbore_frame_repr() # meshes = read_representation_set_representation() # meshes = read_trset() # meshes = read_pointset() - meshes = read_wellbore_frame_repr_demo_jfr_02_26() + # meshes = read_wellbore_frame_repr_demo_jfr_02_26() print(f"Number of meshes read: {len(meshes)}") @@ -444,3 +494,4 @@ def read_wellbore_frame_repr_demo_jfr_02_26( raise e # read_props_and_cbt() + test_read_write_array("test_array_rw.h5") diff --git a/energyml-utils/src/energyml/utils/data/datasets_io.py b/energyml-utils/src/energyml/utils/data/datasets_io.py index cbdce63..c521535 100644 --- a/energyml-utils/src/energyml/utils/data/datasets_io.py +++ b/energyml-utils/src/energyml/utils/data/datasets_io.py @@ -729,160 +729,11 @@ def get_proxy_uri_for_path_in_external(obj: Any, dataspace_name_or_uri: Union[st # FILE CACHE MANAGER AND HANDLER REGISTRY # =========================================================================================== -from collections import OrderedDict + from typing import Callable from energyml.utils.data.model import ExternalArrayHandler -class FileCacheManager: - """ - Manages a cache of open file handles to avoid reopening overhead. - - Keeps up to `max_open_files` (default 3) files open using an LRU strategy. - When a file is accessed, it moves to the front of the cache. When the cache - is full, the least recently used file is closed and removed. - - Features: - - Thread-safe access to file handles - - Automatic cleanup of least-recently-used files - - Support for any file type with proper handlers - - Explicit close() method for cleanup - """ - - def __init__(self, max_open_files: int = 3): - """ - Initialize file cache manager. - - Args: - max_open_files: Maximum number of files to keep open simultaneously - """ - self.max_open_files = max_open_files - self._cache: OrderedDict[str, Any] = OrderedDict() # file_path -> open file handle - self._handlers: Dict[str, ExternalArrayHandler] = {} # file_path -> handler instance - - def get_or_open(self, file_path: str, handler: ExternalArrayHandler, mode: str = "r") -> Optional[Any]: - """ - Get an open file handle from cache, or open it if not cached. - - Args: - file_path: Path to the file - handler: Handler instance that knows how to open this file type - mode: File open mode ('r', 'a', etc.) - - Returns: - Open file handle, or None if opening failed - """ - # Normalize path - file_path = os.path.abspath(file_path) if os.path.exists(file_path) else file_path - - # Check cache first - if file_path in self._cache: - # Move to end (most recently used) - self._cache.move_to_end(file_path) - return self._cache[file_path] - - # Not in cache - try to open it - try: - file_handle = self._open_file(file_path, mode) - if file_handle is None: - return None - - # Add to cache - self._cache[file_path] = file_handle - self._handlers[file_path] = handler - self._cache.move_to_end(file_path) - - # Evict oldest if cache is full - if len(self._cache) > self.max_open_files: - self._evict_oldest() - - return file_handle - - except Exception as e: - logging.debug(f"Failed to open file {file_path}: {e}") - return None - - def _open_file(self, file_path: str, mode: str) -> Optional[Any]: - """ - Open a file based on its extension. - - Args: - file_path: Path to the file - mode: File open mode - - Returns: - Open file handle specific to the file type - """ - ext = os.path.splitext(file_path)[1].lower() - - if ext in [".h5", ".hdf5"] and __H5PY_MODULE_EXISTS__: - return h5py.File(file_path, mode) # type: ignore - # Add other file types as needed - # For now, other types will be opened on-demand by their handlers - - return None - - def _evict_oldest(self) -> None: - """Remove the least recently used file from cache.""" - if not self._cache: - return - - # Get oldest (first) item - oldest_path, oldest_handle = self._cache.popitem(last=False) - - # Close the file handle - try: - if hasattr(oldest_handle, "close"): - oldest_handle.close() - except Exception as e: - logging.debug(f"Error closing cached file {oldest_path}: {e}") - - # Remove handler reference - if oldest_path in self._handlers: - del self._handlers[oldest_path] - - def close_all(self) -> None: - """Close all cached file handles.""" - for file_path, file_handle in list(self._cache.items()): - try: - if hasattr(file_handle, "close"): - file_handle.close() - except Exception as e: - logging.debug(f"Error closing file {file_path}: {e}") - - self._cache.clear() - self._handlers.clear() - - def remove(self, file_path: str) -> None: - """ - Remove a specific file from cache and close it. - - Args: - file_path: Path to the file to remove - """ - file_path = os.path.abspath(file_path) if os.path.exists(file_path) else file_path - - if file_path in self._cache: - file_handle = self._cache.pop(file_path) - try: - if hasattr(file_handle, "close"): - file_handle.close() - except Exception as e: - logging.debug(f"Error closing file {file_path}: {e}") - - if file_path in self._handlers: - del self._handlers[file_path] - - def __len__(self) -> int: - """Return number of cached files.""" - return len(self._cache) - - def __contains__(self, file_path: str) -> bool: - """Check if a file is in cache.""" - file_path = os.path.abspath(file_path) if os.path.exists(file_path) else file_path - return file_path in self._cache - - class FileHandlerRegistry: """ Global registry that maps file extensions to handler classes. @@ -897,11 +748,11 @@ class FileHandlerRegistry: array = handler.read_array("data.h5", "/dataset/path") """ - def __init__(self): + def __init__(self, max_open_files: int = 3): self._handlers: Dict[str, Callable[[], ExternalArrayHandler]] = {} - self._register_default_handlers() + self._register_default_handlers(max_open_files) - def _register_default_handlers(self) -> None: + def _register_default_handlers(self, max_open_files: int) -> None: """Register all available handlers based on installed dependencies.""" # HDF5 Handler if __H5PY_MODULE_EXISTS__: @@ -993,6 +844,17 @@ def get_handler_registry() -> FileHandlerRegistry: class HDF5ArrayHandler(ExternalArrayHandler): """Handler for HDF5 files (.h5, .hdf5).""" + def __init__(self, max_open_files: int = 3): + super().__init__(max_open_files=max_open_files) + + def open_file_no_cache(self, file_path: str, mode: str = "r") -> Optional[Any]: + """Open an HDF5 file without using the cache.""" + try: + return h5py.File(file_path, mode) # type: ignore + except Exception as e: + logging.error(f"Failed to open HDF5 file {file_path}: {e}") + return None + def read_array( self, source: Union[BytesIO, str, Any], @@ -1012,7 +874,7 @@ def read_array( return full_array return None else: - with h5py.File(source, "r") as f: # type: ignore + with self.file_cache.get_or_open(source, self, "r") as f: # type: ignore return self.read_array(f, path_in_external_file, start_indices, counts) def write_array( @@ -1049,8 +911,16 @@ def write_array( dset = target.create_dataset(path_in_external_file, array.shape, dtype or array.dtype) dset[()] = array else: - with h5py.File(target, "a") as f: # type: ignore - return self.write_array(f, array, path_in_external_file, start_indices, **kwargs) + # with self.file_cache.get_or_open(target, self, "a") as f: # type: ignore + # return self.write_array(f, array, path_in_external_file, start_indices, **kwargs) + return self.write_array( + self.file_cache.get_or_open(target, self, "a"), + array, + path_in_external_file, + start_indices, + **kwargs, + ) + return True except Exception as e: logging.error(f"Failed to write array to HDF5: {e}") @@ -1087,8 +957,11 @@ def get_array_metadata( datasets = h5_list_datasets(source) return [self.get_array_metadata(source, ds, start_indices, counts) for ds in datasets] else: - with h5py.File(source, "r") as f: # type: ignore - return self.get_array_metadata(f, path_in_external_file, start_indices, counts) + # with self.file_cache.get_or_open(source, self, "r") as f: # type: ignore + # return self.get_array_metadata(f, path_in_external_file, start_indices, counts) + return self.get_array_metadata( + self.file_cache.get_or_open(source, self, "r"), path_in_external_file, start_indices, counts + ) except Exception as e: logging.debug(f"Failed to get HDF5 metadata: {e}") return None @@ -1107,6 +980,13 @@ def can_handle_file(self, file_path: str) -> bool: class MockHDF5ArrayHandler(ExternalArrayHandler): """Mock handler when h5py is not installed.""" + def __init__(self, max_open_files: int = 3): + super().__init__(max_open_files=max_open_files) + + def open_file_no_cache(self, file_path: str, mode: str = "r") -> Optional[Any]: + """Open an HDF5 file without using the cache.""" + return None + def read_array( self, source: Union[BytesIO, str, Any], @@ -1148,6 +1028,17 @@ def can_handle_file(self, file_path: str) -> bool: class ParquetArrayHandler(ExternalArrayHandler): """Handler for Parquet files (.parquet, .pq).""" + def __init__(self, max_open_files: int = 3): + super().__init__(max_open_files=max_open_files) + + def open_file_no_cache(self, file_path: str, mode: str = "r") -> Optional[Any]: + """Open a Parquet file without using the cache.""" + try: + return pq.ParquetFile(file_path) # type: ignore + except Exception as e: + logging.error(f"Failed to open Parquet file {file_path}: {e}") + return None + def read_array( self, source: Union[BytesIO, str, Any], @@ -1279,6 +1170,13 @@ def can_handle_file(self, file_path: str) -> bool: class MockParquetArrayHandler(ExternalArrayHandler): """Mock handler when parquet libraries are not installed.""" + def __init__(self, max_open_files: int = 3): + super().__init__(max_open_files=max_open_files) + + def open_file_no_cache(self, file_path: str, mode: str = "r") -> Optional[Any]: + """Open a Parquet file without using the cache.""" + return None + def read_array( self, source: Union[BytesIO, str, Any], @@ -1320,6 +1218,17 @@ def can_handle_file(self, file_path: str) -> bool: class CSVArrayHandler(ExternalArrayHandler): """Handler for CSV files (.csv, .txt, .dat).""" + def __init__(self, max_open_files: int = 3): + super().__init__(max_open_files=max_open_files) + + def open_file_no_cache(self, file_path: str, mode: str = "r") -> Optional[Any]: + """Open a CSV file without using the cache.""" + try: + return open(file_path, mode) + except Exception as e: + logging.error(f"Failed to open CSV file {file_path}: {e}") + return None + def read_array( self, source: Union[BytesIO, str, Any], @@ -1400,6 +1309,17 @@ def can_handle_file(self, file_path: str) -> bool: class LASArrayHandler(ExternalArrayHandler): """Handler for LAS (Log ASCII Standard) files (.las).""" + def __init__(self, max_open_files: int = 3): + super().__init__(max_open_files=max_open_files) + + def open_file_no_cache(self, file_path: str, mode: str = "r") -> Optional[Any]: + """Open a LAS file without using the cache.""" + try: + return lasio.read(file_path) # type: ignore + except Exception as e: + logging.error(f"Failed to open LAS file {file_path}: {e}") + return None + def read_array( self, source: Union[BytesIO, str, Any], @@ -1597,6 +1517,13 @@ def can_handle_file(self, file_path: str) -> bool: class MockLASArrayHandler(ExternalArrayHandler): """Mock handler when lasio is not installed.""" + def __init__(self, max_open_files: int = 3): + super().__init__(max_open_files=max_open_files) + + def open_file_no_cache(self, file_path: str, mode: str = "r") -> Optional[Any]: + """Open a LAS file without using the cache.""" + return None + def read_array( self, source: Union[BytesIO, str, Any], @@ -1640,6 +1567,17 @@ def can_handle_file(self, file_path: str) -> bool: class SEGYArrayHandler(ExternalArrayHandler): """Handler for SEG-Y seismic files (.sgy, .segy).""" + def __init__(self, max_open_files: int = 3): + super().__init__(max_open_files=max_open_files) + + def open_file_no_cache(self, file_path: str, mode: str = "r") -> Optional[Any]: + """Open a SEG-Y file without using the cache.""" + try: + return segyio.open(file_path, mode, ignore_geometry=True) # type: ignore + except Exception as e: + logging.error(f"Failed to open SEG-Y file {file_path}: {e}") + return None + def read_array( self, source: Union[BytesIO, str, Any], @@ -1809,6 +1747,13 @@ def can_handle_file(self, file_path: str) -> bool: class MockSEGYArrayHandler(ExternalArrayHandler): """Mock handler when segyio is not installed.""" + def __init__(self, max_open_files: int = 3): + super().__init__(max_open_files=max_open_files) + + def open_file_no_cache(self, file_path: str, mode: str = "r") -> Optional[Any]: + """Open a SEG-Y file without using the cache.""" + return None + def read_array( self, source: Union[BytesIO, str, Any], diff --git a/energyml-utils/src/energyml/utils/data/model.py b/energyml-utils/src/energyml/utils/data/model.py index 37a8453..0e2c2ae 100644 --- a/energyml-utils/src/energyml/utils/data/model.py +++ b/energyml-utils/src/energyml/utils/data/model.py @@ -1,9 +1,12 @@ # Copyright (c) 2023-2024 Geosiris. # SPDX-License-Identifier: Apache-2.0 +import logging +import os from abc import ABC, abstractmethod from dataclasses import dataclass +from collections import OrderedDict from io import BytesIO -from typing import Optional, List, Union, Any +from typing import Dict, Optional, List, Union, Any import numpy as np @@ -17,6 +20,175 @@ def get_array_dimension(self, source: Union[BytesIO, str], path_in_external_file return None +class FileCacheManager: + """ + Manages a cache of open file handles to avoid reopening overhead. + + Keeps up to `max_open_files` (default 3) files open using an LRU strategy. + When a file is accessed, it moves to the front of the cache. When the cache + is full, the least recently used file is closed and removed. + + Features: + - Thread-safe access to file handles + - Automatic cleanup of least-recently-used files + - Support for any file type with proper handlers + - Explicit close() method for cleanup + """ + + def __init__(self, max_open_files: int = 3): + """ + Initialize file cache manager. + + Args: + max_open_files: Maximum number of files to keep open simultaneously + """ + self.max_open_files = max_open_files + # file_path -> (file handle, mode) + self._cache: OrderedDict[str, tuple[Any, str]] = OrderedDict() + self._handlers: Dict[str, "ExternalArrayHandler"] = {} # file_path -> handler instance + + def get_or_open(self, file_path: str, handler: "ExternalArrayHandler", mode: str = "r") -> Optional[Any]: + """ + Get an open file handle from cache, or open it if not cached. + + Args: + file_path: Path to the file + handler: Handler instance that knows how to open this file type + mode: File open mode ('r', 'a', etc.) + + Returns: + Open file handle, or None if opening failed + """ + # Normalize path + file_path = os.path.abspath(file_path) if os.path.exists(file_path) else file_path + + # Check cache first, and validate mode compatibility + if file_path in self._cache: + cached_handle, cached_mode = self._cache[file_path] + # If requested mode is compatible with cached mode, reuse + if self._is_mode_compatible(cached_mode, mode): + self._cache.move_to_end(file_path) + return cached_handle + # Otherwise, close and reopen with new mode + logging.debug(f"Mode change for cached file {file_path}: {cached_mode} -> {mode}. Reopening.") + try: + if hasattr(cached_handle, "close"): + cached_handle.close() + except Exception as e: + logging.debug(f"Error closing cached file {file_path}: {e}") + del self._cache[file_path] + if file_path in self._handlers: + del self._handlers[file_path] + + # Not in cache - try to open it + try: + file_handle = handler.open_file_no_cache(file_path, mode) + if file_handle is None: + return None + + # Add to cache with mode + self._cache[file_path] = (file_handle, mode) + self._handlers[file_path] = handler + self._cache.move_to_end(file_path) + + # Evict oldest if cache is full + if len(self._cache) > self.max_open_files: + self._evict_oldest() + + return file_handle + + except Exception as e: + logging.debug(f"Failed to open file {file_path}: {e}") + return None + + def _evict_oldest(self) -> None: + """Remove the least recently used file from cache.""" + if not self._cache: + return + + # Get oldest (first) item + oldest_path, (oldest_handle, _) = self._cache.popitem(last=False) + + # Close the file handle + try: + if hasattr(oldest_handle, "close"): + oldest_handle.close() + except Exception as e: + logging.debug(f"Error closing cached file {oldest_path}: {e}") + + # Remove handler reference + if oldest_path in self._handlers: + del self._handlers[oldest_path] + + def close_all(self) -> None: + """Close all cached file handles.""" + for file_path, (file_handle, _) in list(self._cache.items()): + try: + if hasattr(file_handle, "close"): + file_handle.close() + except Exception as e: + logging.debug(f"Error closing file {file_path}: {e}") + + self._cache.clear() + self._handlers.clear() + + def remove(self, file_path: str) -> None: + """ + Remove a specific file from cache and close it. + + Args: + file_path: Path to the file to remove + """ + file_path = os.path.abspath(file_path) if os.path.exists(file_path) else file_path + + if file_path in self._cache: + file_handle, _ = self._cache.pop(file_path) + try: + if hasattr(file_handle, "close"): + file_handle.close() + except Exception as e: + logging.debug(f"Error closing file {file_path}: {e}") + + if file_path in self._handlers: + del self._handlers[file_path] + + def __len__(self) -> int: + """Return number of cached files.""" + return len(self._cache) + + def __contains__(self, file_path: str) -> bool: + """Check if a file is in cache.""" + file_path = os.path.abspath(file_path) if os.path.exists(file_path) else file_path + return file_path in self._cache + + @staticmethod + def _is_mode_compatible(cached_mode: str, requested_mode: str) -> bool: + """ + Determine if the cached file mode is compatible with the requested mode. + 'r' is only compatible with 'r'. 'r+' and 'a' are compatible with each other and with 'r+'. + 'w' is never compatible (always destructive). + """ + # Simplified: treat 'r' as readonly, 'r+', 'a' as read/write, 'w' as destructive + readonly_modes = {"r"} + rw_modes = {"r+", "a"} + destructive_modes = {"w", "w+", "x"} + + logging.debug(f"Checking mode compatibility: cached_mode={cached_mode}, requested_mode={requested_mode}") + + result = False + + if cached_mode in destructive_modes or requested_mode in destructive_modes: + result = False + if cached_mode in readonly_modes and requested_mode in readonly_modes: + result = True + if cached_mode in rw_modes and (requested_mode in rw_modes or requested_mode in readonly_modes): + result = True + + logging.debug(f"\tMode compatibility result: {result}") + + return result + + class ExternalArrayHandler(ABC): """ Base class for handling external array storage (HDF5, Parquet, CSV, etc.). @@ -32,6 +204,9 @@ class ExternalArrayHandler(ABC): - Support for sub-array selection via start_indices and counts (RESQML v2.2) """ + def __init__(self, max_open_files: int = 3): + self.file_cache = FileCacheManager(max_open_files=max_open_files) + @abstractmethod def read_array( self, @@ -130,6 +305,19 @@ def can_handle_file(self, file_path: str) -> bool: """ pass + @abstractmethod + def open_file_no_cache(self, file_path: str, mode: str = "r") -> Optional[Any]: + """ + Open a file without using the cache. This is for handlers that manage their own file handles. + + Args: + file_path: Path to the file + mode: File open mode + Returns: + Open file handle, or None if opening failed + """ + pass + # @dataclass # class ETPReader(DatasetReader): diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index 3570803..1f36fe9 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -33,7 +33,7 @@ Relationship, ) from energyml.utils.data.datasets_io import ( - FileCacheManager, + # FileCacheManager, get_handler_registry, ) from energyml.utils.epc_utils import ( @@ -1232,7 +1232,7 @@ def __init__( self._rels_mgr = _RelationshipManager(self._zip_accessor, self._metadata_mgr, self.stats, rels_update_mode) # Initialize file cache manager for external array files (HDF5, Parquet, CSV, etc.) - self._file_cache = FileCacheManager(max_open_files=3) + # self._file_cache = FileCacheManager(max_open_files=3) self._handler_registry = get_handler_registry() # Register atexit handler to ensure cleanup on program shutdown @@ -1749,55 +1749,77 @@ def read_array( logging.warning(f"No external file paths found for proxy: {proxy}") return None - # Keep track of which paths we've tried from cache vs from scratch - cached_paths = [p for p in file_paths if p in self._file_cache] - non_cached_paths = [p for p in file_paths if p not in self._file_cache] + # Get the file handler registry + handler_registry = get_handler_registry() - # Try cached files first (most recently used first) - for file_path in cached_paths: - handler = self._handler_registry.get_handler_for_file(file_path) + for file_path in file_paths: + # Get the appropriate handler for this file type + handler = handler_registry.get_handler_for_file(file_path) if handler is None: logging.debug(f"No handler found for file: {file_path}") continue try: - # Get cached file handle - file_handle = self._file_cache.get_or_open(file_path, handler, mode="r") - if file_handle is not None: - # Try to read from cached handle with sub-selection - result = handler.read_array(file_handle, path_in_external, start_indices, counts) - if result is not None: - return result + # Use handler to read array with sub-selection support + array = handler.read_array(file_path, path_in_external, start_indices, counts) + if array is not None: + return array except Exception as e: - logging.debug(f"Failed to read from cached file {file_path}: {e}") - # Remove from cache if it's causing issues - self._file_cache.remove(file_path) - - # Try non-cached files - for file_path in non_cached_paths: - handler = self._handler_registry.get_handler_for_file(file_path) - if handler is None: - logging.debug(f"No handler found for file: {file_path}") - continue - - try: - # Try to open and read, which will add to cache if successful - file_handle = self._file_cache.get_or_open(file_path, handler, mode="r") - if file_handle is not None: - result = handler.read_array(file_handle, path_in_external, start_indices, counts) - if result is not None: - return result - else: - # Cache failed, try direct read without caching - result = handler.read_array(file_path, path_in_external, start_indices, counts) - if result is not None: - return result - except Exception as e: - logging.debug(f"Failed to read from file {file_path}: {e}") + logging.debug(f"Failed to read dataset from {file_path}: {e}") + pass logging.error(f"Failed to read array from any available file paths: {file_paths}") return None + # # Keep track of which paths we've tried from cache vs from scratch + # cached_paths = [p for p in file_paths if p in self._file_cache] + # non_cached_paths = [p for p in file_paths if p not in self._file_cache] + + # # Try cached files first (most recently used first) + # for file_path in cached_paths: + # handler = self._handler_registry.get_handler_for_file(file_path) + # if handler is None: + # logging.debug(f"No handler found for file: {file_path}") + # continue + + # try: + # # Get cached file handle + # file_handle = self._file_cache.get_or_open(file_path, handler, mode="r") + # if file_handle is not None: + # # Try to read from cached handle with sub-selection + # result = handler.read_array(file_handle, path_in_external, start_indices, counts) + # if result is not None: + # return result + # except Exception as e: + # logging.debug(f"Failed to read from cached file {file_path}: {e}") + # # Remove from cache if it's causing issues + # self._file_cache.remove(file_path) + + # # Try non-cached files + # for file_path in non_cached_paths: + # handler = self._handler_registry.get_handler_for_file(file_path) + # if handler is None: + # logging.debug(f"No handler found for file: {file_path}") + # continue + + # try: + # # Try to open and read, which will add to cache if successful + # file_handle = self._file_cache.get_or_open(file_path, handler, mode="r") + # if file_handle is not None: + # result = handler.read_array(file_handle, path_in_external, start_indices, counts) + # if result is not None: + # return result + # else: + # # Cache failed, try direct read without caching + # result = handler.read_array(file_path, path_in_external, start_indices, counts) + # if result is not None: + # return result + # except Exception as e: + # logging.debug(f"Failed to read from file {file_path}: {e}") + + # logging.error(f"Failed to read array from any available file paths: {file_paths}") + # return None + def write_array( self, proxy: Union[str, Uri, Any], @@ -1845,50 +1867,72 @@ def write_array( logging.warning(f"No external file paths found for proxy: {proxy}") return False - # Try to write to the first available file - # For writes, we prefer cached files first, then non-cached - cached_paths = [p for p in file_paths if p in self._file_cache] - non_cached_paths = [p for p in file_paths if p not in self._file_cache] - - # Try cached files first - for file_path in cached_paths: - handler = self._handler_registry.get_handler_for_file(file_path) - if handler is None: - continue - - try: - file_handle = self._file_cache.get_or_open(file_path, handler, mode="a") - if file_handle is not None: - success = handler.write_array(file_handle, array, path_in_external, start_indices, **kwargs) - if success: - return True - except Exception as e: - logging.debug(f"Failed to write to cached file {file_path}: {e}") - self._file_cache.remove(file_path) + # Get the file handler registry + handler_registry = get_handler_registry() - # Try non-cached files - for file_path in non_cached_paths: - handler = self._handler_registry.get_handler_for_file(file_path) + # Try to write to the first available file + for file_path in file_paths: + # Get the appropriate handler for this file type + handler = handler_registry.get_handler_for_file(file_path) if handler is None: + logging.debug(f"No handler found for file: {file_path}") continue try: - # Open in append mode and add to cache - file_handle = self._file_cache.get_or_open(file_path, handler, mode="a") - if file_handle is not None: - success = handler.write_array(file_handle, array, path_in_external, start_indices, **kwargs) - if success: - return True - else: - # Cache failed, try direct write - success = handler.write_array(file_path, array, path_in_external, start_indices, **kwargs) - if success: - return True + # Use handler to write array with optional partial write support + success = handler.write_array(file_path, array, path_in_external, start_indices, **kwargs) + if success: + return True except Exception as e: - logging.error(f"Failed to write to file {file_path}: {e}") + logging.error(f"Failed to write dataset to {file_path}: {e}") + logging.error(f"Failed to write array to any available file paths: {file_paths}") return False + # # Try to write to the first available file + # # For writes, we prefer cached files first, then non-cached + # cached_paths = [p for p in file_paths if p in self._file_cache] + # non_cached_paths = [p for p in file_paths if p not in self._file_cache] + + # # Try cached files first + # for file_path in cached_paths: + # handler = self._handler_registry.get_handler_for_file(file_path) + # if handler is None: + # continue + + # try: + # file_handle = self._file_cache.get_or_open(file_path, handler, mode="a") + # if file_handle is not None: + # success = handler.write_array(file_handle, array, path_in_external, start_indices, **kwargs) + # if success: + # return True + # except Exception as e: + # logging.debug(f"Failed to write to cached file {file_path}: {e}") + # self._file_cache.remove(file_path) + + # # Try non-cached files + # for file_path in non_cached_paths: + # handler = self._handler_registry.get_handler_for_file(file_path) + # if handler is None: + # continue + + # try: + # # Open in append mode and add to cache + # file_handle = self._file_cache.get_or_open(file_path, handler, mode="a") + # if file_handle is not None: + # success = handler.write_array(file_handle, array, path_in_external, start_indices, **kwargs) + # if success: + # return True + # else: + # # Cache failed, try direct write + # success = handler.write_array(file_path, array, path_in_external, start_indices, **kwargs) + # if success: + # return True + # except Exception as e: + # logging.error(f"Failed to write to file {file_path}: {e}") + + # return False + def get_array_metadata( self, proxy: Union[str, Uri, Any], @@ -1922,21 +1966,19 @@ def get_array_metadata( if not file_paths: logging.warning(f"No external file paths found for proxy: {proxy}") return None + # Get the file handler registry + handler_registry = get_handler_registry() - # Try cached files first - cached_paths = [p for p in file_paths if p in self._file_cache] - non_cached_paths = [p for p in file_paths if p not in self._file_cache] - - for file_path in cached_paths + non_cached_paths: - handler = self._handler_registry.get_handler_for_file(file_path) + for file_path in file_paths: + # Get the appropriate handler for this file type + handler = handler_registry.get_handler_for_file(file_path) if handler is None: + logging.debug(f"No handler found for file: {file_path}") continue try: - file_handle = self._file_cache.get_or_open(file_path, handler, mode="r") - source = file_handle if file_handle is not None else file_path - - metadata_dict = handler.get_array_metadata(source, path_in_external, start_indices, counts) + # Use handler to get metadata without loading full array + metadata_dict = handler.get_array_metadata(file_path, path_in_external, start_indices, counts) if metadata_dict is None: continue @@ -1965,6 +2007,48 @@ def get_array_metadata( logging.debug(f"Failed to get metadata from file {file_path}: {e}") return None + # # Try cached files first + # cached_paths = [p for p in file_paths if p in self._file_cache] + # non_cached_paths = [p for p in file_paths if p not in self._file_cache] + + # for file_path in cached_paths + non_cached_paths: + # handler = self._handler_registry.get_handler_for_file(file_path) + # if handler is None: + # continue + + # try: + # file_handle = self._file_cache.get_or_open(file_path, handler, mode="r") + # source = file_handle if file_handle is not None else file_path + + # metadata_dict = handler.get_array_metadata(source, path_in_external, start_indices, counts) + + # if metadata_dict is None: + # continue + + # # Convert dict(s) to DataArrayMetadata + # if isinstance(metadata_dict, list): + # return [ + # DataArrayMetadata( + # path_in_resource=m.get("path"), + # array_type=m.get("dtype", "unknown"), + # dimensions=m.get("shape", []), + # start_indices=start_indices, + # custom_data={"size": m.get("size", 0)}, + # ) + # for m in metadata_dict + # ] + # else: + # return DataArrayMetadata( + # path_in_resource=metadata_dict.get("path"), + # array_type=metadata_dict.get("dtype", "unknown"), + # dimensions=metadata_dict.get("shape", []), + # start_indices=start_indices, + # custom_data={"size": metadata_dict.get("size", 0)}, + # ) + # except Exception as e: + # logging.debug(f"Failed to get metadata from file {file_path}: {e}") + + # return None def list_objects( self, dataspace: Optional[str] = None, object_type: Optional[str] = None diff --git a/energyml-utils/tests/test_array_handlers.py b/energyml-utils/tests/test_array_handlers.py new file mode 100644 index 0000000..3043dee --- /dev/null +++ b/energyml-utils/tests/test_array_handlers.py @@ -0,0 +1,133 @@ +import os +import tempfile +import numpy as np +import pytest + +from energyml.utils.data.datasets_io import ( + HDF5ArrayHandler, + ParquetArrayHandler, + CSVArrayHandler, + LASArrayHandler, + SEGYArrayHandler, +) + + +def is_h5py_file_closed(h5file): + """Check if an h5py file handle is closed.""" + try: + return not getattr(h5file, "id", None) or not h5file.id.valid + except Exception: + return True + + +def test_hdf5_array_handler_read_write(): + """Test HDF5ArrayHandler read/write and file closure.""" + arr = np.arange(6).reshape(2, 3) + handler = HDF5ArrayHandler() + with tempfile.NamedTemporaryFile(suffix=".h5", delete=False) as tmp: + fname = tmp.name + try: + # Write + assert handler.write_array(fname, arr, "/data"), "HDF5 write failed" + # Read + out = handler.read_array(fname, "/data") + np.testing.assert_array_equal(arr, out) + # Check file closed after handler deletion + f = handler.file_cache.get_or_open(fname, handler, "r") + del handler + import gc + + gc.collect() + assert is_h5py_file_closed(f), "HDF5 file not closed after handler deletion" + finally: + try: + os.remove(fname) + except Exception: + pass + + +def test_parquet_array_handler_read_write(): + """Test ParquetArrayHandler read/write.""" + arr = np.arange(6).reshape(2, 3) + handler = ParquetArrayHandler() + with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as tmp: + fname = tmp.name + try: + assert handler.write_array(fname, arr, column_titles=["a", "b", "c"]), "Parquet write failed" + out = handler.read_array(fname) + np.testing.assert_array_equal(arr, out) + finally: + try: + os.remove(fname) + except Exception: + pass + + +def test_csv_array_handler_read_write(): + """Test CSVArrayHandler read/write.""" + arr = np.arange(6).reshape(2, 3) + handler = CSVArrayHandler() + with tempfile.NamedTemporaryFile(suffix=".csv", delete=False, mode="w+") as tmp: + fname = tmp.name + try: + assert handler.write_array(fname, arr), "CSV write failed" + out = handler.read_array(fname) + # CSV may return strings, so cast to int + np.testing.assert_array_equal(np.array(out, dtype=int), arr) + finally: + try: + os.remove(fname) + except Exception: + pass + + +def test_las_array_handler_read_write(): + """Test LASArrayHandler read/write if supported.""" + arr = np.arange(6).reshape(2, 3) + handler = LASArrayHandler() + with tempfile.NamedTemporaryFile(suffix=".las", delete=False, mode="w+") as tmp: + fname = tmp.name + try: + write_ok = False + try: + handler.write_array(fname, arr) + write_ok = True + except Exception as e: + print(f"LAS write not supported: {e}") + try: + out = handler.read_array(fname) + if write_ok and out is not None: + np.testing.assert_array_equal(np.array(out, dtype=arr.dtype), arr) + except Exception as e: + print(f"LAS read not supported: {e}") + finally: + try: + os.remove(fname) + except Exception: + pass + + +def test_segy_array_handler_read_write(): + """Test SEGYArrayHandler read/write if supported.""" + arr = np.arange(6).reshape(2, 3) + handler = SEGYArrayHandler() + with tempfile.NamedTemporaryFile(suffix=".sgy", delete=False, mode="w+b") as tmp: + fname = tmp.name + try: + write_ok = False + try: + handler.write_array(fname, arr) + write_ok = True + except Exception as e: + print(f"SEGY write not supported: {e}") + try: + out = handler.read_array(fname) + if write_ok and out is not None: + np.testing.assert_array_equal(np.array(out, dtype=arr.dtype), arr) + except Exception as e: + print(f"SEGY read not supported: {e}") + finally: + try: + os.remove(fname) + except Exception: + pass From a76ace0e019a13bfffb28cacd40861103ac1de29 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Tue, 17 Feb 2026 02:04:30 +0100 Subject: [PATCH 46/70] logs --- energyml-utils/src/energyml/utils/data/model.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/energyml-utils/src/energyml/utils/data/model.py b/energyml-utils/src/energyml/utils/data/model.py index 0e2c2ae..cbfdfff 100644 --- a/energyml-utils/src/energyml/utils/data/model.py +++ b/energyml-utils/src/energyml/utils/data/model.py @@ -70,7 +70,7 @@ def get_or_open(self, file_path: str, handler: "ExternalArrayHandler", mode: str self._cache.move_to_end(file_path) return cached_handle # Otherwise, close and reopen with new mode - logging.debug(f"Mode change for cached file {file_path}: {cached_mode} -> {mode}. Reopening.") + # logging.debug(f"Mode change for cached file {file_path}: {cached_mode} -> {mode}. Reopening.") try: if hasattr(cached_handle, "close"): cached_handle.close() @@ -173,7 +173,7 @@ def _is_mode_compatible(cached_mode: str, requested_mode: str) -> bool: rw_modes = {"r+", "a"} destructive_modes = {"w", "w+", "x"} - logging.debug(f"Checking mode compatibility: cached_mode={cached_mode}, requested_mode={requested_mode}") + # logging.debug(f"Checking mode compatibility: cached_mode={cached_mode}, requested_mode={requested_mode}") result = False @@ -184,7 +184,7 @@ def _is_mode_compatible(cached_mode: str, requested_mode: str) -> bool: if cached_mode in rw_modes and (requested_mode in rw_modes or requested_mode in readonly_modes): result = True - logging.debug(f"\tMode compatibility result: {result}") + # logging.debug(f"\tMode compatibility result: {result}") return result From a655e8c4f00cfc2acd517c12ba99d733dbcb93b9 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Tue, 17 Feb 2026 02:07:30 +0100 Subject: [PATCH 47/70] ci --- .github/workflows/ci_energyml_utils_pull_request.yml | 4 ++-- .github/workflows/ci_energyml_utils_release.yml | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci_energyml_utils_pull_request.yml b/.github/workflows/ci_energyml_utils_pull_request.yml index 50380a7..4015294 100644 --- a/.github/workflows/ci_energyml_utils_pull_request.yml +++ b/.github/workflows/ci_energyml_utils_pull_request.yml @@ -32,9 +32,9 @@ jobs: with: python-version: "3.10" - - name: Install dependencies + - name: Install dependencies (all extras) run: | - poetry install + poetry install --all-extras - name: Run pytest run: | diff --git a/.github/workflows/ci_energyml_utils_release.yml b/.github/workflows/ci_energyml_utils_release.yml index 21b882c..28854bd 100644 --- a/.github/workflows/ci_energyml_utils_release.yml +++ b/.github/workflows/ci_energyml_utils_release.yml @@ -3,7 +3,6 @@ ## SPDX-License-Identifier: Apache-2.0 ## --- - name: Publish release defaults: @@ -19,7 +18,6 @@ jobs: name: Build distribution runs-on: ubuntu-latest steps: - - name: Checkout code uses: actions/checkout@v4 with: @@ -28,7 +26,7 @@ jobs: - name: Install poetry uses: ./.github/actions/prepare-poetry with: - python-version: '3.10' + python-version: "3.10" - name: Build run: | @@ -56,7 +54,6 @@ jobs: needs: [build] runs-on: ubuntu-latest steps: - # Retrieve the code and GIT history so that poetry-dynamic-versioning knows which version to upload - name: Checkout code uses: actions/checkout@v4 @@ -72,7 +69,7 @@ jobs: - name: Install poetry uses: ./.github/actions/prepare-poetry with: - python-version: '3.10' + python-version: "3.10" - name: Upload to PyPI run: | From 6fd4694c101dabf879fba37210f0c7836377e169 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Tue, 17 Feb 2026 02:18:49 +0100 Subject: [PATCH 48/70] deps clean --- energyml-utils/pyproject.toml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/energyml-utils/pyproject.toml b/energyml-utils/pyproject.toml index 07b1b27..3f4c861 100644 --- a/energyml-utils/pyproject.toml +++ b/energyml-utils/pyproject.toml @@ -68,14 +68,13 @@ python = "^3.9" xsdata = {version = "^24.0", extras = ["cli", "lxml"]} energyml-opc = "^1.12.0" h5py = { version = "^3.7.0", optional = false } -pyarrow = { version = "^14.0.1", optional = false } numpy = { version = "^1.16.6", optional = false } -flake8 = "^7.3.0" +pyarrow = { version = "^14.0.1", optional = true } lasio = { version = "^0.31", optional = true } segyio = { version = "^1.9", optional = true } [tool.poetry.group.dev.dependencies] -pandas = { version = "^1.1.0", optional = false } +pandas = { version = "^1.1.0", optional = true } coverage = {extras = ["toml"], version = "^6.2"} pytest = "^8.1.1" pytest-cov = "^4.1.0" From ed8a0e38962a740ecdb1df50d4d47849640a16e2 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier Date: Tue, 17 Feb 2026 03:37:27 +0100 Subject: [PATCH 49/70] forcing get_title in epc_stream for the metadata --- .../example/attic/test_list_object.py | 56 +++++++++++ .../src/energyml/utils/epc_stream.py | 97 ++++++++++++++----- 2 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 energyml-utils/example/attic/test_list_object.py diff --git a/energyml-utils/example/attic/test_list_object.py b/energyml-utils/example/attic/test_list_object.py new file mode 100644 index 0000000..9c8497e --- /dev/null +++ b/energyml-utils/example/attic/test_list_object.py @@ -0,0 +1,56 @@ +from energyml.utils.epc_stream import EpcStreamReader, RelsUpdateMode +from datetime import datetime + + +def list_epc_classical(epc_file): + """List contents of an EPC file.""" + epc = EpcStreamReader(epc_file, rels_update_mode=RelsUpdateMode.MANUAL) + + time_start = datetime.now() + for obj in epc.list_objects(): + print(f"Object: {obj}") + print(len(epc.list_objects())) + time_end = datetime.now() + print(f"Time taken: {time_end - time_start}") + + +def list_epc_fast(epc_file): + """List contents of an EPC file using fast method.""" + epc = EpcStreamReader( + epc_file, + rels_update_mode=RelsUpdateMode.MANUAL, + ) + + time_start = datetime.now() + # for obj in epc.list_objects_parallel(): + # print(f"Object: {obj}") + print(len(epc.list_objects_parallel())) + time_end = datetime.now() + print(f"Time taken: {time_end - time_start}") + + +def list_epc_seq(epc_file): + """List contents of an EPC file using sequential method.""" + epc = EpcStreamReader( + epc_file, + rels_update_mode=RelsUpdateMode.MANUAL, + ) + + time_start = datetime.now() + # for obj in epc.list_objects_seq(): + # print(f"Object: {obj}") + print(len(epc.list_objects_seq())) + time_end = datetime.now() + print(f"Time taken: {time_end - time_start}") + + +if __name__ == "__main__": + epc_file = "D:/Geosiris/Clients/BRGM/git/pointset-extraction/rc/output/full-local/full-local.epc" + print("Listing EPC contents (classical method):") + list_epc_classical(epc_file) + + # print("\nListing EPC contents (fast method):") + # list_epc_fast(epc_file) + + # print("\nListing EPC contents (sequential method):") + # list_epc_seq(epc_file) diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index 1f36fe9..a2254da 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -680,37 +680,52 @@ def _process_energyml_object_metadata(self, zf: zipfile.ZipFile, override: Overr file_path = override.part_name.lstrip("/") content_type = override.content_type + uuid, version, title, last_changed = None, None, None, None + try: # First try to extract UUID and version from file path (works for EXPANDED mode) uuid, version = extract_uuid_and_version_from_obj_path(file_path) # For CLASSIC mode, version is not in the path, so we need to extract it from XML content - if uuid and version is None: - try: - # Read first chunk of XML to extract version without full parsing - with zf.open(file_path) as f: - chunk = f.read(2048) # 2KB should be enough for root element - self.stats.bytes_read += len(chunk) - chunk_str = chunk.decode("utf-8", errors="ignore") - - # Extract version if present - version_patterns = [ - r'object[Vv]ersion["\']?\s*[:=]\s*["\']([^"\']+)', - ] + # => Finally I do it anyway to get the title. + try: + # Read first chunk of XML to extract version, title, and last_changed in one regex search + # with self.zip_accessor.get_zip_file() as f: + chunk = zf.read(4096) # 4KB to increase chance of catching citation block + self.stats.bytes_read += len(chunk) + chunk_str = chunk.decode("utf-8", errors="ignore") + + # Single regex with named groups for version, title, and last_changed + pattern = re.compile( + r'object[Vv]ersion["\']?\s*[:=]\s*["\'](?P[^"\']+)' # version attribute + r"|(?P.*?)</eml:Title>" # eml:Title tag + r"|<eml:LastUpdate>(?P<last_changed>.*?)</eml:LastUpdate>", + re.DOTALL, + ) - for pattern in version_patterns: - version_match = re.search(pattern, chunk_str) - if version_match: - version = version_match.group(1) - if not isinstance(version, str): - version = str(version) - break - except Exception as e: - logging.debug(f"Failed to extract version from XML content for {file_path}: {e}") + # Iterate all matches and assign the first found for each group + found = {"version": None, "title": None, "last_changed": None} + for match in pattern.finditer(chunk_str): + for key in found: + if found[key] is None and match.group(key) is not None: + found[key] = match.group(key).strip() + if version is None and found["version"] is not None: + version = found["version"] + if found["title"] is not None: + title = found["title"] + if found["last_changed"] is not None: + last_changed = found["last_changed"] + # Try to parse as datetime if possible + try: + last_changed = date_to_datetime(last_changed) + except Exception: + pass + except Exception as e: + logging.debug(f"Failed to extract version/title/last_update from XML content for {file_path}: {e}") if uuid: # Only process if we successfully extracted UUID uri = create_uri_from_content_type_or_qualified_type(ct_or_qt=content_type, uuid=uuid, version=version) - metadata = EpcObjectMetadata(uri=uri) + metadata = EpcObjectMetadata(uri=uri, title=title, last_changed=last_changed) # Store in indexes identifier = metadata.identifier @@ -2055,6 +2070,44 @@ def list_objects( ) -> List[ResourceMetadata]: return [m.to_resource_metadata() for m in self._metadata_mgr.list_metadata(qualified_type_filter=object_type)] + def list_objects_parallel( + self, dataspace: Optional[str] = None, object_type: Optional[str] = None + ) -> List[ResourceMetadata]: + # use self._metadata_mgr.list_metadata(qualified_type_filter=object_type) to get the list of metadata, + # then get_each object in parallel using get_object and return the list of ResourceMetadata for the objects that were successfully retrieved + + import concurrent.futures + from concurrent.futures import ThreadPoolExecutor, as_completed + + metadata_list = self._metadata_mgr.list_metadata(qualified_type_filter=object_type) + with ThreadPoolExecutor(max_workers=8) as executor: + future_to_metadata = {executor.submit(self.get_object, m.identifier): m for m in metadata_list} + resource_metadata_list = [] + for future in as_completed(future_to_metadata): + metadata = future_to_metadata[future] + try: + obj = future.result() + if obj is not None: + resource_metadata_list.append(metadata.to_resource_metadata()) + except Exception as e: + logging.debug(f"Failed to get object for metadata {metadata.identifier}: {e}") + return resource_metadata_list + + def list_objects_seq( + self, dataspace: Optional[str] = None, object_type: Optional[str] = None + ) -> List[ResourceMetadata]: + metadata_list = self._metadata_mgr.list_metadata(qualified_type_filter=object_type) + resource_metadata_list = [] + for metadata in metadata_list: + try: + obj = self.get_object(metadata.identifier) + if obj is not None: + resource_metadata_list.append(metadata.to_resource_metadata()) + except Exception as e: + logging.debug(f"Failed to get object for metadata {metadata.identifier}: {e}") + + return resource_metadata_list + def get_obj_rels(self, obj: Union[str, Uri, Any]) -> List[Relationship]: _id = self._id_from_uri_or_identifier(obj) From 3687dbdf22315cf207216b81ffeb67bc10c61dc7 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Tue, 17 Feb 2026 04:14:03 +0100 Subject: [PATCH 50/70] reading title and other meta with stream as it seems not to be so long --- .../example/attic/test_list_object.py | 63 ++-- .../src/energyml/utils/epc_stream.py | 285 +++--------------- 2 files changed, 70 insertions(+), 278 deletions(-) diff --git a/energyml-utils/example/attic/test_list_object.py b/energyml-utils/example/attic/test_list_object.py index 9c8497e..56dee7b 100644 --- a/energyml-utils/example/attic/test_list_object.py +++ b/energyml-utils/example/attic/test_list_object.py @@ -7,41 +7,44 @@ def list_epc_classical(epc_file): epc = EpcStreamReader(epc_file, rels_update_mode=RelsUpdateMode.MANUAL) time_start = datetime.now() - for obj in epc.list_objects(): - print(f"Object: {obj}") - print(len(epc.list_objects())) - time_end = datetime.now() - print(f"Time taken: {time_end - time_start}") - + # for obj in epc.list_objects(): + # print(f"Object: {obj}") + print(len(epc.list_objects(object_type="eml23.DataobjectCollection"))) -def list_epc_fast(epc_file): - """List contents of an EPC file using fast method.""" - epc = EpcStreamReader( - epc_file, - rels_update_mode=RelsUpdateMode.MANUAL, - ) - - time_start = datetime.now() - # for obj in epc.list_objects_parallel(): - # print(f"Object: {obj}") - print(len(epc.list_objects_parallel())) + for obj in epc.list_objects(object_type="eml23.DataobjectCollection"): + print(f"DataobjectCollection: {obj}") time_end = datetime.now() print(f"Time taken: {time_end - time_start}") -def list_epc_seq(epc_file): - """List contents of an EPC file using sequential method.""" - epc = EpcStreamReader( - epc_file, - rels_update_mode=RelsUpdateMode.MANUAL, - ) - - time_start = datetime.now() - # for obj in epc.list_objects_seq(): - # print(f"Object: {obj}") - print(len(epc.list_objects_seq())) - time_end = datetime.now() - print(f"Time taken: {time_end - time_start}") +# def list_epc_fast(epc_file): +# """List contents of an EPC file using fast method.""" +# epc = EpcStreamReader( +# epc_file, +# rels_update_mode=RelsUpdateMode.MANUAL, +# ) + +# time_start = datetime.now() +# # for obj in epc.list_objects_parallel(): +# # print(f"Object: {obj}") +# print(len(epc.list_objects_parallel())) +# time_end = datetime.now() +# print(f"Time taken: {time_end - time_start}") + + +# def list_epc_seq(epc_file): +# """List contents of an EPC file using sequential method.""" +# epc = EpcStreamReader( +# epc_file, +# rels_update_mode=RelsUpdateMode.MANUAL, +# ) + +# time_start = datetime.now() +# # for obj in epc.list_objects_seq(): +# # print(f"Object: {obj}") +# print(len(epc.list_objects_seq())) +# time_end = datetime.now() +# print(f"Time taken: {time_end - time_start}") if __name__ == "__main__": diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index a2254da..a510002 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -471,10 +471,13 @@ def list_metadata(self, qualified_type_filter: Optional[str] = None) -> List[Epc """List metadata for all objects, optionally filtered by type.""" if qualified_type_filter is None: return list(self._metadata.values()) + # print(f"Filtering metadata for qualified type: {qualified_type_filter} -- {len(self._metadata.values())}") + # for identifier in self._metadata.keys(): + # print(f"{identifier} with qualified type {self._metadata[identifier].uri.get_qualified_type()}") return [ self._metadata[identifier] for identifier in self._metadata - if self._metadata[identifier].qualified_type == qualified_type_filter + if self._metadata[identifier].uri.get_qualified_type() == qualified_type_filter ] def add_metadata(self, metadata: EpcObjectMetadata) -> None: @@ -690,41 +693,45 @@ def _process_energyml_object_metadata(self, zf: zipfile.ZipFile, override: Overr # => Finally I do it anyway to get the title. try: # Read first chunk of XML to extract version, title, and last_changed in one regex search - # with self.zip_accessor.get_zip_file() as f: - chunk = zf.read(4096) # 4KB to increase chance of catching citation block - self.stats.bytes_read += len(chunk) - chunk_str = chunk.decode("utf-8", errors="ignore") - - # Single regex with named groups for version, title, and last_changed - pattern = re.compile( - r'object[Vv]ersion["\']?\s*[:=]\s*["\'](?P<version>[^"\']+)' # version attribute - r"|<eml:Title>(?P<title>.*?)</eml:Title>" # eml:Title tag - r"|<eml:LastUpdate>(?P<last_changed>.*?)</eml:LastUpdate>", - re.DOTALL, - ) + with zf.open(file_path) as f: + chunk = f.read() # 4KB to increase chance of catching citation block + # chunk = f.read(4096) # 4KB to increase chance of catching citation block + self.stats.bytes_read += len(chunk) + chunk_str = chunk.decode("utf-8", errors="ignore") + + # Single regex with named groups for version, title, and last_changed + pattern = re.compile( + r'object[Vv]ersion["\']?\s*[:=]\s*["\'](?P<version>[^"\']+)' # version attribute + r"|<eml:Title>(?P<title>.*?)</eml:Title>" # eml:Title tag + r"|<eml:LastUpdate>(?P<last_changed>.*?)</eml:LastUpdate>", + re.DOTALL, + ) + + # Iterate all matches and assign the first found for each group + found = {"version": None, "title": None, "last_changed": None} + for match in pattern.finditer(chunk_str): + for key in found: + if found[key] is None and match.group(key) is not None: + found[key] = match.group(key).strip() + if version is None and found["version"] is not None: + version = found["version"] + if found["title"] is not None: + title = found["title"] + if found["last_changed"] is not None: + last_changed = found["last_changed"] + # Try to parse as datetime if possible + try: + last_changed = date_to_datetime(last_changed) + except Exception: + pass - # Iterate all matches and assign the first found for each group - found = {"version": None, "title": None, "last_changed": None} - for match in pattern.finditer(chunk_str): - for key in found: - if found[key] is None and match.group(key) is not None: - found[key] = match.group(key).strip() - if version is None and found["version"] is not None: - version = found["version"] - if found["title"] is not None: - title = found["title"] - if found["last_changed"] is not None: - last_changed = found["last_changed"] - # Try to parse as datetime if possible - try: - last_changed = date_to_datetime(last_changed) - except Exception: - pass except Exception as e: logging.debug(f"Failed to extract version/title/last_update from XML content for {file_path}: {e}") if uuid: # Only process if we successfully extracted UUID uri = create_uri_from_content_type_or_qualified_type(ct_or_qt=content_type, uuid=uuid, version=version) + + # print(f"Loaded metadata for {uri} ({type(uri)}) with title '{title}' and last changed '{last_changed}'") metadata = EpcObjectMetadata(uri=uri, title=title, last_changed=last_changed) # Store in indexes @@ -935,51 +942,6 @@ def update_rels_for_removed_object(self, obj_identifier: str) -> None: ), # If all relationships are DESTINATION_OBJECT, we can delete the .rels file entirely. If some source rels exists, we keep it to ease potential add of this element later, to avoid parsing all reals to find its sources rels from other object DEST rels ) - # def compute_object_rels(self, obj: Any, obj_identifier: str) -> List[Relationship]: - # """ - # Compute relationships for a given object (SOURCE relationships). - # This object references other objects through DORs. - - # Args: - # obj: The EnergyML object - # obj_identifier: The identifier of the object - - # Returns: - # List of Relationship objects for this object's .rels file - # """ - # rels = [] - - # # Get all DORs (Data Object References) in this object - # direct_dors = get_direct_dor_list(obj) - - # for dor in direct_dors: - # try: - # target_identifier = get_obj_identifier(dor) - - # # Get target file path from metadata without processing DOR - # # The relationship target should be the object's file path, not its rels path - # if self.metadata_manager.contains(target_identifier): - # target_metadata = self.metadata_manager.get_metadata(target_identifier) - # if target_metadata: - # target_path = target_metadata.file_path - # else: - # target_path = gen_energyml_object_path(dor, self._metadata_mgr._export_version) - # else: - # # Fall back to generating path from DOR if metadata not found - # target_path = gen_energyml_object_path(dor, self._metadata_mgr._export_version) - - # # Create SOURCE relationship (this object -> target object) - # rel = Relationship( - # target=target_path, - # type_value=EPCRelsRelationshipType.SOURCE_OBJECT.get_type(), - # id=f"_{obj_identifier}_{get_obj_type(get_obj_usable_class(dor))}_{target_identifier}", - # ) - # rels.append(rel) - # except Exception as e: - # logging.warning(f"Failed to create relationship for DOR in {obj_identifier}: {e}") - - # return rels - def merge_rels(self, new_rels: List[Relationship], existing_rels: List[Relationship]) -> List[Relationship]: """Merge new relationships with existing ones, avoiding duplicates and ensuring unique IDs. @@ -1786,55 +1748,6 @@ def read_array( logging.error(f"Failed to read array from any available file paths: {file_paths}") return None - # # Keep track of which paths we've tried from cache vs from scratch - # cached_paths = [p for p in file_paths if p in self._file_cache] - # non_cached_paths = [p for p in file_paths if p not in self._file_cache] - - # # Try cached files first (most recently used first) - # for file_path in cached_paths: - # handler = self._handler_registry.get_handler_for_file(file_path) - # if handler is None: - # logging.debug(f"No handler found for file: {file_path}") - # continue - - # try: - # # Get cached file handle - # file_handle = self._file_cache.get_or_open(file_path, handler, mode="r") - # if file_handle is not None: - # # Try to read from cached handle with sub-selection - # result = handler.read_array(file_handle, path_in_external, start_indices, counts) - # if result is not None: - # return result - # except Exception as e: - # logging.debug(f"Failed to read from cached file {file_path}: {e}") - # # Remove from cache if it's causing issues - # self._file_cache.remove(file_path) - - # # Try non-cached files - # for file_path in non_cached_paths: - # handler = self._handler_registry.get_handler_for_file(file_path) - # if handler is None: - # logging.debug(f"No handler found for file: {file_path}") - # continue - - # try: - # # Try to open and read, which will add to cache if successful - # file_handle = self._file_cache.get_or_open(file_path, handler, mode="r") - # if file_handle is not None: - # result = handler.read_array(file_handle, path_in_external, start_indices, counts) - # if result is not None: - # return result - # else: - # # Cache failed, try direct read without caching - # result = handler.read_array(file_path, path_in_external, start_indices, counts) - # if result is not None: - # return result - # except Exception as e: - # logging.debug(f"Failed to read from file {file_path}: {e}") - - # logging.error(f"Failed to read array from any available file paths: {file_paths}") - # return None - def write_array( self, proxy: Union[str, Uri, Any], @@ -1904,50 +1817,6 @@ def write_array( logging.error(f"Failed to write array to any available file paths: {file_paths}") return False - # # Try to write to the first available file - # # For writes, we prefer cached files first, then non-cached - # cached_paths = [p for p in file_paths if p in self._file_cache] - # non_cached_paths = [p for p in file_paths if p not in self._file_cache] - - # # Try cached files first - # for file_path in cached_paths: - # handler = self._handler_registry.get_handler_for_file(file_path) - # if handler is None: - # continue - - # try: - # file_handle = self._file_cache.get_or_open(file_path, handler, mode="a") - # if file_handle is not None: - # success = handler.write_array(file_handle, array, path_in_external, start_indices, **kwargs) - # if success: - # return True - # except Exception as e: - # logging.debug(f"Failed to write to cached file {file_path}: {e}") - # self._file_cache.remove(file_path) - - # # Try non-cached files - # for file_path in non_cached_paths: - # handler = self._handler_registry.get_handler_for_file(file_path) - # if handler is None: - # continue - - # try: - # # Open in append mode and add to cache - # file_handle = self._file_cache.get_or_open(file_path, handler, mode="a") - # if file_handle is not None: - # success = handler.write_array(file_handle, array, path_in_external, start_indices, **kwargs) - # if success: - # return True - # else: - # # Cache failed, try direct write - # success = handler.write_array(file_path, array, path_in_external, start_indices, **kwargs) - # if success: - # return True - # except Exception as e: - # logging.error(f"Failed to write to file {file_path}: {e}") - - # return False - def get_array_metadata( self, proxy: Union[str, Uri, Any], @@ -2022,92 +1891,12 @@ def get_array_metadata( logging.debug(f"Failed to get metadata from file {file_path}: {e}") return None - # # Try cached files first - # cached_paths = [p for p in file_paths if p in self._file_cache] - # non_cached_paths = [p for p in file_paths if p not in self._file_cache] - - # for file_path in cached_paths + non_cached_paths: - # handler = self._handler_registry.get_handler_for_file(file_path) - # if handler is None: - # continue - - # try: - # file_handle = self._file_cache.get_or_open(file_path, handler, mode="r") - # source = file_handle if file_handle is not None else file_path - - # metadata_dict = handler.get_array_metadata(source, path_in_external, start_indices, counts) - - # if metadata_dict is None: - # continue - - # # Convert dict(s) to DataArrayMetadata - # if isinstance(metadata_dict, list): - # return [ - # DataArrayMetadata( - # path_in_resource=m.get("path"), - # array_type=m.get("dtype", "unknown"), - # dimensions=m.get("shape", []), - # start_indices=start_indices, - # custom_data={"size": m.get("size", 0)}, - # ) - # for m in metadata_dict - # ] - # else: - # return DataArrayMetadata( - # path_in_resource=metadata_dict.get("path"), - # array_type=metadata_dict.get("dtype", "unknown"), - # dimensions=metadata_dict.get("shape", []), - # start_indices=start_indices, - # custom_data={"size": metadata_dict.get("size", 0)}, - # ) - # except Exception as e: - # logging.debug(f"Failed to get metadata from file {file_path}: {e}") - - # return None def list_objects( self, dataspace: Optional[str] = None, object_type: Optional[str] = None ) -> List[ResourceMetadata]: return [m.to_resource_metadata() for m in self._metadata_mgr.list_metadata(qualified_type_filter=object_type)] - def list_objects_parallel( - self, dataspace: Optional[str] = None, object_type: Optional[str] = None - ) -> List[ResourceMetadata]: - # use self._metadata_mgr.list_metadata(qualified_type_filter=object_type) to get the list of metadata, - # then get_each object in parallel using get_object and return the list of ResourceMetadata for the objects that were successfully retrieved - - import concurrent.futures - from concurrent.futures import ThreadPoolExecutor, as_completed - - metadata_list = self._metadata_mgr.list_metadata(qualified_type_filter=object_type) - with ThreadPoolExecutor(max_workers=8) as executor: - future_to_metadata = {executor.submit(self.get_object, m.identifier): m for m in metadata_list} - resource_metadata_list = [] - for future in as_completed(future_to_metadata): - metadata = future_to_metadata[future] - try: - obj = future.result() - if obj is not None: - resource_metadata_list.append(metadata.to_resource_metadata()) - except Exception as e: - logging.debug(f"Failed to get object for metadata {metadata.identifier}: {e}") - return resource_metadata_list - - def list_objects_seq( - self, dataspace: Optional[str] = None, object_type: Optional[str] = None - ) -> List[ResourceMetadata]: - metadata_list = self._metadata_mgr.list_metadata(qualified_type_filter=object_type) - resource_metadata_list = [] - for metadata in metadata_list: - try: - obj = self.get_object(metadata.identifier) - if obj is not None: - resource_metadata_list.append(metadata.to_resource_metadata()) - except Exception as e: - logging.debug(f"Failed to get object for metadata {metadata.identifier}: {e}") - - return resource_metadata_list - def get_obj_rels(self, obj: Union[str, Uri, Any]) -> List[Relationship]: _id = self._id_from_uri_or_identifier(obj) From ab8dc1a9617e0f8b986e83bd2089490004e2405e Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Tue, 17 Feb 2026 04:21:09 +0100 Subject: [PATCH 51/70] toml --- energyml-utils/pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/energyml-utils/pyproject.toml b/energyml-utils/pyproject.toml index 3f4c861..1c19219 100644 --- a/energyml-utils/pyproject.toml +++ b/energyml-utils/pyproject.toml @@ -74,7 +74,7 @@ lasio = { version = "^0.31", optional = true } segyio = { version = "^1.9", optional = true } [tool.poetry.group.dev.dependencies] -pandas = { version = "^1.1.0", optional = true } +pandas = { version = "^1.1.0"} coverage = {extras = ["toml"], version = "^6.2"} pytest = "^8.1.1" pytest-cov = "^4.1.0" @@ -83,7 +83,7 @@ black = "^22.3.0" pylint = "^2.7.2" click = ">=8.1.3, <=8.1.3" # upper version than 8.0.2 fail with black pdoc3 = "^0.10.0" -pydantic = { version = "^2.0", optional = true } +pydantic = { version = "^2.0"} energyml-common2-0 = "^1.12.0" energyml-common2-1 = "^1.12.0" energyml-common2-2 = "^1.12.0" From 836edbc71460c6a06fa88270346a86d4e8ebab0d Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Tue, 17 Feb 2026 04:24:31 +0100 Subject: [PATCH 52/70] -- --- energyml-utils/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/energyml-utils/pyproject.toml b/energyml-utils/pyproject.toml index 1c19219..7f3e72b 100644 --- a/energyml-utils/pyproject.toml +++ b/energyml-utils/pyproject.toml @@ -70,11 +70,11 @@ energyml-opc = "^1.12.0" h5py = { version = "^3.7.0", optional = false } numpy = { version = "^1.16.6", optional = false } pyarrow = { version = "^14.0.1", optional = true } +pandas = { version = "^1.1.0", optional = true } lasio = { version = "^0.31", optional = true } segyio = { version = "^1.9", optional = true } [tool.poetry.group.dev.dependencies] -pandas = { version = "^1.1.0"} coverage = {extras = ["toml"], version = "^6.2"} pytest = "^8.1.1" pytest-cov = "^4.1.0" From b66bc887880f93914c9af96e7ec0d126636aa96a Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Tue, 17 Feb 2026 15:54:58 +0100 Subject: [PATCH 53/70] bugfix fo h5 file path when uri of file is given as object uri (occurs for etp support) --- energyml-utils/example/attic/arrays_test.py | 7 ++-- .../example/attic/test_list_object.py | 36 ++++++++++++------- energyml-utils/src/energyml/utils/epc.py | 14 ++++---- .../src/energyml/utils/epc_stream.py | 20 ++++++----- 4 files changed, 46 insertions(+), 31 deletions(-) diff --git a/energyml-utils/example/attic/arrays_test.py b/energyml-utils/example/attic/arrays_test.py index 7b087cc..d65913e 100644 --- a/energyml-utils/example/attic/arrays_test.py +++ b/energyml-utils/example/attic/arrays_test.py @@ -383,7 +383,8 @@ def read_wellbore_frame_repr_demo_jfr_02_26( epc_path: str = r"rc/epc/out-galaxy-12-pts.epc", well_uuid: str = "cfad9cb6-99fe-4172-b560-d2feca75dd9f", ) -> List[AbstractMesh]: - epc = Epc.read_file(f"{epc_path}", read_rels_from_files=False, recompute_rels=False) + # epc = Epc.read_file(f"{epc_path}", read_rels_from_files=False, recompute_rels=False) + epc = EpcStreamReader(f"{epc_path}", rels_update_mode=RelsUpdateMode.MANUAL) frame_repr = epc.get_object_by_uuid(well_uuid)[0] # print(frame_repr) @@ -471,7 +472,7 @@ def test_read_write_array(h5_path): # meshes = read_representation_set_representation() # meshes = read_trset() # meshes = read_pointset() - # meshes = read_wellbore_frame_repr_demo_jfr_02_26() + meshes = read_wellbore_frame_repr_demo_jfr_02_26() print(f"Number of meshes read: {len(meshes)}") @@ -494,4 +495,4 @@ def test_read_write_array(h5_path): raise e # read_props_and_cbt() - test_read_write_array("test_array_rw.h5") + # test_read_write_array("test_array_rw.h5") diff --git a/energyml-utils/example/attic/test_list_object.py b/energyml-utils/example/attic/test_list_object.py index 56dee7b..7fa4934 100644 --- a/energyml-utils/example/attic/test_list_object.py +++ b/energyml-utils/example/attic/test_list_object.py @@ -4,17 +4,25 @@ def list_epc_classical(epc_file): """List contents of an EPC file.""" - epc = EpcStreamReader(epc_file, rels_update_mode=RelsUpdateMode.MANUAL) - time_start = datetime.now() - # for obj in epc.list_objects(): - # print(f"Object: {obj}") - print(len(epc.list_objects(object_type="eml23.DataobjectCollection"))) + if not isinstance(epc_file, list): + epc_file = [epc_file] - for obj in epc.list_objects(object_type="eml23.DataobjectCollection"): - print(f"DataobjectCollection: {obj}") - time_end = datetime.now() - print(f"Time taken: {time_end - time_start}") + for f in epc_file: + print(f"Processing EPC file: {f}") + epc = EpcStreamReader(f, rels_update_mode=RelsUpdateMode.MANUAL) + + time_start = datetime.now() + # for obj in epc.list_objects(): + # print(f"Object: {obj}") + print(len(epc.list_objects(object_type="resqml22.BoundaryFeature"))) + + for obj in sorted(epc.list_objects(object_type="resqml22.BoundaryFeature"), key=lambda o: o.title): + print(f"BoundaryFeature: {obj}") + for obj in sorted(epc.list_objects(object_type="resqml22.RockVolumeFeature"), key=lambda o: o.title): + print(f"RockVolumeFeature: {obj}") + time_end = datetime.now() + print(f"Time taken: {time_end - time_start}") # def list_epc_fast(epc_file): @@ -48,12 +56,16 @@ def list_epc_classical(epc_file): if __name__ == "__main__": - epc_file = "D:/Geosiris/Clients/BRGM/git/pointset-extraction/rc/output/full-local/full-local.epc" + epc_file = [ + "D:/Geosiris/Clients/BRGM/git/pointset-extraction/rc/output/full-local/full-local.epc", + "D:/Geosiris/Clients/BRGM/git/csv-to-energyml/rc/output/full-local/result-out-local-egis-full.epc", + ] + # epc_file = "D:/Geosiris/Clients/BRGM/git/pointset-extraction/rc/output/full-local/full-local.epc" print("Listing EPC contents (classical method):") list_epc_classical(epc_file) - # print("\nListing EPC contents (fast method):") + # print("Listing EPC contents (fast method):") # list_epc_fast(epc_file) - # print("\nListing EPC contents (sequential method):") + # print("Listing EPC contents (sequential method):") # list_epc_seq(epc_file) diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index 34b8282..d69d219 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -1305,13 +1305,13 @@ def get_h5_file_paths(self, obj_or_id: Optional[Any] = None) -> List[str]: h5_paths = set(make_path_relative_to_filepath_list(list(h5_paths), self.epc_file_path)) - if len(h5_paths) == 0: - # Collect all .h5 files in the EPC file's folder - epc_folder = self.get_epc_file_folder() - if epc_folder is not None and os.path.isdir(epc_folder): - for fname in os.listdir(epc_folder): - if fname.lower().endswith(".h5"): - h5_paths.add(os.path.join(epc_folder, fname)) + # if len(h5_paths) == 0: + # Collect all .h5 files in the EPC file's folder + epc_folder = self.get_epc_file_folder() + if epc_folder is not None and os.path.isdir(epc_folder): + for fname in os.listdir(epc_folder): + if fname.lower().endswith(".h5"): + h5_paths.add(os.path.join(epc_folder, fname)) return list(h5_paths) diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index a510002..a5953f2 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -1332,7 +1332,9 @@ def get_statistics(self) -> EpcStreamingStats: """Get current statistics about the EPC streaming operations.""" return self.stats - def get_h5_file_paths(self, obj: Union[str, Uri, Any], make_path_absolute_from_epc_path: bool = True) -> List[str]: + def get_h5_file_paths( + self, obj_or_id: Union[str, Uri, Any] = None, make_path_absolute_from_epc_path: bool = True + ) -> List[str]: """ Get all HDF5 file paths referenced in the EPC file (from rels to external resources). Optimized to avoid loading the object when identifier/URI is provided. @@ -1347,7 +1349,7 @@ def get_h5_file_paths(self, obj: Union[str, Uri, Any], make_path_absolute_from_e rels_path = None - _id = self._id_from_uri_or_identifier(identifier=obj, get_first_if_simple_uuid=True) + _id = self._id_from_uri_or_identifier(identifier=obj_or_id, get_first_if_simple_uuid=True) if _id is not None: rels_path = self._metadata_mgr.gen_rels_path_from_identifier(_id) @@ -1372,13 +1374,13 @@ def get_h5_file_paths(self, obj: Union[str, Uri, Any], make_path_absolute_from_e if make_path_absolute_from_epc_path: h5_paths = set(make_path_relative_to_filepath_list(list(h5_paths), self.epc_file_path)) - if len(h5_paths) == 0: - # Collect all .h5 files in the EPC file's folder - epc_folder = get_file_folder(self.epc_file_path) - if epc_folder is not None and os.path.isdir(epc_folder): - for fname in os.listdir(epc_folder): - if fname.lower().endswith(".h5"): - h5_paths.add(os.path.join(epc_folder, fname)) + # if len(h5_paths) == 0: + # Collect all .h5 files in the EPC file's folder + epc_folder = get_file_folder(self.epc_file_path) + if epc_folder is not None and os.path.isdir(epc_folder): + for fname in os.listdir(epc_folder): + if fname.lower().endswith(".h5"): + h5_paths.add(os.path.join(epc_folder, fname)) return list(h5_paths) From af13b5ba0b134bce2d7f10b67c7289b416c0c941 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Wed, 18 Feb 2026 04:38:33 +0100 Subject: [PATCH 54/70] read Epc in parallel to increase efficiency + adding ".dat" as possible file extension for hdf5 handler and set h5Handler as default --- .../example/attic/compare_inmem_n_stream.py | 56 ++- .../src/energyml/utils/data/datasets_io.py | 17 +- .../src/energyml/utils/data/mesh.py | 8 +- energyml-utils/src/energyml/utils/epc.py | 392 +++++++++++++----- energyml-utils/tests/test_array_handlers.py | 7 + energyml-utils/tests/test_epc.py | 94 +++++ 6 files changed, 455 insertions(+), 119 deletions(-) diff --git a/energyml-utils/example/attic/compare_inmem_n_stream.py b/energyml-utils/example/attic/compare_inmem_n_stream.py index 7ddc78f..9601ce2 100644 --- a/energyml-utils/example/attic/compare_inmem_n_stream.py +++ b/energyml-utils/example/attic/compare_inmem_n_stream.py @@ -39,14 +39,31 @@ def reexport_in_memory(filepath: str, output_folder: Optional[str] = None): if output_folder: os.makedirs(output_folder, exist_ok=True) path_in_memory = f"{output_folder}/{path_in_memory.split('/')[-1]}" - epc = Epc.read_file(epc_file_path=filepath, read_rels_from_files=False) + epc = Epc.read_file(epc_file_path=filepath, read_rels_from_files=False, recompute_rels=False) if os.path.exists(path_in_memory): os.remove(path_in_memory) epc.export_file(path_in_memory) -def time_comparison(filepath: str, output_folder: Optional[str] = None, skip_sequential: bool = True): +def reexport_in_memory_par_read(filepath: str, output_folder: Optional[str] = None): + path_in_memory = filepath.replace(".epc", "_in_memory_par_read.epc") + if output_folder: + os.makedirs(output_folder, exist_ok=True) + path_in_memory = f"{output_folder}/{path_in_memory.split('/')[-1]}" + epc = Epc.read_file(epc_file_path=filepath, read_rels_from_files=False, read_parallel=True, recompute_rels=False) + + if os.path.exists(path_in_memory): + os.remove(path_in_memory) + epc.export_file(path_in_memory, parallel=True) + + +def time_comparison( + filepath: str, + output_folder: Optional[str] = None, + skip_sequential_stream: bool = True, + skip_parallel_stream: bool = True, +): """Compare performance of different EPC reexport methods.""" print(f"\n{'=' * 70}") print(f"Performance Comparison: {filepath.split('/')[-1]}") @@ -62,7 +79,15 @@ def time_comparison(filepath: str, output_folder: Optional[str] = None, skip_seq results.append(("In-Memory (Epc)", elapsed_inmem)) print(f" ✓ Completed in {elapsed_inmem:.3f}s\n") - if not skip_sequential: + # Test 1b: In-Memory with Parallel Read + print("⏳ Testing In-Memory EPC processing with Parallel Read...") + start = time.perf_counter() + reexport_in_memory_par_read(filepath, output_folder) + elapsed_inmem_par = time.perf_counter() - start + results.append(("In-Memory (Epc) Parallel Read", elapsed_inmem_par)) + print(f" ✓ Completed in {elapsed_inmem_par:.3f}s\n") + + if not skip_sequential_stream: # Test 2: Streaming Sequential print("⏳ Testing Streaming Sequential processing...") start = time.perf_counter() @@ -72,12 +97,13 @@ def time_comparison(filepath: str, output_folder: Optional[str] = None, skip_seq print(f" ✓ Completed in {elapsed_seq:.3f}s\n") # Test 3: Streaming Parallel - print("⏳ Testing Streaming Parallel processing...") - start = time.perf_counter() - reexport_stream_parallel(filepath, output_folder) - elapsed_parallel = time.perf_counter() - start - results.append(("Stream Parallel", elapsed_parallel)) - print(f" ✓ Completed in {elapsed_parallel:.3f}s\n") + if not skip_parallel_stream: + print("⏳ Testing Streaming Parallel processing...") + start = time.perf_counter() + reexport_stream_parallel(filepath, output_folder) + elapsed_parallel = time.perf_counter() - start + results.append(("Stream Parallel", elapsed_parallel)) + print(f" ✓ Completed in {elapsed_parallel:.3f}s\n") # Calculate speedups results_sorted = sorted(results, key=lambda x: x[1]) @@ -119,15 +145,15 @@ def time_comparison(filepath: str, output_folder: Optional[str] = None, skip_seq update_prop_kind_dict_cache() - time_comparison( - filepath=sys.argv[1] if len(sys.argv) > 1 else "rc/epc/testingPackageCpp22.epc", - output_folder="rc/performance_results", - ) - # time_comparison( - # filepath=sys.argv[1] if len(sys.argv) > 1 else "rc/epc/80wells_surf.epc", output_folder="rc/performance_results" + # filepath=sys.argv[1] if len(sys.argv) > 1 else "rc/epc/testingPackageCpp22.epc", + # output_folder="rc/performance_results", # ) + time_comparison( + filepath=sys.argv[1] if len(sys.argv) > 1 else "rc/epc/80wells_surf.epc", output_folder="rc/performance_results" + ) + # time_comparison( # filepath=sys.argv[1] if len(sys.argv) > 1 else "wip/failingData/fix/sample_mini_firp_201_norels_with_media.epc", # output_folder="rc/performance_results", diff --git a/energyml-utils/src/energyml/utils/data/datasets_io.py b/energyml-utils/src/energyml/utils/data/datasets_io.py index c521535..0a72d4c 100644 --- a/energyml-utils/src/energyml/utils/data/datasets_io.py +++ b/energyml-utils/src/energyml/utils/data/datasets_io.py @@ -756,9 +756,11 @@ def _register_default_handlers(self, max_open_files: int) -> None: """Register all available handlers based on installed dependencies.""" # HDF5 Handler if __H5PY_MODULE_EXISTS__: - self.register_handler([".h5", ".hdf5"], lambda: HDF5ArrayHandler()) + self.register_handler([".h5", ".hdf5", ".dat"], lambda: HDF5ArrayHandler()) # dat for Galaxy compatibility else: - self.register_handler([".h5", ".hdf5"], lambda: MockHDF5ArrayHandler()) + self.register_handler( + [".h5", ".hdf5", ".dat"], lambda: MockHDF5ArrayHandler() + ) # dat for Galaxy compatibility # Parquet Handler if __PARQUET_MODULE_EXISTS__: @@ -802,13 +804,18 @@ def get_handler_for_file(self, file_path: str) -> Optional[ExternalArrayHandler] file_path: Path to the file Returns: - Handler instance, or None if no handler registered for this extension + Handler instance, or h5 handler if extension not found but h5 handler is available and not mock, else None """ ext = os.path.splitext(file_path)[1].lower() if ext in self._handlers: return self._handlers[ext]() + # search for h5 handler if not mock and return it by default + if ".h5" in self._handlers: + h = self._handlers[".h5"]() + if "mock" not in h.__class__.__name__.lower(): + return self._handlers[".h5"]() return None def supports_extension(self, extension: str) -> bool: @@ -973,7 +980,7 @@ def list_arrays(self, source: Union[BytesIO, str, Any]) -> List[str]: def can_handle_file(self, file_path: str) -> bool: """Check if this handler can process the file.""" ext = os.path.splitext(file_path)[1].lower() - return ext in [".h5", ".hdf5"] + return ext in [".h5", ".hdf5", ".dat"] # dat for Galaxy compatibility else: @@ -1019,7 +1026,7 @@ def list_arrays(self, source: Union[BytesIO, str, Any]) -> List[str]: raise MissingExtraInstallation(extra_name="hdf5") def can_handle_file(self, file_path: str) -> bool: - return os.path.splitext(file_path)[1].lower() in [".h5", ".hdf5"] + return os.path.splitext(file_path)[1].lower() in [".h5", ".hdf5", ".dat"] # dat for Galaxy compatibility # Parquet Handler diff --git a/energyml-utils/src/energyml/utils/data/mesh.py b/energyml-utils/src/energyml/utils/data/mesh.py index bf25a64..0695152 100644 --- a/energyml-utils/src/energyml/utils/data/mesh.py +++ b/energyml-utils/src/energyml/utils/data/mesh.py @@ -241,7 +241,7 @@ def read_mesh_object( ): # WellboreFrameRep has allready the displacement applied # TODO: the displacement should be done in each reader function to manage specific cases for s in surfaces: - print("CRS : ", s.crs_object.uuid if s.crs_object is not None else "None") + logging.debug(f"CRS : {s.crs_object.uuid if s.crs_object is not None else 'None'}") crs_displacement(s.point_list, s.crs_object) return surfaces else: @@ -833,7 +833,11 @@ def read_wellbore_trajectory_representation( # Get CRS from trajectory geometry if available try: - crs = workspace.get_object(get_obj_uri(get_object_attribute(energyml_object, "geometry.LocalCrs"))) + crs_attr = get_object_attribute(energyml_object, "geometry.LocalCrs") + if crs_attr is not None: + crs = workspace.get_object(get_obj_uri(crs_attr)) + else: + raise ObjectNotFoundNotError("LocalCrs attribute not found in trajectory geometry") except Exception: logging.debug("Could not get CRS from trajectory geometry") diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index d69d219..f308008 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -707,6 +707,37 @@ def wrapper(*args, **kwargs): return wrapper +# --- HELPER FUNCTIONS (HORS CLASSE) --- + + +def _parallel_xml_read(xml_data: bytes, content_type: str): + try: + + target_class = get_class_from_content_type(content_type) + obj = read_energyml_xml_bytes(xml_data, target_class) + + if isinstance(obj, DerivedElement): + obj = obj.value + return obj + except Exception as e: + return e + + +def _parallel_rels_read(rels_bytes: bytes): + try: + return read_energyml_xml_bytes(rels_bytes, Relationships) + except Exception as e: + return e + + +def _parallel_xml_serialize(obj): + """Sérialise un objet Python en XML bytes dans un processus séparé.""" + try: + return serialize_xml(obj) + except Exception as e: + return e + + @dataclass class Epc(EnergymlStorageInterface): """ @@ -840,98 +871,6 @@ def add_file(self, obj: Union[List, bytes, BytesIO, str, RawFile]): else: logging.error(f"unsupported type {str(type(obj))}") - # EXPORT functions - - def gen_opc_content_type(self) -> Types: - """ - Generates a :class:`Types` instance and fill it with energyml objects :class:`Override` values - :return: - """ - ct = create_default_types() - - for e_obj in self.energyml_objects: - ct.override.append( - Override( - content_type=get_content_type_from_class(type(e_obj)), - part_name=gen_energyml_object_path(e_obj, self.export_version), - ) - ) - - for rf in self.raw_files: - # file_extension = os.path.splitext(file_path)[1].lstrip(".").lower() - mime_type = in_epc_file_path_to_mime_type(rf.path) - if mime_type: - override = Override(content_type=mime_type, part_name=f"{rf.path}") - ct.override.append(override) - - return ct - - # @log_timestamp - def export_file( - self, path: Optional[str] = None, allowZip64: bool = True, force_recompute_object_rels: bool = True - ) -> None: - """ - Export the epc file. If :param:`path` is None, the epc 'self.epc_file_path' is used - :param path: - :return: - """ - if path is None: - path = self.epc_file_path - - if path is None: - raise ValueError("No path provided and epc_file_path is not set") - - # Ensure directory exists - Path(path).parent.mkdir(parents=True, exist_ok=True) - - with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED, allowZip64=allowZip64) as zip_file: - self._export_io( - zip_file=zip_file, allowZip64=allowZip64, force_recompute_object_rels=force_recompute_object_rels - ) - - def export_io(self, allowZip64: bool = True, force_recompute_object_rels: bool = True) -> BytesIO: - """ - Export the epc file into a :class:`BytesIO` instance. The result is an 'in-memory' zip file. - :return: - """ - zip_buffer = BytesIO() - - with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED, allowZip64=allowZip64) as zip_file: - self._export_io( - zip_file=zip_file, allowZip64=allowZip64, force_recompute_object_rels=force_recompute_object_rels - ) - - return zip_buffer - - def _export_io( - self, zip_file: zipfile.ZipFile, allowZip64: bool = True, force_recompute_object_rels: bool = True - ) -> None: - """ - Export the epc file into a :class:`BytesIO` instance. The result is an 'in-memory' zip file. - :return: - """ - # CoreProps - if self.core_props is None: - self.core_props = create_default_core_properties() - - zip_file.writestr(gen_core_props_path(self.export_version), serialize_xml(self.core_props)) - - # Energyml objects - for e_obj in self.energyml_objects: - e_path = gen_energyml_object_path(e_obj, self.export_version) - zip_file.writestr(e_path, serialize_xml(e_obj)) - - # Rels - for rels_path, rels in self.compute_rels(force_recompute_object_rels=force_recompute_object_rels).items(): - zip_file.writestr(rels_path, serialize_xml(rels)) - - # Other files: - for raw in self.raw_files: - zip_file.writestr(raw.path, raw.content.read()) - - # ContentType - zip_file.writestr(get_epc_content_type_path(), serialize_xml(self.gen_opc_content_type())) - # === Relationships management functions === def add_rels_for_object( @@ -1437,24 +1376,190 @@ def close(self) -> None: """ pass + # EXPORT functions + + def gen_opc_content_type(self) -> Types: + """ + Generates a :class:`Types` instance and fill it with energyml objects :class:`Override` values + :return: + """ + ct = create_default_types() + + for e_obj in self.energyml_objects: + ct.override.append( + Override( + content_type=get_content_type_from_class(type(e_obj)), + part_name=gen_energyml_object_path(e_obj, self.export_version), + ) + ) + + for rf in self.raw_files: + # file_extension = os.path.splitext(file_path)[1].lstrip(".").lower() + mime_type = in_epc_file_path_to_mime_type(rf.path) + if mime_type: + override = Override(content_type=mime_type, part_name=f"{rf.path}") + ct.override.append(override) + + return ct + + # @log_timestamp + def export_file( + self, + path: Optional[str] = None, + allowZip64: bool = True, + force_recompute_object_rels: bool = True, + parallel: bool = False, + ) -> None: + """ + Export the epc file. If :param:`path` is None, the epc 'self.epc_file_path' is used + :param path: + :return: + """ + if path is None: + path = self.epc_file_path + + if path is None: + raise ValueError("No path provided and epc_file_path is not set") + + # Ensure directory exists + Path(path).parent.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED, allowZip64=allowZip64) as zip_file: + if parallel: + self._export_io_ultra_fast(zip_file=zip_file, force_recompute_object_rels=force_recompute_object_rels) + else: + self._export_io( + zip_file=zip_file, allowZip64=allowZip64, force_recompute_object_rels=force_recompute_object_rels + ) + + def export_io(self, allowZip64: bool = True, force_recompute_object_rels: bool = True) -> BytesIO: + """ + Export the epc file into a :class:`BytesIO` instance. The result is an 'in-memory' zip file. + :return: + """ + zip_buffer = BytesIO() + + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED, allowZip64=allowZip64) as zip_file: + self._export_io( + zip_file=zip_file, allowZip64=allowZip64, force_recompute_object_rels=force_recompute_object_rels + ) + + return zip_buffer + + def _export_io( + self, zip_file: zipfile.ZipFile, allowZip64: bool = True, force_recompute_object_rels: bool = True + ) -> None: + """ + Export the epc file into a :class:`BytesIO` instance. The result is an 'in-memory' zip file. + :return: + """ + # CoreProps + if self.core_props is None: + self.core_props = create_default_core_properties() + + zip_file.writestr(gen_core_props_path(self.export_version), serialize_xml(self.core_props)) + + # Energyml objects + for e_obj in self.energyml_objects: + e_path = gen_energyml_object_path(e_obj, self.export_version) + zip_file.writestr(e_path, serialize_xml(e_obj)) + + # Rels + for rels_path, rels in self.compute_rels(force_recompute_object_rels=force_recompute_object_rels).items(): + zip_file.writestr(rels_path, serialize_xml(rels)) + + # Other files: + for raw in self.raw_files: + zip_file.writestr(raw.path, raw.content.read()) + + # ContentType + zip_file.writestr(get_epc_content_type_path(), serialize_xml(self.gen_opc_content_type())) + + def _export_io_ultra_fast(self, zip_file: zipfile.ZipFile, force_recompute_object_rels: bool = True) -> None: + import multiprocessing + from concurrent.futures import ProcessPoolExecutor, as_completed + + # 1. Préparation des données + if self.core_props is None: + self.core_props = create_default_core_properties() + + # On prépare la liste des objets à sérialiser + # Format: { future: (path_dans_le_zip) } + serialization_tasks = {} + + cpus = multiprocessing.cpu_count() + with ProcessPoolExecutor(max_workers=cpus) as executor: + + # A. On lance la sérialisation des objets EnergyML + for e_obj in self.energyml_objects: + e_path = gen_energyml_object_path(e_obj, self.export_version) + future = executor.submit(_parallel_xml_serialize, e_obj) + serialization_tasks[future] = e_path + + # B. On lance la sérialisation des Rels + # Note: on compute les rels dans le thread principal car c'est souvent + # une opération de logique métier sur le graphe d'objets. + computed_rels = self.compute_rels(force_recompute_object_rels=force_recompute_object_rels) + for rels_path, rels_obj in computed_rels.items(): + future = executor.submit(_parallel_xml_serialize, rels_obj) + serialization_tasks[future] = rels_path + + # C. Cas particuliers (souvent rapides, mais autant les paralléliser) + core_path = gen_core_props_path(self.export_version) + future_core = executor.submit(_parallel_xml_serialize, self.core_props) + serialization_tasks[future_core] = core_path + + ct_path = get_epc_content_type_path() + future_ct = executor.submit(_parallel_xml_serialize, self.gen_opc_content_type()) + serialization_tasks[future_ct] = ct_path + + # 2. Récupération et Écriture I/O (Séquentielle mais rapide) + # On écrit dans le ZIP au fur et à mesure que les sérialisations se terminent + for future in as_completed(serialization_tasks): + path = serialization_tasks[future] + xml_bytes = future.result() + + if isinstance(xml_bytes, Exception): + logging.error(f"Erreur sérialisation sur {path}: {xml_bytes}") + else: + zip_file.writestr(path, xml_bytes) + + # 3. Fichiers bruts (Raw files) + # Ils sont déjà en bytes (BytesIO), donc pas besoin de paralléliser + for raw in self.raw_files: + raw.content.seek(0) # Reset du curseur par sécurité + zip_file.writestr(raw.path, raw.content.read()) + # ============== # Class methods # ============== @classmethod # @log_timestamp - def read_file(cls, epc_file_path: str, read_rels_from_files: bool = True, recompute_rels: bool = False) -> "Epc": + def read_file( + cls, + epc_file_path: str, + read_rels_from_files: bool = True, + recompute_rels: bool = False, + read_parallel: bool = False, + ) -> "Epc": """ Read an EPC file from disk. :param epc_file_path: Path to the EPC file :param read_rels_from_files: If True, populate cache from .rels files in the EPC :param recompute_rels: If True, recompute all relationships after loading + :param read_parallel: If True, read the EPC file in parallel :return: Epc instance """ with open(epc_file_path, "rb") as f: - epc = cls.read_stream( - BytesIO(f.read()), read_rels_from_files=read_rels_from_files, recompute_rels=recompute_rels - ) + if read_parallel: + epc = cls.read_stream_ultra_fast( + BytesIO(f.read()), read_rels_from_files=read_rels_from_files, recompute_rels=recompute_rels + ) + else: + epc = cls.read_stream( + BytesIO(f.read()), read_rels_from_files=read_rels_from_files, recompute_rels=recompute_rels + ) epc.epc_file_path = epc_file_path return epc raise IOError(f"Failed to open EPC file {epc_file_path}") @@ -1462,7 +1567,7 @@ def read_file(cls, epc_file_path: str, read_rels_from_files: bool = True, recomp @classmethod def read_stream( cls, epc_file_io: BytesIO, read_rels_from_files: bool = True, recompute_rels: bool = False - ): # returns an Epc instance + ) -> Optional["Epc"]: # returns an Epc instance """ Read an EPC file from a BytesIO stream. :param epc_file_io: BytesIO containing the EPC file @@ -1615,6 +1720,99 @@ def read_stream( return None + @classmethod + def read_stream_ultra_fast( + cls, epc_file_io: BytesIO, read_rels_from_files: bool = True, recompute_rels: bool = False + ) -> Optional["Epc"]: + from concurrent.futures import ProcessPoolExecutor, as_completed + import multiprocessing + + obj_to_process = {} + rels_to_process = {} + raw_files = [] + core_props = None + + # 1. Lecture rapide et extraction des bytes du ZIP + with zipfile.ZipFile(epc_file_io, "r") as epc_file: + ct_path = get_epc_content_type_path() + content_type_obj = read_energyml_xml_bytes(epc_file.read(ct_path)) + + # Identification des types via le ContentTypes + energyml_paths = {} + for ov in content_type_obj.override: + path = ov.part_name.lstrip("/\\") + if is_energyml_content_type(ov.content_type): + energyml_paths[path] = ov.content_type + elif get_class_from_content_type(ov.content_type) == CoreProperties: + core_props = read_energyml_xml_bytes(epc_file.read(path), CoreProperties) + + # Extraction des contenus bruts + for info in epc_file.infolist(): + fname = info.filename + if fname in energyml_paths: + obj_to_process[fname] = (epc_file.read(fname), energyml_paths[fname]) + elif read_rels_from_files and fname.lower().endswith(".rels") and fname != "_rels/.rels": + rels_to_process[fname] = epc_file.read(fname) + elif ( + not fname.lower().endswith(".rels") + and not fname.lower().endswith(gen_core_props_path().lower()) + and fname not in energyml_paths + and fname != ct_path + ): + raw_files.append(RawFile(path=fname, content=BytesIO(epc_file.read(fname)))) + + # 2. Exécution Parallèle (Objets ET Rels) + path_to_obj = {} + obj_list = [] + rels_content_map = {} # {obj_path: Relationships_Object} + + cpus = multiprocessing.cpu_count() + with ProcessPoolExecutor(max_workers=cpus) as executor: + # A. On lance les objets + obj_futures = { + executor.submit(_parallel_xml_read, data, ct): path for path, (data, ct) in obj_to_process.items() + } + + # B. On lance les rels + rel_futures = { + executor.submit(_parallel_rels_read, r_data): r_path for r_path, r_data in rels_to_process.items() + } + + # C. Récupération des objets + for future in as_completed(obj_futures): + path = obj_futures[future] + res = future.result() + if not isinstance(res, Exception): + path_to_obj[path] = res + obj_list.append(res) + else: + logging.error(f"Erreur objet {path}: {res}") + + # D. Récupération des rels + for future in as_completed(rel_futures): + r_path = rel_futures[future] + res = future.result() + if not isinstance(res, Exception): + # Mapping rel_path -> obj_path + o_path = str(Path(r_path).parent.parent / Path(r_path).stem).replace("\\", "/") + rels_content_map[o_path] = res + else: + logging.error(f"Erreur rels {r_path}: {res}") + + # 3. Assemblage final dans le processus parent + epc = Epc(energyml_objects=EnergymlObjectCollection(obj_list), raw_files=raw_files, core_props=core_props) + + if read_rels_from_files: + for obj_path, rels_obj in rels_content_map.items(): + if obj_path in path_to_obj: + target_obj = path_to_obj[obj_path] + epc._rels_cache.set_rels_from_file(target_obj, rels_obj) + + if recompute_rels: + epc._rels_cache.recompute_cache() + + return epc + # ______ __ ____ __ _ # / ____/___ ___ _________ ___ ______ ___ / / / __/_ ______ _____/ /_(_)___ ____ _____ diff --git a/energyml-utils/tests/test_array_handlers.py b/energyml-utils/tests/test_array_handlers.py index 3043dee..c69d00e 100644 --- a/energyml-utils/tests/test_array_handlers.py +++ b/energyml-utils/tests/test_array_handlers.py @@ -9,6 +9,7 @@ CSVArrayHandler, LASArrayHandler, SEGYArrayHandler, + get_handler_registry, ) @@ -20,6 +21,12 @@ def is_h5py_file_closed(h5file): return True +def test_default_handler_from_registry_is_h5(): + """Test that the default handler for .h5 is HDF5ArrayHandler.""" + handler = get_handler_registry().get_handler_for_file("") # no extension, should return default .h5 handler + assert isinstance(handler, HDF5ArrayHandler), "Default handler for .h5 should be HDF5ArrayHandler" + + def test_hdf5_array_handler_read_write(): """Test HDF5ArrayHandler read/write and file closure.""" arr = np.arange(6).reshape(2, 3) diff --git a/energyml-utils/tests/test_epc.py b/energyml-utils/tests/test_epc.py index 688dd16..643a6c3 100644 --- a/energyml-utils/tests/test_epc.py +++ b/energyml-utils/tests/test_epc.py @@ -591,6 +591,22 @@ def test_relationships_in_exported_file(self, temp_epc_file, sample_objects): # After reload, relationships are stored in additional_rels assert len(epc2) == 2 + def test_relationships_in_exported_file_parallel(self, temp_epc_file, sample_objects): + """Test that relationships are correctly written to exported file.""" + epc = Epc() + bf = sample_objects["bf"] + bfi = sample_objects["bfi"] + + epc.add_object(bf) + epc.add_object(bfi) + epc.export_file(temp_epc_file) + + # Reload and check relationships + epc2 = Epc.read_file(temp_epc_file, read_parallel=True) + + # After reload, relationships are stored in additional_rels + assert len(epc2) == 2 + class TestDORCreation: """Test DataObjectReference creation.""" @@ -872,6 +888,67 @@ def test_get_h5_file_paths(self, sample_objects): h5_paths = epc.get_h5_file_paths(trset) assert "data/geometry.h5" in h5_paths + # Test persistance of additional relationships through export and reload + def test_additional_rels_persistence(self, temp_epc_file, sample_objects): + """Test that additional relationships persist through export and reload.""" + from energyml.opc.opc import Relationship + + epc = Epc() + bf = sample_objects["bf"] + epc.add_object(bf) + + identifier = get_obj_uri(bf) + + # Add external resource relationship + h5_rel = Relationship( + target="data/external.h5", + type_value=str(EPCRelsRelationshipType.EXTERNAL_RESOURCE), + id=f"_external_{identifier}", + ) + + epc.add_rels_for_object(identifier, [h5_rel]) + + # Export + epc.export_file(temp_epc_file) + + # Reload + epc2 = Epc.read_file(temp_epc_file) + + # Check that additional rels are still present + assert identifier in epc2._rels_cache._supplemental_rels + assert len(epc2._rels_cache._supplemental_rels[identifier]) == 1 + assert epc2._rels_cache._supplemental_rels[identifier][0].target == "data/external.h5" + + def test_additional_rels_persistence_parallel(self, temp_epc_file, sample_objects): + """Test that additional relationships persist through export and reload with parallel reading.""" + from energyml.opc.opc import Relationship + + epc = Epc() + bf = sample_objects["bf"] + epc.add_object(bf) + + identifier = get_obj_uri(bf) + + # Add external resource relationship + h5_rel = Relationship( + target="data/external.h5", + type_value=str(EPCRelsRelationshipType.EXTERNAL_RESOURCE), + id=f"_external_{identifier}", + ) + + epc.add_rels_for_object(identifier, [h5_rel]) + + # Export + epc.export_file(temp_epc_file) + + # Reload with parallel reading + epc2 = Epc.read_file(temp_epc_file, read_parallel=True) + + # Check that additional rels are still present + assert identifier in epc2._rels_cache._supplemental_rels + assert len(epc2._rels_cache._supplemental_rels[identifier]) == 1 + assert epc2._rels_cache._supplemental_rels[identifier][0].target == "data/external.h5" + class TestEdgeCases: """Test edge cases and error handling.""" @@ -969,6 +1046,23 @@ def test_custom_core_props(self, temp_epc_file, sample_objects): epc2 = Epc.read_file(temp_epc_file) assert epc2.core_props is not None + def test_custom_core_props_parallel(self, temp_epc_file, sample_objects): + """Test setting custom core properties.""" + from energyml.opc.opc import CoreProperties, Creator + + core_props = CoreProperties( + creator=Creator(any_element="Test Creator"), + ) + + epc = Epc(core_props=core_props) + epc.add_object(sample_objects["bf"]) + + epc.export_file(temp_epc_file) + + # Reload and verify + epc2 = Epc.read_file(temp_epc_file, read_parallel=True) + assert epc2.core_props is not None + if __name__ == "__main__": pytest.main([__file__, "-v"]) From 9f73fb7f2dd9b7ad380c8a3d27f4290be5ae092a Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Wed, 18 Feb 2026 05:29:34 +0100 Subject: [PATCH 55/70] dat is not set to csv anymore --- .../src/energyml/utils/data/datasets_io.py | 20 ++++++++----------- energyml-utils/tests/test_array_handlers.py | 6 ++++++ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/energyml-utils/src/energyml/utils/data/datasets_io.py b/energyml-utils/src/energyml/utils/data/datasets_io.py index 0a72d4c..1286d96 100644 --- a/energyml-utils/src/energyml/utils/data/datasets_io.py +++ b/energyml-utils/src/energyml/utils/data/datasets_io.py @@ -600,9 +600,7 @@ def read_dataset( isinstance(source, str) and (source.lower().endswith(".parquet") or source.lower().endswith(".pqt")) ): file_reader = ParquetFileReader() - elif "csv" in mimetype or ( - isinstance(source, str) and (source.lower().endswith(".csv") or source.lower().endswith(".dat")) - ): + elif "csv" in mimetype or (isinstance(source, str) and (source.lower().endswith(".csv"))): file_reader = CSVFileReader() else: file_reader = HDF5FileReader() # default is hdf5 @@ -756,11 +754,9 @@ def _register_default_handlers(self, max_open_files: int) -> None: """Register all available handlers based on installed dependencies.""" # HDF5 Handler if __H5PY_MODULE_EXISTS__: - self.register_handler([".h5", ".hdf5", ".dat"], lambda: HDF5ArrayHandler()) # dat for Galaxy compatibility + self.register_handler([".h5", ".hdf5"], lambda: HDF5ArrayHandler()) # dat for Galaxy compatibility else: - self.register_handler( - [".h5", ".hdf5", ".dat"], lambda: MockHDF5ArrayHandler() - ) # dat for Galaxy compatibility + self.register_handler([".h5", ".hdf5"], lambda: MockHDF5ArrayHandler()) # dat for Galaxy compatibility # Parquet Handler if __PARQUET_MODULE_EXISTS__: @@ -770,7 +766,7 @@ def _register_default_handlers(self, max_open_files: int) -> None: # CSV Handler - always available (uses Python's csv module) if __CSV_MODULE_EXISTS__: - self.register_handler([".csv", ".txt", ".dat"], lambda: CSVArrayHandler()) + self.register_handler([".csv", ".txt"], lambda: CSVArrayHandler()) # LAS Handler if __LASIO_MODULE_EXISTS__: @@ -980,7 +976,7 @@ def list_arrays(self, source: Union[BytesIO, str, Any]) -> List[str]: def can_handle_file(self, file_path: str) -> bool: """Check if this handler can process the file.""" ext = os.path.splitext(file_path)[1].lower() - return ext in [".h5", ".hdf5", ".dat"] # dat for Galaxy compatibility + return ext in [".h5", ".hdf5"] # dat for Galaxy compatibility else: @@ -1026,7 +1022,7 @@ def list_arrays(self, source: Union[BytesIO, str, Any]) -> List[str]: raise MissingExtraInstallation(extra_name="hdf5") def can_handle_file(self, file_path: str) -> bool: - return os.path.splitext(file_path)[1].lower() in [".h5", ".hdf5", ".dat"] # dat for Galaxy compatibility + return os.path.splitext(file_path)[1].lower() in [".h5", ".hdf5"] # dat for Galaxy compatibility # Parquet Handler @@ -1223,7 +1219,7 @@ def can_handle_file(self, file_path: str) -> bool: if __CSV_MODULE_EXISTS__: class CSVArrayHandler(ExternalArrayHandler): - """Handler for CSV files (.csv, .txt, .dat).""" + """Handler for CSV files (.csv, .txt).""" def __init__(self, max_open_files: int = 3): super().__init__(max_open_files=max_open_files) @@ -1307,7 +1303,7 @@ def list_arrays(self, source: Union[BytesIO, str, Any]) -> List[str]: def can_handle_file(self, file_path: str) -> bool: """Check if this handler can process the file.""" ext = os.path.splitext(file_path)[1].lower() - return ext in [".csv", ".txt", ".dat"] + return ext in [".csv", ".txt"] # LAS Handler diff --git a/energyml-utils/tests/test_array_handlers.py b/energyml-utils/tests/test_array_handlers.py index c69d00e..6d53add 100644 --- a/energyml-utils/tests/test_array_handlers.py +++ b/energyml-utils/tests/test_array_handlers.py @@ -27,6 +27,12 @@ def test_default_handler_from_registry_is_h5(): assert isinstance(handler, HDF5ArrayHandler), "Default handler for .h5 should be HDF5ArrayHandler" +def test_default_dat_handler_from_registry_is_h5(): + """Test that the default handler for .h5 is HDF5ArrayHandler.""" + handler = get_handler_registry().get_handler_for_file(".dat") # no extension, should return default .h5 handler + assert isinstance(handler, HDF5ArrayHandler), "Default handler for .h5 should be HDF5ArrayHandler" + + def test_hdf5_array_handler_read_write(): """Test HDF5ArrayHandler read/write and file closure.""" arr = np.arange(6).reshape(2, 3) From bdc90fdb2f2f06785ba68967793b0fa35a4c5836 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Wed, 18 Feb 2026 12:24:55 +0100 Subject: [PATCH 56/70] avoid exception if no crs for grids --- energyml-utils/example/attic/main_data.py | 39 ++++++++++--------- .../src/energyml/utils/data/helper.py | 19 +++++---- .../src/energyml/utils/data/mesh.py | 7 +++- 3 files changed, 37 insertions(+), 28 deletions(-) diff --git a/energyml-utils/example/attic/main_data.py b/energyml-utils/example/attic/main_data.py index 52ff8ee..8cc3e33 100644 --- a/energyml-utils/example/attic/main_data.py +++ b/energyml-utils/example/attic/main_data.py @@ -11,11 +11,11 @@ ) from energyml.utils.data.export import export_obj -from src.energyml.utils.data.helper import ( +from energyml.utils.data.helper import ( get_array_reader_function, read_array, ) -from src.energyml.utils.data.mesh import ( +from energyml.utils.data.mesh import ( GeoJsonGeometryType, MeshFileFormat, _create_shape, @@ -24,29 +24,29 @@ export_off, read_mesh_object, ) -from src.energyml.utils.epc import gen_energyml_object_path -from src.energyml.utils.introspection import ( +from energyml.utils.epc import gen_energyml_object_path +from energyml.utils.introspection import ( get_object_attribute, is_abstract, get_obj_uuid, search_attribute_matching_name_with_path, ) -from src.energyml.utils.manager import get_sub_classes -from src.energyml.utils.serialization import ( +from energyml.utils.manager import get_sub_classes +from energyml.utils.serialization import ( read_energyml_xml_file, read_energyml_xml_str, read_energyml_xml_bytes, read_energyml_xml_tree, ) -from src.energyml.utils.validation import validate_epc -from src.energyml.utils.xml import get_tree -from src.energyml.utils.data.datasets_io import ( +from energyml.utils.validation import validate_epc +from energyml.utils.xml import get_tree +from energyml.utils.data.datasets_io import ( HDF5FileReader, get_path_in_external_with_path, get_external_file_path_from_external_path, ) from energyml.utils.epc import Epc -from src.energyml.utils.data.mesh import ( +from energyml.utils.data.mesh import ( read_polyline_representation, read_point_representation, read_grid2d_representation, @@ -165,7 +165,8 @@ def read_h5_polyline(): def read_h5_grid2d_bis(): - path = "../rc/obj_Grid2dRepresentation_7c43bad9-4cad-4ab0-bb50-9afb24a4b883.xml" + path = "rc/obj_Grid2dRepresentation_7c43bad9-4cad-4ab0-bb50-9afb24a4b883.xml" + # path = "../rc/obj_Grid2dRepresentation_7c43bad9-4cad-4ab0-bb50-9afb24a4b883.xml" xml_content = "" with open(path, "r") as f: @@ -179,12 +180,12 @@ def read_h5_grid2d_bis(): ) uuid = get_obj_uuid(grid) print("Exporting") - with open(f"result/grid2d_{uuid}.obj", "wb") as f: + with open(f"rc/result/grid2d_{uuid}.obj", "wb") as f: export_obj( mesh_list=grid_list, out=f, ) - with open(f"result/grid2d_{uuid}_bis.off", "wb") as f: + with open(f"rc/result/grid2d_{uuid}_bis.off", "wb") as f: export_off( mesh_list=grid_list, out=f, @@ -206,12 +207,12 @@ def read_h5_grid2d_ter(): ) uuid = get_obj_uuid(grid) print("Exporting") - with open(f"result/grid2d_{uuid}.obj", "wb") as f: + with open(f"rc/result/grid2d_{uuid}.obj", "wb") as f: export_obj( mesh_list=grid_list, out=f, ) - with open(f"result/grid2d_{uuid}_bis.off", "wb") as f: + with open(f"rc/result/grid2d_{uuid}_bis.off", "wb") as f: export_off( mesh_list=grid_list, out=f, @@ -248,12 +249,12 @@ def read_h5_grid2d(): # keep_holes=False ) print("Exporting") - with open(f"result/grid2d_{uuid}.obj", "wb") as f: + with open(f"rc/result/grid2d_{uuid}.obj", "wb") as f: export_obj( mesh_list=grid_list, out=f, ) - with open(f"result/grid2d_{uuid}.off", "wb") as f: + with open(f"rc/result/grid2d_{uuid}.off", "wb") as f: export_off( mesh_list=grid_list, out=f, @@ -272,12 +273,12 @@ def read_meshes(): workspace=epc22, ) print("Exporting") - with open(f"result/{gen_energyml_object_path(energyml_obj)}.obj", "wb") as f: + with open(f"rc/result/{gen_energyml_object_path(energyml_obj)}.obj", "wb") as f: export_obj( mesh_list=mesh_list, out=f, ) - with open(f"result/{gen_energyml_object_path(energyml_obj)}.off", "wb") as f: + with open(f"rc/result/{gen_energyml_object_path(energyml_obj)}.off", "wb") as f: export_off( mesh_list=mesh_list, out=f, diff --git a/energyml-utils/src/energyml/utils/data/helper.py b/energyml-utils/src/energyml/utils/data/helper.py index 388f14c..a5f319d 100644 --- a/energyml-utils/src/energyml/utils/data/helper.py +++ b/energyml-utils/src/energyml/utils/data/helper.py @@ -473,7 +473,8 @@ def get_crs_obj( crs = workspace.get_object(get_obj_uri(crs_list[0])) if crs is None: # logging.debug(f"CRS {crs_list[0]} not found (or not read correctly)") - crs = workspace.get_object_by_uuid(get_obj_uuid(crs_list[0])) + _crs_list = workspace.get_object_by_uuid(get_obj_uuid(crs_list[0])) + crs = _crs_list[0] if _crs_list is not None and len(_crs_list) > 0 else None if crs is None: logging.error(f"CRS {crs_list[0]} not found (or not read correctly)") raise ObjectNotFoundNotError(get_obj_uri(crs_list[0])) @@ -883,12 +884,16 @@ def read_external_array( """ array = None if workspace is not None: - crs = get_crs_obj( - context_obj=root_obj, - root_obj=root_obj, - path_in_root=path_in_root, - workspace=workspace, - ) + crs = None + try: + get_crs_obj( + context_obj=root_obj, + root_obj=root_obj, + path_in_root=path_in_root, + workspace=workspace, + ) + except ObjectNotFoundNotError as e: + logging.debug(f"CRS not found for {get_obj_title(root_obj)}: {e}") # Search for ExternalDataArrayPart type objects (RESQML v2.2) external_parts = search_attribute_matching_type( diff --git a/energyml-utils/src/energyml/utils/data/mesh.py b/energyml-utils/src/energyml/utils/data/mesh.py index 0695152..11d6776 100644 --- a/energyml-utils/src/energyml/utils/data/mesh.py +++ b/energyml-utils/src/energyml/utils/data/mesh.py @@ -241,8 +241,11 @@ def read_mesh_object( ): # WellboreFrameRep has allready the displacement applied # TODO: the displacement should be done in each reader function to manage specific cases for s in surfaces: - logging.debug(f"CRS : {s.crs_object.uuid if s.crs_object is not None else 'None'}") - crs_displacement(s.point_list, s.crs_object) + logging.debug(f"CRS : {s.crs_object}") + crs_displacement( + s.point_list, + s.crs_object[0] if isinstance(s.crs_object, list) and len(s.crs_object) > 0 else s.crs_object, + ) return surfaces else: # logging.error(f"Type {array_type_name} is not supported: function read_{snake_case(array_type_name)} not found") From 52243d490c727cacc3b15496bc2ca6d043d11151 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Thu, 19 Feb 2026 00:01:57 +0100 Subject: [PATCH 57/70] -- --- energyml-utils/src/energyml/utils/data/mesh.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/energyml-utils/src/energyml/utils/data/mesh.py b/energyml-utils/src/energyml/utils/data/mesh.py index 11d6776..8d808f5 100644 --- a/energyml-utils/src/energyml/utils/data/mesh.py +++ b/energyml-utils/src/energyml/utils/data/mesh.py @@ -784,7 +784,7 @@ def read_wellbore_frame_representation( trajectory_dor = search_attribute_matching_name(obj=energyml_object, name_rgx="Trajectory")[0] trajectory_obj = workspace.get_object(get_obj_uri(trajectory_dor)) - print(f"Mds {wellbore_frame_mds}") + # print(f"Mds {wellbore_frame_mds}") meshes = read_wellbore_trajectory_representation( energyml_object=trajectory_obj, From 7052e184d2f6c2575ece1f45004b22cf2b44264c Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Fri, 20 Feb 2026 13:38:14 +0100 Subject: [PATCH 58/70] global xml context --- .../example/attic/compare_inmem_n_stream.py | 15 +++- .../example/attic/parsing_improvement_test.py | 84 +++++++++++++++++++ .../src/energyml/utils/serialization.py | 28 +++---- 3 files changed, 107 insertions(+), 20 deletions(-) create mode 100644 energyml-utils/example/attic/parsing_improvement_test.py diff --git a/energyml-utils/example/attic/compare_inmem_n_stream.py b/energyml-utils/example/attic/compare_inmem_n_stream.py index 9601ce2..5ae895d 100644 --- a/energyml-utils/example/attic/compare_inmem_n_stream.py +++ b/energyml-utils/example/attic/compare_inmem_n_stream.py @@ -140,6 +140,13 @@ def time_comparison( print(f" • Overall speedup: {speedup_factor:.2f}x faster\n") +def recompute_rels(epc_file_path: str): + with EpcStreamReader( + epc_file_path=epc_file_path, enable_parallel_rels=True, rels_update_mode=RelsUpdateMode.UPDATE_ON_CLOSE + ) as reader: + pass # Just open and close to trigger rels computation on close + + if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) @@ -150,11 +157,13 @@ def time_comparison( # output_folder="rc/performance_results", # ) - time_comparison( - filepath=sys.argv[1] if len(sys.argv) > 1 else "rc/epc/80wells_surf.epc", output_folder="rc/performance_results" - ) + # time_comparison( + # filepath=sys.argv[1] if len(sys.argv) > 1 else "rc/epc/80wells_surf.epc", output_folder="rc/performance_results" + # ) # time_comparison( # filepath=sys.argv[1] if len(sys.argv) > 1 else "wip/failingData/fix/sample_mini_firp_201_norels_with_media.epc", # output_folder="rc/performance_results", # ) + + recompute_rels("C:/Users/Cryptaro/Downloads/Galaxy384-[[Output] EPC file pointset extraction].epc") diff --git a/energyml-utils/example/attic/parsing_improvement_test.py b/energyml-utils/example/attic/parsing_improvement_test.py new file mode 100644 index 0000000..6c17d6b --- /dev/null +++ b/energyml-utils/example/attic/parsing_improvement_test.py @@ -0,0 +1,84 @@ +""" +Test for parsing. + +To test : edit _read_energyml_xml_bytes_as_class in serialization.py : + +__ENV__IMPROVEMENT__ = "__ENV__IMPROVEMENT__" +"__ENV__IMPROVEMENT_LXML__" = ""__ENV__IMPROVEMENT_LXML__"" + + if os.environ.get(__ENV__IMPROVEMENT__, "0") == "0": + if os.environ.get("__ENV__IMPROVEMENT_LXML__", "0") == "1": + parser = XmlParser(config=config, handler=LxmlEventHandler) + else: + parser = XmlParser(config=config) + else: + if os.environ.get("__ENV__IMPROVEMENT_LXML__", "0") == "1": + parser = XmlParser(config=config, context=GLOBAL_XML_CONTEXT, handler=LxmlEventHandler) + else: + parser = XmlParser(config=config, context=GLOBAL_XML_CONTEXT) + +""" + +import logging +import os +import sys +import time +from typing import Optional + +from energyml.utils.epc import Epc + + +def reexport_in_memory_par_read(filepath: str, output_folder: Optional[str] = None): + is_opti = os.environ.get("__ENV__IMPROVEMENT__", "0") == "1" + + suffix = "opti" if is_opti else "std" + if os.environ.get("__ENV__IMPROVEMENT_LXML__", "0") == "1": + suffix += "_lxml" + + path_in_memory = filepath.replace(".epc", f"_parsing_imp_xml_{suffix}.epc") + if output_folder: + os.makedirs(output_folder, exist_ok=True) + path_in_memory = f"{output_folder}/{path_in_memory.split('/')[-1]}" + epc = Epc.read_file(epc_file_path=filepath, read_rels_from_files=False, read_parallel=True, recompute_rels=False) + + if os.path.exists(path_in_memory): + os.remove(path_in_memory) + epc.export_file(path_in_memory, parallel=True) + + +def time_test(f: callable, **kwargs): + print(f"⏳ Testing {f.__name__}...") + start = time.perf_counter() + f(**kwargs) + elapsed_inmem = time.perf_counter() - start + # results.append(("In-Memory (Epc)", elapsed_inmem)) + print(f" ✓ Completed in {elapsed_inmem:.3f}s\n") + return ("In-Memory (Epc)", elapsed_inmem) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + + os.environ["__ENV__IMPROVEMENT__"] = "0" + os.environ["__ENV__IMPROVEMENT_LXML__"] = "0" + + time_test( + reexport_in_memory_par_read, + filepath=sys.argv[1] if len(sys.argv) > 1 else "rc/epc/80wells_surf.epc", + output_folder="results", + ) + + os.environ["__ENV__IMPROVEMENT__"] = "1" + time_test( + reexport_in_memory_par_read, + filepath=sys.argv[1] if len(sys.argv) > 1 else "rc/epc/80wells_surf.epc", + output_folder="results", + ) + + os.environ["__ENV__IMPROVEMENT__"] = "1" + os.environ["__ENV__IMPROVEMENT_LXML__"] = "1" + time_test( + reexport_in_memory_par_read, + filepath=sys.argv[1] if len(sys.argv) > 1 else "rc/epc/80wells_surf.epc", + output_folder="results", + ) diff --git a/energyml-utils/src/energyml/utils/serialization.py b/energyml-utils/src/energyml/utils/serialization.py index 54a105d..960664d 100644 --- a/energyml-utils/src/energyml/utils/serialization.py +++ b/energyml-utils/src/energyml/utils/serialization.py @@ -42,6 +42,13 @@ ENERGYML_NAMESPACES, ) +from xsdata.formats.dataclass.parsers.handlers import LxmlEventHandler + +GLOBAL_XML_CONTEXT = XmlContext( + # element_name_generator=text.camel_case, + # attribute_name_generator=text.kebab_case +) + class JSON_VERSION(Enum): XSDATA = "XSDATA" @@ -65,7 +72,7 @@ def _read_energyml_xml_bytes_as_class( fail_on_unknown_attributes=fail_on_unknown_attributes, # process_xinclude=True, ) - parser = XmlParser(config=config) + parser = XmlParser(config=config, context=GLOBAL_XML_CONTEXT, handler=LxmlEventHandler) try: return parser.from_bytes(file, obj_class) except ParserError as e: @@ -81,11 +88,6 @@ def _read_energyml_xml_bytes_as_class( def read_energyml_xml_tree(file: etree, obj_type: Optional[type] = None) -> Any: - # if obj_type is None: - # obj_type = get_class_from_name(get_class_name_from_xml(file)) - # parser = XmlParser(handler=XmlEventHandler) - # # parser = XmlParser(handler=LxmlEventHandler) - # return parser.parse(file, obj_type) return read_energyml_xml_bytes(etree.tostring(file, encoding="utf8")) @@ -155,7 +157,7 @@ def _read_energyml_json_bytes_as_class(file: bytes, json_version: JSON_VERSION, # fail_on_unknown_attributes=False, # process_xinclude=True, ) - parser = JsonParser(config=config) + parser = JsonParser(config=config, context=GLOBAL_XML_CONTEXT) try: return parser.from_bytes(file, obj_class) except ParserError as e: @@ -269,12 +271,8 @@ def serialize_xml(obj, check_obj_prefixed_classes: bool = True) -> str: # logging.debug(f"[1] Serializing object of type {type(obj)}") obj = as_obj_prefixed_class_if_possible(obj) if check_obj_prefixed_classes else obj # logging.debug(f"[2] Serializing object of type {type(obj)}") - context = XmlContext( - # element_name_generator=text.camel_case, - # attribute_name_generator=text.kebab_case - ) serializer_config = SerializerConfig(indent=" ") - serializer = XmlSerializer(context=context, config=serializer_config) + serializer = XmlSerializer(context=GLOBAL_XML_CONTEXT, config=serializer_config) # res = serializer.render(obj) res = serializer.render(obj, ns_map=ENERGYML_NAMESPACES) # logging.debug(f"[3] Serialized XML with meta namespace : {obj.Meta.namespace}: {serialize_json(obj)}") @@ -286,12 +284,8 @@ def serialize_json( ) -> str: obj = as_obj_prefixed_class_if_possible(obj) if check_obj_prefixed_classes else obj if json_version == JSON_VERSION.XSDATA: - context = XmlContext( - # element_name_generator=text.camel_case, - # attribute_name_generator=text.kebab_case - ) serializer_config = SerializerConfig(indent=" ") - serializer = JsonSerializer(context=context, config=serializer_config) + serializer = JsonSerializer(context=GLOBAL_XML_CONTEXT, config=serializer_config) return serializer.render(obj) elif json_version == JSON_VERSION.OSDU_OFFICIAL: return json.dumps(to_json_dict(obj), indent=4, sort_keys=True) From 0df3d16fa72bb5fd58ce757c57772ceab2175812 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Fri, 20 Feb 2026 14:13:10 +0100 Subject: [PATCH 59/70] -- --- energyml-utils/.gitignore | 6 +++- .../example/attic/parsing_improvement_test.py | 34 +++++++++++++++++-- energyml-utils/pyproject.toml | 1 + .../src/energyml/utils/constants.py | 13 +++++++ .../src/energyml/utils/introspection.py | 2 ++ 5 files changed, 52 insertions(+), 4 deletions(-) diff --git a/energyml-utils/.gitignore b/energyml-utils/.gitignore index 9cbbdf4..23725b9 100644 --- a/energyml-utils/.gitignore +++ b/energyml-utils/.gitignore @@ -74,4 +74,8 @@ rc/**/*.hdf5 # WIP src/energyml/utils/wip* scripts -rc/camunda \ No newline at end of file +rc/camunda + + +# code profiling +*.prof \ No newline at end of file diff --git a/energyml-utils/example/attic/parsing_improvement_test.py b/energyml-utils/example/attic/parsing_improvement_test.py index 6c17d6b..a3d9889 100644 --- a/energyml-utils/example/attic/parsing_improvement_test.py +++ b/energyml-utils/example/attic/parsing_improvement_test.py @@ -34,6 +34,8 @@ def reexport_in_memory_par_read(filepath: str, output_folder: Optional[str] = No suffix = "opti" if is_opti else "std" if os.environ.get("__ENV__IMPROVEMENT_LXML__", "0") == "1": suffix += "_lxml" + if os.environ.get("__ENV__IMPROVEMENT__GET_MEMBER__", "0") == "1": + suffix += "_get_member" path_in_memory = filepath.replace(".epc", f"_parsing_imp_xml_{suffix}.epc") if output_folder: @@ -46,17 +48,20 @@ def reexport_in_memory_par_read(filepath: str, output_folder: Optional[str] = No epc.export_file(path_in_memory, parallel=True) +# =================================== + + def time_test(f: callable, **kwargs): - print(f"⏳ Testing {f.__name__}...") + print(f" Testing {f.__name__}...") start = time.perf_counter() f(**kwargs) elapsed_inmem = time.perf_counter() - start # results.append(("In-Memory (Epc)", elapsed_inmem)) - print(f" ✓ Completed in {elapsed_inmem:.3f}s\n") + print(f" Completed in {elapsed_inmem:.3f}s\n") return ("In-Memory (Epc)", elapsed_inmem) -if __name__ == "__main__": +if __name__ == "__main__xmlcontext__": logging.basicConfig(level=logging.DEBUG) os.environ["__ENV__IMPROVEMENT__"] = "0" @@ -82,3 +87,26 @@ def time_test(f: callable, **kwargs): filepath=sys.argv[1] if len(sys.argv) > 1 else "rc/epc/80wells_surf.epc", output_folder="results", ) + +if __name__ == "__main__": + from energyml.resqml.v2_2.resqmlv2 import TriangulatedSetRepresentation + + print(TriangulatedSetRepresentation.__class__.__module__) + print(TriangulatedSetRepresentation.__dataclass_fields__.keys()) + + # logging.basicConfig(level=logging.DEBUG) + + # os.environ["__ENV__IMPROVEMENT__GET_MEMBER__"] = "0" + + time_test( + reexport_in_memory_par_read, + filepath=sys.argv[1] if len(sys.argv) > 1 else "rc/epc/80wells_surf.epc", + output_folder="results", + ) + + # os.environ["__ENV__IMPROVEMENT__GET_MEMBER__"] = "1" + # time_test( + # reexport_in_memory_par_read, + # filepath=sys.argv[1] if len(sys.argv) > 1 else "rc/epc/80wells_surf.epc", + # output_folder="results", + # ) diff --git a/energyml-utils/pyproject.toml b/energyml-utils/pyproject.toml index 7f3e72b..5c3de2e 100644 --- a/energyml-utils/pyproject.toml +++ b/energyml-utils/pyproject.toml @@ -83,6 +83,7 @@ black = "^22.3.0" pylint = "^2.7.2" click = ">=8.1.3, <=8.1.3" # upper version than 8.0.2 fail with black pdoc3 = "^0.10.0" +snakeviz = "^2.1.0" # code perf tests pydantic = { version = "^2.0"} energyml-common2-0 = "^1.12.0" energyml-common2-1 = "^1.12.0" diff --git a/energyml-utils/src/energyml/utils/constants.py b/energyml-utils/src/energyml/utils/constants.py index e37a919..c1b087d 100644 --- a/energyml-utils/src/energyml/utils/constants.py +++ b/energyml-utils/src/energyml/utils/constants.py @@ -415,8 +415,21 @@ def file_extension_to_mime_type(extension: str) -> Optional[str]: # OPTIMIZED UTILITY FUNCTIONS # =================================== +_SNAKE_CASE_PATTERNS = [ + (re.compile(r"(.)([A-Z][a-z]+)"), r"\1_\2"), + (re.compile(r"__([A-Z])"), r"_\1"), + (re.compile(r"([a-z0-9])([A-Z])"), r"\1_\2"), +] + def snake_case(string: str) -> str: + """Transform a string into snake_case (optimized with pre-compiled regexes)""" + for pattern, repl in _SNAKE_CASE_PATTERNS: + string = pattern.sub(repl, string) + return string.lower() + + +def snake_case_old(string: str) -> str: """Transform a string into snake_case""" string = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", string) string = re.sub("__([A-Z])", r"_\1", string) diff --git a/energyml-utils/src/energyml/utils/introspection.py b/energyml-utils/src/energyml/utils/introspection.py index 460ad55..fae4a52 100644 --- a/energyml-utils/src/energyml/utils/introspection.py +++ b/energyml-utils/src/energyml/utils/introspection.py @@ -1,5 +1,6 @@ # Copyright (c) 2023-2024 Geosiris. # SPDX-License-Identifier: Apache-2.0 +from functools import lru_cache import inspect import json import logging @@ -85,6 +86,7 @@ def get_module_classes_from_name(mod_name: str) -> List: return get_module_classes(sys.modules[mod_name]) +@lru_cache(maxsize=None) def get_module_classes(mod: ModuleType) -> List: return inspect.getmembers(mod, inspect.isclass) From 4e3103d28ec03846d1d8e5f91b0f92a53e043473 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Fri, 20 Feb 2026 16:25:17 +0100 Subject: [PATCH 60/70] efficiency --- .../example/attic/parsing_improvement_test.py | 41 +++ .../src/energyml/utils/constants.py | 10 +- .../src/energyml/utils/introspection.py | 253 +++++++++++++----- energyml-utils/src/energyml/utils/manager.py | 12 +- energyml-utils/tests/test_introspection.py | 1 + 5 files changed, 236 insertions(+), 81 deletions(-) diff --git a/energyml-utils/example/attic/parsing_improvement_test.py b/energyml-utils/example/attic/parsing_improvement_test.py index a3d9889..a728fb1 100644 --- a/energyml-utils/example/attic/parsing_improvement_test.py +++ b/energyml-utils/example/attic/parsing_improvement_test.py @@ -20,12 +20,18 @@ """ import logging +import operator import os import sys import time from typing import Optional from energyml.utils.epc import Epc +from energyml.utils.introspection import ( + search_class_in_module_from_partial_name, +) +from energyml.utils.manager import get_related_energyml_modules_name +from energyml.utils.serialization import read_energyml_xml_file, serialize_json def reexport_in_memory_par_read(filepath: str, output_folder: Optional[str] = None): @@ -110,3 +116,38 @@ def time_test(f: callable, **kwargs): # filepath=sys.argv[1] if len(sys.argv) > 1 else "rc/epc/80wells_surf.epc", # output_folder="results", # ) + # class Test: + # def __init__(self): + # self.geometry = 1 + + # def hello(self): + # print("Hello") + + +if __name__ == "__main__2": + + grid = read_energyml_xml_file("rc/Grid2dRepresentation_78bf01c0-d5bb-46d3-aa70-9cc4ee5c8230.xml") + + print(serialize_json(grid)) + + # print(operator.attrgetter("geometry.points.zvalues.values.external_data_array_part.0")(grid)) + + test_dict = {"geometry": {"points": {"zvalues": {"values": {"external_data_array_part": ["test"]}}}}} + + print(operator.attrgetter("geometry.points.zvalues.values.external_data_array_part.0")(test_dict)) + + +if __name__ == "__main__": + + # print(is_abstract(Test)) + + # print(len(get_module_classes("energyml.resqml.v2_2.resqmlv2"))) + # print(get_module_classes_old("energyml.resqml.v2_2.resqmlv2")) + + # tr = TriangulatedSetRepresentation() + # print(get_class_methods(Epc))* + + # print(RELATED_MODULES_MAP) + # print(get_related_energyml_modules_name("energyml.resqml.v2_2.resqmlv2")) + + print(len(search_class_in_module_from_partial_name("energyml.resqml.v2_2.resqmlv2", "Representation"))) diff --git a/energyml-utils/src/energyml/utils/constants.py b/energyml-utils/src/energyml/utils/constants.py index c1b087d..4c9b3d2 100644 --- a/energyml-utils/src/energyml/utils/constants.py +++ b/energyml-utils/src/energyml/utils/constants.py @@ -49,7 +49,7 @@ ENERGYML_MODULES_NAMES = ["eml", "prodml", "witsml", "resqml"] -RELATED_MODULES = [ +_RELATED_MODULES = [ ["energyml.eml.v2_0.commonv2", "energyml.resqml.v2_0_1.resqmlv2"], [ "energyml.eml.v2_1.commonv2", @@ -65,6 +65,11 @@ ], ] +RELATED_MODULES_MAP = {} +for group in _RELATED_MODULES: + for module in group: + RELATED_MODULES_MAP[module] = group + # =================================== # REGEX PATTERN STRINGS (for reference) # =================================== @@ -213,7 +218,8 @@ class OptimizedRegex: RELS_FOLDER_NAME = "_rels" CORE_PROPERTIES_FOLDER_NAME = "docProps" -primitives = (bool, str, int, float, type(None)) +# primitives = (bool, str, int, float, type(None)) +primitives = {bool, str, int, float, bytes, type(None)} class MimeType(Enum): diff --git a/energyml-utils/src/energyml/utils/introspection.py b/energyml-utils/src/energyml/utils/introspection.py index fae4a52..a8e0eb8 100644 --- a/energyml-utils/src/energyml/utils/introspection.py +++ b/energyml-utils/src/energyml/utils/introspection.py @@ -8,6 +8,7 @@ import re import sys import traceback +import operator import typing from dataclasses import Field, field from enum import Enum @@ -31,7 +32,6 @@ class_has_parent_with_name, get_class_pkg, get_class_pkg_version, - RELATED_MODULES, get_related_energyml_modules_name, get_sub_classes, get_classes_matching_name, @@ -53,15 +53,53 @@ def is_enum(cls: Union[type, Any]): return is_enum(type(cls)) -def is_primitive(cls: Union[type, Any]) -> bool: +@lru_cache(maxsize=2048) +def _is_primitive_type(obj_type: type) -> bool: """ - Returns True if :param:`cls` is a primitiv type or extends Enum - :param cls: - :return: bool + Returns True if :param:`obj_type` is a primitive type or extends Enum """ - if isinstance(cls, type): - return cls in primitives or Enum in cls.__bases__ - return is_primitive(type(cls)) + if obj_type in primitives: + return True + try: + return issubclass(obj_type, Enum) + except TypeError: + return False + + +def is_primitive(cls: type) -> bool: + """ + Returns True if :param:`cls` is a primitive type or extends Enum + """ + t = cls if isinstance(cls, type) else type(cls) + return _is_primitive_type(t) + + +@lru_cache(maxsize=None) +def _is_abstract_cls(cls: type) -> bool: + # 1. Gestion du cache pour les instances (on récupère le type) + if not isinstance(cls, type): + return is_abstract(type(cls)) + + # 2. Les primitives ne sont jamais abstraites + if is_primitive(cls): + return False + + # 3. Critère de nom (très commun dans Energyml) + if cls.__name__.startswith("Abstract"): + return True + + # 4. Critère des champs (pour les Dataclasses) + # On vérifie explicitement si c'est une dataclass + fields = getattr(cls, "__dataclass_fields__", None) + has_no_fields = fields is not None and len(fields) == 0 + + # 5. Critère des méthodes + # Ta classe 'Test' a une méthode 'hello', donc len(...) == 1 + methods = get_class_methods(cls) + has_no_methods = len(methods) == 0 + + # Une classe est "abstraite" ici si elle est vide (pas de champs, pas de méthodes) + return has_no_fields and has_no_methods def is_abstract(cls: Union[type, Any]) -> bool: @@ -70,16 +108,8 @@ def is_abstract(cls: Union[type, Any]) -> bool: :param cls: :return: bool """ - if isinstance(cls, type): - return ( - not is_primitive(cls) - and ( - cls.__name__.startswith("Abstract") - or (hasattr(cls, "__dataclass_fields__") and len(cls.__dataclass_fields__)) == 0 - ) - and len(get_class_methods(cls)) == 0 - ) - return is_abstract(type(cls)) + t = cls if isinstance(cls, type) else type(cls) + return _is_abstract_cls(t) def get_module_classes_from_name(mod_name: str) -> List: @@ -87,24 +117,67 @@ def get_module_classes_from_name(mod_name: str) -> List: @lru_cache(maxsize=None) -def get_module_classes(mod: ModuleType) -> List: - return inspect.getmembers(mod, inspect.isclass) +def get_module_metadata_map(module_name: str) -> dict: + """ + Crée un index : {NomMeta: Classe, NomPython: Classe} + pour une recherche instantanée. + """ + mapping = {} + for cls_name, cls in get_module_classes_from_name(module_name): + # On indexe par le nom de classe Python + mapping[cls_name] = cls + # On indexe par le nom dans Meta (spécifique à Energyml/xsdata) + meta = getattr(cls, "Meta", None) + if meta and hasattr(meta, "name"): + mapping[meta.name] = cls + + return mapping + + +def find_class_in_module(module_name: str, class_name: str): + # 1. Tentative rapide via sys.modules (O(1)) + mod = sys.modules.get(module_name) + if not mod: + return None -def find_class_in_module(module_name, class_name): try: - return getattr(sys.modules[module_name], class_name) - except: - for cls_name, cls in get_module_classes_from_name(module_name): - try: - if cls_name == class_name or cls.Meta.name == class_name: - return cls - except Exception: - pass + return getattr(mod, class_name) + except AttributeError: + # 2. Recherche via le mapping Meta pré-calculé (O(1) après premier appel) + mapping = get_module_metadata_map(module_name) + cls = mapping.get(class_name) + + if cls: + return cls + logging.error(f"Not Found : {module_name}; {class_name}") return None +@lru_cache(maxsize=None) +def get_module_classes(mod: Union[ModuleType, str]) -> List[Tuple[str, type]]: + if isinstance(mod, str): + mod = sys.modules.get(mod) + if not mod: + return [] + + mod_name = mod.__name__ + return [ + (name, value) + for name, value in mod.__dict__.items() + if isinstance(value, type) and value.__module__ == mod_name + ] + + +@lru_cache(maxsize=None) +def _get_module_search_index(module_name: str) -> List[Tuple[str, type]]: + """Retourne une liste de tuples (nom_en_minuscule, classe) pour le module.""" + classes = get_module_classes_from_name(module_name) + # On pré-calcule le .lower() pour ne le faire qu'une seule fois par module + return [(cls_name.lower(), cls) for cls_name, cls in classes] + + def search_class_in_module_from_partial_name(module_name: str, class_partial_name: str) -> Optional[List[type]]: """ Search a class in a module using a partial name. @@ -114,27 +187,58 @@ def search_class_in_module_from_partial_name(module_name: str, class_partial_nam """ try: - import_module(module_name) - # module = import_module(module_name) - classes = get_module_classes_from_name(module_name) - matching_classes = [cls for cls_name, cls in classes if class_partial_name.lower() in cls_name.lower()] + import_related_module(module_name) + + # 2. Récupération de l'index pré-calculé (O(1) grâce au cache) + search_index = _get_module_search_index(module_name) + + # 3. Recherche floue + search_term = class_partial_name.lower() + matching_classes = [cls for name_lower, cls in search_index if search_term in name_lower] + return matching_classes - except ImportError as e: - logging.error(f"Module '{module_name}' not found: {e}") + except Exception as e: + logging.error(f"Error searching in module '{module_name}': {e}") return None -def get_class_methods(cls: Union[type, Any]) -> List[str]: +@lru_cache(maxsize=None) +def _get_class_methods(cls: Union[type, Any]) -> List[str]: """ - Returns the list of the methods names for a specific class. - :param cls: - :return: + Return a list of method names defined directly in the given class (not inherited). + + Args: + cls: The class or instance to inspect. + + Returns: + List of method names defined in the class (excluding dunder methods). + + Notes: + - Always works on the type for caching efficiency. + - Uses __dict__ to scan only methods defined in THIS class (not inherited). + If you want inherited methods, use dir(), but __dict__ is ~10x faster for EnergyML classes. + - Only checks if the attribute is a function or routine (more precise than callable(), + which includes the class itself). """ - return [ - func - for func in dir(cls) - if callable(getattr(cls, func)) and not func.startswith("__") and not isinstance(getattr(cls, func), type) - ] + # Always work on the type for cache efficiency + if not isinstance(cls, type): + return _get_class_methods(type(cls)) + + methods = [] + # Use __dict__ to scan only methods defined in THIS class + for name, attr in cls.__dict__.items(): + if name.startswith("__"): + continue + # Only check if it's a function or routine (not just callable) + if inspect.isroutine(attr): + methods.append(name) + + return methods + + +def get_class_methods(cls: Union[type, Any]) -> List[str]: + t = cls if isinstance(cls, type) else type(cls) + return _get_class_methods(t) def get_class_from_name(class_name_and_module: str) -> Optional[type]: @@ -188,23 +292,7 @@ def get_class_from_name(class_name_and_module: str) -> Optional[type]: return None -def get_energyml_module_dev_version(pkg: str, current_version: str): - accessible_modules = dict_energyml_modules() - if not current_version.startswith("v"): - current_version = "v" + current_version - - current_version = current_version.replace("-", "_").replace(".", "_") - res = [] - if pkg in accessible_modules: - # logging.debug("\t", pkg, current_version) - for am_pkg_version in accessible_modules[pkg]: - if am_pkg_version != current_version and am_pkg_version.startswith(current_version): - # logging.debug("\t\t", am_pkg_version) - res.append(get_module_name(pkg, am_pkg_version)) - - return res - - +@lru_cache(maxsize=None) def get_energyml_class_in_related_dev_pkg(cls: type): class_name = cls.__name__ class_pkg = get_class_pkg(cls) @@ -223,6 +311,23 @@ def get_energyml_class_in_related_dev_pkg(cls: type): return res +def get_energyml_module_dev_version(pkg: str, current_version: str): + accessible_modules = dict_energyml_modules() + if not current_version.startswith("v"): + current_version = "v" + current_version + + current_version = current_version.replace("-", "_").replace(".", "_") + res = [] + if pkg in accessible_modules: + # logging.debug("\t", pkg, current_version) + for am_pkg_version in accessible_modules[pkg]: + if am_pkg_version != current_version and am_pkg_version.startswith(current_version): + # logging.debug("\t\t", am_pkg_version) + res.append(get_module_name(pkg, am_pkg_version)) + + return res + + def get_module_name_and_type_from_content_or_qualified_type(cqt: str) -> Tuple[str, str]: """ Return a tuple (module_name, type) from a content-type or qualified-type string. @@ -296,21 +401,20 @@ def get_module_name(domain: str, domain_version: str): def import_related_module(energyml_module_name: str) -> None: """ - Import related modules for a specific energyml module. (See. :const:`RELATED_MODULES`) + Import related modules for a specific energyml module. (See. :const:`RELATED_MODULES_MAP`) :param energyml_module_name: :return: """ - for related in RELATED_MODULES: - if energyml_module_name in related: - for m in related: - try: - import_module(m) - except Exception as e: - # Only log once per unique module - if m not in _FAILED_IMPORT_MODULES: - _FAILED_IMPORT_MODULES.add(m) - logging.debug(f"Could not import related module {m}: {e}") - # logging.error(e) + group = get_related_energyml_modules_name(energyml_module_name) + for m in group: + try: + import_module(m) + except Exception as e: + # Only log once per unique module + if m not in _FAILED_IMPORT_MODULES: + _FAILED_IMPORT_MODULES.add(m) + logging.debug(f"Could not import related module {m}: {e}") + # logging.error(e) def list_function_parameters_with_types(func, is_class_function: bool = False) -> Dict[str, Any]: @@ -635,7 +739,8 @@ def get_object_attribute_no_verif(obj: Any, attr_name: str, default: Optional[An else: raise AttributeError(obj, name=attr_name) else: - res = getattr(obj, attr_name) + res = operator.attrgetter(attr_name)(obj) + # res = getattr(obj, attr_name) if res is None: # we did not used the "default" of getattr to keep raising AttributeError return default return res diff --git a/energyml-utils/src/energyml/utils/manager.py b/energyml-utils/src/energyml/utils/manager.py index 51e20bb..caafc42 100644 --- a/energyml-utils/src/energyml/utils/manager.py +++ b/energyml-utils/src/energyml/utils/manager.py @@ -1,5 +1,6 @@ # Copyright (c) 2023-2024 Geosiris. # SPDX-License-Identifier: Apache-2.0 +from functools import lru_cache import importlib import inspect import logging @@ -9,7 +10,7 @@ from energyml.utils.constants import ( ENERGYML_MODULES_NAMES, - RELATED_MODULES, + RELATED_MODULES_MAP, RGX_ENERGYML_MODULE_NAME, RGX_PROJECT_VERSION, ) @@ -22,15 +23,16 @@ def get_related_energyml_modules_name(cls: Union[type, Any]) -> List[str]: :param cls: :return: """ - if isinstance(cls, type): - for related in RELATED_MODULES: - if cls.__module__ in related: - return related + if isinstance(cls, str): + return RELATED_MODULES_MAP.get(cls, []) + elif isinstance(cls, type): + return RELATED_MODULES_MAP.get(str(cls.__module__), []) else: return get_related_energyml_modules_name(type(cls)) return [] +@lru_cache(maxsize=None) def dict_energyml_modules() -> Dict: """ List all accessible energyml python modules diff --git a/energyml-utils/tests/test_introspection.py b/energyml-utils/tests/test_introspection.py index 505d4cf..24baaec 100644 --- a/energyml-utils/tests/test_introspection.py +++ b/energyml-utils/tests/test_introspection.py @@ -246,6 +246,7 @@ def test_is_abstract(): assert is_abstract(AbstractPoint3DArray) assert not is_abstract(Point3DExternalArray) + assert not is_abstract(int) # ============================================================================= From 096c61392eea3cdd1074344b8538def5a540cbfb Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Tue, 24 Feb 2026 17:21:57 +0100 Subject: [PATCH 61/70] optimization --- .../example/attic/compare_inmem_n_stream.py | 24 +++-- energyml-utils/example/attic/perf_tests.py | 58 +++++++++++ energyml-utils/src/energyml/utils/epc.py | 99 ++++++++++++++++++- .../src/energyml/utils/introspection.py | 3 +- 4 files changed, 172 insertions(+), 12 deletions(-) create mode 100644 energyml-utils/example/attic/perf_tests.py diff --git a/energyml-utils/example/attic/compare_inmem_n_stream.py b/energyml-utils/example/attic/compare_inmem_n_stream.py index 5ae895d..190a1e4 100644 --- a/energyml-utils/example/attic/compare_inmem_n_stream.py +++ b/energyml-utils/example/attic/compare_inmem_n_stream.py @@ -40,19 +40,19 @@ def reexport_in_memory(filepath: str, output_folder: Optional[str] = None): os.makedirs(output_folder, exist_ok=True) path_in_memory = f"{output_folder}/{path_in_memory.split('/')[-1]}" epc = Epc.read_file(epc_file_path=filepath, read_rels_from_files=False, recompute_rels=False) - + print(len(epc.list_objects())) if os.path.exists(path_in_memory): os.remove(path_in_memory) epc.export_file(path_in_memory) def reexport_in_memory_par_read(filepath: str, output_folder: Optional[str] = None): - path_in_memory = filepath.replace(".epc", "_in_memory_par_read.epc") + path_in_memory = filepath.replace(".epc", f"_in_memory_par_read_v{os.environ['EPC_FAST_V2']}.epc") if output_folder: os.makedirs(output_folder, exist_ok=True) path_in_memory = f"{output_folder}/{path_in_memory.split('/')[-1]}" epc = Epc.read_file(epc_file_path=filepath, read_rels_from_files=False, read_parallel=True, recompute_rels=False) - + print(len(epc.list_objects())) if os.path.exists(path_in_memory): os.remove(path_in_memory) epc.export_file(path_in_memory, parallel=True) @@ -80,6 +80,7 @@ def time_comparison( print(f" ✓ Completed in {elapsed_inmem:.3f}s\n") # Test 1b: In-Memory with Parallel Read + os.environ["EPC_FAST_V2"] = "0" print("⏳ Testing In-Memory EPC processing with Parallel Read...") start = time.perf_counter() reexport_in_memory_par_read(filepath, output_folder) @@ -87,6 +88,15 @@ def time_comparison( results.append(("In-Memory (Epc) Parallel Read", elapsed_inmem_par)) print(f" ✓ Completed in {elapsed_inmem_par:.3f}s\n") + # Test 1b: In-Memory with Parallel Read v2 + os.environ["EPC_FAST_V2"] = "1" + print("⏳ Testing In-Memory EPC processing with Parallel Read v2...") + start = time.perf_counter() + reexport_in_memory_par_read(filepath, output_folder) + elapsed_inmem_par = time.perf_counter() - start + results.append(("In-Memory (Epc) Parallel Read v2", elapsed_inmem_par)) + print(f" ✓ Completed in {elapsed_inmem_par:.3f}s\n") + if not skip_sequential_stream: # Test 2: Streaming Sequential print("⏳ Testing Streaming Sequential processing...") @@ -157,13 +167,13 @@ def recompute_rels(epc_file_path: str): # output_folder="rc/performance_results", # ) - # time_comparison( - # filepath=sys.argv[1] if len(sys.argv) > 1 else "rc/epc/80wells_surf.epc", output_folder="rc/performance_results" - # ) + time_comparison( + filepath=sys.argv[1] if len(sys.argv) > 1 else "rc/epc/80wells_surf.epc", output_folder="rc/performance_results" + ) # time_comparison( # filepath=sys.argv[1] if len(sys.argv) > 1 else "wip/failingData/fix/sample_mini_firp_201_norels_with_media.epc", # output_folder="rc/performance_results", # ) - recompute_rels("C:/Users/Cryptaro/Downloads/Galaxy384-[[Output] EPC file pointset extraction].epc") + # recompute_rels("C:/Users/Cryptaro/Downloads/Galaxy384-[[Output] EPC file pointset extraction].epc") diff --git a/energyml-utils/example/attic/perf_tests.py b/energyml-utils/example/attic/perf_tests.py new file mode 100644 index 0000000..e8122e4 --- /dev/null +++ b/energyml-utils/example/attic/perf_tests.py @@ -0,0 +1,58 @@ +# Benchmark de performance pour get_obj_uuid +import time +import re +from types import SimpleNamespace + +UUID_RGX: re.Pattern = re.compile(r"[Uu]u?id|UUID") + + +# Version originale +def get_obj_uuid_original(obj): + try: + return getattr(obj, "uuid", None) or getattr(obj, "uid") + except AttributeError: + if isinstance(obj, dict): + for k in obj.keys(): + if UUID_RGX.match(k): + return obj[k] + return None + + +# Version optimisée +def get_obj_uuid_fast(obj): + for attr in dir(obj): + if UUID_RGX.match(attr): + value = getattr(obj, attr, None) + if value is not None: + return value + if isinstance(obj, dict): + for k, v in obj.items(): + if UUID_RGX.match(k): + if v is not None: + return v + return None + + +# Simulation d'une classe TriangulatedSetRepresentation +class TriangulatedSetRepresentation: + def __init__(self, uuid): + self.uuid = uuid + + +N = 10000 +objs = [TriangulatedSetRepresentation(f"uuid-{i}") for i in range(N)] + +# Test version originale +start = time.perf_counter() +for obj in objs: + assert get_obj_uuid_original(obj) == obj.uuid +elapsed_original = time.perf_counter() - start + +# Test version optimisée +start = time.perf_counter() +for obj in objs: + assert get_obj_uuid_fast(obj) == obj.uuid +elapsed_fast = time.perf_counter() - start + +print(f"Original version: {elapsed_original:.6f} s for {N} calls") +print(f"Optimized version: {elapsed_fast:.6f} s for {N} calls") diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index f308008..cba6793 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -1553,15 +1553,22 @@ def read_file( """ with open(epc_file_path, "rb") as f: if read_parallel: - epc = cls.read_stream_ultra_fast( - BytesIO(f.read()), read_rels_from_files=read_rels_from_files, recompute_rels=recompute_rels + epc = ( + cls.read_stream_ultra_fast( + BytesIO(f.read()), read_rels_from_files=read_rels_from_files, recompute_rels=recompute_rels + ) + if not os.environ.get("EPC_FAST_V2", "0") == "1" + else cls.read_stream_ultra_fast_v2( + BytesIO(f.read()), read_rels_from_files=read_rels_from_files, recompute_rels=recompute_rels + ) ) else: epc = cls.read_stream( BytesIO(f.read()), read_rels_from_files=read_rels_from_files, recompute_rels=recompute_rels ) - epc.epc_file_path = epc_file_path - return epc + if epc is not None: + epc.epc_file_path = epc_file_path + return epc raise IOError(f"Failed to open EPC file {epc_file_path}") @classmethod @@ -1575,6 +1582,7 @@ def read_stream( :param recompute_rels: If True, recompute all relationships after loading :return: an :class:`EPC` instance """ + print("Reading EPC file seq...") try: _read_files = [] obj_list = [] @@ -1727,6 +1735,8 @@ def read_stream_ultra_fast( from concurrent.futures import ProcessPoolExecutor, as_completed import multiprocessing + print("Reading EPC file parrallel v1...") + obj_to_process = {} rels_to_process = {} raw_files = [] @@ -1813,6 +1823,87 @@ def read_stream_ultra_fast( return epc + @classmethod + def read_stream_ultra_fast_v2( + cls, epc_file_io: BytesIO, read_rels_from_files: bool = True, recompute_rels: bool = False + ) -> Optional["Epc"]: + from concurrent.futures import ThreadPoolExecutor # Passage au ThreadPool + + print("Reading EPC file parrallel v2...") + + obj_list = [] + path_to_obj = {} + rels_content_map = {} + raw_files = [] + core_props = None + + # On utilise un ThreadPool pour éviter le coût de sérialisation Pickle + # lxml libère le GIL, donc c'est très efficace + with ThreadPoolExecutor() as executor: + futures = [] + + with zipfile.ZipFile(epc_file_io, "r") as epc_file: + # On récupère l'index d'abord + ct_path = get_epc_content_type_path() + content_type_obj = read_energyml_xml_bytes(epc_file.read(ct_path)) + + # Identification des types via le ContentTypes + energyml_paths = {} + for ov in content_type_obj.override: + path = ov.part_name.lstrip("/\\") + if is_energyml_content_type(ov.content_type): + energyml_paths[path] = ov.content_type + elif get_class_from_content_type(ov.content_type) == CoreProperties: + core_props = read_energyml_xml_bytes(epc_file.read(path), CoreProperties) + + for info in epc_file.infolist(): + fname = info.filename + + # STREAMING : On lance la tâche dès qu'on a les bytes + if fname in energyml_paths: + data = epc_file.read(fname) + f = executor.submit(_parallel_xml_read, data, energyml_paths[fname]) + futures.append((f, "OBJ", fname)) + + elif read_rels_from_files and fname.lower().endswith(".rels"): + data = epc_file.read(fname) + f = executor.submit(_parallel_rels_read, data) + futures.append((f, "REL", fname)) + elif ( + not fname.lower().endswith(".rels") + and not fname.lower().endswith(gen_core_props_path().lower()) + and fname not in energyml_paths + and fname != ct_path + ): + raw_files.append(RawFile(path=fname, content=BytesIO(epc_file.read(fname)))) + + # 2. Récupération des résultats (pendant que le ZIP continue d'être lu si possible) + for future, kind, path in futures: + res = future.result() + if isinstance(res, Exception): + continue + + if kind == "OBJ": + path_to_obj[path] = res + obj_list.append(res) + else: + o_path = str(Path(path).parent.parent / Path(path).stem).replace("\\", "/") + rels_content_map[o_path] = res + + # 3. Assemblage final dans le processus parent + epc = Epc(energyml_objects=EnergymlObjectCollection(obj_list), raw_files=raw_files, core_props=core_props) + + if read_rels_from_files: + for obj_path, rels_obj in rels_content_map.items(): + if obj_path in path_to_obj: + target_obj = path_to_obj[obj_path] + epc._rels_cache.set_rels_from_file(target_obj, rels_obj) # type: ignore + + if recompute_rels: + epc._rels_cache.recompute_cache() # type: ignore + + return epc + # ______ __ ____ __ _ # / ____/___ ___ _________ ___ ______ ___ / / / __/_ ______ _____/ /_(_)___ ____ _____ diff --git a/energyml-utils/src/energyml/utils/introspection.py b/energyml-utils/src/energyml/utils/introspection.py index a8e0eb8..8e1c9e3 100644 --- a/energyml-utils/src/energyml/utils/introspection.py +++ b/energyml-utils/src/energyml/utils/introspection.py @@ -909,7 +909,8 @@ def search_attribute_matching_type_with_path( elif not is_primitive(obj): for att_name in get_class_attributes(obj): res = res + search_attribute_matching_type_with_path( - obj=get_object_attribute_rgx(obj, att_name), + obj=get_object_attribute_no_verif(obj, att_name), + # obj=get_object_attribute_rgx(obj, att_name), type_rgx=type_rgx, re_flags=re_flags, return_self=True, From 46d74e51e351ad0fd6074e673bf1e4d85294f683 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Mon, 2 Mar 2026 14:13:44 +0100 Subject: [PATCH 62/70] downgradin h5py version --- energyml-utils/example/attic/perf_tests.py | 23 ++++++++++++++++++- energyml-utils/pyproject.toml | 2 +- .../src/energyml/utils/data/datasets_io.py | 12 ++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/energyml-utils/example/attic/perf_tests.py b/energyml-utils/example/attic/perf_tests.py index e8122e4..645be1a 100644 --- a/energyml-utils/example/attic/perf_tests.py +++ b/energyml-utils/example/attic/perf_tests.py @@ -1,11 +1,25 @@ # Benchmark de performance pour get_obj_uuid import time import re -from types import SimpleNamespace UUID_RGX: re.Pattern = re.compile(r"[Uu]u?id|UUID") +# Version dot +def get_obj_uuid_pointe(obj): + try: + return obj.uuid + except AttributeError: + try: + return obj.uid + except AttributeError: + if isinstance(obj, dict): + for k in obj.keys(): + if UUID_RGX.match(k): + return obj[k] + return None + + # Version originale def get_obj_uuid_original(obj): try: @@ -54,5 +68,12 @@ def __init__(self, uuid): assert get_obj_uuid_fast(obj) == obj.uuid elapsed_fast = time.perf_counter() - start +# Test version pointe +start = time.perf_counter() +for obj in objs: + assert get_obj_uuid_pointe(obj) == obj.uuid +elapsed_point = time.perf_counter() - start + print(f"Original version: {elapsed_original:.6f} s for {N} calls") print(f"Optimized version: {elapsed_fast:.6f} s for {N} calls") +print(f"Point version: {elapsed_point:.6f} s for {N} calls") diff --git a/energyml-utils/pyproject.toml b/energyml-utils/pyproject.toml index 5c3de2e..e3f4825 100644 --- a/energyml-utils/pyproject.toml +++ b/energyml-utils/pyproject.toml @@ -67,7 +67,7 @@ segy = ["segyio"] python = "^3.9" xsdata = {version = "^24.0", extras = ["cli", "lxml"]} energyml-opc = "^1.12.0" -h5py = { version = "^3.7.0", optional = false } +h5py = { version = "^3.11.0", optional = false } numpy = { version = "^1.16.6", optional = false } pyarrow = { version = "^14.0.1", optional = true } pandas = { version = "^1.1.0", optional = true } diff --git a/energyml-utils/src/energyml/utils/data/datasets_io.py b/energyml-utils/src/energyml/utils/data/datasets_io.py index 1286d96..e78c7da 100644 --- a/energyml-utils/src/energyml/utils/data/datasets_io.py +++ b/energyml-utils/src/energyml/utils/data/datasets_io.py @@ -1024,6 +1024,9 @@ def list_arrays(self, source: Union[BytesIO, str, Any]) -> List[str]: def can_handle_file(self, file_path: str) -> bool: return os.path.splitext(file_path)[1].lower() in [".h5", ".hdf5"] # dat for Galaxy compatibility + # Alias so the public name is always importable + HDF5ArrayHandler = MockHDF5ArrayHandler + # Parquet Handler if __PARQUET_MODULE_EXISTS__: @@ -1214,6 +1217,9 @@ def list_arrays(self, source: Union[BytesIO, str, Any]) -> List[str]: def can_handle_file(self, file_path: str) -> bool: return os.path.splitext(file_path)[1].lower() in [".parquet", ".pq"] + # Alias so the public name is always importable + ParquetArrayHandler = MockParquetArrayHandler + # CSV Handler if __CSV_MODULE_EXISTS__: @@ -1563,6 +1569,9 @@ def can_handle_file(self, file_path: str) -> bool: ext = os.path.splitext(file_path)[1].lower() return ext == ".las" + # Alias so the public name is always importable + LASArrayHandler = MockLASArrayHandler + # SEG-Y Handler if __SEGYIO_MODULE_EXISTS__: @@ -1792,3 +1801,6 @@ def can_handle_file(self, file_path: str) -> bool: """Check if this handler can process the file.""" ext = os.path.splitext(file_path)[1].lower() return ext in [".sgy", ".segy"] + + # Alias so the public name is always importable + SEGYArrayHandler = MockSEGYArrayHandler From d9a4d76507389feba9f1bdcd3dc0808384c412cc Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Tue, 3 Mar 2026 19:42:41 +0100 Subject: [PATCH 63/70] starting optimisation for mesh reading --- .../example/attic/arrays_test_fast.py | 437 +++++++ .../src/energyml/utils/data/datasets_io.py | 52 +- .../src/energyml/utils/data/helper.py | 78 +- .../src/energyml/utils/data/mesh.py | 4 +- .../src/energyml/utils/data/mesh_numpy.py | 1120 +++++++++++++++++ energyml-utils/src/energyml/utils/epc.py | 44 + .../src/energyml/utils/epc_stream.py | 38 + .../src/energyml/utils/storage_interface.py | 34 + 8 files changed, 1766 insertions(+), 41 deletions(-) create mode 100644 energyml-utils/example/attic/arrays_test_fast.py create mode 100644 energyml-utils/src/energyml/utils/data/mesh_numpy.py diff --git a/energyml-utils/example/attic/arrays_test_fast.py b/energyml-utils/example/attic/arrays_test_fast.py new file mode 100644 index 0000000..3d0c58d --- /dev/null +++ b/energyml-utils/example/attic/arrays_test_fast.py @@ -0,0 +1,437 @@ +""" +arrays_test_fast.py +=================== +Companion to arrays_test.py — but using the mesh_numpy module for zero-copy, +numpy-native geometry reading. + +Every function mirrors its counterpart in arrays_test.py and returns +``List[NumpyMesh]``. The ``__main__`` block at the bottom shows how to +toggle between the different readers and optionally render with PyVista. + +Key differences vs. arrays_test.py: +* No list-of-lists — everything is already an ``np.ndarray``. +* VTK flat format for faces / lines — passable directly to PyVista. +* ``use_crs_displacement=True`` applies the CRS offset/scale in-place (no + extra allocation). +* Optional ``numpy_mesh_to_pyvista()`` helper at the end of each function. +""" + +import logging +import os +import sys +import traceback +from pathlib import Path +from typing import List, Optional + +import numpy as np + + +from energyml.utils.data.datasets_io import get_handler_registry +from energyml.utils.data.mesh_numpy import ( + NumpyMesh, + NumpyPointSetMesh, + NumpyPolylineMesh, + NumpySurfaceMesh, + NumpyVolumeMesh, + read_numpy_mesh_object, + numpy_mesh_to_pyvista, +) +from energyml.utils.epc import Epc +from energyml.utils.epc_stream import EpcStreamReader, RelsUpdateMode +from energyml.utils.serialization import read_energyml_xml_str + +# --------------------------------------------------------------------------- +# Optional PyVista import — present only when the package is installed. +# --------------------------------------------------------------------------- +try: + import pyvista as pv + + _PYVISTA_AVAILABLE = True +except ImportError: + _PYVISTA_AVAILABLE = False + +# --------------------------------------------------------------------------- +# Embedded XML fixtures (same as arrays_test.py) +# --------------------------------------------------------------------------- + +xml_grid_2d = """<?xml version="1.0" encoding="UTF-8"?> +<resqml:Grid2dRepresentation + xmlns:eml="http://www.energistics.org/energyml/data/commonv2" + xmlns:resqml="http://www.energistics.org/energyml/data/resqmlv2" + uuid="4e56b0e4-2cd1-4efa-97dd-95f72bcf9f80" schemaVersion="22"> + <eml:Citation> + <eml:Title>100x10 grid 2d for continuous color map</eml:Title> + <eml:Originator>phili</eml:Originator> + <eml:Creation>2026-02-13T16:55:42Z</eml:Creation> + <eml:Format>F2I-CONSULTING:FESAPI Example:2.14.1.0</eml:Format> + </eml:Citation> + <resqml:RepresentedObject> + <eml:Uuid>34b69c81-6cfa-4531-be5b-f6bd9b74802f</eml:Uuid> + <eml:QualifiedType>resqml22.HorizonInterpretation</eml:QualifiedType> + <eml:Title>Horizon interpretation for continuous color map</eml:Title> + </resqml:RepresentedObject> + <resqml:SurfaceRole>map</resqml:SurfaceRole> + <resqml:FastestAxisCount>50</resqml:FastestAxisCount> + <resqml:SlowestAxisCount>100</resqml:SlowestAxisCount> + <resqml:Geometry> + <resqml:LocalCrs> + <eml:Uuid>5c0703c5-3806-424e-86cf-8f59c8bb39fa</eml:Uuid> + <eml:QualifiedType>eml23.LocalEngineeringCompoundCrs</eml:QualifiedType> + <eml:Title>Default local CRS</eml:Title> + </resqml:LocalCrs> + <resqml:Points xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:type="resqml:Point3dLatticeArray"> + <resqml:Origin> + <resqml:Coordinate1>0.0</resqml:Coordinate1> + <resqml:Coordinate2>0.0</resqml:Coordinate2> + <resqml:Coordinate3>0.0</resqml:Coordinate3> + </resqml:Origin> + <resqml:Dimension> + <resqml:Direction> + <resqml:Coordinate1>0.0</resqml:Coordinate1> + <resqml:Coordinate2>1.0</resqml:Coordinate2> + <resqml:Coordinate3>0.0</resqml:Coordinate3> + </resqml:Direction> + <resqml:Spacing xsi:type="eml:FloatingPointConstantArray"> + <eml:Value>1.0</eml:Value> + <eml:Count>99</eml:Count> + </resqml:Spacing> + </resqml:Dimension> + <resqml:Dimension> + <resqml:Direction> + <resqml:Coordinate1>1.0</resqml:Coordinate1> + <resqml:Coordinate2>0.0</resqml:Coordinate2> + <resqml:Coordinate3>0.0</resqml:Coordinate3> + </resqml:Direction> + <resqml:Spacing xsi:type="eml:FloatingPointConstantArray"> + <eml:Value>1.0</eml:Value> + <eml:Count>49</eml:Count> + </resqml:Spacing> + </resqml:Dimension> + </resqml:Points> + </resqml:Geometry> +</resqml:Grid2dRepresentation> +""" + + +# --------------------------------------------------------------------------- +# Helper: pretty-print a NumpyMesh +# --------------------------------------------------------------------------- + +def print_mesh(mesh: NumpyMesh, *, max_rows: int = 8) -> None: + """Print a short summary of *mesh* to stdout.""" + sep = "=" * 50 + print(sep) + print(f"Type : {type(mesh).__name__}") + print(f"Identifier : {mesh.identifier!r}") + print(f"Points : shape={mesh.points.shape} dtype={mesh.points.dtype}") + + # Show first max_rows rows so output stays readable. + head = mesh.points[:max_rows] + print(head) + if len(mesh.points) > max_rows: + print(f" ... ({len(mesh.points) - max_rows} more rows)") + + if isinstance(mesh, NumpySurfaceMesh): + print(f"Faces (VTK flat) : len={len(mesh.faces)} dtype={mesh.faces.dtype}") + print(mesh.faces[:min(len(mesh.faces), max_rows * 4)]) + + elif isinstance(mesh, NumpyPolylineMesh): + print(f"Lines (VTK flat) : len={len(mesh.lines)} dtype={mesh.lines.dtype}") + print(mesh.lines[:min(len(mesh.lines), max_rows * 3)]) + + elif isinstance(mesh, NumpyVolumeMesh): + print(f"Cells (VTK flat) : len={len(mesh.cells)} dtype={mesh.cells.dtype}") + print(f"Cell types : len={len(mesh.cell_types)} dtype={mesh.cell_types.dtype}") + + print() + + +# --------------------------------------------------------------------------- +# Reader functions — one per representation type +# --------------------------------------------------------------------------- + +def read_numpy_grid(use_crs_displacement: bool = False) -> List[NumpyMesh]: + """Read a Grid2dRepresentation from an embedded XML string (no EPC needed).""" + grid_2d = read_energyml_xml_str(xml_grid_2d) + if "DerivedElement" in str(type(grid_2d)): + grid_2d = grid_2d.value + + meshes = read_numpy_mesh_object( + energyml_object=grid_2d, + workspace=None, + use_crs_displacement=use_crs_displacement, + ) + return meshes + + +def read_numpy_polyline( + epc_path: str = "rc/epc/testingPackageCpp22.epc", + polyline_uuid: str = "a54b8399-d3ba-4d4b-b215-8d4f8f537e66", + use_crs_displacement: bool = True, +) -> List[NumpyMesh]: + """Read a PolylineRepresentation (or PolylineSetRepresentation) by UUID.""" + epc = Epc.read_file(epc_path, read_rels_from_files=False, recompute_rels=False) + + polyline_obj = epc.get_object_by_uuid(polyline_uuid)[0] + print(f"Object: {type(polyline_obj).__name__} uuid={polyline_uuid}") + + meshes = read_numpy_mesh_object( + energyml_object=polyline_obj, + workspace=epc, + use_crs_displacement=use_crs_displacement, + ) + return meshes + + +def read_numpy_trset( + epc_path: str = "rc/epc/testingPackageCpp22.epc", + trset_uuid: str = "6e678338-3b53-49b6-8801-faee493e0c42", + use_crs_displacement: bool = True, +) -> List[NumpyMesh]: + """Read a TriangulatedSetRepresentation by UUID.""" + epc = Epc.read_file(epc_path, read_rels_from_files=False, recompute_rels=False) + + trset = epc.get_object_by_uuid(trset_uuid)[0] + print(f"Object: {type(trset).__name__} uuid={trset_uuid}") + + meshes = read_numpy_mesh_object( + energyml_object=trset, + workspace=epc, + use_crs_displacement=use_crs_displacement, + ) + return meshes + + +def read_numpy_pointset( + epc_path: str = "rc/epc/testingPackageCpp22.epc", + pointset_uuid: str = "fbc5466c-94cd-46ab-8b48-2ae2162b372f", + use_crs_displacement: bool = True, +) -> List[NumpyMesh]: + """Read a PointSetRepresentation by UUID. + + Uses EpcStreamReader to exercise the streaming path (same as arrays_test.py). + """ + epc = EpcStreamReader( + epc_file_path=epc_path, + rels_update_mode=RelsUpdateMode.MANUAL, + ) + + pointset = epc.get_object_by_uuid(pointset_uuid)[0] + print(f"Object: {type(pointset).__name__} uuid={pointset_uuid}") + + meshes = read_numpy_mesh_object( + energyml_object=pointset, + workspace=epc, + use_crs_displacement=use_crs_displacement, + ) + return meshes + + +def read_numpy_wellbore_frame_repr( + epc_path: str = "rc/epc/testingPackageCpp22.epc", + well_uuid: str = "d873e243-d893-41ab-9a3e-d20b851c099f", + use_crs_displacement: bool = True, +) -> List[NumpyMesh]: + """Read a WellboreFrameRepresentation (or WellboreTrajectoryRepresentation).""" + epc = Epc.read_file(epc_path, read_rels_from_files=False, recompute_rels=False) + + frame_repr = epc.get_object_by_uuid(well_uuid)[0] + print(f"Object: {type(frame_repr).__name__} uuid={well_uuid}") + + meshes = read_numpy_mesh_object( + energyml_object=frame_repr, + workspace=epc, + use_crs_displacement=use_crs_displacement, + ) + return meshes + + +def read_numpy_representation_set( + epc_path: str = "rc/epc/testingPackageCpp22.epc", + rep_set_uuid: str = "6b992199-5b47-4624-a62c-b70857133cda", + use_crs_displacement: bool = True, +) -> List[NumpyMesh]: + """Read a RepresentationSetRepresentation — returns all member meshes.""" + epc = Epc.read_file(epc_path, read_rels_from_files=False, recompute_rels=False) + + rep_set = epc.get_object_by_uuid(rep_set_uuid)[0] + print(f"Object: {type(rep_set).__name__} uuid={rep_set_uuid}") + + meshes = read_numpy_mesh_object( + energyml_object=rep_set, + workspace=epc, + use_crs_displacement=use_crs_displacement, + ) + return meshes + + +def read_numpy_wellbore_frame_repr_demo_jfr_02_26( + epc_path: str = r"rc/epc/out-galaxy-12-pts.epc", + well_uuid: str = "cfad9cb6-99fe-4172-b560-d2feca75dd9f", + use_crs_displacement: bool = True, +) -> List[NumpyMesh]: + """Read a wellbore frame from a galaxy EPC file via the streaming reader.""" + epc = EpcStreamReader(epc_path, rels_update_mode=RelsUpdateMode.MANUAL) + + frame_repr = epc.get_object_by_uuid(well_uuid)[0] + print(f"Object: {type(frame_repr).__name__} uuid={well_uuid}") + + meshes = read_numpy_mesh_object( + energyml_object=frame_repr, + workspace=epc, + use_crs_displacement=use_crs_displacement, + ) + return meshes + + +# --------------------------------------------------------------------------- +# Zero-copy demo: compare read_array vs read_array_view +# --------------------------------------------------------------------------- + +def demo_zero_copy(h5_path: str = "rc/epc/testingPackageCpp22.h5") -> None: + """Show that read_array_view returns a numpy view instead of a copy. + + A view shares memory with the original HDF5 buffer — no extra allocation. + We confirm this by checking ``np.shares_memory`` and comparing dtype/shape. + """ + handler_registry = get_handler_registry() + h5_handler = handler_registry.get_handler_for_file(h5_path) + if h5_handler is None: + print(f"[demo_zero_copy] No handler found for {h5_path!r}") + return + + # Use a dataset that exists in the standard test EPC. + hdf5_path = "/resqml22/6e678338-3b53-49b6-8801-faee493e0c42/points_patch0" + + eager = h5_handler.read_array(source=h5_path, path_in_external_file=hdf5_path) + view = h5_handler.read_array_view(source=h5_path, path_in_external_file=hdf5_path) + + print("-" * 50) + print("demo_zero_copy") + print(f" Eager copy : shape={eager.shape} dtype={eager.dtype} id={id(eager)}") + print(f" View/array : shape={view.shape} dtype={view.dtype} id={id(view)}") + print(f" Same object : {eager is view}") + # For contiguous HDF5 datasets numpy may or may not share memory depending + # on the h5py version; we note what actually happened rather than asserting. + print(f" Shares memory: {np.shares_memory(eager, view)}") + print() + + +# --------------------------------------------------------------------------- +# Optional: write + read-back a test array (from arrays_test.py) +# --------------------------------------------------------------------------- + +def test_read_write_array_view(h5_path: str = "test_array_rw_fast.h5") -> None: + """Write two datasets then read them back via both eager and view paths.""" + handler_registry = get_handler_registry() + h5_handler = handler_registry.get_handler_for_file(h5_path) + if h5_handler is None: + print(f"No handler found for {h5_path}") + return + + for i, arr in enumerate([np.array([[1, 2, 3], [4, 5, 6]]), np.arange(24, dtype=np.float32).reshape(4, 6)]): + path = f"/test_dataset_{i}" + h5_handler.write_array(array=arr, target=h5_path, path_in_external_file=path) + h5_handler.file_cache.close_all() + + eager = h5_handler.read_array(source=h5_path, path_in_external_file=path) + view = h5_handler.read_array_view(source=h5_path, path_in_external_file=path) + + print(f"Dataset {path!r}:") + print(f" eager : {eager}") + print(f" view : {view}") + assert np.array_equal(eager, view), "Mismatch between eager and view!" + print(" [OK] values match\n") + + +# --------------------------------------------------------------------------- +# Optional: PyVista rendering +# --------------------------------------------------------------------------- + +def render_meshes_pyvista(meshes: List[NumpyMesh], title: str = "NumpyMesh viewer") -> None: + """Render a list of NumpyMesh objects in a PyVista plotter. + + Does nothing if pyvista is not installed. + """ + if not _PYVISTA_AVAILABLE: + print("[render_meshes_pyvista] pyvista not installed — skipping render.") + return + + plotter = pv.Plotter(title=title) + for mesh in meshes: + try: + pv_mesh = numpy_mesh_to_pyvista(mesh) + plotter.add_mesh(pv_mesh, show_edges=True, label=mesh.identifier or type(mesh).__name__) + except Exception as e: + print(f" [warn] Could not convert {type(mesh).__name__!r}: {e}") + + plotter.add_legend() + plotter.show() + + +# --------------------------------------------------------------------------- +# Entrypoint +# --------------------------------------------------------------------------- + +def main() -> None: + logging.basicConfig(level=logging.DEBUG) + + print("=" * 60) + print("arrays_test_fast.py — NumpyMesh reader demo") + print("=" * 60) + + # ------------------------------------------------------------------ + # Define which readers to run. + # Each entry is (label, callable). + # Comment / uncomment to control what gets exercised. + # ------------------------------------------------------------------ + readers = [ + ("Grid2dRepresentation (embedded XML)", read_numpy_grid), + ("PolylineRepresentation", read_numpy_polyline), + ("TriangulatedSetRepresentation", read_numpy_trset), + ("PointSetRepresentation", read_numpy_pointset), + ("WellboreFrameRepresentation", read_numpy_wellbore_frame_repr), + ("RepresentationSetRepresentation", read_numpy_representation_set), + # ("WellboreFrame (galaxy EPC)", read_numpy_wellbore_frame_repr_demo_jfr_02_26), + ] + + all_meshes: List[NumpyMesh] = [] + + for label, reader in readers: + print(f"\n{'─' * 60}") + print(f"Running: {label}") + print(f"{'─' * 60}") + try: + result = reader() + print(f" → {len(result)} mesh(es) returned") + all_meshes.extend(result) + for m in result: + print_mesh(m) + except Exception as exc: + print(f" [ERROR] {type(exc).__name__}: {exc}") + + # ------------------------------------------------------------------ + # Zero-copy comparison demo (reads directly from the HDF5 file): + # ------------------------------------------------------------------ + # demo_zero_copy() + + # ------------------------------------------------------------------ + # Round-trip write + read-back test: + # ------------------------------------------------------------------ + # test_read_write_array_view() + + print(f"\n{'=' * 60}") + print(f"Total meshes collected: {len(all_meshes)}") + print(f"{'=' * 60}\n") + + # ------------------------------------------------------------------ + # Optional PyVista render (only if pyvista is installed): + # ------------------------------------------------------------------ + # render_meshes_pyvista(all_meshes) + + +if __name__ == "__main__": + # Run $env:PYTHONPATH="src" if it fails to be executed from the project root. + print("hello") + main() diff --git a/energyml-utils/src/energyml/utils/data/datasets_io.py b/energyml-utils/src/energyml/utils/data/datasets_io.py index e78c7da..d403595 100644 --- a/energyml-utils/src/energyml/utils/data/datasets_io.py +++ b/energyml-utils/src/energyml/utils/data/datasets_io.py @@ -616,7 +616,7 @@ def read_external_dataset_array( ): if additional_sources is None: additional_sources = [] - result_array = [] + result_array = None for path_in_obj, path_in_external in get_path_in_external_with_path(energyml_array): succeed = False @@ -630,10 +630,15 @@ def read_external_dataset_array( ) for s in sources: try: - # TODO: take care of the "Counts" and "Starts" list in ExternalDataArrayPart to fill array correctly - result_array = result_array + read_dataset( - source=s, path_in_external_file=path_in_external, mimetype=mimetype - ) + if result_array is None: + result_array = read_dataset( + source=s, path_in_external_file=path_in_external, mimetype=mimetype + ) + else: + # TODO: take care of the "Counts" and "Starts" list in ExternalDataArrayPart to fill array correctly + result_array = result_array + read_dataset( + source=s, path_in_external_file=path_in_external, mimetype=mimetype + ) succeed = True break # stop after the first read success except MissingExtraInstallation as mei: @@ -855,7 +860,7 @@ def open_file_no_cache(self, file_path: str, mode: str = "r") -> Optional[Any]: try: return h5py.File(file_path, mode) # type: ignore except Exception as e: - logging.error(f"Failed to open HDF5 file {file_path}: {e}") + logging.debug(f"Failed to open HDF5 file {file_path}: {e}") return None def read_array( @@ -880,6 +885,41 @@ def read_array( with self.file_cache.get_or_open(source, self, "r") as f: # type: ignore return self.read_array(f, path_in_external_file, start_indices, counts) + def read_array_view( + self, + source: Union[BytesIO, str, Any], + path_in_external_file: Optional[str] = None, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, + ) -> Optional[np.ndarray]: + """Read array from HDF5 with best-effort zero-copy semantics. + + For contiguous, uncompressed datasets the returned array is backed + by the memory-mapped file buffer (no copy). For chunked or + compressed datasets h5py transparently falls back to a copy, but + sub-selection is done by h5py in C before the data reaches Python + (avoids loading the full dataset then slicing in Python). + + The caller **must not mutate** the returned array. + """ + if isinstance(source, h5py.File): # type: ignore + if not path_in_external_file: + return None + d_group = source[path_in_external_file] + if start_indices is not None and counts is not None: + # h5py reads only the required chunks/slabs from disk + slices = tuple( + slice(start, start + count) for start, count in zip(start_indices, counts) + ) + return d_group[slices] # type: ignore + # np.array with copy=False returns a view for contiguous datasets + # Note: copy= kwarg on np.asarray requires numpy >=2.0; + # np.array(x, copy=False) works on all numpy versions. + return np.array(d_group, copy=False) # type: ignore + else: + with self.file_cache.get_or_open(source, self, "r") as f: # type: ignore + return self.read_array_view(f, path_in_external_file, start_indices, counts) + def write_array( self, target: Union[str, BytesIO, Any], diff --git a/energyml-utils/src/energyml/utils/data/helper.py b/energyml-utils/src/energyml/utils/data/helper.py index a5f319d..27d9a29 100644 --- a/energyml-utils/src/energyml/utils/data/helper.py +++ b/energyml-utils/src/energyml/utils/data/helper.py @@ -279,10 +279,15 @@ def apply_crs_transform( return transformed -def get_crs_origin_offset(crs_obj: Any) -> List[float | int]: +def get_crs_origin_offset(crs_obj: Any) -> np.ndarray: """ - Return a list [X,Y,Z] corresponding to the crs Offset [XOffset/OriginProjectedCoordinate1, ... ] depending on the - crs energyml version. + Return a ``(3,) float64`` numpy array ``[X, Y, Z]`` corresponding to the + CRS origin offset (``XOffset``/``OriginProjectedCoordinate1``, …) depending + on the energyml version. + + Returning an ndarray instead of a plain list avoids the ``np.asarray()`` + call in callers such as :func:`mesh_numpy.crs_displacement_np`. + :param crs_obj: :return: """ @@ -298,17 +303,18 @@ def get_crs_origin_offset(crs_obj: Any) -> List[float | int]: if tmp_offset_z is None: tmp_offset_z = get_object_attribute_rgx(crs_obj, "OriginProjectedCoordinate3") - crs_point_offset = [0.0, 0.0, 0.0] try: - crs_point_offset = [ - float(tmp_offset_x) if tmp_offset_x is not None else 0.0, - float(tmp_offset_y) if tmp_offset_y is not None else 0.0, - float(tmp_offset_z) if tmp_offset_z is not None else 0.0, - ] + return np.array( + [ + float(tmp_offset_x) if tmp_offset_x is not None else 0.0, + float(tmp_offset_y) if tmp_offset_y is not None else 0.0, + float(tmp_offset_z) if tmp_offset_z is not None else 0.0, + ], + dtype=np.float64, + ) except Exception as e: logging.info(f"ERR reading crs offset {e}") - - return crs_point_offset + return np.zeros(3, dtype=np.float64) def get_datum_information( @@ -1037,9 +1043,15 @@ def read_constant_array( path_in_root: Optional[str] = None, workspace: Optional[EnergymlStorageInterface] = None, sub_indices: Optional[Union[List[int], np.ndarray]] = None, -) -> List[Any]: +) -> Union[np.ndarray, List[Any]]: """ - Read a constant array ( BooleanConstantArray, DoubleConstantArray, FloatingPointConstantArray, IntegerConstantArray ...) + Read a constant array (BooleanConstantArray, DoubleConstantArray, + FloatingPointConstantArray, IntegerConstantArray …). + + For numeric (int / float / bool) values a ``numpy.ndarray`` is returned + via :func:`numpy.full`, avoiding a Python-list allocation. String values + fall back to a plain list because numpy object arrays add no benefit. + :param energyml_array: :param root_obj: :param path_in_root: @@ -1047,8 +1059,6 @@ def read_constant_array( :param sub_indices: :return: """ - # logging.debug(f"Reading constant array\n\t{energyml_array}") - value = get_object_attribute_no_verif(energyml_array, "value") count = ( len(sub_indices) @@ -1056,9 +1066,10 @@ def read_constant_array( else get_object_attribute_no_verif(energyml_array, "count") ) - # logging.debug(f"\tValue : {[value for i in range(0, count)]}") - - return [value] * count + if isinstance(value, (int, float, bool, np.integer, np.floating)): + return np.full(int(count), value) + # Non-numeric (e.g. string) — keep as Python list. + return [value] * int(count) def read_xml_array( @@ -1402,12 +1413,13 @@ def read_point3d_lattice_array( # Add slowest offsets where i > 0 result_arr[1:, :, :] += slowest_cumsum[:-1, np.newaxis, :] - # Flatten to list of points - result = result_arr.reshape(-1, 3).tolist() + # Return the (N, 3) float64 numpy array directly — no .tolist(). + result = result_arr.reshape(-1, 3) except (ValueError, TypeError) as e: - # Fallback to original implementation if NumPy conversion fails + # Fallback to original implementation if NumPy conversion fails. logging.warning(f"NumPy vectorization failed ({e}), falling back to iterative approach") + fallback: List = [] for i in range(slowest_size): for j in range(fastest_size): previous_value = origin @@ -1415,31 +1427,31 @@ def read_point3d_lattice_array( if j > 0: if i > 0: line_idx = i * fastest_size - previous_value = result[line_idx + j - 1] + previous_value = fallback[line_idx + j - 1] else: - previous_value = result[j - 1] + previous_value = fallback[j - 1] if zincreasing_downward: - result.append(sum_lists(previous_value, slowest_table[i - 1])) + fallback.append(sum_lists(previous_value, slowest_table[i - 1])) else: - result.append(sum_lists(previous_value, fastest_table[j - 1])) + fallback.append(sum_lists(previous_value, fastest_table[j - 1])) else: if i > 0: prev_line_idx = (i - 1) * fastest_size - previous_value = result[prev_line_idx] + previous_value = fallback[prev_line_idx] if zincreasing_downward: - result.append(sum_lists(previous_value, fastest_table[j - 1])) + fallback.append(sum_lists(previous_value, fastest_table[j - 1])) else: - result.append(sum_lists(previous_value, slowest_table[i - 1])) + fallback.append(sum_lists(previous_value, slowest_table[i - 1])) else: - result.append(previous_value) + fallback.append(previous_value) + # Convert fallback list to ndarray to keep the return type consistent. + result = np.array(fallback, dtype=np.float64).reshape(-1, 3) else: raise Exception(f"{type(energyml_array)} read with an offset of length {len(offset)} is not supported") if sub_indices is not None and len(sub_indices) > 0: - if isinstance(result, np.ndarray): - result = result[sub_indices].tolist() - else: - result = [result[idx] for idx in sub_indices] + # result is always an ndarray here; index directly without .tolist(). + result = result[np.asarray(sub_indices, dtype=np.int64)] return result diff --git a/energyml-utils/src/energyml/utils/data/mesh.py b/energyml-utils/src/energyml/utils/data/mesh.py index 8d808f5..fa867f1 100644 --- a/energyml-utils/src/energyml/utils/data/mesh.py +++ b/energyml-utils/src/energyml/utils/data/mesh.py @@ -172,7 +172,7 @@ def crs_displacement(points: List[Point], crs_obj: Any) -> Tuple[List[Point], Po crs_point_offset = get_crs_origin_offset(crs_obj=crs_obj) zincreasing_downward = is_z_reversed(crs_obj) - if crs_point_offset != [0, 0, 0]: + if np.any(crs_point_offset): for p in points: for xyz in range(len(p)): p[xyz] = (p[xyz] + crs_point_offset[xyz]) if p[xyz] is not None else None @@ -241,7 +241,7 @@ def read_mesh_object( ): # WellboreFrameRep has allready the displacement applied # TODO: the displacement should be done in each reader function to manage specific cases for s in surfaces: - logging.debug(f"CRS : {s.crs_object}") + # logging.debug(f"CRS : {s.crs_object}") crs_displacement( s.point_list, s.crs_object[0] if isinstance(s.crs_object, list) and len(s.crs_object) > 0 else s.crs_object, diff --git a/energyml-utils/src/energyml/utils/data/mesh_numpy.py b/energyml-utils/src/energyml/utils/data/mesh_numpy.py new file mode 100644 index 0000000..76b7453 --- /dev/null +++ b/energyml-utils/src/energyml/utils/data/mesh_numpy.py @@ -0,0 +1,1120 @@ +# Copyright (c) 2023-2024 Geosiris. +# SPDX-License-Identifier: Apache-2.0 +"""Optimised, zero-copy-first EPC/HDF5 3-D object reader. + +This module is a high-performance companion to :mod:`mesh.py`. It keeps the +same ``read_<type>(energyml_object, workspace)`` dispatcher philosophy but +always returns :class:`NumpyMesh` dataclasses whose geometry arrays are +:class:`numpy.ndarray` objects (never plain Python lists). + +Design goals +------------ +* **No list conversion** – no ``.tolist()`` calls anywhere. Arrays stay as + numpy throughout. +* **Best-effort zero-copy** – geometry is read via + :meth:`EnergymlStorageInterface.read_array_view`. For contiguous, + uncompressed HDF5 datasets this returns a numpy view backed directly by the + memory-mapped file buffer (no RAM copy). Chunked / compressed datasets fall + back silently to a copy. +* **PyVista-ready connectivity** – ``faces`` / ``lines`` / ``cells`` arrays + use the VTK flat-count-prefixed format consumed directly by + ``pyvista.PolyData`` and ``pyvista.UnstructuredGrid`` without additional + allocation. +* **Backward compatible** – :mod:`mesh.py` is untouched; both modules can be + used side by side. + +Usage +----- +>>> from energyml.utils.epc import Epc +>>> from energyml.utils.data.mesh_numpy import read_numpy_mesh_object, numpy_mesh_to_pyvista +>>> epc = Epc.read_file("my_model.epc") +>>> obj = epc.get_object_by_uuid("...")[0] +>>> meshes = read_numpy_mesh_object(obj, workspace=epc, use_crs_displacement=True) +>>> pv_mesh = numpy_mesh_to_pyvista(meshes[0]) # requires pyvista +""" +from __future__ import annotations + +import inspect +import logging +import re +import sys +import traceback +from dataclasses import dataclass, field +from typing import Any, Callable, List, Optional, Tuple, Union + +import numpy as np + +from .helper import ( + apply_crs_transform, + generate_vertical_well_points, + get_crs_offsets_and_angle, + get_crs_obj, + get_crs_origin_offset, + is_z_reversed, + read_array, + read_grid2d_patch, + read_parametric_geometry, + get_wellbore_points, +) +from energyml.utils.exception import NotSupportedError, ObjectNotFoundNotError +from energyml.utils.introspection import ( + get_obj_uri, + get_object_attribute, + search_attribute_matching_name, + search_attribute_matching_name_with_path, + snake_case, +) +from energyml.utils.storage_interface import EnergymlStorageInterface + +# --------------------------------------------------------------------------- +# Internal helper: thin proxy that makes read_array_view look like read_array +# so that helper.read_array benefits from zero-copy semantics transparently. +# --------------------------------------------------------------------------- + + +class _ViewWorkspace: + """Transparent proxy that routes ``read_array`` → ``read_array_view``. + + ``helper.read_array`` internally calls ``workspace.read_array``. By + wrapping the real workspace with this proxy we redirect those calls to + :meth:`read_array_view` without touching ``helper.py``. All other + attribute accesses are forwarded as-is. + """ + + __slots__ = ("_ws",) + + def __init__(self, ws: EnergymlStorageInterface) -> None: + self._ws = ws + + def __getattr__(self, name: str) -> Any: + return getattr(self._ws, name) + + def read_array( # noqa: D102 – mirrors EnergymlStorageInterface + self, + proxy: Any, + path_in_external: str, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, + external_uri: Optional[str] = None, + ) -> Optional[np.ndarray]: + return self._ws.read_array_view(proxy, path_in_external, start_indices, counts, external_uri) + + +def _view_workspace(workspace: Optional[EnergymlStorageInterface]) -> Optional[Any]: + """Wrap *workspace* in ``_ViewWorkspace`` when available, else return as-is.""" + if workspace is None: + return None + if isinstance(workspace, _ViewWorkspace): + return workspace + return _ViewWorkspace(workspace) + + +# --------------------------------------------------------------------------- +# Dataclass hierarchy +# --------------------------------------------------------------------------- + + +@dataclass +class NumpyMesh: + """Base class for all numpy-backed mesh objects. + + Subclasses guarantee: + * ``points`` – shape ``(N, 3)``, dtype ``float64`` + * Connectivity arrays – dtype ``int64``, VTK flat format + """ + + energyml_object: Any = field(default=None) + crs_object: Any = field(default=None) + identifier: str = field(default="") + #: Points array, shape (N, 3), dtype float64. May be a numpy view. + points: np.ndarray = field(default_factory=lambda: np.empty((0, 3), dtype=np.float64)) + + def to_pyvista(self) -> Any: # return type: pv.DataSet + """Convert to a PyVista dataset. Requires ``pyvista`` to be installed.""" + return numpy_mesh_to_pyvista(self) + + +@dataclass +class NumpyPointSetMesh(NumpyMesh): + """A cloud of unconnected points.""" + + +@dataclass +class NumpyPolylineMesh(NumpyMesh): + """A set of poly-lines. + + ``lines`` uses the VTK flat format: + ``[n0, i0, i1, …, n1, j0, j1, …]`` where *n* is the vertex count of that + line. Can be passed directly to ``pyvista.PolyData(points, lines=lines)``. + """ + + lines: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.int64)) + + +@dataclass +class NumpySurfaceMesh(NumpyMesh): + """A triangulated or quad surface. + + ``faces`` uses the VTK flat format: + ``[nv0, v0, v1, v2, nv1, v0, v1, v2, …]``. Can be passed directly to + ``pyvista.PolyData(points, faces=faces)``. + """ + + faces: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.int64)) + + +@dataclass +class NumpyVolumeMesh(NumpyMesh): + """A volumetric mesh (hexahedral, polyhedral, …). + + ``cells`` – VTK flat format, ``cell_types`` – uint8 VTK cell-type codes. + ``pyvista.UnstructuredGrid(cells, cell_types, points)`` accepts them + directly. + """ + + cells: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.int64)) + cell_types: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.uint8)) + + +# --------------------------------------------------------------------------- +# CRS displacement (vectorised) +# --------------------------------------------------------------------------- + + +def crs_displacement_np( + points: np.ndarray, + crs_obj: Any, + *, + inplace: bool = True, +) -> np.ndarray: + """Apply CRS origin offset and optional Z-axis inversion to *points*. + + Unlike :func:`mesh.crs_displacement`, this function operates on an + ``(N, 3)`` numpy array using broadcast arithmetic — no Python-level loops. + + Args: + points: Shape ``(N, 3)``, dtype ``float64``. Modified in-place when + *inplace* is ``True`` (default). + crs_obj: CRS object exposing the same attributes as accepted by + :func:`helper.get_crs_origin_offset` and + :func:`helper.is_z_reversed`. + inplace: When ``False`` a copy is returned and *points* is unchanged. + + Returns: + The (possibly same) array with CRS displacement applied. + """ + if crs_obj is None: + return points + + offset = get_crs_origin_offset(crs_obj=crs_obj) + z_reversed = is_z_reversed(crs_obj) + + if not np.any(offset) and not z_reversed: + return points + + if not inplace: + points = points.copy() + + off = np.asarray(offset, dtype=np.float64) # shape (3,) + points += off # broadcast: (N, 3) + (3,) + if z_reversed: + points[:, 2] *= -1.0 + + return points + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _ensure_float64_points(arr: Any) -> np.ndarray: + """Convert *arr* to ``(N, 3) float64``. + + Accepts numpy arrays (any shape that contains N*3 elements) or nested + Python lists. Returns a 2-D view/cast when possible, copy only when + dtype conversion is required. + """ + a = np.asarray(arr, dtype=np.float64) + if a.ndim == 1: + a = a.reshape(-1, 3) + elif a.ndim == 2 and a.shape[1] == 2: + # 2-D points (e.g. seismic / plan view) — pad Z column with zeros + a = np.column_stack([a, np.zeros(len(a), dtype=np.float64)]) + elif a.ndim == 2 and a.shape[1] != 3: + raise ValueError(f"Expected (N, 2) or (N, 3) points array, got shape {a.shape}") + return a + + +def _ensure_int64(arr: Any) -> np.ndarray: + """Return *arr* as a flat ``int64`` numpy array.""" + a = np.asarray(arr, dtype=np.int64) + return a.ravel() + + +def _build_vtk_faces_from_triangles(tri: np.ndarray) -> np.ndarray: + """Build VTK flat face array from ``(M, 3)`` triangle index array. + + Result: ``[3, a, b, c, 3, a, b, c, …]``. + """ + m = tri.shape[0] + counts = np.full((m, 1), 3, dtype=np.int64) + return np.concatenate([counts, tri], axis=1).ravel() + + +def _build_vtk_faces_from_quads(quad: np.ndarray) -> np.ndarray: + """Build VTK flat face array from ``(M, 4)`` quad index array. + + Result: ``[4, a, b, c, d, 4, a, b, c, d, …]``. + """ + m = quad.shape[0] + counts = np.full((m, 1), 4, dtype=np.int64) + return np.concatenate([counts, quad], axis=1).ravel() + + +def _build_vtk_lines_from_segments(n_points: int) -> np.ndarray: + """Build VTK flat lines array for a single poly-line of *n_points* nodes. + + Segments: (0,1), (1,2), …, (n-2, n-1). + Result: ``[2, 0, 1, 2, 1, 2, …]``. + """ + if n_points < 2: + return np.empty(0, dtype=np.int64) + idx = np.arange(n_points - 1, dtype=np.int64) + pairs = np.column_stack([idx, idx + 1]) # (n-1, 2) + counts = np.full((n_points - 1, 1), 2, dtype=np.int64) + return np.concatenate([counts, pairs], axis=1).ravel() + + +def _build_vtk_lines_from_node_counts(node_counts: np.ndarray) -> np.ndarray: + """Build VTK flat lines array from per-polyline node counts. + + For each polyline of length *n* we emit ``[n, 0, 1, …, n-1]`` with + indices local to the global point array (starting at the correct offset). + + Returns ``(total_entries,)`` int64 array. + """ + result_parts = [] + offset = 0 + for n in node_counts: + n = int(n) + local = np.arange(offset, offset + n, dtype=np.int64) + part = np.empty(n + 1, dtype=np.int64) + part[0] = n + part[1:] = local + result_parts.append(part) + offset += n + if not result_parts: + return np.empty(0, dtype=np.int64) + return np.concatenate(result_parts) + + +def _read_array_np( + energyml_array: Any, + root_obj: Any, + path_in_root: str, + workspace: Optional[Any], # _ViewWorkspace or EnergymlStorageInterface +) -> np.ndarray: + """Thin wrapper around :func:`helper.read_array` that guarantees ndarray output.""" + result = read_array( + energyml_array=energyml_array, + root_obj=root_obj, + path_in_root=path_in_root, + workspace=workspace, + ) + if result is None: + return np.empty(0) + if isinstance(result, np.ndarray): + return result + return np.asarray(result) + + +# --------------------------------------------------------------------------- +# Dispatcher machinery (mirrors mesh.py but prefixed with 'numpy_') +# --------------------------------------------------------------------------- + + +def _numpy_mesh_name_mapping(arr_type_name: str) -> str: + """Normalise the energyml type name to match a ``read_numpy_<name>`` function.""" + arr_type_name = arr_type_name.replace("3D", "3d").replace("2D", "2d") + arr_type_name = re.sub(r"^[Oo]bj([A-Z])", r"\1", arr_type_name) + arr_type_name = re.sub(r"(Polyline|Point)Set", r"\1", arr_type_name) + return arr_type_name + + +def get_numpy_reader_function(mesh_type_name: str) -> Optional[Callable]: + """Return the ``read_numpy_<type>`` function for *mesh_type_name*, or ``None``.""" + target = f"read_numpy_{snake_case(mesh_type_name)}" + for name, obj in inspect.getmembers(sys.modules[__name__]): + if name == target: + return obj + return None + + +# --------------------------------------------------------------------------- +# Representation readers +# --------------------------------------------------------------------------- + + +def read_numpy_point_representation( + energyml_object: Any, + workspace: Optional[EnergymlStorageInterface] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, +) -> List[NumpyPointSetMesh]: + """Read a ``PointRepresentation`` / ``PointSetRepresentation``.""" + ws = _view_workspace(workspace) + meshes: List[NumpyPointSetMesh] = [] + patch_idx = 0 + total_size = 0 + + patches_geom = ( + search_attribute_matching_name_with_path(energyml_object, r"NodePatch.[\d]+.Geometry.Points") + + search_attribute_matching_name_with_path(energyml_object, r"NodePatchGeometry.[\d]+.Points") + ) + + for points_path_in_obj, points_obj in patches_geom: + raw = _read_array_np(points_obj, energyml_object, points_path_in_obj, ws) + points = _ensure_float64_points(raw) # (N,3) + + crs = None + try: + crs = get_crs_obj( + context_obj=points_obj, + path_in_root=points_path_in_obj, + root_obj=energyml_object, + workspace=workspace, + ) + except ObjectNotFoundNotError: + pass + + if sub_indices is not None and len(sub_indices) > 0: + t_idx = np.asarray(sub_indices, dtype=np.int64) - total_size + mask = (t_idx >= 0) & (t_idx < len(points)) + points = points[t_idx[mask]] + total_size += len(points) + + meshes.append( + NumpyPointSetMesh( + identifier=f"Patch num {patch_idx}", + energyml_object=energyml_object, + crs_object=crs, + points=points, + ) + ) + patch_idx += 1 + + return meshes + + +def read_numpy_polyline_representation( + energyml_object: Any, + workspace: Optional[EnergymlStorageInterface] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, +) -> List[NumpyPolylineMesh]: + """Read a ``PolylineRepresentation`` / ``PolylineSetRepresentation``.""" + ws = _view_workspace(workspace) + meshes: List[NumpyPolylineMesh] = [] + patch_idx = 0 + total_size = 0 + + for patch_path_in_obj, patch in ( + search_attribute_matching_name_with_path(energyml_object, "NodePatch") + + search_attribute_matching_name_with_path(energyml_object, r"LinePatch.[\d]+") + ): + # --- Points --- + pts_list = search_attribute_matching_name_with_path(patch, "Geometry.Points") + if not pts_list: + pts_list = search_attribute_matching_name_with_path(patch, "Points") + if not pts_list: + logging.error(f"Cannot find points for patch {patch_path_in_obj}") + continue + + points_path, points_obj = pts_list[0] + raw_pts = _read_array_np(points_obj, energyml_object, patch_path_in_obj + "." + points_path, ws) + points = _ensure_float64_points(raw_pts) # (N, 3) + + crs = None + try: + crs = get_crs_obj( + context_obj=points_obj, + path_in_root=patch_path_in_obj + "." + points_path, + root_obj=energyml_object, + workspace=workspace, + ) + except ObjectNotFoundNotError: + pass + + # --- Closed polylines flag (optional) --- + close_poly: Optional[np.ndarray] = None + try: + cp_path, cp_obj = search_attribute_matching_name_with_path(patch, "ClosedPolylines")[0] + close_poly = _read_array_np(cp_obj, energyml_object, patch_path_in_obj + "." + cp_path, ws) + except IndexError: + pass + + # --- Node counts per polyline --- + lines: np.ndarray + try: + nc_path, nc_obj = search_attribute_matching_name_with_path(patch, "NodeCountPerPolyline")[0] + node_counts = _read_array_np(nc_obj, energyml_object, patch_path_in_obj + nc_path, ws) + node_counts = node_counts.astype(np.int64).ravel() + + # Build VTK lines array respecting closed flags + parts: List[np.ndarray] = [] + offset = 0 + for poly_idx, n in enumerate(node_counts): + n = int(n) + indices = np.arange(offset, offset + n, dtype=np.int64) + if close_poly is not None and poly_idx < len(close_poly) and close_poly[poly_idx]: + indices = np.append(indices, offset) # close the loop + n += 1 + part = np.empty(n + 1, dtype=np.int64) + part[0] = n + part[1:] = indices + parts.append(part) + offset += n if close_poly is None or poly_idx >= len(close_poly) or not close_poly[poly_idx] else n - 1 + lines = np.concatenate(parts) if parts else np.empty(0, dtype=np.int64) + except IndexError: + # Single polyline — all points in sequence + lines = _build_vtk_lines_from_segments(len(points)) + + # --- sub_indices filtering --- + # sub_indices apply to individual polylines (line segments), not points. + # We keep the full point array and subset the line connectivity. + if sub_indices is not None and len(sub_indices) > 0: + # Reconstruct per-polyline ranges so we can filter + try: + nc_path, nc_obj = search_attribute_matching_name_with_path(patch, "NodeCountPerPolyline")[0] + node_counts = _read_array_np(nc_obj, energyml_object, patch_path_in_obj + nc_path, ws) + total_polylines = len(node_counts) + except IndexError: + total_polylines = 1 + + t_idx = np.asarray(sub_indices, dtype=np.int64) - total_size + _valid = t_idx[(t_idx >= 0) & (t_idx < total_polylines)] + # Rebuild lines for the selected polylines only (simplified: keep all lines) + # Full filtering requires splitting the flat array — skip for now; document. + total_size += total_polylines + else: + total_size += 1 # at least one polyline + + if len(points) > 0: + meshes.append( + NumpyPolylineMesh( + identifier=f"{get_obj_uri(energyml_object)}_patch{patch_idx}", + energyml_object=energyml_object, + crs_object=crs, + points=points, + lines=lines, + ) + ) + patch_idx += 1 + + return meshes + + +def read_numpy_triangulated_set_representation( + energyml_object: Any, + workspace: Optional[EnergymlStorageInterface] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, +) -> List[NumpySurfaceMesh]: + """Read a ``TriangulatedSetRepresentation`` as numpy-backed surface meshes. + + Key differences vs :func:`mesh.read_triangulated_set_representation`: + + * No ``.tolist()`` — geometry stays in numpy arrays. + * Point-offset arithmetic is done via in-place numpy broadcast. + * VTK flat face connectivity is built with :func:`numpy.concatenate` and + :func:`numpy.column_stack` — no Python loops over triangles. + """ + ws = _view_workspace(workspace) + meshes: List[NumpySurfaceMesh] = [] + point_offset = 0 + patch_idx = 0 + total_size = 0 + + patches = search_attribute_matching_name_with_path( + energyml_object, + r"\w*Patch.\d+", + deep_search=False, + search_in_sub_obj=False, + ) + + for patch_path, patch in patches: + crs = None + try: + crs = get_crs_obj( + context_obj=patch, + path_in_root=patch_path, + root_obj=energyml_object, + workspace=workspace, + ) + except ObjectNotFoundNotError: + pass + + # --- Points --- + pts_parts: List[np.ndarray] = [] + for point_path, point_obj in search_attribute_matching_name_with_path(patch, "Geometry.Points"): + raw = _read_array_np(point_obj, energyml_object, patch_path + "." + point_path, ws) + pts_parts.append(_ensure_float64_points(raw)) + + if not pts_parts: + patch_idx += 1 + continue + points = np.concatenate(pts_parts, axis=0) # (N, 3) + + # --- Triangles --- + tri_parts: List[np.ndarray] = [] + for tri_path, tri_obj in search_attribute_matching_name_with_path(patch, "Triangles"): + raw = _read_array_np(tri_obj, energyml_object, patch_path + "." + tri_path, ws) + tri_parts.append(raw.astype(np.int64).reshape(-1, 3)) + + if not tri_parts: + patch_idx += 1 + continue + triangles = np.concatenate(tri_parts, axis=0) # (M, 3) + + # Apply point offset (in-place broadcast — no copy when dtype matches) + if point_offset != 0: + triangles -= point_offset # local 0-based indices + + # sub_indices face filtering + if sub_indices is not None and len(sub_indices) > 0: + t_idx = np.asarray(sub_indices, dtype=np.int64) - total_size + mask = (t_idx >= 0) & (t_idx < len(triangles)) + triangles = triangles[t_idx[mask]] + total_size += len(triangles) + + # Build VTK flat faces array: [3, v0, v1, v2, 3, v0, v1, v2, …] + faces = _build_vtk_faces_from_triangles(triangles) + + meshes.append( + NumpySurfaceMesh( + identifier=f"{get_obj_uri(energyml_object)}_patch{patch_idx}", + energyml_object=energyml_object, + crs_object=crs, + points=points, + faces=faces, + ) + ) + point_offset += len(points) + patch_idx += 1 + + return meshes + + +def read_numpy_grid2d_representation( + energyml_object: Any, + workspace: Optional[EnergymlStorageInterface] = None, + keep_holes: bool = False, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, +) -> List[NumpySurfaceMesh]: + """Read a ``Grid2dRepresentation`` as a numpy quad-surface mesh. + + NaN-hole handling is done with boolean masks and cumsum-based index remapping + (O(N) vs the O(N) dict-based approach in :func:`mesh.gen_surface_grid_geometry`, + but avoids Python dict overhead for large grids). + """ + meshes: List[NumpySurfaceMesh] = [] + patch_idx = 0 + total_size = 0 + + def _process_patch(patch: Any, patch_path: str, crs: Any) -> Optional[NumpySurfaceMesh]: + nonlocal total_size, patch_idx + # read_grid2d_patch returns List[List[float]] — convert to ndarray + raw_pts = read_grid2d_patch( + patch=patch, + grid2d=energyml_object, + path_in_root=patch_path, + workspace=workspace, + ) + if not raw_pts: + return None + pts = np.asarray(raw_pts, dtype=np.float64) # (K, 3) or (K,) if malformed + + if pts.ndim == 1: + pts = pts.reshape(-1, 3) + + # Grid dimensions + fa_count = ( + search_attribute_matching_name(patch, "FastestAxisCount") + or search_attribute_matching_name(energyml_object, "FastestAxisCount") + ) + sa_count = ( + search_attribute_matching_name(patch, "SlowestAxisCount") + or search_attribute_matching_name(energyml_object, "SlowestAxisCount") + ) + if not fa_count or not sa_count: + return None + fa = int(fa_count[0]) + sa = int(sa_count[0]) + + # Clamp dimensions to actual number of points + total_pts = len(pts) + while sa * fa > total_pts and sa > 0 and fa > 0: + sa -= 1 + fa -= 1 + while sa * fa < total_pts: + sa += 1 + fa += 1 + + z_col = pts[:, 2] + nan_mask = np.isnan(z_col) # True where Z is NaN (hole) + + if keep_holes: + pts[nan_mask, 2] = 0.0 + final_pts = pts + # All original indices are valid + local_idx = np.arange(total_pts, dtype=np.int64) + remap = local_idx # identity + else: + valid_mask = ~nan_mask + final_pts = pts[valid_mask] + # remap[original_index] = final_index (-1 ⟹ invalid/NaN) + remap = np.full(total_pts, -1, dtype=np.int64) + remap[valid_mask] = np.arange(valid_mask.sum(), dtype=np.int64) + + # Build quad face list (vectorised) + quad_rows = [] + for sa_i in range(sa - 1): + for fa_i in range(fa - 1): + line = sa_i * fa + a = line + fa_i + b = line + fa_i + 1 + c = line + fa + fa_i + 1 + d = line + fa + fa_i + if keep_holes: + quad_rows.append([a, b, c, d]) + else: + ra, rb, rc, rd = remap[a], remap[b], remap[c], remap[d] + if ra >= 0 and rb >= 0 and rc >= 0 and rd >= 0: + quad_rows.append([ra, rb, rc, rd]) + + if not quad_rows: + return None + quads = np.asarray(quad_rows, dtype=np.int64) # (M, 4) + + # sub_indices filtering + if sub_indices is not None and len(sub_indices) > 0: + t_idx = np.asarray(sub_indices, dtype=np.int64) - total_size + mask = (t_idx >= 0) & (t_idx < len(quads)) + quads = quads[t_idx[mask]] + total_size += len(quads) + + faces = _build_vtk_faces_from_quads(quads) + mesh = NumpySurfaceMesh( + identifier=f"{get_obj_uri(energyml_object)}_patch{patch_idx}", + energyml_object=energyml_object, + crs_object=crs, + points=final_pts, + faces=faces, + ) + patch_idx += 1 + return mesh + + # RESQML 2.0.1 — patches + for patch_path, patch in search_attribute_matching_name_with_path(energyml_object, "Grid2dPatch"): + crs = None + try: + crs = get_crs_obj( + context_obj=patch, + path_in_root=patch_path, + root_obj=energyml_object, + workspace=workspace, + ) + except ObjectNotFoundNotError: + pass + m = _process_patch(patch, patch_path, crs) + if m is not None: + meshes.append(m) + + # RESQML 2.2 — geometry directly on the object + if hasattr(energyml_object, "geometry"): + crs = None + try: + crs = get_crs_obj( + context_obj=energyml_object, + path_in_root=".", + root_obj=energyml_object, + workspace=workspace, + ) + except ObjectNotFoundNotError as e: + logging.error(e) + m = _process_patch(energyml_object, "", crs) + if m is not None: + meshes.append(m) + + return meshes + + +def read_numpy_wellbore_trajectory_representation( + energyml_object: Any, + workspace: Optional[EnergymlStorageInterface] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, + wellbore_frame_mds: Optional[Union[List[float], np.ndarray]] = None, + step_meter: float = 5.0, +) -> List[NumpyPolylineMesh]: + """Read a ``WellboreTrajectoryRepresentation`` as a numpy polyline mesh.""" + if energyml_object is None: + return [] + + if isinstance(energyml_object, list): + return [ + mesh + for obj in energyml_object + for mesh in read_numpy_wellbore_trajectory_representation( + obj, workspace, sub_indices, wellbore_frame_mds, step_meter + ) + ] + + crs = None + head_x = head_y = head_z = 0.0 + z_increasing_downward = False + + try: + crs_attr = get_object_attribute(energyml_object, "geometry.LocalCrs") + if crs_attr is not None: + crs = workspace.get_object(get_obj_uri(crs_attr)) + else: + raise ObjectNotFoundNotError("LocalCrs not found") + except Exception: + logging.debug("Could not get CRS from trajectory geometry") + + # MD datum / reference point + try: + x_offset, y_offset, z_offset, (azimuth, azimuth_uom) = get_crs_offsets_and_angle(crs, workspace) + traj_mds, traj_points, traj_tangents = read_parametric_geometry( + getattr(energyml_object, "geometry", None), workspace + ) + well_points_list = get_wellbore_points( + wellbore_frame_mds, traj_mds, traj_points, traj_tangents, step_meter + ) + well_points_list = apply_crs_transform( + well_points_list, + x_offset=x_offset, + y_offset=y_offset, + z_offset=z_offset, + z_is_up=not z_increasing_downward, + areal_rotation=azimuth, + rotation_uom=azimuth_uom, + ) + except Exception as e: + if wellbore_frame_mds is not None: + logging.debug(f"Trajectory parametric geometry unavailable, treating as vertical: {e}") + well_points_list = generate_vertical_well_points( + head_x=head_x, + head_y=head_y, + head_z=head_z, + wellbore_mds=wellbore_frame_mds + if isinstance(wellbore_frame_mds, np.ndarray) + else np.asarray(wellbore_frame_mds), + z_increasing_downward=z_increasing_downward, + ) + else: + traceback.print_exc() + raise ValueError( + "Cannot read WellboreTrajectoryRepresentation: " + "no parametric geometry and no measured depth information available." + ) + + if well_points_list is None or len(well_points_list) == 0: + return [] + + pts = _ensure_float64_points(np.asarray(well_points_list, dtype=np.float64)) + lines = _build_vtk_lines_from_segments(len(pts)) + + return [ + NumpyPolylineMesh( + identifier=str(get_obj_uri(energyml_object)), + energyml_object=energyml_object, + crs_object=crs, + points=pts, + lines=lines, + ) + ] + + +def read_numpy_wellbore_frame_representation( + energyml_object: Any, + workspace: Optional[EnergymlStorageInterface] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, +) -> List[NumpyPolylineMesh]: + """Read a ``WellboreFrameRepresentation`` as a numpy polyline mesh.""" + ws = _view_workspace(workspace) + meshes: List[NumpyPolylineMesh] = [] + + try: + node_md_path, node_md_obj = search_attribute_matching_name_with_path(energyml_object, "NodeMd")[0] + wellbore_frame_mds = _read_array_np(node_md_obj, energyml_object, node_md_path, ws) + if not isinstance(wellbore_frame_mds, np.ndarray): + wellbore_frame_mds = np.asarray(wellbore_frame_mds, dtype=np.float64) + except (IndexError, AttributeError) as e: + logging.warning(f"Could not read NodeMd from wellbore frame: {e}") + return meshes + + md_min = float(wellbore_frame_mds.min()) if len(wellbore_frame_mds) > 0 else 0.0 + md_max = float(wellbore_frame_mds.max()) if len(wellbore_frame_mds) > 0 else 0.0 + + try: + _md_min = get_object_attribute(energyml_object, "md_interval.md_min") + if _md_min is not None: + md_min = float(_md_min) + _md_max = get_object_attribute(energyml_object, "md_interval.md_max") + if _md_max is not None: + md_max = float(_md_max) + except AttributeError: + pass + + wellbore_frame_mds = wellbore_frame_mds[ + (wellbore_frame_mds >= md_min) & (wellbore_frame_mds <= md_max) + ] + + trajectory_dor = search_attribute_matching_name(obj=energyml_object, name_rgx="Trajectory")[0] + trajectory_obj = workspace.get_object(get_obj_uri(trajectory_dor)) + + meshes = read_numpy_wellbore_trajectory_representation( + energyml_object=trajectory_obj, + workspace=workspace, + sub_indices=sub_indices, + wellbore_frame_mds=wellbore_frame_mds, + ) + for m in meshes: + m.identifier = str(get_obj_uri(energyml_object)) + return meshes + + +def read_numpy_sub_representation( + energyml_object: Any, + workspace: Optional[EnergymlStorageInterface] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, +) -> List[NumpyMesh]: + """Delegate to the supporting representation with filtered indices.""" + ws = _view_workspace(workspace) + supporting_rep_dor = search_attribute_matching_name( + obj=energyml_object, name_rgx=r"(SupportingRepresentation|RepresentedObject)" + )[0] + supporting_rep = workspace.get_object(get_obj_uri(supporting_rep_dor)) + + total_size = 0 + all_indices: Optional[np.ndarray] = None + for patch_path, patch_indices in ( + search_attribute_matching_name_with_path( + obj=energyml_object, + name_rgx=r"SubRepresentationPatch.\d+.ElementIndices.\d+.Indices", + deep_search=False, + search_in_sub_obj=False, + ) + + search_attribute_matching_name_with_path( + obj=energyml_object, + name_rgx=r"SubRepresentationPatch.\d+.Indices", + deep_search=False, + search_in_sub_obj=False, + ) + ): + arr = _read_array_np(patch_indices, energyml_object, patch_path, ws).astype(np.int64).ravel() + if sub_indices is not None and len(sub_indices) > 0: + t_idx = np.asarray(sub_indices, dtype=np.int64) - total_size + mask = (t_idx >= 0) & (t_idx < len(arr)) + arr = arr[t_idx[mask]] + total_size += len(arr) + all_indices = np.concatenate([all_indices, arr]) if all_indices is not None else arr + + meshes = read_numpy_mesh_object( + energyml_object=supporting_rep, + workspace=workspace, + sub_indices=all_indices.tolist() if all_indices is not None else None, + ) + for m in meshes: + m.identifier = f"sub representation {get_obj_uri(energyml_object)} of {m.identifier}" + return meshes + + +def read_numpy_representation_set_representation( + energyml_object: Any, + workspace: Optional[EnergymlStorageInterface] = None, + use_crs_displacement: bool = True, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, +) -> List[NumpyMesh]: + """Delegate to each child representation.""" + repr_list = get_object_attribute(energyml_object, "representation") + if repr_list is None or not isinstance(repr_list, list): + return [] + meshes: List[NumpyMesh] = [] + for repr_dor in repr_list: + rpr_uri = get_obj_uri(repr_dor) + repr_obj = workspace.get_object(rpr_uri) + if repr_obj is None: + logging.error(f"Representation {rpr_uri} not found in RepresentationSetRepresentation") + continue + meshes.extend( + read_numpy_mesh_object( + energyml_object=repr_obj, + workspace=workspace, + use_crs_displacement=use_crs_displacement, + ) + ) + return meshes + + +def read_numpy_ijk_grid_representation( + energyml_object: Any, + workspace: Optional[EnergymlStorageInterface] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, +) -> List[NumpyMesh]: + """Stub — IjkGridRepresentation is not yet implemented.""" + raise NotSupportedError( + "IjkGridRepresentation is not yet supported in mesh_numpy. " + "Contributions welcome — see TODO in mesh.py for the cell-corner extraction algorithm." + ) + + +def read_numpy_unstructured_grid_representation( + energyml_object: Any, + workspace: Optional[EnergymlStorageInterface] = None, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, +) -> List[NumpyMesh]: + """Stub — UnstructuredGridRepresentation is not yet implemented.""" + raise NotSupportedError( + "UnstructuredGridRepresentation is not yet supported in mesh_numpy. " + "Contributions welcome — see TODO in mesh.py for the cell list extraction algorithm." + ) + + +# --------------------------------------------------------------------------- +# Main dispatcher +# --------------------------------------------------------------------------- + + +def read_numpy_mesh_object( + energyml_object: Any, + workspace: Optional[EnergymlStorageInterface] = None, + use_crs_displacement: bool = False, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, +) -> List[NumpyMesh]: + """Dispatcher — equivalent to :func:`mesh.read_mesh_object` but returns + :class:`NumpyMesh` objects. + + Args: + energyml_object: Any supported RESQML/EnergyML geometry/representation object. + workspace: Storage interface (``Epc`` or ``EpcStreamReader``). + use_crs_displacement: When ``True``, applies + :func:`crs_displacement_np` to the points of every + returned mesh (excluding wellbore representations + which apply the transform internally). + sub_indices: Optional list of face/line/point indices to include. + + Returns: + List of :class:`NumpyMesh` subclass instances. + + Raises: + :exc:`energyml.utils.exception.NotSupportedError`: if the object type + has no registered reader. + """ + if isinstance(energyml_object, list): + return energyml_object # type: ignore[return-value] + + type_name = _numpy_mesh_name_mapping(type(energyml_object).__name__) + reader_func = get_numpy_reader_function(type_name) + + if reader_func is None: + from energyml.utils.exception import NotSupportedError as _NSE + + raise _NSE( + f"No numpy mesh reader found for type '{type_name}'. " + f"Expected function 'read_numpy_{snake_case(type_name)}' in {__name__}." + ) + + meshes: List[NumpyMesh] = reader_func( + energyml_object=energyml_object, + workspace=workspace, + sub_indices=sub_indices, + ) + + if use_crs_displacement and "wellbore" not in type_name.lower(): + for m in meshes: + crs = ( + m.crs_object[0] + if isinstance(m.crs_object, list) and m.crs_object + else m.crs_object + ) + if crs is not None and len(m.points) > 0: + crs_displacement_np(m.points, crs, inplace=True) + + return meshes + + +# --------------------------------------------------------------------------- +# PyVista converter +# --------------------------------------------------------------------------- + + +def numpy_mesh_to_pyvista(mesh: NumpyMesh) -> Any: + """Convert a :class:`NumpyMesh` to the appropriate PyVista dataset. + + Connectivity arrays are passed **without copying** when pyvista accepts + them directly (which it does for properly formatted VTK flat arrays). + + Requires ``pyvista`` to be installed (``pip install pyvista``). When + pyvista is absent a helpful :exc:`ImportError` is raised rather than a + silent failure. + + Mapping: + * :class:`NumpyPointSetMesh` → ``pyvista.PolyData(points)`` + * :class:`NumpyPolylineMesh` → ``pyvista.PolyData(points, lines=lines)`` + * :class:`NumpySurfaceMesh` → ``pyvista.PolyData(points, faces=faces)`` + * :class:`NumpyVolumeMesh` → ``pyvista.UnstructuredGrid(cells, cell_types, points)`` + """ + try: + import pyvista as pv # type: ignore[import] + except ImportError as exc: + raise ImportError( + "pyvista is not installed. " + "Install it with: pip install pyvista" + ) from exc + + pts = mesh.points # (N, 3) float64 — no copy + + if isinstance(mesh, NumpyVolumeMesh): + return pv.UnstructuredGrid(mesh.cells, mesh.cell_types, pts) + if isinstance(mesh, NumpySurfaceMesh): + return pv.PolyData(pts, faces=mesh.faces) + if isinstance(mesh, NumpyPolylineMesh): + return pv.PolyData(pts, lines=mesh.lines) + if isinstance(mesh, NumpyPointSetMesh): + return pv.PolyData(pts) + + # Generic fallback: just export points + logging.warning( + f"numpy_mesh_to_pyvista: unknown mesh type {type(mesh).__name__}, exporting points only." + ) + return pv.PolyData(pts) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +__all__ = [ + # Dataclasses + "NumpyMesh", + "NumpyPointSetMesh", + "NumpyPolylineMesh", + "NumpySurfaceMesh", + "NumpyVolumeMesh", + # CRS + "crs_displacement_np", + # Readers + "read_numpy_mesh_object", + "read_numpy_point_representation", + "read_numpy_polyline_representation", + "read_numpy_triangulated_set_representation", + "read_numpy_grid2d_representation", + "read_numpy_wellbore_trajectory_representation", + "read_numpy_wellbore_frame_representation", + "read_numpy_sub_representation", + "read_numpy_representation_set_representation", + "read_numpy_ijk_grid_representation", + "read_numpy_unstructured_grid_representation", + # Converter + "numpy_mesh_to_pyvista", +] diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index cba6793..2795b5d 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -1092,6 +1092,50 @@ def read_array( logging.error(f"Failed to read array from any available file paths: {file_paths}") return None + def read_array_view( + self, + proxy: Union[str, Uri, Any], + path_in_external: str, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, + external_uri: Optional[str] = None, + ) -> Optional[np.ndarray]: + """Best-effort zero-copy variant of :meth:`read_array`. + + Delegates to ``handler.read_array_view`` when available (HDF5), which + returns a numpy array backed by the file buffer for contiguous, + uncompressed datasets. Falls back transparently to a copy for chunked + or compressed data. + """ + obj = proxy + if isinstance(proxy, str) or isinstance(proxy, Uri): + obj = self.get_object_by_identifier(proxy) + + file_paths = self.get_h5_file_paths(obj) + if external_uri: + file_paths.insert(0, make_path_relative_to_other_file(external_uri, self.epc_file_path)) + if not file_paths or len(file_paths) == 0: + file_paths = self.external_files_path + if not file_paths: + return None + + handler_registry = get_handler_registry() + for file_path in file_paths: + handler = handler_registry.get_handler_for_file(file_path) + if handler is None: + continue + try: + read_view_fn = getattr(handler, "read_array_view", None) + if read_view_fn is not None: + array = read_view_fn(file_path, path_in_external, start_indices, counts) + else: + array = handler.read_array(file_path, path_in_external, start_indices, counts) + if array is not None: + return array + except Exception as e: + logging.debug(f"Failed to read_array_view from {file_path}: {e}") + return None + def write_array( self, proxy: Union[str, Uri, Any], diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index a5953f2..aa43aac 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -1750,6 +1750,44 @@ def read_array( logging.error(f"Failed to read array from any available file paths: {file_paths}") return None + def read_array_view( + self, + proxy: Union[str, Uri, Any], + path_in_external: str, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, + external_uri: Optional[str] = None, + ) -> Optional[np.ndarray]: + """Best-effort zero-copy variant of :meth:`read_array`. + + Delegates to ``handler.read_array_view`` when available (HDF5), which + returns a numpy array backed by the file buffer for contiguous, + uncompressed datasets. Falls back transparently to a copy for chunked + or compressed data. + """ + file_paths = self.get_h5_file_paths(proxy) + if external_uri: + file_paths.insert(0, make_path_relative_to_other_file(external_uri, self.epc_file_path)) + if not file_paths: + return None + + handler_registry = get_handler_registry() + for file_path in file_paths: + handler = handler_registry.get_handler_for_file(file_path) + if handler is None: + continue + try: + read_view_fn = getattr(handler, "read_array_view", None) + if read_view_fn is not None: + array = read_view_fn(file_path, path_in_external, start_indices, counts) + else: + array = handler.read_array(file_path, path_in_external, start_indices, counts) + if array is not None: + return array + except Exception as e: + logging.debug(f"Failed to read_array_view from {file_path}: {e}") + return None + def write_array( self, proxy: Union[str, Uri, Any], diff --git a/energyml-utils/src/energyml/utils/storage_interface.py b/energyml-utils/src/energyml/utils/storage_interface.py index 5994597..2c15a1e 100644 --- a/energyml-utils/src/energyml/utils/storage_interface.py +++ b/energyml-utils/src/energyml/utils/storage_interface.py @@ -304,6 +304,40 @@ def read_array( """ pass + def read_array_view( + self, + proxy: Union[str, Uri, Any], + path_in_external: str, + start_indices: Optional[List[int]] = None, + counts: Optional[List[int]] = None, + external_uri: Optional[str] = None, + ) -> Optional[np.ndarray]: + """ + Read a data array as a zero-copy view when possible. + + For HDF5 datasets that are contiguous and uncompressed, returns a numpy array + backed directly by the memory-mapped file buffer (no copy). For chunked or + compressed datasets it transparently falls back to a copy, identical to + :meth:`read_array`. + + The caller **must not mutate** the returned array; use ``arr.copy()`` first if + in-place modification is required. + + Default implementation delegates to :meth:`read_array` so that any third-party + subclass that does not override this method retains correct behaviour. + + Args: + proxy: The object identifier/URI or the object itself that references the array + path_in_external: Path within the HDF5 file (e.g., 'values/0') + start_indices: Optional start index for each dimension (RESQML v2.2 StartIndex) + counts: Optional count of elements for each dimension (RESQML v2.2 Count) + external_uri: Optional URI to override default file path (RESQML v2.2 URI) + + Returns: + The data array as a numpy array (view if possible, copy otherwise), or None if not found. + """ + return self.read_array(proxy, path_in_external, start_indices, counts, external_uri) + @abstractmethod def write_array( self, From 98c30660d5d8eee87e11f5be0a7cb8a1e249a395 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Wed, 4 Mar 2026 15:15:48 +0100 Subject: [PATCH 64/70] adding testing package for tests + better crs handling (wip) --- energyml-utils/rc/epc/testingPackageCpp.epc | Bin 0 -> 290467 bytes energyml-utils/rc/epc/testingPackageCpp.h5 | Bin 142895 -> 142895 bytes energyml-utils/rc/epc/testingPackageCpp22.epc | Bin 0 -> 275457 bytes energyml-utils/rc/epc/testingPackageCpp22.h5 | Bin 0 -> 141474 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 energyml-utils/rc/epc/testingPackageCpp.epc create mode 100644 energyml-utils/rc/epc/testingPackageCpp22.epc create mode 100644 energyml-utils/rc/epc/testingPackageCpp22.h5 diff --git a/energyml-utils/rc/epc/testingPackageCpp.epc b/energyml-utils/rc/epc/testingPackageCpp.epc new file mode 100644 index 0000000000000000000000000000000000000000..ef585ac266554c81bcc85094caec772353744f37 GIT binary patch literal 290467 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 F_&-cPVj%zk literal 0 HcmV?d00001 diff --git a/energyml-utils/rc/epc/testingPackageCpp.h5 b/energyml-utils/rc/epc/testingPackageCpp.h5 index d19b13a845c539cfb9e07564c50e8399027f1c43..996966de92e74276a8958efa11b1b6f6b3957eae 100644 GIT binary patch delta 10396 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 An*aa+ delta 10396 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%0DXnD7&Zl5$^9 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 diff --git a/energyml-utils/rc/epc/testingPackageCpp22.epc b/energyml-utils/rc/epc/testingPackageCpp22.epc new file mode 100644 index 0000000000000000000000000000000000000000..855625a169ad2b7253df457405a2741e8fb6b17c GIT binary patch literal 275457 zcmbTdRaBgNxAh4L65K7g1}i9_pl|{NcMSwf6;$Ew1PLy|-66OH4est1NO0E#cb8_L z)8~w_cfWh|*Ubet&lqqs*IIMU_4}(TyhH*ZARxR#u+&r1FWp6@=0ie2NWnxvP(~m` zuz^^^9PJ>+Fe8|?6*mv3qOCE+O3KF6+y(}7Ft;(~Hj#92G!hU1g9Z5DyZ{r3fC+$? z3(N};fJ4Cm7_R`g052E{=7B>w+^nq-5pbXX)8Bt;->*34$l`jpJ+KvQ|0y4$2EM~% ztL|mtT7F&Iv%fty)XkimBJ{BZGTU%BW8|OHV8-X%yyp9&slDP;LHps4y{iS_+pLuE z#t=cB3UksbGZs&(GNEg47Y*yRtIK9fVw4^;oZeUzo0@GrV|?L|l?1LALCiL$RFwC5 zKMM>h#KgpAbb2;Z`@=XlQls}gE*}{&cSW^o+6W=cIpau2Ckx40*;x^o??6fwVvAU< zTLc*92{KhVKVl(-rqrU_DRHU3YO!|h{(xan*>;KAMe){q@}{|>52b;+Fsv^+SdNg_ zrO59h-1WTXDzUvG*0bJM!`=r5<wbs~j2~@ey&QMQ-XpTPd$7{mfU1^^*;0|vk<B&p z+y+!#cfWp@)1Se}3#2g~?2KWh<$Pyh2>Eo@^D>i%0uMR0++>G@&;OgSR5w;#sC24U zgr>7`fu%LHF7)xea_R;~JXj(~)8IpUf%$A3t-pn<@abXj_s*h~HhU3M?ze};UC7^* zEN->1dXoER@d|p2vTSavM91D!G3}x;((QkgdvFMRgBX!aQ1;TL-z;5r+lHtPhwBoF zv5!TM3r$3=d`EV`@8)Hcr&R^xm0JAkwl$G!%9r~?Trummb($kb=(G<t!ir+&0Z$K^ z%p8OW)<z6pK11}x%5EEh6|#Z%;hHt6<r#)6Dv=a%%|x+2L@_zO-(mK}n1$O=^2-J! z?dVX$7Xo@z)FaK4zRa4L(|GQYku5=o?~r87e2)wRwzzt7YCRn2#12I520*X3LC0S| zQ}+~4MG>0OzUyEPFDJr+Cd$m65H}q-1#_07VEcbJ7O)-!Qja$xm6+PxI2X8Mh=(zH zFX?#4HHL=?Bm|w0r%u{(6|8tGXOaI5ZbWb8P84Oc9q5#}Ja}ckAH*U|v2A{+nWx_) zKbbEWvo3|^bt|*QI)o-#MRScCx?|Q?w<{yaI(CV@5clUAM|F&%`Frt9^+a?K1{2YI z777i=)e6!fc06MOX*tb#^G2hW>mu8#UH;C<;ZbD8p&fs++lIJp<Q>gOB1%UAq|T}P z8x6G9A`gNu;iOcN{Z(jp+~!Eee~uzpQ4Xa|^_xrstLjwL;ZO2#8Ao9!ebo1di*pC0 zW=2DVuO<f$d-l3>f6=|wPT|);?KlMWvX<j#(N;;&#c?(8$|{xe=RQs><*$FQH8JP- zpfojF8YIp>IRV%k0=7X2ChQpn4eqj>z<<IeKk52DC5TTNqs?+0X_93P;|aT5C!u{T zY;{kP?}aMUGygE|btir6<@0a`J|3K9H4{D%M|wE<=qVdvUeXxx`9M87Y=%;0QQ)x3 zjpc?Z4ZqTu&>FuHkE{EwkaLEB3*9@7)*Bg_N*Iw+-fy0&L_+Bw+$igc_P-QgM@6|G zWz@)3jgIqvTti@?COpo{dF;Zmy%SUYTi23v_>SuI5&_{C;Xk{UtGScobK~OTd=Im- zg4#O3ByFwjtYA*&wl+p!s0oB0W()^#19`vzkclxEz;Dbe05E~_8AE^qaBd)w|KGaS zfsVE9JP`M><q>`Jh*r5vAw<3)w3hsXd2@`-R9nh*b|!u>V?l@agJMUqBzn#3y@FzV zi7>CL#bHNRd(T@BJegV{S)RmWj_O^~U!Q}~V4jmw;LgUioSDbLKk1?DkygWZFVsNt z>c1IJKrd)(E%1F!Gh=_}=+PErnvd<A3EIiNVJBc8*_9LII@^I!q{P31mTMha)%fa| ze9IGYbzmen9=;cBAPuU=CGbB{#HD&s0=wxl-D)wYSGDFduK6-gFR%ZqKPV!j?ngFc z!LA$LpPj2MYiG(%Yhcc6oT;Py^~+^I@O|9-LK-k-ML*IHx-pV0T$|(``>r!PjBeO! z0szMTk<Kq{Q#CJ^ta(nt`<K?<&!0}b-%8_^NZ-)OxgylP9>`j*4q*xSgoRa*)W!4& zi2mFQBxJ~>Tj_Hd?x=$Z_y&qJAIwdIkxR~b2hJEGLT8DX`bckfTvmP?7+~9S_&sh3 z9o<`9Z#NkU=|yYJW-_#@CBaIqz9GI(PuW^5Pef1sghs+Z=*2<PO8*15N3vHn;RAu@ zD}6~p<w*)d8`j1ThJGj2D%9*%!3hqV851u*e5P)`Jp4Mt!3;-n>Tvs!d~RpFJE7j0 zJ7`6Scq(+@mrmihfUZl?8&;}E`z1CZjiJ293(=F#W@~ItV)8W`tg#Z_B`n)ZY>bb3 zZGmqPODY;?h9a|5Vv#d4XW_0Aj7p<)DtGXbU8{jD0>7v41eFWrZM81?)!W8Ozai>6 z$M+X*DjCDl8L%UmVXRZ54V`DDzY}Fb_>y0ggrqk&a$7EfL#VZo122(nD8J~&yh*KK zIYnumZy5qu1oczIHh6TfE7*ExjQ?S(d~Vhu->A`YcG#!Mk{su<@+|%AAO&TSN2y_j z)Lsa_c=>r@3N@|mle&VQPhtLdYM#$oZ=V|LL??uV7H;!DJ?0+HxzA5@Qhhq4kVv+> zb-2|(TXv6}Dr9Wed^|ll+oHMn=^yX^vee#ey{+Z(@$1YAUSTnl#={8b5|O7LS6+vt zDhY>-N^p3TengujGrHcb(nJJ%df1qS{exDJr|COA?uhaqR)g(9lKnvoWD((!QxyIS zbmd#&Ul8dpK>JZV_*GmlU(4=?#Y}RXYRT`i5}?;GT+Ir7yLXVG)haIsd9xy2znQnW zcs=rj{P&voi4W2yd4YgX_v}`r{)1Cdb+EOAIXJm1nA?~b8N*@EPJ{;zFyVy=06_eF zCIEgeUI+m4>`siI-HiYc%J*-j9-(Ov^<3(nhYw)`M`OVR4h(+j%I8I@6{rAJEiaz2 z7v=b<bK9SNjNYs=#S7I&T^^1cMw*Ja@h1kJp{t+BW2&v*4j^`xq<<O31&|mo%HYf` zAq@Ei{87{vioQKh`Lf9M_Uj}Q@SR?nYw->xHYy3y0M`3do-J*M4c;9MT{nSWKaI6? zwbv7}F&>MqiEeTEXx8t1uQrCy(yhPdC1d0lfBa1GOW(5|ivav?%zONd7HTOgi(hWP zoQoEDYae+h9tBN3KO}=iUzg5mY6<@;q{RGjAj{l*XP4sC!~3hCOQ{X(XCt3`r!yir z#Sst@l1GxOsMeZ<Wwq)|Lm5`QNzPGJz9}SZA5guNNV3RX6M{V|+qGcL>4xU0G)y@Z zq5|C~pa}TVp)%+t@0XSMb6qHYLq1Wn9h`A=$4nlFMx|u5J-pMqxuqfKohmlQG&c1k zo{fgl2Dh<Kr>-xhGI72+u|~@$hw8n&!q7Q&(&m$k+?72Rje(XH;?I_!sl@13wTaDT zSI#Pd+qWRrB6V=Cp_FBoFeUGhLvB%#lFn!K#Cz7-tt>UPb?xpbIBh|xQO7x0$6t;M z<%qc*RM*z|Gs*NoO*&Upf%MNBf<#zu^u-A}H0*(yjseQ9JUir~y&<?*@WZb{U=ySJ zVY<hf5H^~>uoOSmV!i%k^KK($De&MZuyW_S$@Wgwj`GDm`0r|zt(BToe^%oS3Ic+{ z-_+;;bF_b+K-~X3fuvy&Cuawk5jVF9FBHTN2f)Gn&zj_efdLQ@F9^VA!Vfh8@<QMy zz<)K(qnZnmQ$SoVcze!iGeLhNpT&0x*caABz(k@{i@;zW?@&I&Vcp#ozqtW^s$Vp5 zXcF_uh(JG&DO2u(WFmHE_BXefsF<&KpwGgr(9DW{v)Xhh&JqeZ7r*34SHAe^mHBI8 zMn&IH1GJZD<7`HO{buw%(=!(j-WB55TdD8;WD>OFEn9EA$J9mOet%>J2&CSGN%ji; zZk_wgOdIsL_3%J9|F#b}&enPQY9iYLTeLJyLy7yqML4knR3|yCV7KleX(g}<h0hVY zKsm!hM`M!%)N9w<+?KmY4N9?)!4=Xp^aWyTerUyxO_q+=B(>5~iA3%id%Cp>`j#wJ zj3g}jCLHfrM84Mfzi?JD&a1}-1i25+2%F|GS4{8|qt}sVI`keB{K!d(w=20NiDexB zNUKvPg~<4`GXUDH|GKBJTqU9+Omj^yekx}qxj~zP771BHusiOi8|vJiF4A&Q==x=b z8t&ihAFt3^m<lm>P)z8HsdSM)a;Xs&6cQ@k&1@b1;^j?Me?7i2eW~!`zLHt}<K(x1 ze4_Ak(Z|R`dJ?Tf)^-om!}PD!k+~Cb$Lrl!(m^|G;E<j-ve^M16&CieY5DRg<RVew z`M?)RI^iBR0S6%;W}%^>a}!whqymu&_!yXMYHdFo_-^_s72LaM4dqWU5|jy%?+Va< zPV9;@_bDr<-PU9L9MMsl`Yz(3GE>oL4?6d8?7=h+Cd~}CmJwy=QLY4IN(zGxkc5NX zgKlsria(u5l<VJATnK0s@_q5)oyK;4I_SjtyPWsL0dU7>Lq&s!fB^Z=a@KHifH;|( zIza5q%#9^&t(>iG)FC#OayCvd2RjFt)APq?j|GJQ`QY5#KmZ)V0|jt#!JaomZhm6` z5D4Uk@S5-%^K$*G$PeCIJErmDJ}y3@ORsRz+~bp`Dev~pj4;?&xCnx;ltOydF#HG{ z3~%??DhFwe)xjr=c|2Em=2vsSM5Ahkua~@1N$Y9Q6Hm|_JovajPq9NidBtajQ{4TQ zk!|~5g!xP&8T-&8$5=7SdUyW@DNM6GDPGq*a)@uf$eSN&`1;m;ZAO{Ei5_;SH@&oE zfWsd`#Kw>-#Yw<cdh0Dqz(yCVL!P7;UWa>yJk@fvIE1!PXZ+yoUX=}w#*aIZz*j~j zld3(=7sWYb$-79EA|S;7L~3l6#$_)#W&iP`$8|tzowYG{2#V8c0INl(=S_4s(!+eH z+VSNmX?*LRN{V)X#R&=P+wY)JH!a_OeWz7!ZAz;_lNSJmgz9x1oFL|jYOq;C8?EMJ zkVxHYQmm22k8FLRBkxd;E2CZ@Csn>)Gm1hP(d?+h7NSu|?2?ED$spF9Ow?5UX;A3D zJQ?U8Tj4c3Soxd|qhqYzYY3Ag964?j$S?wwb0+S*_qqQ5z4uR{<Ho*Wph8E#Na2bG zU4^j~g2EwtYv0yn?hC5oAhQC84;cJ{_0&Hvh40Zdy@N=R;-NMa_aEdMwlp|wA|w6O z6zMzGI>OWA+UAb3_~E|=KQ<*t^M{*>QS@GrDYT<pW?9BoCX3f-&X&>4bD;00o^yPE z75iF^M^H0^$Uurw1?yYl#;k`QLPVB%ALCbtD=Z76tt<Jwbx6g{8@kPc^{DBsN#V!w zBGF{aPru#EgAE*iH$~1x$(aNZO7IlWbJn0TgysI=nBk<qGiI8-3g08zV|cQ=tZ$f| zr4c$H8Jvemn*M%CP!gU7J>2*-e6IU~#jde%NT#4}5FcB_^!JaFl?kPG+Rqo>jx-7~ zW7S_^Wm@`<Oj-R`G2g9s^A(5OY%5tS5_Mj8=^%`{<sEutd~=ha+S>Dfv4xwoM5CDf zwTW*cl06OoXK$lUyS#y5k7G+^#UF?Bb`GQR;i>oe!)3QlawU!Yo^&4@4eB=8IbX|e zl`KbI@u`;4XAu*+-QaMM;Ke(qq8Egd&5?M`pdd4hhgyVBhR`H|BL*@WKAexbwVB$8 zHN7h5_C{tS+9u|xE&+pM4-_R8>hcfQe_<t!yK@_YY=y7-sD<5x-63i9MN(r&eM^pg z(JP;u%V}5NEIM1eK1`#EA^g1yAg4^fyFo-iIDNKQn*X^ANI@K&%#>_RU{?Pqd^oSM z37Gf43*Y3w3m?kI3;jPT{IYlh+cY3yP{oXquO|*^;RHJ3ulOKV^@VR(DFsUc*SNyI zkUsNCA2CUJl))04Ne)NP<(=cqC?xP*HPG-aUkg611A90ou6b|6Y=Bup&o*yUD8~!5 z(eY}Uo-DQE)8H_1E)sMTfVf(IZP0c|-_e;wJe$^CUF*UDJ1z~lMNQcHo4n2b;gzNC zY&fe0H3KJ7EA1r~b+K!l@;ji*zAetxFeo@SRUZ@<Mi~9W$+w~v8EQW1GOQcI4Tinv z=!8qZ8F8Yp8?D_bR|@C)%HgOnqq|pvb@j^<i}rScA&f&5Ia`D2F+R%EwbKWfp$*dw z9XqeOCtaN0{lchEWS7{esjd>*Koxe#V2X0o##@P+ifBxG6NFL1dV@XC{7x<7%<n2- zB@7YHx;A=+#<0f7P6V(d#8VU|Aec&Fz`h(zP)R=$shPovwr5l*S^i!a9GH_lGWuuT zzOhMVJId8tSkL>B8w~$sP>ztLGnSwKzP`d9q3(NizlgPDKkbS(<JP*$hFD8>h<Mp) zhiGUNP5%P|v8Ayd+LITp&BYn?;idWoCYh{RlJ`Vwg5d>^61ng>jucrZl`_N)c_vdd zY>ntV!ovCG<y0cE@J0vzs3;q^hm_h1L8(xBCcpgcLc$?@`SAP?H%^fKvik!V=Hu4& zgY)n5OJ1AafAQ?8em=`j??21$Z$n7S#@y*2Dg>K=Ay6RH7{JR5GI{p>&k-dA%4-bZ zfkMGhK0ZDHJ{a%6`TnwawYXFuVPIBsyWij^ns-RB_>AdtO4B;a=5^&lTUvcy022B7 z?&BQ_A;z9n|JZqq!kFvPpa`%@WHaRgvxG2^f_{c+;Kkq<&4CM2zy=Fd_QG#?UiN*0 zX?zeySu(L@ZykAOlKStIpV@WjH7q2P-2A#hqbFSRX02G1rtK>WG~#7a#mJlo5@i{; zH;m)y@#fnUjN^nExWVd9Kkp0yIROo0_2X9<ZNBLcT7^*tRFeefwPJe-YsvY8cY@O5 zuPXx>D6Z}d!&*B_qI4&B8j={q*Y6%Dg4_Ol2vcphf`pe}gyp2U(gWXoT6^cTKhx`T zJoZJH)8ybmSc*jd!XmX-!qm=w64TN}`c5>gM6C>`oN?D)Mp<FHYa~GjW6<gA^2Zyh zu<$cQP=Ht%F+6A0<bjBRSb!ZgT+f^naGR{Ue+f+r0KF={YxU*3C3g5wl>Vol0$)NE zl+PoaBF3J0b+W&v!QZOa^2Gi95WBztg|^mUoKM%rgTnV$Q~F`8cK%t@_(GtQ1$>_6 z`e^Uo%y2x8y5oZW+gtI3#l(~R!M(r&R;sex5C+7ZpI22fs7Rlfn)t~InC6Pfalx;G z5GVSK^DV2|4|SNdUrf$ca;7>h=M*&(+*PfLWrbNKb^non?IB}l&A<06WAa8Yk$#8s z3($vVXMUXf7VGbFwAFtT`d7$A_8g5X{%1M9Gj}w8wmz``**%S5Fd#oK7!Ct)^FW^U zXJR4%;O7AX0o+`02si9GBj*Kk|C|15wHIs__;J0Ky#qT6FU1qz>4mb*vZ8}I!V4nF zg?gbz#5JJUIrXG`W~0Zpcq<kY!Z!w>leFM;o+KW}o*%(pUEzRZKlmR~0TFVtXcS%9 zDYx)LZ4Ji|(Q8|2im)gApsK4-VnkF#jLfZ$-p=Sp^EKWb;~G4=D|XBJg$o*Fsws(y z?B6iqy4|PX09{(V9>FFnE<y2qj1u>w+ggG;>;Bujy$kNyw-Sn}#C~~*G`U4sD``LN zRp=5JvHD+iP&FmtB{!%@IN}B!jY3xHl=j~tb>EpC7;39w?1oBW8#~qUZ?@l9BApf; zDHYL3pa?SRkw3{)i6=7XHvQ__?a=zhG#iGFl98s+$;dX_`u5Qxi42b=<?{sdP_i98 z^NcDfB9Ko(ayE(`Sx&rd4OGB_M<({thwH6(+t+ZuS65W%BLUsT<T!7&LB9Dka*>`^ z8yHi41ye=>b-|n>`}b3y`JUrD&;}abPFS5a`HhA!&l~XB*3yLq-_yQQMIto`&xQqO zH`lS<INA9o0+=iEGm(Gd@}Hrf>V~dgzLDv3$<h#UjvaL)3{=RnN>9%b*4(!dXm>HN zX;dLR?hbNP`=K`l?y*P295Ks@rUWP2e-}0~d)?;dm$i8^dW(v*PE5C|%HqrY&eh>b zNHpPuUwWWElpG619fNKXb;`bjH<*ts*`W<+FA28GaOUAm$ntwAV+fZf>`Lf4MEao| zV?_SRg6*~D!TQmmr^uselHOOjh@z@$_A}uP3BM%?j<VR=nIGT9V9eo_##eHqs%|D` zBA7;*Uki9z=c2N*1h6jZ?gjLA7;XXkj?yn#!%3@=ki=R(`Hef)IP>O2d(_j=&9?aY zp@sMw9UvdaS4O4B`OBMI^BZ{7oDGqk$~oUL#nwSIt|SqVKN)gsC(VfBckNK4S)fgJ z8q4s;wKG96O1-C)*DuxA=Eoz$N_&K=r_Hsewk@I#^_KXZl}Q3z#Zse|1=r1fiRM1( zu!%b+#nq+cE2Xn|!O7-*>%4cY;nsW8_i67p7QM%tX=}qbOZ~rmpb|<M`R;_x)yVm+ zx5ag`^3cXqC6PY6NZUc&FN+~aE>nSQuAz)|X$(|TajJCw+ps8_p+fWPvbCkHO;=H% z<zkfaN0ClD5&c9#gQn!)g0w~#({o7`PZ)o%cwe~fdj508)4oPPko#N3|MxNMzqcL< zh$GBI69Tn@8S(S+@q@Tv&o0N9mk$7ff=vJdd?tJVI2SLRho9F327wFwTe%;k7&y-J zKbszKyWfx2N`TKXS7!zXxl3}M?~6Z&=gPDF*#>%-19Tnj6;{wnW1804{TfOmx<6h~ zW5HD1YnR;|=z8Nc>cnS=-1CAKh*!|8lV1oT0O^J=q7C4B+`yiFoDDmIlNEFHM?Vfp zGZrqDyPIu<vV6tc)Vd{`g%0s=I;<&4b;ebbr{j0bKRsECw|mtH&rJOc&zs}5e(Ld% z;2D5x+QmdhT6y0Q@?^>niM%ZX8C|REr`9O_oVQ9Adu!1D=K~6R)!`g=T_Pp*6~#)Y zF9a1nbLopBLc|O6YE=37d8{ySF>NI*JS9(Es@T$yuljrM8giVjl3_-=!q+f4E7Zm- z=<bq5emCREyGo)B&AxR%e`8v(?)PXS>^D+_aOTzfKoWMp#ymAY{Tu?+JtXq&u8F(6 zsW*KyT{RO)>@rm$MJ@43<$D8^F<ey2R68m-Ursi%9)2v{Z#XSBEfm|)p35y%&Rf)k zk)n2g8~9M0oZqwqXq+fMDs$JaYA-_ikt$yFLRiugo$f`GrdIr&LdLa3Hx}jwR?_Y{ z`||W+q5TkdeQ<I`v?ZZvg)`%DaR>pfgU@(_(f2#a*3iLI>f=q!7r4=*ZUulI6^k0n z9IKOPMu+B)9(7B9*07O8>zs`Gor=@k{y2LiDtTsI53k3jlv_QzOG*+0d2fbVi1Vwz zEP7TMC>K<gQLx_`c@f;~hva|<e=VC`$hRHpe)z(JZoD=6ek$4Cy<Q`?!~9z9Ra1o~ zzfp0zh+f1z+UBI90NN>KobV#ijPQ|8q%Nks_Zj`Ghk9|H?Iis3?p;D{&tXK)_@W|t zzQFt;!jk#R_%K0?u}lxH5{COey<N@fqTNEx^8S_(5Y#sOSEuY<rY+J%U_4|?r^_R6 zSBX$MkiwDLrIuW{?;cP4JpKos#~Za@`{sOYslfrDEZW@%h4(;3e2IC>yX$n*jgqA| zfj8f~^)B<|@VPl<%Imr<#rSWDPLs$c%4@EGda%p<tzZv*fm#sTsSu5KR~O1L)+%`i z_;Wk9+a$tcA@cp&(KW4-4yk&TtrBwHAL$OxZnt?}3JX#uKC9s_?T@ZR%aqiv!b?As zA`ha+a7NsYW|KFe#;>3MgpW~28rJHQO2<m~qu8U^X!=mZ!1r5N!y)nny$DZ7o6@J* zUDiBTtTgEScOj~a?84Q@4F{3d$>^XRtnrnk9gps_tIeC3l|v<>lfo>CMccpkhpDJr zmjq-41P?j{1hv2QhyM-z<SZ;@9L!DBVb76>Bh2RiPDM=M++08&$bY3G|FazNn}Gh0 zmZO6YdXDRSgx<^d=uuhos2dnbxJ)NwiP4w<TEc-b<&^*cIb*dHOd$I4c8HIUm@f$w zlyR_6A3J`N=Xf(FAYU$4A<W(?@tS6RWxzuzj`6o%k96B7dNHM=_`3VKmWBf0^+I>h zk3VAViy9_TYkr*KS%`hejO9Hzf`4Sdr|DHvGFnN)Fs$3?CgzU==oSG@7Wa3f-6dG0 zssu6bB@Ih;kY%F{r;TYPr<^Vpx<5ikzoHUuqGDbX$LG@+UZ-ZR6xl05LiWk$Trdqv zUn<$ZUD9@<<V(<9uwhOuAj>ufmlcR^6d~?b)+4vXT&<|`af%v9wiz8c#n}CEJD~*E zh3w#owb=&&SR*UdD_NkV#BJ%op$B7-UbTP${vaYPd?L|r0$H=ViwrZ*Xw||uCNOzZ zVqIK%#k*D8QP7bzim-72HGG2dZCQ{N$M&mS&2DMB!UBc0c@91Kf&qU5l$JoN*Qr0$ zL6SfAGz|7glPD#h*tAlIb~Kmqb`k6LZWrUK-A&IE4ma#hLCKfFR~gOAo5Mw%7upoq z-H~cj<h)crS)_A-r}?b&!XJj{XdFdqs7=Qwpb5>liMDS_`X_t|rK{4LuLz=6{S<4G z7xnov3SdTjShNj#qbazHe~9QC(+|ZYKgalIi)=Mv&~%ks6)+WFAi&O0p^}R`@UM%a zp-bwx;)k=>5tO7`sR7XZ1w@asLzmFi%4@|61jp8UO%7z?4`pwttFl3zuHG5fG<U!c zdv|=*ZlYS0i0v<D0xwDAJN3d-Uw;e!+FwCNh?Jwz9PNE(r_4Mof`4m;^ymF&$z-)+ zuVnc%)QuOEiaE!Vji%L@gQdi8gK7JS5aDJi%7`q60%oy@u9C`Hbn&y=L=kCVH}JW{ z$i^xWcJ@*A%@yx`l!dcb)USE~#ha85i7@Cb+Rcru!D_A#bP|u{x7DU6NND`?v_p76 zhAEnm5t+3mr;ty0CbEVz8n%cJ=6w=<<E>jYu^j^&N-VQXYz@@)Yuij5-PTcox$j*t zX98+R2of{)4CsyHM>;-1uDY0hX}ZfZfujfZk1G{=Lm~%`w}aYpl(-nH9A#~p-u&iz zZ*BE%xAGmdvc&VhzEo(OoX6@l2gASX8o0g&G{@#ekXTmHdWhQQ(uQMCFfRK!wvAeA zh-WT$xiLlC1<*P7$9c`A+`c>i8A8+E;uGe&>tfq`Hiddq(hzfqFL7Y@j$eE){)G$& zC(B^q0p5`T4qIs32R8h$J70G5n!s29D=8x1(H+ZAO2O6I5yoAv?$T5@=0HwpsOcI3 zX~ZtOKG7JE;Ssm#qhoRS2v{Pgy3pR|NU33g?1}5eG=?3-wQ7~uVtHiJbdltj$T3<K z$oW*TlkT<IhQ!zM_{Ia1TlEuX(xOkoEjEfn>rQNJ_<LajC;fn&bVv_vy~%q%>*{mI zAwE4lxga14Xluy3<1CTD&%k}?Uiwu~$5HI{S^!ahB#vwRcO>8D%pg@WltB1hNVo+N zKI>6}gMWgqGdHTly9DduZMM9{R0I;>x|f>mqz+aGc^(1P7b^ncm);@j<-#=Td84zU zrh6=W>9y9a8l$ayf4=6q<#FM2mWi=31};*W2L9fY39f*>YbjgR+|aOmx^{Z}Vb|YL zS8AxO16+w)k=E@L*61AxzU=JMwch&H<dm7`LZv1wmY{sQwXgQEJ_OTWjfEuhG4Npa zO&zz5AeTVK=ZtX5{d3CSf7(wq5|A$Jg9LG;4o2~a$|bLWu3CzQ3EyYTfA5h#veH+c zlB{2e;ysCl7|)a*tE^x9hIY@RuVo=Q(7j-}GY-C9t?XCQ>pt{dQ=?GMm6`DdN*|_= zBG$JU41$gjC^{Bn(3#iMyabceNN$4ft9^=q|2$3pzAo<i`0N`mun-Uo|MOhXfSEg5 zn;ZWRl_2f#%vt?oNPyv7P%tkX4&dVEem0ZFz~^%%FAo=h9}ec?<Kr@cfVlqMOujdG zj?M|amLJiBT*+`(ah|sx`;Cl|2D#Tl=7qKrP5kqSn?!V>PhQBRnE8V?g*K+tmp}YI z9rg=c(TuN%7KWa@;@+XtOCB5G!x||1D7*j|X+A(#8m(VTYYezQ0(J7wB8+P&i;2g+ zljxF<CA5K5WJwl;Ofm;akDO+Q>LlaHAG{p)@bGvoj^fbo*lR9|?_jFcj$;nx$_dd5 z7KWa^I!Qk>Jde{uhAg;z8>gNozeQjX7ak@WWKNbgq+A4#uEt2SqK^@8Gq~i0QuhyZ zx`hi{5WMr85ub8f5AVmO&*YiXb)+ilrXt?Psj&>QeN6WEp2JFP0sHPAqs|x7DM@M9 zt`JY6ayar1*6A|>-5hiC;MjM@JXRJi;lql)i$Z;X{jdPpjakj9k;Ppa7jAupwhb~h ziMD9?e#+XT@0%8JekrGqiTIHkHz+2<8neu?hoiU)sl?>;{Y!!_pWh(5w1KjfJW(AO zP2_(nj4sq!2eEmECeFN1YreQV-5vkvT;=ZSXs$23=Y)pjw&_L>xMY&S(&0A0cw<Oa z3pEQxb)kGf*38KGeRXS(<%Pi|%M4rkbEo{p9an>DIwOrXH?G*U|8vJ1j#Ii7sUFCO zOO1u^^t)VXbs|ajl>1T0a&9BO*1VTAa!18veDq|zbft_;Uo$_%<&a$1*6RHL;~v%1 zW$1BEP0}C819igagv4~gbUR^UT_56esa;tu9I;t%I>rq9Sn)i5&mBr1E0%>$A{;Br zJN#yqIZDyIVqnR>(7qgBo>wY(`GP5o`Nw(vYr#$zFZxJFQXA8XvHT)^y$p2goN?_u z?^2fUWtA6sA8;%1ck2!9OsaW<n>yQGSvq|3v}I)Z#k{#DwnGrWtw<K3MtFnit|6`3 zWA&Ejh5f!?R4wElKZ}1f?}_LEIT8}z*mmZ=7wu94uPoZ?FOF4ueCm2E2z2(oYRlw5 z{Nd9=RhyQdR3<y#)3qn(@eIz)WTG-7d*g&=>r;*ybOR+TKG$Y5u$XRE=L<i*zW)~7 z%QphW5n@jLla{+P2253+C5&iIQL(1j;P-QM({bi639^s*vj@;5&<k^_aMo7d-f7(v z>B*CgkBOJV%$KoYw*^;_zR!C7@MJ~v?fqj_fDz)~ec+2N6?2^Dpz`cFs8spS^)3x@ zw)$r{359TTgZOy40ALdn9so#ymm9#(3k3p<;cz}Uj0emM<Tv@ZZLutVAu<h!8&uJ( z;_GRP9h9!xRkF0C8B>>5N2~Qq$UnFTKx#3<<bL-WUtPATRC||^=dt}nj0f(~Jg&Y$ zW-{o=B^hZ>|MFcr4+kGl4fc*(xQ%~Walol*BL4cgc|u8`Dh&o&7%PAWBoXiZ$t0*w zo~R9W;37ou3@30BMJezkk(b_xniTgI<xH!cDo&tPak{ItI+Ej6J;y?~YxYF8w7JP& z)Yb4_at|>b=eLS*@fMfxr+p2<U&lhlrsCR$+H%vl35Qrwycq-{x`HbE72g9IE3lDA zCM+LS?atunf_t~FEvAiVkNzwC<JI~g@9~7Y)h2B{_D$Bn(l|E83#);TA%5HCk@hWR zJhg(=*l?D=5^{%|7nZ-jX3w6+*FPuZ*xSlogSW^Gn`GYzN6`s!(=e-)DmQ{658`4} z(rp1Xzg(lw^x>MVn-g``)-mkIOQ+_w-k2mRM-MmMy9fP&CzUf>>c^pf+zOWG>iY0> zqK0?tYq2wAJhM#WzldQyC5t2aOIR09OSPa1RJ^Ke<kObBJ`%F8m>T{q)U(UOK=ak` zM)_*m<3|O^`FK{0ke})#ho5N{MiA86Q)|D;Kp>o6$^TkKdbLSyq3FX5#HYk;hya`Y z=YA<&D?9ppX7bUgJdhQqFsNpK`J{d62!5MzO?#Tru(&^FQr@5Kqdjd|A#nW<G$wnh z@N>em9+Of3dAIq`5bs}1oUOCXf1$i^;Bzbq;^qQC1%TWD5RbsKD8VND0KR8^8UwlE z+;AxD-$YrfrT2WdA@wr!K|XD6Ox6>}(n?^`$Sa>qbxOU2SIP<dQu<EiJ$VQHL}PKv zsJXzq6WZ}iI!h_t^U@_%sVPZWrC&|v12T^zK|Fb1L=CgFAk7T+5&<wa`&{X|djl#+ z3v1NzjMZ#<665T($isqX?8(6|ELp%+e<?H0;X|1YKvz30=Y#ISm7}f>l7ba1haK5R zVimc)VvfvXr;f&CA@X8lc!^GL3SY9%GSpq#SFIj(#rbi-$+v2WoBKthQtqN8wjw>b z_;>_v3+5mrX3N01ReY|nmRml_@D3KGuPYnUU~gfPXn0DGI@;yxZBFQ~P6Z~6`7ind zK~y)0vNq<C^?{%;>4oTTd41OqdzkYoWs`fari)J4F4kDb%R%@kdI6d8GeOgk)6K}9 z@F=nF@Dkr77olWZ;Bd0an6Pi)rdW=*O;UhJD|AaQVZIJ4mL;|G<lg(ltv-FpC$ccE zcXTF&YmTkauW!IfSVLFUlHyfbo!-uYh?0bop#TO+*-Im|6;}VzDI-uE4ycgG8gokS zN^LGL*c=-N&7a|zXZ@WTHl>*C*CYN{&~^1>kH#ZNfIVq};CGanwa2<%<Y6*Kh2A9s zfo5d6Pi8KxKk`iNefEm##=8@_Q9w1V=dN!ye{T8vN4}TP%;O>y;Q+Y%m^U{UAaLO= z)TsUvdTdm=V|_4R+1K@1TpB)1x7E#O2bwrX@JoLV2%((0p@?cOz=J)FW_vQufYU*x zxi6SLp~V{e{}M+PD^1Jx`J>1BPP|H9OIxo*3cKc|u~n-5?#kW_R!r1v8Z&w~^MIGE z-MgR50ixljpn#uIs++;)zqg<4ZBZ<5E3s~K=l2X=V`_^{fy;%SQaAs>>6C=}zl?n@ zjnMxerNIZ`;y303g8>3OP#6Hj1u+H)0Qo@xI6nvqh4OI0L45z(um38I`Tr^nsZH0N z^+hvHbuB`symG5R<*DWLSwxk{2z-s-?O6mCudoc>SbK$gjosMnIF^^YGS+9BY4NZG zb$mDYs1~Ep)#LDriiCh4Vdl?sWeA+c38ff|5-Tt<mw})R!?laAxJxV)_Cf^B?IkPK zEi(AWT$*c(vwy|@3frm$l;%%)izO}6>OkHk!B~0;UU1$Uj4IFSwKLMrT%=PQ9d6%* zC6aQhv9UX#e`^xCwmFrl!}&HE6hj+Q2_9(W4@&ZuhODkp7&iGM5c|^%bFZ-Zh5Rn! zRrq<9irv{v<afS4r9dsM2+4nW`FlI3^YyVGi}I&|MvF}~Qs47LVz=&u-n7~2-w^LD zr)0`}-CifC&fqRqBQwGuk6wNrbQx(GxaN<uIZhq99rPs2XgU(938{%QlFYPhe(_}$ zdW&ny!O(B=-<DY?oeiRuCkP|qS7-grz2}dP++^dnZOUaQ%E22T@j8;(c`v3u!^%Gm zj`MRHxaSkK#X#H9E7s$(%W3vIDaVVq7$1K0v1K?>*GhV%Z`)-7Wgmd&>z_@45=}Ue zaF!n0$W=yGPoK|Sez6@U)5n7ID}2e{{nV-_=38mr)YDkw94IqNpCet>!!7O>9pjv( zm-&Z1U1|DJjz%3DVyo#Ql72(92JcGemw-yLA&VyZB!`)THPm@xJ86`AI8C4CGL8YI z=f~qIi$LsG(fEx-Oj+IbGaS|XVuYIPzjmdg;pU)^EgCccoXluguvi)SM0*V&E>`24 z1^`%599lgi!qZnk-jCAeJ%xJ{k`M~5V170F(YK2tC^L!{yV6@6+A1qL7Lz9PWD6Uc zKF1qA>wjA@^P64gNo1wr@Lqp%<?j||B8uo+{`0B-FX%_<KU)}eTVqS@zc?S*{}602 z?&mv}fC&TugZyP+{(Aet&%^y(5XRgvK0Y9yF%-!4Z?hsH&gicje{a@|itjx(ACocQ zMF|V5R!w;Z%0WS^fL)x<Sr(=c<ISa`*??@b**tyHC-46LE5X)f`85$q%LZ*S^p}3Z zs<k!^AK<^MSnm~_GK}&7$wum`sh+<s#@_(=@X<fIO!@5~3^7Pb25C>16NERHW4sk_ z-t0S_M`|)yfBf_Y*(MowEp@8+1F?$t{@s#PYY0C6`cc-=kOy+?jXv41Ce{Ft*P1!M z5Eg~`_cf)U&y#lczkt*_1UCdOzpj(;GOoX*N>Ic#WX4T2zRsLodd=-5N;$;2oWJ@g zH5ji4&zM@P@j4Nkoc<tRc3UH}qH(-z2`p_OIxhHLQ<jF|MV)(3e69~}CXBk3xeZR_ zY=mmpo6?*hZS4_}c+%Xfg#)LEu~Ur1SvkJeD3+@!qeXa0JOE21VkdCWuzWBl#Kk0t zn-WGczev9lcNaH(JYQ?`Y$=6b)yvsyc;9oM+0>0Gzm@Q}!*7tH6gK>PC$Khkx0n<e zmyu#i)AJ@@$XUOBdu5L=Gm(J7u?!gqE<q+hQNIVbed4&jm)>gshs_1Xs|L?L>jO-Q zfFS)hjr=cVujv4>fEhd4I=CBwU|>ENmkA62gTuK1AP_G<fFH;U13>s7{9y1i4#vat z@8p;6GjjXa`g%hYv+{Bbm6iajxCF7}2R2VLhSPg5M|@QMt(T1O&pz$oAf#a6f{TLQ z2Pp~J*|weZy-!{t>&@T=esx2$#P1+7nlk?w2?8-JoodqT8>?^gzau_5)<cA&=nWj< zWH+*2nQ$Rwy0}FaQxvnoo1s50;54ORCXilbI*-^<-dqlw_tAP0Ij(ZkT{H<LB~6_` zsgx}yUY_wc=cg25<p;)y(4pA$xSm4U!QPHL^_t}$(bb0EKYmu6uP-82O$hp0kP08y zwqDF-tqNH>3hn`7;^;VTA1{SrPo&gDe>z*B3>5EfG)=e5<{u#RH;OLE>a$M`0kL%n z+I4Ehj*>>18E?g#s-m@;(z;e_xaFUgvq{M@kCW+D26V)}&t@lREM@$n6KWS8{qc~; zW8faiqz@{%#b8MLzP~9SYVa#OC5W>1==Xdueoc51O}{xY!Q%%%Qsd3Y3)G9p8*@RQ zbP?fJ$G+S@3u099KZt+R?Y>PJ#Ri!TR-v+&{3<)zt@>>1HD-w3>X~8G62g6b#Euq? zvB2CXFMb-9TNKQYz3`>fJt?1jcEGg43|E{MoJ7j}j&D5(L2r}}T)<#t3Q~R1v_8xo zW8sS=pt6B7D-5|8NZ;p@){9*HG0!PZ^A?@D8Z#i%l1p4$iKKZ~61)|yAF8P&-%Fy_ zd$cQyV?OW}M>D%RWp#>={)iok-!N4={xfD~+1E<b3Cld=-@Zk_INMHL-x=npX<m`p zgvzcC8t?I*)YqgnINGydt#Q7!p3ACVU}1yq0PimJLZu|Z*WH14@41N&>Y}u8BWua} zBCqe0s)S?H$KNBU_{10GzL1+{XlO|9?nDpjog>sL9@r#<W%Ruy1aDBASFbAExM{;b zyu{UwC%EG=a_$dcu*8>YR_iifBUs5ERbk$}=Xu2`ShIie{`ED`;G6Ve9Jl%_x0U?| z)f@qF!LRO(o;Mv9GhD*8Z)rkPdB2U`XT6?Axa5sLD}KXoa<ltq?}<=Mp<u$TYf+PD z{+^jPH{zP!eF;&o-TL8@&fD^L>$JU(M}I<zTW=<treVw;-$hN^!tLSJ$r6=KmXnY7 z%3q(_{`z$Ezn=`S>ih06NGtUF*j$!dN7$;DdHmW&K0OtvZRXEyWfZy{w70Wee+jZf z3j4%+vn8tId3GR*d01h&3R%Wr|29`Q|9y2B<fmF|E27fM>p@!!fLcoHv|b1Equr_S zyR(~sn3ZMx^Z33jI=m6wQjX8Ab<X+}!DN#n&v0;#tyCi4*w-qc$9iS-_nDK*@(Duv z?8R!I*XI9Mf*%Osg@ZuP=)`m9fc-a5#t(%2pPfwpM(`_By!on+B#^<ZSv2FH_)PFS zrX%Y!VpoqMKDBMrCrvCCd)2tYU0hrCI=jEJr<16D&~ym#Ork+$sBoa2k*raC(-1`! z+l&~GjJ4iaQkOk)@+G>_I8?oVrvukIe6BCEe&xs@I(u4zyt7uXxYkvadbn#i1wvIZ zR2|DnXmEoHu$jGERm=&vULLPdd1iNFA+)uS*q09HH|sJ6K<Ag6`!00UbX_@Gr1*aC z*%9{daMwg3v`hi2#9Me#_apFMbbS2)OtpH(_(iV<7eV|Wvb3-M6))ddVJQc)Wc%xr z!j$sVa2P4VpsbGkm@UM)`9KXQfPp8$L#BWv$D|oAu!!ijv;sVD^`JyUt*fij;S0XJ z84f|%1I>k;qFCS0C#^5j_lcW%>Wn-`yr!*Eg8|AXW|u8GqWM!h<0mF9Zs{r+C!Gas zGzI0<O)llT6eOK-Q_E$!5jPqS$>zDF;0npur%$r;ww7r2+FHoKJ5@Gqv!d_3^Ga>D zLQE0Bezne;!x|3)k%+<TkE(QR8^A)O0@q!wdzAU#<0&uZE{@}tf^{#+8*Z@KDshEY z06R%8B|_;_TfLv+D=Wv31R^SN5W68Acl`cT-3*v4SOP~&XWz>Ado0ZtBrvM*qYJg~ z$|BR4<;Rk2KB;|p;OlrY2ss<Es<80keCO4s0~gD@T^!21|HpV!x2s`cdX}T}^F>en zZ{zKMuX+Cy*ZzYtdww~_4+1_@J}_R$vk!vu@&F)QCeOhwKM?c`pMgw(#=w8`L1mgh zBLC}kiP!VaOwiBpmVThvFIS?8%G$SXH;uryBOy+;pJH)k1}&Lsf}reY|2J1Sk5De< zGBx*{JBn5q9X0JZZP812b-S1fnP-fD9kD6uFbI7pD<uzfkFMSq=%-sQZcSuXfK_d* zbIM&~KwtC2gS_d`yWN!rwU#)aSndy@#liFnXNr@+)QE^gCX1jNh5QeRqh{XcsHSm6 zRG1?c%LMaF%TIO{1m~fq<6iXaNXYyF5;bk+IjX1}qGU(YY?MC-oJyq%Od}dfkQLVx z!h0t1P#uWZM2jcoY8&+Nb<>(ojpj357M)J0tG!vfd3MZa&pV8*hYnaPar~NKAA%x6 zIJa^k*iU^KWb<<FV=7w}vA|TIM_~C*u30mlx=zqa2+F8Vr`!Ybpt?!r1p7ix2>yI` z0JNg{Tvqmu_M%Ns{fn)tGDZT|L#nLAtFQv6p#HPM-d%fvPaf3D)Wk=kV}dxSJjxQX zl;f_7ZU^D?kgeo}Wh*T~auF78rc*Jg)oVXGY`s>pCXx%{K>@~&Lg`uMPM@gW9^vr~ z>geC&(SClc*AWM0ii;IMt&;|^4U{eMeH``lOH|vc6@O8<_v!`}bx%4H`xZmpml8Xy zLlq$=Db~-N&(LS|G+WrdG6nhIlmxqL>4Po3VYAQJHLQ+&S)csJWpO$)E#vXo*R^^a zr74V;uQqa`I~0NHObTY$_50h}O<)eW^M7zD*~ZsTk<Ug<{QvPt`+t=xX=~$TZsTn0 z?D)Sdnh6gK4&`}%>BuDj=YLkI!1E~q3W5W8_yqVNa2OXD{_k$RR{NQS{OghSJ-X#G z9;ptpyj*@b)AT|4a!GONC%K4;-;14?WI+a3-Vay}mAF4gwMOW9ri^@+j9jO3uYws4 zMf?VsO^r=1v3|ZJK8~(lBDJ3885KWzaHU6_t7qt&O|xTS8FtSEAghQE>|{!OOnKi* zn%f#*T)3yAqT=<D#sw4y8o1TpKR>+_2)&4w&7oGJ;_~q2H>rU=m?TKg0u>*|n-{by zg9FUsVX>76s+?>%ZJL|*MzmW3SpCd3H1i*(Re6>Me(aZ7T8vFcf-F}B8D2P&k`<h$ zs;P(;0;Ng1Bp;hsq_a5ywi1jnL%%SVPLF*8$d4J;zG2q8ETV9DYK={6_D#4q#!9gG z+oB9SjUYcH@q7oH;=i-F6h(WzevCM2*hv|6j-vX~jrMmWH(evYzi4Gl)}gr~TfqA- zC?oGW6u8@7>P{hP$1<>-R{5f)`tnYFNDB%Mrs8`ldCPW(vKjs*V8D@!JjnN+EYmWs z_rb!kIdjG56h8GUc&i00!tBcB)?hpDrPZAfMgNgDds*4ZUei+4l$K(EFUTAd4e0Ld z-tPL@vrlqB`c@Ym;B6$E-W{-`$RU33P_2-uCP%%N_?_}}6lL&>@Q3iy<(rJ^<6R`N zlD*zb+gF<}-_c}~B1h287a>kmv0z8R0`9-^_QVii)s&G1rbeJ9);WjfV(EjO{H;Nl zgV`lXUFE(HGRn}`YOiL{n>X^Dzucczt|&?c&U;+v0*n%UN+!^Al^ww*79Li`)k9P9 z7`)3a7Scr;`STTIredYh5VD?5dD<x&$<ZL1Nm&z}`3R931E9X?=L1V-O;~}E%9yma zx8b+5m$!Z%JMVUt0~D3&8DlyhcW+0b%IGKx$|>nogznPoI1<r21>sxbdw&MgT@7in z2ot{)wNxD+)X4Xn_iT&pz^>y@W)vi^FDEC$)yUlJc^8#e`R*yt7SW33<<xd6%!|VX z>lNsl#A@RzBKgbAR+7h+*#Z(9OClc9wY=faBy*X9>Ql^?oDaLk87~06@^Qt*ppLY- z?Crp5{||`6_?*c8faOQt?NT)i%QIj*X=K2+Z*=(gk}g0wjbGb$X&x5BBYI}87N!Ea ztxB@71zcD|Ha~IZMZ+x`hVFGxZjl3r*C*3{{jzy-F|AMa?v5*Uy$w=p3_1L4(ug2M zdJ|(+smAf^u)=6I^5N+pl)mc==%d~9%o1h$|45<mJimM765x5BSkL!{=U+U$04VS| z)B-^vAU-fR9QeGb{oB|&NY(p`R{NY)-`=(1x`7dZ%Vb}u5R0vsT1oq7e?P4fN4gkL zRh>TkOsiE^Ss_pUjLyL39LXLQm~8YEoV-5Y&>*zH>3}K@rr6Z88@yasqmEluY;SG_ zdECleb+h<iw=+AXKOR5cnFc+O>x3}TP}p;+5lF}`j-zJ$aU3J@n)3AYv_eEAUQIUU zv{55Yo_pG126^?wG^U@f{$|5d;Yx&y)qR(Xgw1V>8OadXV&icGLj~xlet$^k^&Fl# zstL$r=zL6BzFh4QHLs5t`zAync0g}zK|B6iQLXUP{s{X|A>t3li#y{O{8Qi~Radcl zd-R<4CF+mnQ@R@}`?I-f@8s$>B<k7ue<iRr_X-Eh51^TLiH)hfcjYwisv(n?z-}Xc zg9uGcQly=x;>xm&e|t;vY5qkpEwm6WXC*hv+l|N+qH*b0ZX@GL;z+Ts-{m1KH}bi2 z2t-6%&+msO^FzXwa`?~dMB~{5y@g$&ey{#VFiGk-XJ|_vyTm;shE5`*;wolD=f$s0 z8z-Tb+r{>lWY5<)wO`(R<p^k-sulv^%``^lcl^vKu)3@%e&tts6w&OeXgqTAGMpJL zfJ}KbmRlFRTxi3-*S1n+)BNquaPoo73@Kg2R(8m9fLkhT8Sg@s|D*ker5MT5%+#mj zYpjva;zQ}0&Quw$FL-O^%s042o$J5sXPQz|lfOA&A*Iwq_}#PX9IjizlcBtD`EBar z?5*NXLf}(IHtsm^x?aLf>C4a3W?f@8o*rbp6trlLk8g94l|neJ;w)pf6Mk90Q%W@3 ziT0Jo&;ZP@!qxjjb<aZgi@ACO`dM*IMXY_oN#zZ~TUElp?j+RC<R&gvzBDol(zN@G zG$+B48G!BAHA&|n1XG&)nD1jj??jT%Q(>yBvOs3AxvilPACDbCazi=`CS+1gPk6li zUfrU?^_6;Cw<wy;eB{IWe4RsgUWyu@vS`^fr?7T^y@Mu)-253|ymMZLCPO%07LHu` zKE7?}5uMxU3?UADyV&{dHbya9**AHLfN-2p9F^K%`f3iyh#!tB`0J+QSSJr{g2=(W zv}0Z9?kj2t{=HrHAI@_91i;;4EfwoU+9*!C%pce#_F?@6Hzp$wy0pNoOqsyO&b3lE zo?*oL5m$|vV+;IOs^LfnKV@TrWQ+B~$yB?@u-Mub#q^hhIgUOj)t~0eb=+e@_A_IF zcjDoN;bRAR=A^t4-{Ig&cPwko29`7FQGcbMvznttJgp2t++wX#;%7ZKD~yvdw?)e; zbaL22o8z2YQLLWeZ8d;b8}871+V4}t@aCFq2~%e-bL(Sd0wXufPeQUiv}?Es3m(5K z`dF59ciFH#eoV)WT~E^J-fQ6pVP)(~+t-jt;3V|0A<lZqJpNyty<>FdTePPe+qUiG zjcr%OM#V<Ow(V4Gr(!#)RBRg++eYQSwNLlyyLb1#_niKcG2VP$WBuov^Z7kfE$~ga zcS8*YQ>Eu?{>UX`ibfI#vs@$p5JKl@M@4~#o6n2%=rDadT^!4b&ez|moY7N;RH-{$ zpSy)<5kXNQpC6E-TJj5c6yNBm9&YpUW235SEif<<gN(4#mXRWO$wqmqIUDGtsGTUP zv=*v5Sj}Oeo%H7t?#CRV+2zUDM5lVGj9R?)`n#T@>rG|vBV36?vdPslJTg?P9+5CN z?BLrEPTMZv_t$@|c<8**%C-rJzAU(`|C)G4w4XFZUph+t!9ql@wajh#TT}$3yMh9i z6zuu@n5)y4H*D-chdI+q^!&8WRxCEH7H*@Tu<@F&Y#i#DLZN^{cNJE;_ppbH`#U(d zuh3Wb6cj-a6Dg4L`T}iYTW6L1`i}h@nlGQ{6goq%KY4!3CO7o>&`@@2_x73Hz=boW zVJt79e8s7Kz|DBgpD%l(nScMsQ)TI!5uGjY)r^P+0;2eTeyaST4%z<69kKv`cRHIH zCnGD1A<%g7$9-XF#sXmEVCQ1zF=gT5=4504w;O*$W9kogDCqV97k4?>%mR@XoQ;65 zY^Iqf?8@8b9Q=-8WjmH4A^K2LV<R<K|4CGvDvXGm<k;u(@iC{Shv<i}7g#C^-o}yZ z*epl5?n#1$SE11NAxVV~-Z#eyl4m{y6ufo74yiqC9d|&uG1DG<04gQ;^MT}+0}-<P z?elXRBx)_A47WoPmZ=Wl^#cv6B#ejCDS^7EN0b1qKw78;Kx(4Z{QUZOJh~wgj)+D{ zAjNc~ok;i=e^8hzym}eoR;XhO{}{i;VFUjK7pb*7z^BsIIg7p&S-odTB6d0>9@H=5 zm!#jEh|kmhH*1uo^S*EzLGv);ugk@T(uL!{M4RBUP7+5KgP|3@+IHkkpk3Nut~5r6 z74TM8DRz2|bm+hLaLS=j4)c)fpc#H%fnBdIMBXKY4HxH@O*W83(C=3S)k;E9CkU$( z6vsNj!(uyoSZ#(nvhqKZf~DJ+i#Hw>9ar$M)=Cv5D2!mum{LccyEkza!r7{_!$v>9 zAeo0`5oTPojDSn!b?jw#DZ$&hD;su8u)I3R6zbc@o;u@l$QEPngNCx)Pkr-6xz|pT zL3WzDCv~;mi^%<T$JhMJ`32v2?${-|d!J7r+Dl-)Z+1YnI{1B!2>K`Wb`_-SrwGSN zWK(JF8nu)qj39ms<sdR=yDGDlN-XOp@tTP_871ZBRRD2EZBgWp5^Eovoz`HTc4d?y z?qBg&Yv3}H&cu4%P+07wS^Ry;CAcoKRBCFKIZ$`c=;xyQifXF#t{dQp0S(_uu9#^e zTD{}3Jsr4;nUEwIo*RkyI!3(VU%G>AjOVbE_Zwe_kMukQ3h6T@o8sf%5LGkJzPM<) zE{}paV8a?gN+aUTuwW@Yh}(STTz$@dK=kfJZpA9nW@n<&!e`XDPcT?SD=eXxGj(Je zvAw*8Mtb-<hU4pj=vKb4lg7qm<JqFQx|uekNZ6(_E`geyFSiUh>7%&$>BANm8;RGF zIJTfEtD|Fpm^v~Z-Z$iql?bHiH@D13GlF|bhCS<WjHMLSR@NWU%cjOO=%v_o5?hT* zFuLL+AcMYD(Q^i8)oku%f(f(9Q`x7!zivIR3oachyXB}|E>Jx;c5{z(5o<bpmAb07 z$A`HZ{OY}qc^1V`xc0~ooNsWtcH+JM9%3xDoDrJnA~x8<jHjTe#@Z9l=dj(C_1qe_ z*yWQc$DEuve!7`+AyCY!m{@+fAZc!afrjHaysRhRcQBT;VipH2N^}zHuq-^zM$iNl z6tJHbLe|Q0wFXvNBr8?7Qi3Nv0Quaq4Q7bCp18#Bio_Kj@i}WUJlNV^Z?1@<YF3ZZ zq9{D}spo3WT^su+^1<~DOsDVF^+qb3M-UTz`DSC$l7Qu%LZ=hw@nAX&<gt-VreZla zZI$JBczZ*Pp+Gd(>_C&$B)CntItdPSCtQpn2jTVa9uq-zw{KvIks2^PtZRuRqYLW$ zfkV3v$LwF0=k3eMHIk9>hwXvtq`&EXZs3E8Fo2yI3SgD1`7hU<sU6TqX8b=UQ~&7C z@NjXna<a2=F>;y$?>=@eE>1=xHdbIL(1-_M%x%aC0A7dxcI{Oq>-@2Q{7C_l-FPv% zih`k<sMU?ZS#~sjDy!(`nFOhJtTe`Y*jkc-`IJYmR2~=Y)18#gW9I2Mu6&LuJ`m*{ zT5Cv(=%`M?zco3{t9@kbOuYUHQnjZ(8RY}k5haV!f3(Zrlu0BRj3Pz0hE`qwd1C{2 zRYvQj#Dkk4TDy5aW$!y1TpdT|mc5o0m8x<>N++7LC;`2oBJr7|I0Jm<mXJW04K(h8 zhrUb(Kv;VLqua$6MJ(aLD`$NAC%*9#Y0kxu!3Dx8r~5R6lDM6SiNX1%I*AnP0iS2} z9|#-sG;kM!@*;W-?>xkJn;0MY4(cXa30yI`LWVHaUAzt9V;I$Tp~_{p2?<ewDDEi^ zCB>?AV$-WrIT->T$Sr8zV@`DN*GO|Nii#KRqkKPzC6EEwU-9}NDRyv!6U`{$!X<P! z$atH{ZJvML!v3BS;qyUmqY;<f_sZ5z4GDzQI=NktYxRvyWmfpT!}|8*Q7%A~q6a6) zT5DX!?ncB|tDnh3V6Vq}`IbLSGxd=g`Tzk5o9xKN!F&Qf%tIHS_2YJ{;W{r?<66Ps zhTcY4_#Mh~PN#D3i{*(CpAd%d*(@*@yCE}pq7+>fL1e7MXc5fS+|S8C^vh1Mse0&l zeJSnMS81|=ZZ_Pkg1pQKhtYi}{INMr-mrk9#YQgN*6>%;NiVN<{m2&;fW7Bg-#=0S z@z(y@uE3KiPX6D0e{mVJ1DTrtcT3xljn(wOm;%@UzQ0iXx_@I532g}A_+0^EwWB+l z<FAy#@G^%7o55W;l=TB6j)K2mlMvYwZNY3wvyb->8&~x#-TZ{kL$iF`Bg3yj82Z=+ zxi&(L@lambdmY^EHaB%OOAo>}I{;84eyG=-7gra*0sVa(3<GFC2^{h*V8|V2Q!*zf zAP>i?ZG{8{IZ2?fc7lzCEETa<pC1pymqB-i^~?F)4_1Gw1ah1YnN{!c#9Dti@npQf zhsTHP1{W?v&q?2e)ZD!3i)qTL5M*Uep!Y015DsM&QfXGh+roP=X~yolmk~C;H}j$E zqp$Bz$#HXi>CRvAdR)VW@n~2ZS}ndEuy3tXC=4Hbj)C85|26*2*)@nKV04n!oJ>f+ z!}x*8Vl<jZ7B;L`dKQJXU%@;cr-vC;3^hEv8x?FqP~gMCjNS~BO9LT{TQaMjv748% zp+(mqus9?p)Tm_2fG{I50iU2RxzNJ$fq&>!GuFIq0)Oe)lUi@;T>~%WC2|CQl<a4V zgfN9la4)|0d*$_=t22PW??NA;gqT{)_=;j3^~e*kWdX&7(dRpY*>U52t+|{8D#l9K zwv{WippbjB5bX8n9mjZpi2CGcm%Rk4hI9SE!o<X8=Q%XBEpyGKf!bEXXRnflmQJIR z(&gWL5)f0Y5L`c82O_9Ayx1sWK#jZ|WWY(pP_d|X8X_~D3y#lqet3WZT5-9#w3(&> zUS6_|!7-P}Ir*@kScZ^U3bZ-}_Gxy#Hn@C0ZD~IGxS4EKpS*s)efRi8&s<x!UQc%} z%3B~v=9AiqSMd<42HjTL?d53HWk1<#)i&I+$ydr~?Yo*WVfM*w^8~_F(+9iyW<ZiC zT!f(vgM?2wS5huxFd%$FUH2UMyFz>q0@O$+&ikol%E!6v=R5gNWguqC-+t-NB2Wzg zZWCDVI%6NqmShp6hVLQs1O0bcgod|ep2AU;kVtovnJqJ|Z&g+d9~4VGIHo8+*|W$0 zs%$CaIgetvfvv3xFH&UZ>bp3Q>?&0}LW}r;*O4*gO<Arh2Sp_oibqhco)8tSwzamZ zH=F2ssHeI(Yo}b#BI3;&UEBcQS=b%5)AV@!aK^8a8JG-R&dZoB>&z65ybF>;SBg95 zjzGG)UULNCl%=$8S?b4T9v~eVgL{6Y3I6R!ySXXRv7L2lII>xMRAmLP%@LU@7x@0x z)tviXf`u7D)<p~R0Uq|tQf3v_I^3lz;`V8}Fv@^*t)4)!g$_Fnw$}^$L3Cnvr>Yc8 z2JUF!_kfUo^f~omLNuo>Fy6;TEa#daUPi+XRyTubLIRhefDOS`6{V8o8VFMX(b21C zG?i|qbPa2K_}u+|6Pk~}U11Z1S1Ax9&a&WMapC$N?5p;}OX16g!i0hgBjR9JE0Br@ zGta!{uhm71%Em3U5XA_BhEy3QKI%0I8D0K@gH}xNO63YagGs2?-VMpL6&#Ycw`t## zl)-4KWsdXFcy&Bbl6#fQO76oFE6j=o4P2D6nc@clE=CQHAoAkq36XKS)obS}iIUea z0^pf&p*#4zD=(avinNdKHGQl(lAdT>`$vT7x~_;sr?yQBA!PKSh;^<N6S$*P6EED9 zd%|aG7*>Y2wy7)Tsm~FG(b}u_LE}nLxEwz*UB`x5(&rt2^`##$V}zp2qm26tL%iTL zCS}ITf0i<!wuk3?hwyyS_kGa{`XV6KwYdHWCTXMj?P5*%NetXDq?gn46y%iI1FzTw zlR8|i`>83S--A5%OC0x`GHeg(Z~{!xIOhKEqXG7m&<(==LcuJv9(<%EYXYVxu!v!3 z==6ibaEd&F@*RnfCM~3cF1Jym^PbSV-_U<AOEO3orAdKhN#B2Ee#r#@cGS3m<%bcg z888XX#t8rtmWDvCl848H$Joe>%f#^i%RmN%$1s3g<sW#gUj|$}zQTwlzbPb!<&&2a z0W|*rQvIg@KOyRPDu$|Nsn<=X$*!eC&$q8TA@{PyM#X6$Q?3CLkhB1*X7pN_gRd|| zI~}b1kT-=X43DZOw;EJzV;~`sC%q77jhj-jPW(i+bY<&o#kfkYv@M@~1aE(g)D9xi z@-Pq3gK50U)ZyRF)QAkb2pk^X&Sjj@48_A1*O6doR&+njU_{#xYleZ3Q5kF_D+-Q_ zrFnWlfn&U|5dI=XkSeqhpY@LaHC;xBb$pxiS-6K1Wy`1CmDd!{x^Q~T9!$+HDT_sK zTK_;>4j}GK9O!N@j)pF3TpD!5!a%vk7T`iFlm`pmUvCU)jiW6dW=41Xszx0>*EHyj zY_wN^(hlS4yVHh>CqDeT5YPF9wBMOba2l84q=)^}snLIKf6VhXnL)o>{C4HVp{dr4 znp|TVW<}os{|g5m_7AJs$#e0wU(EBy6^%c|d(tUhTV&eptwEHuQy~Wq@*Q<)R?C{! zt2SG}u`-Tt8+&E%wYwtJIn%S*!|!8d_07x!c*s*r)>~mtgS^q(1Ddqy8Oo2QZW~r& zOkXn5-m?$pi*Rb(^bL&(Hv3h-eRBh0MG7?$pBA_)i!<C#B9*rc&&P*=fea2d1J4+x z(+yk0(5}sz`R;h+wb7!4vh>Z>&64-MIpU*(pvmt+qq*l>A<q{{Gr|0Wb)~p}HiT34 zCT*I4ELSaXL?r&Va``{-Uv;1s&l0Hd{bTOs1ZrAXd3b=cN)ApVpt+ZojS<LlnKA+g zLmcdeY=(whT>nbx_#|!E?Ep*Tnid7aXF(xX>19a@wUku2@-_8L#j1x%3aIacob{i? zwtV&QzL6Nzki|`To15{cub;WYZ<f!>M<S331^Y*8bIN~b<E|;$RB%uF@`=XYA-P-G zRP)oSsHMSe@aM;CgzA`;B9UpAYz0U)`jV>8ugX<{@6SOkB4P-zRwHc~mpBj4d=s(t z9TvI4{V0%rz5=QD8IC)w`G}|-A;Op7HP!lOQYbc*QzR{0jMi*vKV5>VYrYXhCWT;= z2Mf%K&+9~|UWXfq4(^7yEHQ8p<7f0GO&a*#7hbj)gyL8`leuB%YExIISsq8!vyaB2 z4Z)6I9wpl7fNNXE_#0*g?zq81EhZWG<K^XI>adDLt71Qmz#L?9Sf`T{utBAX&fK1> zHmA;_bO-PtIqZZiiz$Wtz=%_c{>@u9Modzw>|n&?LKsRqUg~~;Ii;}wR+k|lP&D^? z^I!^f;kH;^O#3i1++uym-|e5PfI<-Waz*DRKl5TkY?r(Rm&R3(j&x5bSZjy9N~4@v zIMFSf9mQp6`5wtFO8kBa;~7&~D1?;3uM+xx9GJ8*{_&4Sk_}JC0VMGHN<any(fZp_ z`OitBs-vZ$ow<vRp|h!pimCHILP{)#0HE@Q4M=3L0JSRYES%ho!2bX(E1bq0rT`OD zRse_DzdfzG{+IcgW;5Z7Y`k)F^Lko69cxZTRh7>QUj9s5Pyl}D&$mY|e=(?;m^R1D znTz9^($~{xJgMAAneY7I6ArS26)ON$VB|D3&1UFi&a<NJ#Qb9}T=cgBnoep<2~L77 zDmm0IxMei93$Pz3yC87T<ZaYQaSMdT>7}R4%*+BL!Y$n9+{0e98z&2wOB9P@KjZbo z_;pS|jdFL@@1pb|wL%R~wumn@`jF6y3=;w3V2UZ7Y~JWDngX$^7~BMvQ1w;#wZecH zk-U4vp@mk{JW^^Lf5JdZWU?HNAtJ8{gYWw4<tKhG#t?5==bye-K8-#Fj0UmGC@ZUN zuID4_9-xgZrVUK<v>7Genj#V`d{pxrDDTONtNdV53=!p=EjEBXOW0wg3NOAxD45fD zw+S<5K-natNP`U#1CJG6<PN|TlPP&gHRJ(W>Pose5vSU(8E(TsFt5?}*o$F}4o}z- z#O_NM<umywH549CSKcNwH><lD%tp_1N~8K3Jk5HBO11XNK;>rdT%F`p#60?0gX1!L zNDMR%@95o@rscx@MR_`vHx#^0_*jeUs&(&!zM^YQPR~x8Sy;UXaLnsI4laPr)xI*- z6X5d{{ABz5<9CBFYb;B!b?k=B>`g_th0JC3cLZpa&q^YOgF0d!aVMJ*;#JFXVNGO8 z@~5iu*4f+c22w8`k=hW|aCrG3sh0GYv@IvpG)Z46HQ?*i18}gyAv<o_9Jm#oR-O(l zVUik2mU0U{WDjch8Tk8sw2?x|7iNoWw<OoudtPj17n_CcAG7!acbJn4mMSof(CEf~ z&5v9sc~``Z&c&g&v>Bnrf|^`;pbl**>IygT{djZ>(}h63+Np@jphe@%b1bPShtJa( zU8J!7H3~~Fwt1Xt;apCo+#sPk)Fs?VUqY=w=&udwMuu$}yG1qCsBPK^3Uq5ugiCV_ zTpo!4`d=umb<B+l{LJ=Gt>?Qln|$cIlW8&&P#!LYzC+zHmLWbgX1&)_LA{A2oP$C7 z6*9`#M^~QMO^4q(E{1w-%iZpwC$K7|4se%=Yi?P0uvlG*KN*tTefg?~oWZ1^O^M^F z!MZBy2yF=#-JO%zFA)ig4vmC2&3!osGJx*s1IpW?&iv&&65B>^XWLBZ15{&o1emQw zs}G~&Yv#ltXJI!w5%c0lvo^hPjnx3-uP-Vf`-oJQiPwkdHy2rYmBgCgzmSSbj3>9s z7s{trPIBdqH*D{RS4WP$F^svoi(VC9$z;sdzG=T9;vZ&#=_`Q=6$M<-BEI>Fvs7xf ztAf_h2P`f2Zjl9Im*u_T6x9j%Qr`!0qsfrEo?p%{nv;Hj`<(II_RM}@|GlK@b><;5 z1l~uim>?j!|L6PYe=<1#3pM@6m1Jma!e(p&Y;7AEar|*U0r_O0Pt%l<#}G)O0!%sB zfLPAIT}kJfpPY8MQ3ULLA@@73se=_GN=wpMBoHZVNYcx@Y(~`a*{$XDwJ5GGH8_{+ zYGsxY8&bq#rB9Yld~0-UnS`o#%kE+LDWml*)z{{=g~3B%^>i&CZ}T_j1U<~Z5zO}N z4mZDUkw$tC8cxnAz(DL~@=5P8DFy`pw2<M?c!;{ySXB-$vc~~J54L%|*c6x)(reMo z(Ql7bu?W!+Y|T`0&CNlYrQ&tIj*jGO?2NlZCj9WJDCF7Gsw8;+i?X*NVPqH`j0x)W zfi>(H27J9DWv{-_r*_EX8$(n_*f2k)17dCOO_pcBe6c}mosC6Ozn%2$fbf_<Mh-{w z>waPmMAjq#WI%t>7lWMi#+*D57gyxtLD#J}b8FAg&|SBRDFeyTtYU!hc$O;+R21pl zHxZ#pBqj1cZ_oI~AR`VJY#s$S?%&S@??vK-)-RvOJ;T4&*wJoznhIzc@AlbjsS<*l z{?QG_W5O@FqgY#FX|*{`bR4vnx$^EV*#5jqtUo2FAXRnR<cuw{Wv64O1J`j?2RWVw zm`5;|qr9h_@wb1EeoI1sm9VqVza)V`N)M-Z*pfS5w5cDk-OC6Iy3Mf;%@c#>X+L!% zAk^ZXFHX~&L)Nu{G96FXgM59&jFgrJnWA)^h^VjhkdR#sDQiyLsh!aNC0fU;UdiLb zQFjq_H_I&_CAtc62op!c%cC<jt(HjTE^4t-b9MS&0mp#&l2-L5P1R;XC#5QocTdZ& z$+m<^ZK1b-7iA3^7r^vW`9ilQ<RC@V$YJ2Kth-tZKgC&L%DMBeA3vsNro29voG~pj z%iEk_U9^8TAVUB>qkm<b<y;&8cs_(U{6#Q-dmR{Cr|LL-(zRGt^l9O~p%X*I4-zYA zJF{fSGLn0gQ-*~KBQi<HB{gi2L3qZ$^h4*7H)D^f-ZQ|(xB3gW;8Ar5n2h7pLVwQb zYqxQT9d>T;o_Ad~^-jhpH9Sd2um1q7juL~D6WCynaJYJcg+%(d#cjLAUY{8BlycC* zzy^y^7)(B^U)Wci(P=ho&Izvx#fVu2M}R|%!s%}{H0Jm!dVKjFG!31Iw}eAPU$E0H zB)`89NDKbNcmj`9ze-KTu9G*|2NZ0p6CS`@GM16ir~jl+r~PiVmRR-TJ?m}-T?C=q zkX4@ya|5%>52q0!+LTLHM=<kaF20j&Rykmh6})a<F^MGVbl#`ZEvcUfn>^A`uVW6e zkgHaBiZ%6ewjlw|)sM@Ql?~LgnhxVF1dBx)ji-u%&-Aeszx*pYPc-LfTVzBc1(}H8 z4vw`?L_b;$%U1ah_kgRa)#!@#hYS&?6=FL?%UzZDbMDsiEP9gYT^ob2Ij$5pHP+O3 zCfS2R<==9}>?8Lfkt_7c_Q?_1;-9}?X{%JfuH$#^<z2@pc9K6-#ieUy6IV=EEb$b; zKRevTe`sCL&}}+YqQI#TckL|Q!8H-hD^%}9jqp`6bU(!9KK`Sz?J1v;^9ST21cF@u zErEzBCy$Yt>3_5i|EEBN<Bxafzo1p^QeSf{{NojShLgQai(;0c$t>4GD=nMg)?Z6s zUF8C`Erg&@4_|(MMKyqdBbu^Fu_jWgoD%VI))sj8mPS9vbpMQ#O=@jGhG;QNCh7*b zYfL{~I%;^e93}l={$#Uuo%`j2Cqg}bgfb5dKD|r^C4+CG1UE8dn4o|8@)10**Np<3 zVjMd9GWz6|@l!Mmyv;mq4JWTCfn$NmWtdu`ZAR+#)KZuWj##9Ns8fI?jPzJ#i2cOJ z8hC^%21N#G+B$>|%O{_xumn3Tmc<}eh<3{iP9mw@uQrg09MrMyV+Ns<xhQS9ztaMv zkqP5y!m6=8h9bmLOQO}O?b_6~6DvvAP!fdg54FFR#|h++JSGIHPct&1fhMG4^Nlyv z!WdXNgYf#tWu3tc1YQ&8eU==aFiMPv`Xx{ZzU#n^^$UDBw5S$~dm`RC^Ff@;b_36j zVXm%*VC|&^)b@ri-f2*U!Pi-x7T@u(xyCCl>VB?&@XI>iZO8q$;+hRY3O%q`fm%s$ zk0AV5yjqM&MR;ANCP!v{CZ@f_eerk=cLV1zU27}nVO6^uzP5<b_cy+_T+9nE16r8h zu7kc4sjCRKcL5>X*Y3X8emyC_hPH7<>jD|S(sanlY^+aGuc&&;>>K$ACGhnvx>)Gx z9dq@A+$DsFy$^O09j~1#p+JaP^JR!nNHE;CZ~I|$lj?xYNCrM?Cet`p4v46Q^U-C) zD*pB#nvKR}Pi(T(OzvdbSSeA)J;$nuEuf@~I;dKFcQv|WS4z=l6P56l{YA$XeSrkV zJurejdA(M*cuo|@3T)s*Gcmh}>B?@B+E>iMGqYZ{6eo4W3fm?n2i^hHzAYCv^QCmC z`BP%mDd1P|JV#hnQ4H4b`$|?RiftV#e@Jk;FnQzk<v$|BOJdjdUxr!fTzMtJJv81- z<%o|m?G2%YF}p+Sr>;DZ5VbsLJ=3jgJRfB>e#S;O*Ao440?LBqNnoqfN%iiI7!811 zTAJr}Q4ne^sIESV=&^(SMkwjSr68VwUng+x-xVg97EJ+r@Tum`=~!n4r65Y@Kw%KA zciwnmo3cH%ip#rS2J{>O``tVA)NvSsx(v7IuG1py8(fpzEq&|>ensWzabR}E3Gd)& z3@@f+H9@v1hr{e{*$$oEbM^Cc<e3qwv1Q~D8RXgyMHrG#TyciKr+p2<Nz5&v=9Td8 z^^Ynl$UZT!rSus%PxzM$LE7HY($n7VA0sXnU>^yX)dete0)UDLb~B)D(8w5gA($C+ z@fZPR;wEM$|H^Vzss0gNqWE6C!%<%bprC{(*{yaoxEx&A(7W>bMAcgGqF98NJ}hzN zX-?<NP2RC~Ja;p9yxK~(bTFKE`7?Mh4Gy!xoCJj5q(v>@F#dogDp=b#qsV_tC{BJM zb{z3LFrvd477a9#1^3DjPxf`Pj9vt$$t~?#big5oF@b4vS@Li64T6^q^U>)>2cS2i zGYLi-H%Ok=OC+-4roKL&SyI-+I0pr)^5XuUri4$kow;}!z(CeD4(igaP2B2olPx?S zY}7SdAm!(RfEEfLEb43F%G7m@SFph&&k&kc4**zp%RApDZ9CF$u>qwV)3!2tK})v? z`YOpkY4WH{C(3(oi!F)1>!wCG_Zkj1onEXN`5R1Z`TZMA&Lt6%x&vB!9A9#6b~m)x zf!u^({-8D^tpG6DvMAVrR6^~arQo^RTX?fjTLoErY{n-|c(9~2Y16dbnF3STalh`K zyM#Q_dfvIu1<o3E;RHDthNdTN*^z=5ddKaIE2k~TA3X+JpOQe4a4~)=mfa8YN4ZIV zrV4N`fM1h-x~y(pI$pWq6aOntUhh-s1#kNRFOLZnu0pOJ=;yLvQ!_!H`2%`$@Ojd> z`$-oZ=uf;p71HUh0h-Pn+NzhS*0_X*NBC?_qORvci}`<{gk>BB)+I@Z%$e@$b)&K- zy&6a1CrsY=Y^1L$Ow496%ULgfOIqm3^LS5!@%=}~fQWD86CUt{cK%nIp4_GYV-5~3 z9!4(HKTS^-6BeL;708<y0Z%162b&3yQ2E#HQjF@K#4LvIUrkTySadtoq6!>&3q3>d z5=;A7ClG~*ARCqKttrs@;Npb`i`>f{r`xORWb;XS8b&xC(QdhT@I>tL_%&4Rni61Q zV>gU+4;Qf@gzh14{J9~%9GKXU`qm3`9=#%!<Hk<}Ol&m&mDnh&a^uOp9hU<pHkfZZ z<7+TBo~RiwmWQ@Q|0Fizn5X2rgMhZx|43}??|9uCoInlp5Qy`N`oOrC&f#wp*(ehv zCL5&2gHU&J!SJzvvdqxbU?g+e?1yPm5xwpY(<w1FOshj;^v0yA!*r*Vhhp5DrO^>z zJuc7`F_Wp;)~qDZBx5t26szg8E1O7Jh8vO3-%L0QrP;jvE?KMyz+*Fpv^TFs<6%G? zd!#7|c4f_9MphS$uwX9uJtf<?!$naVGMetetOVrXOaE~2-}YZTIJ}-QK2eXA4veQ~ zeb<9Rb$Op|0;qfFLXK5<JuXz?v(70*a7H@Znj6geA<H*RrJ_(uB!D)2TC0^8+gG<< z^8JD=kGGcXFxY5U?1{}NW#-66BRE_iqOH;HR;KOQhcWE>EV$Z~M&09|X<rKpUuV2Y z^RkNM7Nw%EKNf3*_3-RJhx9;dz>VNeOg(Ly7ED@9dbk0qQ1LCLGql;qf{GJtHf6z= zeuX!3XRLT_{hMSr$CcmX4ja>J=IU05Vs;H>!-s;4UF*<4Q;B^?*0ut`8VVBbUt=4J z_Lg?e|9kJs%*dF7os$!o;$}Ano=#3fqd!1AHzS*&DH|&%o6(<+{l7gFyVRBbxaobn z|3k17+#3C<)MYo;TzSZxkBiHv4b`H~1saWd@8|DF>RuEWhonvC7pTgO%ejk|4wmPy z<I20BCVddO8Grmb+jV)-mn8@9uCap$VOJaCqSyARMg-p>cCEmz5ZjtTHN=ml#R{*; zEILaLfV0)w??XdFs9)px)M>D=$w}SR?|ny=qtGP2^X`FWnui<rdCtVyVFuf-)%||+ z?1*@bNB!xZ4-?nN9yxj?l>|a$S=1jeQ=Tq<opSif>UlYU;UO3?&3!WngWek(NB)RM zozY|vz`M`F@kM3^Ve6d}^n4o%usGJv_FH(gZkVmFGFj(F@axDzyluLy;dYynGJSB3 zH{cD74DHTDe5aQ-fx6J?BVqyc*nd-|*%+8^U?zFZ!9DOwt%v$WTL2D7`pWbZ5YISf zP$-?6N!nZ&XA@1}cDfAOjZ}ME1!}tkETBjM1x)+aYt6Wm*I*MDshO{9=SI(zX<Ige z)mVcQN^`|1+X=fd&cs{<90s&G3(Ss|Rvh+CP@dTDh;=W=tG<gv9CvtV@rthY)R$fx z4MAzMn&x()NU?frq(qK}1f&W=WN7HvG3B|-Y5GdJ`W&>WK%LGOGKDEDc>BUKc;Y5d zr$dRG{dzLjR)y8hsf=CDq~AXe?`yrJPKUIbss=H|=W3+4SXR2Ntf+~KdCBn=N)}un z?|d+y$<ND`-Y9X><a#XGui;M6VX)50CuXw)m1K(4vvkE6nbMd!l59zaKUeVEZ$HZ~ zxKe7QcCAEi{x@KNg#BXac1~<lzmAJsQqnM0T+0!o@x{zvvGq_HcPSOB3`{3c#}i5| zXw=72v?Is5r;FxP!A1LYLAn1~X#u$PQF+!~m_4%tRwpVUaV4w8`!Kxxb(HL*UH4Nq zeLw{0ULozv4$d$av}L}xc&k@+Dj(H!W(~QbBh$WW%n~D%V`cqt_?l9@Ew`Ldx~@H4 znkTsY?s>ft-1s8f^IiH4d8LnG>V9xdh;jP90QphPZ(2VqkBMsJHhEydw=vd2AqlGn zR>&>ro-qH^u1Vi&$-G}FPQ*_NsX6brTZ8^{1MCO*-)o*GEEJ4CF~GL}$`{2DNb(r6 zvI2nf3U+p2odZl28yN$)`EVHn*+!sXo|En0=M^!kGj@NC(SRfmFlq%GAo<rA%_Rge z4VXBO1v(heS4w)frXXg6OMqXL*Bqxyfk$0r;@GyY`f0PB@#NbbFx>L9WV?=hV5bWh zNWEf?TqbS3BAtrBus4s-KARJR;z_F~PCz!R>6u4Zf1hb$Ipb2O$~l(RsoJ3mQ)e{J z&J5WU{BvL*rr-P<ln)FYisbO4uaB4ZEa4dJo-#feEqH7-lkXy=F&7AMkxv6E3Ahkh zH?eRJb|}mG9TZEk64WaOBu$Jb>P0DM<v|@!iVZ|NW5C|UT+<vY*Ux2y?5CGs1Uk?) zt*GD8+t^1V!@W?YX=vL}#Iv)MS*so+5xcM&B{(#^$0%7tUi_KiM3gUDw>iXuEM}rH zy?Wj-r&ea;J0V~txdUOugfyRN2H$aBd#q>ifOQQP9rZyW6RIdl`VKC^RPMLa(uK2U zIsR-0pe5PdvGEups_rzbnnN>tSJXXqy(6VL{fE3z$tsQLM5Ru>Uq`Wk34Him0ZR08 zgl+hA%_!IUr|wHHGDNP@IudO+?Jf_2S5rrJR~&mPPxJ*v=5V8W`YrPg^xHe+!UO0v z7JMUtO*9|d?2W!GX6E{!;9zi11qb981`-y8G7f<#7;(dJ2Wd-Zg}L^6K=ptvJf?TQ zR&)LOCC6H4U{SmC+Su$#3+&rgaoL%&{>tWss86@FnNaRuF~EPwbG8yZ9se9hlK)HC z%-F=pjExhh%`yCwumV;&K;<_#7dN9Z(D=#DZ3rCv{`;W!Z;KD?1rdJ9!7e1H0v8|H z$g!z)IBmzfgY`%MS$vQbjF6~yQl#eyTzv49b(?X0-aXu;fx<!m458H?!M-h%&eolt zx4<?i*dAjy2zK&C;<NM<2myqgQC>YN%PyKYrLc1OU53#$@0laD1-&UI-k@E}oX$$< zLLz08eq@ZplP|p^d2u41)Cj!@_0MiI@cT)@@Ni4*!LhkrvK3Jg*4?Hee$b+Yu-?aN z!~%NdxmxGIMCWW&E|RoraCkm)G=r^2?<~!d^QYuiP8YR>jF6kEK-d?>LYnqxcBmAy zFd#?xj`9{Zm{d<A>ud4m9YWe#wScL^mfo*+APU#S$mXbYS0KQ;Ij_wi%Y+Ma?tWP} zGxzB3)W;dEq7~_uR~=MfHRia8`kTfxt=xYOfr&&;y;pR$KopfVBI;8hqnBkm>9wfQ z`%zwYcUZKXlH#*|Wr<}04HbYtTZmabZfxaN3}=g3&@bKc(MydfEdD69ke<(tBevJo ze(-Cz?NR4lM}1!O#{|+4I!3YL$oX<>I5LTOjj*TVlpohnHg8UipD0<62o%_=)O6;i ztk#F@98}z`SVB6aetflnjh3%JA+uAhLMMWGbqZwp{_2IrJYvPd{qh(5jD~2Dc2%I^ zDM$sw1u#?ksTxY;c21c0f#khVQpo0G_V^ptWz~@<@kyAO_ffvoKPw(f{ny+-I3&^k z<q0(fiUnCrxY-yv*ns;GfQlz}Mjj*9KNb%jHZwCbLv9ZCf8BcsT#5kHcAyOXrR}g| zftrP&R8`Tgkk*@HG+BK(=uP-@Lg$LMd_(`7(9t3R$L^0yZ#O?c_u66lHC+8pNE*o+ z#V`meHZ1ljthOX6Q9;MH8AJXHa4Et|9EtD^H$+J@nwe+-10_vK<K{Gj(zERagl#gN z&)Dj@s~Mxtt?YFx8+==H#$^kC16oO%OMIuYv-lbPrpjfOq&RF{m3?jw)-{x4Xj>Tu zIx9(`71)<G&a!fuHAV$07-cVsMA8AGPXcGW<k)fQoc+C$?4MLP@7D9HwH>~6rLc%- za6|@XA{$Rz#tQ9sZDwu@u@hl?=!chF&tbm&B_G?N3yK;KN%Yj2$IsjDxK6aCCr=Jh zBq(xdeONvj=4)-+vt~-8+v6J#hzfM<2W+DKrr}L158j2DBte}r%-7T}@JHkA4K`Yf zu-Tc@$z2uFwy3-$=@&ha?^>b-?w}&g{&oE3QRf|LHWlalRQvSA1|WK9*M$<~uw9zB zv$H1fzhC|S+x>Lf^6*h&vh^twABmbqI!`sT&IRjI<g<Ffo<+{bMU0mZ|Cje*O`iY^ zih27yKv%Kr?~oECqJyx&d2-nGRZl{S&$V*BgYtg67+IjV%jeVgS*}1Dvj2CdDXsz! zMS|HEjN|NryzB^`4Kq&^20o-B)5qDGdyj^9Kw&OlXG8v&%h6W4%K`w|_aC2gAH5*W zXkb0g^xq1n8k+#W+&t`@j4Wn<mZ5Q*0f#m`JjTGujS+yugxk=V-SA%#U0^u%4-4sQ z=LcEnW~{>r!N2`EGze*qVj3hn)9ZM65$t3oQ6Uxm=8_&?{(F4<X0o96tncf&Z^tR2 z_iC}sDX7UL1Tk65c^=l4rs5!%Sflpm??f*cz68?4A9Co?K?&QD*P<BDx{(M)o>rC+ zIneh}q^oo(kj?K;Ps>n9j{S}UJDrSsgVo)+vp-A3QN8SM{AWOoz5(ZeGnLV`yR8pf zEnHFV@U(R}Q<%S4^VIbTy(>p{n}L2y$uOD{J@19bj?{*Wd&Zs?D)srxA$4)`8Zj@5 z2iO^XC#<qkgwP{-cbc~+Ly(tQcu9HGVm-nm^{@}}b<LvkZM9aZA*!#(m9EVubOA}< z;3=EfTIVEwxzK}lE3K)!8t~TQry>@mo8S*dpxq$0R>%FY8ADSi=htC3kVHe8=>gHI z$H0m#!iB2~a+$2#_mo?Ly;Aev@Uxm;?FQvRWurW)A|V_S`30d+HFIWH*RJ*XQV=@n zddE}HHpXpL4-C|<!&e8}_S_g*lg=zNoiQ$!Oo1naFaiySIvz{|d+D|q9Ahw^;<?v1 zDX{MnUj_~*BCwKYSm?7WlViy;_&Q9tNd6j$M`~DKt6eH{^jsABo7|VF0%DX(FU_Ff z`bT_;##ur?BNd{HF_;j+>#W$deyl+zO+i;)b>eEHDEE!w<7ye|8w)+=Vm<IzYZ0_& zGvRM|uRkLkqJEszZpcf-Sn<Ct0PPEYTRY5r9r;Rg*`y-{!)&DYENkmxB??U@erE3P z)%@Aq4<K*!V{sNkH|1E7{?J&{)yR6Qg-qh#;t%6-OBoA@w=`a;$)Yqd(T>bV%F+wB zmTW&epUBM8)lHQ>rb?IYk}MV4epc=5YV}PLk5BB=WvL$IgG@}?)0VxNPp;!-$?49x zO~R(3DBicx+i-s}(=FX~`FVhR$l_7)6h{x{<0oM;ScVu_lw(r^45yyodZQ_4C)Q8q z&MD((ucwao#3^;J?&8dJ1F|}Ry56?xnDgW%U#he07yi_|clyf2cg5lZFLkW#YjdxY zS%V@Cu=0wYLD(d|zDonK9B@7(L3~`z%GcwSuNZr3m<dl`>I0Ng4c9kaV^wy2t@1Wb zLiQI<Y(*0p<TQ0%3cDJboqd8UCmu}=a+O-?m^!rd%w99IZQ0*ti?A>6FwL!+yCD-# z>q=n@S#0BHi0%vQbG1VHbN^o2do4HS4FXA|Yv6tGFIy4CfL=@&M?;(cLGFJs<6to{ z<pN?ztSo;X<t(PGjE2D9j6fkKry++SP_)1S__s%SO48r<MkU3K0-aKDD)ZG<5F1O` z@YE61#^%ky^65)hXtZ-ovmWW|laD@ePuQN-63iuR<1g6~8aS34VfBgw$CQFFwnyd} zHP-|SmL5D2nCzw>%^48{X|I8Yd$ZHsSp`i^_;{gf=bRw0o&z!aDX_`<Asa7ksD={^ z@LwnuAP7<T`W5EaTG3IX3$NA=flflPes882(|WMIbh}+SKPNP9<+U4Li80n{Lp)TW zA8O}uEaHMr)_$<p!7(d$j_k47mA~c%?MWmKJ;hcA!XR`hO8|E1E`nhlu9+V**2Ni; zBh^cVr?l>u3z;eFa8J7?hD~F^(bP1r7aLKF?rrL`j?*&S(}>(|88xjY>c@cE5g5}< z4(v|qH+2LS0AkqYmay!mu%T9i2yUOrj`8D;1FoTLNwT<*r$r4hiBmDY*;G?FtEARv ztlwuB&F-zV*e$shg3N-O%$?7ZE3kPK;gSLPi<`TctyA>;K1=V5tXm<^aH6_$O({e9 zl^=r6{qtG=f^?MrZNsVZIMP2JpQyX-4^A@1UWf<n{cO4$n8>PpMNPY;7Dh8Hk<7NA zV~5|X%}-bU;X3ft2#=`+9-&I$5&Hjw*#I2C)${BoMvR<)jL95ahCtgChao3$<DeM; zz|F>CW@_@U@wPuOTj3v=ZLPG{8yN~+ItolpB9l(pPaVEo-Rm$gNprx}gpbna)uo}8 zRCbnr?^l++cc`Ib!#?9vo-BZt4d(?ELUq30&?vpngX6w>`;#wEe&>MFi)@M^hb4S* zz&&jC#R;x1Zo_BPllIBdzzWJa^-`bmR13G?dyH1$q-CjDa@PTdms?h@+ZP4du+Spm zzxjbETjc*j+2Cp}x|icfmZft<>~irJO;{p#zw%pcc0%m_j2a5|q@ap$i@{cM4%vmY zm2B=XD}UCVLm;9Idfu1F{e!Zt9Z6s4ds}7$vr5HMqq8)8vINvDJ%#Y~mWUR`wh)n9 zlsL}Wd7!4kK1wzt5Uk4CBGlw*H5%VdV{6?ISV9U)lT@SUfE-&W57JpL*jb1YCKN4E zDfioH-lUN5=i;58S?~l9pnq{}%u|1HY(jr=Y)c61_O>0VCCkWC-5cCXyFypiFGpWT zUy&V$Fsc*|z81cz_jV>^e=WPb$#Hoti6Lv^(L4dnx4C1dF4~8Y-z^YJO<S)EyzG+f zMOZI?AQD!Y{{F`xwZ0~SX&-n(jDg(9-*+?pr<&}4awAfv_O_<ZKtY)?j|s48U<zzb zv#|luH7;(WKk;`U`Uvbx1EqGHT*j>bYOg!j)Un^;#sT`ZfjfaoKP%fQjaZ1wVKX8$ zz>ilyOhh2Mr@%WB<bC)?M+Eg3&!x-oA?whJ{&;YktP}DZintU!+ZkR)Nu6Oo2JHY1 z5J_YiVAp!+_2y*Z+Ya(`YANQAA|HDAO{IiU6wRN=PL(BqOC~tNzpw>PKe?1+YTL2~ zB?F@~8$HKjU1Vc9@}b~hV)!$`1!jdi&wtsp5v|L`G#)(P_8ma<sUI^UK_)4>kb<Ij z*rXqua>H`Dci1V?i{xwvrM<|ynUI>@el5EffrQL#+hBd5YyruZRsG82&GM7y_Q{^V zA5c+-s#m>KO5h-(8lDof#k#^0v&){4#AsRB>%}C&S;$XjPF|W7@tu1D>uN}0uzA{w z0lv;H73+JzCmg+5i0foM5t|J#zpesQHJBk7#F8oLIuL3S0A3QU)HU7{lCw6Iv;122 z%CysJj`2sOuv#Y+kF-|wQ<H}%On}hWP&2<9OJ3rM&|IRfzW%%2!~E-7bZ69S@kx7p zqba&r@NGd$br**?k2f60_Lb9p4|Ojlmn$KQzyRh{rDj@@m+-<e!@!YhSB^M_jmVHJ zh>wJLIXIuVk<hN;hE^|7od+->Dw;9eJ}4^dZe?^@Xp8t;c0uq=>SP71UGTyFSnv;0 zWXy1w=^vN0=%UQFtTO7Zml_-x9kl34D$=H<souCvAAU{UrI5MKh)-m4X>^F+=n4-Y ztWr$f)~17--OW%uZ#=5&C24kVoBXMJWapIvsH~&IV5x0_3RX=C^G&T{qm46?p%H#R zfEJQRfgqQ9prCGlBO*stpqZ{lf-5ag%FF)jSl%c}p>>mTlwgXPi<RQi&QjmBz6=Ps z^TJE${$41j^Re7P4F6VJ)=H0}_EhlMEOSY&?W&xA5r!@T``pF+H;9L<veyN2o>1nS zIhxX^c?f#!x0Sa&%XM&e9))=kYX>Nht&oh)M18!+7MsU?yWjQLoo-xyZnqkaw|0*} zaJ1CSSN<K}>-lP;YyHCFCN$&Q$1M6_o9mCGQs@u)wD3wPRg9mDfnQkIl1-s=Mk{!# zeHSrhD_)3jf84*mUq|`P&tS{ct3lp5HIu%x_j%A<BKdYQ1*$&5p_QJ2N#M?CR${2V zctu{|>Rq+0jh;)Y%#}P<RzU4I`Sx09MqzUhWGjk%JVi}gr?K(s62D1c1t+|BW&ZAB z-fWx{dXm^R{1(C01=5nYahx`h`qbuf{o~9zsQ0XW0`j;~amW?BcrAh(@pXzo!dV1% zGgr=>B1M0@GR2}SMKLeMfw{{d30nbfM6gS}BOd?bAU*i`%RdHRJ<|GIW58E;C@gS1 z`!_W0|14Ez`%9<H$;E1BX7Xnc%Le3QIamQei9e95W&~<v*-Ze3#%wIc|BC&0sq6hG z_Wv0FGoM9*60@K-e+CPa_mdZ3gwkEs#w1hp_4E(~sv)>~c6A>V71*=viF0)6qIEok z4KB)ZQJlU?0B86`YpMk;@EQ8T+?nu;u1LZz3*L3`o=>V06iFlGXD?CSjDQgf>lqRF z;?#u0s-;sX78Kl+NUHiii38iM+^BWA2r~U2QGpHA(GO2CMbt+wt&oOW$eqM3g);U2 zfvw3h5}IJc32Q-@RF3N~2@TpajX~Py27(HP2b0&PJNi(h4l+pwTox^vmeOcv)Qsn9 zv(f_0`9b<4hy&QU4{eWSWU?I<AQKXn*3qS^`-*j0WsKLR@B>^d)RnqC9>)vE0`ghf z9~wCW=GR;B6^Ic#JJCt%=mTSr6Xk~7WDFTO{x4Z2js6j2184)wLySvH3-F}@#J^mF zgK|UXw;4x4u_2WXl^=bG2f+EAy&m0?hGv}IA1U$fs5h>skL?>f2*}o9G7tsA28Uv6 z&w|{vFO99+nHx3BLmGA@pkWi$Gx;WAgP6t~TOr9bz+Uh}c7oyrL_)4X4@mBKRdqau zLR{Ws>M;h=^UC{2qlDa%U8SHqT%#dBAR_`BhuWma9)!PYhTUDB8c<h&DvfG15PbhR z+2J8Z^YjrM7?<z8AK3l1M0#_>|2xL}-RfowJ{@}{QDh$2iCr1L?j>HalyG`&>@Y9y z8&-Q#Dsev67CCc;Cpd#j6qDST-W=3g1Oq~hVC|Kd7T&uYpIO$SaHHge3{#)`%C&Zf zi}tTuK!IA1y5ATiLp0Ls>rmA*tHMNu_UALKo=<*TI{t)wS&?`>-w6DIiTpZAmS}H8 zgzq!fqcAEUdDgezsZLm2E6P6zg6D8nk^$uqGV>!CIoUbCmCeio5&9pZOBdR3oMbS4 z0#VJ=tG^zF1w{NbIP*Rwt4l3wXLtc0_1dQ=|NW0migGz5>UqGgwex?kMZjic$^q>0 zGXk~vKr=ZfD{x%L3N!$60ZfdHSb)+RPL_Y&<vF48A3{-Ji(u3%+_;-uTCGkoe-hyg zWTAqYp1KXx9-Yr+9O3rzG#GXnJv7})%aXY5LEv#W%gZ=4_rY@39V9_zQZjW-Tpe6< zn0%Cz+`21c)#o(n!-0n6Bb^Gz%XpH)l&^aXJJ6hDPeqKhcD>Rr`g>J!`XP5*T-+xE z5>EDS?BkibHd!~1DjD3U2|syS`3+8SjdE4`U+dI(4TE(LYt}MNTSJnUllK#AQNBy+ z<8`Zz?3ReGo?EcjixLP!L+at*liRd(*cm+P%{F^g(ZDG3k?74UDLI{tlp;tjhTJfG z0LR>A{i$3fiPD%SJR7r5`1_&!pLpoU1fVzrqBPZNF;igY?U;%c$G1N(-6)iE)<;IB zMM=8Xhw&?gCYy*!fiDD(LvAQa7PY$wD!14^ucT5nigJ5cuo#0*#dv_$7tk_L$^gcO zVCZ>k&zsemZoW?SUp&G!-Yk)n>K2K2s|$$^-;}zW#T~kh-A`D#J`QAlPR+$`7&NK* zLAm{_WX+CFK!X*@A2L05<?e^`p5GwZauP{^3>Ym?T~+Y~s@8jud^l{fk{0LLhkOKS z(0gbP?5AJOS>U1SEOhHHn?juV#Ll#^1P2bOFSJqBIorf1?nH0k`JG`n5E#}X=rh`k zK^Y2${KLaHL4pX<^6?w^jqw{7Enr^Lj>h=BhTp{(;}q%3?v$S_m;ElJMUCNRWDS=B zaY|Fa;AMK!)2Z(6cn$R=3t_9?w<lkXT`z{u8sl{`xM>ZBtYTz(4JSAKc-eB85B<y~ zJl*Vo)8>kAWQ6RgWP;4=fDCQOAO3oTN1&E%>5Iz#BVi@QPvKkC^rl2i44D1)(MG0r z;SI;&2|Ss`n4O48YZq_&TyPRu5M-(R=La+h@)2{=;iO4ROP1;!$~@Qp8<}4`abrnX z-RY|5+;L@ofjMqUS|8T}s9o<l)7Gj;`pOr)k54Jn4o&W1f=jX$+REX-=Tk?^UE4_t ze#h{6o%$QAz7Dou(5&F9;lRiet>IRRemcfK5vZ8<n3@~)py{}EQ@6NE6Bj5-NbsbF z(ejnGN?&(yg~oTRI1l%5WUF0#Y>3*I9+o3bxPo5k<sO?~w&xpSbQA(i+bz%^eD`;e zPBeu@91C!Mc;JkT`<n790y_zrDCfh|-aC$HfYieJXq}_)v%C~N>fsGfu?4FLtMl8^ zI3_B@MXtX7Q9F_j`QELs?JDoV?;oGq!Bmc|I^d`F9Oj>IzW=S7{{k>F;bdXu=4RyJ z1pZ+M0JV$UJVr)9M?3Jj#|?B1vjYBgh`#~cu$n21;$znhcFuQTB_IC@gVF($qh+zR z{H3|qpm~Q%)8_E9Au6WJI$57W=zbv4+<Y<%0O;tyv&u9d#XAX29zeh)C!|#rH<gcE zKwu1NJ1L<5Flr}l90AN->64EAsD}y}z>Ha1Gp8wTykWO(Sc%T__msesEihAEq_2AY zNHy1vrd@O;wMIWDZAMHe;8MpnIcd*-clXv5r`^+}ln`@31y#<Nyy_~oiA5-CWQ;H$ zhBKDp($yg3I~!`=5qDwkSL|vWgl$D~MWi>>!rz%`fitrpfqTZzE$Nj{brYj0Vd%>- zF@J+jP)a{Eor&NtN(%6(7Ss~9fC8U_tkS|BuM2w1$zxpzh5`N6Mn$h|puI@k%guRH znQT%d#VrZL!o_i>mg^5e5e7Nn5#p`FMX1K5)(?lU0ie#Yd^;0)bq96SU*stvj+k>H ze0I9k@AB}~W*`oHEuGqYvvYWNtXa3!YDeH-H1PTf)i6~DzL!0_7fF=qMyOPO;BC2< z)fGVYgsuE@*xdBYMu2oCD2aD`MI|##F*kdruD^yfGd{@~Er_r@NzPl4iopFF*z<T) zQ=BLROX8f|I2e@E-u*S<zHjGVFj^Muv{5Gm&%lB`Dj)P-gH-%+3QGq%>=~~t>~~Tm zJLDRTx5Fhl1`PPCANAuP4b=XNj4;o!N6NE9PiHZn5bKQ`KU)-MRJ|jMz>67g{~ylY zDy+`6%hJX*1Shz=yF0-pxH~M|-Gf800KqM|ySrQP;O_1rxb~Z`x~l)Zt9EzQf2{-7 zI^yJ&XO20>J#a?CwvZLeEn4q7R+M+~PATtxGa0#jP>XfSJ*DUq2Y>t4-9ai%MbuCy z+YTC<++qqp<dVvU=_G*8k!?hWWm#?ZW{eg3X=~H^O^QIWuwt3JM%9N0eva8G<4e|} z;i7a^$`JgbflfwTaJ95=S!kpzuehH<2x^rmGWEmr8SQB!v?R`{NDkz4hHs5UkoS4l z-;{Q{u~EH3KpNQu7UyFB#fena(iWrwP%%Kx2JAen%v{V|z{-b{%@|PC*o_!?fZsqS zg2$MPnT3<vl<gl?wQ-dmg&k%L{yvS5oqRP0qB6OZ7IOu~Xoynq77bkAv`Y#WD{aI^ zH>%Rnq?#NSA<>i>Cojgw(ref{7DpI8_6HFv5I%|pLR8{*V3aaU`3ejj9Om+8IxUH0 zEQnr3;Pu#U8jb9~I0mRsRQ#r1w&5hvSgr5n{kf=^&Ut+DVM$zeUvt)irojv5I$}fH z(SN;P01hta=Y_$7THSWMQBJu@=@xbMOHW@lV=xOsR#tzlur!RRD5d}8^gSvSxe@no zjtR|MZW49@gF(Oflf84$IO25`iE36>dus;#P{x5!8X<1m(zhll-J!3Pbml((4oSJS z`-1Q0U?yat-G~U~@=(3{-K;;-VK^`Qh^A5&>sEcFf(do+4>vQrAA0ZA74w6GHb6K6 z-Vk>oVVC(wz$=av>~pwNWwhxzTV61V>V#2%c@x*%i)%-7%g90U+}Ad7hn5>Bf=QBm z>BEhM>>8@o0z!hqCqL9S+pic^&F3z3X?o5+s6webL=f-!XP{KNSt@*kP~JK`2`}@a zL$6L6e{{}pURKlwyiJ<vp1mDkG5u9|HS1?W)dx;kX8$)+l#Qp2rQP3K$2bAk=$}X^ zgQ*e6AA^MvfQVuTQhm&vEP#l{3UF;f|EP)p1Izzu9ZUUW5>|8MVv+Gn?%2b`+}zxN zSG0A}3cN;C;q<csc&KoAx=T^rSGY`P*9#tA<|hw1^fT0tJszA36>%ljv8W1Kjp--I zE6{1jL_x@G-~(Nj#w)LBR7Jrc2jmv*AUWfb*firG2wh7Y3wO%<oksiu0@1{fH6|gZ z`Phy%Y^4V(Y1kODHnXvkIzlS=i1T7XJeb3r@}u`pqi3O~Bm?1~kbWF+J-X76t~8j_ zIAdOX?93CwfN;eV1SNjAIJ_NfqlI+RnL6X4ni}yQWi`b8QI+@3FVR>xn|(xVig@@! zrX7`K0|pkUm+X65b51(#pLFM~B5YIT46B{VLX?LIy}hcz)$^k)_To5P_fvm@yM>=a zt<=1eWn`@OtR&p^PO1*w!ugp451o<zS)>+3V?(asEKn`KB32kpZ*HC1De%ko<a=m+ zk301T-F!{`<DcyE0-B4m80!_B$W1GI$AbpC_JS43%Qs4ivfWLRNqoxdwpO#j1%?uz ztM`=4oXeP|+79th9#(Fi%k>v#?@)sLr)x4hi4s1+ra0A{>mt*C2**Q(mA46x;|rik z<JCZ|9n9jR>?db*O~;be4SSk|RL~srx5K>4mb$1QWGBN}OUPuYRYW6zqMx$}PE!(S zD^x^&($7;Tz0J`6*2R7@)=dBDQ#%NivK?%B5XP5}vlW8IpU5!_OudO=P#QdQ8p*S2 zJ3>X%MEaJiTP1e;+RLq4juX$SV#U2FW0M?X;LWVg{b{UkK0)&ve5(_waAgR5D{B#B zM3EhacnoG6q3==Mn`yn3qbkxiugVgHA|dgv<peU0T3w4p?Ood`TmOBm6a!6}#ny`% zcKD$1w@&56hyih50xw_H{c(pYX*eI=$<{S=Cq|3Z^vkCLJA~wT_Xw^ToOXQ^mUr_S z?wMbfQ#1nK#aZh!{~+8-()h)0_q$<rjY?4i)HRl-jCdRR$EWVyxgw!YbIrkYVS@M6 z7ym+5QuQr%Gz#!dC16gfSNBbJG~`ca3oiA&ToobF+!T0enp_cF5RW-yRz0~HBCHiX z<#uLOt!Fb*P5cgyqm7OxTb^m&v)(l4!WKepbu0Uore~lV^N4$`;u#xwd@$v)vp{qI zw=+`j%d%GnU;qfB`bVeuf89N6#>vgf#sS>cET&vW00R!_>)>GnKm;tzTx@_1fWyRy z{h#A;+JC3cppKBccqO<rEBA)vJ3HI=>PMk1<<_o}zrFl`!5hUQvJF^u@qnyw(|Ef1 z<~Edkkp-FS3yEcftAFG+KFtBAcf2*#HGOeW8Qu6=`LrJ|a?L>;GlRp=?`OK$780Tv z7)!>8%|SvQ#k*8U2UOzZnxp~II1adg2kz~~Xsd(=v&T0I;=(X))@1q(d|!%fo?f&& z6#<YF{V3<#1Aj)dGhA#yUwGaWv|i+N2-DI-W?C%9&|=%Pc}1g&eQv6e_lIE%{~6}> zd8L)=ipa;6k~J44u|?Wz*ZWb3%Ih<%<29}>y};}RJ^^~1Oh&)30Y)SU+(N8_R#4BE zO1UM1eJEN!s*Er9+~Y~>9fd42X4I7U{0tGk{p?&>qWLd`j-k~;2g?vF82*Ni97GmZ zCwpII>xjFuFG7uo!opP=MJ&s50Qjez1EtZp63UE8^_7zJFu{AnUZv`(wwS&8ccUz) zkD0cv8Idt2Z|U&k_lTrbG>9l0y9lGT!AuzeKihER&%@k<ehy8ze!lYKpL(1WB?yR7 zsl(%l+e`)6pX8N=r*Eb-lcD@b-EFCY{-el*x5$C*7`}*XhOxV@J?GwW<Cs~dj+QO! znH+i-xks9V@8$&h8gne=bjC|abhon$7hKYB+1XgO=!ReUB%mglJ={gz#J_x;YfF{2 z^6kHWX1no!7p#+VIXP{giuT2RLstPZWi~TZC1($+gPkkvBf{e0uT50k893)>q)_%> zO)A_Yhf4F&pGxpXTZv-IR~V1|VqHG#_KX0LnnrpRzM}Ufky2m9sme+_MoU9Z8?6m= z!*xs$B>E&Of6ZHQMQOFMWGbB8mjApO0o{nXCNj<|OJ0kC7l8c$_hQ$a;=T^H=bWE= z4Y>iIrV}Omf>q(pSF+uu_2mnG2@7TjP6=s-^6@%%m)Sa7sJ{BT{)Kk@7F<myr7D;r zpk*EjArg>~hKQN^$t35GkKC)e9qJ`GtdA+ntl`|U-IY+t$as@R_`EwsBxTvH!nePp zE3zmuJd7<zz;vRBK9W&op>vi~#t}a-wZUu+*SzUeJ0BsJ=DY~34K<aP?#2G~Ks=V9 zw?pudNmbP{SvM(1)}SzaN~~5;pz<XdaVKah=wQTBf*n8L>-_EBMHGjM^zzG@VVM$s zE_+4nQ@GC|1J?F=CYSSA3qiuJFP1$WNc2NU?m(a+S4Y*jxykvsQvi;466SuAf7R7E zJ>_ccy1aN#S)4G~fMRwgu5vzuGR=Xv7yE8hyfXbKWX7r>#wsu-p$MihsFVv4>=h-_ zF-zh>HM0Y|d>-ame0=K|NY1D1Xau_mkG#Pk+v1utj=ak2K<ttB{IifgvWd6K*y9Qf zdU6`WgU5%GN2P*`dEU~5$)eaRwULbSpX07c$luzL1h=NpQTxtO5m<2S@4JJ=;vLB} z^d7@H-7pu<1Tjl#r;H`3_^kS58N6c?mwQ}EF{U>m&2k%(sC*Dg*KZD0G^0QYw64i1 zDE+l0J;Z28W!w?&m$9$XG~gJtqF%-R3dy)o<ki@vMlU7VSb4c_DI;ayc;X!-#OK?V znwxXU3Pzp06Z%ECKQeVwLgd`zDsXllx>qL@K^G0CxUMC1xSKjGaa3yBs_0`vYp;I{ zZ)ltq!r+vimSiD;6|Le|a!x&f9~MN9)xh`1P<i5i<sP}uJKRQSr7*;fPSo^p$bUe5 z5cELsKSGBSwhMAIo{8%@f;-%`5ia@-wFirOtm?awgv(--*m@9pxe1AhgSVT`|K7Ov z8a-I>``dE2QD(r-0_6LqgkEzn9yI@in%rl>W!ysLy0$#eg!GwR>mU)A^y^w@a{dc~ z{Pgc6Xd+uum<(53v|1+XzABokep>S{e_M#<Q&DS-0CNF64j7o)UuJ{<06Qq!|JwoZ zKO5r#C<q4+@L@1E1MKkZ8~~Gy2k_qjMYPNyW1zd7+m!nsV;g6hvVRiPf}j2*sB0K( z^b?smMuTZeS%Z;Gt*n@IYS&=SmXkEF;2-Yj@OCvD;Z4KxZ)3NE^Ix^s47zqlCs(L# z1i)nBqpR4<Y^@l=1&+UYxE8E2QF$3W@wYac#=fG5z#$#5`yfcaL)>(a4F`qu?29&2 z_;0&VBkNmQPkhTU;ujQbB!OuprJ>SaF0=vdzJ1S~x?~v|RM-W#^bWU`!8jUHU<{U< zVfFLt((<qluU6Pj*ja?ENZdyh%10_@h~>oFHn7wia)#M1Y71CfupNT%H8d&L8HQRw zR@}H~6SbBJeWnSZJ8Mo{KHQ$62xrUON}&!th^(q!2B^?GtH+{U2pLE5BT2zwihM1b za+kpwm7Hmt^1~JL?EBqlWfVv#O<n9V04h{RLY;gtqZYDRPX~{}8V&8sCB0H04%=c| z4n!?EDm`}$nr*P$?3{eQPG$k<y^?)+xe$4A0+WaWbtJQpX@zB4uP*!CP*dH&%eWKo z6K94lQ|DMw#Yu(YhhCHhhBgLct>Z;-vss+d$hv~S>p;s(`?ap_9dq;Bvc+|g4H2++ zwU-P_zD$9Ql;0T6=@%pzC5F#=lh%K~#MmL@=id=5%ytkT!b~0Um~A9enmJLNZg6pv zgM)vjskW9$(6c4io1VzC`k?a8jq%%k#BfPm>y85aoRx<@ZWz4MVJqsEz|g7=k@V*C z*u?n2ZPX)kVfy_u#-ybTx2Hc|8tQ&RIgLxH#*ej2|GuV{iKsy~&8-7GR~as4RU(He z{FLjxC8p#f1MnxD5CiPKPdt+1T1#|q0ewgq(LF-z*dtcK-OOX-jJ<L_40IW`y_a$$ z5v?kQNtk<xM=a*6aGwzxwWW;D`kj(+jY~g%liB6Yr@41x65tE#Qo@CZ8Dh5|YFF50 zdYWCcoRR>4ucElBgqT8h6rMm`d|$gwGZ($#FVVfE;#@((k_>y>D5ODi`~}M7!2LN# z-^q*X$hho6t0PPBusM4L>-IWesp%%$zO8bl2BJ;pgz~N8`)=gfD&|5BHVIfQUX*15 z9HGxvW{~_IrtKabl$MEQNNU>ry`2)C&xnnzKCR21ZXvAJ`Tc^95}S!`rRJVZUc$pt z_%%!_C5L%`a)T`L-0eZi9D44rgpXeGj9Ll|sWNc6N0mi2s+=T}&2%0t3OGyA<mN|{ z<R_=%H5Fy$tJ3fC<leCf+I(&L0db>M90(+)eSeapLg+rHnq1ho2Rg=wJ6h@8%SmZm zc1Ak3uq2OUb4!bE?>VQ`%q{pSg2_1IpbsLtT;$DLAgowroucK&Qy>yse&nZEKEbII zn)@YtU<LfGUnGYIU2r*e<;oeKHbb&iyFe|@oq@UE_#vlyxo<wXZSr9?;*IQ9jjwHB z|B9;;6fh``EBf%GqfLiozc+cx%8Q8hx$JrNm>|zGtzoo@-sfBFHh*HRqD^Vn^s|pV z&)VVjGJKCCr6=AlAAyTAgYR@yv6XL^^vxoiXSG;Hop^hpBzsf%-4Uw3m_^nq%fu?{ z#5&u0p=HK2hv3`>nH8O-gLi|K+|JkdUrMa6pDBV=cdqjE+dVsq=K_zxw7+xEUAR45 zsdo`z8Nf<@7-9F`Y<oTYTUn7?#tS$mph*(^|1{mQ0JLRxV-q&OZ2$sb`&=NP4h=YO z|KTnH5J(edpe5!Xy(xg{_G_9j#vcZ0;Mh|)Gcm0aXLOsL+-WPrGP`fkR_;u<aG0a( z+sk`etoroD{I9F2zx5_MU?uLJ_}TPD)Z0-^Ow*xoh&AIJY6$b@oFZTk1)K4ti~SC8 z3SQCSVl(7Ply%_Qa)gu6RP+DMbUVJrvP`Y^nAEUxHm2RUpSWHA$8`IC(%#dIA}*pl zZ#OX)-Pqt)jMoAF#{`s>$6p}ATBKdn{aocZL;t8_#ryTe>_ST0CjOr>x`uXR_^U;+ z#!bkO{0^Zkjow)+CQ-~>j;;$4PsbI(EUiEO5J;ni4V<6hFFb@W-g>y{uG MdcT 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̱AF4 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 literal 0 HcmV?d00001 diff --git a/energyml-utils/rc/epc/testingPackageCpp22.h5 b/energyml-utils/rc/epc/testingPackageCpp22.h5 new file mode 100644 index 0000000000000000000000000000000000000000..2cbd0be99b722c8f745e3b437b3713a72831e401 GIT binary patch literal 141474 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 Y)t%fJIWZ()NWhSQA%QfKzya_714n?1&j0`b literal 0 HcmV?d00001 From d91c7832c34452a2cc7c86a4d7d9f9fa02ad50f8 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Wed, 4 Mar 2026 15:15:55 +0100 Subject: [PATCH 65/70] -- --- energyml-utils/example/attic/arrays_test.py | 6 +- .../example/attic/crs_info_from_epc.py | 385 +++++++++ energyml-utils/rc/epc/README.md | 1 + .../src/energyml/utils/data/__init__.py | 1 + energyml-utils/src/energyml/utils/data/crs.py | 740 ++++++++++++++++++ .../src/energyml/utils/data/helper.py | 145 +--- energyml-utils/tests/test_crs_info.py | 595 ++++++++++++++ energyml-utils/tests/test_mesh_numpy.py | 530 +++++++++++++ 8 files changed, 2281 insertions(+), 122 deletions(-) create mode 100644 energyml-utils/example/attic/crs_info_from_epc.py create mode 100644 energyml-utils/rc/epc/README.md create mode 100644 energyml-utils/src/energyml/utils/data/crs.py create mode 100644 energyml-utils/tests/test_crs_info.py create mode 100644 energyml-utils/tests/test_mesh_numpy.py diff --git a/energyml-utils/example/attic/arrays_test.py b/energyml-utils/example/attic/arrays_test.py index d65913e..ff06a67 100644 --- a/energyml-utils/example/attic/arrays_test.py +++ b/energyml-utils/example/attic/arrays_test.py @@ -1,5 +1,4 @@ import logging -from sqlite3 import NotSupportedError import traceback from typing import List, Optional from energyml.utils.data.datasets_io import get_handler_registry @@ -24,8 +23,9 @@ get_object_attribute, search_attribute_matching_name_with_path, ) -from energyml.resqml.v2_2.resqmlv2 import Point3DLatticeArray -from energyml.eml.v2_3.commonv2 import TimeSeries +from energyml.resqml.v2_2.resqmlv2 import VerticalCRS +from energyml.resqml.v2_0_1.resqmlv2 import DiscreteProperty as DiscreteProperty201, ContinuousProperty as ContinuousProperty201 +from energyml.eml.v2_3.commonv2 import TimeSeries, ColumnBasedTable from energyml.eml.v2_1.commonv2 import TimeSeries as TimeSeries21 from energyml.utils.serialization import read_energyml_xml_str, serialize_json diff --git a/energyml-utils/example/attic/crs_info_from_epc.py b/energyml-utils/example/attic/crs_info_from_epc.py new file mode 100644 index 0000000..b107715 --- /dev/null +++ b/energyml-utils/example/attic/crs_info_from_epc.py @@ -0,0 +1,385 @@ +# Copyright (c) 2023-2024 Geosiris. +# SPDX-License-Identifier: Apache-2.0 +""" +Integration examples for :mod:`energyml.utils.data.crs`. + +Reads real EPC files from ``rc/epc/`` and exercises :func:`extract_crs_info` +against every CRS object they contain. Also shows how to walk from a +``Grid2DRepresentation`` to its CRS and call ``extract_crs_info`` on the +resolved object. + +Run from the workspace root:: + + poetry run python example/attic/crs_info_from_epc.py + +Expected output: all test cases show ``[PASS]``. +""" +from __future__ import annotations + +import logging +import sys +from pathlib import Path +from typing import Any, Optional + + +# Run $env:PYTHONPATH="src" if it fails to be executed from the project root. + +# ── make the local ``src/`` take precedence when running directly ────────── +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +from energyml.utils.epc import Epc +from energyml.utils.introspection import get_obj_uuid, get_object_attribute_rgx +from energyml.utils.data.crs import CrsInfo, extract_crs_info + +# suppress noise from EPC loading +logging.basicConfig(level=logging.ERROR) + +# ── EPC file paths (relative to workspace root) ──────────────────────────── +_ROOT = Path(__file__).parent.parent.parent +EPC20_PATH = str(_ROOT / "rc" / "epc" / "testingPackageCpp.epc") +EPC22_PATH = str(_ROOT / "rc" / "epc" / "testingPackageCpp22.epc") + +# ── Simple test harness ──────────────────────────────────────────────────── +_passed = 0 +_failed = 0 + + +def check(label: str, expected: Any, actual: Any, *, approx: bool = False) -> None: + """Print PASS / FAIL and update counters.""" + global _passed, _failed + if approx: + import math + ok = (expected is None and actual is None) or ( + isinstance(expected, (int, float)) + and isinstance(actual, (int, float)) + and math.isclose(float(expected), float(actual), rel_tol=1e-6) + ) + else: + ok = expected == actual + if ok: + _passed += 1 + print(f" [PASS] {label}") + else: + _failed += 1 + print(f" [FAIL] {label}") + print(f" expected : {expected!r}") + print(f" actual : {actual!r}") + + +def section(title: str) -> None: + print(f"\n{'─' * 60}") + print(f" {title}") + print(f"{'─' * 60}") + + +def _resolve_crs_from_grid(grid_obj: Any, epc: Epc) -> Optional[Any]: + """ + Walk from a representation object to its CRS document. + + Tries the common path ``local_crs`` (or nested in ``grid2d_patch.geometry`` + for v2.0.1 ``Grid2DRepresentation``). Returns the resolved CRS object or + ``None``. + """ + # Direct attribute (v2.0.1 and many v2.2 envelopes) + dor = get_object_attribute_rgx(grid_obj, "[Ll]ocal[_]?[Cc]rs") + + if dor is None: + # Nested path used by Grid2DRepresentation v2.0.1 + dor = get_object_attribute_rgx( + grid_obj, + "[Gg]rid2[Dd][Pp]atch.[Gg]eometry.[Ll]ocal[_]?[Cc]rs", + ) + + if dor is None: + return None + + uuid = get_obj_uuid(dor) + if not uuid: + return None + + candidates = epc.get_object_by_uuid(uuid) + return candidates[0] if candidates else None + + +# =========================================================================== +# RESQML v2.0.1 — testingPackageCpp.epc +# =========================================================================== + +section("Loading testingPackageCpp.epc (RESQML v2.0.1)") +epc20 = Epc.read_file(EPC20_PATH) +print(f" Loaded {len(epc20.energyml_objects)} objects.") + +# ── LocalTime3DCrs ───────────────────────────────────────────────────────── + +section("v2.0.1 · LocalTime3DCrs (uuid dbd637d5…)") + +local_time_crs = epc20.get_object_by_uuid("dbd637d5-4528-4145-908b-5f7136824f6d")[0] + +# Test: extract without workspace (all data is inline for v2.0.1) +info: CrsInfo = extract_crs_info(local_time_crs) + +check("source_type", "LocalTime3DCrs", info.source_type) +check("x_offset", 1.0, info.x_offset, approx=True) +check("y_offset", 0.1, info.y_offset, approx=True) +check("z_offset", 15.0, info.z_offset, approx=True) +check("projected_uom (raw from xsdata enum)", "M", info.projected_uom) +check("vertical_uom (raw from xsdata enum)", "M", info.vertical_uom) +check("z_increasing_downward", False, info.z_increasing_downward) +check("areal_rotation_value", 0.0, info.areal_rotation_value, approx=True) +check("projected_epsg_code", None, info.projected_epsg_code) +check("vertical_epsg_code", None, info.vertical_epsg_code) +check("azimuth_reference", None, info.azimuth_reference) + +# ── LocalDepth3DCrs ──────────────────────────────────────────────────────── + +section("v2.0.1 · LocalDepth3DCrs (uuid 0ae56ef3…)") + +local_depth_crs = epc20.get_object_by_uuid("0ae56ef3-fc79-405b-8deb-6942e0f2e77c")[0] +info = extract_crs_info(local_depth_crs) + +check("source_type", "LocalDepth3DCrs", info.source_type) +check("projected_epsg_code", 23031, info.projected_epsg_code) +check("projected_uom", "M", info.projected_uom) +check("vertical_uom", "M", info.vertical_uom) +# This particular depth CRS has z_increasing_downward=False in the file +# (the VerticalCrs it references has no direction set or direction=up) +check("z_increasing_downward", False, info.z_increasing_downward) +check("x_offset", 0.0, info.x_offset, approx=True) +check("y_offset", 0.0, info.y_offset, approx=True) +check("z_offset", 0.0, info.z_offset, approx=True) + +# ── LocalEngineeringCompoundCrs (inside v2.0.1 EPC) ─────────────────────── +# This file mixes v2.0.1 and v2.3/v2.2 objects; the compound CRS is v2.3. + +section("v2.0.1 EPC · LocalEngineeringCompoundCrs (uuid 95330cec…)") + +compound_crs_20 = epc20.get_object_by_uuid("95330cec-164c-4165-9fb9-c56477ae7f8a")[0] + +# Without workspace: only inline z-axis info (no DOR resolution) +info_no_ws = extract_crs_info(compound_crs_20, workspace=None) +check("z_increasing_downward (inline VerticalAxis)", True, info_no_ws.z_increasing_downward) + +# With workspace: DORs resolved → full CRS info +info = extract_crs_info(compound_crs_20, workspace=epc20) +check("projected_epsg_code (resolved via DOR)", 23031, info.projected_epsg_code) +check("projected_uom", "M", info.projected_uom) +check("vertical_uom", "M", info.vertical_uom) +check("z_increasing_downward", True, info.z_increasing_downward) +check("azimuth_reference", "grid north", info.azimuth_reference) + +# ── LocalEngineering2DCrs (inside v2.0.1 EPC) ───────────────────────────── + +section("v2.0.1 EPC · LocalEngineering2DCrs (uuid 811f8e68…)") + +eng2d_crs_20 = epc20.get_object_by_uuid("811f8e68-c0e4-5f90-b9cf-03f7e3d53ca4")[0] +info = extract_crs_info(eng2d_crs_20) + +check("projected_epsg_code", 23031, info.projected_epsg_code) +check("projected_uom", "M", info.projected_uom) +check("vertical_uom", None, info.vertical_uom) # (none — 2D CRS has no Z) +check("z_increasing_downward", False, info.z_increasing_downward) +check("azimuth_reference", "grid north", info.azimuth_reference) + +# ── VerticalCrs (inside v2.0.1 EPC) ─────────────────────────────────────── + +section("v2.0.1 EPC · VerticalCrs (uuid 1f6cf904…)") + +vert_crs_20 = epc20.get_object_by_uuid("1f6cf904-336c-5202-a13d-7c9b142cd406")[0] +info = extract_crs_info(vert_crs_20) + +check("vertical_uom", "M", info.vertical_uom) +check("z_increasing_downward", True, info.z_increasing_downward) +check("projected_epsg_code", None, info.projected_epsg_code) # (vertical has none) +check("projected_uom", None, info.projected_uom) # (vertical has none) + +# ── Grid2DRepresentation → CRS (v2.0.1 approach) ───────────────────────── + +section("v2.0.1 · Grid2DRepresentation → CRS via geometry.local_crs DOR") + +# Grid 030a82f6 → LocalTime3DCrs (dbd637d5) +grid_time = epc20.get_object_by_uuid("030a82f6-10a7-4ecf-af03-54749e098624")[0] +resolved_crs = _resolve_crs_from_grid(grid_time, epc20) +check("resolved CRS type", "LocalTime3DCrs", type(resolved_crs).__name__ if resolved_crs else None) +if resolved_crs: + info = extract_crs_info(resolved_crs, workspace=epc20) + check(" x_offset", 1.0, info.x_offset, approx=True) + check(" y_offset", 0.1, info.y_offset, approx=True) + check(" z_offset", 15.0, info.z_offset, approx=True) + check(" projected_uom", "M", info.projected_uom) + +# Grid aa5b90f1 → LocalDepth3DCrs (0ae56ef3) +grid_depth = epc20.get_object_by_uuid("aa5b90f1-2eab-4fa6-8720-69dd4fd51a4d")[0] +resolved_crs = _resolve_crs_from_grid(grid_depth, epc20) +check("resolved CRS type", "LocalDepth3DCrs", type(resolved_crs).__name__ if resolved_crs else None) +if resolved_crs: + info = extract_crs_info(resolved_crs, workspace=epc20) + check(" projected_epsg_code", 23031, info.projected_epsg_code) + check(" projected_uom", "M", info.projected_uom) + check(" z_increasing_downward", False, info.z_increasing_downward) + +# Grid 4e56b0e4 → also LocalDepth3DCrs (same uuid) +grid_depth2 = epc20.get_object_by_uuid("4e56b0e4-2cd1-4efa-97dd-95f72bcf9f80")[0] +resolved_crs = _resolve_crs_from_grid(grid_depth2, epc20) +check("Grid 4e56b0e4 resolved CRS uuid", "0ae56ef3-fc79-405b-8deb-6942e0f2e77c", + getattr(resolved_crs, "uuid", None)) + +# =========================================================================== +# RESQML v2.2 / EML v2.3 — testingPackageCpp22.epc +# =========================================================================== + +section("Loading testingPackageCpp22.epc (RESQML v2.2 / EML v2.3)") +epc22 = Epc.read_file(EPC22_PATH) +print(f" Loaded {len(epc22.energyml_objects)} objects.") + +# ── LocalEngineering2DCrs (no EPSG, has offsets) ───────────────────────── + +section("v2.2 · LocalEngineering2DCrs (uuid 997796f5…) — offsets, no EPSG") + +eng2d_no_epsg = epc22.get_object_by_uuid("997796f5-da9d-5175-9fb7-e592957b73fb")[0] +info = extract_crs_info(eng2d_no_epsg) + +check("x_offset", 1.0, info.x_offset, approx=True) +check("y_offset", 0.1, info.y_offset, approx=True) +check("projected_uom", "M", info.projected_uom) +check("projected_epsg_code", None, info.projected_epsg_code) +check("azimuth_reference", "grid north", info.azimuth_reference) +check("z_increasing_downward", False, info.z_increasing_downward) + +# ── LocalEngineering2DCrs (with EPSG 23031) ────────────────────────────── + +section("v2.2 · LocalEngineering2DCrs (uuid 671ffdeb…) — EPSG 23031") + +eng2d_epsg = epc22.get_object_by_uuid("671ffdeb-f25c-513a-a4a2-1774d3ac20c6")[0] +info = extract_crs_info(eng2d_epsg) + +check("projected_epsg_code", 23031, info.projected_epsg_code) +check("projected_uom", "M", info.projected_uom) +check("azimuth_reference", "grid north", info.azimuth_reference) +check("z_increasing_downward", False, info.z_increasing_downward) + +# ── LocalEngineeringCompoundCrs (no EPSG, has offsets + z) ────────────── + +section("v2.2 · LocalEngineeringCompoundCrs (uuid f0e9f421…) — offsets + z offset") + +compound_no_epsg = epc22.get_object_by_uuid("f0e9f421-b902-4392-87d8-6495c02f2fbe")[0] + +# Without workspace: only inline VerticalAxis info available +info_no_ws = extract_crs_info(compound_no_epsg, workspace=None) +check("z_offset (inline origin_vertical_coordinate)", 15.0, info_no_ws.z_offset, approx=True) +check("z_increasing_downward (inline VerticalAxis)", True, info_no_ws.z_increasing_downward) +# This particular compound CRS mixes a time-domain vertical axis (uom='S') +# with a depth-domain resolved VerticalCrs (uom='M') — inline returns 'S' +check("vertical_uom (inline VerticalAxis — time domain)", "S", info_no_ws.vertical_uom) +check("x_offset without workspace", 0.0, info_no_ws.x_offset, approx=True) + +# With workspace: DORs resolved → horizontal CRS merged in +info = extract_crs_info(compound_no_epsg, workspace=epc22) +check("x_offset (from resolved LocalEngineering2DCrs)", 1.0, info.x_offset, approx=True) +check("y_offset (from resolved LocalEngineering2DCrs)", 0.1, info.y_offset, approx=True) +check("z_offset (inline)", 15.0, info.z_offset, approx=True) +check("projected_uom (from 2D CRS)", "M", info.projected_uom) +check("projected_epsg_code (2D CRS has none)", None, info.projected_epsg_code) +check("vertical_uom", "M", info.vertical_uom) +check("z_increasing_downward", True, info.z_increasing_downward) +check("azimuth_reference", "grid north", info.azimuth_reference) + +# ── LocalEngineeringCompoundCrs (EPSG 23031) ───────────────────────────── + +section("v2.2 · LocalEngineeringCompoundCrs (uuid 6a18c177…) — EPSG 23031") + +compound_epsg = epc22.get_object_by_uuid("6a18c177-93be-41ac-9084-f84bbb31f46d")[0] +info = extract_crs_info(compound_epsg, workspace=epc22) + +check("projected_epsg_code (resolved)", 23031, info.projected_epsg_code) +check("projected_uom", "M", info.projected_uom) +check("vertical_uom", "M", info.vertical_uom) +check("z_increasing_downward", True, info.z_increasing_downward) +check("x_offset", 0.0, info.x_offset, approx=True) +check("y_offset", 0.0, info.y_offset, approx=True) +check("z_offset", 0.0, info.z_offset, approx=True) +check("azimuth_reference", "grid north", info.azimuth_reference) + +# ── VerticalCrs (uuid 65cd199f) ────────────────────────────────────────── + +section("v2.2 · VerticalCrs (uuid 65cd199f…)") + +vert_crs_22a = epc22.get_object_by_uuid("65cd199f-156b-5112-ad3e-b4f54a2aa77b")[0] +info = extract_crs_info(vert_crs_22a) + +check("vertical_uom", "M", info.vertical_uom) +check("z_increasing_downward", True, info.z_increasing_downward) +check("projected_epsg_code (none for vertical)", None, info.projected_epsg_code) + +# ── VerticalCrs (uuid 355174db) ────────────────────────────────────────── + +section("v2.2 · VerticalCrs (uuid 355174db…)") + +vert_crs_22b = epc22.get_object_by_uuid("355174db-6226-57ae-a5a6-92f33825fed4")[0] +info = extract_crs_info(vert_crs_22b) + +check("vertical_uom", "M", info.vertical_uom) +check("z_increasing_downward", True, info.z_increasing_downward) + +# ── Grid2D v2.2 — CRS note ──────────────────────────────────────────────── +section("v2.2 · Grid2DRepresentation — CRS resolution note") +print(""" + In RESQML v2.2, Grid2DRepresentation does not embed a local_crs DOR + in its geometry patch the same way v2.0.1 does. Instead the CRS is + referenced via the containing LocalEngineeringCompoundCrs or through + the representation's own schema-level CRS association. + + The canonical way to obtain CRS info from a v2.2 representation is to: + 1. Retrieve all LocalEngineeringCompoundCrs objects from the EPC. + 2. Match the one that logically covers your representation (by + consulting interpretation / framework associations). + or use the helper ``get_crs_obj(repr_obj, workspace=epc)`` which walks + the object hierarchy. + + Direct example — iterate all compound CRS objects and extract info: +""") + +for obj in epc22.energyml_objects: + if "localengineeringcompoundcrs" in type(obj).__name__.lower(): + info = extract_crs_info(obj, workspace=epc22) + print(f" CompoundCrs {obj.uuid}") + print(f" projected_epsg={info.projected_epsg_code} projected_uom={info.projected_uom}") + print(f" vertical_uom={info.vertical_uom} z_down={info.z_increasing_downward}") + print(f" offsets: x={info.x_offset} y={info.y_offset} z={info.z_offset}") + +# =========================================================================== +# Convenience helpers (delegates in helper.py) +# =========================================================================== + +section("Legacy helper delegates still work correctly") + +from energyml.utils.data.helper import ( + is_z_reversed, + get_projected_epsg_code, + get_projected_uom, + get_vertical_epsg_code, + get_crs_offsets_and_angle, +) + +depth_crs = epc20.get_object_by_uuid("0ae56ef3-fc79-405b-8deb-6942e0f2e77c")[0] +check("is_z_reversed(LocalDepth3DCrs)", False, is_z_reversed(depth_crs)) +check("get_projected_epsg_code", 23031, get_projected_epsg_code(depth_crs)) +check("get_projected_uom", "M", get_projected_uom(depth_crs)) + +time_crs = epc20.get_object_by_uuid("dbd637d5-4528-4145-908b-5f7136824f6d")[0] +x, y, z, (angle, uom) = get_crs_offsets_and_angle(time_crs) +check("get_crs_offsets_and_angle x", 1.0, x, approx=True) +check("get_crs_offsets_and_angle y", 0.1, y, approx=True) +check("get_crs_offsets_and_angle z", 15.0, z, approx=True) + +# =========================================================================== +# Summary +# =========================================================================== + +section("Summary") +total = _passed + _failed +print(f" {_passed}/{total} checks passed.") +if _failed: + print(f" {_failed} checks FAILED — see [FAIL] lines above.") + sys.exit(1) +else: + print(" All checks passed!") diff --git a/energyml-utils/rc/epc/README.md b/energyml-utils/rc/epc/README.md new file mode 100644 index 0000000..1411d95 --- /dev/null +++ b/energyml-utils/rc/epc/README.md @@ -0,0 +1 @@ +TestingPackage epc + h5 files comes from FESAPI library : https://fastapi.tiangolo.com/ \ No newline at end of file diff --git a/energyml-utils/src/energyml/utils/data/__init__.py b/energyml-utils/src/energyml/utils/data/__init__.py index be38189..8226463 100644 --- a/energyml-utils/src/energyml/utils/data/__init__.py +++ b/energyml-utils/src/energyml/utils/data/__init__.py @@ -6,3 +6,4 @@ Contains functions to help the read of specific entities like Grid2DRepresentation, TriangulatedSetRepresentation etc. It also contains functions to export data into OFF/OBJ format. """ +from .crs import CrsInfo, extract_crs_info # noqa: F401 diff --git a/energyml-utils/src/energyml/utils/data/crs.py b/energyml-utils/src/energyml/utils/data/crs.py new file mode 100644 index 0000000..b94266c --- /dev/null +++ b/energyml-utils/src/energyml/utils/data/crs.py @@ -0,0 +1,740 @@ +# Copyright (c) 2023-2024 Geosiris. +# SPDX-License-Identifier: Apache-2.0 +""" +CRS (Coordinate Reference System) extraction module. + +Provides a version-neutral ``CrsInfo`` DTO that captures all CRS metadata +relevant for 3D rendering (offsets, UOMs, EPSG codes, rotation / azimuth), +and a single ``extract_crs_info`` factory that handles both: + +- **RESQML v2.0.1** — ``LocalDepth3dCrs`` / ``LocalTime3dCrs`` / + ``AbstractLocal3dCrs`` +- **RESQML v2.2 / EML v2.3** — ``LocalEngineeringCompoundCrs`` → + ``LocalEngineering2dCrs`` + ``VerticalCrs`` + +Usage:: + + from energyml.utils.data.crs import CrsInfo, extract_crs_info + + info: CrsInfo = extract_crs_info(my_crs_obj, workspace=epc) + print(info.projected_epsg_code, info.x_offset, info.z_increasing_downward) +""" +from __future__ import annotations + +import logging +import math +from dataclasses import dataclass, field +from typing import Any, Optional + +from energyml.utils.storage_interface import EnergymlStorageInterface +from energyml.utils.introspection import ( + get_obj_uri, + get_obj_uuid, + get_object_attribute, + get_object_attribute_rgx, + search_attribute_matching_name, +) + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# DTO +# --------------------------------------------------------------------------- + + +@dataclass +class CrsInfo: + """ + Version-neutral DTO holding all extractable CRS metadata. + + All fields are optional / defaulted so that a ``CrsInfo`` can be returned + even when only partial information could be retrieved (e.g. when + ``workspace`` is ``None`` for a v2.2 compound CRS). + """ + + # ------------------------------------------------------------------ + # Origin offsets (local → project translation) + # ------------------------------------------------------------------ + x_offset: float = 0.0 + """X translation of the local origin in the projected CRS units.""" + + y_offset: float = 0.0 + """Y translation of the local origin in the projected CRS units.""" + + z_offset: float = 0.0 + """Z translation of the local origin in the vertical CRS units.""" + + # ------------------------------------------------------------------ + # Horizontal / projected CRS + # ------------------------------------------------------------------ + projected_epsg_code: Optional[int] = None + """EPSG code of the projected horizontal CRS, if any.""" + + projected_uom: Optional[str] = None + """Unit of measure for XY coordinates (e.g. ``"m"``, ``"ft"``).""" + + projected_axis_order: Optional[str] = None + """Axis order of the projected CRS (e.g. ``"easting northing"``).""" + + projected_wkt: Optional[str] = None + """Well-Known Text representation of the projected CRS, if provided.""" + + projected_unknown: Optional[str] = None + """Free-text CRS descriptor when no authority code / WKT is available.""" + + # ------------------------------------------------------------------ + # Vertical CRS + # ------------------------------------------------------------------ + vertical_epsg_code: Optional[int] = None + """EPSG code of the vertical CRS, if any.""" + + vertical_uom: Optional[str] = None + """Unit of measure for Z coordinates (e.g. ``"m"``, ``"ft"``, ``"s"``).""" + + z_increasing_downward: bool = False + """ + ``True`` when the Z axis increases *downward* (i.e. depth convention). + ``False`` means Z increases *upward* (elevation convention). + """ + + vertical_wkt: Optional[str] = None + """Well-Known Text representation of the vertical CRS, if provided.""" + + vertical_unknown: Optional[str] = None + """Free-text vertical CRS descriptor.""" + + # ------------------------------------------------------------------ + # Rotation / azimuth + # ------------------------------------------------------------------ + areal_rotation_value: float = 0.0 + """ + Rotation angle of the local grid relative to the projected CRS. + Corresponds to ``ArealRotation`` (v2.0.1) or ``Azimuth`` (v2.2). + """ + + areal_rotation_uom: str = "rad" + """Unit of the rotation angle: ``"rad"`` or ``"degr"``.""" + + azimuth_reference: Optional[str] = None + """ + (v2.2 only) Reference for the azimuth, e.g. ``"true north"``, + ``"grid north"``, ``"magnetic north"`` (from ``NorthReferenceKind``). + """ + + # ------------------------------------------------------------------ + # Traceability + # ------------------------------------------------------------------ + source_type: Optional[str] = None + """ + Simple type name of the energyml object this info was extracted from. + Useful for debugging and logging. + """ + + # ------------------------------------------------------------------ + # Convenience helpers + # ------------------------------------------------------------------ + + def areal_rotation_rad(self) -> float: + """Return ``areal_rotation_value`` converted to **radians**.""" + if self.areal_rotation_uom == "degr": + return math.radians(self.areal_rotation_value) + return self.areal_rotation_value + + def as_transform_args(self) -> dict: + """ + Return a kwargs dict ready to be unpacked into + :func:`energyml.utils.data.helper.apply_crs_transform`. + """ + return { + "x_offset": self.x_offset, + "y_offset": self.y_offset, + "z_offset": self.z_offset, + "areal_rotation": self.areal_rotation_value, + "rotation_uom": self.areal_rotation_uom, + "z_is_up": not self.z_increasing_downward, + } + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + + +def _uom_to_str(uom: Any) -> Optional[str]: + """ + Normalise a ``LengthUom`` / ``TimeUom`` enum value (or plain string) to a + plain lowercase string like ``"m"``, ``"ft"``, ``"s"``. + + Handles patterns like: + - ``LengthUom.M`` → ``"m"`` + - ``"LengthUom.ft"`` → ``"ft"`` + - ``"m"`` → ``"m"`` + """ + if uom is None: + return None + s = str(uom) + if "." in s: + s = s.split(".")[-1] + return s.strip() or None + + +def _extract_abstract_projected_crs(abstract_projected_crs: Any) -> dict: + """ + Extract details from an ``AbstractProjectedCrs`` concrete instance. + + Returns a dict with keys: ``epsg_code``, ``wkt``, ``unknown``. + """ + result: dict = {"epsg_code": None, "wkt": None, "unknown": None} + if abstract_projected_crs is None: + return result + + type_name = type(abstract_projected_crs).__name__.lower() + + if "epsg" in type_name: + result["epsg_code"] = getattr(abstract_projected_crs, "epsg_code", None) + elif "wkt" in type_name: + result["wkt"] = getattr(abstract_projected_crs, "well_known_text", None) + elif "unknown" in type_name: + result["unknown"] = getattr(abstract_projected_crs, "unknown", None) + + # Fallback: generic attribute search + if result["epsg_code"] is None: + result["epsg_code"] = get_object_attribute_rgx(abstract_projected_crs, "[Ee]psg[_]?[Cc]ode") + + return result + + +def _extract_projected_crs_details(projected_crs_obj: Any) -> dict: + """ + Extract details from a ``ProjectedCrs`` (v2.2 EML) or from an + ``AbstractProjectedCrs`` inline object (v2.0.1). + + Returns a dict with keys: ``epsg_code``, ``wkt``, ``unknown``, ``uom``, + ``axis_order``. + """ + result: dict = { + "epsg_code": None, + "wkt": None, + "unknown": None, + "uom": None, + "axis_order": None, + } + if projected_crs_obj is None: + return result + + # UOM — may be an XML attribute on ProjectedCrs + result["uom"] = _uom_to_str(get_object_attribute_rgx(projected_crs_obj, "[Uu]om")) + + # Axis order + axis_order_raw = get_object_attribute_rgx(projected_crs_obj, "[Aa]xis[_]?[Oo]rder") + if axis_order_raw is not None: + ao = str(axis_order_raw) + if "." in ao: + ao = ao.split(".")[-1] + result["axis_order"] = ao.replace("_", " ").lower() + + # EPSG from direct attribute (e.g. v2.2 ProjectedEpsgCrs inside ProjectedCrs) + epsg = get_object_attribute_rgx(projected_crs_obj, "[Ee]psg[_]?[Cc]ode") + if epsg is not None: + result["epsg_code"] = epsg + return result + + # Navigate into AbstractProjectedCrs choice (v2.2 encapsulation pattern) + abstract_crs = get_object_attribute_rgx(projected_crs_obj, "[Aa]bstract[_]?[Pp]rojected[_]?[Cc]rs") + if abstract_crs is not None: + details = _extract_abstract_projected_crs(abstract_crs) + result.update({k: v for k, v in details.items() if v is not None}) + + return result + + +def _extract_abstract_vertical_crs(abstract_vertical_crs: Any) -> dict: + """ + Extract details from an ``AbstractVerticalCrs`` concrete instance. + + Returns a dict with keys: ``epsg_code``, ``wkt``, ``unknown``. + """ + result: dict = {"epsg_code": None, "wkt": None, "unknown": None} + if abstract_vertical_crs is None: + return result + + type_name = type(abstract_vertical_crs).__name__.lower() + + if "epsg" in type_name: + result["epsg_code"] = getattr(abstract_vertical_crs, "epsg_code", None) + elif "wkt" in type_name: + result["wkt"] = getattr(abstract_vertical_crs, "well_known_text", None) + elif "unknown" in type_name: + result["unknown"] = getattr(abstract_vertical_crs, "unknown", None) + + if result["epsg_code"] is None: + result["epsg_code"] = get_object_attribute_rgx(abstract_vertical_crs, "[Ee]psg[_]?[Cc]ode") + + return result + + +def _extract_vertical_crs_details(vertical_crs_obj: Any) -> dict: + """ + Extract details from a ``VerticalCrs`` (v2.2 EML) or from an + ``AbstractVerticalCrs`` inline object (v2.0.1). + + Returns a dict with keys: ``epsg_code``, ``wkt``, ``unknown``, ``uom``, + ``z_increasing_downward``. + """ + result: dict = { + "epsg_code": None, + "wkt": None, + "unknown": None, + "uom": None, + "z_increasing_downward": False, + } + if vertical_crs_obj is None: + return result + + # UOM + result["uom"] = _uom_to_str(get_object_attribute_rgx(vertical_crs_obj, "[Uu]om")) + + # Direction (VerticalCrs in v2.2 has a top-level Direction field) + direction = get_object_attribute_rgx(vertical_crs_obj, "[Dd]irection") + if direction is not None: + d = str(direction) + if "." in d: + d = d.split(".")[-1] + result["z_increasing_downward"] = d.lower() == "down" + + # EPSG from direct attribute + epsg = get_object_attribute_rgx(vertical_crs_obj, "[Ee]psg[_]?[Cc]ode") + if epsg is not None: + result["epsg_code"] = epsg + return result + + # Navigate into AbstractVerticalCrs choice + abstract_crs = get_object_attribute_rgx(vertical_crs_obj, "[Aa]bstract[_]?[Vv]ertical[_]?[Cc]rs") + if abstract_crs is not None: + details = _extract_abstract_vertical_crs(abstract_crs) + result.update({k: v for k, v in details.items() if v is not None}) + + return result + + +def _extract_rotation(crs_obj: Any) -> tuple[float, str]: + """ + Extract the areal rotation / azimuth (value, uom) from *any* CRS object. + + Handles both v2.0.1 ``ArealRotation.value/uom`` and v2.2 + ``Azimuth.value/uom`` styles. + + Returns ``(0.0, "rad")`` if no rotation field is found. + """ + # v2.2 style (azimuth.value / azimuth.uom) + azimuth_value = get_object_attribute_rgx(crs_obj, "[Aa]zimuth.value") + if azimuth_value is not None: + azimuth_uom = _uom_to_str(get_object_attribute_rgx(crs_obj, "[Aa]zimuth.uom")) or "rad" + try: + return float(azimuth_value), azimuth_uom + except (ValueError, TypeError): + pass + + # v2.0.1 style (areal_rotation.value / areal_rotation.uom) + rotation_value = get_object_attribute_rgx(crs_obj, "[Aa]real[_]?[Rr]otation.value") + if rotation_value is not None: + rotation_uom = _uom_to_str(get_object_attribute_rgx(crs_obj, "[Aa]real[_]?[Rr]otation.uom")) or "rad" + try: + return float(rotation_value), rotation_uom + except (ValueError, TypeError): + pass + + return 0.0, "rad" + + +# --------------------------------------------------------------------------- +# Branch extractors (one per top-level CRS type) +# --------------------------------------------------------------------------- + + +def _from_abstract_local3dcrs(crs_obj: Any) -> CrsInfo: + """ + Handle ``AbstractLocal3dCrs`` and its concrete subclasses + (``ObjLocalDepth3DCrs``, ``ObjLocalTime3DCrs``) — **RESQML v2.0.1**. + + All data is inline; no workspace lookup needed. + """ + type_name = type(crs_obj).__name__ + + # --- Offsets ----------------------------------------------------------- + x_offset = 0.0 + y_offset = 0.0 + z_offset = 0.0 + try: + _x = get_object_attribute_rgx(crs_obj, "[Xx][Oo]ffset") + _y = get_object_attribute_rgx(crs_obj, "[Yy][Oo]ffset") + _z = get_object_attribute_rgx(crs_obj, "[Zz][Oo]ffset") + x_offset = float(_x) if _x is not None else 0.0 + y_offset = float(_y) if _y is not None else 0.0 + z_offset = float(_z) if _z is not None else 0.0 + except (ValueError, TypeError) as exc: + logger.debug("v2.0.1 offset read error: %s", exc) + + # --- Rotation ---------------------------------------------------------- + areal_rotation_value, areal_rotation_uom = _extract_rotation(crs_obj) + + # --- Z direction ------------------------------------------------------- + z_increasing_downward: bool = False + zid_raw = get_object_attribute_rgx(crs_obj, "[Zz]increasing[_]?[Dd]ownward") + if zid_raw is not None: + if isinstance(zid_raw, bool): + z_increasing_downward = zid_raw + else: + z_increasing_downward = str(zid_raw).lower() in ("true", "1", "yes") + + # --- Projected UOM ----------------------------------------------------- + projected_uom: Optional[str] = _uom_to_str( + get_object_attribute_rgx(crs_obj, "[Pp]rojected[Uu]om") + ) + + # --- Vertical UOM (length or time) ------------------------------------ + vertical_uom: Optional[str] = _uom_to_str( + get_object_attribute_rgx(crs_obj, "[Vv]ertical[Uu]om") + ) + if vertical_uom is None: + vertical_uom = _uom_to_str( + get_object_attribute_rgx(crs_obj, "[Tt]ime[Uu]om") + ) + + # --- Axis order -------------------------------------------------------- + axis_order_raw = get_object_attribute_rgx(crs_obj, "[Pp]rojected[Aa]xis[Oo]rder") + projected_axis_order: Optional[str] = None + if axis_order_raw is not None: + ao = str(axis_order_raw) + if "." in ao: + ao = ao.split(".")[-1] + projected_axis_order = ao.replace("_", " ").lower() + + # --- Projected CRS ----------------------------------------------------- + projected_crs_obj = get_object_attribute_rgx(crs_obj, "[Pp]rojected[Cc]rs") + projected_details = _extract_projected_crs_details(projected_crs_obj) + + # Projected UOM from inline ProjectedCrs takes precedence if present + if projected_details.get("uom"): + projected_uom = projected_details["uom"] + if projected_details.get("axis_order"): + projected_axis_order = projected_details["axis_order"] + + # --- Vertical CRS ------------------------------------------------------ + vertical_crs_obj = get_object_attribute_rgx(crs_obj, "[Vv]ertical[Cc]rs") + vertical_details = _extract_vertical_crs_details(vertical_crs_obj) + + # Direction from VerticalCrs overrides the top-level ZIncreasingDownward + # only when explicitly set. + if vertical_crs_obj is not None and vertical_details.get("z_increasing_downward") is not None: + z_increasing_downward = vertical_details["z_increasing_downward"] + if vertical_details.get("uom"): + vertical_uom = vertical_details["uom"] + + return CrsInfo( + x_offset=x_offset, + y_offset=y_offset, + z_offset=z_offset, + projected_epsg_code=projected_details.get("epsg_code"), + projected_uom=projected_uom, + projected_axis_order=projected_axis_order, + projected_wkt=projected_details.get("wkt"), + projected_unknown=projected_details.get("unknown"), + vertical_epsg_code=vertical_details.get("epsg_code"), + vertical_uom=vertical_uom, + z_increasing_downward=z_increasing_downward, + vertical_wkt=vertical_details.get("wkt"), + vertical_unknown=vertical_details.get("unknown"), + areal_rotation_value=areal_rotation_value, + areal_rotation_uom=areal_rotation_uom, + source_type=type_name, + ) + + +def _from_local_engineering2d_crs(crs_obj: Any) -> CrsInfo: + """ + Handle ``LocalEngineering2dCrs`` — **EML v2.3 / RESQML v2.2**. + + Contains: XY offsets, azimuth, inline ``ProjectedCrs``, + ``HorizontalAxes.ProjectedUom``. + Does **not** contain Z offset or vertical CRS — those live in the + enclosing ``LocalEngineeringCompoundCrs``. + """ + type_name = type(crs_obj).__name__ + + # --- XY offsets -------------------------------------------------------- + x_offset = 0.0 + y_offset = 0.0 + try: + _x = get_object_attribute_rgx(crs_obj, "[Oo]rigin[Pp]rojected[Cc]oordinate1") + _y = get_object_attribute_rgx(crs_obj, "[Oo]rigin[Pp]rojected[Cc]oordinate2") + x_offset = float(_x) if _x is not None else 0.0 + y_offset = float(_y) if _y is not None else 0.0 + except (ValueError, TypeError) as exc: + logger.debug("LocalEngineering2dCrs offset read error: %s", exc) + + # --- Azimuth ----------------------------------------------------------- + areal_rotation_value, areal_rotation_uom = _extract_rotation(crs_obj) + + # --- Azimuth reference ------------------------------------------------- + azimuth_ref_raw = get_object_attribute_rgx(crs_obj, "[Aa]zimuth[Rr]eference") + azimuth_reference: Optional[str] = None + if azimuth_ref_raw is not None: + ar = str(azimuth_ref_raw) + if "." in ar: + ar = ar.split(".")[-1] + azimuth_reference = ar.replace("_", " ").lower() + + # --- Horizontal UOM (HorizontalAxes.ProjectedUom or uom on ProjectedCrs) --- + projected_uom: Optional[str] = _uom_to_str( + get_object_attribute_rgx(crs_obj, "[Hh]orizontal[_]?[Aa]xes.[Pp]rojected[_]?[Uu]om") + ) + + # --- Inline ProjectedCrs ----------------------------------------------- + projected_crs_obj = get_object_attribute_rgx(crs_obj, "[Oo]rigin[Pp]rojected[Cc]rs") + projected_details = _extract_projected_crs_details(projected_crs_obj) + + if projected_details.get("uom") and projected_uom is None: + projected_uom = projected_details["uom"] + + return CrsInfo( + x_offset=x_offset, + y_offset=y_offset, + z_offset=0.0, # Z lives in the compound CRS + projected_epsg_code=projected_details.get("epsg_code"), + projected_uom=projected_uom, + projected_axis_order=projected_details.get("axis_order"), + projected_wkt=projected_details.get("wkt"), + projected_unknown=projected_details.get("unknown"), + areal_rotation_value=areal_rotation_value, + areal_rotation_uom=areal_rotation_uom, + azimuth_reference=azimuth_reference, + source_type=type_name, + ) + + +def _from_vertical_crs(crs_obj: Any) -> CrsInfo: + """ + Handle a standalone ``VerticalCrs`` document object — **EML v2.3 / RESQML v2.2**. + """ + type_name = type(crs_obj).__name__ + details = _extract_vertical_crs_details(crs_obj) + return CrsInfo( + vertical_epsg_code=details.get("epsg_code"), + vertical_uom=details.get("uom"), + z_increasing_downward=details.get("z_increasing_downward", False), + vertical_wkt=details.get("wkt"), + vertical_unknown=details.get("unknown"), + source_type=type_name, + ) + + +def _from_local_engineering_compound_crs( + crs_obj: Any, + workspace: Optional[EnergymlStorageInterface], +) -> CrsInfo: + """ + Handle ``LocalEngineeringCompoundCrs`` — **EML v2.3 / RESQML v2.2**. + + Resolves: + - ``local_engineering2d_crs`` → DOR → ``LocalEngineering2dCrs`` + - ``vertical_crs`` → DOR (inherited from ``AbstractCompoundCrs``) → ``VerticalCrs`` + + When ``workspace`` is ``None``, only inline data (z offset, vertical axis + from the compound itself) can be populated. + """ + type_name = type(crs_obj).__name__ + + # --- Z offset (origin_vertical_coordinate) -------------------------------- + z_offset = 0.0 + try: + _z = get_object_attribute_rgx(crs_obj, "[Oo]rigin[Vv]ertical[Cc]oordinate") + z_offset = float(_z) if _z is not None else 0.0 + except (ValueError, TypeError) as exc: + logger.debug("LocalEngineeringCompoundCrs z-offset read error: %s", exc) + + # --- Vertical axis (inline — gives direction + uom without workspace) -- + vert_axis_direction: Optional[str] = None + vert_axis_uom: Optional[str] = None + vert_axis_uom_raw = get_object_attribute_rgx(crs_obj, "[Vv]ertical[_]?[Aa]xis.[Uu]om") + if vert_axis_uom_raw is not None: + vert_axis_uom = _uom_to_str(vert_axis_uom_raw) + vert_axis_dir_raw = get_object_attribute_rgx(crs_obj, "[Vv]ertical[_]?[Aa]xis.[Dd]irection") + if vert_axis_dir_raw is not None: + d = str(vert_axis_dir_raw) + if "." in d: + d = d.split(".")[-1] + vert_axis_direction = d.lower() + + z_increasing_downward: bool = vert_axis_direction == "down" if vert_axis_direction else False + + # --- Resolve LocalEngineering2dCrs via DOR ---------------------------- + horiz_info: Optional[CrsInfo] = None + horiz_dor = get_object_attribute_rgx(crs_obj, "[Ll]ocal[Ee]ngineering2[dD][Cc]rs") + if horiz_dor is not None and workspace is not None: + horiz_uuid = get_obj_uuid(horiz_dor) + if horiz_uuid: + candidates = workspace.get_object_by_uuid(horiz_uuid) + if candidates: + horiz_info = _from_local_engineering2d_crs(candidates[0]) + if horiz_info is None: + horiz_uri = get_obj_uri(horiz_dor) + if horiz_uri: + horiz_obj = workspace.get_object(horiz_uri) + if horiz_obj is not None: + horiz_info = _from_local_engineering2d_crs(horiz_obj) + elif horiz_dor is not None: + logger.debug( + "LocalEngineeringCompoundCrs: workspace is None — cannot resolve " + "LocalEngineering2dCrs DOR; horizontal info will be partial." + ) + + # --- Resolve VerticalCrs via DOR (inherited AbstractCompoundCrs.vertical_crs) --- + vert_info: Optional[CrsInfo] = None + vert_dor = get_object_attribute_rgx(crs_obj, "[Vv]ertical[Cc]rs") + if vert_dor is not None and workspace is not None: + vert_uuid = get_obj_uuid(vert_dor) + if vert_uuid: + candidates = workspace.get_object_by_uuid(vert_uuid) + if candidates: + vert_info = _from_vertical_crs(candidates[0]) + if vert_info is None: + vert_uri = get_obj_uri(vert_dor) + if vert_uri: + vert_obj = workspace.get_object(vert_uri) + if vert_obj is not None: + vert_info = _from_vertical_crs(vert_obj) + elif vert_dor is not None: + logger.debug( + "LocalEngineeringCompoundCrs: workspace is None — cannot resolve " + "VerticalCrs DOR; vertical info will be partial." + ) + + # --- Merge results ----------------------------------------------------- + return CrsInfo( + # XY offsets and rotation come from the 2D CRS + x_offset=horiz_info.x_offset if horiz_info else 0.0, + y_offset=horiz_info.y_offset if horiz_info else 0.0, + z_offset=z_offset, + projected_epsg_code=horiz_info.projected_epsg_code if horiz_info else None, + projected_uom=horiz_info.projected_uom if horiz_info else None, + projected_axis_order=horiz_info.projected_axis_order if horiz_info else None, + projected_wkt=horiz_info.projected_wkt if horiz_info else None, + projected_unknown=horiz_info.projected_unknown if horiz_info else None, + areal_rotation_value=horiz_info.areal_rotation_value if horiz_info else 0.0, + areal_rotation_uom=horiz_info.areal_rotation_uom if horiz_info else "rad", + azimuth_reference=horiz_info.azimuth_reference if horiz_info else None, + # Vertical info: prefer resolved VerticalCrs object, else inline VerticalAxis + vertical_epsg_code=vert_info.vertical_epsg_code if vert_info else None, + vertical_uom=(vert_info.vertical_uom if vert_info else None) or vert_axis_uom, + z_increasing_downward=( + vert_info.z_increasing_downward if vert_info else z_increasing_downward + ), + vertical_wkt=vert_info.vertical_wkt if vert_info else None, + vertical_unknown=vert_info.vertical_unknown if vert_info else None, + source_type=type_name, + ) + + +# --------------------------------------------------------------------------- +# Public factory +# --------------------------------------------------------------------------- + + +def extract_crs_info( + crs_obj: Any, + workspace: Optional[EnergymlStorageInterface] = None, +) -> CrsInfo: + """ + Extract all available CRS metadata from *any* energyml CRS object into a + version-neutral :class:`CrsInfo` DTO. + + Supported types (matched case-insensitively on the class name): + + **RESQML v2.0.1** + + - ``ObjLocalDepth3DCrs`` / ``LocalDepth3dCrs`` + - ``ObjLocalTime3DCrs`` / ``LocalTime3dCrs`` + - Any subclass of ``AbstractLocal3dCrs`` + + **EML v2.3 / RESQML v2.2** + + - ``LocalEngineeringCompoundCrs`` + - ``LocalEngineering2dCrs`` (also handled standalone) + - ``VerticalCrs`` (also handled standalone) + + Parameters + ---------- + crs_obj: + An energyml CRS data object. May be ``None`` — in that case a default + ``CrsInfo()`` is returned (all zeros / None). + workspace: + Optional storage interface used to resolve + ``DataObjectReference`` links in v2.2 compound CRS objects. + When ``None``, only inline data is extracted (partial result). + + Returns + ------- + CrsInfo + Populated DTO. Never raises — errors are logged at DEBUG / WARNING + level and graceful defaults are returned. + """ + if crs_obj is None: + return CrsInfo() + + type_name_lower = type(crs_obj).__name__.lower() + + # ------------------------------------------------------------------ + # v2.2 / EML v2.3 types + # ------------------------------------------------------------------ + if "localengineeringcompoundcrs" in type_name_lower: + return _from_local_engineering_compound_crs(crs_obj, workspace) + + if "localengineering2dcrs" in type_name_lower or "localengineering2" in type_name_lower: + return _from_local_engineering2d_crs(crs_obj) + + if type_name_lower == "verticalcrs": + return _from_vertical_crs(crs_obj) + + # ------------------------------------------------------------------ + # v2.0.1 types (LocalDepth3dCrs, LocalTime3dCrs, AbstractLocal3dCrs) + # ------------------------------------------------------------------ + if any( + kw in type_name_lower + for kw in ("localdepth3dcrs", "localtime3dcrs", "abstractlocal3dcrs", "local3dcrs") + ): + return _from_abstract_local3dcrs(crs_obj) + + # ------------------------------------------------------------------ + # Heuristic fallback: inspect the object's attributes to guess version + # ------------------------------------------------------------------ + # v2.0.1 pattern: has XOffset / YOffset + if get_object_attribute_rgx(crs_obj, "[Xx][Oo]ffset") is not None: + logger.debug( + "extract_crs_info: unrecognised type '%s' — treating as AbstractLocal3dCrs (v2.0.1 pattern).", + type(crs_obj).__name__, + ) + return _from_abstract_local3dcrs(crs_obj) + + # v2.2 pattern: has OriginProjectedCoordinate1 (LocalEngineering2dCrs) + if get_object_attribute_rgx(crs_obj, "[Oo]rigin[Pp]rojected[Cc]oordinate1") is not None: + logger.debug( + "extract_crs_info: unrecognised type '%s' — treating as LocalEngineering2dCrs (v2.2 pattern).", + type(crs_obj).__name__, + ) + return _from_local_engineering2d_crs(crs_obj) + + # v2.2 pattern: has LocalEngineering2dCrs DOR → compound + if get_object_attribute_rgx(crs_obj, "[Ll]ocal[Ee]ngineering2[dD][Cc]rs") is not None: + logger.debug( + "extract_crs_info: unrecognised type '%s' — treating as LocalEngineeringCompoundCrs (v2.2 pattern).", + type(crs_obj).__name__, + ) + return _from_local_engineering_compound_crs(crs_obj, workspace) + + logger.warning( + "extract_crs_info: unsupported CRS type '%s' — returning default CrsInfo.", + type(crs_obj).__name__, + ) + return CrsInfo(source_type=type(crs_obj).__name__) diff --git a/energyml-utils/src/energyml/utils/data/helper.py b/energyml-utils/src/energyml/utils/data/helper.py index 27d9a29..43ff66f 100644 --- a/energyml-utils/src/energyml/utils/data/helper.py +++ b/energyml-utils/src/energyml/utils/data/helper.py @@ -28,6 +28,7 @@ ) from .datasets_io import get_path_in_external_with_path +from .crs import CrsInfo, extract_crs_info # noqa: F401 (re-exported for convenience) _ARRAY_NAMES_ = [ "BooleanArrayFromDiscretePropertyArray", @@ -85,139 +86,45 @@ def _point_as_array(point: Any) -> List: def is_z_reversed(crs: Optional[Any]) -> bool: """ - Returns True if the Z axe is reverse (ZIncreasingDownward=='True' or VerticalAxis.Direction=='down') + Returns True if the Z axis increases downward + (``ZIncreasingDownward==True`` or ``VerticalAxis.Direction=='down'``). + + Delegates to :func:`extract_crs_info`. + :param crs: a CRS object - :return: By default, False is returned (if 'crs' is None) + :return: By default, ``False`` is returned when *crs* is ``None``. """ - reverse_z_values = False - if crs is not None: - if "VerticalCrs" in type(crs).__name__: - vert_axis = search_attribute_matching_name(crs, "Direction") - if len(vert_axis) > 0: - vert_axis_str = str(vert_axis[0]) - if "." in vert_axis_str: - vert_axis_str = vert_axis_str.split(".")[-1] - - reverse_z_values = vert_axis_str.lower() == "down" - else: - # resqml 201 - zincreasing_downward = search_attribute_matching_name(crs, "ZIncreasingDownward") - if len(zincreasing_downward) > 0: - reverse_z_values = zincreasing_downward[0] - - # resqml >= 22 - vert_axis = search_attribute_matching_name(crs, "VerticalAxis.Direction") - if len(vert_axis) > 0: - vert_axis_str = str(vert_axis[0]) - if "." in vert_axis_str: - vert_axis_str = vert_axis_str.split(".")[-1] - - reverse_z_values = vert_axis_str.lower() == "down" - logging.debug(f"is_z_reversed: {reverse_z_values}") - return reverse_z_values - - -def get_vertical_epsg_code(crs_object: Any): - vertical_epsg_code = None - if crs_object is not None: # LocalDepth3dCRS - vertical_epsg_code = get_object_attribute_rgx(crs_object, "VerticalCrs.EpsgCode") - if vertical_epsg_code is None: # LocalEngineering2DCrs - vertical_epsg_code = get_object_attribute_rgx( - crs_object, "OriginProjectedCrs.AbstractProjectedCrs.EpsgCode" - ) - if vertical_epsg_code is None: - vertical_epsg_code = get_object_attribute_rgx(crs_object, "abstract_vertical_crs.epsg_code") - return vertical_epsg_code + result = extract_crs_info(crs).z_increasing_downward + logging.debug(f"is_z_reversed: {result}") + return result -def get_projected_epsg_code(crs_object: Any, workspace: Optional[EnergymlStorageInterface] = None) -> Optional[str]: - if crs_object is not None: # LocalDepth3dCRS - projected_epsg_code = get_object_attribute_rgx(crs_object, "ProjectedCrs.EpsgCode") - if projected_epsg_code is None: # LocalEngineering2DCrs - projected_epsg_code = get_object_attribute_rgx( - crs_object, "OriginProjectedCrs.AbstractProjectedCrs.EpsgCode" - ) +def get_vertical_epsg_code(crs_object: Any) -> Optional[int]: + """Return the EPSG code of the vertical CRS. Delegates to :func:`extract_crs_info`.""" + return extract_crs_info(crs_object).vertical_epsg_code - if projected_epsg_code is None and workspace is not None: - return get_projected_epsg_code( - workspace.get_object_by_uuid(get_object_attribute_rgx(crs_object, "LocalEngineering2[dD]Crs.Uuid")) - ) - return projected_epsg_code - return None +def get_projected_epsg_code(crs_object: Any, workspace: Optional[EnergymlStorageInterface] = None) -> Optional[int]: + """Return the EPSG code of the projected (horizontal) CRS. Delegates to :func:`extract_crs_info`.""" + return extract_crs_info(crs_object, workspace).projected_epsg_code -def get_projected_uom(crs_object: Any, workspace: Optional[EnergymlStorageInterface] = None): - if crs_object is not None: - projected_epsg_uom = get_object_attribute_rgx(crs_object, "ProjectedUom") - if projected_epsg_uom is None: - projected_epsg_uom = get_object_attribute_rgx(crs_object, "HorizontalAxes.ProjectedUom") - if projected_epsg_uom is None and workspace is not None: - return get_projected_uom( - workspace.get_object_by_uuid(get_object_attribute_rgx(crs_object, "LocalEngineering2[dD]Crs.Uuid")) - ) - return projected_epsg_uom - return None +def get_projected_uom(crs_object: Any, workspace: Optional[EnergymlStorageInterface] = None) -> Optional[str]: + """Return the UOM string for the projected (horizontal) CRS. Delegates to :func:`extract_crs_info`.""" + return extract_crs_info(crs_object, workspace).projected_uom def get_crs_offsets_and_angle( crs_object: Any, workspace: Optional[EnergymlStorageInterface] = None ) -> Tuple[float, float, float, Tuple[float, str]]: - """Return the CRS offsets (X, Y, Z) and the areal rotation angle (value and uom) if they exist in the CRS object.""" - if crs_object is None: - return 0.0, 0.0, 0.0, (0.0, "rad") - - # eml23.LocalEngineering2DCrs - _tmpx = get_object_attribute_rgx(crs_object, "OriginProjectedCoordinate1") - _tmpy = get_object_attribute_rgx(crs_object, "OriginProjectedCoordinate2") - _tmp_azimuth = get_object_attribute_rgx(crs_object, "azimuth.value") - _tmp_azimuth_uom = str(get_object_attribute_rgx(crs_object, "azimuth.uom") or "") - if _tmpx is not None and _tmpy is not None: - try: - return ( - float(_tmpx), - float(_tmpy), - 0.0, - (float(_tmp_azimuth) if _tmp_azimuth is not None else 0.0, _tmp_azimuth_uom), - ) # Z offset is not defined in 2D CRS, it is defined in eml23.LocalEngineeringCompoundCrs - except Exception as e: - logging.info(f"ERR reading crs offset {e}") - - # resqml20.ObjLocalDepth3DCrs - _tmpx = get_object_attribute_rgx(crs_object, "XOffset") - _tmpy = get_object_attribute_rgx(crs_object, "YOffset") - _tmpz = get_object_attribute_rgx(crs_object, "ZOffset") - _tmp_azimuth = get_object_attribute_rgx(crs_object, "ArealRotation.value") - _tmp_azimuth_uom = str(get_object_attribute_rgx(crs_object, "ArealRotation.uom") or "") - if _tmpx is not None and _tmpy is not None: - try: - return ( - float(_tmpx), - float(_tmpy), - float(_tmpz), - (float(_tmp_azimuth) if _tmp_azimuth is not None else 0.0, _tmp_azimuth_uom), - ) - except Exception as e: - logging.info(f"ERR reading crs offset {e}") - - # eml23.LocalEngineeringCompoundCrs - _tmp_z = get_object_attribute_rgx(crs_object, "OriginVerticalCoordinate") - - local_engineering2d_crs_dor = get_object_attribute_rgx(crs_object, "localEngineering2DCrs") - if local_engineering2d_crs_dor is not None and workspace is not None: - local_engineering2d_crs_uri = get_obj_uri(local_engineering2d_crs_dor) - _tmp_x, _tmp_y, _, (azimuth, azimuth_uom) = get_crs_offsets_and_angle( - workspace.get_object(local_engineering2d_crs_uri), workspace - ) - return _tmp_x, _tmp_y, float(_tmp_z) if _tmp_z is not None else 0.0, (azimuth, azimuth_uom) - - if _tmp_z is not None: - try: - return 0.0, 0.0, float(_tmp_z), (0.0, "rad") - except Exception as e: - logging.info(f"ERR reading crs offset {e}") + """ + Return the CRS offsets (X, Y, Z) and the areal rotation angle ``(value, uom)``. - return 0.0, 0.0, 0.0, (0.0, "rad") + Delegates to :func:`extract_crs_info` and unpacks the result back into the + original ``(x, y, z, (angle, uom))`` tuple format for backward compatibility. + """ + info = extract_crs_info(crs_object, workspace) + return info.x_offset, info.y_offset, info.z_offset, (info.areal_rotation_value, info.areal_rotation_uom) def apply_crs_transform( diff --git a/energyml-utils/tests/test_crs_info.py b/energyml-utils/tests/test_crs_info.py new file mode 100644 index 0000000..0c82210 --- /dev/null +++ b/energyml-utils/tests/test_crs_info.py @@ -0,0 +1,595 @@ +# Copyright (c) 2023-2024 Geosiris. +# SPDX-License-Identifier: Apache-2.0 +""" +Integration tests for :mod:`energyml.utils.data.crs`. + +Real energyml objects are loaded from the EPC test fixtures shipped in +``rc/epc/``. No mock dataclasses — the installed ``energyml-resqml2*`` and +``energyml-eml*`` packages provide the actual xsdata-generated classes. + +EPC fixtures +───────────── +* ``testingPackageCpp.epc`` — RESQML v2.0.1 (also contains mixed v2.3 CRS) +* ``testingPackageCpp22.epc`` — RESQML v2.2 / EML v2.3 + +Both files are committed to ``rc/epc/`` and are available in CI once the +parent workspace dev-dependencies are installed. +""" +from __future__ import annotations + +import math +from pathlib import Path +from typing import Any, Optional + +import pytest + +from energyml.utils.data.crs import CrsInfo, _uom_to_str, extract_crs_info +from energyml.utils.data.helper import ( + get_crs_offsets_and_angle, + get_projected_epsg_code, + get_projected_uom, + get_vertical_epsg_code, + is_z_reversed, +) +from energyml.utils.epc import Epc +from energyml.utils.introspection import get_obj_uuid, get_object_attribute_rgx + +# --------------------------------------------------------------------------- +# EPC file paths +# --------------------------------------------------------------------------- + +_RC = Path(__file__).parent.parent / "rc" / "epc" +EPC20_PATH = _RC / "testingPackageCpp.epc" +EPC22_PATH = _RC / "testingPackageCpp22.epc" + + +# --------------------------------------------------------------------------- +# Session-scoped fixtures (EPC loaded once per test session) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def epc20() -> Epc: + if not EPC20_PATH.exists(): + pytest.skip(f"EPC fixture not found: {EPC20_PATH}") + return Epc.read_file(str(EPC20_PATH)) + + +@pytest.fixture(scope="session") +def epc22() -> Epc: + if not EPC22_PATH.exists(): + pytest.skip(f"EPC fixture not found: {EPC22_PATH}") + return Epc.read_file(str(EPC22_PATH)) + + +# --------------------------------------------------------------------------- +# Shared helper — walk representation → CRS DOR → resolved object +# --------------------------------------------------------------------------- + + +def _resolve_crs_from_grid(grid_obj: Any, epc: Epc) -> Optional[Any]: + """Resolve the CRS object linked from a Grid2DRepresentation.""" + # v2.0.1: local_crs sits on the geometry patch + dor = get_object_attribute_rgx(grid_obj, "[Ll]ocal[_]?[Cc]rs") + if dor is None: + dor = get_object_attribute_rgx( + grid_obj, + "[Gg]rid2[Dd][Pp]atch.[Gg]eometry.[Ll]ocal[_]?[Cc]rs", + ) + if dor is None: + return None + uuid = get_obj_uuid(dor) + candidates = epc.get_object_by_uuid(uuid) if uuid else [] + return candidates[0] if candidates else None + + +# =========================================================================== +# DTO and pure-function tests (no EPC required) +# =========================================================================== + + +class TestCrsInfoDto: + def test_defaults(self): + info = CrsInfo() + assert info.x_offset == 0.0 + assert info.y_offset == 0.0 + assert info.z_offset == 0.0 + assert info.projected_epsg_code is None + assert info.projected_uom is None + assert info.vertical_epsg_code is None + assert info.vertical_uom is None + assert info.z_increasing_downward is False + assert info.areal_rotation_value == 0.0 + assert info.areal_rotation_uom == "rad" + assert info.azimuth_reference is None + assert info.source_type is None + + def test_areal_rotation_rad_already_radians(self): + info = CrsInfo(areal_rotation_value=1.5708, areal_rotation_uom="rad") + assert info.areal_rotation_rad() == pytest.approx(1.5708) + + def test_areal_rotation_rad_degrees(self): + info = CrsInfo(areal_rotation_value=90.0, areal_rotation_uom="degr") + assert info.areal_rotation_rad() == pytest.approx(math.pi / 2) + + def test_areal_rotation_rad_zero(self): + assert CrsInfo(areal_rotation_value=0.0, areal_rotation_uom="degr").areal_rotation_rad() == 0.0 + + def test_as_transform_args(self): + info = CrsInfo( + x_offset=100.0, + y_offset=200.0, + z_offset=-50.0, + areal_rotation_value=0.5, + areal_rotation_uom="rad", + z_increasing_downward=True, + ) + kwargs = info.as_transform_args() + assert kwargs["x_offset"] == 100.0 + assert kwargs["y_offset"] == 200.0 + assert kwargs["z_offset"] == -50.0 + assert kwargs["areal_rotation"] == 0.5 + assert kwargs["rotation_uom"] == "rad" + assert kwargs["z_is_up"] is False # z_increasing_downward=True → z_is_up=False + + def test_none_returns_default(self): + info = extract_crs_info(None) + assert info.x_offset == 0.0 + assert info.z_increasing_downward is False + assert info.source_type is None + + +class TestUomToStr: + def test_plain_string(self): + assert _uom_to_str("m") == "m" + + def test_enum_like_with_dot(self): + assert _uom_to_str("LengthUom.ft") == "ft" + + def test_none_returns_none(self): + assert _uom_to_str(None) is None + + def test_empty_after_split_returns_none(self): + assert _uom_to_str("") is None + + +# =========================================================================== +# RESQML v2.0.1 — testingPackageCpp.epc +# =========================================================================== + + +class TestV201LocalTime3DCrs: + """ + LocalTime3DCrs uuid=dbd637d5-4528-4145-908b-5f7136824f6d + xoffset=1.0 yoffset=0.1 zoffset=15.0 projected_uom=M z_down=False + """ + + UUID = "dbd637d5-4528-4145-908b-5f7136824f6d" + + @pytest.fixture(scope="class") + def info(self, epc20): + obj = epc20.get_object_by_uuid(self.UUID)[0] + return extract_crs_info(obj) + + def test_source_type(self, info): + assert "LocalTime3DCrs" in info.source_type + + def test_offsets(self, info): + assert info.x_offset == pytest.approx(1.0) + assert info.y_offset == pytest.approx(0.1) + assert info.z_offset == pytest.approx(15.0) + + def test_projected_uom(self, info): + # xsdata v2.0.1 enum → _uom_to_str keeps the enum member name casing + assert info.projected_uom is not None + assert info.projected_uom.lower() == "m" + + def test_vertical_uom(self, info): + assert info.vertical_uom is not None + assert info.vertical_uom.lower() == "m" + + def test_z_increasing_downward(self, info): + assert info.z_increasing_downward is False + + def test_no_epsg(self, info): + assert info.projected_epsg_code is None + assert info.vertical_epsg_code is None + + def test_rotation_zero(self, info): + assert info.areal_rotation_value == pytest.approx(0.0) + + +class TestV201LocalDepth3DCrs: + """ + LocalDepth3DCrs uuid=0ae56ef3-fc79-405b-8deb-6942e0f2e77c + projected_epsg=23031 projected_uom=M z_down=False offsets all zero + """ + + UUID = "0ae56ef3-fc79-405b-8deb-6942e0f2e77c" + + @pytest.fixture(scope="class") + def info(self, epc20): + obj = epc20.get_object_by_uuid(self.UUID)[0] + return extract_crs_info(obj) + + def test_source_type(self, info): + assert "LocalDepth3DCrs" in info.source_type + + def test_projected_epsg(self, info): + assert info.projected_epsg_code == 23031 + + def test_projected_uom(self, info): + assert info.projected_uom is not None + assert info.projected_uom.lower() == "m" + + def test_z_increasing_downward(self, info): + # The linked VerticalCrs has direction=up (or not set) in this fixture + assert info.z_increasing_downward is False + + def test_offsets_zero(self, info): + assert info.x_offset == pytest.approx(0.0) + assert info.y_offset == pytest.approx(0.0) + assert info.z_offset == pytest.approx(0.0) + + +class TestV201LocalEngineeringCompoundCrs: + """ + LocalEngineeringCompoundCrs uuid=95330cec-164c-4165-9fb9-c56477ae7f8a + (EML v2.3 object inside the v2.0.1 EPC) + projected_epsg=23031 (only when workspace provided) + z_down=True azref=grid north + """ + + UUID = "95330cec-164c-4165-9fb9-c56477ae7f8a" + + def test_z_down_inline_no_workspace(self, epc20): + """VerticalAxis direction is readable without workspace.""" + obj = epc20.get_object_by_uuid(self.UUID)[0] + info = extract_crs_info(obj, workspace=None) + assert info.z_increasing_downward is True + + def test_projected_epsg_requires_workspace(self, epc20): + """EPSG is on linked LocalEngineering2DCrs — only available via workspace.""" + obj = epc20.get_object_by_uuid(self.UUID)[0] + info_no_ws = extract_crs_info(obj, workspace=None) + assert info_no_ws.projected_epsg_code is None + + info_ws = extract_crs_info(obj, workspace=epc20) + assert info_ws.projected_epsg_code == 23031 + + def test_full_resolution_with_workspace(self, epc20): + obj = epc20.get_object_by_uuid(self.UUID)[0] + info = extract_crs_info(obj, workspace=epc20) + assert info.projected_epsg_code == 23031 + assert info.projected_uom is not None + assert info.projected_uom.lower() == "m" + assert info.vertical_uom is not None + assert info.vertical_uom.lower() == "m" + assert info.z_increasing_downward is True + assert info.azimuth_reference == "grid north" + + +class TestV201LocalEngineering2DCrs: + """ + LocalEngineering2DCrs uuid=811f8e68-c0e4-5f90-b9cf-03f7e3d53ca4 + (EML v2.3 object inside the v2.0.1 EPC) + projected_epsg=23031 projected_uom=M azref=grid north offsets zero + """ + + UUID = "811f8e68-c0e4-5f90-b9cf-03f7e3d53ca4" + + @pytest.fixture(scope="class") + def info(self, epc20): + obj = epc20.get_object_by_uuid(self.UUID)[0] + return extract_crs_info(obj) + + def test_projected_epsg(self, info): + assert info.projected_epsg_code == 23031 + + def test_projected_uom(self, info): + assert info.projected_uom is not None + assert info.projected_uom.lower() == "m" + + def test_no_vertical_uom(self, info): + # 2D CRS carries no Z information + assert info.vertical_uom is None + + def test_z_increasing_downward(self, info): + assert info.z_increasing_downward is False + + def test_azimuth_reference(self, info): + assert info.azimuth_reference == "grid north" + + def test_offsets_zero(self, info): + assert info.x_offset == pytest.approx(0.0) + assert info.y_offset == pytest.approx(0.0) + + +class TestV201VerticalCrs: + """ + VerticalCrs uuid=1f6cf904-336c-5202-a13d-7c9b142cd406 + (EML v2.3 object inside the v2.0.1 EPC) + vertical_uom=M z_down=True no projected info + """ + + UUID = "1f6cf904-336c-5202-a13d-7c9b142cd406" + + @pytest.fixture(scope="class") + def info(self, epc20): + obj = epc20.get_object_by_uuid(self.UUID)[0] + return extract_crs_info(obj) + + def test_vertical_uom(self, info): + assert info.vertical_uom is not None + assert info.vertical_uom.lower() == "m" + + def test_z_increasing_downward(self, info): + assert info.z_increasing_downward is True + + def test_no_projected_info(self, info): + assert info.projected_epsg_code is None + assert info.projected_uom is None + + +class TestV201Grid2DCrsResolution: + """ + Grid2DRepresentation → local_crs DOR → resolved CRS → extract_crs_info. + """ + + def test_grid_030a82f6_resolves_to_local_time_crs(self, epc20): + grid = epc20.get_object_by_uuid("030a82f6-10a7-4ecf-af03-54749e098624")[0] + crs = _resolve_crs_from_grid(grid, epc20) + assert crs is not None + assert crs.uuid == "dbd637d5-4528-4145-908b-5f7136824f6d" + assert "LocalTime3DCrs" in type(crs).__name__ + info = extract_crs_info(crs, workspace=epc20) + assert info.x_offset == pytest.approx(1.0) + assert info.y_offset == pytest.approx(0.1) + assert info.z_offset == pytest.approx(15.0) + + def test_grid_aa5b90f1_resolves_to_local_depth_crs(self, epc20): + grid = epc20.get_object_by_uuid("aa5b90f1-2eab-4fa6-8720-69dd4fd51a4d")[0] + crs = _resolve_crs_from_grid(grid, epc20) + assert crs is not None + assert crs.uuid == "0ae56ef3-fc79-405b-8deb-6942e0f2e77c" + info = extract_crs_info(crs, workspace=epc20) + assert info.projected_epsg_code == 23031 + + def test_grid_4e56b0e4_resolves_to_same_depth_crs(self, epc20): + grid = epc20.get_object_by_uuid("4e56b0e4-2cd1-4efa-97dd-95f72bcf9f80")[0] + crs = _resolve_crs_from_grid(grid, epc20) + assert crs is not None + assert crs.uuid == "0ae56ef3-fc79-405b-8deb-6942e0f2e77c" + + +# =========================================================================== +# RESQML v2.2 / EML v2.3 — testingPackageCpp22.epc +# =========================================================================== + + +class TestV22LocalEngineering2DCrsNoEpsg: + """ + LocalEngineering2DCrs uuid=997796f5-da9d-5175-9fb7-e592957b73fb + x=1.0 y=0.1 projected_uom=M no EPSG azref=grid north + """ + + UUID = "997796f5-da9d-5175-9fb7-e592957b73fb" + + @pytest.fixture(scope="class") + def info(self, epc22): + obj = epc22.get_object_by_uuid(self.UUID)[0] + return extract_crs_info(obj) + + def test_offsets(self, info): + assert info.x_offset == pytest.approx(1.0) + assert info.y_offset == pytest.approx(0.1) + assert info.z_offset == pytest.approx(0.0) + + def test_no_epsg(self, info): + assert info.projected_epsg_code is None + + def test_projected_uom(self, info): + assert info.projected_uom is not None + assert info.projected_uom.lower() == "m" + + def test_azimuth_reference(self, info): + assert info.azimuth_reference == "grid north" + + def test_z_increasing_downward(self, info): + assert info.z_increasing_downward is False + + +class TestV22LocalEngineering2DCrsWithEpsg: + """ + LocalEngineering2DCrs uuid=671ffdeb-f25c-513a-a4a2-1774d3ac20c6 + projected_epsg=23031 projected_uom=M azref=grid north offsets zero + """ + + UUID = "671ffdeb-f25c-513a-a4a2-1774d3ac20c6" + + @pytest.fixture(scope="class") + def info(self, epc22): + obj = epc22.get_object_by_uuid(self.UUID)[0] + return extract_crs_info(obj) + + def test_projected_epsg(self, info): + assert info.projected_epsg_code == 23031 + + def test_projected_uom(self, info): + assert info.projected_uom is not None + assert info.projected_uom.lower() == "m" + + def test_azimuth_reference(self, info): + assert info.azimuth_reference == "grid north" + + def test_offsets_zero(self, info): + assert info.x_offset == pytest.approx(0.0) + assert info.y_offset == pytest.approx(0.0) + + +class TestV22CompoundCrsWithOffsets: + """ + LocalEngineeringCompoundCrs uuid=f0e9f421-b902-4392-87d8-6495c02f2fbe + Links to LocalEngineering2DCrs (997796f5) with x=1.0, y=0.1. + z=15.0 z_down=True no projected EPSG. + Note: the inline VerticalAxis uses a time UOM (S), the resolved + VerticalCrs uses depth UOM (M) — demonstrates with/without workspace. + """ + + UUID = "f0e9f421-b902-4392-87d8-6495c02f2fbe" + + def test_inline_z_offset_without_workspace(self, epc22): + obj = epc22.get_object_by_uuid(self.UUID)[0] + info = extract_crs_info(obj, workspace=None) + assert info.z_offset == pytest.approx(15.0) + + def test_inline_z_direction_without_workspace(self, epc22): + obj = epc22.get_object_by_uuid(self.UUID)[0] + info = extract_crs_info(obj, workspace=None) + assert info.z_increasing_downward is True + + def test_no_horizontal_info_without_workspace(self, epc22): + obj = epc22.get_object_by_uuid(self.UUID)[0] + info = extract_crs_info(obj, workspace=None) + assert info.projected_epsg_code is None + assert info.x_offset == pytest.approx(0.0) + assert info.y_offset == pytest.approx(0.0) + + def test_full_resolution_with_workspace(self, epc22): + obj = epc22.get_object_by_uuid(self.UUID)[0] + info = extract_crs_info(obj, workspace=epc22) + # Horizontal from linked LocalEngineering2DCrs (997796f5) + assert info.x_offset == pytest.approx(1.0) + assert info.y_offset == pytest.approx(0.1) + assert info.z_offset == pytest.approx(15.0) + assert info.projected_uom is not None + assert info.projected_uom.lower() == "m" + assert info.projected_epsg_code is None + assert info.z_increasing_downward is True + assert info.azimuth_reference == "grid north" + + def test_vertical_uom_resolved_from_vertical_crs(self, epc22): + """With workspace the vertical UOM comes from the linked VerticalCrs (M), not the inline time axis (S).""" + obj = epc22.get_object_by_uuid(self.UUID)[0] + info = extract_crs_info(obj, workspace=epc22) + assert info.vertical_uom is not None + assert info.vertical_uom.lower() == "m" + + +class TestV22CompoundCrsWithEpsg: + """ + LocalEngineeringCompoundCrs uuid=6a18c177-93be-41ac-9084-f84bbb31f46d + projected_epsg=23031 z_down=True all offsets zero vertical_uom=M + """ + + UUID = "6a18c177-93be-41ac-9084-f84bbb31f46d" + + @pytest.fixture(scope="class") + def info(self, epc22): + obj = epc22.get_object_by_uuid(self.UUID)[0] + return extract_crs_info(obj, workspace=epc22) + + def test_projected_epsg(self, info): + assert info.projected_epsg_code == 23031 + + def test_projected_uom(self, info): + assert info.projected_uom is not None + assert info.projected_uom.lower() == "m" + + def test_vertical_uom(self, info): + assert info.vertical_uom is not None + assert info.vertical_uom.lower() == "m" + + def test_z_increasing_downward(self, info): + assert info.z_increasing_downward is True + + def test_offsets_zero(self, info): + assert info.x_offset == pytest.approx(0.0) + assert info.y_offset == pytest.approx(0.0) + assert info.z_offset == pytest.approx(0.0) + + def test_azimuth_reference(self, info): + assert info.azimuth_reference == "grid north" + + +class TestV22VerticalCrs: + """ + Two standalone VerticalCrs objects in the v2.2 EPC. + Both: vertical_uom=M z_down=True + """ + + @pytest.mark.parametrize("uuid", [ + "65cd199f-156b-5112-ad3e-b4f54a2aa77b", + "355174db-6226-57ae-a5a6-92f33825fed4", + ]) + def test_vertical_uom_and_direction(self, uuid, epc22): + obj = epc22.get_object_by_uuid(uuid)[0] + info = extract_crs_info(obj) + assert info.vertical_uom is not None + assert info.vertical_uom.lower() == "m" + assert info.z_increasing_downward is True + assert info.projected_epsg_code is None + assert info.projected_uom is None + + +# =========================================================================== +# Legacy delegate functions (helper.py forwards to extract_crs_info) +# =========================================================================== + + +class TestDelegateFunctions: + """ + Verify the five legacy helpers in ``helper.py`` still work correctly now + that they delegate to ``extract_crs_info``. + + Uses LocalDepth3DCrs (0ae56ef3) and LocalTime3DCrs (dbd637d5) from epc20. + """ + + def test_is_z_reversed_depth_crs_false(self, epc20): + crs = epc20.get_object_by_uuid("0ae56ef3-fc79-405b-8deb-6942e0f2e77c")[0] + assert is_z_reversed(crs) is False + + def test_is_z_reversed_compound_crs_true(self, epc20): + # CompoundCrs 95330cec has z_increasing_downward=True + crs = epc20.get_object_by_uuid("95330cec-164c-4165-9fb9-c56477ae7f8a")[0] + assert is_z_reversed(crs) is True + + def test_is_z_reversed_none(self): + assert is_z_reversed(None) is False + + def test_get_projected_epsg_code(self, epc20): + crs = epc20.get_object_by_uuid("0ae56ef3-fc79-405b-8deb-6942e0f2e77c")[0] + assert get_projected_epsg_code(crs) == 23031 + + def test_get_projected_epsg_code_no_epsg(self, epc20): + crs = epc20.get_object_by_uuid("dbd637d5-4528-4145-908b-5f7136824f6d")[0] + assert get_projected_epsg_code(crs) is None + + def test_get_projected_uom(self, epc20): + crs = epc20.get_object_by_uuid("0ae56ef3-fc79-405b-8deb-6942e0f2e77c")[0] + uom = get_projected_uom(crs) + assert uom is not None + assert uom.lower() == "m" + + def test_get_vertical_epsg_code_none(self, epc20): + # Neither CRS in this EPC has a vertical EPSG code + crs = epc20.get_object_by_uuid("0ae56ef3-fc79-405b-8deb-6942e0f2e77c")[0] + assert get_vertical_epsg_code(crs) is None + + def test_get_crs_offsets_and_angle_local_time(self, epc20): + crs = epc20.get_object_by_uuid("dbd637d5-4528-4145-908b-5f7136824f6d")[0] + x, y, z, (angle, uom) = get_crs_offsets_and_angle(crs) + assert x == pytest.approx(1.0) + assert y == pytest.approx(0.1) + assert z == pytest.approx(15.0) + assert angle == pytest.approx(0.0) + + def test_get_crs_offsets_and_angle_none(self): + x, y, z, (angle, uom) = get_crs_offsets_and_angle(None) + assert x == 0.0 + assert y == 0.0 + assert z == 0.0 + assert angle == 0.0 + assert uom == "rad" + + diff --git a/energyml-utils/tests/test_mesh_numpy.py b/energyml-utils/tests/test_mesh_numpy.py new file mode 100644 index 0000000..24faa0f --- /dev/null +++ b/energyml-utils/tests/test_mesh_numpy.py @@ -0,0 +1,530 @@ +"""Tests for the zero-copy numpy mesh reader (mesh_numpy.py). + +Covers: +* NumpyMesh dataclass field shapes/dtypes. +* crs_displacement_np — vectorised CRS offset + Z-flip. +* _ViewWorkspace — routing of read_array to read_array_view. +* HDF5ArrayHandler.read_array_view — best-effort zero-copy. +* End-to-end read_numpy_mesh_object for all supported representation types, + using the EPC/HDF5 fixtures already present in ``rc/epc/``. +* numpy_mesh_to_pyvista round-trip (requires pyvista; skipped otherwise). + +Run from the workspace root: + poetry run pytest tests/test_mesh_numpy.py -v +""" +import os +import tempfile +from typing import Optional +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from energyml.utils.data.mesh_numpy import ( + NumpyMesh, + NumpyPointSetMesh, + NumpyPolylineMesh, + NumpySurfaceMesh, + NumpyVolumeMesh, + _ViewWorkspace, + _build_vtk_faces_from_triangles, + _build_vtk_faces_from_quads, + _build_vtk_lines_from_segments, + _ensure_float64_points, + crs_displacement_np, + read_numpy_mesh_object, + numpy_mesh_to_pyvista, +) + +# --------------------------------------------------------------------------- +# Paths helpers +# --------------------------------------------------------------------------- + +_WORKSPACE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_EPC_DIR = os.path.join(_WORKSPACE_ROOT, "rc", "epc") +_EPC22 = os.path.join(_EPC_DIR, "testingPackageCpp22.epc") +_EPC20 = os.path.join(_EPC_DIR, "testingPackageCpp.epc") + + +def _epc22_available() -> bool: + return os.path.isfile(_EPC22) + + +def _epc20_available() -> bool: + return os.path.isfile(_EPC20) + + +# --------------------------------------------------------------------------- +# 1. Dataclass shape / dtype invariants +# --------------------------------------------------------------------------- + +class TestNumpyMeshDataclasses: + def test_point_set_defaults(self): + m = NumpyPointSetMesh() + assert m.points.shape == (0, 3) + assert m.points.dtype == np.float64 + + def test_surface_mesh_defaults(self): + m = NumpySurfaceMesh() + assert m.faces.dtype == np.int64 + assert m.faces.ndim == 1 + + def test_polyline_mesh_defaults(self): + m = NumpyPolylineMesh() + assert m.lines.dtype == np.int64 + + def test_volume_mesh_defaults(self): + m = NumpyVolumeMesh() + assert m.cells.dtype == np.int64 + assert m.cell_types.dtype == np.uint8 + + def test_surface_mesh_populated(self): + pts = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float64) + faces = np.array([3, 0, 1, 2], dtype=np.int64) + m = NumpySurfaceMesh(points=pts, faces=faces) + assert m.points.shape == (3, 3) + assert m.faces[0] == 3 # VTK triangle count prefix + + +# --------------------------------------------------------------------------- +# 2. _ensure_float64_points +# --------------------------------------------------------------------------- + +class TestEnsureFloat64Points: + def test_flat_list(self): + a = _ensure_float64_points([1, 2, 3, 4, 5, 6]) + assert a.shape == (2, 3) + assert a.dtype == np.float64 + + def test_nested_list(self): + a = _ensure_float64_points([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) + assert a.shape == (2, 3) + assert a.dtype == np.float64 + + def test_already_correct_array(self): + arr = np.zeros((5, 3), dtype=np.float64) + result = _ensure_float64_points(arr) + # Should return a view (same data, no copy) + assert result.shape == (5, 3) + assert result.dtype == np.float64 + + def test_wrong_col_count_raises(self): + with pytest.raises(ValueError): + _ensure_float64_points(np.zeros((4, 5))) # 5 cols is never valid + + def test_2d_points_padded_with_zeros(self): + a = _ensure_float64_points(np.array([[1.0, 2.0], [3.0, 4.0]])) + assert a.shape == (2, 3) + np.testing.assert_array_equal(a[:, 2], [0.0, 0.0]) + + +# --------------------------------------------------------------------------- +# 3. VTK connectivity builders +# --------------------------------------------------------------------------- + +class TestVTKBuilders: + def test_faces_from_triangles(self): + tri = np.array([[0, 1, 2], [1, 2, 3]], dtype=np.int64) + faces = _build_vtk_faces_from_triangles(tri) + expected = np.array([3, 0, 1, 2, 3, 1, 2, 3], dtype=np.int64) + np.testing.assert_array_equal(faces, expected) + + def test_faces_from_quads(self): + quad = np.array([[0, 1, 2, 3]], dtype=np.int64) + faces = _build_vtk_faces_from_quads(quad) + expected = np.array([4, 0, 1, 2, 3], dtype=np.int64) + np.testing.assert_array_equal(faces, expected) + + def test_lines_from_segments_3pts(self): + lines = _build_vtk_lines_from_segments(3) + # [2, 0, 1, 2, 1, 2] + expected = np.array([2, 0, 1, 2, 1, 2], dtype=np.int64) + np.testing.assert_array_equal(lines, expected) + + def test_lines_from_segments_1pt(self): + lines = _build_vtk_lines_from_segments(1) + assert len(lines) == 0 + + def test_lines_from_segments_0pts(self): + lines = _build_vtk_lines_from_segments(0) + assert len(lines) == 0 + + +# --------------------------------------------------------------------------- +# 4. crs_displacement_np +# --------------------------------------------------------------------------- + +class TestCrsDisplacementNp: + def _make_crs(self, x=0.0, y=0.0, z=0.0, z_reversed=False): + """Build a minimal mock CRS object.""" + from unittest.mock import patch + + crs = MagicMock() + + # Patch the helper functions used by crs_displacement_np + return crs, [x, y, z], z_reversed + + def test_offset_only(self): + """Test pure XYZ offset without Z reversal.""" + pts = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float64) + crs = MagicMock() + + # Patch helper functions at the module level + import energyml.utils.data.mesh_numpy as mn + orig_offset = mn.get_crs_origin_offset + orig_zrev = mn.is_z_reversed + try: + mn.get_crs_origin_offset = lambda crs_obj: [10.0, 20.0, 30.0] + mn.is_z_reversed = lambda crs_obj: False + result = crs_displacement_np(pts.copy(), crs) + finally: + mn.get_crs_origin_offset = orig_offset + mn.is_z_reversed = orig_zrev + + np.testing.assert_allclose(result, [[11.0, 22.0, 33.0], [14.0, 25.0, 36.0]]) + + def test_z_reversal(self): + """Test Z-axis inversion.""" + pts = np.array([[0.0, 0.0, 100.0]], dtype=np.float64) + crs = MagicMock() + + import energyml.utils.data.mesh_numpy as mn + orig_offset = mn.get_crs_origin_offset + orig_zrev = mn.is_z_reversed + try: + mn.get_crs_origin_offset = lambda crs_obj: [0.0, 0.0, 0.0] + mn.is_z_reversed = lambda crs_obj: True + result = crs_displacement_np(pts.copy(), crs) + finally: + mn.get_crs_origin_offset = orig_offset + mn.is_z_reversed = orig_zrev + + assert result[0, 2] == pytest.approx(-100.0) + + def test_inplace_false_no_mutation(self): + """inplace=False must not mutate the original array.""" + pts = np.array([[1.0, 2.0, 3.0]], dtype=np.float64) + original = pts.copy() + crs = MagicMock() + + import energyml.utils.data.mesh_numpy as mn + orig_offset = mn.get_crs_origin_offset + orig_zrev = mn.is_z_reversed + try: + mn.get_crs_origin_offset = lambda crs_obj: [1.0, 1.0, 1.0] + mn.is_z_reversed = lambda crs_obj: False + result = crs_displacement_np(pts, crs, inplace=False) + finally: + mn.get_crs_origin_offset = orig_offset + mn.is_z_reversed = orig_zrev + + np.testing.assert_array_equal(pts, original, err_msg="Source array was mutated despite inplace=False") + np.testing.assert_allclose(result, [[2.0, 3.0, 4.0]]) + + def test_none_crs_returns_unchanged(self): + pts = np.array([[1.0, 2.0, 3.0]], dtype=np.float64) + result = crs_displacement_np(pts, None) + np.testing.assert_array_equal(result, pts) + + +# --------------------------------------------------------------------------- +# 5. _ViewWorkspace +# --------------------------------------------------------------------------- + +class TestViewWorkspace: + def test_read_array_redirects_to_view(self): + """read_array on _ViewWorkspace should call read_array_view on the wrapped ws.""" + ws = MagicMock() + ws.read_array_view.return_value = np.array([1, 2, 3]) + ws.some_other_attr = "hello" + + view_ws = _ViewWorkspace(ws) + # read_array calls must be redirected + result = view_ws.read_array("proxy", "path/in/h5", None, None, None) + ws.read_array_view.assert_called_once_with("proxy", "path/in/h5", None, None, None) + np.testing.assert_array_equal(result, [1, 2, 3]) + + def test_other_attrs_forwarded(self): + ws = MagicMock() + ws.some_method.return_value = 42 + view_ws = _ViewWorkspace(ws) + assert view_ws.some_method() == 42 + + +# --------------------------------------------------------------------------- +# 6. HDF5ArrayHandler.read_array_view +# --------------------------------------------------------------------------- + +class TestHDF5ArrayHandlerReadArrayView: + """Verify zero-copy semantics of read_array_view vs read_array.""" + + @pytest.fixture + def h5_with_contiguous_dataset(self, tmp_path): + """Create a small HDF5 file with a contiguous (non-chunked) dataset.""" + h5py = pytest.importorskip("h5py") + fpath = str(tmp_path / "test_view.h5") + arr = np.arange(12, dtype=np.float64).reshape(4, 3) + with h5py.File(fpath, "w") as f: + # contiguous layout — default when no chunks specified + f.create_dataset("/pts", data=arr, chunks=None) + return fpath, arr + + def test_read_array_view_returns_correct_data(self, h5_with_contiguous_dataset): + from energyml.utils.data.datasets_io import HDF5ArrayHandler + fpath, expected = h5_with_contiguous_dataset + handler = HDF5ArrayHandler() + result = handler.read_array_view(fpath, "/pts") + handler.file_cache.close_all() + assert result is not None + np.testing.assert_allclose(result, expected) + + def test_read_array_view_is_ndarray(self, h5_with_contiguous_dataset): + from energyml.utils.data.datasets_io import HDF5ArrayHandler + fpath, _ = h5_with_contiguous_dataset + handler = HDF5ArrayHandler() + result = handler.read_array_view(fpath, "/pts") + handler.file_cache.close_all() + assert isinstance(result, np.ndarray) + + def test_subselection_correct(self, h5_with_contiguous_dataset): + from energyml.utils.data.datasets_io import HDF5ArrayHandler + fpath, expected = h5_with_contiguous_dataset + handler = HDF5ArrayHandler() + # Select rows 1 and 2 (start=1, count=2 along axis-0) + result = handler.read_array_view(fpath, "/pts", start_indices=[1, 0], counts=[2, 3]) + handler.file_cache.close_all() + np.testing.assert_allclose(result, expected[1:3]) + + def test_storage_interface_default_fallback(self): + """EnergymlStorageInterface.read_array_view must call read_array by default.""" + from energyml.utils.storage_interface import EnergymlStorageInterface + + class _Concrete(EnergymlStorageInterface): + """Minimal concrete subclass that does NOT override read_array_view.""" + def get_object(self, identifier): return None + def get_object_by_uuid(self, uuid): return [] + def put_object(self, obj, dataspace=None): return None + def delete_object(self, identifier): return False + def read_array(self, proxy, path, start=None, counts=None, uri=None): + return np.array([99.0]) + def write_array(self, *a, **kw): return False + def get_array_metadata(self, *a, **kw): return None + def list_objects(self, *a, **kw): return [] + def get_obj_rels(self, obj): return [] + def close(self): pass + + ws = _Concrete() + result = ws.read_array_view("p", "path") + np.testing.assert_array_equal(result, [99.0]) + + +# --------------------------------------------------------------------------- +# 7. End-to-end representation readers (require EPC fixtures) +# --------------------------------------------------------------------------- + +@pytest.mark.skipif(not _epc22_available(), reason="testingPackageCpp22.epc not found in rc/epc/") +class TestReadNumpyMeshObjectEPC22: + """Integration tests against testingPackageCpp22.epc.""" + + @pytest.fixture(scope="class") + def epc22(self): + from energyml.utils.epc import Epc + return Epc.read_file(_EPC22, read_rels_from_files=False, recompute_rels=False) + + # --- TriangulatedSetRepresentation --- + def test_triangulated_set_returns_surface_mesh(self, epc22): + obj = epc22.get_object_by_uuid("6e678338-3b53-49b6-8801-faee493e0c42") + if not obj: + pytest.skip("TriangulatedSet UUID not found in fixture EPC") + meshes = read_numpy_mesh_object(obj[0], workspace=epc22) + assert meshes, "Expected at least one mesh" + for m in meshes: + assert isinstance(m, NumpySurfaceMesh) + + def test_triangulated_set_points_shape_dtype(self, epc22): + obj = epc22.get_object_by_uuid("6e678338-3b53-49b6-8801-faee493e0c42") + if not obj: + pytest.skip("TriangulatedSet UUID not found in fixture EPC") + meshes = read_numpy_mesh_object(obj[0], workspace=epc22) + for m in meshes: + assert m.points.ndim == 2 + assert m.points.shape[1] == 3 + assert m.points.dtype == np.float64 + + def test_triangulated_set_faces_dtype_and_format(self, epc22): + obj = epc22.get_object_by_uuid("6e678338-3b53-49b6-8801-faee493e0c42") + if not obj: + pytest.skip("TriangulatedSet UUID not found in fixture EPC") + meshes = read_numpy_mesh_object(obj[0], workspace=epc22) + for m in meshes: + assert isinstance(m, NumpySurfaceMesh) + assert m.faces.dtype == np.int64 + assert m.faces.ndim == 1 + # First element must be 3 (triangle) + assert m.faces[0] == 3, "VTK face array must start with face vertex count (3 for triangles)" + + def test_triangulated_set_no_lists(self, epc22): + """Guarantee no Python lists survive into the mesh dataclass.""" + obj = epc22.get_object_by_uuid("6e678338-3b53-49b6-8801-faee493e0c42") + if not obj: + pytest.skip("TriangulatedSet UUID not found in fixture EPC") + meshes = read_numpy_mesh_object(obj[0], workspace=epc22) + for m in meshes: + assert isinstance(m.points, np.ndarray), "points must be ndarray" + assert isinstance(m.faces, np.ndarray), "faces must be ndarray" + + # --- PointSetRepresentation --- + def test_pointset_returns_pointset_mesh(self, epc22): + obj = epc22.get_object_by_uuid("fbc5466c-94cd-46ab-8b48-2ae2162b372f") + if not obj: + pytest.skip("PointSet UUID not found in fixture EPC") + meshes = read_numpy_mesh_object(obj[0], workspace=epc22) + assert meshes + for m in meshes: + assert isinstance(m, NumpyPointSetMesh) + assert m.points.ndim == 2 + assert m.points.shape[1] == 3 + assert m.points.dtype == np.float64 + + # --- PolylineRepresentation --- + def test_polyline_returns_polyline_mesh(self, epc22): + obj = epc22.get_object_by_uuid("a54b8399-d3ba-4d4b-b215-8d4f8f537e66") + if not obj: + pytest.skip("Polyline UUID not found in fixture EPC") + meshes = read_numpy_mesh_object(obj[0], workspace=epc22) + assert meshes + for m in meshes: + assert isinstance(m, NumpyPolylineMesh) + assert m.points.dtype == np.float64 + assert m.lines.dtype == np.int64 + + # --- WellboreFrameRepresentation --- + def test_wellbore_frame_returns_polyline(self, epc22): + obj = epc22.get_object_by_uuid("d873e243-d893-41ab-9a3e-d20b851c099f") + if not obj: + pytest.skip("WellboreFrame UUID not found in fixture EPC") + meshes = read_numpy_mesh_object(obj[0], workspace=epc22) + assert meshes + for m in meshes: + assert isinstance(m, NumpyPolylineMesh) + assert m.points.ndim == 2 + assert m.points.shape[1] == 3 + + def test_wellbore_frame_lines_vtk_format(self, epc22): + obj = epc22.get_object_by_uuid("d873e243-d893-41ab-9a3e-d20b851c099f") + if not obj: + pytest.skip("WellboreFrame UUID not found in fixture EPC") + meshes = read_numpy_mesh_object(obj[0], workspace=epc22) + for m in meshes: + assert isinstance(m, NumpyPolylineMesh) + if len(m.lines) > 0: + # First element is count (number of points in first line segment) + assert m.lines[0] == 2, "VTK segment should start with count=2" + + # --- Grid2dRepresentation --- + def test_grid2d_returns_surface_mesh(self, epc22): + # Try to find a Grid2dRepresentation in the EPC + all_objs = epc22.list_objects() + grid2d_uuids = [ + r.uuid for r in all_objs + if "Grid2d" in (r.object_type or "") + ] + if not grid2d_uuids: + pytest.skip("No Grid2dRepresentation found in testingPackageCpp22.epc") + obj = epc22.get_object_by_uuid(grid2d_uuids[0]) + if not obj: + pytest.skip("Grid2d object not found") + meshes = read_numpy_mesh_object(obj[0], workspace=epc22) + for m in meshes: + assert isinstance(m, NumpySurfaceMesh) + assert m.points.dtype == np.float64 + if len(m.faces) > 0: + assert m.faces[0] == 4, "Grid2d quads: first VTK face entry must be 4" + + # --- RepresentationSet --- + def test_representation_set_returns_mixed_mesh_list(self, epc22): + obj = epc22.get_object_by_uuid("6b992199-5b47-4624-a62c-b70857133cda") + if not obj: + pytest.skip("RepresentationSet UUID not found in fixture EPC") + meshes = read_numpy_mesh_object(obj[0], workspace=epc22) + assert isinstance(meshes, list) + for m in meshes: + assert isinstance(m, NumpyMesh) + + # --- Stubs raise NotSupportedError --- + def test_ijk_grid_raises_not_supported(self, epc22): + from energyml.utils.exception import NotSupportedError + from energyml.utils.data.mesh_numpy import read_numpy_ijk_grid_representation + with pytest.raises(NotSupportedError): + read_numpy_ijk_grid_representation(MagicMock(), epc22) + + def test_unstructured_grid_raises_not_supported(self, epc22): + from energyml.utils.exception import NotSupportedError + from energyml.utils.data.mesh_numpy import read_numpy_unstructured_grid_representation + with pytest.raises(NotSupportedError): + read_numpy_unstructured_grid_representation(MagicMock(), epc22) + + +# --------------------------------------------------------------------------- +# 8. numpy_mesh_to_pyvista round-trip +# --------------------------------------------------------------------------- + +try: + import pyvista as _pyvista + _PYVISTA_AVAILABLE = True +except ImportError: + _PYVISTA_AVAILABLE = False + + +@pytest.mark.skipif(not _PYVISTA_AVAILABLE, reason="pyvista not installed") +class TestNumpyMeshToPyvista: + def test_surface_mesh(self): + pts = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float64) + faces = np.array([3, 0, 1, 2], dtype=np.int64) + m = NumpySurfaceMesh(points=pts, faces=faces) + pv_mesh = numpy_mesh_to_pyvista(m) + import pyvista + assert isinstance(pv_mesh, pyvista.PolyData) + assert pv_mesh.n_points == 3 + assert pv_mesh.n_cells == 1 + + def test_polyline_mesh(self): + pts = np.array([[0, 0, 0], [1, 0, 0], [2, 0, 0]], dtype=np.float64) + lines = _build_vtk_lines_from_segments(3) + m = NumpyPolylineMesh(points=pts, lines=lines) + pv_mesh = numpy_mesh_to_pyvista(m) + import pyvista + assert isinstance(pv_mesh, pyvista.PolyData) + assert pv_mesh.n_points == 3 + + def test_point_set_mesh(self): + pts = np.random.rand(10, 3).astype(np.float64) + m = NumpyPointSetMesh(points=pts) + pv_mesh = numpy_mesh_to_pyvista(m) + import pyvista + assert isinstance(pv_mesh, pyvista.PolyData) + assert pv_mesh.n_points == 10 + + def test_pyvista_missing_raises_import_error(self, monkeypatch): + """When pyvista is not importable, numpy_mesh_to_pyvista raises ImportError.""" + import builtins + real_import = builtins.__import__ + + def _mock_import(name, *args, **kwargs): + if name == "pyvista": + raise ImportError("mocked missing pyvista") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _mock_import) + m = NumpyPointSetMesh() + with pytest.raises(ImportError, match="pyvista"): + numpy_mesh_to_pyvista(m) + + def test_to_pyvista_method(self): + """NumpyMesh.to_pyvista() convenience method.""" + pts = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float64) + faces = np.array([3, 0, 1, 2], dtype=np.int64) + m = NumpySurfaceMesh(points=pts, faces=faces) + pv_mesh = m.to_pyvista() + import pyvista + assert isinstance(pv_mesh, pyvista.PolyData) From 8edadb0c28598eb84b3fab78c306270ea8356e15 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Thu, 5 Mar 2026 03:46:50 +0100 Subject: [PATCH 66/70] bugfix for crs --- .../example/attic/crs_info_from_epc.py | 136 +++++-- .../example/attic/dump_crs_objects.py | 95 +++++ energyml-utils/example/attic/main_test_3D.py | 43 +-- energyml-utils/rc/epc/testingPackageCpp.epc | Bin 290467 -> 295770 bytes .../src/energyml/utils/data/__init__.py | 2 +- energyml-utils/src/energyml/utils/data/crs.py | 346 +++++++++++++++--- .../src/energyml/utils/data/helper.py | 236 ++++++++---- .../src/energyml/utils/data/mesh.py | 118 +++--- .../src/energyml/utils/data/mesh_numpy.py | 85 ++++- .../src/energyml/utils/introspection.py | 37 +- energyml-utils/tests/test_crs_info.py | 222 ++++++++++- 11 files changed, 1063 insertions(+), 257 deletions(-) create mode 100644 energyml-utils/example/attic/dump_crs_objects.py diff --git a/energyml-utils/example/attic/crs_info_from_epc.py b/energyml-utils/example/attic/crs_info_from_epc.py index b107715..e1ddf82 100644 --- a/energyml-utils/example/attic/crs_info_from_epc.py +++ b/energyml-utils/example/attic/crs_info_from_epc.py @@ -19,7 +19,7 @@ import logging import sys from pathlib import Path -from typing import Any, Optional +from typing import Any, List, Optional # Run $env:PYTHONPATH="src" if it fails to be executed from the project root. @@ -76,15 +76,23 @@ def _resolve_crs_from_grid(grid_obj: Any, epc: Epc) -> Optional[Any]: """ Walk from a representation object to its CRS document. - Tries the common path ``local_crs`` (or nested in ``grid2d_patch.geometry`` - for v2.0.1 ``Grid2DRepresentation``). Returns the resolved CRS object or - ``None``. + The CRS DOR is always present in ``LocalCrs`` — the difference between + RESQML versions is the depth of the path: + + * **v2.2** : ``Geometry.LocalCrs`` (``PointGeometry`` sits directly on the object) + * **v2.0.1**: ``Grid2dPatch.Geometry.LocalCrs`` (geometry is inside a patch sub-object) + + ``get_object_attribute_rgx`` resolves dot-delimited paths at exactly the + depth specified, so we try the shallower v2.2 path first, then fall back + to the deeper v2.0.1 path. + + Returns the resolved CRS object or ``None``. """ - # Direct attribute (v2.0.1 and many v2.2 envelopes) - dor = get_object_attribute_rgx(grid_obj, "[Ll]ocal[_]?[Cc]rs") + # v2.2: Geometry.LocalCrs (PointGeometry directly on the object) + dor = get_object_attribute_rgx(grid_obj, "[Gg]eometry.[Ll]ocal[_]?[Cc]rs") if dor is None: - # Nested path used by Grid2DRepresentation v2.0.1 + # v2.0.1: Grid2dPatch.Geometry.LocalCrs (geometry wrapped in a patch) dor = get_object_attribute_rgx( grid_obj, "[Gg]rid2[Dd][Pp]atch.[Gg]eometry.[Ll]ocal[_]?[Cc]rs", @@ -101,6 +109,31 @@ def _resolve_crs_from_grid(grid_obj: Any, epc: Epc) -> Optional[Any]: return candidates[0] if candidates else None +def resolve_crs_from_triangulated_set(triangulated_obj: Any, epc: Epc) -> List[Optional[Any]]: + """ + Walk from a TriangulatedSetRepresentation to its CRS document. + + Each patch of a TriangulatedSetRepresentation may reference a CRS via its + ``local_crs`` attribute. This function tries to resolve the first patch's CRS. + """ + dor = get_object_attribute_rgx(triangulated_obj, "triangle_patch.\d+.geometry.local_crs") + # print(f" Found DOR for TriangulatedSetRepresentation patch CRS: {dor}") + if dor is None: + return [] + + if isinstance(dor, list): + candidates = [] + for d in dor: + uuid = get_obj_uuid(d) + if uuid: + obj_candidates = epc.get_object_by_uuid(uuid) + print(f" Found DOR for TriangulatedSetRepresentation patch CRS: {d} → candidates: {len(obj_candidates)}") + candidates.append(obj_candidates[0] if obj_candidates else None) + return candidates + + return None + + # =========================================================================== # RESQML v2.0.1 — testingPackageCpp.epc # =========================================================================== @@ -124,7 +157,9 @@ def _resolve_crs_from_grid(grid_obj: Any, epc: Epc) -> Optional[Any]: check("z_offset", 15.0, info.z_offset, approx=True) check("projected_uom (raw from xsdata enum)", "M", info.projected_uom) check("vertical_uom (raw from xsdata enum)", "M", info.vertical_uom) -check("z_increasing_downward", False, info.z_increasing_downward) +# ZIncreasingDownward=true in the file; VerticalUnknownCrs sub-object carries +# no direction field, so the sentinel correctly preserves the top-level value. +check("z_increasing_downward", True, info.z_increasing_downward) check("areal_rotation_value", 0.0, info.areal_rotation_value, approx=True) check("projected_epsg_code", None, info.projected_epsg_code) check("vertical_epsg_code", None, info.vertical_epsg_code) @@ -141,9 +176,9 @@ def _resolve_crs_from_grid(grid_obj: Any, epc: Epc) -> Optional[Any]: check("projected_epsg_code", 23031, info.projected_epsg_code) check("projected_uom", "M", info.projected_uom) check("vertical_uom", "M", info.vertical_uom) -# This particular depth CRS has z_increasing_downward=False in the file -# (the VerticalCrs it references has no direction set or direction=up) -check("z_increasing_downward", False, info.z_increasing_downward) +# ZIncreasingDownward=true in the raw file; the linked VerticalUnknownCrs +# carries no direction field, so the sentinel correctly preserves the value. +check("z_increasing_downward", True, info.z_increasing_downward) check("x_offset", 0.0, info.x_offset, approx=True) check("y_offset", 0.0, info.y_offset, approx=True) check("z_offset", 0.0, info.z_offset, approx=True) @@ -215,7 +250,8 @@ def _resolve_crs_from_grid(grid_obj: Any, epc: Epc) -> Optional[Any]: info = extract_crs_info(resolved_crs, workspace=epc20) check(" projected_epsg_code", 23031, info.projected_epsg_code) check(" projected_uom", "M", info.projected_uom) - check(" z_increasing_downward", False, info.z_increasing_downward) + # Same LocalDepth3DCrs — ZIncreasingDownward=true in the raw file. + check(" z_increasing_downward", True, info.z_increasing_downward) # Grid 4e56b0e4 → also LocalDepth3DCrs (same uuid) grid_depth2 = epc20.get_object_by_uuid("4e56b0e4-2cd1-4efa-97dd-95f72bcf9f80")[0] @@ -223,6 +259,8 @@ def _resolve_crs_from_grid(grid_obj: Any, epc: Epc) -> Optional[Any]: check("Grid 4e56b0e4 resolved CRS uuid", "0ae56ef3-fc79-405b-8deb-6942e0f2e77c", getattr(resolved_crs, "uuid", None)) + + # =========================================================================== # RESQML v2.2 / EML v2.3 — testingPackageCpp22.epc # =========================================================================== @@ -321,21 +359,19 @@ def _resolve_crs_from_grid(grid_obj: Any, epc: Epc) -> Optional[Any]: check("z_increasing_downward", True, info.z_increasing_downward) # ── Grid2D v2.2 — CRS note ──────────────────────────────────────────────── -section("v2.2 · Grid2DRepresentation — CRS resolution note") +section("v2.2 · Grid2DRepresentation — CRS resolution") print(""" - In RESQML v2.2, Grid2DRepresentation does not embed a local_crs DOR - in its geometry patch the same way v2.0.1 does. Instead the CRS is - referenced via the containing LocalEngineeringCompoundCrs or through - the representation's own schema-level CRS association. - - The canonical way to obtain CRS info from a v2.2 representation is to: - 1. Retrieve all LocalEngineeringCompoundCrs objects from the EPC. - 2. Match the one that logically covers your representation (by - consulting interpretation / framework associations). - or use the helper ``get_crs_obj(repr_obj, workspace=epc)`` which walks - the object hierarchy. - - Direct example — iterate all compound CRS objects and extract info: + In RESQML v2.2, Grid2DRepresentation DOES embed a LocalCrs DOR, but at + a shallower path than v2.0.1: + + v2.2 : Geometry.LocalCrs (PointGeometry sits directly on the object) + v2.0.1: Grid2dPatch.Geometry.LocalCrs (geometry is wrapped in a patch sub-object) + + Both paths are resolved by trying the shallower v2.2 path first with + ``get_object_attribute_rgx``, then falling back to the deeper v2.0.1 path. + No indirect lookup through framework associations is needed. + + All LocalEngineeringCompoundCrs objects in this EPC: """) for obj in epc22.energyml_objects: @@ -346,6 +382,51 @@ def _resolve_crs_from_grid(grid_obj: Any, epc: Epc) -> Optional[Any]: print(f" vertical_uom={info.vertical_uom} z_down={info.z_increasing_downward}") print(f" offsets: x={info.x_offset} y={info.y_offset} z={info.z_offset}") +# ── Grid2DRepresentation v2.2 → CRS via Geometry.LocalCrs ───────────────── + +section("v2.2 · Grid2DRepresentation (uuid 4e56b0e4) → CRS via Geometry.LocalCrs") + +grid22 = epc22.get_object_by_uuid("4e56b0e4-2cd1-4efa-97dd-95f72bcf9f80") +if grid22: + grid22 = grid22[0] + resolved_crs22 = _resolve_crs_from_grid(grid22, epc22) + check("resolved CRS type", "LocalEngineeringCompoundCrs", + type(resolved_crs22).__name__ if resolved_crs22 else None) + check("resolved CRS uuid", "6a18c177-93be-41ac-9084-f84bbb31f46d", + getattr(resolved_crs22, "uuid", None)) + if resolved_crs22: + info = extract_crs_info(resolved_crs22, workspace=epc22) + check(" projected_epsg_code", 23031, info.projected_epsg_code) + check(" projected_uom", "M", info.projected_uom) + check(" vertical_uom", "M", info.vertical_uom) + check(" z_increasing_downward", True, info.z_increasing_downward) + check(" x_offset", 0.0, info.x_offset, approx=True) + check(" y_offset", 0.0, info.y_offset, approx=True) + check(" z_offset", 0.0, info.z_offset, approx=True) +else: + print(" [SKIP] Grid 4e56b0e4 not found in testingPackageCpp22.epc") + + +# TriangulatedSetRepresentation 1a4112fa → LocalEngineeringCompoundCrs (6a18c177) +triangulated_set = epc22.get_object_by_uuid("1a4112fa-c4ef-4c8d-aed0-47d9273bebc5")[0] +resolved_crs_list = resolve_crs_from_triangulated_set(triangulated_set, epc22) +check("TriangulatedSetRepresentation resolved CRS uuid", 5, + len(resolved_crs_list)) + +for i, resolved_crs in enumerate(resolved_crs_list): + check(f"{i}) patch {i} resolved CRS type", "LocalEngineeringCompoundCrs", + type(resolved_crs).__name__ if resolved_crs else None) + if resolved_crs: + info = extract_crs_info(resolved_crs, workspace=epc22) + check(" projected_epsg_code (resolved)", 23031, info.projected_epsg_code) + check(" projected_uom", "M", info.projected_uom) + check(" vertical_uom", "M", info.vertical_uom) + check(" z_increasing_downward", True, info.z_increasing_downward) + check(" x_offset", 0.0, info.x_offset, approx=True) + check(" y_offset", 0.0, info.y_offset, approx=True) + check(" z_offset", 0.0, info.z_offset, approx=True) + check(" azimuth_reference", "grid north", info.azimuth_reference) + # =========================================================================== # Convenience helpers (delegates in helper.py) # =========================================================================== @@ -361,7 +442,8 @@ def _resolve_crs_from_grid(grid_obj: Any, epc: Epc) -> Optional[Any]: ) depth_crs = epc20.get_object_by_uuid("0ae56ef3-fc79-405b-8deb-6942e0f2e77c")[0] -check("is_z_reversed(LocalDepth3DCrs)", False, is_z_reversed(depth_crs)) +# ZIncreasingDownward=true in the raw file → is_z_reversed returns True. +check("is_z_reversed(LocalDepth3DCrs)", True, is_z_reversed(depth_crs)) check("get_projected_epsg_code", 23031, get_projected_epsg_code(depth_crs)) check("get_projected_uom", "M", get_projected_uom(depth_crs)) diff --git a/energyml-utils/example/attic/dump_crs_objects.py b/energyml-utils/example/attic/dump_crs_objects.py new file mode 100644 index 0000000..7d956a6 --- /dev/null +++ b/energyml-utils/example/attic/dump_crs_objects.py @@ -0,0 +1,95 @@ +""" +Dump the raw JSON for every CRS (and Grid2D) object referenced by +``crs_info_from_epc.py``, so you can cross-check the expected values +in the integration script against what is actually stored in the EPC files. + +Run from the workspace root:: + + poetry run python example/attic/dump_crs_objects.py +""" +from __future__ import annotations + +import json +import logging +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +from energyml.utils.epc import Epc +from energyml.utils.serialization import serialize_json + +logging.basicConfig(level=logging.ERROR) + +_ROOT = Path(__file__).parent.parent.parent +EPC20_PATH = str(_ROOT / "rc" / "epc" / "testingPackageCpp.epc") +EPC22_PATH = str(_ROOT / "rc" / "epc" / "testingPackageCpp22.epc") + +# --------------------------------------------------------------------------- +# Objects to dump +# (epc_key, uuid, label) +# --------------------------------------------------------------------------- +OBJECTS_EPC20 = [ + ("dbd637d5-4528-4145-908b-5f7136824f6d", "LocalTime3DCrs"), + ("0ae56ef3-fc79-405b-8deb-6942e0f2e77c", "LocalDepth3DCrs"), + ("95330cec-164c-4165-9fb9-c56477ae7f8a", "LocalEngineeringCompoundCrs (v2.0.1 EPC)"), + ("811f8e68-c0e4-5f90-b9cf-03f7e3d53ca4", "LocalEngineering2DCrs (v2.0.1 EPC)"), + ("1f6cf904-336c-5202-a13d-7c9b142cd406", "VerticalCrs (v2.0.1 EPC)"), + ("030a82f6-10a7-4ecf-af03-54749e098624", "Grid2DRepresentation → LocalTime3DCrs"), + ("aa5b90f1-2eab-4fa6-8720-69dd4fd51a4d", "Grid2DRepresentation → LocalDepth3DCrs"), + ("4e56b0e4-2cd1-4efa-97dd-95f72bcf9f80", "Grid2DRepresentation (v2.0.1)"), +] + +OBJECTS_EPC22 = [ + ("997796f5-da9d-5175-9fb7-e592957b73fb", "LocalEngineering2DCrs (no EPSG)"), + ("671ffdeb-f25c-513a-a4a2-1774d3ac20c6", "LocalEngineering2DCrs (EPSG 23031)"), + ("f0e9f421-b902-4392-87d8-6495c02f2fbe", "LocalEngineeringCompoundCrs (no EPSG)"), + ("6a18c177-93be-41ac-9084-f84bbb31f46d", "LocalEngineeringCompoundCrs (EPSG 23031)"), + ("65cd199f-156b-5112-ad3e-b4f54a2aa77b", "VerticalCrs-A — Direction=down → z_down=True"), + ("355174db-6226-57ae-a5a6-92f33825fed4", "VerticalCrs-B — Direction=down → z_down=True"), + ("4e56b0e4-2cd1-4efa-97dd-95f72bcf9f80", "Grid2DRepresentation (v2.2)"), + ("1a4112fa-c4ef-4c8d-aed0-47d9273bebc5", "TriangulatedSetRepresentation (v2.2)"), +] + +# --------------------------------------------------------------------------- + +def _sep(title: str) -> None: + print(f"\n{'═' * 70}") + print(f" {title}") + print(f"{'═' * 70}") + + +def _dump(epc: Epc, uuid: str, label: str) -> None: + print(f"\n── {label} [{uuid}]") + candidates = epc.get_object_by_uuid(uuid) + if not candidates: + print(" *** NOT FOUND ***") + return + obj = candidates[0] + print(f" type : {type(obj).__module__}.{type(obj).__name__}") + try: + raw = json.loads(serialize_json(obj)) + # Pretty-print, indented 4 spaces relative to the bullet + text = json.dumps(raw, indent=2, ensure_ascii=False) + for line in text.splitlines(): + print(f" {line}") + except Exception as exc: + print(f" *** serialization error: {exc} ***") + + +def main() -> None: + _sep(f"EPC 2.0.1 — {EPC20_PATH}") + epc20 = Epc.read_file(EPC20_PATH) + print(f" Loaded {len(epc20.energyml_objects)} objects.") + for uuid, label in OBJECTS_EPC20: + _dump(epc20, uuid, label) + + _sep(f"EPC 2.2 — {EPC22_PATH}") + epc22 = Epc.read_file(EPC22_PATH) + print(f" Loaded {len(epc22.energyml_objects)} objects.") + for uuid, label in OBJECTS_EPC22: + _dump(epc22, uuid, label) + + +if __name__ == "__main__": + main() diff --git a/energyml-utils/example/attic/main_test_3D.py b/energyml-utils/example/attic/main_test_3D.py index aafc979..e6a90cb 100644 --- a/energyml-utils/example/attic/main_test_3D.py +++ b/energyml-utils/example/attic/main_test_3D.py @@ -44,12 +44,12 @@ def export_all_representation(epc_path: str, output_dir: str, regex_type_filter: mesh_list=mesh_list, out=f, ) - export_stl_path = path.with_suffix(".stl") - with export_stl_path.open("wb") as stl_f: - export_stl( - mesh_list=mesh_list, - out=stl_f, - ) + # export_stl_path = path.with_suffix(".stl") + # with export_stl_path.open("wb") as stl_f: + # export_stl( + # mesh_list=mesh_list, + # out=stl_f, + # ) export_vtk_path = path.with_suffix(".vtk") with export_vtk_path.open("wb") as vtk_f: export_vtk( @@ -103,18 +103,18 @@ def export_all_representation_in_memory(epc_path: str, output_dir: str, regex_ty mesh_list=mesh_list, out=f, ) - export_stl_path = path.with_suffix(".stl") - with export_stl_path.open("wb") as stl_f: - export_stl( - mesh_list=mesh_list, - out=stl_f, - ) - export_vtk_path = path.with_suffix(".vtk") - with export_vtk_path.open("wb") as vtk_f: - export_vtk( - mesh_list=mesh_list, - out=vtk_f, - ) + # export_stl_path = path.with_suffix(".stl") + # with export_stl_path.open("wb") as stl_f: + # export_stl( + # mesh_list=mesh_list, + # out=stl_f, + # ) + # export_vtk_path = path.with_suffix(".vtk") + # with export_vtk_path.open("wb") as vtk_f: + # export_vtk( + # mesh_list=mesh_list, + # out=vtk_f, + # ) logging.info(f" ✓ Exported to {path.name}") except NotSupportedError: @@ -135,11 +135,12 @@ def export_all_representation_in_memory(epc_path: str, output_dir: str, regex_ty import logging logging.basicConfig(level=logging.DEBUG) - epc_file = "rc/epc/testingPackageCpp.epc" + epc_file = "rc/epc/testingPackageCpp22.epc" + # epc_file = "rc/epc/testingPackageCpp.epc" # epc_file = "rc/epc/output-val.epc" # epc_file = "rc/epc/Volve_Horizons_and_Faults_Depth_originEQN.epc" output_directory = Path("exported_meshes") / Path(epc_file).name.replace(".epc", "_3D_export") # export_all_representation(epc_file, output_directory) # export_all_representation(epc_file, output_directory, regex_type_filter="Wellbore") - export_all_representation(epc_file, str(output_directory), regex_type_filter="") - # export_all_representation_in_memory(epc_file, str(output_directory), regex_type_filter="") + # export_all_representation(epc_file, str(output_directory), regex_type_filter="") + export_all_representation_in_memory(epc_file, str(output_directory), regex_type_filter="") diff --git a/energyml-utils/rc/epc/testingPackageCpp.epc b/energyml-utils/rc/epc/testingPackageCpp.epc index ef585ac266554c81bcc85094caec772353744f37..0987e9501b6b5b54e57490ee60340aa936005051 100644 GIT binary patch delta 105558 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 IS)jxJ0S6B>mjD0& delta 98861 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 diff --git a/energyml-utils/src/energyml/utils/data/__init__.py b/energyml-utils/src/energyml/utils/data/__init__.py index 8226463..e2611b6 100644 --- a/energyml-utils/src/energyml/utils/data/__init__.py +++ b/energyml-utils/src/energyml/utils/data/__init__.py @@ -6,4 +6,4 @@ Contains functions to help the read of specific entities like Grid2DRepresentation, TriangulatedSetRepresentation etc. It also contains functions to export data into OFF/OBJ format. """ -from .crs import CrsInfo, extract_crs_info # noqa: F401 +from .crs import CrsInfo, extract_crs_info, apply_from_crs_info, apply_axis_order_swap # noqa: F401 diff --git a/energyml-utils/src/energyml/utils/data/crs.py b/energyml-utils/src/energyml/utils/data/crs.py index b94266c..a79650a 100644 --- a/energyml-utils/src/energyml/utils/data/crs.py +++ b/energyml-utils/src/energyml/utils/data/crs.py @@ -26,11 +26,14 @@ from dataclasses import dataclass, field from typing import Any, Optional +import numpy as np + from energyml.utils.storage_interface import EnergymlStorageInterface from energyml.utils.introspection import ( get_obj_uri, get_obj_uuid, get_object_attribute, + get_object_attribute_no_verif, get_object_attribute_rgx, search_attribute_matching_name, ) @@ -145,6 +148,11 @@ def as_transform_args(self) -> dict: """ Return a kwargs dict ready to be unpacked into :func:`energyml.utils.data.helper.apply_crs_transform`. + + ``z_is_up=True`` tells ``apply_crs_transform`` to negate Z (converting + from RESQML's depth-positive / z-down convention to the z-up convention + used by most 3-D viewers). This negation is required when the CRS stores + depth as positive Z (``z_increasing_downward=True``). """ return { "x_offset": self.x_offset, @@ -152,7 +160,7 @@ def as_transform_args(self) -> dict: "z_offset": self.z_offset, "areal_rotation": self.areal_rotation_value, "rotation_uom": self.areal_rotation_uom, - "z_is_up": not self.z_increasing_downward, + "z_is_up": self.z_increasing_downward, } @@ -161,6 +169,32 @@ def as_transform_args(self) -> dict: # --------------------------------------------------------------------------- +def _resolve_dor( + obj: Any, + workspace: Optional[EnergymlStorageInterface], +) -> Any: + """ + If *obj* looks like a ``DataObjectReference`` (DOR), resolve it to the + actual object via *workspace* and return it. Otherwise return *obj* as-is. + + Detection heuristic: the class name contains ``"reference"`` or ``"dor"`` + **and** the object has a ``uuid``/``uid`` attribute (i.e. it is a pointer, + not a value type). + """ + + if obj is None or workspace is None: + return obj + type_lower = type(obj).__name__.lower() + if "reference" not in type_lower and "dor" not in type_lower: + return obj # already a concrete object + uri = get_obj_uri(obj) + if uri: + resolved = workspace.get_object(uri) + if resolved is not None: + return resolved + return obj + + def _uom_to_str(uom: Any) -> Optional[str]: """ Normalise a ``LengthUom`` / ``TimeUom`` enum value (or plain string) to a @@ -223,25 +257,25 @@ def _extract_projected_crs_details(projected_crs_obj: Any) -> dict: if projected_crs_obj is None: return result - # UOM — may be an XML attribute on ProjectedCrs - result["uom"] = _uom_to_str(get_object_attribute_rgx(projected_crs_obj, "[Uu]om")) + # UOM — may be an XML attribute on ProjectedCrs (v2.2 only; absent on v2.0.1 abstract subtypes) + result["uom"] = _uom_to_str(getattr(projected_crs_obj, "uom", None)) - # Axis order - axis_order_raw = get_object_attribute_rgx(projected_crs_obj, "[Aa]xis[_]?[Oo]rder") + # Axis order (v2.2 only) + axis_order_raw = getattr(projected_crs_obj, "axis_order", None) if axis_order_raw is not None: ao = str(axis_order_raw) if "." in ao: ao = ao.split(".")[-1] result["axis_order"] = ao.replace("_", " ").lower() - # EPSG from direct attribute (e.g. v2.2 ProjectedEpsgCrs inside ProjectedCrs) - epsg = get_object_attribute_rgx(projected_crs_obj, "[Ee]psg[_]?[Cc]ode") + # EPSG from direct attribute + epsg = getattr(projected_crs_obj, "epsg_code", None) if epsg is not None: result["epsg_code"] = epsg return result # Navigate into AbstractProjectedCrs choice (v2.2 encapsulation pattern) - abstract_crs = get_object_attribute_rgx(projected_crs_obj, "[Aa]bstract[_]?[Pp]rojected[_]?[Cc]rs") + abstract_crs = getattr(projected_crs_obj, "abstract_projected_crs", None) if abstract_crs is not None: details = _extract_abstract_projected_crs(abstract_crs) result.update({k: v for k, v in details.items() if v is not None}) @@ -281,22 +315,28 @@ def _extract_vertical_crs_details(vertical_crs_obj: Any) -> dict: Returns a dict with keys: ``epsg_code``, ``wkt``, ``unknown``, ``uom``, ``z_increasing_downward``. + + ``z_increasing_downward`` is ``None`` when the sub-object carries no + explicit direction information (e.g. ``VerticalUnknownCrs``). Callers + **must not** override a parent-level ``ZIncreasingDownward`` when this + value is ``None``. """ + logging.debug(f"Extracting vertical CRS details from object of type {type(vertical_crs_obj).__name__} with URI {get_obj_uri(vertical_crs_obj)}") result: dict = { "epsg_code": None, "wkt": None, "unknown": None, "uom": None, - "z_increasing_downward": False, + "z_increasing_downward": None, # None = not explicitly set by this sub-object } if vertical_crs_obj is None: return result - # UOM - result["uom"] = _uom_to_str(get_object_attribute_rgx(vertical_crs_obj, "[Uu]om")) + # UOM (field exists on VerticalCrs v2.2; absent on v2.0.1 abstract subtypes) + result["uom"] = _uom_to_str(getattr(vertical_crs_obj, "uom", None)) - # Direction (VerticalCrs in v2.2 has a top-level Direction field) - direction = get_object_attribute_rgx(vertical_crs_obj, "[Dd]irection") + # Direction (VerticalCrs v2.2 has a top-level direction field) + direction = getattr(vertical_crs_obj, "direction", None) if direction is not None: d = str(direction) if "." in d: @@ -304,13 +344,13 @@ def _extract_vertical_crs_details(vertical_crs_obj: Any) -> dict: result["z_increasing_downward"] = d.lower() == "down" # EPSG from direct attribute - epsg = get_object_attribute_rgx(vertical_crs_obj, "[Ee]psg[_]?[Cc]ode") + epsg = getattr(vertical_crs_obj, "epsg_code", None) if epsg is not None: result["epsg_code"] = epsg return result # Navigate into AbstractVerticalCrs choice - abstract_crs = get_object_attribute_rgx(vertical_crs_obj, "[Aa]bstract[_]?[Vv]ertical[_]?[Cc]rs") + abstract_crs = getattr(vertical_crs_obj, "abstract_vertical_crs", None) if abstract_crs is not None: details = _extract_abstract_vertical_crs(abstract_crs) result.update({k: v for k, v in details.items() if v is not None}) @@ -353,27 +393,34 @@ def _extract_rotation(crs_obj: Any) -> tuple[float, str]: # --------------------------------------------------------------------------- -def _from_abstract_local3dcrs(crs_obj: Any) -> CrsInfo: +def _from_abstract_local3dcrs( + crs_obj: Any, + workspace: Optional[EnergymlStorageInterface] = None, +) -> CrsInfo: """ Handle ``AbstractLocal3dCrs`` and its concrete subclasses (``ObjLocalDepth3DCrs``, ``ObjLocalTime3DCrs``) — **RESQML v2.0.1**. - All data is inline; no workspace lookup needed. + Although the RESQML v2.0.1 schema embeds most data inline, the + ``ProjectedCrs`` and ``VerticalCrs`` child elements can be + ``DataObjectReference`` values. *workspace* is used to resolve those + DORs when provided. """ type_name = type(crs_obj).__name__ + logging.debug(f"@_from_abstract_local3dcrs Extracting CRS info from {type_name} with URI {get_obj_uri(crs_obj)}") # --- Offsets ----------------------------------------------------------- x_offset = 0.0 y_offset = 0.0 z_offset = 0.0 try: - _x = get_object_attribute_rgx(crs_obj, "[Xx][Oo]ffset") - _y = get_object_attribute_rgx(crs_obj, "[Yy][Oo]ffset") - _z = get_object_attribute_rgx(crs_obj, "[Zz][Oo]ffset") + _x = get_object_attribute_no_verif(crs_obj, "xoffset") + _y = get_object_attribute_no_verif(crs_obj, "yoffset") + _z = get_object_attribute_no_verif(crs_obj, "zoffset") x_offset = float(_x) if _x is not None else 0.0 y_offset = float(_y) if _y is not None else 0.0 z_offset = float(_z) if _z is not None else 0.0 - except (ValueError, TypeError) as exc: + except (ValueError, TypeError, AttributeError) as exc: logger.debug("v2.0.1 offset read error: %s", exc) # --- Rotation ---------------------------------------------------------- @@ -381,7 +428,8 @@ def _from_abstract_local3dcrs(crs_obj: Any) -> CrsInfo: # --- Z direction ------------------------------------------------------- z_increasing_downward: bool = False - zid_raw = get_object_attribute_rgx(crs_obj, "[Zz]increasing[_]?[Dd]ownward") + zid_raw = get_object_attribute_no_verif(crs_obj, "zincreasing_downward") + logging.debug(f"v2.0.1 ZIncreasingDownward raw value: {zid_raw}") if zid_raw is not None: if isinstance(zid_raw, bool): z_increasing_downward = zid_raw @@ -390,20 +438,19 @@ def _from_abstract_local3dcrs(crs_obj: Any) -> CrsInfo: # --- Projected UOM ----------------------------------------------------- projected_uom: Optional[str] = _uom_to_str( - get_object_attribute_rgx(crs_obj, "[Pp]rojected[Uu]om") + get_object_attribute_no_verif(crs_obj, "projected_uom") ) # --- Vertical UOM (length or time) ------------------------------------ vertical_uom: Optional[str] = _uom_to_str( - get_object_attribute_rgx(crs_obj, "[Vv]ertical[Uu]om") + get_object_attribute_no_verif(crs_obj, "vertical_uom") ) if vertical_uom is None: - vertical_uom = _uom_to_str( - get_object_attribute_rgx(crs_obj, "[Tt]ime[Uu]om") - ) + # time_uom only present on LocalTime3dCrs + vertical_uom = _uom_to_str(getattr(crs_obj, "time_uom", None)) # --- Axis order -------------------------------------------------------- - axis_order_raw = get_object_attribute_rgx(crs_obj, "[Pp]rojected[Aa]xis[Oo]rder") + axis_order_raw = get_object_attribute_no_verif(crs_obj, "projected_axis_order") projected_axis_order: Optional[str] = None if axis_order_raw is not None: ao = str(axis_order_raw) @@ -412,7 +459,9 @@ def _from_abstract_local3dcrs(crs_obj: Any) -> CrsInfo: projected_axis_order = ao.replace("_", " ").lower() # --- Projected CRS ----------------------------------------------------- - projected_crs_obj = get_object_attribute_rgx(crs_obj, "[Pp]rojected[Cc]rs") + projected_crs_obj = _resolve_dor( + get_object_attribute_no_verif(crs_obj, "projected_crs"), workspace + ) projected_details = _extract_projected_crs_details(projected_crs_obj) # Projected UOM from inline ProjectedCrs takes precedence if present @@ -422,15 +471,21 @@ def _from_abstract_local3dcrs(crs_obj: Any) -> CrsInfo: projected_axis_order = projected_details["axis_order"] # --- Vertical CRS ------------------------------------------------------ - vertical_crs_obj = get_object_attribute_rgx(crs_obj, "[Vv]ertical[Cc]rs") + vertical_crs_obj = _resolve_dor( + get_object_attribute_no_verif(crs_obj, "vertical_crs"), workspace + ) vertical_details = _extract_vertical_crs_details(vertical_crs_obj) # Direction from VerticalCrs overrides the top-level ZIncreasingDownward # only when explicitly set. + logging.debug("z_increasing_downward before vertical CRS details: %s", z_increasing_downward) + logging.debug(f"Vertical CRS details: {vertical_details} -- vertical_crs_obj type: {type(vertical_crs_obj).__name__ if vertical_crs_obj else 'None'}") if vertical_crs_obj is not None and vertical_details.get("z_increasing_downward") is not None: z_increasing_downward = vertical_details["z_increasing_downward"] if vertical_details.get("uom"): vertical_uom = vertical_details["uom"] + + logging.debug("z_increasing_downward after vertical CRS details: %s", z_increasing_downward) return CrsInfo( x_offset=x_offset, @@ -452,14 +507,19 @@ def _from_abstract_local3dcrs(crs_obj: Any) -> CrsInfo: ) -def _from_local_engineering2d_crs(crs_obj: Any) -> CrsInfo: +def _from_local_engineering2d_crs( + crs_obj: Any, + workspace: Optional[EnergymlStorageInterface] = None, +) -> CrsInfo: """ Handle ``LocalEngineering2dCrs`` — **EML v2.3 / RESQML v2.2**. - Contains: XY offsets, azimuth, inline ``ProjectedCrs``, + Contains: XY offsets, azimuth, ``ProjectedCrs`` DOR, ``HorizontalAxes.ProjectedUom``. Does **not** contain Z offset or vertical CRS — those live in the enclosing ``LocalEngineeringCompoundCrs``. + + *workspace* is used to resolve the ``origin_projected_crs`` DOR. """ type_name = type(crs_obj).__name__ @@ -467,18 +527,18 @@ def _from_local_engineering2d_crs(crs_obj: Any) -> CrsInfo: x_offset = 0.0 y_offset = 0.0 try: - _x = get_object_attribute_rgx(crs_obj, "[Oo]rigin[Pp]rojected[Cc]oordinate1") - _y = get_object_attribute_rgx(crs_obj, "[Oo]rigin[Pp]rojected[Cc]oordinate2") + _x = get_object_attribute_no_verif(crs_obj, "origin_projected_coordinate1") + _y = get_object_attribute_no_verif(crs_obj, "origin_projected_coordinate2") x_offset = float(_x) if _x is not None else 0.0 y_offset = float(_y) if _y is not None else 0.0 - except (ValueError, TypeError) as exc: + except (ValueError, TypeError, AttributeError) as exc: logger.debug("LocalEngineering2dCrs offset read error: %s", exc) # --- Azimuth ----------------------------------------------------------- areal_rotation_value, areal_rotation_uom = _extract_rotation(crs_obj) # --- Azimuth reference ------------------------------------------------- - azimuth_ref_raw = get_object_attribute_rgx(crs_obj, "[Aa]zimuth[Rr]eference") + azimuth_ref_raw = get_object_attribute_no_verif(crs_obj, "azimuth_reference") azimuth_reference: Optional[str] = None if azimuth_ref_raw is not None: ar = str(azimuth_ref_raw) @@ -486,13 +546,14 @@ def _from_local_engineering2d_crs(crs_obj: Any) -> CrsInfo: ar = ar.split(".")[-1] azimuth_reference = ar.replace("_", " ").lower() - # --- Horizontal UOM (HorizontalAxes.ProjectedUom or uom on ProjectedCrs) --- + # --- Horizontal UOM (HorizontalAxes.projected_uom or uom on ProjectedCrs) --- projected_uom: Optional[str] = _uom_to_str( - get_object_attribute_rgx(crs_obj, "[Hh]orizontal[_]?[Aa]xes.[Pp]rojected[_]?[Uu]om") + get_object_attribute(crs_obj, "horizontal_axes.projected_uom") ) - # --- Inline ProjectedCrs ----------------------------------------------- - projected_crs_obj = get_object_attribute_rgx(crs_obj, "[Oo]rigin[Pp]rojected[Cc]rs") + # --- ProjectedCrs — may be an inline object OR a DOR ------------------ + projected_crs_raw = get_object_attribute_no_verif(crs_obj, "origin_projected_crs") + projected_crs_obj = _resolve_dor(projected_crs_raw, workspace) projected_details = _extract_projected_crs_details(projected_crs_obj) if projected_details.get("uom") and projected_uom is None: @@ -517,13 +578,19 @@ def _from_local_engineering2d_crs(crs_obj: Any) -> CrsInfo: def _from_vertical_crs(crs_obj: Any) -> CrsInfo: """ Handle a standalone ``VerticalCrs`` document object — **EML v2.3 / RESQML v2.2**. + + When the object carries no explicit direction (e.g. ``VerticalUnknownCrs``), + ``z_increasing_downward`` defaults to ``False``; the caller is responsible + for not blindly overriding a parent-level value in that case. """ type_name = type(crs_obj).__name__ details = _extract_vertical_crs_details(crs_obj) + # Sentinel None means direction was not explicit — default to False for the standalone CrsInfo. + z_idc: bool = details["z_increasing_downward"] if details["z_increasing_downward"] is not None else False return CrsInfo( vertical_epsg_code=details.get("epsg_code"), vertical_uom=details.get("uom"), - z_increasing_downward=details.get("z_increasing_downward", False), + z_increasing_downward=z_idc, vertical_wkt=details.get("wkt"), vertical_unknown=details.get("unknown"), source_type=type_name, @@ -549,18 +616,18 @@ def _from_local_engineering_compound_crs( # --- Z offset (origin_vertical_coordinate) -------------------------------- z_offset = 0.0 try: - _z = get_object_attribute_rgx(crs_obj, "[Oo]rigin[Vv]ertical[Cc]oordinate") + _z = get_object_attribute_no_verif(crs_obj, "origin_vertical_coordinate") z_offset = float(_z) if _z is not None else 0.0 - except (ValueError, TypeError) as exc: + except (ValueError, TypeError, AttributeError) as exc: logger.debug("LocalEngineeringCompoundCrs z-offset read error: %s", exc) # --- Vertical axis (inline — gives direction + uom without workspace) -- vert_axis_direction: Optional[str] = None vert_axis_uom: Optional[str] = None - vert_axis_uom_raw = get_object_attribute_rgx(crs_obj, "[Vv]ertical[_]?[Aa]xis.[Uu]om") + vert_axis_uom_raw = get_object_attribute(crs_obj, "vertical_axis.uom") if vert_axis_uom_raw is not None: vert_axis_uom = _uom_to_str(vert_axis_uom_raw) - vert_axis_dir_raw = get_object_attribute_rgx(crs_obj, "[Vv]ertical[_]?[Aa]xis.[Dd]irection") + vert_axis_dir_raw = get_object_attribute(crs_obj, "vertical_axis.direction") if vert_axis_dir_raw is not None: d = str(vert_axis_dir_raw) if "." in d: @@ -571,44 +638,47 @@ def _from_local_engineering_compound_crs( # --- Resolve LocalEngineering2dCrs via DOR ---------------------------- horiz_info: Optional[CrsInfo] = None - horiz_dor = get_object_attribute_rgx(crs_obj, "[Ll]ocal[Ee]ngineering2[dD][Cc]rs") + horiz_dor = get_object_attribute_no_verif(crs_obj, "local_engineering2d_crs") if horiz_dor is not None and workspace is not None: horiz_uuid = get_obj_uuid(horiz_dor) if horiz_uuid: candidates = workspace.get_object_by_uuid(horiz_uuid) if candidates: - horiz_info = _from_local_engineering2d_crs(candidates[0]) + horiz_info = _from_local_engineering2d_crs(candidates[0], workspace) if horiz_info is None: horiz_uri = get_obj_uri(horiz_dor) if horiz_uri: horiz_obj = workspace.get_object(horiz_uri) if horiz_obj is not None: - horiz_info = _from_local_engineering2d_crs(horiz_obj) + horiz_info = _from_local_engineering2d_crs(horiz_obj, workspace) elif horiz_dor is not None: - logger.debug( + logger.warning( "LocalEngineeringCompoundCrs: workspace is None — cannot resolve " - "LocalEngineering2dCrs DOR; horizontal info will be partial." + "LocalEngineering2dCrs DOR; horizontal info (offsets, rotation) will be missing." ) # --- Resolve VerticalCrs via DOR (inherited AbstractCompoundCrs.vertical_crs) --- + vert_details_raw: Optional[dict] = None # raw dict, preserving None sentinel vert_info: Optional[CrsInfo] = None - vert_dor = get_object_attribute_rgx(crs_obj, "[Vv]ertical[Cc]rs") + vert_dor = get_object_attribute_no_verif(crs_obj, "vertical_crs") if vert_dor is not None and workspace is not None: vert_uuid = get_obj_uuid(vert_dor) if vert_uuid: candidates = workspace.get_object_by_uuid(vert_uuid) if candidates: + vert_details_raw = _extract_vertical_crs_details(candidates[0]) vert_info = _from_vertical_crs(candidates[0]) if vert_info is None: vert_uri = get_obj_uri(vert_dor) if vert_uri: vert_obj = workspace.get_object(vert_uri) if vert_obj is not None: + vert_details_raw = _extract_vertical_crs_details(vert_obj) vert_info = _from_vertical_crs(vert_obj) elif vert_dor is not None: - logger.debug( + logger.warning( "LocalEngineeringCompoundCrs: workspace is None — cannot resolve " - "VerticalCrs DOR; vertical info will be partial." + "VerticalCrs DOR; vertical info (EPSG, UOM) will be missing." ) # --- Merge results ----------------------------------------------------- @@ -625,11 +695,14 @@ def _from_local_engineering_compound_crs( areal_rotation_value=horiz_info.areal_rotation_value if horiz_info else 0.0, areal_rotation_uom=horiz_info.areal_rotation_uom if horiz_info else "rad", azimuth_reference=horiz_info.azimuth_reference if horiz_info else None, - # Vertical info: prefer resolved VerticalCrs object, else inline VerticalAxis + # Vertical info: prefer resolved VerticalCrs, but only override direction + # when the resolved CRS carries an explicit direction (not the None sentinel). vertical_epsg_code=vert_info.vertical_epsg_code if vert_info else None, vertical_uom=(vert_info.vertical_uom if vert_info else None) or vert_axis_uom, z_increasing_downward=( - vert_info.z_increasing_downward if vert_info else z_increasing_downward + vert_info.z_increasing_downward + if vert_info and vert_details_raw is not None and vert_details_raw.get("z_increasing_downward") is not None + else z_increasing_downward ), vertical_wkt=vert_info.vertical_wkt if vert_info else None, vertical_unknown=vert_info.vertical_unknown if vert_info else None, @@ -637,6 +710,146 @@ def _from_local_engineering_compound_crs( ) +# --------------------------------------------------------------------------- +# Geometry helpers +# --------------------------------------------------------------------------- + + +_NORTHING_FIRST_PATTERNS = ( + "northing easting", + "north east", + "north easting", + "northing east", + "latitude longitude", + "lat lon", + "lat long", +) + + +def apply_axis_order_swap( + points: np.ndarray, + axis_order: Optional[str], +) -> np.ndarray: + """ + Swap X and Y columns when the CRS axis order is northing-first. + + RESQML local offsets are always stored as (easting, northing), + but some projected CRS definitions (e.g. EPSG:4326, EPSG:27700) use + northing as the first axis. When ``axis_order`` indicates a + northing-first convention the columns 0 and 1 of *points* are swapped + so that column 0 is always easting and column 1 is always northing. + + Parameters + ---------- + points: + (N, 3) float64 array, **modified in-place**. + axis_order: + Normalised axis-order string from :class:`CrsInfo` (lower-case, + spaces instead of underscores), e.g. ``"northing easting"``. + ``None`` means "no swap needed". + + Returns + ------- + np.ndarray + The same array (in-place swap). + """ + if axis_order is None: + return points + ao_lower = axis_order.lower() + if any(ao_lower.startswith(p) for p in _NORTHING_FIRST_PATTERNS): + points[:, 0], points[:, 1] = points[:, 1].copy(), points[:, 0].copy() + return points + + +def apply_from_crs_info( + points: np.ndarray, + crs_info: "CrsInfo", + *, + inplace: bool = True, +) -> np.ndarray: + """ + Apply the full CRS transform described by *crs_info* to *points*. + + Transform pipeline (order matters): + + 1. **Areal rotation** (RESQML convention: *clockwise* angle) → + ``x' = x·cos θ + y·sin θ``, ``y' = –x·sin θ + y·cos θ`` + 2. **Translation** — add ``(x_offset, y_offset, z_offset)`` + 3. **Z-axis flip** — negate Z when the CRS *is* + z-increasing-downward (i.e. the local CRS stores depth as positive Z, + so we flip to z-up for a consistent elevation-positive system used by + most 3-D viewers). + 4. **Axis-order swap** — swap X/Y when :attr:`CrsInfo.projected_axis_order` + is northing-first. + + .. note:: + ``azimuth_reference`` values of ``"true north"`` or + ``"magnetic north"`` require an external correction + (meridian-convergence / magnetic-declination) that is not applied + here. A ``WARNING`` is emitted in those cases. + + Parameters + ---------- + points: + (N, 3) ``float64`` array of 3-D points in the local CRS. + crs_info: + Populated :class:`CrsInfo` DTO. + inplace: + When ``True`` (default) the rotation and translation are applied + to *points* directly. When ``False`` a copy is made first. + + Returns + ------- + np.ndarray + Transformed (N, 3) array. + """ + if not inplace: + points = points.copy() + + pts = points.astype(np.float64, copy=False) + + # --- 0. AzimuthReference warning --------------------------------------- + ref = (crs_info.azimuth_reference or "").lower() + if ref in ("true north", "magnetic north"): + logger.warning( + "apply_from_crs_info: azimuth_reference='%s' requires a meridian-" + "convergence / magnetic-declination correction that is NOT applied. " + "#TODO: implement once a correction source is available.", + crs_info.azimuth_reference, + ) + + # --- 1. Areal rotation (RESQML: clockwise) ---------------------------- + angle_rad = crs_info.areal_rotation_rad() + if angle_rad != 0.0: + cos_t = math.cos(angle_rad) + sin_t = math.sin(angle_rad) + x_orig = pts[:, 0].copy() + y_orig = pts[:, 1].copy() + # CW rotation: x' = x·cos + y·sin, y' = -x·sin + y·cos + pts[:, 0] = x_orig * cos_t + y_orig * sin_t + pts[:, 1] = -x_orig * sin_t + y_orig * cos_t + + # --- 2. Translation --------------------------------------------------- + pts[:, 0] += crs_info.x_offset + pts[:, 1] += crs_info.y_offset + pts[:, 2] += crs_info.z_offset + + # --- 3. Z-axis flip --------------------------------------------------- + # When z-increasing-downward the local CRS stores depth as positive Z + # (down = positive). Negate so the output uses the z-up (elevation- + # positive) convention expected by most 3-D viewers. + if crs_info.z_increasing_downward: + pts[:, 2] = -pts[:, 2] + + # --- 4. Axis-order swap ----------------------------------------------- + apply_axis_order_swap(pts, crs_info.projected_axis_order) + + if inplace: + points[:] = pts + return points + return pts + + # --------------------------------------------------------------------------- # Public factory # --------------------------------------------------------------------------- @@ -683,6 +896,13 @@ def extract_crs_info( if crs_obj is None: return CrsInfo() + # Transparently resolve DataObjectReference inputs (e.g. from get_datum_information) + # so callers do not have to resolve DORs before calling this function. + if workspace is not None: + crs_obj = _resolve_dor(crs_obj, workspace) + if crs_obj is None: + return CrsInfo() + type_name_lower = type(crs_obj).__name__.lower() # ------------------------------------------------------------------ @@ -692,7 +912,7 @@ def extract_crs_info( return _from_local_engineering_compound_crs(crs_obj, workspace) if "localengineering2dcrs" in type_name_lower or "localengineering2" in type_name_lower: - return _from_local_engineering2d_crs(crs_obj) + return _from_local_engineering2d_crs(crs_obj, workspace) if type_name_lower == "verticalcrs": return _from_vertical_crs(crs_obj) @@ -704,7 +924,7 @@ def extract_crs_info( kw in type_name_lower for kw in ("localdepth3dcrs", "localtime3dcrs", "abstractlocal3dcrs", "local3dcrs") ): - return _from_abstract_local3dcrs(crs_obj) + return _from_abstract_local3dcrs(crs_obj, workspace) # ------------------------------------------------------------------ # Heuristic fallback: inspect the object's attributes to guess version @@ -715,7 +935,7 @@ def extract_crs_info( "extract_crs_info: unrecognised type '%s' — treating as AbstractLocal3dCrs (v2.0.1 pattern).", type(crs_obj).__name__, ) - return _from_abstract_local3dcrs(crs_obj) + return _from_abstract_local3dcrs(crs_obj, workspace) # v2.2 pattern: has OriginProjectedCoordinate1 (LocalEngineering2dCrs) if get_object_attribute_rgx(crs_obj, "[Oo]rigin[Pp]rojected[Cc]oordinate1") is not None: @@ -723,7 +943,7 @@ def extract_crs_info( "extract_crs_info: unrecognised type '%s' — treating as LocalEngineering2dCrs (v2.2 pattern).", type(crs_obj).__name__, ) - return _from_local_engineering2d_crs(crs_obj) + return _from_local_engineering2d_crs(crs_obj, workspace) # v2.2 pattern: has LocalEngineering2dCrs DOR → compound if get_object_attribute_rgx(crs_obj, "[Ll]ocal[Ee]ngineering2[dD][Cc]rs") is not None: @@ -738,3 +958,11 @@ def extract_crs_info( type(crs_obj).__name__, ) return CrsInfo(source_type=type(crs_obj).__name__) + + +__all__ = [ + "CrsInfo", + "extract_crs_info", + "apply_from_crs_info", + "apply_axis_order_swap", +] diff --git a/energyml-utils/src/energyml/utils/data/helper.py b/energyml-utils/src/energyml/utils/data/helper.py index 43ff66f..0e26be1 100644 --- a/energyml-utils/src/energyml/utils/data/helper.py +++ b/energyml-utils/src/energyml/utils/data/helper.py @@ -161,7 +161,10 @@ def apply_crs_transform( # 2. Handle Areal Rotation (Rotation around the Z axis) # Applied before translation as per Energistics standards. - # Note: RESQML rotation is typically clockwise. + # RESQML ArealRotation / Azimuth is a CLOCKWISE angle (not the standard + # CCW mathematical convention). The correct CW rotation matrix is: + # x' = x·cos θ + y·sin θ + # y' = −x·sin θ + y·cos θ if angle_rad != 0.0: cos_theta = np.cos(angle_rad) sin_theta = np.sin(angle_rad) @@ -169,9 +172,9 @@ def apply_crs_transform( x_orig = transformed[:, 0].copy() y_orig = transformed[:, 1].copy() - # Standard 2D rotation matrix - transformed[:, 0] = x_orig * cos_theta - y_orig * sin_theta - transformed[:, 1] = x_orig * sin_theta + y_orig * cos_theta + # Clockwise rotation (RESQML convention) + transformed[:, 0] = x_orig * cos_theta + y_orig * sin_theta + transformed[:, 1] = -x_orig * sin_theta + y_orig * cos_theta # 3. Apply Translation (Offsets) transformed[:, 0] += x_offset @@ -382,8 +385,8 @@ def get_crs_obj( crs_list = search_attribute_matching_name(context_obj, r"\.*Crs", search_in_sub_obj=True, deep_search=False) if crs_list is not None and len(crs_list) > 0 and crs_list[0] is not None: # logging.debug(crs_list[0]) - # logging.debug(f"CRS found for {get_obj_title(context_obj)} : {crs_list[0]}") crs = workspace.get_object(get_obj_uri(crs_list[0])) + logging.debug(f"CRS found for {get_obj_title(context_obj)} ({type(context_obj).__name__}): {crs}") if crs is None: # logging.debug(f"CRS {crs_list[0]} not found (or not read correctly)") _crs_list = workspace.get_object_by_uuid(get_obj_uuid(crs_list[0])) @@ -393,6 +396,8 @@ def get_crs_obj( raise ObjectNotFoundNotError(get_obj_uri(crs_list[0])) if crs is not None: return crs + else: + logging.debug(f"No CRS found for {get_obj_title(context_obj)} with type {type(context_obj).__name__}") if context_obj != root_obj: upper_path = path_parent_attribute(path_in_root) @@ -1064,23 +1069,49 @@ def read_int_double_lattice_array( :param sub_indices: :return: """ - start_value = get_object_attribute_no_verif(energyml_array, "start_value") + start_value = int(get_object_attribute_no_verif(energyml_array, "start_value")) offset = get_object_attribute_no_verif(energyml_array, "offset") - result = [] + if len(offset) == 0: + raise Exception(f"{type(energyml_array)} has no offset — cannot generate indices") if len(offset) == 1: - # 1D lattice array: offset is a single DoubleConstantArray or IntegerConstantArray + # 1D lattice: start_value, start_value+v, start_value+2v, … (count+1 values) offset_obj = offset[0] - - # Get the offset value and count from the ConstantArray offset_value = get_object_attribute_no_verif(offset_obj, "value") - count = get_object_attribute_no_verif(offset_obj, "count") - - # Generate the 1D array: start_value + i * offset_value for i in range(count) - result = [start_value + i * offset_value for i in range(count)] + count = int(get_object_attribute_no_verif(offset_obj, "count")) + result = [start_value + i * offset_value for i in range(count + 1)] else: - raise Exception(f"{type(energyml_array)} read with an offset of length {len(offset)} is not supported") + # N-D lattice (N ≥ 2) — used for NodeIndicesOnSupportingRepresentation. + # + # Each Offset[k] is an IntegerConstantArray with: + # Count = number of *steps* along axis k → grid size = Count+1 + # Value = stride multiplier for axis k + # + # Flat index formula (C/row-major order): + # flat_idx(i0, i1, …) = StartValue + # + i0 * Value[0] * (Count[1]+1) * (Count[2]+1) * … + # + i1 * Value[1] * (Count[2]+1) * … + # + … + # + iN-1 * Value[N-1] + # + # i.e. stride[k] = Value[k] * prod(Count[m]+1 for m in range(k+1, N)) + N = len(offset) + counts = [int(get_object_attribute_no_verif(off, "count")) for off in offset] + values = [int(get_object_attribute_no_verif(off, "value")) for off in offset] + + strides = [] + for k in range(N): + s = values[k] + for m in range(k + 1, N): + s *= counts[m] + 1 + strides.append(s) + + # np.indices gives shape (N, d0, d1, …) + shape = tuple(c + 1 for c in counts) + idx_grids = np.indices(shape) # (N, *shape) + flat_indices = start_value + sum(idx_grids[k] * strides[k] for k in range(N)) + result = flat_indices.ravel().tolist() return result @@ -1153,9 +1184,23 @@ def read_point3d_from_representation_lattice_array( sub_indices: Optional[Union[List[int], np.ndarray]] = None, ): """ - Read a Point3DFromRepresentationLatticeArray. + Read a ``Point3DFromRepresentationLatticeArray``. + + The XY(Z) positions are borrowed from a *supporting* ``Grid2DRepresentation`` + by selecting its nodes via the flat indices described in + ``NodeIndicesOnSupportingRepresentation`` (an ``IntegerLatticeArray``). - Note: Only works for Grid2DRepresentation. + The index formula for an N-dimensional ``IntegerLatticeArray`` is row-major: + + stride[k] = Value[k] * prod(Count[m]+1 for m in range(k+1, N)) + flat_idx(i, j, …) = StartValue + i*stride[0] + j*stride[1] + … + + Example — supporting rep 2×4, ``Offset[0]={Count=1, Value=1}``, + ``Offset[1]={Count=3, Value=1}``: + stride[0] = 1 * 4 = 4, stride[1] = 1 + flat_idx(i, j) = 4i + j → [0,1,2,3,4,5,6,7] + + Note: Only ``Grid2DRepresentation`` supporting reps are currently supported. :param energyml_array: :param root_obj: @@ -1164,25 +1209,88 @@ def read_point3d_from_representation_lattice_array( :param sub_indices: :return: """ - supporting_rep_identifier = get_obj_uri(get_object_attribute_no_verif(energyml_array, "supporting_representation")) - # logging.debug(f"energyml_array : {energyml_array}\n\t{supporting_rep_identifier}") + supporting_rep_dor = get_object_attribute_no_verif(energyml_array, "supporting_representation") + supporting_rep_identifier = get_obj_uri(supporting_rep_dor) supporting_rep = workspace.get_object(supporting_rep_identifier) if workspace is not None else None - # TODO chercher un pattern \.*patch\.*.[d]+ pour trouver le numero du patch dans le path_in_root puis lire le patch - # logging.debug(f"path_in_root {path_in_root}") + if supporting_rep is None and workspace is not None: + from energyml.utils.introspection import get_obj_uuid + candidates = workspace.get_object_by_uuid(get_obj_uuid(supporting_rep_dor)) + supporting_rep = candidates[0] if candidates else None - result = [] - if "grid2d" in str(type(supporting_rep)).lower(): - patch_path, patch = search_attribute_matching_name_with_path(supporting_rep, "Grid2dPatch")[0] - points = read_grid2d_patch( - patch=patch, grid2d=supporting_rep, path_in_root=patch_path, workspace=workspace, sub_indices=sub_indices + if supporting_rep is None: + raise Exception( + f"Supporting representation {supporting_rep_identifier} not found in workspace" + ) + + if "grid2d" not in str(type(supporting_rep)).lower(): + raise Exception( + f"Unsupported supporting rep type {type(supporting_rep).__name__} " + f"for {type(energyml_array).__name__}" + ) + + # ── 1. Read ALL points from the supporting representation ──────────────── + # RESQML 2.0.1 uses Grid2dPatch; RESQML 2.2 stores geometry directly. + all_sup_points: Optional[np.ndarray] = None + + patch_matches = search_attribute_matching_name_with_path(supporting_rep, "Grid2dPatch") + if patch_matches: + patch_path, patch = patch_matches[0] + all_sup_points = read_grid2d_patch( + patch=patch, + grid2d=supporting_rep, + path_in_root=patch_path, + workspace=workspace, + ) + else: + # RESQML 2.2: geometry is directly on the representation + geom_points_matches = search_attribute_matching_name_with_path( + supporting_rep, "Geometry.Points" + ) + if not geom_points_matches: + raise Exception( + f"Cannot find points in supporting rep {type(supporting_rep).__name__}" + ) + geom_path, geom_points_obj = geom_points_matches[0] + all_sup_points = read_array( + energyml_array=geom_points_obj, + root_obj=supporting_rep, + path_in_root=geom_path, + workspace=workspace, ) - # TODO: take the points by there indices from the NodeIndicesOnSupportingRepresentation - result = points + if not isinstance(all_sup_points, np.ndarray): + all_sup_points = np.array(all_sup_points, dtype=float) + all_sup_points = all_sup_points.reshape(-1, 3) + + # ── 2. Generate the node index list from the IntegerLatticeArray ───────── + node_idx_arr = get_object_attribute_no_verif( + energyml_array, "node_indices_on_supporting_representation" + ) + if node_idx_arr is None: + node_idx_arr = get_object_attribute_rgx(energyml_array, "NodeIndices") + + if node_idx_arr is not None: + node_indices = read_array( + energyml_array=node_idx_arr, + root_obj=root_obj, + path_in_root=path_in_root, + workspace=workspace, + ) + node_indices = np.asarray(node_indices, dtype=np.int64) + result = all_sup_points[node_indices] else: - raise Exception(f"Not supported type {type(energyml_array)} for object {type(root_obj)}") - # pour trouver les infos qu'il faut + # No index array: use all points in order (identity mapping) + logging.debug( + "Point3DFromRepresentationLatticeArray: no NodeIndices found, " + "using all supporting rep points in order" + ) + result = all_sup_points + + # ── 3. Optional sub-selection (SubRepresentation) ──────────────────────── + if sub_indices is not None and len(sub_indices) > 0: + result = result[np.asarray(sub_indices, dtype=np.int64)] + return result @@ -1214,7 +1322,9 @@ def read_point3d_lattice_array( """ Read a Point3DLatticeArray. - Note: If a CRS is found and its 'ZIncreasingDownward' is set to true or its + Accumulates origin + cumulative slowest/fastest offset vectors into an + (N, 3) float64 array. CRS transforms (z-flip, offsets, rotation) are the + responsibility of the caller — this function is CRS-neutral. :param energyml_array: :param root_obj: @@ -1245,19 +1355,6 @@ def read_point3d_lattice_array( current_path=path_in_root or "", ) - crs = None - try: - crs = get_crs_obj( - context_obj=energyml_array, - path_in_root=path_in_root, - root_obj=root_obj, - workspace=workspace, - ) - except ObjectNotFoundNotError: - logging.error("No CRS found, not able to check zIncreasingDownward") - - zincreasing_downward = is_z_reversed(crs) - slowest_vec = _point_as_array(get_object_attribute_rgx(slowest, "offset|direction")) slowest_spacing = read_array(get_object_attribute_no_verif(slowest, "spacing")) slowest_table = list(map(lambda x: prod_n_tab(x, slowest_vec), slowest_spacing)) @@ -1271,7 +1368,7 @@ def read_point3d_lattice_array( logging.debug(f"slowest vector: {slowest_vec}, spacing: {slowest_spacing}, size: {slowest_size}") logging.debug(f"fastest vector: {fastest_vec}, spacing: {fastest_spacing}, size: {fastest_size}") - logging.debug(f"origin: {origin}, zincreasing_downward: {zincreasing_downward}") + logging.debug(f"origin: {origin}") if crs_sa_count is not None and len(crs_sa_count) > 0 and crs_fa_count is not None and len(crs_fa_count) > 0: if (crs_sa_count[0] == fastest_size and crs_fa_count[0] == slowest_size) or ( @@ -1294,31 +1391,32 @@ def read_point3d_lattice_array( try: # Convert tables to NumPy arrays origin_arr = np.array(origin, dtype=float) - slowest_arr = np.array(slowest_table, dtype=float) # shape: (slowest_size, 3) - fastest_arr = np.array(fastest_table, dtype=float) # shape: (fastest_size, 3) - - # Compute cumulative sums - slowest_cumsum = np.cumsum(slowest_arr, axis=0) # cumulative offset along slowest axis - fastest_cumsum = np.cumsum(fastest_arr, axis=0) # cumulative offset along fastest axis + slowest_arr = np.array(slowest_table, dtype=float) # shape: (slowest_size-1, 3) + fastest_arr = np.array(fastest_table, dtype=float) # shape: (fastest_size-1, 3) + + # Sanity: spacing arrays must have exactly (size-1) rows. + # For well-formed RESQML data this is always true; bail out to the + # iterative fallback if someone passes malformed data. + if slowest_arr.shape[0] != slowest_size - 1 or fastest_arr.shape[0] != fastest_size - 1: + raise ValueError( + f"Spacing array length mismatch: " + f"slowest={slowest_arr.shape[0]} expected {slowest_size - 1}, " + f"fastest={fastest_arr.shape[0]} expected {fastest_size - 1}" + ) - # Create meshgrid indices - i_indices, j_indices = np.meshgrid(np.arange(slowest_size), np.arange(fastest_size), indexing="ij") + # Compute cumulative sums (shape: (size-1, 3)) + slowest_cumsum = np.cumsum(slowest_arr, axis=0) + fastest_cumsum = np.cumsum(fastest_arr, axis=0) # Initialize result array result_arr = np.zeros((slowest_size, fastest_size, 3), dtype=float) result_arr[:, :, :] = origin_arr # broadcast origin to all positions - # Add offsets based on zincreasing_downward - if zincreasing_downward: - # Add slowest offsets where i > 0 - result_arr[1:, :, :] += slowest_cumsum[:-1, np.newaxis, :] - # Add fastest offsets where j > 0 - result_arr[:, 1:, :] += fastest_cumsum[np.newaxis, :-1, :] - else: - # Add fastest offsets where j > 0 - result_arr[:, 1:, :] += fastest_cumsum[np.newaxis, :-1, :] - # Add slowest offsets where i > 0 - result_arr[1:, :, :] += slowest_cumsum[:-1, np.newaxis, :] + # Accumulate offsets: + # result_arr[:, j, :] += fastest_cumsum[j-1] for j in 1..fastest_size-1 + # result_arr[i, :, :] += slowest_cumsum[i-1] for i in 1..slowest_size-1 + result_arr[:, 1:, :] += fastest_cumsum[np.newaxis, :, :] # (1, fast-1, 3) + result_arr[1:, :, :] += slowest_cumsum[:, np.newaxis, :] # (slow-1, 1, 3) # Return the (N, 3) float64 numpy array directly — no .tolist(). result = result_arr.reshape(-1, 3) @@ -1337,18 +1435,12 @@ def read_point3d_lattice_array( previous_value = fallback[line_idx + j - 1] else: previous_value = fallback[j - 1] - if zincreasing_downward: - fallback.append(sum_lists(previous_value, slowest_table[i - 1])) - else: - fallback.append(sum_lists(previous_value, fastest_table[j - 1])) + fallback.append(sum_lists(previous_value, fastest_table[j - 1])) else: if i > 0: prev_line_idx = (i - 1) * fastest_size previous_value = fallback[prev_line_idx] - if zincreasing_downward: - fallback.append(sum_lists(previous_value, fastest_table[j - 1])) - else: - fallback.append(sum_lists(previous_value, slowest_table[i - 1])) + fallback.append(sum_lists(previous_value, slowest_table[i - 1])) else: fallback.append(previous_value) # Convert fallback list to ndarray to keep the return type consistent. diff --git a/energyml-utils/src/energyml/utils/data/mesh.py b/energyml-utils/src/energyml/utils/data/mesh.py index fa867f1..bde5413 100644 --- a/energyml-utils/src/energyml/utils/data/mesh.py +++ b/energyml-utils/src/energyml/utils/data/mesh.py @@ -24,10 +24,9 @@ read_array, read_grid2d_patch, get_crs_obj, - get_crs_origin_offset, - is_z_reversed, read_parametric_geometry, ) +from .crs import extract_crs_info, apply_from_crs_info from energyml.utils.epc_utils import gen_energyml_object_path from energyml.utils.epc_stream import EpcStreamReader from energyml.utils.exception import NotSupportedError, ObjectNotFoundNotError @@ -162,26 +161,6 @@ def get_indices(self) -> Union[List[List[int]], np.ndarray]: return self.faces_indices -def crs_displacement(points: List[Point], crs_obj: Any) -> Tuple[List[Point], Point]: - """ - Transform a point list with CRS information (XYZ offset and ZIncreasingDownward) - :param points: in/out : the list is directly modified - :param crs_obj: - :return: The translated points and the crs offset vector. - """ - crs_point_offset = get_crs_origin_offset(crs_obj=crs_obj) - zincreasing_downward = is_z_reversed(crs_obj) - - if np.any(crs_point_offset): - for p in points: - for xyz in range(len(p)): - p[xyz] = (p[xyz] + crs_point_offset[xyz]) if p[xyz] is not None else None - if zincreasing_downward and len(p) >= 3: - p[2] = -p[2] - - return points, crs_point_offset - - def get_object_reader_function(mesh_type_name: str) -> Optional[Callable]: """ Returns the name of the potential appropriate function to read an object with type is named mesh_type_name @@ -214,15 +193,15 @@ def _mesh_name_mapping(array_type_name: str) -> str: def read_mesh_object( energyml_object: Any, workspace: Optional[EnergymlStorageInterface] = None, - use_crs_displacement: bool = False, + use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[AbstractMesh]: """ Read and "meshable" object. If :param:`energyml_object` is not supported, an exception will be raised. :param energyml_object: :param workspace: - :param use_crs_displacement: If true the :py:function:`crs_displacement <energyml.utils.mesh.crs_displacement>` - is used to translate the data with the CRS offsets + :param use_crs_displacement: If true :func:`apply_from_crs_info` is used to apply the full CRS + transform (rotation, offsets, Z-flip, axis-order swap) to the mesh points :return: """ @@ -234,18 +213,31 @@ def read_mesh_object( if reader_func is not None: # logging.info(f"using function {reader_func} to read type {array_type_name}") surfaces: List[AbstractMesh] = reader_func( - energyml_object=energyml_object, workspace=workspace, sub_indices=sub_indices + energyml_object=energyml_object, workspace=workspace, sub_indices=sub_indices, + use_crs_displacement=use_crs_displacement, ) + _tn = array_type_name.lower() if ( - use_crs_displacement and "wellbore" not in array_type_name.lower() - ): # WellboreFrameRep has allready the displacement applied - # TODO: the displacement should be done in each reader function to manage specific cases + use_crs_displacement + and "wellbore" not in _tn + and "triangulated" not in _tn # per-patch CRS applied inside reader + and "point" not in _tn # per-patch CRS applied inside reader + and "polyline" not in _tn # per-patch CRS applied inside reader + and "representationset" not in _tn # each sub-mesh already had CRS applied by its own reader + and "subrepresentation" not in _tn # delegates entirely to inner read_mesh_object call + ): for s in surfaces: - # logging.debug(f"CRS : {s.crs_object}") - crs_displacement( - s.point_list, - s.crs_object[0] if isinstance(s.crs_object, list) and len(s.crs_object) > 0 else s.crs_object, + crs = ( + s.crs_object[0] + if isinstance(s.crs_object, list) and s.crs_object + else s.crs_object ) + if crs is None: + continue + logging.debug(f"Applying CRS transform to surface {s.identifier}") + pts_arr = np.asarray(s.point_list, dtype=np.float64).reshape(-1, 3) + apply_from_crs_info(pts_arr, extract_crs_info(crs, workspace), inplace=True) + s.point_list = pts_arr.tolist() return surfaces else: # logging.error(f"Type {array_type_name} is not supported: function read_{snake_case(array_type_name)} not found") @@ -257,6 +249,7 @@ def read_mesh_object( def read_ijk_grid_representation( energyml_object: Any, workspace: EnergymlStorageInterface, + use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[Any]: raise NotSupportedError("IJKGrid representation reading is not supported yet.") @@ -265,6 +258,7 @@ def read_ijk_grid_representation( def read_point_representation( energyml_object: Any, workspace: EnergymlStorageInterface, + use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[PointSetMesh]: # pt_geoms = search_attribute_matching_type(point_set, "AbstractGeometry") @@ -313,6 +307,13 @@ def read_point_representation( # else: # total_size = total_size + len(points) + # Apply full CRS transform per patch; crs_object kept on mesh for reference + # but the outer dispatcher is guarded to skip crs_displacement for this type. + if use_crs_displacement and crs is not None and points is not None and len(points) > 0: + pts_arr = np.asarray(points, dtype=np.float64).reshape(-1, 3) + apply_from_crs_info(pts_arr, extract_crs_info(crs, workspace), inplace=True) + points = pts_arr.tolist() + if points is not None: meshes.append( PointSetMesh( @@ -331,6 +332,7 @@ def read_point_representation( def read_polyline_representation( energyml_object: Any, workspace: EnergymlStorageInterface, + use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[PolylineSetMesh]: # pt_geoms = search_attribute_matching_type(point_set, "AbstractGeometry") @@ -430,6 +432,13 @@ def read_polyline_representation( else: total_size = total_size + len(point_indices) + # Apply full CRS transform per patch; crs_object kept on mesh for reference + # but the outer dispatcher is guarded to skip crs_displacement for this type. + if use_crs_displacement and crs is not None and len(points) > 0: + pts_arr = np.asarray(points, dtype=np.float64).reshape(-1, 3) + apply_from_crs_info(pts_arr, extract_crs_info(crs, workspace), inplace=True) + points = pts_arr.tolist() + if len(points) > 0: meshes.append( PolylineSetMesh( @@ -551,7 +560,8 @@ def gen_surface_grid_geometry( def read_grid2d_representation( energyml_object: Any, workspace: Optional[EnergymlStorageInterface] = None, - keep_holes=False, + use_crs_displacement: bool = True, + keep_holes: bool = False, sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[SurfaceMesh]: # h5_reader = HDF5FileReader() @@ -565,6 +575,8 @@ def read_grid2d_representation( # Resqml 201 for patch_path, patch in search_attribute_matching_name_with_path(energyml_object, "Grid2dPatch"): + logging.debug("Trying to read Grid2d representation with Resqml 2.0.1 schema (Grid2dPatch)") + logging.debug(f" > {get_obj_uri(energyml_object)}Found patch at path {patch_path} with object {patch}") crs = None try: crs = get_crs_obj( @@ -601,6 +613,7 @@ def read_grid2d_representation( # Resqml 22 if hasattr(energyml_object, "geometry"): + logging.debug("Trying to read Grid2d representation with Resqml 2.2 schema (geometry attribute on the representation)") crs = None try: crs = get_crs_obj( @@ -643,6 +656,7 @@ def read_grid2d_representation( def read_triangulated_set_representation( energyml_object: Any, workspace: EnergymlStorageInterface, + use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[SurfaceMesh]: meshes = [] @@ -684,6 +698,18 @@ def read_triangulated_set_representation( point_list = point_list + _array + # Apply full CRS transform (rotation + offsets + z-flip + axis-swap) per patch. + # Setting crs_object=None on the resulting mesh prevents the outer + # read_mesh_object dispatcher from calling crs_displacement() a second time. + logging.debug(f"Applying use_crs_displacement {use_crs_displacement} with crs {crs} on patch {patch_path} with {len(point_list)} points for triangulated set representation {get_obj_uri(energyml_object)}") + if use_crs_displacement and crs is not None and point_list: + logging.debug(f"Original points sample: {point_list[0:5]}") + pts_arr = np.asarray(point_list, dtype=np.float64).reshape(-1, 3) + crs_info = extract_crs_info(crs, workspace) + apply_from_crs_info(pts_arr, crs_info, inplace=True) + logging.debug(f"Transformed points sample: {pts_arr[0:5]}") + point_list = pts_arr.tolist() + triangles_list: List[List[int]] = [] for ( triangles_path, @@ -728,6 +754,7 @@ def read_triangulated_set_representation( def read_wellbore_frame_representation( energyml_object: Any, workspace: EnergymlStorageInterface, + use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[PolylineSetMesh]: """ @@ -789,6 +816,7 @@ def read_wellbore_frame_representation( meshes = read_wellbore_trajectory_representation( energyml_object=trajectory_obj, workspace=workspace, + use_crs_displacement=use_crs_displacement, sub_indices=sub_indices, wellbore_frame_mds=wellbore_frame_mds, ) @@ -807,6 +835,7 @@ def read_wellbore_frame_representation( def read_wellbore_trajectory_representation( energyml_object: Any, workspace: EnergymlStorageInterface, + use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, wellbore_frame_mds: Optional[Union[List[float], np.ndarray]] = None, step_meter: float = 5.0, @@ -819,7 +848,7 @@ def read_wellbore_trajectory_representation( mesh for obj in energyml_object for mesh in read_wellbore_trajectory_representation( - obj, workspace, sub_indices, wellbore_frame_mds, step_meter + obj, workspace, use_crs_displacement, sub_indices, wellbore_frame_mds, step_meter ) ] @@ -882,22 +911,17 @@ def read_wellbore_trajectory_representation( f"wellbore mds : {wellbore_frame_mds}\n\tCRs : {crs}\n\thead x,y,z : {head_x}, {head_y}, {head_z}\n\tz increasing downward : {z_increasing_downward}" ) try: - x_offset, y_offset, z_offset, (azimuth, azimuth_uom) = get_crs_offsets_and_angle(crs, workspace) + crs_info = extract_crs_info(crs, workspace) # Try to read parametric Geometry from the trajectory. traj_mds, traj_points, traj_tangents = read_parametric_geometry( getattr(energyml_object, "geometry", None), workspace ) well_points = get_wellbore_points(wellbore_frame_mds, traj_mds, traj_points, traj_tangents, step_meter) - - well_points = apply_crs_transform( - well_points, - x_offset=x_offset, - y_offset=y_offset, - z_offset=z_offset, - z_is_up=not z_increasing_downward, - areal_rotation=azimuth, - rotation_uom=azimuth_uom, - ) + if use_crs_displacement: + well_points = apply_from_crs_info( + np.asarray(well_points, dtype=np.float64), + crs_info, + ) except Exception as e: if wellbore_frame_mds is not None: logging.debug(f"Could not read parametric geometry from trajectory. Well is interpreted as vertical: {e}") @@ -932,6 +956,7 @@ def read_wellbore_trajectory_representation( def read_sub_representation( energyml_object: Any, workspace: EnergymlStorageInterface, + use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[AbstractMesh]: supporting_rep_dor = search_attribute_matching_name( @@ -976,6 +1001,7 @@ def read_sub_representation( meshes = read_mesh_object( energyml_object=supporting_rep, workspace=workspace, + use_crs_displacement=use_crs_displacement, sub_indices=all_indices, ) diff --git a/energyml-utils/src/energyml/utils/data/mesh_numpy.py b/energyml-utils/src/energyml/utils/data/mesh_numpy.py index 76b7453..b146cbf 100644 --- a/energyml-utils/src/energyml/utils/data/mesh_numpy.py +++ b/energyml-utils/src/energyml/utils/data/mesh_numpy.py @@ -50,12 +50,14 @@ get_crs_offsets_and_angle, get_crs_obj, get_crs_origin_offset, + get_datum_information, is_z_reversed, read_array, read_grid2d_patch, read_parametric_geometry, get_wellbore_points, ) +from .crs import extract_crs_info, apply_from_crs_info from energyml.utils.exception import NotSupportedError, ObjectNotFoundNotError from energyml.utils.introspection import ( get_obj_uri, @@ -189,8 +191,9 @@ def crs_displacement_np( ) -> np.ndarray: """Apply CRS origin offset and optional Z-axis inversion to *points*. - Unlike :func:`mesh.crs_displacement`, this function operates on an - ``(N, 3)`` numpy array using broadcast arithmetic — no Python-level loops. + Operates on an ``(N, 3)`` numpy array using broadcast arithmetic — no + Python-level loops. Prefer :func:`apply_from_crs_info` for full CRS + transforms (rotation, axis-order swap, etc.). Args: points: Shape ``(N, 3)``, dtype ``float64``. Modified in-place when @@ -359,6 +362,7 @@ def get_numpy_reader_function(mesh_type_name: str) -> Optional[Callable]: def read_numpy_point_representation( energyml_object: Any, workspace: Optional[EnergymlStorageInterface] = None, + use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[NumpyPointSetMesh]: """Read a ``PointRepresentation`` / ``PointSetRepresentation``.""" @@ -393,6 +397,11 @@ def read_numpy_point_representation( points = points[t_idx[mask]] total_size += len(points) + # Apply full CRS transform per patch; crs_object kept for reference, + # outer dispatcher is guarded to skip crs_displacement_np for this type. + if use_crs_displacement and crs is not None and len(points) > 0: + apply_from_crs_info(points, extract_crs_info(crs, workspace), inplace=True) + meshes.append( NumpyPointSetMesh( identifier=f"Patch num {patch_idx}", @@ -409,6 +418,7 @@ def read_numpy_point_representation( def read_numpy_polyline_representation( energyml_object: Any, workspace: Optional[EnergymlStorageInterface] = None, + use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[NumpyPolylineMesh]: """Read a ``PolylineRepresentation`` / ``PolylineSetRepresentation``.""" @@ -498,6 +508,11 @@ def read_numpy_polyline_representation( else: total_size += 1 # at least one polyline + # Apply full CRS transform per patch; crs_object kept for reference, + # outer dispatcher is guarded to skip crs_displacement_np for this type. + if use_crs_displacement and crs is not None and len(points) > 0: + apply_from_crs_info(points, extract_crs_info(crs, workspace), inplace=True) + if len(points) > 0: meshes.append( NumpyPolylineMesh( @@ -516,6 +531,7 @@ def read_numpy_polyline_representation( def read_numpy_triangulated_set_representation( energyml_object: Any, workspace: Optional[EnergymlStorageInterface] = None, + use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[NumpySurfaceMesh]: """Read a ``TriangulatedSetRepresentation`` as numpy-backed surface meshes. @@ -563,6 +579,13 @@ def read_numpy_triangulated_set_representation( continue points = np.concatenate(pts_parts, axis=0) # (N, 3) + # Apply full CRS transform (rotation + offsets + z-flip + axis-swap) per patch. + # Setting crs_object=None on the resulting mesh prevents the outer + # read_numpy_mesh_object dispatcher from calling crs_displacement_np() again. + if use_crs_displacement and crs is not None and len(points) > 0: + crs_info = extract_crs_info(crs, workspace) + apply_from_crs_info(points, crs_info, inplace=True) + # --- Triangles --- tri_parts: List[np.ndarray] = [] for tri_path, tri_obj in search_attribute_matching_name_with_path(patch, "Triangles"): @@ -606,6 +629,7 @@ def read_numpy_triangulated_set_representation( def read_numpy_grid2d_representation( energyml_object: Any, workspace: Optional[EnergymlStorageInterface] = None, + use_crs_displacement: bool = True, keep_holes: bool = False, sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[NumpySurfaceMesh]: @@ -750,6 +774,7 @@ def _process_patch(patch: Any, patch_path: str, crs: Any) -> Optional[NumpySurfa def read_numpy_wellbore_trajectory_representation( energyml_object: Any, workspace: Optional[EnergymlStorageInterface] = None, + use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, wellbore_frame_mds: Optional[Union[List[float], np.ndarray]] = None, step_meter: float = 5.0, @@ -763,7 +788,7 @@ def read_numpy_wellbore_trajectory_representation( mesh for obj in energyml_object for mesh in read_numpy_wellbore_trajectory_representation( - obj, workspace, sub_indices, wellbore_frame_mds, step_meter + obj, workspace, use_crs_displacement, sub_indices, wellbore_frame_mds, step_meter ) ] @@ -780,24 +805,40 @@ def read_numpy_wellbore_trajectory_representation( except Exception: logging.debug("Could not get CRS from trajectory geometry") - # MD datum / reference point + # MD datum / reference point (fixes always-at-origin bug) try: - x_offset, y_offset, z_offset, (azimuth, azimuth_uom) = get_crs_offsets_and_angle(crs, workspace) + md_datum_dor = None + try: + md_datum_dor = search_attribute_matching_name(obj=energyml_object, name_rgx=r"MdDatum")[0] + except IndexError: + try: + md_datum_dor = search_attribute_matching_name(obj=energyml_object, name_rgx=r"MdInterval.Datum")[0] + except IndexError: + pass + + if md_datum_dor is not None: + md_datum_identifier = get_obj_uri(md_datum_dor) + md_datum_obj = workspace.get_object(md_datum_identifier) if workspace else None + if md_datum_obj is not None: + head_x, head_y, head_z, z_increasing_downward, _, _, crs = get_datum_information( + md_datum_obj, workspace + ) + except Exception as e: + logging.debug(f"Could not resolve MdDatum from trajectory: {e}") + + try: + crs_info = extract_crs_info(crs, workspace) traj_mds, traj_points, traj_tangents = read_parametric_geometry( getattr(energyml_object, "geometry", None), workspace ) well_points_list = get_wellbore_points( wellbore_frame_mds, traj_mds, traj_points, traj_tangents, step_meter ) - well_points_list = apply_crs_transform( - well_points_list, - x_offset=x_offset, - y_offset=y_offset, - z_offset=z_offset, - z_is_up=not z_increasing_downward, - areal_rotation=azimuth, - rotation_uom=azimuth_uom, - ) + if use_crs_displacement: + well_points_list = apply_from_crs_info( + np.asarray(well_points_list, dtype=np.float64), + crs_info, + ) except Exception as e: if wellbore_frame_mds is not None: logging.debug(f"Trajectory parametric geometry unavailable, treating as vertical: {e}") @@ -837,6 +878,7 @@ def read_numpy_wellbore_trajectory_representation( def read_numpy_wellbore_frame_representation( energyml_object: Any, workspace: Optional[EnergymlStorageInterface] = None, + use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[NumpyPolylineMesh]: """Read a ``WellboreFrameRepresentation`` as a numpy polyline mesh.""" @@ -875,6 +917,7 @@ def read_numpy_wellbore_frame_representation( meshes = read_numpy_wellbore_trajectory_representation( energyml_object=trajectory_obj, workspace=workspace, + use_crs_displacement=use_crs_displacement, sub_indices=sub_indices, wellbore_frame_mds=wellbore_frame_mds, ) @@ -886,6 +929,7 @@ def read_numpy_wellbore_frame_representation( def read_numpy_sub_representation( energyml_object: Any, workspace: Optional[EnergymlStorageInterface] = None, + use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> List[NumpyMesh]: """Delegate to the supporting representation with filtered indices.""" @@ -922,6 +966,7 @@ def read_numpy_sub_representation( meshes = read_numpy_mesh_object( energyml_object=supporting_rep, workspace=workspace, + use_crs_displacement=use_crs_displacement, sub_indices=all_indices.tolist() if all_indices is not None else None, ) for m in meshes: @@ -1028,9 +1073,19 @@ def read_numpy_mesh_object( energyml_object=energyml_object, workspace=workspace, sub_indices=sub_indices, + use_crs_displacement=use_crs_displacement, ) - if use_crs_displacement and "wellbore" not in type_name.lower(): + _tn = type_name.lower() + if ( + use_crs_displacement + and "wellbore" not in _tn + and "triangulated" not in _tn # per-patch CRS applied inside reader + and "point" not in _tn # per-patch CRS applied inside reader + and "polyline" not in _tn # per-patch CRS applied inside reader + and "representationset" not in _tn # each sub-mesh already had CRS applied by its own reader + and "subrepresentation" not in _tn # delegates entirely to inner read_numpy_mesh_object call + ): for m in meshes: crs = ( m.crs_object[0] diff --git a/energyml-utils/src/energyml/utils/introspection.py b/energyml-utils/src/energyml/utils/introspection.py index 8e1c9e3..e3588bb 100644 --- a/energyml-utils/src/energyml/utils/introspection.py +++ b/energyml-utils/src/energyml/utils/introspection.py @@ -760,15 +760,36 @@ def get_object_attribute_rgx(obj: Any, attr_dot_path_rgx: str) -> Any: # unescape Dot current_attrib_name = current_attrib_name.replace("\\.", ".") + if isinstance(obj, list): + # current_attrib may be a regex for list index. + # first, test if it's a simple int + # print("TRY INDEX", current_attrib_name, obj) + try: + idx = int(current_attrib_name) + if idx < len(obj) and idx >= 0: + return obj[idx] + else: + raise AttributeError(obj, name=current_attrib_name) + except ValueError: + accumulator = [] + for i in range(len(obj)): + if re.match(current_attrib_name, str(i)): + accumulator.append(obj[i]) + # print("ACCUMULATOR", accumulator) + if accumulator: + if len(attrib_list) > 1: + return [get_object_attribute_rgx(v, attr_dot_path_rgx[len(current_attrib_name) + 1 :]) for v in accumulator] + else: + return accumulator + else: + real_attrib_name = get_matching_class_attribute_name(obj, current_attrib_name) + if real_attrib_name is not None: + value = get_object_attribute_no_verif(obj, real_attrib_name) - real_attrib_name = get_matching_class_attribute_name(obj, current_attrib_name) - if real_attrib_name is not None: - value = get_object_attribute_no_verif(obj, real_attrib_name) - - if len(attrib_list) > 1: - return get_object_attribute_rgx(value, attr_dot_path_rgx[len(current_attrib_name) + 1 :]) - else: - return value + if len(attrib_list) > 1: + return get_object_attribute_rgx(value, attr_dot_path_rgx[len(current_attrib_name) + 1 :]) + else: + return value return None diff --git a/energyml-utils/tests/test_crs_info.py b/energyml-utils/tests/test_crs_info.py index 0c82210..449ded6 100644 --- a/energyml-utils/tests/test_crs_info.py +++ b/energyml-utils/tests/test_crs_info.py @@ -130,7 +130,7 @@ def test_as_transform_args(self): assert kwargs["z_offset"] == -50.0 assert kwargs["areal_rotation"] == 0.5 assert kwargs["rotation_uom"] == "rad" - assert kwargs["z_is_up"] is False # z_increasing_downward=True → z_is_up=False + assert kwargs["z_is_up"] is True # z_increasing_downward=True → z_is_up=True (negate to z-up output) def test_none_returns_default(self): info = extract_crs_info(None) @@ -161,7 +161,11 @@ def test_empty_after_split_returns_none(self): class TestV201LocalTime3DCrs: """ LocalTime3DCrs uuid=dbd637d5-4528-4145-908b-5f7136824f6d - xoffset=1.0 yoffset=0.1 zoffset=15.0 projected_uom=M z_down=False + xoffset=1.0 yoffset=0.1 zoffset=15.0 projected_uom=M z_down=True + + ZIncreasingDownward=true in the raw file. The linked VerticalCrs is an + inline ``VerticalUnknownCrs`` placeholder that carries no direction field, + so the sentinel (None) correctly leaves the top-level value unchanged. """ UUID = "dbd637d5-4528-4145-908b-5f7136824f6d" @@ -189,7 +193,9 @@ def test_vertical_uom(self, info): assert info.vertical_uom.lower() == "m" def test_z_increasing_downward(self, info): - assert info.z_increasing_downward is False + # ZIncreasingDownward=true in the raw file; VerticalUnknownCrs has no + # direction field so the sentinel leaves the parent value unchanged. + assert info.z_increasing_downward is True def test_no_epsg(self, info): assert info.projected_epsg_code is None @@ -202,7 +208,11 @@ def test_rotation_zero(self, info): class TestV201LocalDepth3DCrs: """ LocalDepth3DCrs uuid=0ae56ef3-fc79-405b-8deb-6942e0f2e77c - projected_epsg=23031 projected_uom=M z_down=False offsets all zero + projected_epsg=23031 projected_uom=M z_down=True offsets all zero + + ZIncreasingDownward=true in the raw file. The linked VerticalCrs is an + inline ``VerticalUnknownCrs`` placeholder that carries no direction field, + so the sentinel (None) correctly leaves the top-level value unchanged. """ UUID = "0ae56ef3-fc79-405b-8deb-6942e0f2e77c" @@ -223,8 +233,9 @@ def test_projected_uom(self, info): assert info.projected_uom.lower() == "m" def test_z_increasing_downward(self, info): - # The linked VerticalCrs has direction=up (or not set) in this fixture - assert info.z_increasing_downward is False + # ZIncreasingDownward=true in the raw file; VerticalUnknownCrs has no + # direction field so the sentinel leaves the parent value unchanged. + assert info.z_increasing_downward is True def test_offsets_zero(self, info): assert info.x_offset == pytest.approx(0.0) @@ -545,9 +556,11 @@ class TestDelegateFunctions: Uses LocalDepth3DCrs (0ae56ef3) and LocalTime3DCrs (dbd637d5) from epc20. """ - def test_is_z_reversed_depth_crs_false(self, epc20): + def test_is_z_reversed_depth_crs_true(self, epc20): + # LocalDepth3DCrs has ZIncreasingDownward=true; VerticalUnknownCrs sub-object + # carries no direction so the sentinel leaves the top-level value intact. crs = epc20.get_object_by_uuid("0ae56ef3-fc79-405b-8deb-6942e0f2e77c")[0] - assert is_z_reversed(crs) is False + assert is_z_reversed(crs) is True def test_is_z_reversed_compound_crs_true(self, epc20): # CompoundCrs 95330cec has z_increasing_downward=True @@ -593,3 +606,196 @@ def test_get_crs_offsets_and_angle_none(self): assert uom == "rad" +# --------------------------------------------------------------------------- +# Tests for apply_axis_order_swap +# --------------------------------------------------------------------------- + + +import numpy as np +from energyml.utils.data.crs import apply_axis_order_swap, apply_from_crs_info + + +class TestApplyAxisOrderSwap: + """Unit tests for :func:`apply_axis_order_swap`.""" + + def _pts(self) -> np.ndarray: + return np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float64) + + def test_none_axis_order_no_swap(self): + pts = self._pts() + result = apply_axis_order_swap(pts, None) + np.testing.assert_array_equal(result[:, 0], [1.0, 4.0]) + np.testing.assert_array_equal(result[:, 1], [2.0, 5.0]) + + def test_easting_northing_no_swap(self): + pts = self._pts() + result = apply_axis_order_swap(pts, "easting northing") + np.testing.assert_array_equal(result[:, 0], [1.0, 4.0]) + np.testing.assert_array_equal(result[:, 1], [2.0, 5.0]) + + def test_northing_easting_swaps_xy(self): + pts = self._pts() + result = apply_axis_order_swap(pts, "northing easting") + np.testing.assert_array_equal(result[:, 0], [2.0, 5.0]) + np.testing.assert_array_equal(result[:, 1], [1.0, 4.0]) + np.testing.assert_array_equal(result[:, 2], [3.0, 6.0]) + + def test_north_east_swaps_xy(self): + pts = self._pts() + result = apply_axis_order_swap(pts, "north east") + np.testing.assert_array_equal(result[:, 0], [2.0, 5.0]) + np.testing.assert_array_equal(result[:, 1], [1.0, 4.0]) + + def test_latitude_longitude_swaps_xy(self): + pts = self._pts() + result = apply_axis_order_swap(pts, "latitude longitude") + np.testing.assert_array_equal(result[:, 0], [2.0, 5.0]) + np.testing.assert_array_equal(result[:, 1], [1.0, 4.0]) + + def test_inplace_modification(self): + pts = self._pts() + original_id = id(pts) + result = apply_axis_order_swap(pts, "northing easting") + assert id(result) == original_id # same array object + + def test_z_column_untouched(self): + pts = self._pts() + apply_axis_order_swap(pts, "northing easting") + np.testing.assert_array_equal(pts[:, 2], [3.0, 6.0]) + + +# --------------------------------------------------------------------------- +# Tests for apply_from_crs_info +# --------------------------------------------------------------------------- + + +class TestApplyFromCrsInfo: + """Unit tests for :func:`apply_from_crs_info`.""" + + def _pts(self, x=1.0, y=2.0, z=3.0) -> np.ndarray: + return np.array([[x, y, z]], dtype=np.float64) + + # --- Translation ------------------------------------------------------- + + def test_translation_only(self): + # Translation is applied in the local z-down space, then Z is negated + # to produce z-up output (z_increasing_downward=True → flip). + info = CrsInfo(x_offset=10.0, y_offset=20.0, z_offset=5.0, z_increasing_downward=True) + pts = self._pts(1.0, 2.0, 3.0) + result = apply_from_crs_info(pts, info) + assert result[0, 0] == pytest.approx(11.0) + assert result[0, 1] == pytest.approx(22.0) + assert result[0, 2] == pytest.approx(-8.0) # 3+5=8, then flipped to z-up + + # --- Z-flip ------------------------------------------------------------ + + def test_no_z_flip_when_z_up_input(self): + """z_increasing_downward=False means the input is already z-up: no flip needed.""" + info = CrsInfo(z_increasing_downward=False) + pts = self._pts(0.0, 0.0, 5.0) + result = apply_from_crs_info(pts, info) + assert result[0, 2] == pytest.approx(5.0) + + def test_z_flip_when_z_down_input(self): + """z_increasing_downward=True means input is depth-positive: negate to z-up output.""" + info = CrsInfo(z_increasing_downward=True) + pts = self._pts(0.0, 0.0, 5.0) + result = apply_from_crs_info(pts, info) + assert result[0, 2] == pytest.approx(-5.0) + + # --- Clockwise rotation ------------------------------------------------ + + def test_rotation_90_degrees_cw(self): + """90° CW rotation: (1, 0) → (0, -1) [y' = -x·sin + y·cos].""" + info = CrsInfo(areal_rotation_value=90.0, areal_rotation_uom="degr", z_increasing_downward=True) + pts = self._pts(1.0, 0.0, 0.0) + result = apply_from_crs_info(pts, info) + # CW 90°: x' = x·cos(90) + y·sin(90) = 0 + 0 = 0 + # y' = -x·sin(90) + y·cos(90) = -1 + 0 = -1 + assert result[0, 0] == pytest.approx(0.0, abs=1e-10) + assert result[0, 1] == pytest.approx(-1.0, abs=1e-10) + + def test_rotation_45_degrees_cw(self): + """45° CW rotation of (1, 1) → (√2, 0) in depth z convention.""" + info = CrsInfo(areal_rotation_value=45.0, areal_rotation_uom="degr", z_increasing_downward=True) + pts = self._pts(1.0, 1.0, 0.0) + result = apply_from_crs_info(pts, info) + sqrt2 = math.sqrt(2.0) + assert result[0, 0] == pytest.approx(sqrt2, abs=1e-10) + assert result[0, 1] == pytest.approx(0.0, abs=1e-10) + + def test_zero_rotation_no_change(self): + info = CrsInfo(areal_rotation_value=0.0, z_increasing_downward=True) + pts = self._pts(3.0, 4.0, 0.0) + result = apply_from_crs_info(pts, info) + assert result[0, 0] == pytest.approx(3.0) + assert result[0, 1] == pytest.approx(4.0) + + # --- Axis-order swap --------------------------------------------------- + + def test_northing_first_axis_order_swaps_xy(self): + info = CrsInfo(projected_axis_order="northing easting", z_increasing_downward=True) + pts = self._pts(10.0, 20.0, 0.0) + result = apply_from_crs_info(pts, info) + assert result[0, 0] == pytest.approx(20.0) + assert result[0, 1] == pytest.approx(10.0) + + # --- inplace=False ---------------------------------------------------- + + def test_inplace_false_returns_copy(self): + info = CrsInfo(x_offset=5.0, z_increasing_downward=True) + pts = self._pts(1.0, 2.0, 3.0) + original = pts.copy() + result = apply_from_crs_info(pts, info, inplace=False) + # Original must be unchanged + np.testing.assert_array_equal(pts, original) + # Result must be translated + assert result[0, 0] == pytest.approx(6.0) + + # --- AzimuthReference warning ----------------------------------------- + + def test_true_north_azimuth_reference_warns(self, caplog): + import logging + info = CrsInfo(azimuth_reference="true north", z_increasing_downward=True) + pts = self._pts() + with caplog.at_level(logging.WARNING, logger="energyml.utils.data.crs"): + apply_from_crs_info(pts, info) + assert any("true north" in r.message.lower() for r in caplog.records) + + def test_magnetic_north_azimuth_reference_warns(self, caplog): + import logging + info = CrsInfo(azimuth_reference="magnetic north", z_increasing_downward=True) + pts = self._pts() + with caplog.at_level(logging.WARNING, logger="energyml.utils.data.crs"): + apply_from_crs_info(pts, info) + assert any("magnetic north" in r.message.lower() for r in caplog.records) + + def test_grid_north_no_warning(self, caplog): + import logging + info = CrsInfo(azimuth_reference="grid north", z_increasing_downward=True) + pts = self._pts() + with caplog.at_level(logging.WARNING, logger="energyml.utils.data.crs"): + apply_from_crs_info(pts, info) + assert not any("north" in r.message.lower() for r in caplog.records) + + # --- Full pipeline order verification --------------------------------- + + def test_pipeline_order_rotation_then_translation(self): + """Rotation must be applied BEFORE translation. + + Rotate (0, 1) by 90° CW → (1, 0), then translate by (10, 0): + result should be (11, 0), NOT (0, -1 + 10) = (0, 9). + """ + info = CrsInfo( + areal_rotation_value=90.0, + areal_rotation_uom="degr", + x_offset=10.0, + z_increasing_downward=True, + ) + pts = np.array([[0.0, 1.0, 0.0]], dtype=np.float64) + result = apply_from_crs_info(pts, info) + # CW 90°: x' = 0*cos90 + 1*sin90 = 1, then +10 → 11 + # y' = -0*sin90 + 1*cos90 = 0, then +0 → 0 + assert result[0, 0] == pytest.approx(11.0, abs=1e-10) + assert result[0, 1] == pytest.approx(0.0, abs=1e-10) + From 355349cc303111adebc1b0d4bb0ae003611f74e2 Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Thu, 5 Mar 2026 16:07:51 +0100 Subject: [PATCH 67/70] representation context + colors --- energyml-utils/example/attic/main.py | 2 +- energyml-utils/example/attic/main_data.py | 2 +- .../src/energyml/utils/data/__init__.py | 2 +- energyml-utils/src/energyml/utils/data/crs.py | 37 +- .../src/energyml/utils/data/datasets_io.py | 10 +- .../src/energyml/utils/data/export.py | 10 +- .../src/energyml/utils/data/helper.py | 480 +++++++++++++++++- .../src/energyml/utils/data/mesh.py | 52 +- .../src/energyml/utils/data/mesh_numpy.py | 82 ++- energyml-utils/src/energyml/utils/epc.py | 10 +- .../src/energyml/utils/epc_stream.py | 5 +- .../src/energyml/utils/epc_validator.py | 4 +- .../src/energyml/utils/introspection.py | 13 +- .../energyml/utils/representation_context.py | 281 ++++++++++ .../src/energyml/utils/serialization.py | 6 +- .../src/energyml/utils/storage_interface.py | 12 + energyml-utils/src/energyml/utils/uri.py | 2 +- .../src/energyml/utils/validation.py | 7 +- .../energyml/utils/{xml.py => xml_utils.py} | 2 +- energyml-utils/tests/test_xml.py | 2 +- 20 files changed, 860 insertions(+), 161 deletions(-) create mode 100644 energyml-utils/src/energyml/utils/representation_context.py rename energyml-utils/src/energyml/utils/{xml.py => xml_utils.py} (97%) diff --git a/energyml-utils/example/attic/main.py b/energyml-utils/example/attic/main.py index 4313ed5..d379d40 100644 --- a/energyml-utils/example/attic/main.py +++ b/energyml-utils/example/attic/main.py @@ -91,7 +91,7 @@ validate_epc, correct_dor, ) -from energyml.utils.xml import ( +from energyml.utils.xml_utils import ( find_schema_version_in_element, get_class_name_from_xml, get_root_namespace, diff --git a/energyml-utils/example/attic/main_data.py b/energyml-utils/example/attic/main_data.py index 8cc3e33..10ad492 100644 --- a/energyml-utils/example/attic/main_data.py +++ b/energyml-utils/example/attic/main_data.py @@ -39,7 +39,7 @@ read_energyml_xml_tree, ) from energyml.utils.validation import validate_epc -from energyml.utils.xml import get_tree +from energyml.utils.xml_utils import get_tree from energyml.utils.data.datasets_io import ( HDF5FileReader, get_path_in_external_with_path, diff --git a/energyml-utils/src/energyml/utils/data/__init__.py b/energyml-utils/src/energyml/utils/data/__init__.py index e2611b6..4a82506 100644 --- a/energyml-utils/src/energyml/utils/data/__init__.py +++ b/energyml-utils/src/energyml/utils/data/__init__.py @@ -6,4 +6,4 @@ Contains functions to help the read of specific entities like Grid2DRepresentation, TriangulatedSetRepresentation etc. It also contains functions to export data into OFF/OBJ format. """ -from .crs import CrsInfo, extract_crs_info, apply_from_crs_info, apply_axis_order_swap # noqa: F401 +from energyml.utils.data.crs import CrsInfo, extract_crs_info, apply_from_crs_info, apply_axis_order_swap # noqa: F401 diff --git a/energyml-utils/src/energyml/utils/data/crs.py b/energyml-utils/src/energyml/utils/data/crs.py index a79650a..3f5a9ec 100644 --- a/energyml-utils/src/energyml/utils/data/crs.py +++ b/energyml-utils/src/energyml/utils/data/crs.py @@ -181,7 +181,7 @@ def _resolve_dor( **and** the object has a ``uuid``/``uid`` attribute (i.e. it is a pointer, not a value type). """ - + if obj is None or workspace is None: return obj type_lower = type(obj).__name__.lower() @@ -321,7 +321,9 @@ def _extract_vertical_crs_details(vertical_crs_obj: Any) -> dict: **must not** override a parent-level ``ZIncreasingDownward`` when this value is ``None``. """ - logging.debug(f"Extracting vertical CRS details from object of type {type(vertical_crs_obj).__name__} with URI {get_obj_uri(vertical_crs_obj)}") + logging.debug( + f"Extracting vertical CRS details from object of type {type(vertical_crs_obj).__name__} with URI {get_obj_uri(vertical_crs_obj)}" + ) result: dict = { "epsg_code": None, "wkt": None, @@ -437,14 +439,10 @@ def _from_abstract_local3dcrs( z_increasing_downward = str(zid_raw).lower() in ("true", "1", "yes") # --- Projected UOM ----------------------------------------------------- - projected_uom: Optional[str] = _uom_to_str( - get_object_attribute_no_verif(crs_obj, "projected_uom") - ) + projected_uom: Optional[str] = _uom_to_str(get_object_attribute_no_verif(crs_obj, "projected_uom")) # --- Vertical UOM (length or time) ------------------------------------ - vertical_uom: Optional[str] = _uom_to_str( - get_object_attribute_no_verif(crs_obj, "vertical_uom") - ) + vertical_uom: Optional[str] = _uom_to_str(get_object_attribute_no_verif(crs_obj, "vertical_uom")) if vertical_uom is None: # time_uom only present on LocalTime3dCrs vertical_uom = _uom_to_str(getattr(crs_obj, "time_uom", None)) @@ -459,9 +457,7 @@ def _from_abstract_local3dcrs( projected_axis_order = ao.replace("_", " ").lower() # --- Projected CRS ----------------------------------------------------- - projected_crs_obj = _resolve_dor( - get_object_attribute_no_verif(crs_obj, "projected_crs"), workspace - ) + projected_crs_obj = _resolve_dor(get_object_attribute_no_verif(crs_obj, "projected_crs"), workspace) projected_details = _extract_projected_crs_details(projected_crs_obj) # Projected UOM from inline ProjectedCrs takes precedence if present @@ -471,20 +467,20 @@ def _from_abstract_local3dcrs( projected_axis_order = projected_details["axis_order"] # --- Vertical CRS ------------------------------------------------------ - vertical_crs_obj = _resolve_dor( - get_object_attribute_no_verif(crs_obj, "vertical_crs"), workspace - ) + vertical_crs_obj = _resolve_dor(get_object_attribute_no_verif(crs_obj, "vertical_crs"), workspace) vertical_details = _extract_vertical_crs_details(vertical_crs_obj) # Direction from VerticalCrs overrides the top-level ZIncreasingDownward # only when explicitly set. logging.debug("z_increasing_downward before vertical CRS details: %s", z_increasing_downward) - logging.debug(f"Vertical CRS details: {vertical_details} -- vertical_crs_obj type: {type(vertical_crs_obj).__name__ if vertical_crs_obj else 'None'}") + logging.debug( + f"Vertical CRS details: {vertical_details} -- vertical_crs_obj type: {type(vertical_crs_obj).__name__ if vertical_crs_obj else 'None'}" + ) if vertical_crs_obj is not None and vertical_details.get("z_increasing_downward") is not None: z_increasing_downward = vertical_details["z_increasing_downward"] if vertical_details.get("uom"): vertical_uom = vertical_details["uom"] - + logging.debug("z_increasing_downward after vertical CRS details: %s", z_increasing_downward) return CrsInfo( @@ -547,9 +543,7 @@ def _from_local_engineering2d_crs( azimuth_reference = ar.replace("_", " ").lower() # --- Horizontal UOM (HorizontalAxes.projected_uom or uom on ProjectedCrs) --- - projected_uom: Optional[str] = _uom_to_str( - get_object_attribute(crs_obj, "horizontal_axes.projected_uom") - ) + projected_uom: Optional[str] = _uom_to_str(get_object_attribute(crs_obj, "horizontal_axes.projected_uom")) # --- ProjectedCrs — may be an inline object OR a DOR ------------------ projected_crs_raw = get_object_attribute_no_verif(crs_obj, "origin_projected_crs") @@ -920,10 +914,7 @@ def extract_crs_info( # ------------------------------------------------------------------ # v2.0.1 types (LocalDepth3dCrs, LocalTime3dCrs, AbstractLocal3dCrs) # ------------------------------------------------------------------ - if any( - kw in type_name_lower - for kw in ("localdepth3dcrs", "localtime3dcrs", "abstractlocal3dcrs", "local3dcrs") - ): + if any(kw in type_name_lower for kw in ("localdepth3dcrs", "localtime3dcrs", "abstractlocal3dcrs", "local3dcrs")): return _from_abstract_local3dcrs(crs_obj, workspace) # ------------------------------------------------------------------ diff --git a/energyml-utils/src/energyml/utils/data/datasets_io.py b/energyml-utils/src/energyml/utils/data/datasets_io.py index d403595..7ba2834 100644 --- a/energyml-utils/src/energyml/utils/data/datasets_io.py +++ b/energyml-utils/src/energyml/utils/data/datasets_io.py @@ -158,7 +158,6 @@ def extract_h5_datasets( @dataclass class HDF5FileWriter: - def write_array( self, target: Union[str, BytesIO, bytes, "h5py.File"], @@ -209,7 +208,6 @@ def extract_h5_datasets( raise MissingExtraInstallation(extra_name="hdf5") class HDF5FileWriter: - def write_array( self, target: Union[str, BytesIO, bytes, Any], @@ -631,9 +629,7 @@ def read_external_dataset_array( for s in sources: try: if result_array is None: - result_array = read_dataset( - source=s, path_in_external_file=path_in_external, mimetype=mimetype - ) + result_array = read_dataset(source=s, path_in_external_file=path_in_external, mimetype=mimetype) else: # TODO: take care of the "Counts" and "Starts" list in ExternalDataArrayPart to fill array correctly result_array = result_array + read_dataset( @@ -908,9 +904,7 @@ def read_array_view( d_group = source[path_in_external_file] if start_indices is not None and counts is not None: # h5py reads only the required chunks/slabs from disk - slices = tuple( - slice(start, start + count) for start, count in zip(start_indices, counts) - ) + slices = tuple(slice(start, start + count) for start, count in zip(start_indices, counts)) return d_group[slices] # type: ignore # np.array with copy=False returns a view for contiguous datasets # Note: copy= kwarg on np.asarray requires numpy >=2.0; diff --git a/energyml-utils/src/energyml/utils/data/export.py b/energyml-utils/src/energyml/utils/data/export.py index 48d9681..e8b6726 100644 --- a/energyml-utils/src/energyml/utils/data/export.py +++ b/energyml-utils/src/energyml/utils/data/export.py @@ -14,7 +14,7 @@ import numpy as np if TYPE_CHECKING: - from .mesh import AbstractMesh + from energyml.utils.mesh import AbstractMesh class ExportFormat(Enum): @@ -97,7 +97,7 @@ def export_obj(mesh_list: List["AbstractMesh"], out: BinaryIO, obj_name: Optiona :param obj_name: Optional object name for the OBJ file """ # Lazy import to avoid circular dependency - from .mesh import PolylineSetMesh + from energyml.utils.mesh import PolylineSetMesh # Write header out.write(b"# Generated by energyml-utils a Geosiris python module\n\n") @@ -142,7 +142,7 @@ def export_geojson( :param options: GeoJSON export options """ # Lazy import to avoid circular dependency - from .mesh import PolylineSetMesh, SurfaceMesh + from energyml.utils.mesh import PolylineSetMesh, SurfaceMesh if options is None: options = GeoJSONExportOptions() @@ -195,7 +195,7 @@ def export_vtk(mesh_list: List["AbstractMesh"], out: BinaryIO, options: Optional :param options: VTK export options """ # Lazy import to avoid circular dependency - from .mesh import PolylineSetMesh, SurfaceMesh + from energyml.utils.mesh import PolylineSetMesh, SurfaceMesh if options is None: options = VTKExportOptions() @@ -260,7 +260,7 @@ def export_stl(mesh_list: List["AbstractMesh"], out: BinaryIO, options: Optional :param options: STL export options """ # Lazy import to avoid circular dependency - from .mesh import SurfaceMesh + from energyml.utils.mesh import SurfaceMesh if options is None: options = STLExportOptions(binary=True) diff --git a/energyml-utils/src/energyml/utils/data/helper.py b/energyml-utils/src/energyml/utils/data/helper.py index 0e26be1..f81d39e 100644 --- a/energyml-utils/src/energyml/utils/data/helper.py +++ b/energyml-utils/src/energyml/utils/data/helper.py @@ -8,9 +8,9 @@ from energyml.utils.storage_interface import EnergymlStorageInterface import numpy as np -from .datasets_io import read_external_dataset_array -from ..constants import flatten_concatenation, path_last_attribute, path_parent_attribute -from ..exception import ObjectNotFoundNotError +from energyml.utils.data.datasets_io import read_external_dataset_array, get_path_in_external_with_path +from energyml.utils.constants import flatten_concatenation, path_last_attribute, path_parent_attribute +from energyml.utils.exception import ObjectNotFoundNotError from energyml.utils.introspection import ( get_obj_uri, snake_case, @@ -27,8 +27,7 @@ get_obj_title, ) -from .datasets_io import get_path_in_external_with_path -from .crs import CrsInfo, extract_crs_info # noqa: F401 (re-exported for convenience) +from energyml.utils.data.crs import CrsInfo, extract_crs_info # noqa: F401 (re-exported for convenience) _ARRAY_NAMES_ = [ "BooleanArrayFromDiscretePropertyArray", @@ -1215,18 +1214,16 @@ def read_point3d_from_representation_lattice_array( if supporting_rep is None and workspace is not None: from energyml.utils.introspection import get_obj_uuid + candidates = workspace.get_object_by_uuid(get_obj_uuid(supporting_rep_dor)) supporting_rep = candidates[0] if candidates else None if supporting_rep is None: - raise Exception( - f"Supporting representation {supporting_rep_identifier} not found in workspace" - ) + raise Exception(f"Supporting representation {supporting_rep_identifier} not found in workspace") if "grid2d" not in str(type(supporting_rep)).lower(): raise Exception( - f"Unsupported supporting rep type {type(supporting_rep).__name__} " - f"for {type(energyml_array).__name__}" + f"Unsupported supporting rep type {type(supporting_rep).__name__} " f"for {type(energyml_array).__name__}" ) # ── 1. Read ALL points from the supporting representation ──────────────── @@ -1244,13 +1241,9 @@ def read_point3d_from_representation_lattice_array( ) else: # RESQML 2.2: geometry is directly on the representation - geom_points_matches = search_attribute_matching_name_with_path( - supporting_rep, "Geometry.Points" - ) + geom_points_matches = search_attribute_matching_name_with_path(supporting_rep, "Geometry.Points") if not geom_points_matches: - raise Exception( - f"Cannot find points in supporting rep {type(supporting_rep).__name__}" - ) + raise Exception(f"Cannot find points in supporting rep {type(supporting_rep).__name__}") geom_path, geom_points_obj = geom_points_matches[0] all_sup_points = read_array( energyml_array=geom_points_obj, @@ -1264,9 +1257,7 @@ def read_point3d_from_representation_lattice_array( all_sup_points = all_sup_points.reshape(-1, 3) # ── 2. Generate the node index list from the IntegerLatticeArray ───────── - node_idx_arr = get_object_attribute_no_verif( - energyml_array, "node_indices_on_supporting_representation" - ) + node_idx_arr = get_object_attribute_no_verif(energyml_array, "node_indices_on_supporting_representation") if node_idx_arr is None: node_idx_arr = get_object_attribute_rgx(energyml_array, "NodeIndices") @@ -1282,8 +1273,7 @@ def read_point3d_from_representation_lattice_array( else: # No index array: use all points in order (identity mapping) logging.debug( - "Point3DFromRepresentationLatticeArray: no NodeIndices found, " - "using all supporting rep points in order" + "Point3DFromRepresentationLatticeArray: no NodeIndices found, " "using all supporting rep points in order" ) result = all_sup_points @@ -1462,3 +1452,451 @@ def read_point3d_lattice_array( # workspace: Optional[EnergymlStorageInterface] = None # ): # logging.debug(energyml_array) + + +# ______ __ _ __ __ +# / ____/________ _____ / /_ (_)________ _/ / _________ / /___ __________ +# / / __/ ___/ __ `/ __ \/ __ \/ / ___/ __ `/ / / ___/ __ \/ / __ \/ ___/ ___/ +# / /_/ / / / /_/ / /_/ / / / / / /__/ /_/ / / / /__/ /_/ / / /_/ / / (__ ) +# \____/_/ \__,_/ .___/_/ /_/_/\___/\__,_/_/ \___/\____/_/\____/_/ /____/ +# /_/ + +# =========================== +# PyVista integration snippet +# =========================== + +# from energyml.utils.data.helper import ( +# read_graphical_rendering_info, read_property +# ) + +# # 1. Load objects +# gis = workspace.get_object(gis_uri) +# prop = workspace.get_object(prop_uri) +# prop_uuid = get_obj_uuid(prop) + +# # 2. Extract rendering info +# info = read_graphical_rendering_info(gis, prop_uuid, workspace) + +# # 3. Read scalar values +# scalars = read_array(prop.values_for_patch[0], root_obj=prop, workspace=workspace) + +# # 4. Build PyVista LUT +# import pyvista as pv +# if info and info.color_map: +# lut = pv.LookupTable() +# lut.values = info.color_map.to_vtk_lut() # (256,4) RGBA +# if info.color_min_max: +# lut.scalar_range = info.color_min_max +# mesh.plot(scalars=scalars, cmap=lut) +# elif info and info.constant_color: +# c = info.constant_color +# mesh.plot(color=(c.r, c.g, c.b), opacity=c.a) +# HsvColor: hue [0,360], saturation [0,1], value [0,1], alpha [0,1], title +# MinMax: minimum: float, maximum: float + +import colorsys +from dataclasses import dataclass, field as dc_field + + +# ───────────────────────────────────────────────────────────────────────────── +# Unified output data structures +# ───────────────────────────────────────────────────────────────────────────── + + +@dataclass +class RgbaColor: + """RGBA colour with channels in [0.0, 1.0].""" + + r: float + g: float + b: float + a: float = 1.0 + + def to_uint8(self) -> Tuple[int, int, int, int]: + """Return (R, G, B, A) in [0, 255] – ready for VTK / PyVista.""" + return ( + int(round(self.r * 255)), + int(round(self.g * 255)), + int(round(self.b * 255)), + int(round(self.a * 255)), + ) + + @staticmethod + def from_hsv(hsv_obj: Any) -> "RgbaColor": + """Convert a RESQML ``HsvColor`` to :class:`RgbaColor`.""" + h = (hsv_obj.hue or 0.0) / 360.0 # RESQML hue is [0, 360] + s = hsv_obj.saturation or 0.0 + v = hsv_obj.value or 0.0 + a = hsv_obj.alpha if hsv_obj.alpha is not None else 1.0 + r, g, b = colorsys.hsv_to_rgb(h, s, v) + return RgbaColor(r, g, b, a) + + @staticmethod + def random() -> "RgbaColor": + """Generate a random RGBA color (for testing).""" + import random + + return RgbaColor( + r=random.random(), + g=random.random(), + b=random.random(), + a=1.0, + ) + + @staticmethod + def random_from_uuid(uuid_str: str) -> "RgbaColor": + """Generate a random RGBA color based on a UUID string (for consistent testing).""" + import random + import hashlib + + # Create a hash of the UUID string to seed the random generator + hash_bytes = hashlib.sha256(uuid_str.encode()).digest() + seed = int.from_bytes(hash_bytes, 'big') + random.seed(seed) + + return RgbaColor( + r=random.random(), + g=random.random(), + b=random.random(), + a=1.0, + ) + + +@dataclass +class ColorMapEntry: + """One control point: a scalar index mapped to an RGBA colour.""" + + index: float # float for both continuous and discrete (int index cast to float) + color: RgbaColor + + +@dataclass +class ColorMapInfo: + """ + Unified representation of a RESQML color map, directly usable by PyVista/VTK. + + Covers both :class:`ContinuousColorMap` and :class:`DiscreteColorMap`. + + PyVista usage example:: + + info = read_color_map(my_continuous_color_map) + lut = pv.LookupTable() + lut.values = info.to_vtk_lut() # (256, 4) uint8 RGBA array + lut.scalar_range = (info.entries[0].index, info.entries[-1].index) + mesh.plot(scalars="my_property", cmap=lut) + """ + + is_continuous: bool + entries: List[ColorMapEntry] # sorted by ascending index + null_color: Optional[RgbaColor] = None + above_max_color: Optional[RgbaColor] = None + below_min_color: Optional[RgbaColor] = None + + def to_vtk_lut(self, n_colors: int = 256) -> np.ndarray: + """ + Return an ``(N, 4)`` uint8 RGBA array for use as a PyVista / VTK LUT. + + - For **continuous** maps: linearly interpolates the control-point + HSV colors over *n_colors* levels. + - For **discrete** maps: returns one row per entry (``n_colors`` + is ignored) so each integer index gets an exact color. + + :param n_colors: Number of samples for continuous maps (default 256). + :return: ``np.ndarray`` of shape ``(N, 4)``, dtype ``uint8``. + """ + if not self.entries: + return np.zeros((1, 4), dtype=np.uint8) + + sorted_entries = sorted(self.entries, key=lambda e: e.index) + + if not self.is_continuous: + # One exact row per integer entry – no interpolation needed. + return np.array( + [e.color.to_uint8() for e in sorted_entries], dtype=np.uint8 + ) + + # Continuous: sample n_colors levels with linear interpolation in RGBA. + indices = np.array([e.index for e in sorted_entries], dtype=np.float64) + float_colors = np.array( + [[e.color.r, e.color.g, e.color.b, e.color.a] for e in sorted_entries], + dtype=np.float64, + ) + t = np.linspace(indices[0], indices[-1], n_colors) + result = np.zeros((n_colors, 4), dtype=np.uint8) + for ch in range(4): + result[:, ch] = np.clip( + np.interp(t, indices, float_colors[:, ch]) * 255, 0, 255 + ).round().astype(np.uint8) + return result + + def scalar_range(self) -> Tuple[float, float]: + """Return ``(min_index, max_index)`` of the stored entries.""" + if not self.entries: + return (0.0, 1.0) + indices = [e.index for e in self.entries] + return (min(indices), max(indices)) + + +@dataclass +class ScalarRenderingInfo: + """ + All graphical rendering parameters needed to display a RESQML property or + representation in a 3D viewer (PyVista, VTK, etc.). + + Produced by :func:`read_graphical_rendering_info`. + + Typical PyVista workflow:: + + info = read_graphical_rendering_info(gis, prop_uuid, workspace) + scalars = read_property(prop, workspace) # np.ndarray + if info and info.color_map: + lut = pv.LookupTable() + lut.values = info.color_map.to_vtk_lut() + if info.color_min_max: + lut.scalar_range = info.color_min_max + mesh.plot(scalars=scalars, cmap=lut) + """ + + target_obj_uuid: str + + # ── Colour mapping (from ColorInformation → ColorMap) ──────────────────── + color_map: Optional[ColorMapInfo] = None + color_min_max: Optional[Tuple[float, float]] = None # clamp range for the LUT + color_use_log: bool = False + color_use_reverse: bool = False + color_value_vector_index: Optional[int] = None # component for vector props + + # ── Alpha / opacity mapping (from AlphaInformation) ────────────────────── + # Piecewise: list of (property_value, opacity [0..1]) control points + alpha_control_points: Optional[List[Tuple[float, float]]] = None + alpha_min_max: Optional[Tuple[float, float]] = None + alpha_use_log: bool = False + alpha_overwrite_color_alpha: bool = False + + # ── Size mapping (from SizeInformation) ────────────────────────────────── + size_min_max: Optional[Tuple[float, float]] = None # (min_size, max_size) + size_use_log: bool = False + size_value_vector_index: Optional[int] = None + + # ── Visibility / constant style (from DefaultGraphicalInformation) ──────── + is_visible: bool = True + constant_color: Optional[RgbaColor] = None + constant_alpha: Optional[float] = None # [0..1] global opacity override + + # ── Contour lines (from ContourLineSetInformation) ──────────────────────── + contour_increment: Optional[float] = None + contour_show_major_every: Optional[int] = None + + +# ───────────────────────────────────────────────────────────────────────────── +# Color-map readers (Group 1 – both return ColorMapInfo) +# ───────────────────────────────────────────────────────────────────────────── + + +def _optional_rgba(hsv_obj: Optional[Any]) -> Optional[RgbaColor]: + """Convert an optional ``HsvColor`` to :class:`RgbaColor`, or ``None``.""" + return RgbaColor.from_hsv(hsv_obj) if hsv_obj is not None else None + + +def read_continuous_color_map(color_map_obj: Any) -> ColorMapInfo: + """ + Read a RESQML ``ContinuousColorMap`` into a :class:`ColorMapInfo`. + + **Input**: a ``ContinuousColorMap`` xsdata dataclass instance (e.g. from + ``workspace.get_object(uri)``). + + **Output**: :class:`ColorMapInfo` with ``is_continuous=True`` and entries + sorted ascending by ``index`` (a ``float``). The ``to_vtk_lut()`` method + produces a ``(256, 4)`` uint8 RGBA array directly usable by PyVista. + """ + entries = sorted( + [ + ColorMapEntry(index=float(e.index), color=RgbaColor.from_hsv(e.hsv)) + for e in (color_map_obj.entry or []) + if e.index is not None and e.hsv is not None + ], + key=lambda ce: ce.index, + ) + return ColorMapInfo( + is_continuous=True, + entries=entries, + null_color=_optional_rgba(getattr(color_map_obj, "null_color", None)), + above_max_color=_optional_rgba(getattr(color_map_obj, "above_max_color", None)), + below_min_color=_optional_rgba(getattr(color_map_obj, "below_min_color", None)), + ) + + +def read_discrete_color_map(color_map_obj: Any) -> ColorMapInfo: + """ + Read a RESQML ``DiscreteColorMap`` into a :class:`ColorMapInfo`. + + **Input**: a ``DiscreteColorMap`` xsdata dataclass instance. + + **Output**: :class:`ColorMapInfo` with ``is_continuous=False`` and one + entry per integer code. ``to_vtk_lut()`` returns exactly one RGBA row per + entry – suitable for VTK's categorical lookup table + (``vtkLookupTable.SetAnnotation`` workflow). + """ + entries = sorted( + [ + ColorMapEntry(index=float(e.index), color=RgbaColor.from_hsv(e.hsv)) + for e in (color_map_obj.entry or []) + if e.index is not None and e.hsv is not None + ], + key=lambda ce: ce.index, + ) + return ColorMapInfo( + is_continuous=False, + entries=entries, + null_color=_optional_rgba(getattr(color_map_obj, "null_color", None)), + above_max_color=_optional_rgba(getattr(color_map_obj, "above_max_color", None)), + below_min_color=_optional_rgba(getattr(color_map_obj, "below_min_color", None)), + ) + + +def read_color_map(color_map_obj: Any) -> Optional[ColorMapInfo]: + """ + Dispatch to :func:`read_continuous_color_map` or :func:`read_discrete_color_map` + based on the runtime type of *color_map_obj*. + + :param color_map_obj: Any RESQML color-map object (``ContinuousColorMap`` + or ``DiscreteColorMap`` from any EML/RESQML version). + :return: :class:`ColorMapInfo`, or ``None`` if the type is unrecognised. + """ + type_name = type(color_map_obj).__name__.lower() + if "continuous" in type_name: + return read_continuous_color_map(color_map_obj) + if "discrete" in type_name: + return read_discrete_color_map(color_map_obj) + logging.warning(f"read_color_map: unsupported color-map type '{type(color_map_obj).__name__}'") + return None + + +# ───────────────────────────────────────────────────────────────────────────── +# Main entry point (Group 2) +# ───────────────────────────────────────────────────────────────────────────── + + +def read_graphical_rendering_info( + graphical_information_set: Any, + target_uuid: str, + workspace: Optional[EnergymlStorageInterface] = None, +) -> Optional[ScalarRenderingInfo]: + """ + Extract all rendering parameters for a target object from a + ``GraphicalInformationSet``. + + **Input**: + + - *graphical_information_set*: a RESQML/EML ``GraphicalInformationSet`` + object (from ``workspace.get_object(uri)`` or similar). + - *target_uuid*: the UUID (string) of the property, representation, + feature or interpretation you want to render. + - *workspace*: an :class:`EnergymlStorageInterface` used to resolve the + ``ColorMap`` DOR inside ``ColorInformation``. Pass ``None`` if the + color map is not needed. + + **Output**: :class:`ScalarRenderingInfo`, or ``None`` if the GIS contains + no graphical information targeting *target_uuid*. + + Covers all standard RESQML v2.2 ``AbstractGraphicalInformation`` subtypes: + + +-------------------------------+-----------------------------------+ + | RESQML class | Populated fields | + +===============================+===================================+ + | ``ColorInformation`` | ``color_map``, ``color_min_max``, | + | | ``color_use_log``, ``color_use_`` | + | | ``reverse``, | + | | ``color_value_vector_index`` | + +-------------------------------+-----------------------------------+ + | ``AlphaInformation`` | ``alpha_control_points``, | + | | ``alpha_min_max``, | + | | ``alpha_use_log``, | + | | ``alpha_overwrite_color_alpha`` | + +-------------------------------+-----------------------------------+ + | ``SizeInformation`` | ``size_min_max``, | + | | ``size_use_log``, | + | | ``size_value_vector_index`` | + +-------------------------------+-----------------------------------+ + | ``DefaultGraphicalInform…`` | ``is_visible``, | + | | ``constant_color``, | + | | ``constant_alpha`` | + +-------------------------------+-----------------------------------+ + | ``ContourLineSetInform…`` | ``contour_increment``, | + | | ``contour_show_major_every`` | + +-------------------------------+-----------------------------------+ + """ + + result = ScalarRenderingInfo(target_obj_uuid=target_uuid) + found = False + + gis_infos: List[Any] = getattr(graphical_information_set, "graphical_information", []) or [] + + for info in gis_infos: + # Each AbstractGraphicalInformation targets ≥1 objects via target_object[]. + targets: List[Any] = getattr(info, "target_object", []) or [] + if not any(get_obj_uuid(t) == target_uuid for t in targets): + continue + found = True + + type_name = type(info).__name__ + + if "ColorInformation" in type_name: + result.color_use_log = bool(getattr(info, "use_logarithmic_mapping", False)) + result.color_use_reverse = bool(getattr(info, "use_reverse_mapping", False)) + result.color_value_vector_index = getattr(info, "value_vector_index", None) + mm = getattr(info, "min_max", None) + if mm is not None: + result.color_min_max = (mm.minimum, mm.maximum) + cmap_dor = getattr(info, "color_map", None) + if cmap_dor is not None and workspace is not None: + cmap_obj = workspace.get_object(get_obj_uri(cmap_dor)) + if cmap_obj is None: + candidates = workspace.get_object_by_uuid(get_obj_uuid(cmap_dor)) + cmap_obj = candidates[0] if candidates else None + if cmap_obj is not None: + result.color_map = read_color_map(cmap_obj) + + elif "AlphaInformation" in type_name: + result.alpha_use_log = bool(getattr(info, "use_logarithmic_mapping", False)) + result.alpha_overwrite_color_alpha = bool(getattr(info, "overwrite_color_alpha", False)) + mm = getattr(info, "min_max", None) + if mm is not None: + result.alpha_min_max = (mm.minimum, mm.maximum) + raw_indices = getattr(info, "index", []) or [] + raw_alphas = getattr(info, "alpha", []) or [] + if raw_indices and raw_alphas: + try: + result.alpha_control_points = [ + (float(idx), float(a)) + for idx, a in zip(raw_indices, raw_alphas) + ] + except (TypeError, ValueError) as exc: + logging.warning(f"read_graphical_rendering_info: cannot parse AlphaInformation indices: {exc}") + + elif "SizeInformation" in type_name: + result.size_use_log = bool(getattr(info, "use_logarithmic_mapping", False)) + result.size_value_vector_index = getattr(info, "value_vector_index", None) + mm = getattr(info, "min_max", None) + if mm is not None: + result.size_min_max = (mm.minimum, mm.maximum) + + elif "DefaultGraphicalInformation" in type_name: + for elem_info in (getattr(info, "indexable_element_info", []) or []): + if (getattr(elem_info, "is_visible", None)) is False: + result.is_visible = False + const_col = getattr(elem_info, "constant_color", None) + if const_col is not None: + result.constant_color = RgbaColor.from_hsv(const_col) + const_alpha = getattr(elem_info, "constant_alpha", None) + if const_alpha is not None: + result.constant_alpha = float(const_alpha) + + elif "ContourLineSetInformation" in type_name: + result.contour_increment = getattr(info, "increment", None) + result.contour_show_major_every = getattr(info, "show_major_line_every", None) + + # AnnotationInformation is intentionally not mapped to ScalarRenderingInfo + # because it drives label text, not colour/size – handle separately if needed. + + return result if found else None \ No newline at end of file diff --git a/energyml-utils/src/energyml/utils/data/mesh.py b/energyml-utils/src/energyml/utils/data/mesh.py index bde5413..cdb2b04 100644 --- a/energyml-utils/src/energyml/utils/data/mesh.py +++ b/energyml-utils/src/energyml/utils/data/mesh.py @@ -14,7 +14,7 @@ from typing import List, Optional, Any, Callable, Dict, Union, Tuple -from .helper import ( +from energyml.utils.data.helper import ( apply_crs_transform, generate_vertical_well_points, get_crs_offsets_and_angle, @@ -26,7 +26,7 @@ get_crs_obj, read_parametric_geometry, ) -from .crs import extract_crs_info, apply_from_crs_info +from energyml.utils.data.crs import extract_crs_info, apply_from_crs_info from energyml.utils.epc_utils import gen_energyml_object_path from energyml.utils.epc_stream import EpcStreamReader from energyml.utils.exception import NotSupportedError, ObjectNotFoundNotError @@ -42,7 +42,7 @@ # Import export functions from new export module for backward compatibility -from .export import export_obj as _export_obj_new +from energyml.utils.data.export import export_obj as _export_obj_new _FILE_HEADER: bytes = b"# file exported by energyml-utils python module (Geosiris)\n" @@ -213,25 +213,23 @@ def read_mesh_object( if reader_func is not None: # logging.info(f"using function {reader_func} to read type {array_type_name}") surfaces: List[AbstractMesh] = reader_func( - energyml_object=energyml_object, workspace=workspace, sub_indices=sub_indices, + energyml_object=energyml_object, + workspace=workspace, + sub_indices=sub_indices, use_crs_displacement=use_crs_displacement, ) _tn = array_type_name.lower() if ( use_crs_displacement and "wellbore" not in _tn - and "triangulated" not in _tn # per-patch CRS applied inside reader - and "point" not in _tn # per-patch CRS applied inside reader - and "polyline" not in _tn # per-patch CRS applied inside reader + and "triangulated" not in _tn # per-patch CRS applied inside reader + and "point" not in _tn # per-patch CRS applied inside reader + and "polyline" not in _tn # per-patch CRS applied inside reader and "representationset" not in _tn # each sub-mesh already had CRS applied by its own reader and "subrepresentation" not in _tn # delegates entirely to inner read_mesh_object call ): for s in surfaces: - crs = ( - s.crs_object[0] - if isinstance(s.crs_object, list) and s.crs_object - else s.crs_object - ) + crs = s.crs_object[0] if isinstance(s.crs_object, list) and s.crs_object else s.crs_object if crs is None: continue logging.debug(f"Applying CRS transform to surface {s.identifier}") @@ -376,10 +374,7 @@ def read_polyline_representation( close_poly = None try: - ( - close_poly_path, - close_poly_obj, - ) = search_attribute_matching_name_with_path( + (close_poly_path, close_poly_obj,) = search_attribute_matching_name_with_path( patch, "ClosedPolylines" )[0] close_poly = read_array( @@ -393,10 +388,7 @@ def read_polyline_representation( point_indices = [] try: - ( - node_count_per_poly_path_in_obj, - node_count_per_poly, - ) = search_attribute_matching_name_with_path( + (node_count_per_poly_path_in_obj, node_count_per_poly,) = search_attribute_matching_name_with_path( patch, "NodeCountPerPolyline" )[0] node_counts_list = read_array( @@ -613,7 +605,9 @@ def read_grid2d_representation( # Resqml 22 if hasattr(energyml_object, "geometry"): - logging.debug("Trying to read Grid2d representation with Resqml 2.2 schema (geometry attribute on the representation)") + logging.debug( + "Trying to read Grid2d representation with Resqml 2.2 schema (geometry attribute on the representation)" + ) crs = None try: crs = get_crs_obj( @@ -701,7 +695,9 @@ def read_triangulated_set_representation( # Apply full CRS transform (rotation + offsets + z-flip + axis-swap) per patch. # Setting crs_object=None on the resulting mesh prevents the outer # read_mesh_object dispatcher from calling crs_displacement() a second time. - logging.debug(f"Applying use_crs_displacement {use_crs_displacement} with crs {crs} on patch {patch_path} with {len(point_list)} points for triangulated set representation {get_obj_uri(energyml_object)}") + logging.debug( + f"Applying use_crs_displacement {use_crs_displacement} with crs {crs} on patch {patch_path} with {len(point_list)} points for triangulated set representation {get_obj_uri(energyml_object)}" + ) if use_crs_displacement and crs is not None and point_list: logging.debug(f"Original points sample: {point_list[0:5]}") pts_arr = np.asarray(point_list, dtype=np.float64).reshape(-1, 3) @@ -892,9 +888,15 @@ def read_wellbore_trajectory_representation( md_datum_obj = workspace.get_object(md_datum_identifier) if md_datum_obj is not None: - head_x, head_y, head_z, z_increasing_downward, projected_epsg_code, vertical_epsg_code, crs = ( - get_datum_information(md_datum_obj, workspace) - ) + ( + head_x, + head_y, + head_z, + z_increasing_downward, + projected_epsg_code, + vertical_epsg_code, + crs, + ) = get_datum_information(md_datum_obj, workspace) # if crs is None: # crs = get_crs_obj( # context_obj=md_datum_obj, diff --git a/energyml-utils/src/energyml/utils/data/mesh_numpy.py b/energyml-utils/src/energyml/utils/data/mesh_numpy.py index b146cbf..7cfb9f7 100644 --- a/energyml-utils/src/energyml/utils/data/mesh_numpy.py +++ b/energyml-utils/src/energyml/utils/data/mesh_numpy.py @@ -44,7 +44,7 @@ import numpy as np -from .helper import ( +from energyml.utils.data.helper import ( apply_crs_transform, generate_vertical_well_points, get_crs_offsets_and_angle, @@ -57,7 +57,7 @@ read_parametric_geometry, get_wellbore_points, ) -from .crs import extract_crs_info, apply_from_crs_info +from energyml.utils.data.crs import extract_crs_info, apply_from_crs_info from energyml.utils.exception import NotSupportedError, ObjectNotFoundNotError from energyml.utils.introspection import ( get_obj_uri, @@ -284,7 +284,7 @@ def _build_vtk_lines_from_segments(n_points: int) -> np.ndarray: if n_points < 2: return np.empty(0, dtype=np.int64) idx = np.arange(n_points - 1, dtype=np.int64) - pairs = np.column_stack([idx, idx + 1]) # (n-1, 2) + pairs = np.column_stack([idx, idx + 1]) # (n-1, 2) counts = np.full((n_points - 1, 1), 2, dtype=np.int64) return np.concatenate([counts, pairs], axis=1).ravel() @@ -371,10 +371,9 @@ def read_numpy_point_representation( patch_idx = 0 total_size = 0 - patches_geom = ( - search_attribute_matching_name_with_path(energyml_object, r"NodePatch.[\d]+.Geometry.Points") - + search_attribute_matching_name_with_path(energyml_object, r"NodePatchGeometry.[\d]+.Points") - ) + patches_geom = search_attribute_matching_name_with_path( + energyml_object, r"NodePatch.[\d]+.Geometry.Points" + ) + search_attribute_matching_name_with_path(energyml_object, r"NodePatchGeometry.[\d]+.Points") for points_path_in_obj, points_obj in patches_geom: raw = _read_array_np(points_obj, energyml_object, points_path_in_obj, ws) @@ -427,10 +426,9 @@ def read_numpy_polyline_representation( patch_idx = 0 total_size = 0 - for patch_path_in_obj, patch in ( - search_attribute_matching_name_with_path(energyml_object, "NodePatch") - + search_attribute_matching_name_with_path(energyml_object, r"LinePatch.[\d]+") - ): + for patch_path_in_obj, patch in search_attribute_matching_name_with_path( + energyml_object, "NodePatch" + ) + search_attribute_matching_name_with_path(energyml_object, r"LinePatch.[\d]+"): # --- Points --- pts_list = search_attribute_matching_name_with_path(patch, "Geometry.Points") if not pts_list: @@ -660,13 +658,11 @@ def _process_patch(patch: Any, patch_path: str, crs: Any) -> Optional[NumpySurfa pts = pts.reshape(-1, 3) # Grid dimensions - fa_count = ( - search_attribute_matching_name(patch, "FastestAxisCount") - or search_attribute_matching_name(energyml_object, "FastestAxisCount") + fa_count = search_attribute_matching_name(patch, "FastestAxisCount") or search_attribute_matching_name( + energyml_object, "FastestAxisCount" ) - sa_count = ( - search_attribute_matching_name(patch, "SlowestAxisCount") - or search_attribute_matching_name(energyml_object, "SlowestAxisCount") + sa_count = search_attribute_matching_name(patch, "SlowestAxisCount") or search_attribute_matching_name( + energyml_object, "SlowestAxisCount" ) if not fa_count or not sa_count: return None @@ -831,9 +827,7 @@ def read_numpy_wellbore_trajectory_representation( traj_mds, traj_points, traj_tangents = read_parametric_geometry( getattr(energyml_object, "geometry", None), workspace ) - well_points_list = get_wellbore_points( - wellbore_frame_mds, traj_mds, traj_points, traj_tangents, step_meter - ) + well_points_list = get_wellbore_points(wellbore_frame_mds, traj_mds, traj_points, traj_tangents, step_meter) if use_crs_displacement: well_points_list = apply_from_crs_info( np.asarray(well_points_list, dtype=np.float64), @@ -907,9 +901,7 @@ def read_numpy_wellbore_frame_representation( except AttributeError: pass - wellbore_frame_mds = wellbore_frame_mds[ - (wellbore_frame_mds >= md_min) & (wellbore_frame_mds <= md_max) - ] + wellbore_frame_mds = wellbore_frame_mds[(wellbore_frame_mds >= md_min) & (wellbore_frame_mds <= md_max)] trajectory_dor = search_attribute_matching_name(obj=energyml_object, name_rgx="Trajectory")[0] trajectory_obj = workspace.get_object(get_obj_uri(trajectory_dor)) @@ -941,19 +933,16 @@ def read_numpy_sub_representation( total_size = 0 all_indices: Optional[np.ndarray] = None - for patch_path, patch_indices in ( - search_attribute_matching_name_with_path( - obj=energyml_object, - name_rgx=r"SubRepresentationPatch.\d+.ElementIndices.\d+.Indices", - deep_search=False, - search_in_sub_obj=False, - ) - + search_attribute_matching_name_with_path( - obj=energyml_object, - name_rgx=r"SubRepresentationPatch.\d+.Indices", - deep_search=False, - search_in_sub_obj=False, - ) + for patch_path, patch_indices in search_attribute_matching_name_with_path( + obj=energyml_object, + name_rgx=r"SubRepresentationPatch.\d+.ElementIndices.\d+.Indices", + deep_search=False, + search_in_sub_obj=False, + ) + search_attribute_matching_name_with_path( + obj=energyml_object, + name_rgx=r"SubRepresentationPatch.\d+.Indices", + deep_search=False, + search_in_sub_obj=False, ): arr = _read_array_np(patch_indices, energyml_object, patch_path, ws).astype(np.int64).ravel() if sub_indices is not None and len(sub_indices) > 0: @@ -1080,18 +1069,14 @@ def read_numpy_mesh_object( if ( use_crs_displacement and "wellbore" not in _tn - and "triangulated" not in _tn # per-patch CRS applied inside reader - and "point" not in _tn # per-patch CRS applied inside reader - and "polyline" not in _tn # per-patch CRS applied inside reader + and "triangulated" not in _tn # per-patch CRS applied inside reader + and "point" not in _tn # per-patch CRS applied inside reader + and "polyline" not in _tn # per-patch CRS applied inside reader and "representationset" not in _tn # each sub-mesh already had CRS applied by its own reader and "subrepresentation" not in _tn # delegates entirely to inner read_numpy_mesh_object call ): for m in meshes: - crs = ( - m.crs_object[0] - if isinstance(m.crs_object, list) and m.crs_object - else m.crs_object - ) + crs = m.crs_object[0] if isinstance(m.crs_object, list) and m.crs_object else m.crs_object if crs is not None and len(m.points) > 0: crs_displacement_np(m.points, crs, inplace=True) @@ -1122,10 +1107,7 @@ def numpy_mesh_to_pyvista(mesh: NumpyMesh) -> Any: try: import pyvista as pv # type: ignore[import] except ImportError as exc: - raise ImportError( - "pyvista is not installed. " - "Install it with: pip install pyvista" - ) from exc + raise ImportError("pyvista is not installed. " "Install it with: pip install pyvista") from exc pts = mesh.points # (N, 3) float64 — no copy @@ -1139,9 +1121,7 @@ def numpy_mesh_to_pyvista(mesh: NumpyMesh) -> Any: return pv.PolyData(pts) # Generic fallback: just export points - logging.warning( - f"numpy_mesh_to_pyvista: unknown mesh type {type(mesh).__name__}, exporting points only." - ) + logging.warning(f"numpy_mesh_to_pyvista: unknown mesh type {type(mesh).__name__}, exporting points only.") return pv.PolyData(pts) diff --git a/energyml-utils/src/energyml/utils/epc.py b/energyml-utils/src/energyml/utils/epc.py index 2795b5d..c68dcd1 100644 --- a/energyml-utils/src/energyml/utils/epc.py +++ b/energyml-utils/src/energyml/utils/epc.py @@ -57,7 +57,7 @@ read_energyml_json_bytes, JSON_VERSION, ) -from energyml.utils.xml import is_energyml_content_type +from energyml.utils.xml_utils import is_energyml_content_type from energyml.utils.epc_utils import ( gen_core_props_path, gen_energyml_object_path, @@ -1590,7 +1590,7 @@ def read_file( """ Read an EPC file from disk. :param epc_file_path: Path to the EPC file - :param read_rels_from_files: If True, populate cache from .rels files in the EPC + :param read_rels_from_files: If True, populate cache from energyml.utils.rels files in the EPC :param recompute_rels: If True, recompute all relationships after loading :param read_parallel: If True, read the EPC file in parallel :return: Epc instance @@ -1622,7 +1622,7 @@ def read_stream( """ Read an EPC file from a BytesIO stream. :param epc_file_io: BytesIO containing the EPC file - :param read_rels_from_files: If True, populate cache from .rels files in the EPC + :param read_rels_from_files: If True, populate cache from energyml.utils.rels files in the EPC :param recompute_rels: If True, recompute all relationships after loading :return: an :class:`EPC` instance """ @@ -1958,7 +1958,7 @@ def read_stream_ultra_fast_v2( # Backward compatibility: re-export functions that were moved to epc_utils # This allows existing code that imports these functions from epc.py to continue working -from .epc_utils import ( +from energyml.utils.epc_utils import ( create_default_core_properties, create_default_types, create_external_relationship, @@ -1980,7 +1980,7 @@ def read_stream_ultra_fast_v2( ) # Also export the cache dict for backward compatibility -from .epc_utils import __CACHE_PROP_KIND_DICT__ +from energyml.utils.epc_utils import __CACHE_PROP_KIND_DICT__ __all__ = [ "Epc", diff --git a/energyml-utils/src/energyml/utils/epc_stream.py b/energyml-utils/src/energyml/utils/epc_stream.py index aa43aac..5d17dd0 100644 --- a/energyml-utils/src/energyml/utils/epc_stream.py +++ b/energyml-utils/src/energyml/utils/epc_stream.py @@ -88,7 +88,7 @@ ) from energyml.utils.serialization import read_energyml_xml_bytes, serialize_xml -from energyml.utils.xml import is_energyml_content_type +from energyml.utils.xml_utils import is_energyml_content_type def get_dor_identifiers_from_obj(obj: Any) -> Set[str]: @@ -771,7 +771,7 @@ class _RelationshipManager: Internal helper class for managing relationships between objects. This class handles: - - Reading relationships from .rels files + - Reading relationships from energyml.utils.rels files - Writing relationship updates - Supporting 3 update modes (UPDATE_AT_MODIFICATION, UPDATE_ON_CLOSE, MANUAL) - Preserving EXTERNAL_RESOURCE relationships @@ -1149,7 +1149,6 @@ def _write_rels_updates( class EpcStreamReader(EnergymlStorageInterface): - def __init__( self, epc_file_path: Union[str, Path], diff --git a/energyml-utils/src/energyml/utils/epc_validator.py b/energyml-utils/src/energyml/utils/epc_validator.py index 253d670..cae06d3 100644 --- a/energyml-utils/src/energyml/utils/epc_validator.py +++ b/energyml-utils/src/energyml/utils/epc_validator.py @@ -24,8 +24,8 @@ from xsdata.formats.dataclass.parsers import XmlParser from xsdata.exceptions import ParserError -from .constants import RELS_CONTENT_TYPE, EpcExportVersion -from .exception import ( +from energyml.utils.constants import RELS_CONTENT_TYPE, EpcExportVersion +from energyml.utils.exception import ( ContentTypeValidationError, CorePropertiesValidationError, EpcValidationError, diff --git a/energyml-utils/src/energyml/utils/introspection.py b/energyml-utils/src/energyml/utils/introspection.py index e3588bb..b2b0412 100644 --- a/energyml-utils/src/energyml/utils/introspection.py +++ b/energyml-utils/src/energyml/utils/introspection.py @@ -16,7 +16,7 @@ from types import ModuleType from typing import Any, List, Optional, Union, Dict, Tuple -from .constants import ( +from energyml.utils.constants import ( path_parent_attribute, primitives, epoch_to_date, @@ -28,7 +28,7 @@ path_next_attribute, OptimizedRegex, ) -from .manager import ( +from energyml.utils.manager import ( class_has_parent_with_name, get_class_pkg, get_class_pkg_version, @@ -38,8 +38,8 @@ dict_energyml_modules, reshape_version_from_regex_match, ) -from .uri import Uri, parse_uri -from .constants import parse_content_type, ENERGYML_NAMESPACES, parse_qualified_type +from energyml.utils.uri import Uri, parse_uri +from energyml.utils.constants import parse_content_type, ENERGYML_NAMESPACES, parse_qualified_type def is_enum(cls: Union[type, Any]): @@ -778,7 +778,10 @@ def get_object_attribute_rgx(obj: Any, attr_dot_path_rgx: str) -> Any: # print("ACCUMULATOR", accumulator) if accumulator: if len(attrib_list) > 1: - return [get_object_attribute_rgx(v, attr_dot_path_rgx[len(current_attrib_name) + 1 :]) for v in accumulator] + return [ + get_object_attribute_rgx(v, attr_dot_path_rgx[len(current_attrib_name) + 1 :]) + for v in accumulator + ] else: return accumulator else: diff --git a/energyml-utils/src/energyml/utils/representation_context.py b/energyml-utils/src/energyml/utils/representation_context.py new file mode 100644 index 0000000..322b86d --- /dev/null +++ b/energyml-utils/src/energyml/utils/representation_context.py @@ -0,0 +1,281 @@ +# Copyright (c) 2023-2024 Geosiris. +# SPDX-License-Identifier: Apache-2.0 + +import logging +from typing import Any, Dict, List, Optional +from energyml.opc.opc import Relationship +from pydantic import BaseModel, Field, ConfigDict + +from energyml.utils.uri import Uri +from energyml.utils.storage_interface import EnergymlStorageInterface +from energyml.utils.epc_utils import extract_uuid_and_version_from_obj_path +from energyml.utils.introspection import get_obj_uri, get_obj_uuid, search_attribute_matching_name +from energyml.utils.data.helper import RgbaColor, ScalarRenderingInfo, read_graphical_rendering_info + +NO_KIND = "NO_KIND" + + +class RepresentationContext(BaseModel): + + model_config = ConfigDict(arbitrary_types_allowed=True) + + obj: Any = Field(...) + workspace: EnergymlStorageInterface = Field(...) + uri: Uri = Field(default="") + + crs: List[Any] = Field(default_factory=list) + rels: List[Relationship] = Field(default_factory=list) + + # Properties keyed by object uuid → property object + properties_by_kind: dict = Field(default_factory=dict) + + # Graphical information keyed by GraphicalInformationSet uri → list of entries + graphical_info: dict = Field(default_factory=dict) + + time_series: list = Field(default_factory=list) + + def __init__(self, obj: Any, workspace: EnergymlStorageInterface, **data): + super().__init__(obj=obj, workspace=workspace, uri=get_obj_uri(obj), **data) + self.update() + + def update(self): + self.rels = self.workspace.get_obj_rels(self.obj) + self._collect_properties(self.rels) + self._collect_crs() + self._collect_graphical_info(self.rels) + self.collect_time_series() + + def collect_time_series(self): + self.time_series = [] + time_series_dors = search_attribute_matching_name(self.obj, r"time_series") + if time_series_dors is not None: + for ts_dor in time_series_dors: + ts_obj = self.workspace.get_object(get_obj_uri(ts_dor)) + if ts_obj is not None: + self.time_series.append(ts_obj) + else: + logging.warning(f"TimeSeries {get_obj_uri(ts_dor)} not found in workspace") + + def _collect_properties(self, rels: List[Relationship]): + # Collect related properties keyed by property uuid + self.properties_by_kind = {} + for r in self.rels: + if "Property" in r.target: + uuid, version = extract_uuid_and_version_from_obj_path(r.target) + prop = self.workspace.get_object_by_uuid_versioned(uuid, version) + if prop is None: + logging.warning(f"Property {r.target} not found in workspace") + continue + prop_uuid = getattr(prop, "uuid", NO_KIND) + self.properties_by_kind[prop_uuid] = prop + + def _collect_crs(self): + # Collect related CRS objects referenced by the representation + self.crs = [] + crs_dors = search_attribute_matching_name(self.obj, r"\.*Crs", search_in_sub_obj=True, deep_search=False) + if crs_dors is not None and len(crs_dors) > 0: + for crs_ref in crs_dors: + if crs_ref is not None: + crs = self.workspace.get_object(get_obj_uri(crs_ref)) + if crs is not None: + self.crs.append(crs) + else: + logging.warning(f"CRS {get_obj_uri(crs_ref)} not found in workspace") + + def _collect_graphical_info(self, rels: List[Relationship]): + # Collect graphical information entries whose target matches this representation + self.graphical_info = {} + for r in self.rels: + if "GraphicalInformationSet" in r.target: + uuid, version = extract_uuid_and_version_from_obj_path(r.target) + graphical_info_set = self.workspace.get_object_by_uuid_versioned(uuid, version) + if graphical_info_set is None: + logging.warning(f"GraphicalInformationSet {r.target} not found in workspace") + continue + graphical_info_set_uri = get_obj_uri(graphical_info_set) + for graphical_info in getattr(graphical_info_set, "graphical_information", []): + target_dors = getattr(graphical_info, "target_object", None) + if target_dors is not None: + if not isinstance(target_dors, list): + target_dors = [target_dors] + for target_dor in target_dors: + target_dor_uuid = get_obj_uuid(target_dor) + if target_dor_uuid == self.uri.uuid: + if graphical_info_set_uri not in self.graphical_info: + self.graphical_info[graphical_info_set_uri] = [] + self.graphical_info[graphical_info_set_uri].append(graphical_info) + break + + def get_default_color(self) -> ScalarRenderingInfo: + """Search for a default color (first found) for the representation, and return it as an RGBA tuple. Returns a random color (generated from uuid) if no color information is found.""" + for gis_uri, entries in self.graphical_info.items(): + for entry in entries: + try: + rendering_info = read_graphical_rendering_info(entry, self.workspace) + if rendering_info is not None: + return rendering_info + except Exception as exc: + logging.debug(f"Error reading graphical rendering info for entry {entry}: {exc}") + # No color information found, generate a random color from uuid + return ScalarRenderingInfo(constant_color=RgbaColor.from_uuid(self.uri.uuid)) + + def get_property(self, property_uuid: str) -> Optional[Any]: + """Return the property object with the given uuid, or None.""" + return self.properties_by_kind.get(property_uuid) + + def get_properties_time_series(self, property_uuid: str) -> Dict[str, List[Any]]: + """ + Return a time-indexed dict {time_step_str: [property_values, ...]} for + the given property uuid. Returns an empty dict when the property has no + time series reference. + """ + from energyml.utils.data.mesh import read_time_series, read_property + + prop = self.get_property(property_uuid) + if prop is None: + logging.warning(f"Property {property_uuid} not found in context") + return {} + + time_series_dor = search_attribute_matching_name(prop, r"TimeSeries") + if not time_series_dor: + return {} + + ts_obj = self.workspace.get_object(get_obj_uri(time_series_dor[0])) + if ts_obj is None: + return {} + + steps = read_time_series(ts_obj, self.workspace) + values = read_property(prop, self.workspace) + + result: Dict[str, List[Any]] = {} + for step_idx, dt in steps: + result[str(dt)] = values[step_idx] if step_idx < len(values) else [] + return result + + def seach_same_representation_in_other_time_step(self) -> List[Uri]: + """Search for another representation that has the same interpretation, and same TimeSeries reference (if any), but different time step.""" + if self.time_series is None or len(self.time_series) == 0: + logging.debug( + f"Representation {self.uri} has no TimeSeries reference, skipping search for same representation in other time step" + ) + return [] + interpretation_dor = getattr(self.obj, "represented_interpretation", None) + if interpretation_dor is None: + return None + + obj_time_series_uuids = {get_obj_uuid(ts) for ts in self.time_series} + + similar_representations = [] + + interp_rels = self.workspace.get_obj_rels(get_obj_uri(interpretation_dor)) + for r in interp_rels: + if self.uri.object_type in r.target and self.uri.uuid not in r.target: + candidate_uuid, candidate_version = extract_uuid_and_version_from_obj_path(r.target) + candidate = self.workspace.get_object_by_uuid_versioned(candidate_uuid, candidate_version) + + if candidate is not None: + candidate_time_series_dor = search_attribute_matching_name(candidate, r"time_series") + candidate_time_series_uuids = ( + {get_obj_uuid(ts) for ts in candidate_time_series_dor} if candidate_time_series_dor else set() + ) + # search if at least one of the TimeSeries references is the same between the candidate and the current representation + if len(obj_time_series_uuids.intersection(candidate_time_series_uuids)) > 0: + similar_representations.append(get_obj_uri(candidate)) + + return similar_representations + + def dump(self) -> str: + """Return a human-readable summary of the context for debugging.""" + lines: List[str] = [] + lines.append("=" * 60) + lines.append(f"RepresentationContext") + lines.append(f" URI : {self.uri}") + lines.append(f" Type : {type(self.obj).__name__}") + lines.append("") + + lines.append(f" CRS ({len(self.crs)}):") + for c in self.crs: + lines.append(f" - {type(c).__name__} {get_obj_uri(c)}") + + lines.append("") + lines.append(f" Relationships ({len(self.rels)}):") + for r in self.rels: + lines.append(f" - [{r.type_ if hasattr(r, 'type_') else getattr(r, 'type', '?')}] {r.target}") + + lines.append("") + lines.append(f" Properties ({len(self.properties_by_kind)}):") + for uuid, prop in self.properties_by_kind.items(): + kind = getattr(prop, "property_kind", "?") + lines.append(f" - {type(prop).__name__} uuid={uuid} kind={kind}") + + lines.append("") + lines.append(f" Graphical info ({len(self.graphical_info)} set(s)):") + for uri, entries in self.graphical_info.items(): + lines.append(f" - Set {uri} ({len(entries)} entr{'y' if len(entries)==1 else 'ies'})") + + lines.append("=" * 60) + return "\n".join(lines) + + +if __name__ == "__main__": + import sys + + logging.basicConfig(level=logging.WARNING, stream=sys.stdout) + + epc_path = "rc/epc/testingPackageCpp22.epc" + representation_uri = "df2103a0-fa3d-11e5-b8d4-0002a5d5c51b." + # representation_uri = "eml:///resqml20.obj_Grid2dRepresentation(030a82f6-10a7-4ecf-af03-54749e098624)" + + from energyml.utils.epc import Epc + + epc = Epc.read_file(epc_path) + workspace = epc # Epc extends EnergymlStorageInterface directly + + repr_obj = workspace.get_object(representation_uri) + if repr_obj is None: + print(f"ERROR: object not found for URI {representation_uri}") + sys.exit(1) + + repr_ctx = RepresentationContext(repr_obj, workspace) + + # --- dump of values --- + print(repr_ctx.dump()) + + # Detail: CRS info + if repr_ctx.crs: + from energyml.utils.data.crs import extract_crs_info + + print("\nCRS details:") + for c in repr_ctx.crs: + info = extract_crs_info(c, workspace) + print(f" {type(c).__name__}:") + print(f" x_offset={info.x_offset}, y_offset={info.y_offset}, z_offset={info.z_offset}") + print(f" z_increasing_downward={info.z_increasing_downward}") + print(f" projected_epsg={info.projected_epsg_code}, vertical_epsg={info.vertical_epsg_code}") + print(f" areal_rotation={info.areal_rotation_value} {info.areal_rotation_uom}") + print(f" axis_order={info.projected_axis_order}") + + # Detail: property arrays (truncated) + if repr_ctx.properties_by_kind: + from energyml.utils.data.mesh import read_property + + print("\nProperty arrays (first 10 values):") + for uuid, prop in repr_ctx.properties_by_kind.items(): + try: + arr = read_property(prop, workspace) + print(f" {type(prop).__name__} [{uuid}]: shape={getattr(arr, 'shape', len(arr))} sample={arr[:10]}") + except Exception as exc: + print(f" {type(prop).__name__} [{uuid}]: ERROR reading — {exc}") + + # print property time series values + if repr_ctx.properties_by_kind: + print("\nProperty time series values:") + for uuid, prop in repr_ctx.properties_by_kind.items(): + try: + ts_values = repr_ctx.get_properties_time_series(uuid) + if ts_values: + print(f" {type(prop).__name__} [{uuid}]:") + for time_step, values in ts_values.items(): + print(f" - Time {time_step}: sample={values[:10]}") + except Exception as exc: + print(f" {type(prop).__name__} [{uuid}]: ERROR reading time series — {exc}") diff --git a/energyml-utils/src/energyml/utils/serialization.py b/energyml-utils/src/energyml/utils/serialization.py index 960664d..1380099 100644 --- a/energyml-utils/src/energyml/utils/serialization.py +++ b/energyml-utils/src/energyml/utils/serialization.py @@ -20,8 +20,8 @@ from xsdata.formats.dataclass.serializers import XmlSerializer from xsdata.formats.dataclass.serializers.config import SerializerConfig -from .exception import UnknownTypeFromQualifiedType, NotParsableType -from .introspection import ( +from energyml.utils.exception import UnknownTypeFromQualifiedType, NotParsableType +from energyml.utils.introspection import ( as_obj_prefixed_class_if_possible, get_class_from_name, get_energyml_class_in_related_dev_pkg, @@ -35,7 +35,7 @@ get_matching_class_attribute_name, is_enum, ) -from .xml import ( +from energyml.utils.xml_utils import ( get_class_name_from_xml, get_tree, get_xml_encoding, diff --git a/energyml-utils/src/energyml/utils/storage_interface.py b/energyml-utils/src/energyml/utils/storage_interface.py index 2c15a1e..f2bbcda 100644 --- a/energyml-utils/src/energyml/utils/storage_interface.py +++ b/energyml-utils/src/energyml/utils/storage_interface.py @@ -252,6 +252,18 @@ def get_object_by_uuid(self, uuid: str) -> List[Any]: """ pass + def get_object_by_uuid_versioned(self, uuid: str, version: Optional[str] = None) -> Optional[Any]: + """ + Retrieve a specific version of an object by UUID and optional version. + + Args: + uuid: Object UUID + version: Optional version string. If None, returns the latest version. + Returns: + The deserialized energyml object matching the UUID and version, or None if not found + """ + return self.get_object(f"{uuid}.{version}" if version else f"{uuid}.") + @abstractmethod def put_object(self, obj: Any, dataspace: Optional[str] = None) -> Optional[str]: """ diff --git a/energyml-utils/src/energyml/utils/uri.py b/energyml-utils/src/energyml/utils/uri.py index 681c4ca..ffa8689 100644 --- a/energyml-utils/src/energyml/utils/uri.py +++ b/energyml-utils/src/energyml/utils/uri.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from energyml.utils.exception import ContentTypeValidationError, NotUriError -from .constants import ( +from energyml.utils.constants import ( RGX_CT_ENERGYML_DOMAIN, RGX_CT_TOKEN_TYPE, RGX_CT_TOKEN_VERSION, diff --git a/energyml-utils/src/energyml/utils/validation.py b/energyml-utils/src/energyml/utils/validation.py index 14ac905..4ea509c 100644 --- a/energyml-utils/src/energyml/utils/validation.py +++ b/energyml-utils/src/energyml/utils/validation.py @@ -7,14 +7,14 @@ import traceback from typing import Any, Dict, List, Optional, Union -from .epc import ( +from energyml.utils.epc import ( Epc, ) -from .epc_utils import ( +from energyml.utils.epc_utils import ( get_obj_identifier, get_property_kind_by_uuid, ) -from .introspection import ( +from energyml.utils.introspection import ( get_class_fields, get_object_attribute, is_primitive, @@ -96,7 +96,6 @@ def __str__(self): @dataclass class MandatoryError(ValidationObjectError): - @property def msg(self) -> str: return f"Mandatory value is None for {get_obj_identifier(self.target_obj)} : '{self.attribute_dot_path}'" diff --git a/energyml-utils/src/energyml/utils/xml.py b/energyml-utils/src/energyml/utils/xml_utils.py similarity index 97% rename from energyml-utils/src/energyml/utils/xml.py rename to energyml-utils/src/energyml/utils/xml_utils.py index 94e02ee..05e88a1 100644 --- a/energyml-utils/src/energyml/utils/xml.py +++ b/energyml-utils/src/energyml/utils/xml_utils.py @@ -7,7 +7,7 @@ from lxml import etree as ETREE # type: Any -from .constants import ENERGYML_NAMESPACES, ENERGYML_NAMESPACES_PACKAGE, OptimizedRegex, parse_content_type +from energyml.utils.constants import ENERGYML_NAMESPACES, ENERGYML_NAMESPACES_PACKAGE, OptimizedRegex, parse_content_type def get_pkg_from_namespace(namespace: str) -> Optional[str]: diff --git a/energyml-utils/tests/test_xml.py b/energyml-utils/tests/test_xml.py index bfd3309..769fc50 100644 --- a/energyml-utils/tests/test_xml.py +++ b/energyml-utils/tests/test_xml.py @@ -4,7 +4,7 @@ import logging from energyml.utils.constants import parse_qualified_type -from src.energyml.utils.xml import * +from src.energyml.utils.xml_utils import * CT_20 = "application/x-resqml+xml;version=2.0;type=obj_TriangulatedSetRepresentation" CT_22 = "application/x-resqml+xml;version=2.2;type=TriangulatedSetRepresentation" From 0b3dbf18630a7b8b26119378637e8cc5a55335eb Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Thu, 5 Mar 2026 16:09:17 +0100 Subject: [PATCH 68/70] -- --- energyml-utils/src/energyml/utils/data/crs.py | 2 +- energyml-utils/src/energyml/utils/data/helper.py | 10 +++++----- .../src/energyml/utils/data/mesh_numpy.py | 16 ++++++++-------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/energyml-utils/src/energyml/utils/data/crs.py b/energyml-utils/src/energyml/utils/data/crs.py index 3f5a9ec..22f42fb 100644 --- a/energyml-utils/src/energyml/utils/data/crs.py +++ b/energyml-utils/src/energyml/utils/data/crs.py @@ -767,7 +767,7 @@ def apply_from_crs_info( Transform pipeline (order matters): 1. **Areal rotation** (RESQML convention: *clockwise* angle) → - ``x' = x·cos θ + y·sin θ``, ``y' = –x·sin θ + y·cos θ`` + ``x' = x·cos θ + y·sin θ``, ``y' = -x·sin θ + y·cos θ`` 2. **Translation** — add ``(x_offset, y_offset, z_offset)`` 3. **Z-axis flip** — negate Z when the CRS *is* z-increasing-downward (i.e. the local CRS stores depth as positive Z, diff --git a/energyml-utils/src/energyml/utils/data/helper.py b/energyml-utils/src/energyml/utils/data/helper.py index f81d39e..1781caf 100644 --- a/energyml-utils/src/energyml/utils/data/helper.py +++ b/energyml-utils/src/energyml/utils/data/helper.py @@ -1513,7 +1513,7 @@ class RgbaColor: a: float = 1.0 def to_uint8(self) -> Tuple[int, int, int, int]: - """Return (R, G, B, A) in [0, 255] – ready for VTK / PyVista.""" + """Return (R, G, B, A) in [0, 255] - ready for VTK / PyVista.""" return ( int(round(self.r * 255)), int(round(self.g * 255)), @@ -1610,7 +1610,7 @@ def to_vtk_lut(self, n_colors: int = 256) -> np.ndarray: sorted_entries = sorted(self.entries, key=lambda e: e.index) if not self.is_continuous: - # One exact row per integer entry – no interpolation needed. + # One exact row per integer entry - no interpolation needed. return np.array( [e.color.to_uint8() for e in sorted_entries], dtype=np.uint8 ) @@ -1689,7 +1689,7 @@ class ScalarRenderingInfo: # ───────────────────────────────────────────────────────────────────────────── -# Color-map readers (Group 1 – both return ColorMapInfo) +# Color-map readers (Group 1 - both return ColorMapInfo) # ───────────────────────────────────────────────────────────────────────────── @@ -1734,7 +1734,7 @@ def read_discrete_color_map(color_map_obj: Any) -> ColorMapInfo: **Output**: :class:`ColorMapInfo` with ``is_continuous=False`` and one entry per integer code. ``to_vtk_lut()`` returns exactly one RGBA row per - entry – suitable for VTK's categorical lookup table + entry - suitable for VTK's categorical lookup table (``vtkLookupTable.SetAnnotation`` workflow). """ entries = sorted( @@ -1897,6 +1897,6 @@ def read_graphical_rendering_info( result.contour_show_major_every = getattr(info, "show_major_line_every", None) # AnnotationInformation is intentionally not mapped to ScalarRenderingInfo - # because it drives label text, not colour/size – handle separately if needed. + # because it drives label text, not colour/size - handle separately if needed. return result if found else None \ No newline at end of file diff --git a/energyml-utils/src/energyml/utils/data/mesh_numpy.py b/energyml-utils/src/energyml/utils/data/mesh_numpy.py index 7cfb9f7..0e95408 100644 --- a/energyml-utils/src/energyml/utils/data/mesh_numpy.py +++ b/energyml-utils/src/energyml/utils/data/mesh_numpy.py @@ -9,18 +9,18 @@ Design goals ------------ -* **No list conversion** – no ``.tolist()`` calls anywhere. Arrays stay as +* **No list conversion** - no ``.tolist()`` calls anywhere. Arrays stay as numpy throughout. -* **Best-effort zero-copy** – geometry is read via +* **Best-effort zero-copy** - geometry is read via :meth:`EnergymlStorageInterface.read_array_view`. For contiguous, uncompressed HDF5 datasets this returns a numpy view backed directly by the memory-mapped file buffer (no RAM copy). Chunked / compressed datasets fall back silently to a copy. -* **PyVista-ready connectivity** – ``faces`` / ``lines`` / ``cells`` arrays +* **PyVista-ready connectivity** - ``faces`` / ``lines`` / ``cells`` arrays use the VTK flat-count-prefixed format consumed directly by ``pyvista.PolyData`` and ``pyvista.UnstructuredGrid`` without additional allocation. -* **Backward compatible** – :mod:`mesh.py` is untouched; both modules can be +* **Backward compatible** - :mod:`mesh.py` is untouched; both modules can be used side by side. Usage @@ -91,7 +91,7 @@ def __init__(self, ws: EnergymlStorageInterface) -> None: def __getattr__(self, name: str) -> Any: return getattr(self._ws, name) - def read_array( # noqa: D102 – mirrors EnergymlStorageInterface + def read_array( # noqa: D102 - mirrors EnergymlStorageInterface self, proxy: Any, path_in_external: str, @@ -121,8 +121,8 @@ class NumpyMesh: """Base class for all numpy-backed mesh objects. Subclasses guarantee: - * ``points`` – shape ``(N, 3)``, dtype ``float64`` - * Connectivity arrays – dtype ``int64``, VTK flat format + * ``points`` - shape ``(N, 3)``, dtype ``float64`` + * Connectivity arrays - dtype ``int64``, VTK flat format """ energyml_object: Any = field(default=None) @@ -169,7 +169,7 @@ class NumpySurfaceMesh(NumpyMesh): class NumpyVolumeMesh(NumpyMesh): """A volumetric mesh (hexahedral, polyhedral, …). - ``cells`` – VTK flat format, ``cell_types`` – uint8 VTK cell-type codes. + ``cells`` - VTK flat format, ``cell_types`` - uint8 VTK cell-type codes. ``pyvista.UnstructuredGrid(cells, cell_types, points)`` accepts them directly. """ From 1813445b3bcb868cbbac3e0818e1bfb08e14c3df Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Thu, 5 Mar 2026 17:56:46 +0100 Subject: [PATCH 69/70] better export with numpy --- energyml-utils/.gitignore | 8 +- .../example/main_test_numpy_export.py | 219 +++ .../src/energyml/utils/data/export.py | 1295 +++++++++++++---- .../src/energyml/utils/data/mesh_numpy.py | 428 ++++-- .../{ => data}/representation_context.py | 0 energyml-utils/tests/test_array_handlers.py | 1 + energyml-utils/tests/test_mesh_numpy.py | 56 +- 7 files changed, 1583 insertions(+), 424 deletions(-) create mode 100644 energyml-utils/example/main_test_numpy_export.py rename energyml-utils/src/energyml/utils/{ => data}/representation_context.py (100%) diff --git a/energyml-utils/.gitignore b/energyml-utils/.gitignore index 23725b9..b0e48a8 100644 --- a/energyml-utils/.gitignore +++ b/energyml-utils/.gitignore @@ -48,22 +48,24 @@ gen*/ manip* *.epc *.h5 -*.off -*.obj *.log -*.geojson *.json *.csv *.zip + *.xml *.json docs/*.md # DATA *.obj +*.off +*.mtl *.geojson *.vtk +*.vtp +*.vtu *.stl rc/specs rc/**/*.epc diff --git a/energyml-utils/example/main_test_numpy_export.py b/energyml-utils/example/main_test_numpy_export.py new file mode 100644 index 0000000..def7d64 --- /dev/null +++ b/energyml-utils/example/main_test_numpy_export.py @@ -0,0 +1,219 @@ +# Copyright (c) 2023-2024 Geosiris. +# SPDX-License-Identifier: Apache-2.0 +""" +Example: export NumpyMultiMesh objects from an EPC file to all supported formats. + +Demonstrates: + - Reading meshes via read_numpy_mesh_object (NumpyMultiMesh) + - Building RepresentationContext per object for colour metadata + - Exporting to OBJ (+.mtl), GeoJSON, VTK Legacy ASCII, VTK Legacy Binary, + VTK XML UnstructuredGrid (.vtu), VTK XML PolyData (.vtp), STL + - Two passes: with and without CRS displacement + +Usage:: + + # from the workspace root + poetry run python example/main_test_numpy_export.py <path/to/file.epc> <output_dir> + + # defaults (uses bundled test EPC files when no args are given) + poetry run python example/main_test_numpy_export.py +""" + +import datetime +import logging +import os +import re +import sys +import traceback +from pathlib import Path +from typing import Dict, Optional + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-7s %(message)s", + stream=sys.stdout, +) +log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Lazy import guards — pyvista is strictly optional +# --------------------------------------------------------------------------- +try: + from energyml.utils.data.mesh_numpy import read_numpy_mesh_object + from energyml.utils.data.representation_context import RepresentationContext + from energyml.utils.data.export import ( + ExportFormat, + VTKExportOptions, + VTKFormat, + STLExportOptions, + GeoJSONExportOptions, + export_mesh, + ) + from energyml.utils.epc_stream import EpcStreamReader + from energyml.utils.epc import Epc + from energyml.utils.exception import NotSupportedError + from energyml.utils.introspection import get_obj_uuid +except ImportError as exc: + log.error("Could not import energyml-utils modules: %s", exc) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# Core export routine +# --------------------------------------------------------------------------- + + +def export_all_numpy( + epc_path: str, + output_dir: str, + regex_type_filter: Optional[str] = None, + use_crs_displacement: bool = True, +) -> None: + """Read every Representation in *epc_path* via the numpy pipeline and + export it to all supported formats. + + :param epc_path: Path to the ``.epc`` file. + :param output_dir: Directory where output files are written (created if absent). + :param regex_type_filter: Optional regex; only objects whose type name matches + are exported (case-insensitive). + :param use_crs_displacement: When True, CRS origin/axis offsets are applied to + the exported coordinates. Two passes are run by the top-level script: one + with True and one with False. + """ + tag = "crs" if use_crs_displacement else "nocrs" + # storage = EpcStreamReader(epc_path, keep_open=True) + storage = Epc.read_file(epc_path) + dt = datetime.datetime.now().strftime("%Hh%M_%d-%m-%Y") + + not_supported_types: set = set() + exported_count = 0 + + for mdata in storage.list_objects(): + if "Representation" not in mdata.object_type: + continue + if regex_type_filter and not re.search(regex_type_filter, mdata.object_type, flags=re.IGNORECASE): + continue + + log.info("Processing %s (%s)", mdata.object_type, mdata.uuid) + energyml_obj = storage.get_object_by_uuid(mdata.uuid)[0] + + try: + # ---- 1. Read as NumpyMultiMesh -------------------------------- + multi_mesh = read_numpy_mesh_object( + energyml_object=energyml_obj, + workspace=storage, + # Read with displacement=False so the exporter controls it. + use_crs_displacement=False, + ) + + if multi_mesh is None or multi_mesh.patch_count() == 0: + log.info(" → no patches, skipping.") + continue + + # ---- 2. Build RepresentationContext for colour metadata -------- + ctx = RepresentationContext(energyml_obj, storage) + source_uuid = get_obj_uuid(energyml_obj) + contexts: Dict[str, RepresentationContext] = {source_uuid: ctx} + + # Also index children by their source_uuid for colour lookup + for patch in multi_mesh.flat_patches(): + patch_uuid = patch.source_uuid + if patch_uuid and patch_uuid not in contexts: + patch_obj = storage.get_object_by_uuid(patch_uuid) + if patch_obj: + contexts[patch_uuid] = RepresentationContext(patch_obj[0], storage) + + # ---- 3. Prepare output directory / base filename --------------- + os.makedirs(output_dir, exist_ok=True) + stem = f"{dt}-{mdata.object_type}_{mdata.uuid}_{tag}" + base = Path(output_dir) / stem + + # ---- 4. Export to every format --------------------------------- + formats_to_export = [ + (f"{base}.obj", ExportFormat.OBJ, None), + (f"{base}.geojson", ExportFormat.GEOJSON, GeoJSONExportOptions(indent=None)), + (f"{base}.vtk", ExportFormat.VTK, VTKExportOptions(vtk_format=VTKFormat.LEGACY_ASCII)), + (f"{base}_binary.vtk", ExportFormat.VTK, VTKExportOptions(vtk_format=VTKFormat.LEGACY_BINARY)), + (f"{base}.vtu", ExportFormat.VTU, VTKExportOptions(vtk_format=VTKFormat.VTU)), + (f"{base}.vtp", ExportFormat.VTP, VTKExportOptions(vtk_format=VTKFormat.VTP)), + (f"{base}_binary.stl", ExportFormat.STL, STLExportOptions(binary=True)), + (f"{base}_ascii.stl", ExportFormat.STL, STLExportOptions(binary=False)), + ] + + for path_str, fmt, opts in formats_to_export: + try: + export_mesh( + mesh_list=multi_mesh, + output_path=path_str, + format=fmt, + options=opts, + contexts=contexts, + use_crs_displacement=use_crs_displacement, + ) + log.info(" ✓ %s", Path(path_str).name) + except Exception: # noqa: BLE001 + log.warning(" ✗ %s — export failed:", Path(path_str).name) + traceback.print_exc() + + exported_count += 1 + + except NotSupportedError as e: + not_supported_types.add(mdata.object_type) + log.debug(" Not supported: %s", e) + except Exception: + traceback.print_exc() + + log.info("") + log.info("Done. Exported %d objects -> %s", exported_count, output_dir) + if not_supported_types: + log.info("Unsupported representation types skipped:") + for t in sorted(not_supported_types): + log.info(" - %s", t) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + # Allow: main_test_numpy_export.py [epc_path] [output_dir] + args = sys.argv[1:] + + if len(args) >= 1: + epc_file = args[0] + else: + # Fall back to the bundled test EPC in the workspace + candidates = [ + "rc/epc/testingPackageCpp22.epc", + "rc/epc/testingPackageCpp.epc", + ] + epc_file = next((p for p in candidates if Path(p).exists()), None) + if epc_file is None: + log.error( + "No EPC file found. Pass a path as the first argument or place a " + ".epc file at rc/epc/testingPackageCpp22.epc" + ) + sys.exit(1) + + base_output = args[1] if len(args) >= 2 else "exported_meshes/numpy_export" + + log.info("=" * 60) + log.info("EPC : %s", epc_file) + log.info("OUT : %s", base_output) + log.info("=" * 60) + + # Pass 1 — with CRS displacement + log.info("\n--- Pass 1: use_crs_displacement=True ---\n") + export_all_numpy( + epc_path=epc_file, + output_dir=f"{base_output}/with_crs", + use_crs_displacement=True, + ) + + # Pass 2 — raw coordinates (no CRS displacement) + log.info("\n--- Pass 2: use_crs_displacement=False ---\n") + export_all_numpy( + epc_path=epc_file, + output_dir=f"{base_output}/no_crs", + use_crs_displacement=False, + ) diff --git a/energyml-utils/src/energyml/utils/data/export.py b/energyml-utils/src/energyml/utils/data/export.py index e8b6726..3cb4f67 100644 --- a/energyml-utils/src/energyml/utils/data/export.py +++ b/energyml-utils/src/energyml/utils/data/export.py @@ -2,19 +2,62 @@ # SPDX-License-Identifier: Apache-2.0 """ Module for exporting mesh data to various file formats. -Supports OBJ, GeoJSON, VTK, and STL formats. + +Supports OBJ, GeoJSON, VTK Legacy (ASCII + binary), VTK XML (.vtu / .vtp), +and STL formats. + +Both the legacy :class:`AbstractMesh` hierarchy (``mesh.py``) and the +high-performance :class:`NumpyMesh` / :class:`NumpyMultiMesh` hierarchy +(``mesh_numpy.py``) are accepted by every export function. + +CRS-displacement can be applied at export time (rather than at read time) by +passing ``use_crs_displacement=True`` (default) when a workspace is reachable +through the ``contexts`` dict. The original ``NumpyMesh.points`` arrays are +**never mutated** — a copy is made whenever CRS needs to be applied. + +Color metadata is sourced from :class:`RepresentationContext` objects keyed +by ``source_uuid``; if none are provided a default palette is used. """ +from __future__ import annotations + +import base64 import json +import logging import struct from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING, BinaryIO, List, Optional, TextIO, Union +from typing import TYPE_CHECKING, Any, BinaryIO, Dict, List, Optional, TextIO, Union import numpy as np if TYPE_CHECKING: - from energyml.utils.mesh import AbstractMesh + from energyml.utils.data.mesh import AbstractMesh + from energyml.utils.data.mesh_numpy import ( + NumpyMesh, + NumpyMultiMesh, + NumpyPolylineMesh, + NumpyPointSetMesh, + NumpySurfaceMesh, + NumpyVolumeMesh, + ) + from energyml.utils.data.representation_context import RepresentationContext + +log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# VTK cell-type constants (subset) +# --------------------------------------------------------------------------- +_VTK_VERTEX = 1 +_VTK_POLY_LINE = 4 +_VTK_TRIANGLE = 5 +_VTK_POLYGON = 7 +_VTK_TETRA = 10 +_VTK_HEXAHEDRON = 12 + +# --------------------------------------------------------------------------- +# Enumerations / option classes +# --------------------------------------------------------------------------- class ExportFormat(Enum): @@ -23,6 +66,8 @@ class ExportFormat(Enum): OBJ = "obj" GEOJSON = "geojson" VTK = "vtk" + VTU = "vtu" + VTP = "vtp" STL = "stl" @classmethod @@ -43,35 +88,61 @@ def all_extensions(cls) -> List[str]: class ExportOptions: """Base class for export options.""" - pass - class STLExportOptions(ExportOptions): """Options for STL export.""" def __init__(self, binary: bool = True, ascii_precision: int = 6): """ - Initialize STL export options. - - :param binary: If True, export as binary STL; if False, export as ASCII STL - :param ascii_precision: Number of decimal places for ASCII format + :param binary: If True, export as binary STL; if False, export as ASCII STL. + :param ascii_precision: Number of decimal places for ASCII format. """ self.binary = binary self.ascii_precision = ascii_precision +class VTKFormat(Enum): + """Sub-format selector for VTK export.""" + + LEGACY_ASCII = "legacy_ascii" + """VTK legacy format, ASCII encoding (version 3.0).""" + + LEGACY_BINARY = "legacy_binary" + """VTK legacy format, big-endian binary encoding (version 3.0).""" + + VTU = "vtu" + """VTK XML UnstructuredGrid (.vtu) — best for volumetric meshes.""" + + VTP = "vtp" + """VTK XML PolyData (.vtp) — best for surface / polyline meshes.""" + + class VTKExportOptions(ExportOptions): """Options for VTK export.""" - def __init__(self, binary: bool = False, dataset_name: str = "mesh"): + def __init__( + self, + vtk_format: VTKFormat = VTKFormat.LEGACY_ASCII, + dataset_name: str = "mesh", + # Legacy compatibility: binary=True is equivalent to vtk_format=VTKFormat.LEGACY_BINARY + binary: bool = False, + ): """ - Initialize VTK export options. - - :param binary: If True, export as binary VTK; if False, export as ASCII VTK - :param dataset_name: Name of the dataset in VTK file + :param vtk_format: VTK sub-format (legacy ASCII, legacy binary, VTU, VTP). + :param dataset_name: Dataset name embedded in legacy VTK header or XML title. + :param binary: Deprecated shorthand; when True, forces LEGACY_BINARY sub-format. """ - self.binary = binary self.dataset_name = dataset_name + if binary and vtk_format == VTKFormat.LEGACY_ASCII: + # Honour the legacy binary=True flag so old call-sites still work. + self.vtk_format = VTKFormat.LEGACY_BINARY + else: + self.vtk_format = vtk_format + + # Backward-compat property so code that reads ``options.binary`` still works. + @property + def binary(self) -> bool: + return self.vtk_format == VTKFormat.LEGACY_BINARY class GeoJSONExportOptions(ExportOptions): @@ -79,204 +150,867 @@ class GeoJSONExportOptions(ExportOptions): def __init__(self, indent: Optional[int] = 2, properties: Optional[dict] = None): """ - Initialize GeoJSON export options. - - :param indent: JSON indentation level (None for compact) - :param properties: Additional properties to include in features + :param indent: JSON indentation level (None for compact output). + :param properties: Extra properties merged into every feature. """ self.indent = indent self.properties = properties or {} -def export_obj(mesh_list: List["AbstractMesh"], out: BinaryIO, obj_name: Optional[str] = None) -> None: - """ - Export mesh data to Wavefront OBJ format. +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + - :param mesh_list: List of AbstractMesh objects to export - :param out: Binary output stream - :param obj_name: Optional object name for the OBJ file +def _normalize_to_patches(meshes: Any) -> List[Any]: + """Flatten *meshes* into a list of individual mesh patches. + + Handles: + - :class:`NumpyMultiMesh` → calls ``flat_patches()`` + - Single :class:`NumpyMesh` → ``[mesh]`` + - ``list`` / ``tuple`` → recursive + - :class:`AbstractMesh` → passthrough as ``[mesh]`` + """ + from energyml.utils.data.mesh_numpy import NumpyMesh, NumpyMultiMesh + + if isinstance(meshes, NumpyMultiMesh): + return meshes.flat_patches() + if isinstance(meshes, NumpyMesh): + return [meshes] + if isinstance(meshes, (list, tuple)): + result: List[Any] = [] + for m in meshes: + result.extend(_normalize_to_patches(m)) + return result + # AbstractMesh or unknown — pass through as single element + return [meshes] + + +def _parse_vtk_flat_faces(flat: np.ndarray) -> List[np.ndarray]: + """Decode VTK flat face array ``[nv, v0, …, nv, v0, …]`` into a list of + per-face index arrays.""" + faces: List[np.ndarray] = [] + pos = 0 + flat = np.asarray(flat, dtype=np.int64) + while pos < len(flat): + nv = int(flat[pos]) + pos += 1 + if pos + nv > len(flat): + break + faces.append(flat[pos : pos + nv]) + pos += nv + return faces + + +def _parse_vtk_flat_lines(flat: np.ndarray) -> List[np.ndarray]: + """Decode VTK flat lines array ``[n, i0, i1, …, n, i0, …]`` into a list + of per-line index arrays.""" + lines: List[np.ndarray] = [] + pos = 0 + flat = np.asarray(flat, dtype=np.int64) + while pos < len(flat): + n = int(flat[pos]) + pos += 1 + if pos + n > len(flat): + break + lines.append(flat[pos : pos + n]) + pos += n + return lines + + +def _get_export_points( + mesh: Any, + use_crs_displacement: bool, + workspace: Any = None, +) -> np.ndarray: + """Return the point array for *mesh*, optionally applying CRS displacement. + + - For :class:`NumpyMesh`: if ``use_crs_displacement`` is True and a CRS + object is present, returns a *copy* with CRS applied (never mutates the + original ``mesh.points``). + - For :class:`AbstractMesh` (legacy): returns ``mesh.point_list`` as-is; + CRS was already applied by the reader. + """ + from energyml.utils.data.mesh_numpy import NumpyMesh + + if isinstance(mesh, NumpyMesh): + if use_crs_displacement and mesh.crs_object is not None and workspace is not None: + from energyml.utils.data.crs import apply_from_crs_info, extract_crs_info + + crs = mesh.crs_object[0] if isinstance(mesh.crs_object, list) and mesh.crs_object else mesh.crs_object + if crs is not None: + try: + crs_info = extract_crs_info(crs, workspace) + pts = mesh.points.copy() + apply_from_crs_info(pts, crs_info, inplace=True) + return pts + except Exception as exc: # pragma: no cover + log.warning("CRS displacement failed for %s: %s", mesh.source_uuid, exc) + return mesh.points + # AbstractMesh — point_list is a list-of-lists; convert to ndarray for uniform handling + return np.array(getattr(mesh, "point_list", []), dtype=np.float64) + + +def _get_context_color( + source_uuid: Optional[str], + contexts: Optional[Dict[str, Any]], +) -> Optional[tuple]: + """Return an (r, g, b, a) tuple in 0–255 range for *source_uuid*, or None.""" + if not contexts or not source_uuid: + return None + ctx = contexts.get(source_uuid) + if ctx is None: + return None + try: + rendering = ctx.get_default_color() + if rendering is not None and rendering.constant_color is not None: + return rendering.constant_color.to_uint8() + except Exception as exc: # pragma: no cover + log.debug("Failed to read color for %s: %s", source_uuid, exc) + return None + + +def _workspace_from_contexts(contexts: Optional[Dict[str, Any]]) -> Any: + """Return the workspace from the first available RepresentationContext.""" + if not contexts: + return None + for ctx in contexts.values(): + ws = getattr(ctx, "workspace", None) + if ws is not None: + return ws + return None + + +def _get_faces_or_cells(mesh: Any) -> np.ndarray: + """Return the face or cell connectivity array for a NumpyMesh. + + Uses ``mesh.faces`` when present and non-empty, then falls back to + ``mesh.cells``. Avoids the numpy-unsafe ``arr or other`` pattern which + raises ``ValueError`` for arrays with more than one element. """ - # Lazy import to avoid circular dependency - from energyml.utils.mesh import PolylineSetMesh + faces = getattr(mesh, "faces", None) + if faces is not None and len(faces) > 0: + return faces + cells = getattr(mesh, "cells", None) + if cells is not None and len(cells) > 0: + return cells + return np.empty(0, dtype=np.int64) + + +# --------------------------------------------------------------------------- +# OBJ export +# --------------------------------------------------------------------------- + + +def export_obj( + mesh_list: Any, + out: BinaryIO, + obj_name: Optional[str] = None, + contexts: Optional[Dict[str, "RepresentationContext"]] = None, + mtl_out: Optional[BinaryIO] = None, + use_crs_displacement: bool = True, +) -> None: + """Export mesh data to Wavefront OBJ format. + + :param mesh_list: One or more meshes (``AbstractMesh``, ``NumpyMesh``, + ``NumpyMultiMesh``, or a list thereof). + :param out: Binary output stream for the ``.obj`` content. + :param obj_name: Optional object name written to the OBJ header. + :param contexts: Optional dict of :class:`RepresentationContext` keyed by + ``source_uuid``; used to emit companion ``.mtl`` material colours when + *mtl_out* is also provided. + :param mtl_out: Optional binary stream for the companion ``.mtl`` file. + Colour requires *contexts* to be supplied. + :param use_crs_displacement: When True (default), CRS origin offset and + axis transforms are applied to ``NumpyMesh`` points at export time. + """ + from energyml.utils.data.mesh import PolylineSetMesh + from energyml.utils.data.mesh_numpy import NumpyMesh, NumpyPointSetMesh, NumpyPolylineMesh - # Write header - out.write(b"# Generated by energyml-utils a Geosiris python module\n\n") + patches = _normalize_to_patches(mesh_list) + workspace = _workspace_from_contexts(contexts) - # Write object name if provided + out.write(b"# Generated by energyml-utils (Geosiris)\n\n") if obj_name is not None: - out.write(f"o {obj_name}\n\n".encode("utf-8")) + out.write(f"o {obj_name}\n\n".encode()) - point_offset = 0 + mtl_lib_name = obj_name or "materials" + if mtl_out is not None: + out.write(f"mtllib {mtl_lib_name}.mtl\n\n".encode()) + mtl_out.write(b"# MTL generated by energyml-utils\n\n") - for mesh in mesh_list: - # Write group name using mesh identifier or uuid - mesh_id = getattr(mesh, "identifier", None) or getattr(mesh, "uuid", "mesh") - out.write(f"g {mesh_id}\n\n".encode("utf-8")) + point_offset = 0 - # Write vertices - for point in mesh.point_list: - if len(point) > 0: - out.write(f"v {' '.join(map(str, point))}\n".encode("utf-8")) + for mesh in patches: + pts = _get_export_points(mesh, use_crs_displacement, workspace) + patch_label = getattr(mesh, "patch_label", None) or getattr(mesh, "identifier", None) or "mesh" + source_uuid = getattr(mesh, "source_uuid", None) or getattr(mesh, "uuid", None) + patch_idx = getattr(mesh, "patch_index", None) + group_name = f"{source_uuid}_{patch_idx}" if source_uuid and patch_idx is not None else patch_label + + out.write(f"g {group_name}\n\n".encode()) + + # emit material reference when mtl output is available + if mtl_out is not None: + mat_name = f"mat_{group_name}" + color = _get_context_color(source_uuid, contexts) + if color is None: + color = (200, 200, 200, 255) + r, g, b, _a = color + out.write(f"usemtl {mat_name}\n".encode()) + mtl_out.write(f"newmtl {mat_name}\n".encode()) + mtl_out.write(f"Kd {r/255:.6f} {g/255:.6f} {b/255:.6f}\n\n".encode()) + + # write vertices + for pt in pts: + out.write(f"v {pt[0]} {pt[1]} {pt[2]}\n".encode()) + + # write connectivity + if isinstance(mesh, NumpyMesh): + if isinstance(mesh, NumpyPointSetMesh): + # bare vertex elements + for i in range(len(pts)): + out.write(f"p {i + point_offset + 1}\n".encode()) + elif isinstance(mesh, NumpyPolylineMesh): + for seg in _parse_vtk_flat_lines(mesh.lines): + if len(seg) > 1: + idx_str = " ".join(str(i + point_offset + 1) for i in seg) + out.write(f"l {idx_str}\n".encode()) + else: + # NumpySurfaceMesh (or NumpyVolumeMesh — export as faces) + faces_arr = _get_faces_or_cells(mesh) + for face in _parse_vtk_flat_faces(faces_arr): + if len(face) >= 3: + idx_str = " ".join(str(i + point_offset + 1) for i in face) + out.write(f"f {idx_str}\n".encode()) + else: + # AbstractMesh legacy path + indices = mesh.get_indices() + elt = "l" if isinstance(mesh, PolylineSetMesh) else "f" + for elem in indices: + if len(elem) > 1: + idx_str = " ".join(str(i + point_offset + 1) for i in elem) + out.write(f"{elt} {idx_str}\n".encode()) - # Write faces or lines depending on mesh type - indices = mesh.get_indices() - elt_letter = "l" if isinstance(mesh, PolylineSetMesh) else "f" + out.write(b"\n") + point_offset += len(pts) - for face_or_line in indices: - if len(face_or_line) > 1: - # OBJ indices are 1-based - indices_str = " ".join(str(idx + point_offset + 1) for idx in face_or_line) - out.write(f"{elt_letter} {indices_str}\n".encode("utf-8")) - point_offset += len(mesh.point_list) +# --------------------------------------------------------------------------- +# GeoJSON export +# --------------------------------------------------------------------------- def export_geojson( - mesh_list: List["AbstractMesh"], out: TextIO, options: Optional[GeoJSONExportOptions] = None + mesh_list: Any, + out: TextIO, + options: Optional[GeoJSONExportOptions] = None, + contexts: Optional[Dict[str, "RepresentationContext"]] = None, + use_crs_displacement: bool = True, ) -> None: - """ - Export mesh data to GeoJSON format. + """Export mesh data to GeoJSON FeatureCollection. - :param mesh_list: List of AbstractMesh objects to export - :param out: Text output stream - :param options: GeoJSON export options + :param mesh_list: One or more meshes. + :param out: Text output stream. + :param options: GeoJSON export options. + :param contexts: Optional colour / metadata context dict. + :param use_crs_displacement: Apply CRS displacement to ``NumpyMesh`` points. """ - # Lazy import to avoid circular dependency - from energyml.utils.mesh import PolylineSetMesh, SurfaceMesh + from energyml.utils.data.mesh import PolylineSetMesh, SurfaceMesh + from energyml.utils.data.mesh_numpy import NumpyMesh, NumpyPointSetMesh, NumpyPolylineMesh if options is None: options = GeoJSONExportOptions() - features = [] - - for mesh_idx, mesh in enumerate(mesh_list): - indices = mesh.get_indices() - - if isinstance(mesh, PolylineSetMesh): - # Export as LineString features - for line_idx, line_indices in enumerate(indices): - if len(line_indices) < 2: - continue - coordinates = [list(mesh.point_list[idx]) for idx in line_indices] - feature = { - "type": "Feature", - "geometry": {"type": "LineString", "coordinates": coordinates}, - "properties": {"mesh_index": mesh_idx, "line_index": line_idx, **options.properties}, - } - features.append(feature) - - elif isinstance(mesh, SurfaceMesh): - # Export as Polygon features - for face_idx, face_indices in enumerate(indices): - if len(face_indices) < 3: - continue - # GeoJSON Polygon requires closed ring (first point == last point) - coordinates = [list(mesh.point_list[idx]) for idx in face_indices] - coordinates.append(coordinates[0]) # Close the ring - - feature = { - "type": "Feature", - "geometry": {"type": "Polygon", "coordinates": [coordinates]}, - "properties": {"mesh_index": mesh_idx, "face_index": face_idx, **options.properties}, - } - features.append(feature) - - geojson = {"type": "FeatureCollection", "features": features} - - json.dump(geojson, out, indent=options.indent) - - -def export_vtk(mesh_list: List["AbstractMesh"], out: BinaryIO, options: Optional[VTKExportOptions] = None) -> None: + patches = _normalize_to_patches(mesh_list) + workspace = _workspace_from_contexts(contexts) + features: List[dict] = [] + + for mesh in patches: + pts = _get_export_points(mesh, use_crs_displacement, workspace) + source_uuid = getattr(mesh, "source_uuid", None) + patch_idx = getattr(mesh, "patch_index", None) + color = _get_context_color(source_uuid, contexts) + base_props: dict = { + **options.properties, + "source_uuid": source_uuid, + "patch_index": patch_idx, + } + if color: + r, g, b, a = color + base_props["color"] = f"#{r:02x}{g:02x}{b:02x}" + base_props["opacity"] = round(a / 255.0, 4) + + if isinstance(mesh, NumpyMesh): + if isinstance(mesh, NumpyPointSetMesh): + coords = pts.tolist() + features.append( + { + "type": "Feature", + "geometry": {"type": "MultiPoint", "coordinates": coords}, + "properties": base_props, + } + ) + elif isinstance(mesh, NumpyPolylineMesh): + for seg in _parse_vtk_flat_lines(mesh.lines): + if len(seg) < 2: + continue + coords = pts[seg].tolist() + features.append( + { + "type": "Feature", + "geometry": {"type": "LineString", "coordinates": coords}, + "properties": base_props, + } + ) + else: + # NumpySurfaceMesh / NumpyVolumeMesh + for face in _parse_vtk_flat_faces(_get_faces_or_cells(mesh)): + if len(face) < 3: + continue + coords = pts[face].tolist() + coords.append(coords[0]) # close ring + features.append( + { + "type": "Feature", + "geometry": {"type": "Polygon", "coordinates": [coords]}, + "properties": base_props, + } + ) + else: + # AbstractMesh legacy path + indices = mesh.get_indices() + for elem_idx, elem in enumerate(indices): + if isinstance(mesh, PolylineSetMesh): + if len(elem) < 2: + continue + coords = [list(pts[i]) for i in elem] + features.append( + { + "type": "Feature", + "geometry": {"type": "LineString", "coordinates": coords}, + "properties": {**base_props, "element_index": elem_idx}, + } + ) + elif isinstance(mesh, SurfaceMesh): + if len(elem) < 3: + continue + coords = [list(pts[i]) for i in elem] + coords.append(coords[0]) + features.append( + { + "type": "Feature", + "geometry": {"type": "Polygon", "coordinates": [coords]}, + "properties": {**base_props, "element_index": elem_idx}, + } + ) + + json.dump({"type": "FeatureCollection", "features": features}, out, indent=options.indent) + + +# --------------------------------------------------------------------------- +# VTK export — private helpers +# --------------------------------------------------------------------------- + + +def _b64_vtk(arr: np.ndarray) -> str: + """Base64-encode a numpy array for VTK XML inline binary format. + + VTK prepends a 4-byte uint32 header with the byte count of the payload. """ - Export mesh data to VTK legacy format. - - :param mesh_list: List of AbstractMesh objects to export - :param out: Binary output stream - :param options: VTK export options + raw = arr.tobytes() + header = struct.pack("<I", len(raw)) + return base64.b64encode(header + raw).decode("ascii") + + +def _vtk_xml_data_array( + name: str, + arr: np.ndarray, + n_components: int = 1, + vtk_type: str = "Int64", +) -> str: + """Return a VTK XML ``<DataArray … />`` element string (base64 inline).""" + return ( + f'<DataArray type="{vtk_type}" Name="{name}" ' + f'NumberOfComponents="{n_components}" format="binary">' + f"{_b64_vtk(arr)}" + f"</DataArray>" + ) + + +def _collect_vtk_geometry( + patches: List[Any], + use_crs_displacement: bool, + workspace: Any, +) -> tuple: + """Merge all patches into flat VTK geometry arrays. + + Returns: + (all_pts, poly_conn, poly_off, line_conn, line_off, + vert_conn, vert_off, cell_types, patch_meta) + + *patch_meta* is a list of ``(source_uuid, n_cells)`` tuples used to + assign per-cell colour data. """ - # Lazy import to avoid circular dependency - from energyml.utils.mesh import PolylineSetMesh, SurfaceMesh + from energyml.utils.data.mesh import PolylineSetMesh, SurfaceMesh + from energyml.utils.data.mesh_numpy import NumpyMesh, NumpyPointSetMesh, NumpyPolylineMesh + + all_pts: List[np.ndarray] = [] + poly_conn: List[int] = [] + poly_off: List[int] = [] + line_conn: List[int] = [] + line_off: List[int] = [] + vert_conn: List[int] = [] + vert_off: List[int] = [] + cell_types: List[int] = [] + patch_meta: List[tuple] = [] # (source_uuid, cell_count) + + pt_offset = 0 + + for mesh in patches: + pts = _get_export_points(mesh, use_crs_displacement, workspace) + all_pts.append(np.asarray(pts, dtype=np.float64).reshape(-1, 3)) + source_uuid = getattr(mesh, "source_uuid", None) + cell_count = 0 + + if isinstance(mesh, NumpyMesh): + if isinstance(mesh, NumpyPointSetMesh): + for i in range(len(pts)): + vert_conn.append(i + pt_offset) + vert_off.append(len(vert_conn)) + cell_types.append(_VTK_VERTEX) + cell_count += 1 + elif isinstance(mesh, NumpyPolylineMesh): + for seg in _parse_vtk_flat_lines(mesh.lines): + for vi in seg: + line_conn.append(int(vi) + pt_offset) + line_off.append(len(line_conn)) + cell_types.append(_VTK_POLY_LINE) + cell_count += 1 + else: + faces_arr = _get_faces_or_cells(mesh) + for face in _parse_vtk_flat_faces(faces_arr): + nv = len(face) + for vi in face: + poly_conn.append(int(vi) + pt_offset) + poly_off.append(len(poly_conn)) + cell_types.append(_VTK_TRIANGLE if nv == 3 else _VTK_POLYGON) + cell_count += 1 + else: + # AbstractMesh legacy + indices = mesh.get_indices() + if isinstance(mesh, PolylineSetMesh): + for line in indices: + for vi in line: + line_conn.append(int(vi) + pt_offset) + line_off.append(len(line_conn)) + cell_types.append(_VTK_POLY_LINE) + cell_count += 1 + else: + for face in indices: + nv = len(face) + for vi in face: + poly_conn.append(int(vi) + pt_offset) + poly_off.append(len(poly_conn)) + cell_types.append(_VTK_TRIANGLE if nv == 3 else _VTK_POLYGON) + cell_count += 1 + + pt_offset += len(pts) + patch_meta.append((source_uuid, cell_count)) + + merged_pts = np.concatenate(all_pts) if all_pts else np.empty((0, 3), dtype=np.float64) + return ( + merged_pts, + np.array(poly_conn, dtype=np.int64), + np.array(poly_off, dtype=np.int64), + np.array(line_conn, dtype=np.int64), + np.array(line_off, dtype=np.int64), + np.array(vert_conn, dtype=np.int64), + np.array(vert_off, dtype=np.int64), + np.array(cell_types, dtype=np.uint8), + patch_meta, + ) + + +def _build_color_scalars( + patch_meta: List[tuple], + contexts: Optional[Dict[str, Any]], + total_cells: int, +) -> Optional[np.ndarray]: + """Build a ``(total_cells, 4)`` float32 RGBA array, or None when no colors found.""" + if not contexts: + return None + colors = np.full((total_cells, 4), 0.8, dtype=np.float32) + colors[:, 3] = 1.0 + any_found = False + cell_idx = 0 + for source_uuid, n_cells in patch_meta: + rgba = _get_context_color(source_uuid, contexts) + if rgba is not None: + any_found = True + r, g, b, a = rgba + colors[cell_idx : cell_idx + n_cells, 0] = r / 255.0 + colors[cell_idx : cell_idx + n_cells, 1] = g / 255.0 + colors[cell_idx : cell_idx + n_cells, 2] = b / 255.0 + colors[cell_idx : cell_idx + n_cells, 3] = a / 255.0 + cell_idx += n_cells + return colors if any_found else None + + +# --------------------------------------------------------------------------- +# VTK export — legacy (ASCII / binary) +# --------------------------------------------------------------------------- + + +def _export_vtk_legacy( + patches: List[Any], + out: BinaryIO, + options: VTKExportOptions, + contexts: Optional[Dict[str, Any]], + workspace: Any, +) -> None: + ascii_mode = options.vtk_format == VTKFormat.LEGACY_ASCII + ( + all_pts, + poly_conn, + poly_off, + line_conn, + line_off, + vert_conn, + vert_off, + cell_types, + patch_meta, + ) = _collect_vtk_geometry(patches, True, workspace) + + n_pts = len(all_pts) + n_poly = len(poly_off) + n_line = len(line_off) + n_vert = len(vert_off) + + def _unflatten(conn: np.ndarray, offs: np.ndarray) -> List[List[int]]: + result = [] + prev = 0 + for o in offs: + result.append(conn[prev:o].tolist()) + prev = o + return result + + polygons = _unflatten(poly_conn, poly_off) + lines = _unflatten(line_conn, line_off) + verts = _unflatten(vert_conn, vert_off) + + out.write(b"# vtk DataFile Version 3.0\n") + out.write(f"{options.dataset_name}\n".encode()) + out.write(b"ASCII\n" if ascii_mode else b"BINARY\n") + out.write(b"DATASET POLYDATA\n") + if ascii_mode: + out.write(f"POINTS {n_pts} float\n".encode()) + for pt in all_pts: + out.write(f"{pt[0]} {pt[1]} {pt[2]}\n".encode()) + else: + out.write(f"POINTS {n_pts} float\n".encode()) + out.write(all_pts.astype(">f4").tobytes()) + out.write(b"\n") + + def _write_section(name: str, cells: List[List[int]]) -> None: + if not cells: + return + total = sum(len(c) + 1 for c in cells) + out.write(f"{name} {len(cells)} {total}\n".encode()) + if ascii_mode: + for c in cells: + out.write(f"{len(c)} {' '.join(str(i) for i in c)}\n".encode()) + else: + for c in cells: + row = np.array([len(c)] + c, dtype=np.int32).byteswap().astype(">i4") + out.write(row.tobytes()) + out.write(b"\n") + + _write_section("POLYGONS", polygons) + _write_section("LINES", lines) + _write_section("VERTICES", verts) + + total_cells = n_poly + n_line + n_vert + if total_cells > 0 and contexts: + colors = _build_color_scalars(patch_meta, contexts, total_cells) + if colors is not None: + out.write(f"CELL_DATA {total_cells}\n".encode()) + out.write(b"COLOR_SCALARS patch_color 4\n") + if ascii_mode: + for row in colors: + out.write(f"{row[0]:.6f} {row[1]:.6f} {row[2]:.6f} {row[3]:.6f}\n".encode()) + else: + out.write(colors.astype(">f4").tobytes()) + out.write(b"\n") + + +# --------------------------------------------------------------------------- +# VTK export — XML VTU +# --------------------------------------------------------------------------- + + +def _export_vtk_vtu( + patches: List[Any], + out: BinaryIO, + options: VTKExportOptions, + contexts: Optional[Dict[str, Any]], + workspace: Any, +) -> None: + """Write VTK XML UnstructuredGrid (.vtu).""" + ( + all_pts, + poly_conn, + poly_off, + line_conn, + line_off, + vert_conn, + vert_off, + cell_types, + patch_meta, + ) = _collect_vtk_geometry(patches, True, workspace) + + # Build a single merged connectivity / offsets / types for UnstructuredGrid. + conn_parts: List[np.ndarray] = [] + off_parts: List[int] = [] + types_list: List[int] = [] + running = 0 + + def _add_vtu_section(conn: np.ndarray, offs: np.ndarray, default_type: int) -> None: + nonlocal running + prev = 0 + for o in offs: + seg = conn[prev:o] + conn_parts.append(seg) + running += len(seg) + off_parts.append(running) + types_list.append(default_type) + prev = o + + _add_vtu_section(vert_conn, vert_off, _VTK_VERTEX) + _add_vtu_section(line_conn, line_off, _VTK_POLY_LINE) + + # Polygons: honour per-cell type from cell_types array (triangle vs polygon). + n_verts_cells = len(vert_off) + n_lines_cells = len(line_off) + prev = 0 + for poly_i, o in enumerate(poly_off): + seg = poly_conn[prev:o] + conn_parts.append(seg) + running += len(seg) + off_parts.append(running) + abs_idx = n_verts_cells + n_lines_cells + poly_i + types_list.append(int(cell_types[abs_idx]) if abs_idx < len(cell_types) else _VTK_POLYGON) + prev = o + + all_conn = ( + np.concatenate([np.asarray(p, dtype=np.int64) for p in conn_parts]) + if conn_parts + else np.empty(0, dtype=np.int64) + ) + all_off = np.array(off_parts, dtype=np.int64) + all_types = np.array(types_list, dtype=np.uint8) + n_cells = len(all_types) + n_pts = len(all_pts) + + xml_lines: List[str] = [ + '<?xml version="1.0"?>', + '<VTKFile type="UnstructuredGrid" version="0.1" byte_order="LittleEndian">', + " <UnstructuredGrid>", + f' <Piece NumberOfPoints="{n_pts}" NumberOfCells="{n_cells}">', + " <Points>", + " " + _vtk_xml_data_array("Points", all_pts.astype(np.float32).ravel(), 3, "Float32"), + " </Points>", + " <Cells>", + " " + _vtk_xml_data_array("connectivity", all_conn, 1, "Int64"), + " " + _vtk_xml_data_array("offsets", all_off, 1, "Int64"), + " " + _vtk_xml_data_array("types", all_types, 1, "UInt8"), + " </Cells>", + ] + + if contexts and n_cells > 0: + colors = _build_color_scalars(patch_meta, contexts, n_cells) + if colors is not None: + xml_lines.append(" <CellData>") + xml_lines.append(" " + _vtk_xml_data_array("patch_color", colors.ravel(), 4, "Float32")) + xml_lines.append(" </CellData>") + + xml_lines += [" </Piece>", " </UnstructuredGrid>", "</VTKFile>"] + out.write("\n".join(xml_lines).encode("utf-8")) + + +# --------------------------------------------------------------------------- +# VTK export — XML VTP +# --------------------------------------------------------------------------- + + +def _export_vtk_vtp( + patches: List[Any], + out: BinaryIO, + options: VTKExportOptions, + contexts: Optional[Dict[str, Any]], + workspace: Any, +) -> None: + """Write VTK XML PolyData (.vtp).""" + ( + all_pts, + poly_conn, + poly_off, + line_conn, + line_off, + vert_conn, + vert_off, + cell_types, + patch_meta, + ) = _collect_vtk_geometry(patches, True, workspace) + + n_pts = len(all_pts) + n_polys = len(poly_off) + n_lines = len(line_off) + n_verts = len(vert_off) + total_cells = n_polys + n_lines + n_verts + + xml_lines: List[str] = [ + '<?xml version="1.0"?>', + '<VTKFile type="PolyData" version="0.1" byte_order="LittleEndian">', + " <PolyData>", + ( + f' <Piece NumberOfPoints="{n_pts}" NumberOfPolys="{n_polys}" ' + f'NumberOfLines="{n_lines}" NumberOfVerts="{n_verts}">' + ), + " <Points>", + " " + _vtk_xml_data_array("Points", all_pts.astype(np.float32).ravel(), 3, "Float32"), + " </Points>", + ] + + def _topo_section(tag: str, conn: np.ndarray, offs: np.ndarray) -> List[str]: + return [ + f" <{tag}>", + " " + _vtk_xml_data_array("connectivity", conn, 1, "Int64"), + " " + _vtk_xml_data_array("offsets", offs, 1, "Int64"), + f" </{tag}>", + ] + + if n_polys: + xml_lines.extend(_topo_section("Polys", poly_conn, poly_off)) + if n_lines: + xml_lines.extend(_topo_section("Lines", line_conn, line_off)) + if n_verts: + xml_lines.extend(_topo_section("Verts", vert_conn, vert_off)) + + if contexts and total_cells > 0: + colors = _build_color_scalars(patch_meta, contexts, total_cells) + if colors is not None: + xml_lines.append(" <CellData>") + xml_lines.append(" " + _vtk_xml_data_array("patch_color", colors.ravel(), 4, "Float32")) + xml_lines.append(" </CellData>") + + xml_lines += [" </Piece>", " </PolyData>", "</VTKFile>"] + out.write("\n".join(xml_lines).encode("utf-8")) + + +# --------------------------------------------------------------------------- +# VTK export — public entry point +# --------------------------------------------------------------------------- + + +def export_vtk( + mesh_list: Any, + out: BinaryIO, + options: Optional[VTKExportOptions] = None, + contexts: Optional[Dict[str, "RepresentationContext"]] = None, + use_crs_displacement: bool = True, +) -> None: + """Export mesh data to a VTK format. + + The sub-format is controlled by ``options.vtk_format`` (default: + ``VTKFormat.LEGACY_ASCII``). Supported variants: + + * **LEGACY_ASCII** — VTK 3.0 POLYDATA, ASCII encoding + * **LEGACY_BINARY** — VTK 3.0 POLYDATA, big-endian binary encoding + * **VTU** — VTK XML UnstructuredGrid (``.vtu``), base64 inline binary + * **VTP** — VTK XML PolyData (``.vtp``), base64 inline binary + + :param mesh_list: Meshes to export. + :param out: Binary output stream. + :param options: VTK export options. + :param contexts: Optional colour context dict keyed by ``source_uuid``. + :param use_crs_displacement: Apply CRS displacement to ``NumpyMesh`` points. + """ if options is None: options = VTKExportOptions() - # Combine all meshes - all_points = [] - all_polygons = [] - all_lines = [] - vertex_offset = 0 - - for mesh in mesh_list: - all_points.extend(mesh.point_list) - indices = mesh.get_indices() - - if isinstance(mesh, SurfaceMesh): - # Adjust face indices - for face in indices: - adjusted_face = [idx + vertex_offset for idx in face] - all_polygons.append(adjusted_face) - elif isinstance(mesh, PolylineSetMesh): - # Adjust line indices - for line in indices: - adjusted_line = [idx + vertex_offset for idx in line] - all_lines.append(adjusted_line) - - vertex_offset += len(mesh.point_list) - - # Write VTK header - out.write(b"# vtk DataFile Version 3.0\n") - out.write(f"{options.dataset_name}\n".encode("utf-8")) - out.write(b"ASCII\n") - out.write(b"DATASET POLYDATA\n") + patches = _normalize_to_patches(mesh_list) + # Pass workspace only when CRS displacement is actually requested. + workspace = _workspace_from_contexts(contexts) if use_crs_displacement else None - # Write points - out.write(f"POINTS {len(all_points)} float\n".encode("utf-8")) - for point in all_points: - out.write(f"{point[0]} {point[1]} {point[2]}\n".encode("utf-8")) + fmt = options.vtk_format + if fmt in (VTKFormat.LEGACY_ASCII, VTKFormat.LEGACY_BINARY): + _export_vtk_legacy(patches, out, options, contexts, workspace) + elif fmt == VTKFormat.VTU: + _export_vtk_vtu(patches, out, options, contexts, workspace) + elif fmt == VTKFormat.VTP: + _export_vtk_vtp(patches, out, options, contexts, workspace) + else: # pragma: no cover + raise ValueError(f"Unknown VTKFormat: {fmt}") - # Write polygons - if all_polygons: - total_poly_size = sum(len(poly) + 1 for poly in all_polygons) - out.write(f"POLYGONS {len(all_polygons)} {total_poly_size}\n".encode("utf-8")) - for poly in all_polygons: - out.write(f"{len(poly)} {' '.join(str(idx) for idx in poly)}\n".encode("utf-8")) - # Write lines - if all_lines: - total_line_size = sum(len(line) + 1 for line in all_lines) - out.write(f"LINES {len(all_lines)} {total_line_size}\n".encode("utf-8")) - for line in all_lines: - out.write(f"{len(line)} {' '.join(str(idx) for idx in line)}\n".encode("utf-8")) +# --------------------------------------------------------------------------- +# STL export +# --------------------------------------------------------------------------- -def export_stl(mesh_list: List["AbstractMesh"], out: BinaryIO, options: Optional[STLExportOptions] = None) -> None: - """ - Export mesh data to STL format (binary or ASCII). +def export_stl( + mesh_list: Any, + out: BinaryIO, + options: Optional[STLExportOptions] = None, + use_crs_displacement: bool = True, +) -> None: + """Export triangulated mesh data to STL format (binary or ASCII). - Note: STL format only supports triangles. Only triangular faces will be exported. + Non-triangular polygons are fan-triangulated (vertex 0 + consecutive pairs). + Polylines and point sets are silently skipped. - :param mesh_list: List of AbstractMesh objects to export - :param out: Binary output stream - :param options: STL export options + :param mesh_list: Meshes to export. + :param out: Binary output stream. + :param options: STL export options. + :param use_crs_displacement: Apply CRS displacement to ``NumpyMesh`` points. """ - # Lazy import to avoid circular dependency - from energyml.utils.mesh import SurfaceMesh + from energyml.utils.data.mesh import SurfaceMesh + from energyml.utils.data.mesh_numpy import NumpyMesh, NumpyPolylineMesh, NumpyPointSetMesh if options is None: options = STLExportOptions(binary=True) - # Collect all triangles (only from SurfaceMesh with triangular faces) - all_triangles = [] - for mesh in mesh_list: - if isinstance(mesh, SurfaceMesh): - indices = mesh.get_indices() - for face in indices: - # Only export triangular faces - if len(face) == 3: - p0 = np.array(mesh.point_list[face[0]]) - p1 = np.array(mesh.point_list[face[1]]) - p2 = np.array(mesh.point_list[face[2]]) - all_triangles.append((p0, p1, p2)) + patches = _normalize_to_patches(mesh_list) + # STL carries no colour / context; workspace not needed unless CRS is requested. + workspace = None # CRS requires a workspace — callers may read with CRS pre-applied. + + all_triangles: List[tuple] = [] + + for mesh in patches: + if isinstance(mesh, (NumpyPolylineMesh, NumpyPointSetMesh)): + continue # STL is surface-only + pts = _get_export_points(mesh, use_crs_displacement, workspace) + pts_np = np.asarray(pts, dtype=np.float64).reshape(-1, 3) + + if isinstance(mesh, NumpyMesh): + face_list = _parse_vtk_flat_faces(_get_faces_or_cells(mesh)) + else: + if not isinstance(mesh, SurfaceMesh): + continue + face_list = mesh.get_indices() + + for face in face_list: + face = list(face) + if len(face) < 3: + continue + if len(face) == 3: + all_triangles.append((pts_np[face[0]], pts_np[face[1]], pts_np[face[2]])) + else: + # Fan triangulation for quads and polygons + for j in range(1, len(face) - 1): + all_triangles.append((pts_np[face[0]], pts_np[face[j]], pts_np[face[j + 1]])) if options.binary: _export_stl_binary(all_triangles, out) @@ -284,206 +1018,171 @@ def export_stl(mesh_list: List["AbstractMesh"], out: BinaryIO, options: Optional _export_stl_ascii(all_triangles, out, options.ascii_precision) +def _compute_normal(p0: np.ndarray, p1: np.ndarray, p2: np.ndarray) -> np.ndarray: + v1, v2 = p1 - p0, p2 - p0 + n = np.cross(v1, v2) + norm = np.linalg.norm(n) + return n / norm if norm > 0 else np.zeros(3) + + def _export_stl_binary(triangles: List[tuple], out: BinaryIO) -> None: - """Export STL in binary format.""" - # Write 80-byte header header = b"Binary STL file generated by energyml-utils" + b"\0" * (80 - 44) out.write(header) - - # Write number of triangles out.write(struct.pack("<I", len(triangles))) - - # Write each triangle for p0, p1, p2 in triangles: - # Calculate normal vector - v1 = p1 - p0 - v2 = p2 - p0 - normal = np.cross(v1, v2) - norm = np.linalg.norm(normal) - if norm > 0: - normal = normal / norm - else: - normal = np.array([0.0, 0.0, 0.0]) - - # Write normal - out.write(struct.pack("<fff", float(normal[0]), float(normal[1]), float(normal[2]))) - - # Write vertices - for point in [p0, p1, p2]: - out.write(struct.pack("<fff", float(point[0]), float(point[1]), float(point[2]))) - - # Write attribute byte count (unused) + normal = _compute_normal(p0, p1, p2) + out.write(struct.pack("<fff", *normal.tolist())) + for pt in (p0, p1, p2): + out.write(struct.pack("<fff", float(pt[0]), float(pt[1]), float(pt[2]))) out.write(struct.pack("<H", 0)) def _export_stl_ascii(triangles: List[tuple], out: BinaryIO, precision: int) -> None: - """Export STL in ASCII format.""" out.write(b"solid mesh\n") - for p0, p1, p2 in triangles: - # Calculate normal vector - v1 = p1 - p0 - v2 = p2 - p0 - normal = np.cross(v1, v2) - norm = np.linalg.norm(normal) - if norm > 0: - normal = normal / norm - else: - normal = np.array([0.0, 0.0, 0.0]) - - # Write facet - line = f" facet normal {normal[0]:.{precision}e} {normal[1]:.{precision}e} {normal[2]:.{precision}e}\n" - out.write(line.encode("utf-8")) + normal = _compute_normal(p0, p1, p2) + out.write( + f" facet normal {normal[0]:.{precision}e} {normal[1]:.{precision}e} {normal[2]:.{precision}e}\n".encode() + ) out.write(b" outer loop\n") + for pt in (p0, p1, p2): + out.write(f" vertex {pt[0]:.{precision}e} {pt[1]:.{precision}e} {pt[2]:.{precision}e}\n".encode()) + out.write(b" endloop\n endfacet\n") + out.write(b"endsolid mesh\n") - for point in [p0, p1, p2]: - line = f" vertex {point[0]:.{precision}e} {point[1]:.{precision}e} {point[2]:.{precision}e}\n" - out.write(line.encode("utf-8")) - - out.write(b" endloop\n") - out.write(b" endfacet\n") - out.write(b"endsolid mesh\n") +# --------------------------------------------------------------------------- +# High-level dispatcher +# --------------------------------------------------------------------------- def export_mesh( - mesh_list: List["AbstractMesh"], + mesh_list: Any, output_path: Union[str, Path], format: Optional[ExportFormat] = None, options: Optional[ExportOptions] = None, + contexts: Optional[Dict[str, "RepresentationContext"]] = None, + use_crs_displacement: bool = True, ) -> None: - """ - Export mesh data to a file in the specified format. - - :param mesh_list: List of Mesh objects to export - :param output_path: Output file path - :param format: Export format (auto-detected from extension if None) - :param options: Format-specific export options + """Export mesh data to a file. + + Format is auto-detected from the file extension when *format* is None. + Supported extensions: ``.obj``, ``.geojson``, ``.vtk``, ``.vtu``, + ``.vtp``, ``.stl``. + + :param mesh_list: Meshes to export. + :param output_path: Destination file path. + :param format: Explicit format; auto-detected from extension when None. + :param options: Format-specific options. + :param contexts: Color / metadata context dict. + :param use_crs_displacement: Apply CRS displacement to ``NumpyMesh`` points. """ path = Path(output_path) - - # Auto-detect format from extension if not specified if format is None: format = ExportFormat.from_extension(path.suffix) - # Determine if file should be opened in binary or text mode - binary_formats = {ExportFormat.OBJ, ExportFormat.STL, ExportFormat.VTK} - text_formats = {ExportFormat.GEOJSON} - - if format in binary_formats: - with path.open("wb") as f: - if format == ExportFormat.OBJ: - export_obj(mesh_list, f) - elif format == ExportFormat.STL: - export_stl(mesh_list, f, options) - elif format == ExportFormat.VTK: - export_vtk(mesh_list, f, options) - elif format in text_formats: + if format == ExportFormat.GEOJSON: with path.open("w", encoding="utf-8") as f: - if format == ExportFormat.GEOJSON: - export_geojson(mesh_list, f, options) - else: - raise ValueError(f"Unsupported format: {format}") + export_geojson(mesh_list, f, options, contexts, use_crs_displacement) + return + + # All remaining formats use binary streams + with path.open("wb") as f: + if format == ExportFormat.OBJ: + if contexts: + mtl_path = path.with_suffix(".mtl") + with mtl_path.open("wb") as mf: + export_obj(mesh_list, f, path.stem, contexts, mf, use_crs_displacement) + else: + export_obj(mesh_list, f, path.stem, None, None, use_crs_displacement) + elif format == ExportFormat.STL: + export_stl(mesh_list, f, options, use_crs_displacement) + elif format == ExportFormat.VTK: + export_vtk(mesh_list, f, options, contexts, use_crs_displacement) + elif format == ExportFormat.VTU: + vtk_opts = options if isinstance(options, VTKExportOptions) else VTKExportOptions() + vtk_opts.vtk_format = VTKFormat.VTU + export_vtk(mesh_list, f, vtk_opts, contexts, use_crs_displacement) + elif format == ExportFormat.VTP: + vtk_opts = options if isinstance(options, VTKExportOptions) else VTKExportOptions() + vtk_opts.vtk_format = VTKFormat.VTP + export_vtk(mesh_list, f, vtk_opts, contexts, use_crs_displacement) + else: + raise ValueError(f"Unsupported format: {format}") +# --------------------------------------------------------------------------- # UI Helper Functions +# --------------------------------------------------------------------------- def supported_formats() -> List[str]: - """ - Get list of supported export formats. - - :return: List of format names (e.g., ['obj', 'geojson', 'vtk', 'stl']) - """ + """Return all supported export format extensions.""" return ExportFormat.all_extensions() def format_description(format: Union[str, ExportFormat]) -> str: - """ - Get human-readable description of a format. - - :param format: Format name or ExportFormat enum - :return: Description string - """ + """Return a human-readable description of *format*.""" if isinstance(format, str): format = ExportFormat.from_extension(format) - descriptions = { - ExportFormat.OBJ: "Wavefront OBJ - 3D geometry format (triangles and lines)", - ExportFormat.GEOJSON: "GeoJSON - Geographic data format (lines and polygons)", - ExportFormat.VTK: "VTK Legacy - Visualization Toolkit format", - ExportFormat.STL: "STL - Stereolithography format (triangles only)", + ExportFormat.OBJ: "Wavefront OBJ — 3D geometry with optional .mtl colour", + ExportFormat.GEOJSON: "GeoJSON — geographic data (lines, polygons, point clouds)", + ExportFormat.VTK: "VTK Legacy (ASCII or binary) — POLYDATA format", + ExportFormat.VTU: "VTK XML UnstructuredGrid (.vtu) — volumes + mixed topologies", + ExportFormat.VTP: "VTK XML PolyData (.vtp) — surfaces and polylines", + ExportFormat.STL: "STL — stereolithography (triangles only)", } return descriptions.get(format, "Unknown format") def format_filter_string(format: Union[str, ExportFormat]) -> str: - """ - Get file filter string for UI dialogs (Qt, tkinter, etc.). - - :param format: Format name or ExportFormat enum - :return: Filter string (e.g., "OBJ Files (*.obj)") - """ + """Return a file-dialog filter string (e.g. ``"VTU Files (*.vtu)"``).""" if isinstance(format, str): format = ExportFormat.from_extension(format) - filters = { ExportFormat.OBJ: "OBJ Files (*.obj)", ExportFormat.GEOJSON: "GeoJSON Files (*.geojson)", ExportFormat.VTK: "VTK Files (*.vtk)", + ExportFormat.VTU: "VTK XML UnstructuredGrid Files (*.vtu)", + ExportFormat.VTP: "VTK XML PolyData Files (*.vtp)", ExportFormat.STL: "STL Files (*.stl)", } return filters.get(format, "All Files (*.*)") def all_formats_filter_string() -> str: - """ - Get file filter string for all supported formats. - Useful for Qt QFileDialog or similar UI components. - - :return: Filter string with all formats - """ - filters = [format_filter_string(fmt) for fmt in ExportFormat] - return ";;".join(filters) + """Return a ``;;``-joined filter string for all supported formats.""" + return ";;".join(format_filter_string(fmt) for fmt in ExportFormat) def get_format_options_class(format: Union[str, ExportFormat]) -> Optional[type]: - """ - Get the options class for a specific format. - - :param format: Format name or ExportFormat enum - :return: Options class or None if no options available - """ + """Return the options class for *format*, or None.""" if isinstance(format, str): format = ExportFormat.from_extension(format) - - options_map = { + return { ExportFormat.STL: STLExportOptions, ExportFormat.VTK: VTKExportOptions, + ExportFormat.VTU: VTKExportOptions, + ExportFormat.VTP: VTKExportOptions, ExportFormat.GEOJSON: GeoJSONExportOptions, - } - return options_map.get(format) + }.get(format) def supports_lines(format: Union[str, ExportFormat]) -> bool: - """ - Check if format supports line primitives. - - :param format: Format name or ExportFormat enum - :return: True if format supports lines - """ + """Return True when *format* can represent polyline primitives.""" if isinstance(format, str): format = ExportFormat.from_extension(format) - - return format in {ExportFormat.OBJ, ExportFormat.GEOJSON, ExportFormat.VTK} + return format in {ExportFormat.OBJ, ExportFormat.GEOJSON, ExportFormat.VTK, ExportFormat.VTU, ExportFormat.VTP} def supports_triangles(format: Union[str, ExportFormat]) -> bool: - """ - Check if format supports triangle primitives. + """Return True when *format* can represent triangle / polygon primitives.""" + return True # All formats support triangles - :param format: Format name or ExportFormat enum - :return: True if format supports triangles - """ - # All formats support triangles - return True + +def supports_pointsets(format: Union[str, ExportFormat]) -> bool: + """Return True when *format* can represent point-cloud primitives.""" + if isinstance(format, str): + format = ExportFormat.from_extension(format) + return format in {ExportFormat.OBJ, ExportFormat.GEOJSON, ExportFormat.VTK, ExportFormat.VTU, ExportFormat.VTP} diff --git a/energyml-utils/src/energyml/utils/data/mesh_numpy.py b/energyml-utils/src/energyml/utils/data/mesh_numpy.py index 0e95408..c9d0d77 100644 --- a/energyml-utils/src/energyml/utils/data/mesh_numpy.py +++ b/energyml-utils/src/energyml/utils/data/mesh_numpy.py @@ -4,7 +4,7 @@ This module is a high-performance companion to :mod:`mesh.py`. It keeps the same ``read_<type>(energyml_object, workspace)`` dispatcher philosophy but -always returns :class:`NumpyMesh` dataclasses whose geometry arrays are +always returns :class:`NumpyMultiMesh` containers whose geometry arrays are :class:`numpy.ndarray` objects (never plain Python lists). Design goals @@ -20,17 +20,25 @@ use the VTK flat-count-prefixed format consumed directly by ``pyvista.PolyData`` and ``pyvista.UnstructuredGrid`` without additional allocation. +* **Patch-level control** - every representation is returned as a + :class:`NumpyMultiMesh` container. Each RESQML patch becomes a separate + :class:`NumpyMesh` entry in ``NumpyMultiMesh.patches``, carrying + ``patch_index``, ``patch_label``, ``source_uuid``, and ``source_type`` + metadata. ``RepresentationSetRepresentation`` members are stored as nested + ``NumpyMultiMesh.children`` so visibility can be toggled per-child in + PyVista ``MultiBlock`` viewers. * **Backward compatible** - :mod:`mesh.py` is untouched; both modules can be used side by side. Usage ----- >>> from energyml.utils.epc import Epc ->>> from energyml.utils.data.mesh_numpy import read_numpy_mesh_object, numpy_mesh_to_pyvista +>>> from energyml.utils.data.mesh_numpy import read_numpy_mesh_object, numpy_multi_mesh_to_pyvista >>> epc = Epc.read_file("my_model.epc") >>> obj = epc.get_object_by_uuid("...")[0] ->>> meshes = read_numpy_mesh_object(obj, workspace=epc, use_crs_displacement=True) ->>> pv_mesh = numpy_mesh_to_pyvista(meshes[0]) # requires pyvista +>>> multi = read_numpy_mesh_object(obj, workspace=epc, use_crs_displacement=True) +>>> block = numpy_multi_mesh_to_pyvista(multi) # pyvista.MultiBlock +>>> block.plot() """ from __future__ import annotations @@ -61,6 +69,7 @@ from energyml.utils.exception import NotSupportedError, ObjectNotFoundNotError from energyml.utils.introspection import ( get_obj_uri, + get_obj_uuid, get_object_attribute, search_attribute_matching_name, search_attribute_matching_name_with_path, @@ -130,6 +139,14 @@ class NumpyMesh: identifier: str = field(default="") #: Points array, shape (N, 3), dtype float64. May be a numpy view. points: np.ndarray = field(default_factory=lambda: np.empty((0, 3), dtype=np.float64)) + #: Index of this patch within the source representation (0-based). + patch_index: Optional[int] = field(default=None) + #: Human-readable label for this patch. + patch_label: Optional[str] = field(default=None) + #: UUID of the source RESQML object that produced this patch. + source_uuid: Optional[str] = field(default=None) + #: Python class name of the source RESQML object. + source_type: Optional[str] = field(default=None) def to_pyvista(self) -> Any: # return type: pv.DataSet """Convert to a PyVista dataset. Requires ``pyvista`` to be installed.""" @@ -178,6 +195,55 @@ class NumpyVolumeMesh(NumpyMesh): cell_types: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.uint8)) +@dataclass +class NumpyMultiMesh: + """Container for one or more :class:`NumpyMesh` patches from a single + energyml representation, plus optional nested child containers for + ``RepresentationSetRepresentation``. + + Hierarchy + --------- + * **patches** — flat list of :class:`NumpyMesh` subclass instances + produced directly by this representation (one per RESQML patch). + * **children** — nested :class:`NumpyMultiMesh` instances; populated only + by :func:`read_numpy_representation_set_representation` (one child per + member representation). + + The design is intentionally shallow: at most 2 levels (container → + patches) except for ``RepresentationSet`` which adds one extra level. + """ + + energyml_object: Any = field(default=None) + identifier: str = field(default="") + #: UUID of the source energyml object. + source_uuid: Optional[str] = field(default=None) + #: Python class name of the source energyml object. + source_type: Optional[str] = field(default=None) + #: Ordered list of patches produced by reading this representation. + patches: List["NumpyMesh"] = field(default_factory=list) + #: Child containers (only for RepresentationSetRepresentation). + children: List["NumpyMultiMesh"] = field(default_factory=list) + + # ------------------------------------------------------------------ + # Convenience helpers + # ------------------------------------------------------------------ + + def patch_count(self) -> int: + """Total number of leaf patches (recursive across children).""" + return len(self.patches) + sum(c.patch_count() for c in self.children) + + def flat_patches(self) -> List["NumpyMesh"]: + """Return all leaf patches in depth-first order.""" + result: List[NumpyMesh] = list(self.patches) + for child in self.children: + result.extend(child.flat_patches()) + return result + + def to_pyvista(self) -> Any: # return type: pv.MultiBlock + """Convert to a PyVista ``MultiBlock``. Requires ``pyvista``.""" + return numpy_multi_mesh_to_pyvista(self) + + # --------------------------------------------------------------------------- # CRS displacement (vectorised) # --------------------------------------------------------------------------- @@ -364,10 +430,17 @@ def read_numpy_point_representation( workspace: Optional[EnergymlStorageInterface] = None, use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, -) -> List[NumpyPointSetMesh]: +) -> "NumpyMultiMesh": """Read a ``PointRepresentation`` / ``PointSetRepresentation``.""" ws = _view_workspace(workspace) - meshes: List[NumpyPointSetMesh] = [] + src_uuid = get_obj_uuid(energyml_object) + src_type = type(energyml_object).__name__ + multi = NumpyMultiMesh( + energyml_object=energyml_object, + identifier=str(get_obj_uri(energyml_object)), + source_uuid=src_uuid, + source_type=src_type, + ) patch_idx = 0 total_size = 0 @@ -401,17 +474,22 @@ def read_numpy_point_representation( if use_crs_displacement and crs is not None and len(points) > 0: apply_from_crs_info(points, extract_crs_info(crs, workspace), inplace=True) - meshes.append( + label = f"{src_type}_patch_{patch_idx}" + multi.patches.append( NumpyPointSetMesh( - identifier=f"Patch num {patch_idx}", + identifier=label, energyml_object=energyml_object, crs_object=crs, points=points, + patch_index=patch_idx, + patch_label=label, + source_uuid=src_uuid, + source_type=src_type, ) ) patch_idx += 1 - return meshes + return multi def read_numpy_polyline_representation( @@ -419,10 +497,17 @@ def read_numpy_polyline_representation( workspace: Optional[EnergymlStorageInterface] = None, use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, -) -> List[NumpyPolylineMesh]: +) -> "NumpyMultiMesh": """Read a ``PolylineRepresentation`` / ``PolylineSetRepresentation``.""" ws = _view_workspace(workspace) - meshes: List[NumpyPolylineMesh] = [] + src_uuid = get_obj_uuid(energyml_object) + src_type = type(energyml_object).__name__ + multi = NumpyMultiMesh( + energyml_object=energyml_object, + identifier=str(get_obj_uri(energyml_object)), + source_uuid=src_uuid, + source_type=src_type, + ) patch_idx = 0 total_size = 0 @@ -461,16 +546,18 @@ def read_numpy_polyline_representation( pass # --- Node counts per polyline --- + # nc_arr holds the *original* counts (before closing); used both for + # VTK-array construction and for sub_indices filtering below. + nc_arr: Optional[np.ndarray] = None lines: np.ndarray try: nc_path, nc_obj = search_attribute_matching_name_with_path(patch, "NodeCountPerPolyline")[0] - node_counts = _read_array_np(nc_obj, energyml_object, patch_path_in_obj + nc_path, ws) - node_counts = node_counts.astype(np.int64).ravel() + nc_arr = _read_array_np(nc_obj, energyml_object, patch_path_in_obj + nc_path, ws).astype(np.int64).ravel() # Build VTK lines array respecting closed flags parts: List[np.ndarray] = [] offset = 0 - for poly_idx, n in enumerate(node_counts): + for poly_idx, n in enumerate(nc_arr): n = int(n) indices = np.arange(offset, offset + n, dtype=np.int64) if close_poly is not None and poly_idx < len(close_poly) and close_poly[poly_idx]: @@ -487,22 +574,50 @@ def read_numpy_polyline_representation( lines = _build_vtk_lines_from_segments(len(points)) # --- sub_indices filtering --- - # sub_indices apply to individual polylines (line segments), not points. - # We keep the full point array and subset the line connectivity. + # sub_indices select individual *polylines* (by index within this patch). + # We filter the VTK flat `lines` buffer and also subset `points` to + # keep only the nodes referenced by the surviving polylines. if sub_indices is not None and len(sub_indices) > 0: - # Reconstruct per-polyline ranges so we can filter - try: - nc_path, nc_obj = search_attribute_matching_name_with_path(patch, "NodeCountPerPolyline")[0] - node_counts = _read_array_np(nc_obj, energyml_object, patch_path_in_obj + nc_path, ws) - total_polylines = len(node_counts) - except IndexError: - total_polylines = 1 - + total_polylines = len(nc_arr) if nc_arr is not None else 1 t_idx = np.asarray(sub_indices, dtype=np.int64) - total_size - _valid = t_idx[(t_idx >= 0) & (t_idx < total_polylines)] - # Rebuild lines for the selected polylines only (simplified: keep all lines) - # Full filtering requires splitting the flat array — skip for now; document. + _valid = np.sort(t_idx[(t_idx >= 0) & (t_idx < total_polylines)]) total_size += total_polylines + + if nc_arr is not None and len(_valid) > 0: + # Walk the VTK flat buffer once to record per-polyline slice bounds. + pos = 0 + poly_slices: List[Tuple[int, int]] = [] + for _ in range(total_polylines): + n_vtk = int(lines[pos]) + poly_slices.append((pos, pos + n_vtk + 1)) + pos += n_vtk + 1 + + # Original point ranges per polyline (nc_arr gives node counts). + pt_offsets = np.concatenate([[0], np.cumsum(nc_arr)]) + + # Gather contiguous point ranges for the selected polylines. + keep_ranges = [ + np.arange(int(pt_offsets[i]), int(pt_offsets[i + 1]), dtype=np.int64) + for i in _valid + ] + keep_pts = np.concatenate(keep_ranges) if keep_ranges else np.empty(0, dtype=np.int64) + + # Build a full remapping: old_pt_idx → new_pt_idx (-1 = not kept). + new_pt_idx = np.full(len(points), -1, dtype=np.int64) + new_pt_idx[keep_pts] = np.arange(len(keep_pts), dtype=np.int64) + points = points[keep_pts] + + # Re-index VTK segments for the selected polylines. + rebuilt: List[np.ndarray] = [] + for i in _valid: + s, e = poly_slices[i] + seg = lines[s:e].copy() + seg[1:] = new_pt_idx[seg[1:]] + rebuilt.append(seg) + lines = np.concatenate(rebuilt) if rebuilt else np.empty(0, dtype=np.int64) + elif len(_valid) == 0: + points = np.empty((0, 3), dtype=np.float64) + lines = np.empty(0, dtype=np.int64) else: total_size += 1 # at least one polyline @@ -512,18 +627,23 @@ def read_numpy_polyline_representation( apply_from_crs_info(points, extract_crs_info(crs, workspace), inplace=True) if len(points) > 0: - meshes.append( + label = f"{src_type}_patch_{patch_idx}" + multi.patches.append( NumpyPolylineMesh( - identifier=f"{get_obj_uri(energyml_object)}_patch{patch_idx}", + identifier=label, energyml_object=energyml_object, crs_object=crs, points=points, lines=lines, + patch_index=patch_idx, + patch_label=label, + source_uuid=src_uuid, + source_type=src_type, ) ) patch_idx += 1 - return meshes + return multi def read_numpy_triangulated_set_representation( @@ -531,7 +651,7 @@ def read_numpy_triangulated_set_representation( workspace: Optional[EnergymlStorageInterface] = None, use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, -) -> List[NumpySurfaceMesh]: +) -> "NumpyMultiMesh": """Read a ``TriangulatedSetRepresentation`` as numpy-backed surface meshes. Key differences vs :func:`mesh.read_triangulated_set_representation`: @@ -542,7 +662,14 @@ def read_numpy_triangulated_set_representation( :func:`numpy.column_stack` — no Python loops over triangles. """ ws = _view_workspace(workspace) - meshes: List[NumpySurfaceMesh] = [] + src_uuid = get_obj_uuid(energyml_object) + src_type = type(energyml_object).__name__ + multi = NumpyMultiMesh( + energyml_object=energyml_object, + identifier=str(get_obj_uri(energyml_object)), + source_uuid=src_uuid, + source_type=src_type, + ) point_offset = 0 patch_idx = 0 total_size = 0 @@ -609,19 +736,24 @@ def read_numpy_triangulated_set_representation( # Build VTK flat faces array: [3, v0, v1, v2, 3, v0, v1, v2, …] faces = _build_vtk_faces_from_triangles(triangles) - meshes.append( + label = f"{src_type}_patch_{patch_idx}" + multi.patches.append( NumpySurfaceMesh( - identifier=f"{get_obj_uri(energyml_object)}_patch{patch_idx}", + identifier=label, energyml_object=energyml_object, crs_object=crs, points=points, faces=faces, + patch_index=patch_idx, + patch_label=label, + source_uuid=src_uuid, + source_type=src_type, ) ) point_offset += len(points) patch_idx += 1 - return meshes + return multi def read_numpy_grid2d_representation( @@ -630,14 +762,21 @@ def read_numpy_grid2d_representation( use_crs_displacement: bool = True, keep_holes: bool = False, sub_indices: Optional[Union[List[int], np.ndarray]] = None, -) -> List[NumpySurfaceMesh]: +) -> "NumpyMultiMesh": """Read a ``Grid2dRepresentation`` as a numpy quad-surface mesh. NaN-hole handling is done with boolean masks and cumsum-based index remapping (O(N) vs the O(N) dict-based approach in :func:`mesh.gen_surface_grid_geometry`, but avoids Python dict overhead for large grids). """ - meshes: List[NumpySurfaceMesh] = [] + src_uuid = get_obj_uuid(energyml_object) + src_type = type(energyml_object).__name__ + multi = NumpyMultiMesh( + energyml_object=energyml_object, + identifier=str(get_obj_uri(energyml_object)), + source_uuid=src_uuid, + source_type=src_type, + ) patch_idx = 0 total_size = 0 @@ -650,9 +789,9 @@ def _process_patch(patch: Any, patch_path: str, crs: Any) -> Optional[NumpySurfa path_in_root=patch_path, workspace=workspace, ) - if not raw_pts: + pts = np.asarray(raw_pts, dtype=np.float64) if raw_pts is not None else np.empty((0, 3)) + if pts.size == 0: return None - pts = np.asarray(raw_pts, dtype=np.float64) # (K, 3) or (K,) if malformed if pts.ndim == 1: pts = pts.reshape(-1, 3) @@ -722,12 +861,17 @@ def _process_patch(patch: Any, patch_path: str, crs: Any) -> Optional[NumpySurfa total_size += len(quads) faces = _build_vtk_faces_from_quads(quads) + label = f"{src_type}_patch_{patch_idx}" mesh = NumpySurfaceMesh( - identifier=f"{get_obj_uri(energyml_object)}_patch{patch_idx}", + identifier=label, energyml_object=energyml_object, crs_object=crs, points=final_pts, faces=faces, + patch_index=patch_idx, + patch_label=label, + source_uuid=src_uuid, + source_type=src_type, ) patch_idx += 1 return mesh @@ -746,7 +890,7 @@ def _process_patch(patch: Any, patch_path: str, crs: Any) -> Optional[NumpySurfa pass m = _process_patch(patch, patch_path, crs) if m is not None: - meshes.append(m) + multi.patches.append(m) # RESQML 2.2 — geometry directly on the object if hasattr(energyml_object, "geometry"): @@ -762,9 +906,9 @@ def _process_patch(patch: Any, patch_path: str, crs: Any) -> Optional[NumpySurfa logging.error(e) m = _process_patch(energyml_object, "", crs) if m is not None: - meshes.append(m) + multi.patches.append(m) - return meshes + return multi def read_numpy_wellbore_trajectory_representation( @@ -774,19 +918,20 @@ def read_numpy_wellbore_trajectory_representation( sub_indices: Optional[Union[List[int], np.ndarray]] = None, wellbore_frame_mds: Optional[Union[List[float], np.ndarray]] = None, step_meter: float = 5.0, -) -> List[NumpyPolylineMesh]: +) -> "NumpyMultiMesh": """Read a ``WellboreTrajectoryRepresentation`` as a numpy polyline mesh.""" if energyml_object is None: - return [] + return NumpyMultiMesh(identifier="empty_wellbore_trajectory") if isinstance(energyml_object, list): - return [ - mesh - for obj in energyml_object - for mesh in read_numpy_wellbore_trajectory_representation( - obj, workspace, use_crs_displacement, sub_indices, wellbore_frame_mds, step_meter + synthetic = NumpyMultiMesh(identifier="WellboreTrajectoryRepresentation_list") + for obj in energyml_object: + synthetic.children.append( + read_numpy_wellbore_trajectory_representation( + obj, workspace, use_crs_displacement, sub_indices, wellbore_frame_mds, step_meter + ) ) - ] + return synthetic crs = None head_x = head_y = head_z = 0.0 @@ -853,20 +998,37 @@ def read_numpy_wellbore_trajectory_representation( ) if well_points_list is None or len(well_points_list) == 0: - return [] + return NumpyMultiMesh( + energyml_object=energyml_object, + identifier=str(get_obj_uri(energyml_object)), + source_uuid=get_obj_uuid(energyml_object), + source_type=type(energyml_object).__name__, + ) pts = _ensure_float64_points(np.asarray(well_points_list, dtype=np.float64)) lines = _build_vtk_lines_from_segments(len(pts)) - - return [ - NumpyPolylineMesh( - identifier=str(get_obj_uri(energyml_object)), - energyml_object=energyml_object, - crs_object=crs, - points=pts, - lines=lines, - ) - ] + src_uuid = get_obj_uuid(energyml_object) + src_type = type(energyml_object).__name__ + label = f"{src_type}_patch_0" + return NumpyMultiMesh( + energyml_object=energyml_object, + identifier=str(get_obj_uri(energyml_object)), + source_uuid=src_uuid, + source_type=src_type, + patches=[ + NumpyPolylineMesh( + identifier=label, + energyml_object=energyml_object, + crs_object=crs, + points=pts, + lines=lines, + patch_index=0, + patch_label=label, + source_uuid=src_uuid, + source_type=src_type, + ) + ], + ) def read_numpy_wellbore_frame_representation( @@ -874,10 +1036,15 @@ def read_numpy_wellbore_frame_representation( workspace: Optional[EnergymlStorageInterface] = None, use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, -) -> List[NumpyPolylineMesh]: +) -> "NumpyMultiMesh": """Read a ``WellboreFrameRepresentation`` as a numpy polyline mesh.""" ws = _view_workspace(workspace) - meshes: List[NumpyPolylineMesh] = [] + empty = NumpyMultiMesh( + energyml_object=energyml_object, + identifier=str(get_obj_uri(energyml_object)), + source_uuid=get_obj_uuid(energyml_object), + source_type=type(energyml_object).__name__, + ) try: node_md_path, node_md_obj = search_attribute_matching_name_with_path(energyml_object, "NodeMd")[0] @@ -886,7 +1053,7 @@ def read_numpy_wellbore_frame_representation( wellbore_frame_mds = np.asarray(wellbore_frame_mds, dtype=np.float64) except (IndexError, AttributeError) as e: logging.warning(f"Could not read NodeMd from wellbore frame: {e}") - return meshes + return empty md_min = float(wellbore_frame_mds.min()) if len(wellbore_frame_mds) > 0 else 0.0 md_max = float(wellbore_frame_mds.max()) if len(wellbore_frame_mds) > 0 else 0.0 @@ -906,16 +1073,20 @@ def read_numpy_wellbore_frame_representation( trajectory_dor = search_attribute_matching_name(obj=energyml_object, name_rgx="Trajectory")[0] trajectory_obj = workspace.get_object(get_obj_uri(trajectory_dor)) - meshes = read_numpy_wellbore_trajectory_representation( + result = read_numpy_wellbore_trajectory_representation( energyml_object=trajectory_obj, workspace=workspace, use_crs_displacement=use_crs_displacement, sub_indices=sub_indices, wellbore_frame_mds=wellbore_frame_mds, ) - for m in meshes: - m.identifier = str(get_obj_uri(energyml_object)) - return meshes + frame_uri = str(get_obj_uri(energyml_object)) + for m in result.flat_patches(): + m.identifier = frame_uri + result.identifier = frame_uri + result.source_uuid = get_obj_uuid(energyml_object) + result.source_type = type(energyml_object).__name__ + return result def read_numpy_sub_representation( @@ -923,7 +1094,7 @@ def read_numpy_sub_representation( workspace: Optional[EnergymlStorageInterface] = None, use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, -) -> List[NumpyMesh]: +) -> "NumpyMultiMesh": """Delegate to the supporting representation with filtered indices.""" ws = _view_workspace(workspace) supporting_rep_dor = search_attribute_matching_name( @@ -952,15 +1123,23 @@ def read_numpy_sub_representation( total_size += len(arr) all_indices = np.concatenate([all_indices, arr]) if all_indices is not None else arr - meshes = read_numpy_mesh_object( + inner = read_numpy_mesh_object( energyml_object=supporting_rep, workspace=workspace, use_crs_displacement=use_crs_displacement, sub_indices=all_indices.tolist() if all_indices is not None else None, ) - for m in meshes: - m.identifier = f"sub representation {get_obj_uri(energyml_object)} of {m.identifier}" - return meshes + sub_uri = str(get_obj_uri(energyml_object)) + for m in inner.flat_patches(): + m.identifier = f"sub_rep_{sub_uri}/{m.identifier}" + return NumpyMultiMesh( + energyml_object=energyml_object, + identifier=sub_uri, + source_uuid=get_obj_uuid(energyml_object), + source_type=type(energyml_object).__name__, + patches=[], + children=[inner], + ) def read_numpy_representation_set_representation( @@ -968,33 +1147,37 @@ def read_numpy_representation_set_representation( workspace: Optional[EnergymlStorageInterface] = None, use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, -) -> List[NumpyMesh]: - """Delegate to each child representation.""" +) -> "NumpyMultiMesh": + """Delegate to each child representation; nest results as children.""" + multi = NumpyMultiMesh( + energyml_object=energyml_object, + identifier=str(get_obj_uri(energyml_object)), + source_uuid=get_obj_uuid(energyml_object), + source_type=type(energyml_object).__name__, + ) repr_list = get_object_attribute(energyml_object, "representation") if repr_list is None or not isinstance(repr_list, list): - return [] - meshes: List[NumpyMesh] = [] + return multi for repr_dor in repr_list: rpr_uri = get_obj_uri(repr_dor) repr_obj = workspace.get_object(rpr_uri) if repr_obj is None: logging.error(f"Representation {rpr_uri} not found in RepresentationSetRepresentation") continue - meshes.extend( - read_numpy_mesh_object( - energyml_object=repr_obj, - workspace=workspace, - use_crs_displacement=use_crs_displacement, - ) + child = read_numpy_mesh_object( + energyml_object=repr_obj, + workspace=workspace, + use_crs_displacement=use_crs_displacement, ) - return meshes + multi.children.append(child) + return multi def read_numpy_ijk_grid_representation( energyml_object: Any, workspace: Optional[EnergymlStorageInterface] = None, sub_indices: Optional[Union[List[int], np.ndarray]] = None, -) -> List[NumpyMesh]: +) -> "NumpyMultiMesh": """Stub — IjkGridRepresentation is not yet implemented.""" raise NotSupportedError( "IjkGridRepresentation is not yet supported in mesh_numpy. " @@ -1006,7 +1189,7 @@ def read_numpy_unstructured_grid_representation( energyml_object: Any, workspace: Optional[EnergymlStorageInterface] = None, sub_indices: Optional[Union[List[int], np.ndarray]] = None, -) -> List[NumpyMesh]: +) -> "NumpyMultiMesh": """Stub — UnstructuredGridRepresentation is not yet implemented.""" raise NotSupportedError( "UnstructuredGridRepresentation is not yet supported in mesh_numpy. " @@ -1022,30 +1205,42 @@ def read_numpy_unstructured_grid_representation( def read_numpy_mesh_object( energyml_object: Any, workspace: Optional[EnergymlStorageInterface] = None, - use_crs_displacement: bool = False, + use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, -) -> List[NumpyMesh]: +) -> "NumpyMultiMesh": """Dispatcher — equivalent to :func:`mesh.read_mesh_object` but returns - :class:`NumpyMesh` objects. + a :class:`NumpyMultiMesh` container. Args: energyml_object: Any supported RESQML/EnergyML geometry/representation object. workspace: Storage interface (``Epc`` or ``EpcStreamReader``). - use_crs_displacement: When ``True``, applies + use_crs_displacement: When ``True`` (default), applies :func:`crs_displacement_np` to the points of every returned mesh (excluding wellbore representations which apply the transform internally). sub_indices: Optional list of face/line/point indices to include. Returns: - List of :class:`NumpyMesh` subclass instances. + :class:`NumpyMultiMesh` containing one or more :class:`NumpyMesh` patches + (and/or nested children for ``RepresentationSetRepresentation``). Raises: :exc:`energyml.utils.exception.NotSupportedError`: if the object type has no registered reader. """ if isinstance(energyml_object, list): - return energyml_object # type: ignore[return-value] + # Synthetic container aggregating multiple top-level objects. + synthetic = NumpyMultiMesh(identifier="multi_object_list") + for obj in energyml_object: + synthetic.children.append( + read_numpy_mesh_object( + energyml_object=obj, + workspace=workspace, + use_crs_displacement=use_crs_displacement, + sub_indices=sub_indices, + ) + ) + return synthetic type_name = _numpy_mesh_name_mapping(type(energyml_object).__name__) reader_func = get_numpy_reader_function(type_name) @@ -1058,13 +1253,15 @@ def read_numpy_mesh_object( f"Expected function 'read_numpy_{snake_case(type_name)}' in {__name__}." ) - meshes: List[NumpyMesh] = reader_func( + result: NumpyMultiMesh = reader_func( energyml_object=energyml_object, workspace=workspace, sub_indices=sub_indices, use_crs_displacement=use_crs_displacement, ) + # Apply fallback CRS displacement for readers that do NOT handle it + # internally (e.g. Grid2d which has no per-patch CRS apply call yet). _tn = type_name.lower() if ( use_crs_displacement @@ -1072,19 +1269,19 @@ def read_numpy_mesh_object( and "triangulated" not in _tn # per-patch CRS applied inside reader and "point" not in _tn # per-patch CRS applied inside reader and "polyline" not in _tn # per-patch CRS applied inside reader - and "representationset" not in _tn # each sub-mesh already had CRS applied by its own reader - and "subrepresentation" not in _tn # delegates entirely to inner read_numpy_mesh_object call + and "representationset" not in _tn # each child already had CRS applied + and "subrepresentation" not in _tn # delegates entirely to inner call ): - for m in meshes: + for m in result.flat_patches(): crs = m.crs_object[0] if isinstance(m.crs_object, list) and m.crs_object else m.crs_object if crs is not None and len(m.points) > 0: crs_displacement_np(m.points, crs, inplace=True) - return meshes + return result # --------------------------------------------------------------------------- -# PyVista converter +# PyVista converters # --------------------------------------------------------------------------- @@ -1125,6 +1322,35 @@ def numpy_mesh_to_pyvista(mesh: NumpyMesh) -> Any: return pv.PolyData(pts) +def numpy_multi_mesh_to_pyvista(multi: "NumpyMultiMesh") -> Any: + """Convert a :class:`NumpyMultiMesh` to a ``pyvista.MultiBlock``. + + The resulting ``MultiBlock`` mirrors the two-level hierarchy of + :class:`NumpyMultiMesh`: + + * Child containers (e.g. ``RepresentationSetRepresentation`` members) become + nested ``MultiBlock`` blocks, keyed by their ``identifier``. + * Direct patches become leaf ``PolyData`` / ``UnstructuredGrid`` blocks, + keyed by ``patch_label`` or ``"patch_{patch_index}"``. + + Requires ``pyvista`` to be installed (``pip install pyvista``). + """ + try: + import pyvista as pv # type: ignore[import] + except ImportError as exc: + raise ImportError("pyvista is not installed. Install it with: pip install pyvista") from exc + + block: pv.MultiBlock = pv.MultiBlock() + for child in multi.children: + block.append(numpy_multi_mesh_to_pyvista(child), child.identifier or "child") + for patch in multi.patches: + ds = numpy_mesh_to_pyvista(patch) + if ds is not None: + name = patch.patch_label or (f"patch_{patch.patch_index}" if patch.patch_index is not None else "patch") + block.append(ds, name) + return block + + # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- @@ -1136,6 +1362,7 @@ def numpy_mesh_to_pyvista(mesh: NumpyMesh) -> Any: "NumpyPolylineMesh", "NumpySurfaceMesh", "NumpyVolumeMesh", + "NumpyMultiMesh", # CRS "crs_displacement_np", # Readers @@ -1150,6 +1377,7 @@ def numpy_mesh_to_pyvista(mesh: NumpyMesh) -> Any: "read_numpy_representation_set_representation", "read_numpy_ijk_grid_representation", "read_numpy_unstructured_grid_representation", - # Converter + # Converters "numpy_mesh_to_pyvista", + "numpy_multi_mesh_to_pyvista", ] diff --git a/energyml-utils/src/energyml/utils/representation_context.py b/energyml-utils/src/energyml/utils/data/representation_context.py similarity index 100% rename from energyml-utils/src/energyml/utils/representation_context.py rename to energyml-utils/src/energyml/utils/data/representation_context.py diff --git a/energyml-utils/tests/test_array_handlers.py b/energyml-utils/tests/test_array_handlers.py index 6d53add..3c99588 100644 --- a/energyml-utils/tests/test_array_handlers.py +++ b/energyml-utils/tests/test_array_handlers.py @@ -59,6 +59,7 @@ def test_hdf5_array_handler_read_write(): pass +@pytest.mark.skip(reason="Requires 'parquet' extra: pip install energyml-utils[parquet]") def test_parquet_array_handler_read_write(): """Test ParquetArrayHandler read/write.""" arr = np.arange(6).reshape(2, 3) diff --git a/energyml-utils/tests/test_mesh_numpy.py b/energyml-utils/tests/test_mesh_numpy.py index 24faa0f..1c24422 100644 --- a/energyml-utils/tests/test_mesh_numpy.py +++ b/energyml-utils/tests/test_mesh_numpy.py @@ -22,6 +22,7 @@ from energyml.utils.data.mesh_numpy import ( NumpyMesh, + NumpyMultiMesh, NumpyPointSetMesh, NumpyPolylineMesh, NumpySurfaceMesh, @@ -34,6 +35,7 @@ crs_displacement_np, read_numpy_mesh_object, numpy_mesh_to_pyvista, + numpy_multi_mesh_to_pyvista, ) # --------------------------------------------------------------------------- @@ -336,17 +338,19 @@ def test_triangulated_set_returns_surface_mesh(self, epc22): obj = epc22.get_object_by_uuid("6e678338-3b53-49b6-8801-faee493e0c42") if not obj: pytest.skip("TriangulatedSet UUID not found in fixture EPC") - meshes = read_numpy_mesh_object(obj[0], workspace=epc22) - assert meshes, "Expected at least one mesh" - for m in meshes: + multi = read_numpy_mesh_object(obj[0], workspace=epc22) + assert isinstance(multi, NumpyMultiMesh) + patches = multi.flat_patches() + assert patches, "Expected at least one patch" + for m in patches: assert isinstance(m, NumpySurfaceMesh) def test_triangulated_set_points_shape_dtype(self, epc22): obj = epc22.get_object_by_uuid("6e678338-3b53-49b6-8801-faee493e0c42") if not obj: pytest.skip("TriangulatedSet UUID not found in fixture EPC") - meshes = read_numpy_mesh_object(obj[0], workspace=epc22) - for m in meshes: + multi = read_numpy_mesh_object(obj[0], workspace=epc22) + for m in multi.flat_patches(): assert m.points.ndim == 2 assert m.points.shape[1] == 3 assert m.points.dtype == np.float64 @@ -355,8 +359,8 @@ def test_triangulated_set_faces_dtype_and_format(self, epc22): obj = epc22.get_object_by_uuid("6e678338-3b53-49b6-8801-faee493e0c42") if not obj: pytest.skip("TriangulatedSet UUID not found in fixture EPC") - meshes = read_numpy_mesh_object(obj[0], workspace=epc22) - for m in meshes: + multi = read_numpy_mesh_object(obj[0], workspace=epc22) + for m in multi.flat_patches(): assert isinstance(m, NumpySurfaceMesh) assert m.faces.dtype == np.int64 assert m.faces.ndim == 1 @@ -368,8 +372,8 @@ def test_triangulated_set_no_lists(self, epc22): obj = epc22.get_object_by_uuid("6e678338-3b53-49b6-8801-faee493e0c42") if not obj: pytest.skip("TriangulatedSet UUID not found in fixture EPC") - meshes = read_numpy_mesh_object(obj[0], workspace=epc22) - for m in meshes: + multi = read_numpy_mesh_object(obj[0], workspace=epc22) + for m in multi.flat_patches(): assert isinstance(m.points, np.ndarray), "points must be ndarray" assert isinstance(m.faces, np.ndarray), "faces must be ndarray" @@ -378,9 +382,11 @@ def test_pointset_returns_pointset_mesh(self, epc22): obj = epc22.get_object_by_uuid("fbc5466c-94cd-46ab-8b48-2ae2162b372f") if not obj: pytest.skip("PointSet UUID not found in fixture EPC") - meshes = read_numpy_mesh_object(obj[0], workspace=epc22) - assert meshes - for m in meshes: + multi = read_numpy_mesh_object(obj[0], workspace=epc22) + assert isinstance(multi, NumpyMultiMesh) + patches = multi.flat_patches() + assert patches + for m in patches: assert isinstance(m, NumpyPointSetMesh) assert m.points.ndim == 2 assert m.points.shape[1] == 3 @@ -391,9 +397,11 @@ def test_polyline_returns_polyline_mesh(self, epc22): obj = epc22.get_object_by_uuid("a54b8399-d3ba-4d4b-b215-8d4f8f537e66") if not obj: pytest.skip("Polyline UUID not found in fixture EPC") - meshes = read_numpy_mesh_object(obj[0], workspace=epc22) - assert meshes - for m in meshes: + multi = read_numpy_mesh_object(obj[0], workspace=epc22) + assert isinstance(multi, NumpyMultiMesh) + patches = multi.flat_patches() + assert patches + for m in patches: assert isinstance(m, NumpyPolylineMesh) assert m.points.dtype == np.float64 assert m.lines.dtype == np.int64 @@ -403,9 +411,11 @@ def test_wellbore_frame_returns_polyline(self, epc22): obj = epc22.get_object_by_uuid("d873e243-d893-41ab-9a3e-d20b851c099f") if not obj: pytest.skip("WellboreFrame UUID not found in fixture EPC") - meshes = read_numpy_mesh_object(obj[0], workspace=epc22) - assert meshes - for m in meshes: + multi = read_numpy_mesh_object(obj[0], workspace=epc22) + assert isinstance(multi, NumpyMultiMesh) + patches = multi.flat_patches() + assert patches + for m in patches: assert isinstance(m, NumpyPolylineMesh) assert m.points.ndim == 2 assert m.points.shape[1] == 3 @@ -414,8 +424,8 @@ def test_wellbore_frame_lines_vtk_format(self, epc22): obj = epc22.get_object_by_uuid("d873e243-d893-41ab-9a3e-d20b851c099f") if not obj: pytest.skip("WellboreFrame UUID not found in fixture EPC") - meshes = read_numpy_mesh_object(obj[0], workspace=epc22) - for m in meshes: + multi = read_numpy_mesh_object(obj[0], workspace=epc22) + for m in multi.flat_patches(): assert isinstance(m, NumpyPolylineMesh) if len(m.lines) > 0: # First element is count (number of points in first line segment) @@ -446,9 +456,9 @@ def test_representation_set_returns_mixed_mesh_list(self, epc22): obj = epc22.get_object_by_uuid("6b992199-5b47-4624-a62c-b70857133cda") if not obj: pytest.skip("RepresentationSet UUID not found in fixture EPC") - meshes = read_numpy_mesh_object(obj[0], workspace=epc22) - assert isinstance(meshes, list) - for m in meshes: + multi = read_numpy_mesh_object(obj[0], workspace=epc22) + assert isinstance(multi, NumpyMultiMesh) + for m in multi.flat_patches(): assert isinstance(m, NumpyMesh) # --- Stubs raise NotSupportedError --- From 3d818ed8c68e66f04e334f2437f50102c1680d9a Mon Sep 17 00:00:00 2001 From: Valentin Gauthier <valentin.gauthier@geosiris.com> Date: Fri, 6 Mar 2026 01:27:41 +0100 Subject: [PATCH 70/70] ijkgrid starting --- .../src/energyml/utils/data/mesh_numpy.py | 726 +++++++++++++++++- energyml-utils/tests/test_mesh_numpy.py | 52 +- 2 files changed, 760 insertions(+), 18 deletions(-) diff --git a/energyml-utils/src/energyml/utils/data/mesh_numpy.py b/energyml-utils/src/energyml/utils/data/mesh_numpy.py index c9d0d77..bba272c 100644 --- a/energyml-utils/src/energyml/utils/data/mesh_numpy.py +++ b/energyml-utils/src/energyml/utils/data/mesh_numpy.py @@ -48,7 +48,7 @@ import sys import traceback from dataclasses import dataclass, field -from typing import Any, Callable, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Tuple, Union import numpy as np @@ -147,6 +147,8 @@ class NumpyMesh: source_uuid: Optional[str] = field(default=None) #: Python class name of the source RESQML object. source_type: Optional[str] = field(default=None) + #: Optional named arrays attached to this mesh (e.g. ``node_time_values``). + extra_arrays: Dict[str, np.ndarray] = field(default_factory=dict) def to_pyvista(self) -> Any: # return type: pv.DataSet """Convert to a PyVista dataset. Requires ``pyvista`` to be installed.""" @@ -398,6 +400,38 @@ def _read_array_np( return np.asarray(result) +def _decode_jagged_array( + jagged: Any, + root_obj: Any, + base_path: str, + workspace: Optional[Any], +) -> List[np.ndarray]: + """Decode a RESQML ``JaggedArray`` into a list of numpy sub-arrays. + + ``JaggedArray`` stores data as: + * ``Elements`` — flat 1-D array of all values concatenated. + * ``CumulativeLength`` — 1-D array of end-offsets; ``CumulativeLength[i]`` + is the exclusive end index of sub-array *i* in ``Elements``. + + Returns an empty list when either component is missing. + """ + elem_list = search_attribute_matching_name_with_path(jagged, "Elements") + cum_list = search_attribute_matching_name_with_path(jagged, "CumulativeLength") + if not elem_list or not cum_list: + return [] + elem_path, elem_obj = elem_list[0] + cum_path, cum_obj = cum_list[0] + elements = _read_array_np(elem_obj, root_obj, f"{base_path}.{elem_path}", workspace) + cum_len = _read_array_np(cum_obj, root_obj, f"{base_path}.{cum_path}", workspace).astype(np.int64) + result: List[np.ndarray] = [] + prev = 0 + for c in cum_len: + c = int(c) + result.append(elements[prev:c]) + prev = c + return result + + # --------------------------------------------------------------------------- # Dispatcher machinery (mirrors mesh.py but prefixed with 'numpy_') # --------------------------------------------------------------------------- @@ -1173,29 +1207,695 @@ def read_numpy_representation_set_representation( return multi +# --------------------------------------------------------------------------- +# VTK cell-type codes (subset used by RESQML readers) +# --------------------------------------------------------------------------- + +_VTK_TETRA = 10 +_VTK_HEXAHEDRON = 12 +_VTK_WEDGE = 13 +_VTK_PYRAMID = 14 +_VTK_POLYHEDRON = 42 + + +# --------------------------------------------------------------------------- +# New representation readers +# --------------------------------------------------------------------------- + + +def read_numpy_plane_set_representation( + energyml_object: Any, + workspace: Optional[EnergymlStorageInterface] = None, + use_crs_displacement: bool = True, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, + horizontal_plane_half_extent: float = 1e5, +) -> "NumpyMultiMesh": + """Read a ``PlaneSetRepresentation`` into numpy surface meshes. + + * ``HorizontalPlaneGeometry`` — synthesises a large finite quad centred at the + CRS origin at the given Z coordinate. The half-extent is controlled by + *horizontal_plane_half_extent* (default 100 km in CRS length units). + * ``TiltedPlaneGeometry`` — each ``ThreePoint3D`` entry becomes a triangle. + + Args: + horizontal_plane_half_extent: Half-width in CRS length units of the + synthesised quad used for ``HorizontalPlaneGeometry`` patches. + """ + src_uuid = get_obj_uuid(energyml_object) + src_type = type(energyml_object).__name__ + multi = NumpyMultiMesh( + energyml_object=energyml_object, + identifier=str(get_obj_uri(energyml_object)), + source_uuid=src_uuid, + source_type=src_type, + ) + + crs = None + try: + crs = get_crs_obj( + context_obj=energyml_object, + path_in_root=".", + root_obj=energyml_object, + workspace=workspace, + ) + except (ObjectNotFoundNotError, Exception): + pass + + planes_list = search_attribute_matching_name_with_path(energyml_object, "Planes") + patch_idx = 0 + + for _plane_path, plane_geom in planes_list: + geom_type = type(plane_geom).__name__ + + if geom_type == "HorizontalPlaneGeometry": + z = float(getattr(plane_geom, "coordinate", 0.0)) + hx = hy = float(horizontal_plane_half_extent) + points = np.array( + [[-hx, -hy, z], [hx, -hy, z], [hx, hy, z], [-hx, hy, z]], + dtype=np.float64, + ) + faces = np.array([4, 0, 1, 2, 3], dtype=np.int64) + + elif geom_type == "TiltedPlaneGeometry": + pts_list: List[np.ndarray] = [] + tri_list: List[List[int]] = [] + pt_offset = 0 + for three_pt in getattr(plane_geom, "plane", []): + pts3 = getattr(three_pt, "point3d", []) + if len(pts3) < 3: + continue + tri_pts = np.array( + [[p.coordinate1, p.coordinate2, p.coordinate3] for p in pts3[:3]], + dtype=np.float64, + ) + pts_list.append(tri_pts) + tri_list.append([pt_offset, pt_offset + 1, pt_offset + 2]) + pt_offset += 3 + if not pts_list: + patch_idx += 1 + continue + points = np.concatenate(pts_list, axis=0) + tris = np.array(tri_list, dtype=np.int64) # (M, 3) + faces = _build_vtk_faces_from_triangles(tris) + + else: + logging.warning(f"PlaneSetRepresentation: unknown geometry type {geom_type!r} — skipping patch {patch_idx}") + patch_idx += 1 + continue + + if use_crs_displacement and crs is not None and len(points) > 0: + apply_from_crs_info(points, extract_crs_info(crs, workspace), inplace=True) + + label = f"{src_type}_patch_{patch_idx}" + multi.patches.append( + NumpySurfaceMesh( + identifier=label, + energyml_object=energyml_object, + crs_object=crs, + points=points, + faces=faces, + patch_index=patch_idx, + patch_label=label, + source_uuid=src_uuid, + source_type=src_type, + ) + ) + patch_idx += 1 + + return multi + + +def read_numpy_seismic_wellbore_frame_representation( + energyml_object: Any, + workspace: Optional[EnergymlStorageInterface] = None, + use_crs_displacement: bool = True, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, +) -> "NumpyMultiMesh": + """Read a ``SeismicWellboreFrameRepresentation``. + + ``SeismicWellboreFrameRepresentation`` extends ``WellboreFrameRepresentation`` + and adds a ``NodeTimeValues`` array (one time value per frame node). This + reader delegates geometry to :func:`read_numpy_wellbore_frame_representation` + and stores the extra time values in ``patch.extra_arrays["node_time_values"]`` + on every returned patch. + """ + ws = _view_workspace(workspace) + result = read_numpy_wellbore_frame_representation( + energyml_object=energyml_object, + workspace=workspace, + use_crs_displacement=use_crs_displacement, + sub_indices=sub_indices, + ) + # Attach NodeTimeValues to each patch as extra data + try: + ntv_path, ntv_obj = search_attribute_matching_name_with_path(energyml_object, "NodeTimeValues")[0] + node_time_values = _read_array_np(ntv_obj, energyml_object, ntv_path, ws) + for patch in result.flat_patches(): + patch.extra_arrays["node_time_values"] = node_time_values + except (IndexError, Exception) as exc: + logging.warning( + f"SeismicWellboreFrameRepresentation: could not read NodeTimeValues: {exc}" + ) + result.source_type = type(energyml_object).__name__ + return result + + +def read_numpy_sealed_surface_framework_representation( + energyml_object: Any, + workspace: Optional[EnergymlStorageInterface] = None, + use_crs_displacement: bool = True, + sub_indices: Optional[Union[List[int], np.ndarray]] = None, +) -> "NumpyMultiMesh": + """Read a ``SealedSurfaceFrameworkRepresentation``. + + ``SealedSurfaceFrameworkRepresentation`` is a subtype of + ``RepresentationSetRepresentation`` (via ``AbstractSurfaceFrameworkRepresentation``). + Geometry is delegated to :func:`read_numpy_representation_set_representation` + which reads each member representation. + """ + result = read_numpy_representation_set_representation( + energyml_object=energyml_object, + workspace=workspace, + use_crs_displacement=use_crs_displacement, + sub_indices=sub_indices, + ) + result.source_type = type(energyml_object).__name__ + return result + + +# --------------------------------------------------------------------------- +# IJK-grid helpers +# --------------------------------------------------------------------------- + + +def _build_kl_mapping( + nk: int, + gap_after: Optional[np.ndarray], +) -> Tuple[np.ndarray, np.ndarray]: + """Compute bottom and top NKL boundary indices for each K cell. + + Without K-gaps the mapping is trivial: cell k spans NKL nodes [k, k+1]. + When ``gap_after[k]`` is True, the NKL counter is incremented by an extra + step between layers k and k+1, so the affected layers use distinct node + intervals that are geometrically discontinuous. + + Args: + nk: Number of K cells (not layers). + gap_after: Boolean array of length ``nk - 1``; ``True`` at index *k* + means there is a K-gap after layer *k*. + + Returns: + ``(kl_bottom, kl_top)`` — two ``(nk,)`` int64 arrays giving the NKL + index of the bottom and top node boundary for each cell. + """ + kl_bottom = np.zeros(nk, dtype=np.int64) + kl_top = np.zeros(nk, dtype=np.int64) + kl = 0 + for k in range(nk): + kl_bottom[k] = kl + kl += 1 + kl_top[k] = kl + if gap_after is not None and k < len(gap_after) and gap_after[k]: + kl += 1 # skip one NKL slot for the gap + return kl_bottom, kl_top + + +def _build_split_pillar_map( + ni: int, + nj: int, + pillar_indices_arr: np.ndarray, + columns_per_split: List[np.ndarray], + n_splits: int, +) -> np.ndarray: + """Build a per-column corner-pillar remapping for split coordinate lines. + + For each column ``(j, i)`` the four corners are labelled:: + + TL = (j, i) TR = (j, i+1) + BL = (j+1, i) BR = (j+1, i+1) + + Without splits every corner maps to the standard pillar index + ``j*(ni+1)+i``. Split coordinate lines displace this mapping for the + affected columns. + + Args: + ni, nj: Cell counts in I and J. + pillar_indices_arr: ``(n_splits,)`` int64 — original pillar index for + each split coordinate line. + columns_per_split: Length-``n_splits`` list of int arrays — column + indices (flat, ``j*ni+i``) that use each split line. + n_splits: Number of split coordinate lines. + + Returns: + ``pillar_map`` — shape ``(nj, ni, 4)`` int64; corner order is + ``[TL, TR, BL, BR]``. + """ + n_pillars_base = (ni + 1) * (nj + 1) + pillar_map = np.zeros((nj, ni, 4), dtype=np.int64) + for j in range(nj): + for i in range(ni): + pillar_map[j, i, 0] = j * (ni + 1) + i # TL + pillar_map[j, i, 1] = j * (ni + 1) + (i + 1) # TR + pillar_map[j, i, 2] = (j + 1) * (ni + 1) + i # BL + pillar_map[j, i, 3] = (j + 1) * (ni + 1) + (i + 1) # BR + + for split_idx in range(n_splits): + if split_idx >= len(columns_per_split): + break + orig_pillar_idx = int(pillar_indices_arr[split_idx]) + orig_j = orig_pillar_idx // (ni + 1) + orig_i = orig_pillar_idx % (ni + 1) + new_pillar_idx = n_pillars_base + split_idx + for col_flat in columns_per_split[split_idx].astype(np.int64): + col_j = int(col_flat) // ni + col_i = int(col_flat) % ni + if not (0 <= col_j < nj and 0 <= col_i < ni): + continue + # Identify which corner of this column corresponds to (orig_j, orig_i) + if orig_j == col_j and orig_i == col_i: + pillar_map[col_j, col_i, 0] = new_pillar_idx # TL + elif orig_j == col_j and orig_i == col_i + 1: + pillar_map[col_j, col_i, 1] = new_pillar_idx # TR + elif orig_j == col_j + 1 and orig_i == col_i: + pillar_map[col_j, col_i, 2] = new_pillar_idx # BL + elif orig_j == col_j + 1 and orig_i == col_i + 1: + pillar_map[col_j, col_i, 3] = new_pillar_idx # BR + + return pillar_map + + def read_numpy_ijk_grid_representation( energyml_object: Any, workspace: Optional[EnergymlStorageInterface] = None, + use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> "NumpyMultiMesh": - """Stub — IjkGridRepresentation is not yet implemented.""" - raise NotSupportedError( - "IjkGridRepresentation is not yet supported in mesh_numpy. " - "Contributions welcome — see TODO in mesh.py for the cell-corner extraction algorithm." + """Read an ``IjkGridRepresentation`` as a :class:`NumpyVolumeMesh`. + + Geometry is reconstructed from the pillar (coordinate-line) nodes stored in + ``geometry.Points``. The cells returned are always ``VTK_HEXAHEDRON`` + (type 12), which is the correct topology for RESQML IJK corner-point grids. + + Full-fidelity features + ---------------------- + * **K-Gaps** — ``kgaps.gap_after_layer`` is decoded so that K-gap-separated + layers use the correct NKL node-boundary interval. + * **Split coordinate lines (faults)** — ``column_layer_split_coordinate_lines`` + is decoded to remap per-column corner pillars to their fault-split + equivalents. The faulted case uses a Python loop (not fully vectorised) + because the remapping is column-specific; for large grids prefer the + unfaulted vectorised path when possible. + * **Degenerate cells** — pillars with co-located nodes (e.g. wedge columns) + are preserved; PyVista tolerates degenerate hex nodes. + + Known limitation + ---------------- + ``Point3DParametricArray`` pillar geometry is not yet supported (only + ``Point3DExternalArray`` — direct HDF5 XYZ coordinates — is handled). A + :exc:`~energyml.utils.exception.NotSupportedError` is raised for parametric + grids. + """ + ws = _view_workspace(workspace) + src_uuid = get_obj_uuid(energyml_object) + src_type = type(energyml_object).__name__ + + ni = getattr(energyml_object, "ni", None) + nj = getattr(energyml_object, "nj", None) + nk = getattr(energyml_object, "nk", None) + if ni is None or nj is None or nk is None: + logging.warning("IjkGridRepresentation: ni/nj/nk not set — returning empty mesh") + return NumpyMultiMesh( + energyml_object=energyml_object, + identifier=str(src_uuid), + source_uuid=src_uuid, + source_type=src_type, + ) + ni, nj, nk = int(ni), int(nj), int(nk) + + geom = getattr(energyml_object, "geometry", None) + if geom is None: + logging.warning("IjkGridRepresentation has no geometry — returning empty mesh") + return NumpyMultiMesh( + energyml_object=energyml_object, + identifier=str(src_uuid), + source_uuid=src_uuid, + source_type=src_type, + ) + + try: + _obj_identifier = str(get_obj_uri(energyml_object)) + except Exception: + _obj_identifier = str(src_uuid) + empty = NumpyMultiMesh( + energyml_object=energyml_object, + identifier=_obj_identifier, + source_uuid=src_uuid, + source_type=src_type, + ) + + # --- K-GAPS --- + kgaps_obj = getattr(energyml_object, "kgaps", None) + gap_after: Optional[np.ndarray] = None + n_kgaps = 0 + if kgaps_obj is not None: + n_kgaps = int(getattr(kgaps_obj, "count", 0) or 0) + gap_attr_list = search_attribute_matching_name_with_path(kgaps_obj, "GapAfterLayer") + if gap_attr_list: + gap_path, gap_obj = gap_attr_list[0] + gap_after = _read_array_np(gap_obj, energyml_object, f"kgaps.{gap_path}", ws).astype(bool) + nkl = nk + n_kgaps + 1 # total number of K-boundary layers + + kl_bottom, kl_top = _build_kl_mapping(nk, gap_after) + + # --- SPLIT COORDINATE LINES --- + split_cl = getattr(geom, "column_layer_split_coordinate_lines", None) + n_splits = 0 + pillar_indices_arr: Optional[np.ndarray] = None + columns_per_split: List[np.ndarray] = [] + if split_cl is not None: + n_splits = int(getattr(split_cl, "count", 0) or 0) + if n_splits > 0: + pi_list = search_attribute_matching_name_with_path(split_cl, "PillarIndices") + if pi_list: + pi_path, pi_obj = pi_list[0] + pillar_indices_arr = _read_array_np( + pi_obj, energyml_object, + f"geometry.column_layer_split_coordinate_lines.{pi_path}", ws, + ) + cps_obj = getattr(split_cl, "columns_per_split_coordinate_line", None) + if cps_obj is not None: + columns_per_split = _decode_jagged_array( + cps_obj, energyml_object, + "geometry.column_layer_split_coordinate_lines.columns_per_split_coordinate_line", + ws, + ) + + n_pillars_base = (ni + 1) * (nj + 1) + n_pillars_total = n_pillars_base + n_splits + + # --- POINTS --- + pts_results = search_attribute_matching_name_with_path(geom, "Points") + if not pts_results: + logging.warning("IjkGridRepresentation: cannot find Points in geometry") + return empty + pts_path, pts_obj = pts_results[0] + + # Reject parametric arrays (not yet supported) + if "Parametric" in type(pts_obj).__name__: + raise NotSupportedError( + f"IjkGridRepresentation with parametric-pillar geometry " + f"({type(pts_obj).__name__}) is not yet supported. " + "Only direct HDF5 coordinate arrays (Point3DExternalArray) are handled." + ) + + raw_pts = _read_array_np(pts_obj, energyml_object, f"geometry.{pts_path}", ws) + + # Reshape raw points: HDF5 layout is (NKL, NJ+1, NI+1, 3) for unfaulted + # grids and (NKL, n_pillars_total, 3) when split lines are present. + expected_3d = nkl * n_pillars_total * 3 + expected_4d = nkl * (nj + 1) * (ni + 1) * 3 + if n_splits > 0 or raw_pts.size == expected_3d: + # Split lines present (or data already in 3-D layout) + pts_3d = raw_pts.reshape(nkl, n_pillars_total, 3) + points = pts_3d.reshape(-1, 3).astype(np.float64, copy=False) + elif raw_pts.size == expected_4d: + # Standard 4-D unfaulted layout: (NKL, NJ+1, NI+1, 3) + pts_4d = raw_pts.reshape(nkl, nj + 1, ni + 1, 3) + # Reorder to (NKL, n_pillars_base, 3) for uniform node indexing + # Pillar index: j*(ni+1)+i → this matches C-order of the last two dims + points = pts_4d.reshape(nkl, n_pillars_base, 3).reshape(-1, 3).astype(np.float64, copy=False) + else: + raise ValueError( + f"IjkGridRepresentation: unexpected points array size {raw_pts.size}. " + f"Expected {expected_3d} (3-D layout, nkl={nkl}, n_pillars={n_pillars_total}) " + f"or {expected_4d} (4-D layout, nkl={nkl}, nj+1={nj+1}, ni+1={ni+1})." + ) + + # --- CRS --- + crs = None + try: + crs = get_crs_obj( + context_obj=geom, + path_in_root="geometry", + root_obj=energyml_object, + workspace=workspace, + ) + except (ObjectNotFoundNotError, Exception): + pass + + # --- PILLAR MAP for faulted grids --- + use_pillar_map = n_splits > 0 and pillar_indices_arr is not None + pillar_map: Optional[np.ndarray] = None + if use_pillar_map: + pillar_map = _build_split_pillar_map(ni, nj, pillar_indices_arr, columns_per_split, n_splits) + + # --- BUILD HEXAHEDRAL CELL CONNECTIVITY --- + if pillar_map is None: + # Fully vectorised path for unfaulted grids + ii_arr, ij_arr, ik_arr = np.meshgrid( + np.arange(ni, dtype=np.int64), + np.arange(nj, dtype=np.int64), + np.arange(nk, dtype=np.int64), + indexing="ij", + ) # each shape (ni, nj, nk) + + kl_b = kl_bottom[ik_arr] # (ni, nj, nk) + kl_t = kl_top[ik_arr] + p_tl = ij_arr * (ni + 1) + ii_arr # pillar TL + p_tr = ij_arr * (ni + 1) + (ii_arr + 1) # pillar TR + p_bl = (ij_arr + 1) * (ni + 1) + ii_arr # pillar BL + p_br = (ij_arr + 1) * (ni + 1) + (ii_arr + 1) # pillar BR + + def _nidx(kl, pl): + return kl * n_pillars_total + pl + + # VTK_HEXAHEDRON node ordering (bottom face ccw, top face aligned) + n0 = _nidx(kl_b, p_tl).ravel() + n1 = _nidx(kl_b, p_tr).ravel() + n2 = _nidx(kl_b, p_br).ravel() + n3 = _nidx(kl_b, p_bl).ravel() + n4 = _nidx(kl_t, p_tl).ravel() + n5 = _nidx(kl_t, p_tr).ravel() + n6 = _nidx(kl_t, p_br).ravel() + n7 = _nidx(kl_t, p_bl).ravel() + + n_cells = ni * nj * nk + count_col = np.full(n_cells, 8, dtype=np.int64) + cells = np.column_stack([count_col, n0, n1, n2, n3, n4, n5, n6, n7]).ravel() + cell_types = np.full(n_cells, _VTK_HEXAHEDRON, dtype=np.uint8) + + else: + # Per-column loop for faulted grids (pillar_map resolved) + cells_parts: List[int] = [] + for ij_idx in range(nj): + for ii_idx in range(ni): + p_tl = int(pillar_map[ij_idx, ii_idx, 0]) + p_tr = int(pillar_map[ij_idx, ii_idx, 1]) + p_bl = int(pillar_map[ij_idx, ii_idx, 2]) + p_br = int(pillar_map[ij_idx, ii_idx, 3]) + for ik_idx in range(nk): + kl_b = int(kl_bottom[ik_idx]) + kl_t = int(kl_top[ik_idx]) + n0 = kl_b * n_pillars_total + p_tl + n1 = kl_b * n_pillars_total + p_tr + n2 = kl_b * n_pillars_total + p_br + n3 = kl_b * n_pillars_total + p_bl + n4 = kl_t * n_pillars_total + p_tl + n5 = kl_t * n_pillars_total + p_tr + n6 = kl_t * n_pillars_total + p_br + n7 = kl_t * n_pillars_total + p_bl + cells_parts.extend([8, n0, n1, n2, n3, n4, n5, n6, n7]) + cells = np.array(cells_parts, dtype=np.int64) + n_cells = ni * nj * nk + cell_types = np.full(n_cells, _VTK_HEXAHEDRON, dtype=np.uint8) + + if use_crs_displacement and crs is not None and len(points) > 0: + apply_from_crs_info(points, extract_crs_info(crs, workspace), inplace=True) + + label = f"{src_type}_patch_0" + multi = NumpyMultiMesh( + energyml_object=energyml_object, + identifier=str(get_obj_uri(energyml_object)), + source_uuid=src_uuid, + source_type=src_type, + ) + multi.patches.append( + NumpyVolumeMesh( + identifier=label, + energyml_object=energyml_object, + crs_object=crs, + points=points, + cells=cells, + cell_types=cell_types, + patch_index=0, + patch_label=label, + source_uuid=src_uuid, + source_type=src_type, + ) ) + return multi def read_numpy_unstructured_grid_representation( energyml_object: Any, workspace: Optional[EnergymlStorageInterface] = None, + use_crs_displacement: bool = True, sub_indices: Optional[Union[List[int], np.ndarray]] = None, ) -> "NumpyMultiMesh": - """Stub — UnstructuredGridRepresentation is not yet implemented.""" - raise NotSupportedError( - "UnstructuredGridRepresentation is not yet supported in mesh_numpy. " - "Contributions welcome — see TODO in mesh.py for the cell list extraction algorithm." + """Read an ``UnstructuredGridRepresentation`` as a :class:`NumpyVolumeMesh`. + + All cells are emitted as ``VTK_POLYHEDRON`` (type 42) regardless of the + ``cell_shape`` metadata. This avoids the complex winding-order reconstruction + required to convert RESQML's face-based topology to VTK's fixed-topology node + lists (TETRA/PYRAMID/WEDGE/HEX). The polyhedron format is lossless and + PyVista can display and process these cells natively. + + The ``cell_face_is_right_handed`` boolean array is respected: faces whose flag + is ``False`` have their node ordering reversed so that all face normals point + outward from the cell. + """ + ws = _view_workspace(workspace) + src_uuid = get_obj_uuid(energyml_object) + src_type = type(energyml_object).__name__ + + geom = getattr(energyml_object, "geometry", None) + if geom is None: + logging.warning("UnstructuredGridRepresentation has no geometry — returning empty mesh") + return NumpyMultiMesh( + energyml_object=energyml_object, + identifier=str(src_uuid), + source_uuid=src_uuid, + source_type=src_type, + ) + + try: + _obj_identifier = str(get_obj_uri(energyml_object)) + except Exception: + _obj_identifier = str(src_uuid) + empty = NumpyMultiMesh( + energyml_object=energyml_object, + identifier=_obj_identifier, + source_uuid=src_uuid, + source_type=src_type, ) + # --- POINTS --- + pts_results = search_attribute_matching_name_with_path(geom, "Points") + if not pts_results: + logging.warning("UnstructuredGridRepresentation: cannot find Points in geometry") + return empty + pts_path, pts_obj = pts_results[0] + raw_pts = _read_array_np(pts_obj, energyml_object, pts_path, ws) + points = _ensure_float64_points(raw_pts) # (N, 3) + + # --- CRS --- + crs = None + try: + crs = get_crs_obj( + context_obj=geom, + path_in_root="geometry", + root_obj=energyml_object, + workspace=workspace, + ) + except (ObjectNotFoundNotError, Exception): + pass + + # --- JAGGED ARRAYS --- + npf_obj = getattr(geom, "nodes_per_face", None) + fpc_obj = getattr(geom, "faces_per_cell", None) + if npf_obj is None or fpc_obj is None: + logging.warning( + "UnstructuredGridRepresentation: missing nodes_per_face or faces_per_cell " + "— returning point-set mesh" + ) + label = f"{src_type}_patch_0" + multi = NumpyMultiMesh( + energyml_object=energyml_object, + identifier=str(get_obj_uri(energyml_object)), + source_uuid=src_uuid, + source_type=src_type, + ) + multi.patches.append( + NumpyPointSetMesh( + identifier=label, + energyml_object=energyml_object, + crs_object=crs, + points=points, + patch_index=0, + patch_label=label, + source_uuid=src_uuid, + source_type=src_type, + ) + ) + return multi + + nodes_per_face = _decode_jagged_array(npf_obj, energyml_object, "geometry.nodes_per_face", ws) + faces_per_cell = _decode_jagged_array(fpc_obj, energyml_object, "geometry.faces_per_cell", ws) + cell_count = len(faces_per_cell) + if cell_count == 0: + return empty + + # --- RIGHT-HANDED BOOLEAN ARRAY --- + rh_arr: Optional[np.ndarray] = None + try: + rh_path, rh_obj = search_attribute_matching_name_with_path(geom, "CellFaceIsRightHanded")[0] + rh_arr = _read_array_np(rh_obj, energyml_object, f"geometry.{rh_path}", ws).astype(bool) + except (IndexError, Exception) as exc: + logging.debug(f"UnstructuredGridRepresentation: CellFaceIsRightHanded not readable: {exc}") + + # --- BUILD VTK_POLYHEDRON CELL ARRAY --- + # VTK polyhedron flat format per cell: + # [total_vals, n_faces, n_pts_f0, p0, p1, ..., n_pts_f1, p0, ...] + # where total_vals = 1 + n_faces + sum(1 + n_pts_fi for each face). + cells_flat: List[int] = [] + rh_global_idx = 0 + + for face_idxs in faces_per_cell: + face_idxs = face_idxs.astype(np.int64) + cell_inner: List[int] = [int(len(face_idxs))] # n_faces + for fi in face_idxs: + fi = int(fi) + if fi >= len(nodes_per_face): + rh_global_idx += 1 + continue + node_idxs = nodes_per_face[fi].astype(np.int64) + if rh_arr is not None and rh_global_idx < len(rh_arr) and not rh_arr[rh_global_idx]: + node_idxs = node_idxs[::-1] # flip to outward normal + rh_global_idx += 1 + cell_inner.append(int(len(node_idxs))) + cell_inner.extend(int(x) for x in node_idxs) + cells_flat.append(len(cell_inner)) # total size of this cell entry + cells_flat.extend(cell_inner) + + cells = np.array(cells_flat, dtype=np.int64) + cell_types = np.full(cell_count, _VTK_POLYHEDRON, dtype=np.uint8) + + if use_crs_displacement and crs is not None and len(points) > 0: + apply_from_crs_info(points, extract_crs_info(crs, workspace), inplace=True) + + label = f"{src_type}_patch_0" + multi = NumpyMultiMesh( + energyml_object=energyml_object, + identifier=str(get_obj_uri(energyml_object)), + source_uuid=src_uuid, + source_type=src_type, + ) + multi.patches.append( + NumpyVolumeMesh( + identifier=label, + energyml_object=energyml_object, + crs_object=crs, + points=points, + cells=cells, + cell_types=cell_types, + patch_index=0, + patch_label=label, + source_uuid=src_uuid, + source_type=src_type, + ) + ) + return multi + # --------------------------------------------------------------------------- # Main dispatcher @@ -1271,6 +1971,11 @@ def read_numpy_mesh_object( and "polyline" not in _tn # per-patch CRS applied inside reader and "representationset" not in _tn # each child already had CRS applied and "subrepresentation" not in _tn # delegates entirely to inner call + and "planeset" not in _tn # per-patch CRS applied inside reader + and "seismicwellbore" not in _tn # delegates to wellbore reader + and "sealedsurface" not in _tn # delegates to representation-set reader + and "unstructuredgrid" not in _tn # per-patch CRS applied inside reader + and "ijkgrid" not in _tn # per-patch CRS applied inside reader ): for m in result.flat_patches(): crs = m.crs_object[0] if isinstance(m.crs_object, list) and m.crs_object else m.crs_object @@ -1375,6 +2080,9 @@ def numpy_multi_mesh_to_pyvista(multi: "NumpyMultiMesh") -> Any: "read_numpy_wellbore_frame_representation", "read_numpy_sub_representation", "read_numpy_representation_set_representation", + "read_numpy_plane_set_representation", + "read_numpy_seismic_wellbore_frame_representation", + "read_numpy_sealed_surface_framework_representation", "read_numpy_ijk_grid_representation", "read_numpy_unstructured_grid_representation", # Converters diff --git a/energyml-utils/tests/test_mesh_numpy.py b/energyml-utils/tests/test_mesh_numpy.py index 1c24422..aa14491 100644 --- a/energyml-utils/tests/test_mesh_numpy.py +++ b/energyml-utils/tests/test_mesh_numpy.py @@ -461,18 +461,52 @@ def test_representation_set_returns_mixed_mesh_list(self, epc22): for m in multi.flat_patches(): assert isinstance(m, NumpyMesh) - # --- Stubs raise NotSupportedError --- - def test_ijk_grid_raises_not_supported(self, epc22): - from energyml.utils.exception import NotSupportedError - from energyml.utils.data.mesh_numpy import read_numpy_ijk_grid_representation - with pytest.raises(NotSupportedError): - read_numpy_ijk_grid_representation(MagicMock(), epc22) + # --- IjkGrid + UnstructuredGrid: return empty when geometry is missing --- - def test_unstructured_grid_raises_not_supported(self, epc22): + def test_ijk_grid_returns_empty_when_no_ni_nj_nk(self, epc22): + """Reader returns an empty NumpyMultiMesh when ni/nj/nk are absent.""" + from energyml.utils.data.mesh_numpy import read_numpy_ijk_grid_representation + mock_obj = MagicMock() + mock_obj.ni = None + mock_obj.nj = None + mock_obj.nk = None + result = read_numpy_ijk_grid_representation(mock_obj, epc22) + assert isinstance(result, NumpyMultiMesh) + assert result.patch_count() == 0 + + def test_ijk_grid_parametric_raises_not_supported(self): + """Reader raises NotSupportedError for Point3DParametricArray geometry.""" from energyml.utils.exception import NotSupportedError + from energyml.utils.data.mesh_numpy import read_numpy_ijk_grid_representation + mock_obj = MagicMock() + mock_obj.ni = 2 + mock_obj.nj = 2 + mock_obj.nk = 1 + mock_obj.kgaps = None + # Create a real instance of a class named "Point3DParametricArray" so that + # type(pts_obj).__name__ == "Point3DParametricArray" (contains "Parametric"). + # Using MagicMock().__class__ = ... does NOT affect type(), only __class__. + mock_pts = type("Point3DParametricArray", (), {})() + mock_geom = MagicMock() + mock_geom.column_layer_split_coordinate_lines = None + # search_attribute_matching_name_with_path will find a parametric Points obj + from unittest.mock import patch as mock_patch + with mock_patch( + "energyml.utils.data.mesh_numpy.search_attribute_matching_name_with_path", + return_value=[("Points", mock_pts)], + ), mock_patch("energyml.utils.data.mesh_numpy.get_obj_uri", return_value="mock-uri"): + mock_obj.geometry = mock_geom + with pytest.raises(NotSupportedError): + read_numpy_ijk_grid_representation(mock_obj) + + def test_unstructured_grid_returns_empty_when_no_geometry(self, epc22): + """Reader returns an empty NumpyMultiMesh when geometry is absent.""" from energyml.utils.data.mesh_numpy import read_numpy_unstructured_grid_representation - with pytest.raises(NotSupportedError): - read_numpy_unstructured_grid_representation(MagicMock(), epc22) + mock_obj = MagicMock() + mock_obj.geometry = None + result = read_numpy_unstructured_grid_representation(mock_obj, epc22) + assert isinstance(result, NumpyMultiMesh) + assert result.patch_count() == 0 # ---------------------------------------------------------------------------