From 6db2023324025987c20fb21267981c358877437e Mon Sep 17 00:00:00 2001 From: Laurent Ailleres Date: Thu, 20 Nov 2025 09:07:27 +1100 Subject: [PATCH 1/9] Adding the function to add dataframe as pts with nesting in geohy5.py --- LoopStructural/export/geoh5.py | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/LoopStructural/export/geoh5.py b/LoopStructural/export/geoh5.py index c91501e0..15e03d91 100644 --- a/LoopStructural/export/geoh5.py +++ b/LoopStructural/export/geoh5.py @@ -61,6 +61,47 @@ def add_points_to_geoh5(filename, point, overwrite=True, groupname="Loop"): ) point.add_data(data) +def add_points_from_df(filename, df, overwrite=True, child = None, parent = None, + normal_cols=['nx','ny','nz']): + with geoh5py.workspace.Workspace(filename) as workspace: + entities = workspace.get_entity(child) + child_name = child + child = entities[0] if entities else None + if not child: + child = geoh5py.groups.ContainerGroup.create( + workspace, name=child_name, allow_delete=True, + ) + if parent: + parent.add_children(child) + + for _, row in df.iterrows(): + name = row['name'] + loc = np.array([[row['X'], row['Y'], row['Z']]]) # shape (1,3) + + # remove existing entity if present and overwrite requested + if name in workspace.list_entities_name.values(): + existing = workspace.get_entity(name) + if existing: + existing[0].allow_delete = True + if overwrite: + workspace.remove_entity(existing[0]) + + pts = geoh5py.objects.Points.create( + workspace, + name=name, + vertices=loc, + parent=child, + ) + + # build data dict from normal_cols (and any other columns you want) + data = {} + for col in normal_cols: + if col in row and not pd.isna(row[col]): + # association must be "VERTEX" and values length must match vertices (1) + data[col] = {"association": "VERTEX", "values": np.array([row[col]])} + + if data: + pts.add_data(data) def add_structured_grid_to_geoh5(filename, structured_grid, overwrite=True, groupname="Loop"): with geoh5py.workspace.Workspace(filename) as workspace: From cd2aabe7b005f86ceabcfd9c5cedb776cb0e9a48 Mon Sep 17 00:00:00 2001 From: Laurent Ailleres Date: Thu, 20 Nov 2025 09:55:43 +1100 Subject: [PATCH 2/9] Update geoh5.py --- LoopStructural/export/geoh5.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/LoopStructural/export/geoh5.py b/LoopStructural/export/geoh5.py index 15e03d91..c55f68e7 100644 --- a/LoopStructural/export/geoh5.py +++ b/LoopStructural/export/geoh5.py @@ -1,6 +1,8 @@ import geoh5py import geoh5py.workspace import numpy as np +import pandas as pd + from LoopStructural.datatypes import ValuePoints, VectorPoints From 3f064032fa9722340db0e67ac80a01c36b3378cf Mon Sep 17 00:00:00 2001 From: Laurent Ailleres Date: Thu, 20 Nov 2025 10:30:06 +1100 Subject: [PATCH 3/9] Update __init__.py The ability to import that function was missing... annoying! --- LoopStructural/utils/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/LoopStructural/utils/__init__.py b/LoopStructural/utils/__init__.py index 0aaab409..d210e738 100644 --- a/LoopStructural/utils/__init__.py +++ b/LoopStructural/utils/__init__.py @@ -26,6 +26,7 @@ plungeazimuth2vector, azimuthplunge2vector, normal_vector_to_strike_and_dip, + normal_vector_to_dip_and_dip_direction, rotate, ) from .helper import create_surface, create_box From df938d18c9ed5524628e5bd7925536e7b9687f12 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 2 Jun 2026 14:20:26 +0930 Subject: [PATCH 4/9] fix: adding post_init to ValuePoints/VectorPoints to validate as numpy arrays --- LoopStructural/datatypes/_point.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/LoopStructural/datatypes/_point.py b/LoopStructural/datatypes/_point.py index b6ac8bd3..adadb3e2 100644 --- a/LoopStructural/datatypes/_point.py +++ b/LoopStructural/datatypes/_point.py @@ -14,7 +14,18 @@ class ValuePoints: values: np.ndarray = field(default_factory=lambda: np.array([0])) name: str = "unnamed" properties: Optional[dict] = None - + def __post_init__(self): + + self.values = np.asarray(self.values) + self.locations = np.asarray(self.locations) + if self.locations.shape[1] != 3: + raise ValueError('locations must be of shape (n, 3)') + if len(self.values) != len(self.locations): + raise ValueError('values must be the same length as locations') + for k, v in (self.properties or {}).items(): + if len(v) != len(self.locations): + raise ValueError(f'Property {k} must be the same length as locations') + self.properties[k] = np.asarray(v) def to_dict(self): return { "locations": self.locations, @@ -112,7 +123,17 @@ class VectorPoints: vectors: np.ndarray = field(default_factory=lambda: np.array([[0, 0, 0]])) name: str = "unnamed" properties: Optional[dict] = None - + def __post_init__(self): + self.vectors = np.asarray(self.vectors) + self.locations = np.asarray(self.locations) + if self.locations.shape[1] != 3: + raise ValueError('locations must be of shape (n, 3)') + if len(self.vectors) != len(self.locations): + raise ValueError('vectors must be the same length as locations') + for k, v in (self.properties or {}).items(): + if len(v) != len(self.locations): + raise ValueError(f'Property {k} must be the same length as locations') + self.properties[k] = np.asarray(v) def to_dict(self): return { "locations": self.locations, From 9d29d31c28f619438fe41af806d9332181c0eb87 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 2 Jun 2026 14:21:01 +0930 Subject: [PATCH 5/9] fix: adding add_group and add points from dataframe for geoh5 + some tests --- LoopStructural/export/geoh5.py | 115 +++++++++++++++++++++------------ tests/unit/io/test_geoh5.py | 90 ++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 41 deletions(-) create mode 100644 tests/unit/io/test_geoh5.py diff --git a/LoopStructural/export/geoh5.py b/LoopStructural/export/geoh5.py index c55f68e7..ee15f9c7 100644 --- a/LoopStructural/export/geoh5.py +++ b/LoopStructural/export/geoh5.py @@ -5,14 +5,29 @@ from LoopStructural.datatypes import ValuePoints, VectorPoints - -def add_surface_to_geoh5(filename, surface, overwrite=True, groupname="Loop"): +def add_group_to_geoh5(filename, groupname="Loop", parent=None, overwrite=True): with geoh5py.workspace.Workspace(filename) as workspace: + group = workspace.get_entity(groupname)[0] + if group and overwrite: + group.allow_delete = True + workspace.remove_entity(group) if not group: group = geoh5py.groups.ContainerGroup.create( workspace, name=groupname, allow_delete=True ) + if parent is not None: + parent = workspace.get_entity(parent)[0] + if parent: + parent.add_children(group) + return group.uid +def add_surface_to_geoh5(filename, surface, overwrite=True, group="Loop"): + with geoh5py.workspace.Workspace(filename) as workspace: + group = workspace.get_entity(group)[0] + if not group: + group = geoh5py.groups.ContainerGroup.create( + workspace, name=group, allow_delete=True + ) if surface.name in workspace.list_entities_name.values(): existing_surf = workspace.get_entity(surface.name) existing_surf[0].allow_delete = True @@ -34,6 +49,7 @@ def add_surface_to_geoh5(filename, surface, overwrite=True, groupname="Loop"): def add_points_to_geoh5(filename, point, overwrite=True, groupname="Loop"): with geoh5py.workspace.Workspace(filename) as workspace: + group = workspace.get_entity(groupname)[0] if not group: group = geoh5py.groups.ContainerGroup.create( @@ -54,7 +70,7 @@ def add_points_to_geoh5(filename, point, overwrite=True, groupname="Loop"): data['vz'] = {'association': "VERTEX", "values": point.vectors[:, 2]} if isinstance(point, ValuePoints): - data['val'] = {'association': "VERTEX", "values": point.values} + data['values'] = {'association': "VERTEX", "values": point.values} point = geoh5py.objects.Points.create( workspace, name=point.name, @@ -62,48 +78,65 @@ def add_points_to_geoh5(filename, point, overwrite=True, groupname="Loop"): parent=group, ) point.add_data(data) + +def overwrite_object(workspace, name, overwrite): + if name in workspace.list_entities_name.values(): + existing_entity = workspace.get_entity(name) + existing_entity[0].allow_delete = True + if overwrite: + workspace.remove_entity(existing_entity[0]) -def add_points_from_df(filename, df, overwrite=True, child = None, parent = None, - normal_cols=['nx','ny','nz']): +def add_points_from_df(filename, df, name='pointset', overwrite=True, columns=None, groupname="Loop", x_col='X', y_col='Y', z_col='Z'): + """ + Add points to a geoh5 file from a pandas DataFrame. The DataFrame must have columns 'name', 'X', 'Y', 'Z' for the point locations. + Additional columns can be added as data associated with the points. + Parameters + ---------- + filename: str + Path to the geoh5 file. + df: pandas.DataFrame + DataFrame containing point data. Must have columns 'name', 'X', 'Y', 'Z'. Additional columns will be added as data. + overwrite: bool, optional + Whether to overwrite existing points with the same name. Default is True. + columns: list of str, optional + List of columns in the DataFrame to add as data. If None, all columns except 'name', 'X', 'Y', 'Z' will be added. Default is None. + + """ + if columns is None: + columns = df.columns.tolist() + if x_col not in columns or y_col not in columns or z_col not in columns: + raise ValueError("DataFrame must contain 'name', 'X', 'Y', 'Z' columns. " \ + "Specify the column names using x_col, y_col, z_col parameters if they are different.") with geoh5py.workspace.Workspace(filename) as workspace: - entities = workspace.get_entity(child) - child_name = child - child = entities[0] if entities else None - if not child: - child = geoh5py.groups.ContainerGroup.create( - workspace, name=child_name, allow_delete=True, - ) - if parent: - parent.add_children(child) - - for _, row in df.iterrows(): - name = row['name'] - loc = np.array([[row['X'], row['Y'], row['Z']]]) # shape (1,3) - - # remove existing entity if present and overwrite requested - if name in workspace.list_entities_name.values(): - existing = workspace.get_entity(name) - if existing: - existing[0].allow_delete = True - if overwrite: - workspace.remove_entity(existing[0]) + if groupname: + group = workspace.get_entity(groupname) + group = group[0] if group else None + if not group: + group = geoh5py.groups.ContainerGroup.create( + workspace, name=groupname, allow_delete=True, + ) + + location = np.array(df[[x_col, y_col, z_col]].values) # shape (n,3) + + overwrite_object(workspace, name, overwrite) + - pts = geoh5py.objects.Points.create( - workspace, - name=name, - vertices=loc, - parent=child, - ) - - # build data dict from normal_cols (and any other columns you want) - data = {} - for col in normal_cols: - if col in row and not pd.isna(row[col]): - # association must be "VERTEX" and values length must match vertices (1) - data[col] = {"association": "VERTEX", "values": np.array([row[col]])} + pts = geoh5py.objects.Points.create( + workspace, + name=name, + vertices=location, + parent=group, + ) + data = {} + for col in columns: + if col in ['name', x_col, y_col, z_col]: + continue + data[col] = {"association": "VERTEX", "values": np.array(df[col]).flatten()} + - if data: - pts.add_data(data) + if data: + pts.add_data(data) + def add_structured_grid_to_geoh5(filename, structured_grid, overwrite=True, groupname="Loop"): with geoh5py.workspace.Workspace(filename) as workspace: diff --git a/tests/unit/io/test_geoh5.py b/tests/unit/io/test_geoh5.py new file mode 100644 index 00000000..4b10b72d --- /dev/null +++ b/tests/unit/io/test_geoh5.py @@ -0,0 +1,90 @@ +from LoopStructural.export.geoh5 import add_group_to_geoh5, add_points_to_geoh5, add_points_from_df +import geoh5py +import pytest +from pathlib import Path +from LoopStructural.datatypes import ValuePoints, VectorPoints +import numpy as np +@pytest.fixture +def tmp_path(): + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) +@pytest.fixture +def test_setup(tmp_path): + filename = tmp_path / "test.geoh5" + with geoh5py.workspace.Workspace.create(filename) as workspace: + yield filename + workspace.close() + +def test_add_group_to_geoh5(test_setup): + filename = test_setup + group_uid = add_group_to_geoh5(filename, groupname="TestGroup") + + with geoh5py.workspace.Workspace(filename) as workspace: + assert workspace.get_entity(group_uid)[0].name == "TestGroup" + +def test_add_points_to_geoh5(test_setup): + filename = test_setup + group_uid = add_group_to_geoh5(filename, groupname="TestGroup") + points = ValuePoints( + name="TestPoints", + locations=[[0, 0, 0], [1, 1, 1], [2, 2, 2]], + values=[10., 20, 30], + ) + add_points_to_geoh5(filename, points, groupname=group_uid) + with geoh5py.workspace.Workspace(filename) as workspace: + point_entity = workspace.get_entity("TestPoints")[0] + assert point_entity.name == "TestPoints" + assert point_entity.vertices.tolist() == [[0, 0, 0], [1, 1, 1], [2, 2, 2]] + assert np.sum(point_entity.get_data("values")[0].values-np.array([10., 20., 30.])) == 0 + +def test_add_vector_points_to_geoh5(test_setup): + filename = test_setup + group_uid = add_group_to_geoh5(filename, groupname="TestGroup") + points = VectorPoints( + name="TestVectorPoints", + locations=[[0, 0, 0], [1, 1, 1], [2, 2, 2]], + vectors=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], + ) + add_points_to_geoh5(filename, points, groupname=group_uid) + with geoh5py.workspace.Workspace(filename) as workspace: + point_entity = workspace.get_entity("TestVectorPoints")[0] + assert point_entity.name == "TestVectorPoints" + assert point_entity.vertices.tolist() == [[0, 0, 0], [1, 1, 1], [2, 2, 2]] + assert np.sum(point_entity.get_data("vx")[0].values-np.array([1., 0., 0.])) == 0 + assert np.sum(point_entity.get_data("vy")[0].values-np.array([0., 1., 0.])) == 0 + assert np.sum(point_entity.get_data("vz")[0].values-np.array([0., 0., 1.])) == 0 + +def test_add_df_to_geoh5(test_setup): + import pandas as pd + filename = test_setup + group_uid = add_group_to_geoh5(filename, groupname="TestGroup") + df = pd.DataFrame({ + 'X': [0, 1, 2], + 'Y': [0, 1, 2], + 'Z': [0, 1, 2], + 'value': [10., 20., 30.], + }) + add_points_from_df(filename, df, name='df_points', groupname=group_uid) + with geoh5py.workspace.Workspace(filename) as workspace: + point_entity = workspace.get_entity("TestGroup")[0].children[0] + assert point_entity.name == "df_points" + assert point_entity.vertices.tolist() == [[0, 0, 0], [1, 1, 1], [2, 2, 2]] + assert np.sum(point_entity.get_data("value")[0].values-np.array([10., 20., 30.])) == 0 + +def test_add_df_with_alternate_xyz_to_geoh5(test_setup): + import pandas as pd + filename = test_setup + group_uid = add_group_to_geoh5(filename, groupname="TestGroup") + df = pd.DataFrame({ + 'EAST': [0, 1, 2], + 'NORTH': [0, 1, 2], + 'RL': [0, 1, 2], + 'value': [10., 20., 30.], + }) + add_points_from_df(filename, df, name='df_points',groupname=group_uid, x_col='EAST', y_col='NORTH', z_col='RL') + with geoh5py.workspace.Workspace(filename) as workspace: + point_entity = workspace.get_entity("TestGroup")[0].children[0] + assert point_entity.name == "df_points" + assert point_entity.vertices.tolist() == [[0, 0, 0], [1, 1, 1], [2, 2, 2]] + assert np.sum(point_entity.get_data("value")[0].values-np.array([10., 20., 30.])) == 0 \ No newline at end of file From 58b4e07aa306eb7281806510826a82d561e5cb79 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 2 Jun 2026 14:25:29 +0930 Subject: [PATCH 6/9] fix: add test dependencies to test runner + add test dependencies option to pyproject.toml --- .github/workflows/tester.yml | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tester.yml b/.github/workflows/tester.yml index 45730b42..bdb1a3ef 100644 --- a/.github/workflows/tester.yml +++ b/.github/workflows/tester.yml @@ -37,7 +37,7 @@ jobs: - name: Building and install shell: bash -l {0} run: | - pip install . --user + pip install .[tests] --user - name: pytest shell: bash -l {0} run: | diff --git a/pyproject.toml b/pyproject.toml index b1158111..9d1490ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ visualisation = ["matplotlib", "pyvista", "loopstructuralvisualisation>=0.1.14"] export = ["geoh5py", "pyevtk", "dill"] jupyter = ["pyvista[all]"] inequalities = ["loopsolver"] +tests = ['pytest','all'] docs = [ "pyvista[all]", "pydata-sphinx-theme", From 236782cc611c3dda991af482d7fa0e53a0a54864 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 2 Jun 2026 14:31:15 +0930 Subject: [PATCH 7/9] fix: try geoh5 only --- .github/workflows/tester.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tester.yml b/.github/workflows/tester.yml index bdb1a3ef..20741b79 100644 --- a/.github/workflows/tester.yml +++ b/.github/workflows/tester.yml @@ -37,7 +37,8 @@ jobs: - name: Building and install shell: bash -l {0} run: | - pip install .[tests] --user + pip install . --user + pip install geoh5py --user - name: pytest shell: bash -l {0} run: | From 7f6ec9d68d610c8785a95da8b9fef234a8a15608 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 2 Jun 2026 14:40:22 +0930 Subject: [PATCH 8/9] replace conda with uv --- .github/workflows/tester.yml | 42 ++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/.github/workflows/tester.yml b/.github/workflows/tester.yml index 20741b79..7f7f78d4 100644 --- a/.github/workflows/tester.yml +++ b/.github/workflows/tester.yml @@ -7,7 +7,6 @@ on: paths: - '**.py' - .github/workflows/tester.yml - pull_request: branches: - master @@ -15,6 +14,7 @@ on: - '**.py' - .github/workflows/tester.yml workflow_dispatch: + jobs: continuous-integration: name: Continuous integration ${{ matrix.os }} python ${{ matrix.python-version }} @@ -22,24 +22,38 @@ jobs: strategy: fail-fast: false matrix: - os: ${{ fromJSON(vars.BUILD_OS)}} - python-version: ${{ fromJSON(vars.PYTHON_VERSIONS)}} + os: ${{ fromJSON(vars.BUILD_OS) }} + python-version: ${{ fromJSON(vars.PYTHON_VERSIONS) }} + steps: - uses: actions/checkout@v4 - - uses: conda-incubator/setup-miniconda@v3 + # 1. Setup uv instantly (replaces the heavy setup-miniconda) + - name: Set up uv + uses: astral-sh/setup-uv@v3 with: - python-version: ${{ matrix.python }} - - name: Installing dependencies - shell: bash -l {0} - run: | - conda install -c conda-forge numpy scipy scikit-image scikit-learn pytest networkx osqp matplotlib -y - - name: Building and install - shell: bash -l {0} + version: "latest" + + # 2. Pin the exact Python version from your matrix variable + - name: Set up Python ${{ matrix.matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + # 3. Create a clean virtual environment and install everything at once + - name: Install dependencies and library run: | - pip install . --user - pip install geoh5py --user + uv pip install --system \ + numpy \ + scipy \ + scikit-image \ + scikit-learn \ + pytest \ + networkx \ + osqp \ + matplotlib \ + geoh5py \ + -e . + + # 4. Run pytest directly inside the environment - name: pytest - shell: bash -l {0} run: | pytest From 46788e2b35653b6d21037b5403fb9c3daf23f6e7 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 2 Jun 2026 14:45:01 +0930 Subject: [PATCH 9/9] fix: adding test to uv install --- .github/workflows/tester.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tester.yml b/.github/workflows/tester.yml index 7f7f78d4..cb6672f5 100644 --- a/.github/workflows/tester.yml +++ b/.github/workflows/tester.yml @@ -51,7 +51,7 @@ jobs: osqp \ matplotlib \ geoh5py \ - -e . + -e .[tests] # 4. Run pytest directly inside the environment - name: pytest