From e20402ccfc245cd805cb8f2ec396d25f1414931d Mon Sep 17 00:00:00 2001 From: Alif Be Date: Sun, 9 Aug 2026 11:38:35 +0200 Subject: [PATCH] ENH: Add roff2ecl grid mode --- docs/overview.rst | 4 +- .../convert_grid_format.py | 84 ++++++- .../forward_model_steps.py | 45 ++++ tests/test_convert_grid_format.py | 208 ++++++++++++++++++ tests/test_hook_implementations.py | 1 + 5 files changed, 329 insertions(+), 13 deletions(-) diff --git a/docs/overview.rst b/docs/overview.rst index ef86e2d54..3997a28f9 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -40,5 +40,5 @@ welltest_dpds ✅ ✅ ⛔️ ======================== === ================= ============ .. [*] ``convert_grid_format`` is the script that contains functionality - for the ``ECLGRID2ROFF``, ``ECLINIT2ROFF``, and ``ECLRST2ROFF`` forward - models. + for the ``ECLGRID2ROFF``, ``ECLINIT2ROFF``, ``ECLRST2ROFF``, + and ``ROFF2ECLGRID`` forward models. diff --git a/src/subscript/convert_grid_format/convert_grid_format.py b/src/subscript/convert_grid_format/convert_grid_format.py index 03b338f0c..a6c09556d 100644 --- a/src/subscript/convert_grid_format/convert_grid_format.py +++ b/src/subscript/convert_grid_format/convert_grid_format.py @@ -15,8 +15,9 @@ APPNAME = "convert_grid_format (subscript)" # allowed CONVERSIONS and MODES: -CONVERSIONS = ["ecl2roff"] +CONVERSIONS = ["ecl2roff", "roff2ecl"] MODES = ["grid", "init", "restart"] +ECL_FORMATS = {"grdecl", "bgrdecl", "egrid"} xtg = XTGeoDialog() @@ -47,16 +48,30 @@ def get_parser() -> argparse.ArgumentParser: dest="mode", type=str, default="grid", - help=f"Mode: {MODES} (default grid)", + help=f"Mode: {MODES} (default grid). roff2ecl supports grid only.", ) parser.add_argument( - "--file", dest="infile", type=str, help="Input file name (full name or root)" + "--file", + dest="infile", + type=str, + help="Input file name (full name or root)", ) parser.add_argument( "--output", dest="outfile", type=str, help="Output file name (full name)" ) + parser.add_argument( + "--outformat", + dest="outformat", + type=str, + default="auto", + help=( + "Output Eclipse format for roff2ecl: " + f"{sorted(ECL_FORMATS)} or auto (infer from --output extension, " + "fallback to grdecl)" + ), + ) parser.add_argument( "--propnames", @@ -181,6 +196,45 @@ def _convert_ecl2roff( raise SystemExit("Invalid grid extention") +def _resolve_ecl_format(outfile: str, outformat: str) -> str: + format_name = outformat.lower() + if format_name != "auto": + if format_name not in ECL_FORMATS: + raise SystemExit( + f"Invalid outformat <{outformat}>. Allowed: {ECL_FORMATS} or auto" + ) + return format_name + + _name, ext = os.path.splitext(outfile) + ext_map = {".grdecl": "grdecl", ".bgrdecl": "bgrdecl", ".egrid": "egrid"} + inferred = ext_map.get(ext.lower()) + if inferred: + return inferred + + logger.info( + "outformat=auto could not infer format from extension <%s>; defaulting to " + "grdecl", + ext or "", + ) + return "grdecl" + + +def _convert_roff2ecl( + filename: str, + mode: str, + outfile: str, + outformat: str, +) -> None: + if not filename or not outfile: + raise SystemExit("STOP. Both --file and --output are required") + if mode != "grid": + raise SystemExit(f"STOP! Invalid mode for roff2ecl: <{mode}>") + + ecl_format = _resolve_ecl_format(outfile, outformat) + grid = xtgeo.grid_from_file(filename, fformat="roff") + grid.to_file(outfile, fformat=ecl_format) + + def main(args: Sequence[str] | None = None) -> None: """Entry-point""" @@ -196,14 +250,22 @@ def main(args: Sequence[str] | None = None) -> None: ) xtg.say("Running conversion...") - _convert_ecl2roff( - parsed_args.infile, - parsed_args.mode, - parsed_args.outfile, - parsed_args.stdfmu, - parsed_args.propnames, - parsed_args.dates, - ) + if parsed_args.conversion == "ecl2roff": + _convert_ecl2roff( + parsed_args.infile, + parsed_args.mode, + parsed_args.outfile, + parsed_args.stdfmu, + parsed_args.propnames, + parsed_args.dates, + ) + else: + _convert_roff2ecl( + parsed_args.infile, + parsed_args.mode, + parsed_args.outfile, + parsed_args.outformat, + ) if __name__ == "__main__": diff --git a/src/subscript/hook_implementations/forward_model_steps.py b/src/subscript/hook_implementations/forward_model_steps.py index 2d0819412..6891f9859 100644 --- a/src/subscript/hook_implementations/forward_model_steps.py +++ b/src/subscript/hook_implementations/forward_model_steps.py @@ -420,6 +420,50 @@ def documentation() -> ForwardModelStepDocumentation | None: ) +class Roff2EclGrid(ForwardModelStepPlugin): + def __init__(self) -> None: + super().__init__( + name="ROFF2ECLGRID", + command=[ + shutil.which("convert_grid_format"), + "--conversion", + "roff2ecl", + "--file", + "", + "--output", + "", + "--outformat", + "", + "--mode", + "grid", + ], + default_mapping={"": "auto"}, + ) + + @staticmethod + def documentation() -> ForwardModelStepDocumentation | None: + return ForwardModelStepDocumentation( + description="""Convert a ROFF grid geometry file to Eclipse format. + +This forward model uses the script ``convert_grid_format`` from subscript. + +Supported output formats are ``grdecl``, ``bgrdecl`` and ``egrid``. +Destination directory must exist. + +```` is optional and defaults to ``auto`` (infer from output +extension). If the extension is missing or not recognized, it defaults to +``grdecl``. +""", + category="utility.eclipse", + examples=""" +.. code-block:: console + + FORWARD_MODEL ROFF2ECLGRID(=share/results/grids/reek.roff, \ + =share/results/grids/reek.EGRID, =egrid) +""", + ) + + class GravSubsMaps(ForwardModelStepPlugin): def __init__(self) -> None: super().__init__( @@ -861,6 +905,7 @@ def installable_forward_model_steps() -> list[type[ForwardModelStepPlugin]]: Eclgrid2Roff, Eclinit2Roff, Eclrst2Roff, + Roff2EclGrid, GravSubsMaps, GravSubsPoints, InterpRelperm, diff --git a/tests/test_convert_grid_format.py b/tests/test_convert_grid_format.py index ed0a644df..abd53aa5e 100644 --- a/tests/test_convert_grid_format.py +++ b/tests/test_convert_grid_format.py @@ -1,5 +1,6 @@ """Test the convert_grid_format script""" +import logging import subprocess from pathlib import Path @@ -29,6 +30,12 @@ ) +def _create_roff_grid(infile: Path, outfile: Path) -> None: + xtgeo.grid_from_file(str(infile), fformat="egrid").to_file( + str(outfile), fformat="roff" + ) + + def test_convert_grid_format_egrid(tmp_path, mocker): """Convert an ECLIPSE egrid to roff""" @@ -85,6 +92,183 @@ def test_convert_grid_format_restart(tmp_path, mocker): assert gprop.values.mean() == pytest.approx(0.0857, abs=0.001) +@pytest.mark.parametrize( + "outformat, suffix, read_format", + [ + ("grdecl", ".grdecl", "grdecl"), + ("bgrdecl", ".bgrdecl", "bgrdecl"), + ("egrid", ".EGRID", "egrid"), + ], +) +def test_convert_grid_format_roff2ecl_grid( + tmp_path, mocker, outformat, suffix, read_format +): + infile = tmp_path / "input.roff" + _create_roff_grid(RFILE1, infile) + outfile = tmp_path / f"output{suffix}" + + mocker.patch( + "sys.argv", + [ + "convert_grid_format", + "--conversion", + "roff2ecl", + "--file", + str(infile), + "--output", + str(outfile), + "--outformat", + outformat, + "--mode", + "grid", + ], + ) + cgf.main() + + geogrid = xtgeo.grid_from_file(str(outfile), fformat=read_format) + assert geogrid.nactive == 35817 + if outformat in {"grdecl", "bgrdecl"}: + grid = xtgeo.grid_from_file(str(RFILE1), fformat="egrid") + actnum = xtgeo.gridproperty_from_file( + str(outfile), fformat=outformat, name="ACTNUM", grid=grid + ) + assert int(actnum.values.sum()) == 35817 + + +def test_convert_grid_format_roff2ecl_grid_infer_outformat(tmp_path, mocker): + infile = tmp_path / "input.roff" + _create_roff_grid(RFILE1, infile) + outfile = tmp_path / "output.bgrdecl" + + mocker.patch( + "sys.argv", + [ + "convert_grid_format", + "--conversion", + "roff2ecl", + "--file", + str(infile), + "--output", + str(outfile), + "--mode", + "grid", + ], + ) + cgf.main() + + geogrid = xtgeo.grid_from_file(str(outfile), fformat="bgrdecl") + assert geogrid.nactive == 35817 + + +def test_convert_grid_format_roff2ecl_invalid_outformat(tmp_path, mocker): + infile = tmp_path / "input.roff" + _create_roff_grid(RFILE1, infile) + outfile = tmp_path / "output.grdecl" + + mocker.patch( + "sys.argv", + [ + "convert_grid_format", + "--conversion", + "roff2ecl", + "--file", + str(infile), + "--output", + str(outfile), + "--outformat", + "nope", + "--mode", + "grid", + ], + ) + with pytest.raises(SystemExit, match="Invalid outformat"): + cgf.main() + + +def test_convert_grid_format_roff2ecl_auto_outformat(tmp_path, mocker): + infile = tmp_path / "input.roff" + _create_roff_grid(RFILE1, infile) + outfile = tmp_path / "output.EGRID" + + mocker.patch( + "sys.argv", + [ + "convert_grid_format", + "--conversion", + "roff2ecl", + "--file", + str(infile), + "--output", + str(outfile), + "--outformat", + "AUTO", + "--mode", + "grid", + ], + ) + cgf.main() + + geogrid = xtgeo.grid_from_file(str(outfile), fformat="egrid") + assert geogrid.nactive == 35817 + + +def test_convert_grid_format_roff2ecl_auto_fallback_logs_and_grdecl( + tmp_path, mocker, caplog +): + infile = tmp_path / "input.roff" + _create_roff_grid(RFILE1, infile) + outfile = tmp_path / "output.unknown" + + mocker.patch( + "sys.argv", + [ + "convert_grid_format", + "--conversion", + "roff2ecl", + "--file", + str(infile), + "--output", + str(outfile), + "--outformat", + "auto", + "--mode", + "grid", + ], + ) + with caplog.at_level(logging.INFO): + cgf.main() + + assert "defaulting to grdecl" in caplog.text + grid = xtgeo.grid_from_file(str(RFILE1), fformat="egrid") + actnum = xtgeo.gridproperty_from_file( + str(outfile), fformat="grdecl", name="ACTNUM", grid=grid + ) + assert int(actnum.values.sum()) == 35817 + + +def test_convert_grid_format_roff2ecl_invalid_mode(tmp_path, mocker): + infile = tmp_path / "input.roff" + _create_roff_grid(RFILE1, infile) + outfile = tmp_path / "output.grdecl" + + mocker.patch( + "sys.argv", + [ + "convert_grid_format", + "--conversion", + "roff2ecl", + "--file", + str(infile), + "--output", + str(outfile), + "--mode", + "restart", + ], + ) + with pytest.raises(SystemExit, match="Invalid mode for roff2ecl"): + cgf.main() + + @pytest.mark.parametrize( "dates, date_mode, expected_files", [ @@ -173,3 +357,27 @@ def test_ert_integration_eclgrid2roff(tmp_path, monkeypatch): # check number of active cells geogrid = xtgeo.grid_from_file(str(outfile)) assert geogrid.nactive == 35817 + + +@pytest.mark.integration +def test_ert_integration_roff2eclgrid(tmp_path, monkeypatch): + pytest.importorskip("ert") + monkeypatch.chdir(tmp_path) + infile = Path("reek_grid.roff") + _create_roff_grid(RFILE1, infile) + outfile = "reek_grid.EGRID" + ert_config = "config_roff2ecl.ert" + Path(ert_config).write_text( + f""" + NUM_REALIZATIONS 1 + RUNPATH . + FORWARD_MODEL ROFF2ECLGRID(={infile}, \ + ={outfile}, =egrid) + """, + encoding="utf-8", + ) + + subprocess.run(["ert", "test_run", "--disable-monitor", ert_config], check=True) + assert Path(outfile).exists() + geogrid = xtgeo.grid_from_file(str(outfile), fformat="egrid") + assert geogrid.nactive == 35817 diff --git a/tests/test_hook_implementations.py b/tests/test_hook_implementations.py index 2c1c439d7..0d4b33099 100644 --- a/tests/test_hook_implementations.py +++ b/tests/test_hook_implementations.py @@ -22,6 +22,7 @@ "ECLGRID2ROFF", "ECLINIT2ROFF", "ECLRST2ROFF", + "ROFF2ECLGRID", "GRAV_SUBS_MAPS", "GRAV_SUBS_POINTS", "INTERP_RELPERM",