diff --git a/.github/workflows/tester.yml b/.github/workflows/tester.yml index 489bc8bf..e9141713 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,23 +22,34 @@ 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 + - 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 pyvista pandas pytest networkx osqp matplotlib -y - - name: Building and install - shell: bash -l {0} + version: "latest" + + - name: Set up Python ${{ matrix.matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies and library run: | - pip install . --user + uv pip install --system \ + numpy \ + scipy \ + scikit-image \ + scikit-learn \ + pytest \ + networkx \ + osqp \ + matplotlib \ + geoh5py \ + -e .[tests] + - name: pytest - shell: bash -l {0} run: | pytest 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, diff --git a/LoopStructural/export/geoh5.py b/LoopStructural/export/geoh5.py index c91501e0..ee15f9c7 100644 --- a/LoopStructural/export/geoh5.py +++ b/LoopStructural/export/geoh5.py @@ -1,16 +1,33 @@ import geoh5py import geoh5py.workspace import numpy as np -from LoopStructural.datatypes import ValuePoints, VectorPoints +import pandas as pd +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 @@ -32,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( @@ -52,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, @@ -60,7 +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, 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: + 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=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) + def add_structured_grid_to_geoh5(filename, structured_grid, overwrite=True, groupname="Loop"): with geoh5py.workspace.Workspace(filename) as workspace: 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 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", 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