diff --git a/docs/ext/pydantic_autosummary/pydantic.py b/docs/ext/pydantic_autosummary/pydantic.py index 2bdf5132d..6736abf0f 100644 --- a/docs/ext/pydantic_autosummary/pydantic.py +++ b/docs/ext/pydantic_autosummary/pydantic.py @@ -3,7 +3,7 @@ from enum import Enum from typing import Any, Final, get_args, get_origin -_DATAIO_METADATA_PACKAGE: Final = "fmu.dataio._model" +_DATAIO_METADATA_PACKAGE: Final = "fmu.dataio._models" def _is_dataio(annotation: Any) -> bool: diff --git a/docs/src/standard_results/structure_depth_surfaces.md b/docs/src/standard_results/structure_depth_surfaces.md new file mode 100644 index 000000000..f4c94a4f9 --- /dev/null +++ b/docs/src/standard_results/structure_depth_surfaces.md @@ -0,0 +1,45 @@ +# Structure depth surfaces + +This exports the modelled structural depth surfaces from within RMS. +These surfaces typically represent the final surface set generated during a structural +modelling workflow (after well conditioning), and frequently serve as the framework for +constructing the grid. + +Note, it is only possible to export **one single set** of depth surface predictions per +model workflow. They represent the structural prediction of the model in depth. + +:::{table} Current +:widths: auto +:align: left + +| Field | Value | +| --- | --- | +| Version | NA | +| Output | `share/results/maps/structure_depth_surfaces/surfacename.gri` | +::: + +## Requirements + +- RMS +- depth surfaces stored in a horizon folder within RMS + +The surfaces must be located within a horizon folder in RMS and be in domain `depth`. +This export function will automatically export all non-empty horizons from the provided folder. + + +## Usage + +```{eval-rst} +.. autofunction:: fmu.dataio.export.rms.structure_depth_surfaces.export_structure_depth_surfaces +``` + +## Result + +The surfaces from the horizon folder will be exported as 'irap_binary' +files to `share/results/maps/structure_depth_surfaces/surfacename.gri`. + + +## Standard result schema + +This standard result is not presented in a tabular format; therefore, no validation +schema exists. diff --git a/schemas/0.9.0/fmu_results.json b/schemas/0.9.0/fmu_results.json index 77ef6b994..fd4841e08 100644 --- a/schemas/0.9.0/fmu_results.json +++ b/schemas/0.9.0/fmu_results.json @@ -219,6 +219,9 @@ "oneOf": [ { "$ref": "#/$defs/InplaceVolumesStandardResult" + }, + { + "$ref": "#/$defs/StructureDepthSurfaceStandardResult" } ], "title": "AnyStandardResult" @@ -7921,6 +7924,32 @@ "title": "StratigraphicColumn", "type": "object" }, + "StructureDepthSurfaceStandardResult": { + "description": "The ``standard_result`` field contains information about which standard results this\ndata object represent.\nThis class contains metadata for the 'structure_depth_surface' standard result.", + "properties": { + "file_schema": { + "anyOf": [ + { + "$ref": "#/$defs/FileSchema" + }, + { + "type": "null" + } + ], + "default": null + }, + "name": { + "const": "structure_depth_surface", + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "StructureDepthSurfaceStandardResult", + "type": "object" + }, "SubcropData": { "description": "The ``data`` block contains information about the data contained in this object.\nThis class contains metadata for subcrops.", "properties": { diff --git a/src/fmu/dataio/_models/fmu_results/enums.py b/src/fmu/dataio/_models/fmu_results/enums.py index e025a5b33..65a3691d9 100644 --- a/src/fmu/dataio/_models/fmu_results/enums.py +++ b/src/fmu/dataio/_models/fmu_results/enums.py @@ -8,6 +8,7 @@ class StandardResultName(str, Enum): """The standard result name of a given data object.""" inplace_volumes = "inplace_volumes" + structure_depth_surface = "structure_depth_surface" class Classification(str, Enum): diff --git a/src/fmu/dataio/_models/fmu_results/standard_result.py b/src/fmu/dataio/_models/fmu_results/standard_result.py index 3b86e898e..5d50f3dc2 100644 --- a/src/fmu/dataio/_models/fmu_results/standard_result.py +++ b/src/fmu/dataio/_models/fmu_results/standard_result.py @@ -58,6 +58,17 @@ class InplaceVolumesStandardResult(StandardResult): """The schema identifying the format of the 'inplace_volumes' standard result.""" +class StructureDepthSurfaceStandardResult(StandardResult): + """ + The ``standard_result`` field contains information about which standard results this + data object represent. + This class contains metadata for the 'structure_depth_surface' standard result. + """ + + name: Literal[enums.StandardResultName.structure_depth_surface] + """The identifying product name for the 'structure_depth_surface' product.""" + + class AnyStandardResult(RootModel): """ The ``standard result`` field contains information about which standard result this @@ -70,6 +81,9 @@ class AnyStandardResult(RootModel): """ root: Annotated[ - Union[InplaceVolumesStandardResult,], + Union[ + InplaceVolumesStandardResult, + StructureDepthSurfaceStandardResult, + ], Field(discriminator="name"), ] diff --git a/src/fmu/dataio/export/rms/__init__.py b/src/fmu/dataio/export/rms/__init__.py index be0e5fd16..40db040c9 100644 --- a/src/fmu/dataio/export/rms/__init__.py +++ b/src/fmu/dataio/export/rms/__init__.py @@ -1,3 +1,8 @@ from .inplace_volumes import export_inplace_volumes, export_rms_volumetrics +from .structure_depth_surfaces import export_structure_depth_surfaces -__all__ = ["export_inplace_volumes", "export_rms_volumetrics"] +__all__ = [ + "export_structure_depth_surfaces", + "export_inplace_volumes", + "export_rms_volumetrics", +] diff --git a/src/fmu/dataio/export/rms/_utils.py b/src/fmu/dataio/export/rms/_utils.py index c638af550..c8dce02d3 100644 --- a/src/fmu/dataio/export/rms/_utils.py +++ b/src/fmu/dataio/export/rms/_utils.py @@ -3,6 +3,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Final +import xtgeo from packaging.version import parse as versionparse from fmu.dataio._logging import null_logger @@ -65,3 +66,30 @@ def load_global_config() -> dict[str, Any]: f"location: {CONFIG_PATH}." ) return load_config_from_path(CONFIG_PATH) + + +def horizon_folder_exist(project: Any, horizon_folder: str) -> bool: + """Check if a horizon folder exist inside the project""" + return horizon_folder in project.horizons.representations + + +def get_horizons_in_folder( + project: Any, horizon_folder: str +) -> list[xtgeo.RegularSurface]: + """Get all non-empty horizons from a horizon folder stratigraphically ordered.""" + + logger.debug("Reading horizons from folder %s", horizon_folder) + + if not horizon_folder_exist(project, horizon_folder): + raise ValueError( + f"The provided horizon folder name {horizon_folder} " + "does not exist inside RMS." + ) + + surfaces = [] + for horizon in project.horizons: + if not horizon[horizon_folder].is_empty(): + surfaces.append( + xtgeo.surface_from_roxar(project, horizon.name, horizon_folder) + ) + return surfaces diff --git a/src/fmu/dataio/export/rms/structure_depth_surfaces.py b/src/fmu/dataio/export/rms/structure_depth_surfaces.py new file mode 100644 index 000000000..45ede36d8 --- /dev/null +++ b/src/fmu/dataio/export/rms/structure_depth_surfaces.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any, Final + +import fmu.dataio as dio +from fmu.dataio._logging import null_logger +from fmu.dataio._models.fmu_results import standard_result +from fmu.dataio._models.fmu_results.enums import Classification, StandardResultName +from fmu.dataio.export._decorators import experimental +from fmu.dataio.export._export_result import ExportResult, ExportResultItem +from fmu.dataio.export.rms._utils import ( + get_horizons_in_folder, + get_rms_project_units, + load_global_config, +) + +if TYPE_CHECKING: + import xtgeo + +_logger: Final = null_logger(__name__) + + +class _ExportStructureDepthSurfaces: + def __init__( + self, + project: Any, + horizon_folder: str, + ) -> None: + _logger.debug("Process data, establish state prior to export.") + self._config = load_global_config() + self._surfaces = get_horizons_in_folder(project, horizon_folder) + self._unit = "m" if get_rms_project_units(project) == "metric" else "ft" + + _logger.debug("Process data... DONE") + + @property + def _standard_result(self) -> standard_result.StructureDepthSurfaceStandardResult: + """Product type for the exported data.""" + return standard_result.StructureDepthSurfaceStandardResult( + name=StandardResultName.structure_depth_surface + ) + + @property + def _classification(self) -> Classification: + """Get default classification.""" + return Classification.internal + + def _export_surface(self, surf: xtgeo.RegularSurface) -> ExportResultItem: + edata = dio.ExportData( + config=self._config, + content="depth", + unit=self._unit, + vertical_domain="depth", + domain_reference="msl", + subfolder="structure_depth_surfaces", + is_prediction=True, + name=surf.name, + classification=self._classification, + rep_include=True, + ) + + absolute_export_path = edata._export_with_standard_result( + surf, standard_result=self._standard_result + ) + _logger.debug("Surface exported to: %s", absolute_export_path) + + return ExportResultItem( + absolute_path=Path(absolute_export_path), + ) + + def _export_surfaces(self) -> ExportResult: + """Do the actual surface export using dataio setup.""" + return ExportResult( + items=[self._export_surface(surf) for surf in self._surfaces] + ) + + def _validate_surfaces(self) -> None: + """Surface validations.""" + # TODO: Add check that the surfaces are consistent, i.e. a stratigraphic + # deeper surface should never have shallower values than the one above + # also check that the surfaces have a stratigraphy entry. + + def export(self) -> ExportResult: + """Export the depth as a standard_result.""" + return self._export_surfaces() + + +@experimental +def export_structure_depth_surfaces( + project: Any, + horizon_folder: str, +) -> ExportResult: + """Simplified interface when exporting modelled depth surfaces from RMS. + + Args: + project: The 'magic' project variable in RMS. + horizon_folder: Name of horizon folder in RMS. + Note: + This function is experimental and may change in future versions. + + Examples: + Example usage in an RMS script:: + + from fmu.dataio.export.rms import export_structure_depth_surfaces + + export_results = export_structure_depth_surfaces(project, "DS_extracted") + + for result in export_results.items: + print(f"Output surfaces to {result.absolute_path}") + + """ + + return _ExportStructureDepthSurfaces(project, horizon_folder).export() diff --git a/tests/test_export_rms/conftest.py b/tests/test_export_rms/conftest.py index e352ba0fc..7c5b17a48 100644 --- a/tests/test_export_rms/conftest.py +++ b/tests/test_export_rms/conftest.py @@ -207,7 +207,7 @@ def mock_rmsapi_jobs(): yield mock_rmsapi_jobs -@pytest.fixture +@pytest.fixture(autouse=True) def mocked_rmsapi_modules(mock_rmsapi, mock_rmsapi_jobs): with patch.dict( sys.modules, @@ -219,9 +219,26 @@ def mocked_rmsapi_modules(mock_rmsapi, mock_rmsapi_jobs): yield mocked_modules -@pytest.fixture(autouse=True) +@pytest.fixture def mock_project_variable(): # A mock_project variable for the RMS 'project' (potentially extend for later use) mock_project = MagicMock() + mock_project.horizons.representations = ["DS_final"] yield mock_project + + +@pytest.fixture +def xtgeo_surfaces(regsurf): + regsurf_top = regsurf.copy() + regsurf_top.name = "TopVolantis" + + regsurf_mid = regsurf.copy() + regsurf_mid.name = "TopTherys" + regsurf_mid.values += 100 + + regsurf_base = regsurf.copy() + regsurf_base.name = "TopVolon" + regsurf_base.values += 200 + + yield [regsurf_top, regsurf_mid, regsurf_base] diff --git a/tests/test_export_rms/test_export_structure_depth_surfaces.py b/tests/test_export_rms/test_export_structure_depth_surfaces.py new file mode 100644 index 000000000..094800879 --- /dev/null +++ b/tests/test_export_rms/test_export_structure_depth_surfaces.py @@ -0,0 +1,109 @@ +"""Test the dataio running RMS spesici utility function for volumetrics""" + +from pathlib import Path +from unittest import mock + +import pytest + +from fmu import dataio +from fmu.dataio._logging import null_logger +from fmu.dataio._models.fmu_results.enums import StandardResultName +from tests.utils import inside_rms + +logger = null_logger(__name__) + + +@pytest.fixture +def mock_export_class( + mock_project_variable, + monkeypatch, + rmssetup_with_fmuconfig, + xtgeo_surfaces, +): + # needed to find the global config at correct place + monkeypatch.chdir(rmssetup_with_fmuconfig) + + from fmu.dataio.export.rms.structure_depth_surfaces import ( + _ExportStructureDepthSurfaces, + ) + + with mock.patch( + "fmu.dataio.export.rms.structure_depth_surfaces.get_horizons_in_folder", + return_value=xtgeo_surfaces, + ): + yield _ExportStructureDepthSurfaces(mock_project_variable, "geogrid_vol") + + +@inside_rms +def test_files_exported_with_metadata(mock_export_class, rmssetup_with_fmuconfig): + """Test that the standard_result is set correctly in the metadata""" + + mock_export_class.export() + + export_folder = ( + rmssetup_with_fmuconfig / "../../share/results/maps/structure_depth_surfaces" + ) + assert export_folder.exists() + + assert (export_folder / "topvolantis.gri").exists() + assert (export_folder / "toptherys.gri").exists() + assert (export_folder / "topvolon.gri").exists() + + assert (export_folder / ".topvolantis.gri.yml").exists() + assert (export_folder / ".toptherys.gri.yml").exists() + assert (export_folder / ".topvolon.gri.yml").exists() + + +@inside_rms +def test_standard_result_in_metadata(mock_export_class): + """Test that the standard_result is set correctly in the metadata""" + + out = mock_export_class.export() + metadata = dataio.read_metadata(out.items[0].absolute_path) + + assert "standard_result" in metadata["data"] + assert ( + metadata["data"]["standard_result"]["name"] + == StandardResultName.structure_depth_surface + ) + + +@inside_rms +def test_public_export_function(mock_project_variable, mock_export_class): + """Test that the export function works""" + + from fmu.dataio.export.rms import export_structure_depth_surfaces + + out = export_structure_depth_surfaces(mock_project_variable, "DS_extract") + + assert len(out.items) == 3 + + metadata = dataio.read_metadata(out.items[0].absolute_path) + + assert "depth" in metadata["data"]["content"] + assert metadata["access"]["classification"] == "internal" + assert metadata["data"]["is_prediction"] + assert ( + metadata["data"]["standard_result"]["name"] + == StandardResultName.structure_depth_surface + ) + + +@inside_rms +def test_config_missing(mock_project_variable, rmssetup_with_fmuconfig, monkeypatch): + """Test that an exception is raised if the config is missing.""" + + from fmu.dataio.export.rms import export_structure_depth_surfaces + from fmu.dataio.export.rms._utils import CONFIG_PATH + + monkeypatch.chdir(rmssetup_with_fmuconfig) + + config_path_modified = Path("wrong.yml") + + CONFIG_PATH.rename(config_path_modified) + + with pytest.raises(FileNotFoundError, match="Could not detect"): + export_structure_depth_surfaces(mock_project_variable, "DS_extract") + + # restore the global config file for later tests + config_path_modified.rename(CONFIG_PATH) diff --git a/tests/test_export_rms/test_utils.py b/tests/test_export_rms/test_utils.py new file mode 100644 index 000000000..5282129f4 --- /dev/null +++ b/tests/test_export_rms/test_utils.py @@ -0,0 +1,45 @@ +from unittest import mock + +import pytest + + +def test_get_horizons_in_folder(mock_project_variable): + from fmu.dataio.export.rms._utils import get_horizons_in_folder + + horizon_folder = "DS_final" + + horizon1 = mock.MagicMock() + horizon1[horizon_folder].is_empty.return_value = True + horizon1.name = "msl" + + horizon2 = mock.MagicMock() + horizon2[horizon_folder].is_empty.return_value = False + horizon2.name = "TopVolantis" + + horizon3 = mock.MagicMock() + horizon3[horizon_folder].is_empty.return_value = False + horizon3.name = "TopTherys" + + mock_project_variable.horizons.__iter__.return_value = [ + horizon1, + horizon2, + horizon3, + ] + # Mock xtgeo.surface_from_roxar to return just the surface name + with mock.patch( + "xtgeo.surface_from_roxar", + side_effect=lambda _project, name, _category: name, + ): + surfaces = get_horizons_in_folder(mock_project_variable, horizon_folder) + + # cthe empty 'msl' surface should not be included + assert surfaces == ["TopVolantis", "TopTherys"] + + +def test_get_horizons_in_folder_folder_not_exist(mock_project_variable): + from fmu.dataio.export.rms._utils import get_horizons_in_folder + + horizon_folder = "non_existent_folder" + + with pytest.raises(ValueError, match="not exist inside RMS"): + get_horizons_in_folder(mock_project_variable, horizon_folder)