From f8637e76da8910b39030c111b5735f376ed8cc62 Mon Sep 17 00:00:00 2001 From: Bonelli Date: Fri, 6 Feb 2026 18:49:22 -0500 Subject: [PATCH 01/90] ci(release): update version strings --- CITATION.cff | 2 +- README.md | 4 ++-- docs/PyPI_release.md | 2 +- flopy/version.py | 4 ++-- version.txt | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index af7be50b9..4d0e1c562 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -3,7 +3,7 @@ message: If you use this software, please cite both the article from preferred-c references, and the software itself. type: software title: FloPy -version: 3.10.0 +version: 3.11.0.dev0 date-released: '2026-02-06' doi: 10.5066/F7BK19FH abstract: A Python package to create, run, and post-process MODFLOW-based models. diff --git a/README.md b/README.md index 48c169552..3955f98d4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ flopy3 -### Version 3.10.0 +### Version 3.11.0.dev0 [![flopy continuous integration](https://github.com/modflowpy/flopy/actions/workflows/commit.yml/badge.svg?branch=develop)](https://github.com/modflowpy/flopy/actions/workflows/commit.yml) [![Read the Docs](https://github.com/modflowpy/flopy/actions/workflows/rtd.yml/badge.svg?branch=develop)](https://github.com/modflowpy/flopy/actions/workflows/rtd.yml) @@ -150,7 +150,7 @@ How to Cite ##### ***Software/Code citation for FloPy:*** -[Bakker, Mark, Post, Vincent, Hughes, J. D., Langevin, C. D., White, J. T., Leaf, A. T., Paulinski, S. R., Bellino, J. C., Morway, E. D., Toews, M. W., Larsen, J. D., Fienen, M. N., Starn, J. J., Brakenhoff, D. A., and Bonelli, W. P., 2026, FloPy v3.10.0: U.S. Geological Survey Software Release, 06 February 2026, https://doi.org/10.5066/F7BK19FH](https://doi.org/10.5066/F7BK19FH) +[Bakker, Mark, Post, Vincent, Hughes, J. D., Langevin, C. D., White, J. T., Leaf, A. T., Paulinski, S. R., Bellino, J. C., Morway, E. D., Toews, M. W., Larsen, J. D., Fienen, M. N., Starn, J. J., Brakenhoff, D. A., and Bonelli, W. P., 2026, FloPy v3.11.0.dev0: U.S. Geological Survey Software Release, 06 February 2026, https://doi.org/10.5066/F7BK19FH](https://doi.org/10.5066/F7BK19FH) Additional FloPy Related Publications diff --git a/docs/PyPI_release.md b/docs/PyPI_release.md index 6c0bb20ad..0506ad080 100644 --- a/docs/PyPI_release.md +++ b/docs/PyPI_release.md @@ -30,4 +30,4 @@ How to Cite *Software/Code citation for FloPy:* -[Bakker, Mark, Post, Vincent, Hughes, J. D., Langevin, C. D., White, J. T., Leaf, A. T., Paulinski, S. R., Bellino, J. C., Morway, E. D., Toews, M. W., Larsen, J. D., Fienen, M. N., Starn, J. J., Brakenhoff, D. A., and Bonelli, W. P., 2026, FloPy v3.10.0: U.S. Geological Survey Software Release, 06 February 2026, https://doi.org/10.5066/F7BK19FH](https://doi.org/10.5066/F7BK19FH) +[Bakker, Mark, Post, Vincent, Hughes, J. D., Langevin, C. D., White, J. T., Leaf, A. T., Paulinski, S. R., Bellino, J. C., Morway, E. D., Toews, M. W., Larsen, J. D., Fienen, M. N., Starn, J. J., Brakenhoff, D. A., and Bonelli, W. P., 2026, FloPy v3.11.0.dev0: U.S. Geological Survey Software Release, 06 February 2026, https://doi.org/10.5066/F7BK19FH](https://doi.org/10.5066/F7BK19FH) diff --git a/flopy/version.py b/flopy/version.py index 5d45ece6c..f61209fa0 100644 --- a/flopy/version.py +++ b/flopy/version.py @@ -1,4 +1,4 @@ # flopy version file automatically created using -# update_version.py on February 06, 2026 23:21:13 +# update_version.py on February 06, 2026 18:48:16 -__version__ = "3.10.0" +__version__ = "3.11.0.dev0" diff --git a/version.txt b/version.txt index e06d07afe..df007b9dd 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -3.10.0 \ No newline at end of file +3.11.0.dev0 \ No newline at end of file From 60c269733a77cfaa68100ebde5b8cb71663dded4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 04:42:10 -0500 Subject: [PATCH 02/90] chore(deps): bump dawidd6/action-download-artifact from 14 to 15 (#2713) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8bf3ef4d8..7b1915b71 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -175,7 +175,7 @@ jobs: # actions/download-artifact won't look at previous workflow runs but we need to in order to get changelog - name: Download artifacts - uses: dawidd6/action-download-artifact@v14 + uses: dawidd6/action-download-artifact@v15 - name: Draft release env: From 7746e0995a0e72716e0012b4c84d1aac4327a566 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 25 Feb 2026 11:53:34 -0500 Subject: [PATCH 03/90] fix(model_splitter): fix spurious ssm creation (#2716) The model splitter generated empty SSM package files for partitions containing no boundary conditions, causing MODFLOW 6 to fail. The _remap_ssm() method set a dict entry to None to indicate a partition had no boundary flows. Later the dict is checked in an if statement and considered "truthy" since it contains an entry whose value is None Instead, indicate no boundary flows by not adding an entry to the dict. The conditional works, no SSM package is created, no file written, MF6 is happy. Fix #2715 --- autotest/test_model_splitter.py | 47 +++++++++++++++++++++++++++++++ flopy/mf6/utils/model_splitter.py | 2 -- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/autotest/test_model_splitter.py b/autotest/test_model_splitter.py index 624c01b7e..6e1b93d84 100644 --- a/autotest/test_model_splitter.py +++ b/autotest/test_model_splitter.py @@ -578,6 +578,53 @@ def test_empty_packages(function_tmpdir): ) +def test_empty_ssm(function_tmpdir): + nlay, nrow, ncol = 1, 1, 10 + gwfname = "gwf" + gwtname = "gwt" + + sim = flopy.mf6.MFSimulation(sim_ws=function_tmpdir) + flopy.mf6.ModflowIms(sim) + flopy.mf6.ModflowTdis(sim) + + gwf = flopy.mf6.ModflowGwf(sim, modelname=gwfname) + flopy.mf6.ModflowGwfdis(gwf, nlay=nlay, nrow=nrow, ncol=ncol) + flopy.mf6.ModflowGwfnpf(gwf) + flopy.mf6.ModflowGwfic(gwf, strt=1.0) + flopy.mf6.ModflowGwfchd( + gwf, stress_period_data=[((0, 0, 0), 1.0), ((0, 0, ncol - 1), 0.0)] + ) + flopy.mf6.ModflowGwfwel( + gwf, + auxiliary=["CONCENTRATION"], + stress_period_data=[((0, 0, 2), -1.0, 0.0)], + ) + + gwt = flopy.mf6.ModflowGwt(sim, modelname=gwtname) + flopy.mf6.ModflowGwtdis(gwt, nlay=nlay, nrow=nrow, ncol=ncol) + flopy.mf6.ModflowGwtic(gwt, strt=0.0) + flopy.mf6.ModflowGwtmst(gwt, porosity=0.3) + flopy.mf6.ModflowGwtssm(gwt, sources=[("wel", "AUX", "CONCENTRATION")]) + + flopy.mf6.ModflowGwfgwt( + sim, exgtype="GWF6-GWT6", exgmnamea=gwfname, exgmnameb=gwtname + ) + + # Split: partition 0 = cols 0-4 (contains WEL at col 2) + # partition 1 = cols 5-9 (no WEL) + array = np.zeros((nrow, ncol), dtype=int) + array[0, 5:] = 1 + + mfs = Mf6Splitter(sim) + new_sim = mfs.split_multi_model(array) + + gwt0 = new_sim.get_model(f"{gwtname}_0") + gwt1 = new_sim.get_model(f"{gwtname}_1") + + assert gwt0.get_package("ssm") is not None + assert gwt1.get_package("ssm") is None + + @requires_exe("mf6") def test_transient_array(function_tmpdir): name = "tarr" diff --git a/flopy/mf6/utils/model_splitter.py b/flopy/mf6/utils/model_splitter.py index b0130d234..f55a6a738 100644 --- a/flopy/mf6/utils/model_splitter.py +++ b/flopy/mf6/utils/model_splitter.py @@ -2648,8 +2648,6 @@ def _remap_ssm(self, package, mapped_data): if records: mapped_data[mkey]["sources"] = records - else: - mapped_data[mkey]["sources"] = None return mapped_data From 1b72355dc6b5125feb1f7453542ec781340fa3b2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 07:36:14 -0500 Subject: [PATCH 04/90] chore(deps): bump dawidd6/action-download-artifact from 15 to 16 (#2719) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b1915b71..bad038650 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -175,7 +175,7 @@ jobs: # actions/download-artifact won't look at previous workflow runs but we need to in order to get changelog - name: Download artifacts - uses: dawidd6/action-download-artifact@v15 + uses: dawidd6/action-download-artifact@v16 - name: Draft release env: From 8cfa2e28cd641c392cae272b20d718804a8a21c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:42:16 -0500 Subject: [PATCH 05/90] chore(deps): bump actions/upload-artifact from 6 to 7 (#2721) --- .github/workflows/benchmark.yml | 6 +++--- .github/workflows/commit.yml | 4 ++-- .github/workflows/examples.yml | 2 +- .github/workflows/optional.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- .github/workflows/rtd.yml | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 8f1af2110..000fa9885 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -52,14 +52,14 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload failed benchmark artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() with: name: failed-benchmark-${{ matrix.os }}-${{ matrix.python-version }}-${{ github.run_id }} path: autotest/.failed/** - name: Upload benchmark result artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: benchmarks-${{ matrix.os }}-${{ matrix.python-version }}-${{ github.run_id }} path: autotest/.benchmarks/*.json @@ -130,7 +130,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload benchmark results - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: benchmarks-${{ github.run_id }} path: | diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index ba0f049ac..594121959 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -95,7 +95,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload failed test outputs - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() with: name: failed-smoke-${{ runner.os }}-${{ env.PYTHON_VERSION }} @@ -186,7 +186,7 @@ jobs: run: pixi run --manifest-path=../../modflow6/pixi.toml coverage report - name: Upload failed test outputs - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() with: name: failed-${{ matrix.os }}-${{ matrix.python-version }} diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 36809ed6e..dfbb41713 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -80,7 +80,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload failed test outputs - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() with: name: failed-example-${{ matrix.os }}-${{ matrix.python-version }} diff --git a/.github/workflows/optional.yml b/.github/workflows/optional.yml index 0b781f293..e68746d3e 100644 --- a/.github/workflows/optional.yml +++ b/.github/workflows/optional.yml @@ -52,7 +52,7 @@ jobs: fi - name: Upload removed dependencies log - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: smoke-test-removed-dependencies path: ./removed_dependencies.txt @@ -67,7 +67,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload failed test outputs - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() with: name: failed-smoke-${{ runner.os }}-${{ env.PYTHON_VERSION }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bad038650..065b4447c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,7 +77,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload failed test outputs - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() with: name: failed-outputs-${{ github.run_id }} @@ -111,7 +111,7 @@ jobs: sed -i '1i # Changelog' $clog - name: Upload changelog - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: changelog path: CHANGELOG.md diff --git a/.github/workflows/rtd.yml b/.github/workflows/rtd.yml index b6dcbf443..0edd6b269 100644 --- a/.github/workflows/rtd.yml +++ b/.github/workflows/rtd.yml @@ -159,7 +159,7 @@ jobs: github.event_name == 'push' || github.event_name == 'workflow_dispatch' ) - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: notebooks-for-${{ needs.set_options.outputs.sha }} path: flopy/.docs/Notebooks/*.ipynb From ef597740dcad877d3a3feda8f4e95e4bc1575a15 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:42:32 -0500 Subject: [PATCH 06/90] chore(deps): bump actions/download-artifact from 7 to 8 (#2720) --- .github/workflows/benchmark.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 000fa9885..cb1b7152b 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -91,7 +91,7 @@ jobs: run: uv pip install seaborn - name: Download all artifacts - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: path: autotest/.benchmarks From 2ac89a243e6e56dc08b88e7567ae8cfd18c9b499 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Fri, 27 Feb 2026 16:10:16 -0500 Subject: [PATCH 07/90] chore(dependencies): support pandas 3 (#2723) Recently pandas 3 was released. Only minimal changes needed from us, namely * switching timedelta units from deprecated "d" to "D" * a fix in the model splitter where columns that were previously renamed automatically on dataframe construction must now be renamed manually due to stricter dataframe init method requirements There are two other potentially relevant changes * String dtype Pandas 3 will infer string columns as str dtype instead of object. Code checking dtype == 'object' for strings will break. I think we are safe as internal dtype checks are generally on numpy arrays, not pandas DataFrames. We kind of luck out for having not migrated everything over to pandas. When we do pd.DataFrame.from_records(recarray), numpy string fields will get converted to str dtype instead of object. But I don't think we need to care, indexing, selection, etc should all work as before. * Copy-on-Write (CoW) As far as I can tell we use .loc internally, no chained assignments, so we should be good. --- flopy/export/metadata.py | 2 +- flopy/mf6/data/mfdataplist.py | 24 ++++++++++++++---------- flopy/utils/mtlistfile.py | 2 +- pyproject.toml | 2 +- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/flopy/export/metadata.py b/flopy/export/metadata.py index 3677f0ae6..f94471cea 100644 --- a/flopy/export/metadata.py +++ b/flopy/export/metadata.py @@ -188,7 +188,7 @@ def time_coverage(self): mlen = self.model_time.perlen.sum() tunits = self.model_time.time_units tc["duration"] = f"{mlen} {tunits}" - end = strt + pd.Timedelta(mlen, unit="d") + end = strt + pd.Timedelta(mlen, unit="D") tc["end"] = str(end) return tc diff --git a/flopy/mf6/data/mfdataplist.py b/flopy/mf6/data/mfdataplist.py index b2d7ee17c..e2377d490 100644 --- a/flopy/mf6/data/mfdataplist.py +++ b/flopy/mf6/data/mfdataplist.py @@ -676,9 +676,11 @@ def set_data(self, data, autofill=False, check_data=True, append=False): if len(data[0]) == len(self._data_item_names): # data most likely being stored with cellids as tuples, # create a dataframe and untuple the cellids - data = pandas.DataFrame( - data, columns=self._data_item_names - ) + # In pandas 3+, DataFrame() with recarray requires columns to match + # field names, so create without columns param then rename if needed + data = pandas.DataFrame(data) + if list(data.columns) != self._data_item_names: + data.columns = self._data_item_names data = self._untuple_cellids(data)[0] # make sure columns are still in correct order data = pandas.DataFrame(data, columns=self._header_names) @@ -691,19 +693,21 @@ def set_data(self, data, autofill=False, check_data=True, append=False): else: # data size matches the expected header names, create a pandas # dataframe from the data - data_new = pandas.DataFrame(data, columns=self._header_names) + # In pandas 3+, DataFrame() with recarray requires columns to match + # field names, so create without columns param then rename if needed + data_new = pandas.DataFrame(data) + if list(data_new.columns) != self._header_names: + data_new.columns = self._header_names if not self._dataframe_check(data_new): data_list = self._untuple_recarray(data) - data = pandas.DataFrame( - data_list, columns=self._header_names - ) + data = pandas.DataFrame(data_list) + if list(data.columns) != self._header_names: + data.columns = self._header_names else: data, count = self._untuple_cellids(data_new) if count > 0: # make sure columns are still in correct order - data = pandas.DataFrame( - data, columns=self._header_names - ) + data = pandas.DataFrame(data, columns=self._header_names) elif isinstance(data, list) or isinstance(data, tuple): if not (isinstance(data[0], list) or isinstance(data[0], tuple)): # get data in the format of a tuple of lists (or tuples) diff --git a/flopy/utils/mtlistfile.py b/flopy/utils/mtlistfile.py index e3d40c6d2..42cfcf916 100644 --- a/flopy/utils/mtlistfile.py +++ b/flopy/utils/mtlistfile.py @@ -51,7 +51,7 @@ def __init__(self, file_name): return - def parse(self, forgive=True, diff=True, start_datetime=None, time_unit="d"): + def parse(self, forgive=True, diff=True, start_datetime=None, time_unit="D"): """ Main entry point for parsing the list file. diff --git a/pyproject.toml b/pyproject.toml index 0324b2d05..1ad5ce709 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ requires-python = ">=3.10" dependencies = [ "numpy>=1.20.3,<3.0", "matplotlib >=1.4.0", - "pandas >=2.0.0,<3.0", + "pandas >=2.0.0", ] dynamic = ["version", "readme"] From 26855e74e94ccccf83043dd1da0fc2cac04b4644 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 4 Mar 2026 07:56:55 -0500 Subject: [PATCH 08/90] chore(dependencies): exclude modflow-devtools 1.9.0 (#2725) I borked devtools 1.9.0 MODFLOW-ORG/modflow-devtools#299, make sure we don't use it. Also add an upper bound. --- etc/environment.yml | 2 +- pyproject.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/etc/environment.yml b/etc/environment.yml index 620214b14..a3228f805 100644 --- a/etc/environment.yml +++ b/etc/environment.yml @@ -14,7 +14,7 @@ dependencies: # codegen - boltons>=1.0 - Jinja2>=3.0 - - modflow-devtools>=1.7.0 + - modflow-devtools>=1.7.0,!=1.9.0,<2 - tomli - tomli-w diff --git a/pyproject.toml b/pyproject.toml index 1ad5ce709..a98ca7407 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dev = ["flopy[codegen,lint,test,optional,doc]", "tach"] codegen = [ "Jinja2>=3.0", "boltons", - "modflow-devtools>=1.7.0", + "modflow-devtools>=1.7.0,!=1.9.0,<2", "tomli", "tomli-w" ] @@ -51,7 +51,7 @@ test = [ "jupyter", "jupyter_client >=8.4.0", # avoid datetime.utcnow() deprecation warning "jupytext", - "modflow-devtools>=1.7.0", + "modflow-devtools>=1.7.0,!=1.9.0,<2", "pytest !=8.1.0", "pytest-benchmark", "pytest-cov", From 224432b81ce862632463ed652e48d97c3952ff43 Mon Sep 17 00:00:00 2001 From: Joshua Larsen Date: Thu, 5 Mar 2026 10:36:53 -0800 Subject: [PATCH 09/90] fix(UcnFile): Add check for modflow-6 GWT concentration file header type (#2718) --- autotest/test_binaryfile.py | 32 +++++++++++++ flopy/utils/binaryfile/__init__.py | 75 +++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/autotest/test_binaryfile.py b/autotest/test_binaryfile.py index 59558d98a..1de8a77f9 100644 --- a/autotest/test_binaryfile.py +++ b/autotest/test_binaryfile.py @@ -198,6 +198,38 @@ def test_concentration_build_index(example_data_path): ) +def test_mf6_concentration_build_index(example_data_path): + # test low-level BinaryLayerFile._build_index() method with UCN file + pth = ( + example_data_path + / "mf6/create_tests/test_transport/expected_output/gwt_mst03.ucn" + ) + with UcnFile(pth) as ucn: + pass + assert ucn.nrow == 1 + assert ucn.ncol == 1 + assert ucn.nlay == 1 + assert not hasattr(ucn, "ntrans") + assert ucn.totalbytes == 1680 + assert len(ucn.recordarray) == 28 + assert type(ucn.recordarray) == np.ndarray + assert ucn.recordarray.dtype == np.dtype( + [ + ("kstp", "i4"), + ("kper", "i4"), + ("pertim", "f8"), + ("totim", "f8"), + ("text", "S16"), + ("ncol", "i4"), + ("nrow", "i4"), + ("ilay", "i4"), + ] + ) + + assert np.max(ucn.times) == 4.0 + assert ucn.kstpkper[-1] == (14, 2) + + def test_binaryfile_writeread(function_tmpdir, nwt_model_path): model = "Pr3_MFNWT_lower.nam" ml = flopy.modflow.Modflow.load(model, version="mfnwt", model_ws=nwt_model_path) diff --git a/flopy/utils/binaryfile/__init__.py b/flopy/utils/binaryfile/__init__.py index 07027f852..393f1aae9 100644 --- a/flopy/utils/binaryfile/__init__.py +++ b/flopy/utils/binaryfile/__init__.py @@ -296,6 +296,78 @@ def get_headfile_precision(filename: Union[str, PathLike]): return result +def get_concentration_file_type(filename: Union[str, PathLike], precision): + """ + Method to check header and determine if the concentration file is a MT3D like + file or a MF6 GWT like concentration file + + Parameters + ---------- + filename : str or PathLike + Path of binary MODFLOW file to determine precision. + precision : str + double or single + + Returns + ------- + str + Result will be ucn or head + + """ + f = open(filename, "rb") + f.seek(0, 2) + totalbytes = f.tell() + f.seek(0, 0) # reset to beginning + assert f.tell() == 0 + if totalbytes == 0: + raise ValueError(f"datafile error: file is empty: {filename}") + + floattype = "f4" + if precision == "double": + floattype = "f8" + + # first try mt3d ucn + vartype = [ + ("ntrans", "i4"), + ("kstp", "i4"), + ("kper", "i4"), + ("totim", floattype), + ("text", "S16"), + ] + hdr = binaryread(f, vartype) + + try: + s = hdr[0][4].decode() + if not s.strip().lower().startswith("c"): + success = False + else: + success = True + result = "ucn" + except ValueError: + success = False + + if not success: + f.seek(0) + vartype = [ + ("kstp", " Date: Fri, 13 Mar 2026 21:14:42 -0400 Subject: [PATCH 10/90] chore(deps): bump dawidd6/action-download-artifact from 16 to 17 (#2729) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 065b4447c..eb7b55227 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -175,7 +175,7 @@ jobs: # actions/download-artifact won't look at previous workflow runs but we need to in order to get changelog - name: Download artifacts - uses: dawidd6/action-download-artifact@v16 + uses: dawidd6/action-download-artifact@v17 - name: Draft release env: From 066270c1e1bcfb5df80d5e3ff9479f0d06a5d340 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Sat, 14 Mar 2026 09:47:33 -0400 Subject: [PATCH 11/90] fix: wipe toml dfn dir before regenerating mf6 module (#2730) Delete all the TOMLs before recreating them from DFNs otherwise unwanted TOML components can remain --- flopy/mf6/utils/generate_classes.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flopy/mf6/utils/generate_classes.py b/flopy/mf6/utils/generate_classes.py index 6bab04505..7015ea971 100644 --- a/flopy/mf6/utils/generate_classes.py +++ b/flopy/mf6/utils/generate_classes.py @@ -89,7 +89,8 @@ def generate_classes( print() tomlpath = dfnpath / "toml" - tomlpath.mkdir(exist_ok=True) + shutil.rmtree(tomlpath, ignore_errors=True) + tomlpath.mkdir() dfn2toml(dfnpath, tomlpath) shutil.rmtree(_MF6_AUTOGEN_PATH) From 10031ade2381f44a5edeea8aa90b64dc5147d786 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:24:58 -0400 Subject: [PATCH 12/90] chore(deps): bump dawidd6/action-download-artifact from 17 to 18 (#2731) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb7b55227..d6d2b1e22 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -175,7 +175,7 @@ jobs: # actions/download-artifact won't look at previous workflow runs but we need to in order to get changelog - name: Download artifacts - uses: dawidd6/action-download-artifact@v17 + uses: dawidd6/action-download-artifact@v18 - name: Draft release env: From 20ce356115ec8012b435e8c074cdc46a06e6d108 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 18 Mar 2026 10:37:21 -0400 Subject: [PATCH 13/90] fix(vtk): fix add_pathline_points prt data handling (#2732) The logic to separate PRT pathlines by composite key for VTK export was - slow: O(n^4 * m) where n is unique values per column and m is the total number of rows - wrong: exported empty pathlines in most cases due to some incorrect filtering Replace the nested loops and repeated np.unique() calls with a single unique(return_inverse=True) taking a composite key dtype. Should be O(n log n) now. --- autotest/test_export.py | 32 ++++++++++++++++++++++++-------- flopy/export/vtk.py | 22 +++++++++++----------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/autotest/test_export.py b/autotest/test_export.py index e04f73fcc..0c107cb2f 100644 --- a/autotest/test_export.py +++ b/autotest/test_export.py @@ -2,6 +2,7 @@ import os import shutil from pathlib import Path +from pprint import pformat import matplotlib.pyplot as plt import numpy as np @@ -38,6 +39,7 @@ ) from flopy.modflow import Modflow, ModflowDis from flopy.modpath import Modpath6, Modpath6Bas +from flopy.plot.plotutil import to_prt_pathlines from flopy.utils import ( CellBudgetFile, HeadFile, @@ -1508,8 +1510,6 @@ def test_vtk_unstructured(function_tmpdir, unstructured_grid): @requires_pkg("vtk", "pyvista") def test_vtk_to_pyvista(function_tmpdir): - from pprint import pformat - from autotest.test_mp7_cases import Mp7Cases case_mf6 = Mp7Cases.mp7_mf6(function_tmpdir) @@ -1529,13 +1529,29 @@ def test_vtk_to_pyvista(function_tmpdir): assert grid.n_cells == gwf.modelgrid.nnodes vtk.add_pathline_points(pls) - grid, pathlines = vtk.to_pyvista() + grid, mp7_pls = vtk.to_pyvista() + n_pts = sum(pl.shape[0] for pl in pls) + assert mp7_pls.n_points == n_pts + assert mp7_pls.n_cells == n_pts + len(pls) + assert "particleid" in mp7_pls.point_data + assert "time" in mp7_pls.point_data + assert "k" in mp7_pls.point_data + + vtk = Vtk(model=gwf, binary=True, smooth=False) + assert not any(vtk.to_pyvista()) + + prt_pathlines = to_prt_pathlines(np.hstack(pls).view(np.recarray)) + + vtk.add_model(gwf) + vtk.add_pathline_points(prt_pathlines) + grid, prt_pls = vtk.to_pyvista() n_pts = sum(pl.shape[0] for pl in pls) - assert pathlines.n_points == n_pts - assert pathlines.n_cells == n_pts + len(pls) - assert "particleid" in pathlines.point_data - assert "time" in pathlines.point_data - assert "k" in pathlines.point_data + assert prt_pls.n_points == n_pts + assert prt_pls.n_cells == n_pts + len(pls) + assert "imdl" in prt_pls.point_data + assert "iprp" in prt_pls.point_data + assert "irpt" in prt_pls.point_data + assert "trelease" in prt_pls.point_data # uncomment to debug # grid.plot() diff --git a/flopy/export/vtk.py b/flopy/export/vtk.py index 1155f49c2..4e3de88aa 100644 --- a/flopy/export/vtk.py +++ b/flopy/export/vtk.py @@ -1109,17 +1109,17 @@ def add_pathline_points(self, pathlines, timeseries=False): pids = np.unique(pathlines.particleid) pathlines = [pathlines[pathlines.particleid == pid] for pid in pids] elif all(k in pathlines.dtype.names for k in prt_fields): - pls = [] - for imdl in np.unique(pathlines.imdl): - for iprp in np.unique(pathlines.iprp): - for irpt in np.unique(pathlines.irpt): - pl = pathlines[ - (pathlines.imdl == imdl) - & (pathlines.iprp == iprp) - & (pathlines.irpt == irpt) - ] - pls.extend([pl[pl.trelease == t] for t in np.unique(pl.t)]) - pathlines = pls + # particle composite key + keys = np.column_stack( + [ + pathlines["imdl"], + pathlines["iprp"], + pathlines["irpt"], + pathlines["trelease"], + ] + ) + _, inv = np.unique(keys, axis=0, return_inverse=True) + pathlines = [pathlines[inv == i] for i in range(inv.max() + 1)] else: raise ValueError("Unrecognized pathline dtype") else: From 705b271ca696dce9f1a6eb07b23176c5993b01d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:03:15 -0400 Subject: [PATCH 14/90] chore(deps): bump dawidd6/action-download-artifact from 18 to 19 (#2734) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d6d2b1e22..09e16fe10 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -175,7 +175,7 @@ jobs: # actions/download-artifact won't look at previous workflow runs but we need to in order to get changelog - name: Download artifacts - uses: dawidd6/action-download-artifact@v18 + uses: dawidd6/action-download-artifact@v19 - name: Draft release env: From 11432e43a81f62ada5d62425ab13bf030294add2 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 19 Mar 2026 17:27:06 -0400 Subject: [PATCH 15/90] fix(vtk): transpose vector before numpy_to_vtk (#2733) add_vector() assigns a vector to each grid cell. The method takes an array of shape (3, nnodes) and passes it to numpy_support.numpy_to_vtk(), which due to the order of indices flattens it in column-major order, but VTK expects row-major order. So where cell i should get (x[i], y[i], z[i]) it instead gets (x[3i], x[3i+1], x[3i+2]) Fixed by transposing the array passed to numpy_to_vtk() so it is (nnodes, 3) and is flattened/interpreted correctly --- flopy/export/vtk.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flopy/export/vtk.py b/flopy/export/vtk.py index 4e3de88aa..9fdb4741f 100644 --- a/flopy/export/vtk.py +++ b/flopy/export/vtk.py @@ -897,7 +897,7 @@ def add_vector(self, vector, name, masked_values=None): else: raise AssertionError("Size of vector must be 3 * nnodes or 3 * ncpl") else: - vector = np.reshape(vector, (3, self.nnodes)) + vector = np.reshape(vector, (3, self.nnodes)).T if self.point_scalars: tmp = [] From c80fc6c6e4b3a15ba1b8e9e90e7b6ccd2a052ec0 Mon Sep 17 00:00:00 2001 From: Joshua Larsen Date: Mon, 23 Mar 2026 14:35:13 -0700 Subject: [PATCH 16/90] fix(model_splitter): multiple bug fixes (#2728) * set data internal for model splitting * fix(split_model): update external file handling for model splitter Maintain external file paths when user passes optional sim_ws parameter * updates(model_splitter.py): multiple bug fixes * filter (-1,) SFR cellids from splitting mask creation in optimize_splitting_mask * adjust (-1,) SFR cellid layer number to 0 in _remap_sfr to avoid index errors * handle external model files by allowing user to specify new simulation workspace `sim_ws` to `split_model` and `split_multi_model` * dynamically adjust `max_columns_of_data` when external model files are maintained in the split models * update external ascii test * Add "angrot" to the offsets dictionary --- autotest/test_model_splitter.py | 2 +- flopy/mf6/utils/model_splitter.py | 79 ++++++++++++++++++++++++------- 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/autotest/test_model_splitter.py b/autotest/test_model_splitter.py index 6e1b93d84..c08995c2c 100644 --- a/autotest/test_model_splitter.py +++ b/autotest/test_model_splitter.py @@ -437,7 +437,7 @@ def test_control_records(function_tmpdir): split_ws.mkdir() with set_dir(split_ws): mfsplit = flopy.mf6.utils.Mf6Splitter(sim) - new_sim = mfsplit.split_model(arr) + new_sim = mfsplit.split_model(arr, split_ws) ml1 = new_sim.get_model("model_1") diff --git a/flopy/mf6/utils/model_splitter.py b/flopy/mf6/utils/model_splitter.py index f55a6a738..8c264cad1 100644 --- a/flopy/mf6/utils/model_splitter.py +++ b/flopy/mf6/utils/model_splitter.py @@ -181,6 +181,7 @@ def __init__(self, sim, modelname=None): self._allow_splitting = True self._fdigits = 1 + self._keep_external = True # multi-model splitting attr self._multimodel_exchange_gwf_names = {} @@ -678,12 +679,13 @@ def optimize_splitting_mask(self, nparts, active_only=False, options=None, verbo else: cellids = package.packagedata.array.cellid if self._modelgrid.grid_type == "structured": - cellids = [(0, i[1], i[2]) for i in cellids] + # skip disconnected cells in SFR package + cellids = [(0, i[1], i[2]) for i in cellids if i != (-1, -1, -1)] nodes = self._modelgrid.get_node(cellids) elif self._modelgrid.grid_type == "vertex": - nodes = [i[1] for i in cellids] + nodes = [i[1] for i in cellids if i != (-1, -1)] else: - nodes = [i[0] for i in cellids] + nodes = [i[0] for i in cellids if i != (-1,)] if isinstance(package, (modflow.ModflowGwflak, modflow.ModflowGwtlkt, modflow.ModflowGwelke)): lakenos = package.connectiondata.array.ifno + 1 @@ -758,15 +760,24 @@ def optimize_splitting_mask(self, nparts, active_only=False, options=None, verbo cellids2 = recarray.cellid2 _, nodes1 = self._cellid_to_layer_node(cellids1) _, nodes2 = self._cellid_to_layer_node(cellids2) - mnums1 = membership[nodes1] - mnums2 = membership[nodes2] - ev = np.equal(mnums1, mnums2) - if np.all(ev): - continue - idx = np.asarray(~ev).nonzero()[0] - mnum_to = mnums1[idx] - adj_nodes = nodes2[idx] - membership[adj_nodes] = mnum_to + cnt = 0 + while cnt < len(nodes1): + mnums1 = membership[nodes1] + mnums2 = membership[nodes2] + ev = np.equal(mnums1, mnums2) + if np.all(ev): + break + idx = np.asarray(~ev).nonzero()[0] + mnum_to = mnums1[idx] + adj_nodes = np.array(nodes2)[idx] + membership[adj_nodes] = mnum_to + cnt += 1 + + if cnt == len(nodes1): + raise AssertionError( + "Cannot uniquely spilt around HFB boundaries, try another " + "value for nparts" + ) return membership.reshape(shape) @@ -1012,6 +1023,7 @@ def _remap_nodes(self, array): self._offsets[m] = { "xorigin": self._modelgrid.xvertices[rmax + 1, cmin], "yorigin": self._modelgrid.yvertices[rmax + 1, cmin], + "angrot": self._modelgrid.angrot } # get new nrow and ncol information nrow = (rmax - rmin) + 1 @@ -1035,6 +1047,7 @@ def _remap_nodes(self, array): self._offsets[m] = { "xorigin": self._modelgrid.xoffset, "yorigin": self._modelgrid.yoffset, + "angrot": self._modelgrid.angrot } new_ncpl = {} @@ -1536,6 +1549,9 @@ def _remap_array(self, item, mfarray, mapped_data, **kwargs): # external array tmp = fnames[lay].split(".") filename = f"{'.'.join(tmp[:-1])}.{mkey :0{self._fdigits}d}.{tmp[-1]}" + folder_path = (self._new_sim.sim_path / filename).parent + if not folder_path.exists(): + folder_path.mkdir(parents=True) cr = { "filename": filename, @@ -1621,6 +1637,9 @@ def _remap_mflist( if how == 3 and new_recarray is not None: tmp = fname.split(".") filename = f"{'.'.join(tmp[:-1])}.{mkey :0{self._fdigits}d}.{tmp[-1]}" + folder_path = (self._new_sim.sim_path / filename).parent + if not folder_path.exists(): + folder_path.mkdir(parents=True) new_recarray = { "data": new_recarray, @@ -2040,6 +2059,10 @@ def _remap_sfr(self, package, mapped_data): cellids[messy_idx] = rcids layers, nodes = self._cellid_to_layer_node(cellids) + # adjust the messy_idx layer number + if layers is not None and messy_idx: + layers[messy_idx] = 0 + new_model, new_node = self._get_new_model_new_node(nodes) for mkey, model in self._model_dict.items(): @@ -3519,6 +3542,15 @@ def _remap_package(self, package, ismvr=False): if "stress_period_data" in data: if not data["stress_period_data"]: continue + + if self._keep_external: + shape = self._grid_info[mdl][0] + if len(shape) == 2: + max_cols = shape[1] + else: + max_cols = shape[0] + self._new_sim.simulation_data.max_columns_of_data = max_cols + paks[mdl] = pak_cls( self._model_dict[mdl], pname=package.name[0], **data ) @@ -3842,7 +3874,7 @@ def create_multi_model_exchanges(self, mname0, mname1): filename=filename, ) - def split_model(self, array): + def split_model(self, array, sim_ws=None): """ User method to split a model based on an array @@ -3852,6 +3884,10 @@ def split_model(self, array): integer array of new model numbers. Array must either be of dimension (NROW, NCOL), (NCPL), or (NNODES for unstructured grid models). + sim_ws : PathLike or str + optional directory path for writing the new simulation to. This parameter + is recommended when the model contains external files and the user would + like to preserve external linkages while splitting. Returns ------- @@ -3863,6 +3899,10 @@ def split_model(self, array): "is part of a split simulation" ) + if sim_ws is None: + self._keep_external = False + sim_ws = self._sim.sim_path + # set number formatting string for file paths array = np.array(array).astype(int) s = str(np.max(array)) @@ -3872,7 +3912,7 @@ def split_model(self, array): if self._new_sim is None: self._new_sim = modflow.MFSimulation( - version=self._sim.version, exe_name=self._sim.exe_name, sim_ws=self._sim.sim_path + version=self._sim.version, exe_name=self._sim.exe_name, sim_ws=sim_ws ) self._create_sln_tdis() @@ -3898,6 +3938,9 @@ def split_model(self, array): **nam_options[mkey], ) + if not self._keep_external: + self._model.set_all_data_internal(check_data=True) + for package in self._model.packagelist: paks = self._remap_package(package) @@ -3909,7 +3952,7 @@ def split_model(self, array): return self._new_sim - def split_multi_model(self, array): + def split_multi_model(self, array, sim_ws=None): """ Method to split integrated models such as GWF-GWT or GWF-GWE models. Note: this method will not work to split multiple connected GWF models @@ -3920,6 +3963,10 @@ def split_multi_model(self, array): integer array of new model numbers. Array must either be of dimension (NROW, NCOL), (NCPL), or (NNODES for unstructured grid models). + sim_ws : PathLike or str + optional directory path for writing the new simulation to. This parameter + is recommended when the model contains external files and the user would + like to preserve external linkages while splitting. Returns ------- @@ -3986,7 +4033,7 @@ def split_multi_model(self, array): new_sim = self.split_model(array) for mname in model_names[1:]: self.switch_models(modelname=mname, remap_nodes=False) - new_sim = self.split_model(array) + new_sim = self.split_model(array, sim_ws=sim_ws) for mbase in model_names[1:]: for label in model_labels: From f0c043255a7134e121b42a4dbdeca988b26394cd Mon Sep 17 00:00:00 2001 From: Kalle Jahn <27511789+kallejahn@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:55:52 -0400 Subject: [PATCH 17/90] fix(mp7particledata): to_prp bug fix (#2737) Small fix to iteration in NodeParticleData.to_prp(). Added another iteration through nodedata, as previously iteration only passed through the first node, so total particles could only equal the number of subdivisions. With fix total particles now equals the number of nodes times the number of subdivisions. --- flopy/modpath/mp7particledata.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/flopy/modpath/mp7particledata.py b/flopy/modpath/mp7particledata.py index 7ed5b404c..4eeb095f2 100644 --- a/flopy/modpath/mp7particledata.py +++ b/flopy/modpath/mp7particledata.py @@ -1421,18 +1421,19 @@ def to_prp(self, grid, localz=False, global_xy=False) -> Iterator[tuple]: """ for sd in self.subdivisiondata: - for nd in self.nodedata: - for irpt, rpt in enumerate( - get_release_points( - sd, grid, nn=int(nd[0]), localz=localz, global_xy=global_xy - ) - ): - row = [irpt] - if grid.grid_type == "structured": - k, i, j = grid.get_lrc([rpt[0]])[0] - row.extend([k, i, j]) - else: - k, j = grid.get_lni([rpt[0]])[0] - row.extend([(k, j)]) - row.extend([rpt[1], rpt[2], rpt[3]]) - yield tuple(row) + for nds in self.nodedata: + for nd in nds: + for irpt, rpt in enumerate( + get_release_points( + sd, grid, nn=int(nd), localz=localz, global_xy=global_xy + ) + ): + row = [irpt] + if grid.grid_type == "structured": + k, i, j = grid.get_lrc([rpt[0]])[0] + row.extend([k, i, j]) + else: + k, j = grid.get_lni([rpt[0]])[0] + row.extend([(k, j)]) + row.extend([rpt[1], rpt[2], rpt[3]]) + yield tuple(row) From 741ee84acb8c6a1c0da36f306e23b90c84d8f7ff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:16:46 -0400 Subject: [PATCH 18/90] chore(deps): bump codecov/codecov-action from 5 to 6 (#2739) --- .github/workflows/commit.yml | 4 ++-- .github/workflows/mf6.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index 594121959..c92d51117 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -103,7 +103,7 @@ jobs: - name: Upload coverage if: github.repository_owner == 'modflowpy' && (github.event_name == 'push' || github.event_name == 'pull_request') - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: files: autotest/coverage.xml @@ -194,6 +194,6 @@ jobs: - name: Upload coverage if: github.repository_owner == 'modflowpy' && (github.event_name == 'push' || github.event_name == 'pull_request') - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: files: flopy/autotest/coverage.xml diff --git a/.github/workflows/mf6.yml b/.github/workflows/mf6.yml index 15ebcdd07..dfe093ca1 100644 --- a/.github/workflows/mf6.yml +++ b/.github/workflows/mf6.yml @@ -86,7 +86,7 @@ jobs: - name: Upload coverage to Codecov if: github.repository_owner == 'modflowpy' && (github.event_name == 'push' || github.event_name == 'pull_request') - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: files: ./modflow6/autotest/coverage.xml From 269abdcdc8d2c84ff0b10385246a007c3d014acb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:17:08 -0400 Subject: [PATCH 19/90] chore(deps): bump mamba-org/setup-micromamba from 1 to 3 (#2740) --- .github/workflows/examples.yml | 2 +- .github/workflows/rtd.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index dfbb41713..13952f09f 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v6 - name: Setup Micromamba - uses: mamba-org/setup-micromamba@v2 + uses: mamba-org/setup-micromamba@v3 with: environment-file: etc/environment.yml cache-environment: true diff --git a/.github/workflows/rtd.yml b/.github/workflows/rtd.yml index 0edd6b269..dc5205dac 100644 --- a/.github/workflows/rtd.yml +++ b/.github/workflows/rtd.yml @@ -87,7 +87,7 @@ jobs: echo $GITHUB_EVENT_NAME - name: Setup Micromamba - uses: mamba-org/setup-micromamba@v1 + uses: mamba-org/setup-micromamba@v3 with: environment-file: flopy/etc/environment.yml cache-environment: true From 671bb0b05613281156fc7f451866524746a4087b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:47:22 -0400 Subject: [PATCH 20/90] chore(deps): bump prefix-dev/setup-pixi from 0.9.4 to 0.9.5 (#2741) --- .github/workflows/commit.yml | 2 +- .github/workflows/mf6.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index c92d51117..9dcda5b5a 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -150,7 +150,7 @@ jobs: version: 13 - name: Setup pixi - uses: prefix-dev/setup-pixi@v0.9.4 + uses: prefix-dev/setup-pixi@v0.9.5 with: pixi-version: v0.41.4 manifest-path: modflow6/pixi.toml diff --git a/.github/workflows/mf6.yml b/.github/workflows/mf6.yml index dfe093ca1..49e9806de 100644 --- a/.github/workflows/mf6.yml +++ b/.github/workflows/mf6.yml @@ -39,7 +39,7 @@ jobs: version: 13 - name: Setup pixi - uses: prefix-dev/setup-pixi@v0.9.4 + uses: prefix-dev/setup-pixi@v0.9.5 with: pixi-version: v0.41.4 manifest-path: modflow6/pixi.toml @@ -122,7 +122,7 @@ jobs: version: 13 - name: Setup pixi - uses: prefix-dev/setup-pixi@v0.9.4 + uses: prefix-dev/setup-pixi@v0.9.5 with: pixi-version: v0.41.4 manifest-path: modflow6/pixi.toml From cbc6e863044bdc098eac5a5d0086ca4bcc09aa7a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 06:00:08 -0700 Subject: [PATCH 21/90] chore(deps): bump dawidd6/action-download-artifact from 19 to 20 (#2743) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09e16fe10..e54ac5a94 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -175,7 +175,7 @@ jobs: # actions/download-artifact won't look at previous workflow runs but we need to in order to get changelog - name: Download artifacts - uses: dawidd6/action-download-artifact@v19 + uses: dawidd6/action-download-artifact@v20 - name: Draft release env: From e6951c4639387ae0935a3d3a3a9291aee186d02e Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Fri, 10 Apr 2026 07:52:41 -0700 Subject: [PATCH 22/90] feat(binaryfile): add write methods (#2722) Support writing binary head, budget and grid files, both brand-new via classmethods, and copying existing files to new paths via instance methods. Add write() classmethods to HeadFile and CellBudgetFile. These write a new file with the given data and return an instance with it open. There are a few syntax variants for head files. # dict keyed by (kstp, kper) hds = HeadFile.write( 'output.hds', # totim/pertim inferred (dt = 1.0 / time step) data={ (1, 1): h_t1, (1, 2): h_t2, } ) # list hds = HeadFile.write('output.hds', data=[ {'data': h_t2, 'kstp': 1, 'kper': 1, 'totim': 10.0, 'pertim': 10.0}, {'data': h_t2, 'kstp': 1, 'kper': 2, 'totim': 20.0, 'pertim': 10.0}, ]) # array/list with time as first dimension # defaults to sequential stress periods: (1,1), (1,2), (1,3), ... heads = [h_t1, h_t2, h_t3] # or np.array([h_t1, h_t2, h_t3]) hds = HeadFile.write('output.hds', heads) # array/list with custom tdis hds = HeadFile.write('output.hds', heads, kstpkper=[(1, 1), (2, 1), (3, 1)]) Again there are variants for budget files. The typical case is to use data with a list, where each entry has "text", "kper", "kstp", and "data" entries, and "data" is an array, typically grid-shaped. But it can be convenient to use text and pass a time-indexed dictionary to data to create a file with a single variable. # just face flows, dict keyed by (kstp, kper) cbc = CellBudgetFile.write( 'output.cbc', text='FLOW-JA-FACE', nlay=3, nrow=10, ncol=20, data={ (1, 1): q_t1, (1, 2): q_t2, } ) # multiple variables, list cbc = CellBudgetFile.write( 'output.cbc', data=[ {'data': q_t1, 'kstp': 1, 'kper': 1, 'totim': 10.0, 'text': 'FLOW-JA-FACE'}, {'data': ...}, ... ] ) # array/list with time dimension (grid-shaped data like storage) # defaults to sequential stress periods: (1,1), (1,2), (1,3), ... # grid dimensions inferred from array shape storage = [s_t1, s_t2, s_t3] # nlay x nrow x ncol arrays cbc = CellBudgetFile.write('output.cbc', storage, text='STORAGE') # for face flows, grid dimensions are required since data is 1D flows = [q_t1, q_t2] # 1D arrays cbc = CellBudgetFile.write( 'output.cbc', flows, text='FLOW-JA-FACE', nlay=3, nrow=10, ncol=20 ) If only face flows are provided, the grid shape must be specified with nlay/nrow/ncol, nlay/ncpl, or nnodes. If grid-shaped variables are provided, the grid's shape will be inferred. Instance methods Add instance export() methods to MfGrdFile, HeadFile, and CellBudgetFile. These copy the contents of an open file to another, optionally filtering by variable and/or time step, or changing the precision. There is no write() method for MfGrdFile, as its signature would have been long and complicated to accommodate all grid types. from flopy.mf6.utils.binarygrid_util import MfGrdFile grb = MfGrdFile("model.grb") grb.export("copy.grb") # copy to another file grb.export("diff_prec.grb", precision="single") # different precision from flopy.utils.binaryfile import HeadFile, CellBudgetFile hds = HeadFile("model.hds") hds.export("copy.hds") # copy to another file hds.export("filtered.hds", kstpkper=[(1, 0), (1, 1)]) # filter time steps hds.export("diff_prec.hds", precision="single") # different precision cbc = CellBudgetFile("model.cbc") cbc.export("copy.cbc") cbc.export("flowja.cbc", text="FLOW-JA-FACE") cbc.export("bndpkgs.cbc", text=["STORAGE", "CONSTANT HEAD"]) cbc.export("filtered.cbc", kstpkper=[(1, 0)], text="FLOW-JA-FACE") --- autotest/benchmark_binaryfile_write.py | 151 +++ autotest/test_binaryfile.py | 502 ++++++++ autotest/test_binarygrid_util.py | 237 ++++ autotest/test_cellbudgetfile.py | 50 + flopy/mf6/utils/binarygrid_util.py | 192 ++- flopy/utils/binaryfile/__init__.py | 1485 ++++++++++++++++++++++++ flopy/utils/utils_def.py | 30 + 7 files changed, 2643 insertions(+), 4 deletions(-) create mode 100644 autotest/benchmark_binaryfile_write.py diff --git a/autotest/benchmark_binaryfile_write.py b/autotest/benchmark_binaryfile_write.py new file mode 100644 index 000000000..329572a12 --- /dev/null +++ b/autotest/benchmark_binaryfile_write.py @@ -0,0 +1,151 @@ +"""Benchmark tests for binaryfile write methods.""" + +from pathlib import Path + +import numpy as np +import pytest + +from flopy.mf6.utils import MfGrdFile +from flopy.utils import CellBudgetFile, HeadFile + + +@pytest.fixture +def freyberg_hds_path(example_data_path): + return example_data_path / "freyberg_multilayer_transient" / "freyberg.hds" + + +@pytest.fixture +def freyberg_cbc_path(example_data_path): + return example_data_path / "freyberg_multilayer_transient" / "freyberg.cbc" + + +@pytest.fixture +def mfgrd_dis_path(example_data_path): + return example_data_path / "mf6-freyberg" / "freyberg.dis.grb" + + +@pytest.fixture +def mfgrd_disv_path(example_data_path): + return ( + example_data_path + / "mf6" + / "test006_gwf3_disv" + / "expected_output" + / "flow.disv.grb" + ) + + +@pytest.mark.slow +def test_headfile_write_benchmark(benchmark, freyberg_hds_path, tmp_path): + hds = HeadFile(freyberg_hds_path) + nsteps = min(100, len(hds.kstpkper)) + kstpkper = hds.kstpkper[:nsteps] + output_file = tmp_path / "benchmark_output.hds" + + def write_head(): + hds.export(output_file, kstpkper=kstpkper) + + benchmark(write_head) + assert output_file.exists() + + +@pytest.mark.slow +def test_cellbudgetfile_write_benchmark(benchmark, freyberg_cbc_path, tmp_path): + cbc = CellBudgetFile(freyberg_cbc_path) + nsteps = min(50, len(cbc.kstpkper)) + kstpkper = cbc.kstpkper[:nsteps] + output_file = tmp_path / "benchmark_output.cbc" + + def write_budget(): + cbc.export(output_file, kstpkper=kstpkper) + + benchmark(write_budget) + assert output_file.exists() + + +@pytest.mark.slow +def test_mfgrdfile_write_benchmark_dis(benchmark, tmp_path): + nlay, nrow, ncol = 10, 100, 100 + nodes = nlay * nrow * ncol + nja = 7 * nodes - 2 * (nlay * nrow + nlay * ncol + nrow * ncol) + grb_data = { + "NCELLS": nodes, + "NLAY": nlay, + "NROW": nrow, + "NCOL": ncol, + "NJA": nja, + "XORIGIN": 0.0, + "YORIGIN": 0.0, + "ANGROT": 0.0, + "DELR": np.ones(ncol, dtype=np.float64) * 100.0, + "DELC": np.ones(nrow, dtype=np.float64) * 100.0, + "TOP": np.ones(nodes, dtype=np.float64) * 100.0, + "BOTM": np.arange(nodes, dtype=np.float64), + "IA": np.arange(nodes + 1, dtype=np.int32), + "JA": np.arange(nja, dtype=np.int32) % nodes, + "IDOMAIN": np.ones(nodes, dtype=np.int32), + "ICELLTYPE": np.ones(nodes, dtype=np.int32), + } + + from flopy.utils.utils_def import FlopyBinaryData + + temp_grb = tmp_path / "temp_input.grb" + writer = FlopyBinaryData() + writer.precision = "double" + + with open(temp_grb, "wb") as f: + writer.file = f + writer.write_text("GRID DIS\n", 50) + writer.write_text("VERSION 1\n", 50) + writer.write_text("NTXT 16\n", 50) + writer.write_text("LENTXT 100\n", 50) + var_list = [ + ("NCELLS", "INTEGER", 0, []), + ("NLAY", "INTEGER", 0, []), + ("NROW", "INTEGER", 0, []), + ("NCOL", "INTEGER", 0, []), + ("NJA", "INTEGER", 0, []), + ("XORIGIN", "DOUBLE", 0, []), + ("YORIGIN", "DOUBLE", 0, []), + ("ANGROT", "DOUBLE", 0, []), + ("DELR", "DOUBLE", 1, [ncol]), + ("DELC", "DOUBLE", 1, [nrow]), + ("TOP", "DOUBLE", 1, [nodes]), + ("BOTM", "DOUBLE", 1, [nodes]), + ("IA", "INTEGER", 1, [nodes + 1]), + ("JA", "INTEGER", 1, [nja]), + ("IDOMAIN", "INTEGER", 1, [nodes]), + ("ICELLTYPE", "INTEGER", 1, [nodes]), + ] + + for name, dtype_str, ndim, dims in var_list: + if ndim == 0: + line = f"{name} {dtype_str} NDIM {ndim}\n" + else: + dims_str = " ".join(str(d) for d in dims[::-1]) + line = f"{name} {dtype_str} NDIM {ndim} {dims_str}\n" + writer.write_text(line, 100) + + for name, dtype_str, ndim, dims in var_list: + value = grb_data[name] + if ndim == 0: + if dtype_str == "INTEGER": + writer.write_integer(int(value)) + else: + writer.write_real(float(value)) + else: + arr = np.asarray(value) + if dtype_str == "INTEGER": + arr = arr.astype(np.int32) + elif dtype_str == "DOUBLE": + arr = arr.astype(np.float64) + writer.write_record(arr.flatten(order="F"), dtype=arr.dtype) + + grb = MfGrdFile(str(temp_grb), verbose=False) + output_file = tmp_path / "benchmark_output.grb" + + def write_grb(): + grb.export(output_file, verbose=False) + + benchmark(write_grb) + assert output_file.exists() diff --git a/autotest/test_binaryfile.py b/autotest/test_binaryfile.py index 1de8a77f9..e3308ef9c 100644 --- a/autotest/test_binaryfile.py +++ b/autotest/test_binaryfile.py @@ -807,3 +807,505 @@ def test_headfile_get_ts_disu_grid(dis_sim, function_tmpdir): ts_old_list, err_msg="DISU HeadFile: old list format should match new list format", ) + + +def test_headfile_write_dict(function_tmpdir): + """Test HeadFile.write() with dict input format.""" + # Create test data for a 3-layer, 10-row, 20-col grid + nlay, nrow, ncol = 3, 10, 20 + head1 = np.random.rand(nlay, nrow, ncol).astype(np.float64) + head2 = np.random.rand(nlay, nrow, ncol).astype(np.float64) + + # Create using dict format + data = { + (1, 1): head1, + (1, 2): head2, + } + + # Write to file + outfile = function_tmpdir / "test.hds" + HeadFile.write(outfile, data, precision="double") + assert outfile.exists() + + # Read back and verify + hds = HeadFile(outfile) + assert hds.nlay == nlay + assert hds.nrow == nrow + assert hds.ncol == ncol + assert len(hds.get_times()) == 2 + assert hds.get_times() == [1.0, 2.0] + assert hds.get_kstpkper() == [(0, 0), (0, 1)] + + # Verify data round-trips correctly + data_read1 = hds.get_data(kstpkper=(0, 0)) + data_read2 = hds.get_data(kstpkper=(0, 1)) + np.testing.assert_allclose(data_read1, head1, rtol=1e-10) + np.testing.assert_allclose(data_read2, head2, rtol=1e-10) + + +def test_headfile_write_list(function_tmpdir): + """Test HeadFile.write() with list input format.""" + nlay, nrow, ncol = 2, 5, 10 + head1 = np.random.rand(nlay, nrow, ncol).astype(np.float32) + head2 = np.random.rand(nlay, nrow, ncol).astype(np.float32) + + # Create using list format with explicit metadata + data = [ + {"data": head1, "kstp": 1, "kper": 1, "totim": 10.0, "pertim": 10.0}, + {"data": head2, "kstp": 1, "kper": 2, "totim": 20.0, "pertim": 10.0}, + ] + + outfile = function_tmpdir / "test.hds" + HeadFile.write(outfile, data, precision="single") + hds = HeadFile(outfile) + assert hds.nlay == nlay + assert hds.nrow == nrow + assert hds.ncol == ncol + assert hds.get_times() == [10.0, 20.0] + + # Verify data + data_read1 = hds.get_data(totim=10.0) + data_read2 = hds.get_data(totim=20.0) + np.testing.assert_allclose(data_read1, head1, rtol=1e-6) + np.testing.assert_allclose(data_read2, head2, rtol=1e-6) + + +def test_headfile_write_2d(function_tmpdir): + """Test HeadFile.write() with 2D arrays (single layer).""" + nrow, ncol = 10, 20 + head1 = np.random.rand(nrow, ncol) + head2 = np.random.rand(nrow, ncol) + + data = { + (1, 1): head1, + (1, 2): head2, + } + + outfile = function_tmpdir / "test.hds" + HeadFile.write(outfile, data) + hds = HeadFile(outfile) + assert hds.nlay == 1 + assert hds.nrow == nrow + assert hds.ncol == ncol + + # Get data and check - should get 3D array back + data_read = hds.get_data(kstpkper=(0, 0)) + assert data_read.shape == (1, nrow, ncol) + np.testing.assert_allclose(data_read[0], head1, rtol=1e-10) + + +def test_headfile_write_custom_times(function_tmpdir): + """Test HeadFile.write() with custom time values.""" + nlay, nrow, ncol = 1, 5, 5 + head1 = np.ones((nlay, nrow, ncol)) + head2 = np.ones((nlay, nrow, ncol)) * 2 + + data = { + (1, 1): head1, + (2, 1): head2, + } + + # Custom totim and pertim + totim = {(1, 1): 5.5, (2, 1): 10.5} + pertim = {(1, 1): 5.5, (2, 1): 5.0} + + outfile = function_tmpdir / "test.hds" + HeadFile.write(outfile, data, totim=totim, pertim=pertim) + hds = HeadFile(outfile) + assert hds.get_times() == [5.5, 10.5] + + # Verify we can retrieve by totim + data_read = hds.get_data(totim=5.5) + np.testing.assert_allclose(data_read, head1) + + +def test_cellbudgetfile_write_dict(function_tmpdir): + """Test CellBudgetFile.write() with dict input format.""" + # Create test data for multiple budget terms + nnodes = 600 # 3 * 10 * 20 + flow1 = np.random.rand(nnodes).astype(np.float64) + flow2 = np.random.rand(nnodes).astype(np.float64) + storage1 = np.random.rand(nnodes).astype(np.float64) + storage2 = np.random.rand(nnodes).astype(np.float64) + + # Write multiple budget terms so dimensions get set properly + # (FLOW-JA-FACE alone won't set dimensions) + data = [ + {"data": flow1, "kstp": 1, "kper": 1, "text": "FLOW-JA-FACE", "totim": 1.0}, + {"data": storage1, "kstp": 1, "kper": 1, "text": "STORAGE", "totim": 1.0}, + {"data": flow2, "kstp": 1, "kper": 2, "text": "FLOW-JA-FACE", "totim": 2.0}, + {"data": storage2, "kstp": 1, "kper": 2, "text": "STORAGE", "totim": 2.0}, + ] + + # Create with explicit dimensions + outfile = function_tmpdir / "test.cbc" + CellBudgetFile.write(outfile, data, nlay=3, nrow=10, ncol=20, precision="double") + cbb = CellBudgetFile(outfile) + assert cbb.nlay == 3 + assert cbb.nrow == 10 + assert cbb.ncol == 20 + assert len(cbb.get_times()) == 2 + assert cbb.get_times() == [1.0, 2.0] + + # Verify data round-trips for both budget terms + flow_read1 = cbb.get_data(kstpkper=(0, 0), text="FLOW-JA-FACE")[0] + flow_read2 = cbb.get_data(kstpkper=(0, 1), text="FLOW-JA-FACE")[0] + storage_read1 = cbb.get_data(kstpkper=(0, 0), text="STORAGE")[0] + storage_read2 = cbb.get_data(kstpkper=(0, 1), text="STORAGE")[0] + np.testing.assert_allclose(flow_read1.flatten(), flow1, rtol=1e-10) + np.testing.assert_allclose(flow_read2.flatten(), flow2, rtol=1e-10) + np.testing.assert_allclose(storage_read1.flatten(), storage1, rtol=1e-10) + np.testing.assert_allclose(storage_read2.flatten(), storage2, rtol=1e-10) + + +def test_cellbudgetfile_write_list(function_tmpdir): + """Test CellBudgetFile.write() with list input format.""" + nnodes = 100 + flow1 = np.random.rand(nnodes).astype(np.float32) + flow2 = np.random.rand(nnodes).astype(np.float32) + + data = [ + { + "data": flow1, + "kstp": 1, + "kper": 1, + "totim": 10.0, + "pertim": 10.0, + "text": "STORAGE", + }, + { + "data": flow2, + "kstp": 1, + "kper": 2, + "totim": 20.0, + "pertim": 10.0, + "text": "STORAGE", + }, + ] + + outfile = function_tmpdir / "test.cbc" + CellBudgetFile.write(outfile, data, nlay=1, nrow=1, ncol=100, precision="single") + cbb = CellBudgetFile(outfile) + assert cbb.get_times() == [10.0, 20.0] + + # Verify data + data_read1 = cbb.get_data(totim=10.0, text="STORAGE")[0] + data_read2 = cbb.get_data(totim=20.0, text="STORAGE")[0] + np.testing.assert_allclose(data_read1.flatten(), flow1, rtol=1e-6) + np.testing.assert_allclose(data_read2.flatten(), flow2, rtol=1e-6) + + +def test_headfile_write_errors(function_tmpdir): + """Test that HeadFile.write() raises appropriate errors.""" + # Empty data + with pytest.raises(ValueError, match="No data records"): + HeadFile.write(function_tmpdir / "test.hds", {}) + + # 1D array (not allowed for DIS) + with pytest.raises(ValueError, match="1D arrays require ncpl or nnodes"): + HeadFile.write(function_tmpdir / "test.hds", {(1, 1): np.array([1, 2, 3])}) + + # Inconsistent shapes + data = { + (1, 1): np.ones((10, 20)), + (1, 2): np.ones((10, 15)), # Different ncol + } + with pytest.raises(ValueError, match="Inconsistent array shapes"): + HeadFile.write(function_tmpdir / "test.hds", data) + + +def test_cellbudgetfile_write_errors(function_tmpdir): + """Test that CellBudgetFile.write() raises appropriate errors.""" + # Empty data + with pytest.raises(ValueError, match="No data records"): + CellBudgetFile.write(function_tmpdir / "test.cbc", {}) + + # Dimension mismatch (use non-FLOW-JA-FACE text for validation) + with pytest.raises(ValueError, match="Dimensions don't match"): + CellBudgetFile.write( + function_tmpdir / "test.cbc", + {(1, 1): np.ones(100)}, + text="STORAGE", + nlay=2, + nrow=10, + ncol=10, # Should be 200 nodes + ) + + +def test_headfile_write_scalar(function_tmpdir): + """Test HeadFile.write() with scalar data.""" + # Single timestep + outfile = function_tmpdir / "test_scalar.hds" + hds = HeadFile.write(outfile, 100.0, nlay=3, nrow=10, ncol=20) + assert hds.get_times() == [1.0] + + data_read = hds.get_data(totim=1.0) + assert data_read.shape == (3, 10, 20) + np.testing.assert_allclose(data_read, 100.0) + hds.close() + + # Multiple timesteps with kstpkper + outfile = function_tmpdir / "test_scalar_multi.hds" + hds = HeadFile.write( + outfile, 50.0, nlay=2, nrow=5, ncol=10, kstpkper=[(1, 1), (1, 2), (1, 3)] + ) + assert hds.get_times() == [1.0, 2.0, 3.0] + + for totim in [1.0, 2.0, 3.0]: + data_read = hds.get_data(totim=totim) + assert data_read.shape == (2, 5, 10) + np.testing.assert_allclose(data_read, 50.0) + hds.close() + + # Error if dimensions not provided + with pytest.raises(ValueError, match="Must provide grid dimensions"): + HeadFile.write(function_tmpdir / "test.hds", 100.0) + + +def test_cellbudgetfile_write_scalar(function_tmpdir): + """Test CellBudgetFile.write() with scalar data.""" + # Single timestep + outfile = function_tmpdir / "test_scalar.cbc" + cbb = CellBudgetFile.write( + outfile, 25.0, text="CONSTANT HEAD", nlay=3, nrow=10, ncol=20 + ) + assert cbb.get_times() == [1.0] + + data_read = cbb.get_data(totim=1.0, text="CONSTANT HEAD")[0] + assert data_read.shape == (3, 10, 20) + np.testing.assert_allclose(data_read, 25.0) + cbb.close() + + # Multiple timesteps with kstpkper + outfile = function_tmpdir / "test_scalar_multi.cbc" + cbb = CellBudgetFile.write( + outfile, + -10.0, + text="STORAGE", + nlay=2, + nrow=5, + ncol=10, + kstpkper=[(1, 1), (2, 1), (1, 2)], + ) + assert cbb.get_times() == [1.0, 2.0, 3.0] + + for totim in [1.0, 2.0, 3.0]: + data_read = cbb.get_data(totim=totim, text="STORAGE")[0] + assert data_read.shape == (2, 5, 10) + np.testing.assert_allclose(data_read, -10.0) + cbb.close() + + +def test_headfile_write_list_of_arrays(function_tmpdir): + """Test HeadFile.write() with list of arrays.""" + nlay, nrow, ncol = 3, 10, 20 + + # Create list of arrays + heads = [ + np.full((nlay, nrow, ncol), 100.0), + np.full((nlay, nrow, ncol), 95.0), + np.full((nlay, nrow, ncol), 90.0), + ] + + outfile = function_tmpdir / "test_list.hds" + hds = HeadFile.write(outfile, heads) + + # Should create 3 timesteps with sequential (kstp, kper) + assert hds.get_times() == [1.0, 2.0, 3.0] + + # Verify data + data1 = hds.get_data(totim=1.0) + data2 = hds.get_data(totim=2.0) + data3 = hds.get_data(totim=3.0) + + np.testing.assert_allclose(data1, 100.0) + np.testing.assert_allclose(data2, 95.0) + np.testing.assert_allclose(data3, 90.0) + hds.close() + + # Test with custom kstpkper + outfile = function_tmpdir / "test_list_kstpkper.hds" + hds = HeadFile.write(outfile, heads, kstpkper=[(1, 1), (1, 2), (1, 3)]) + assert hds.get_times() == [1.0, 2.0, 3.0] + hds.close() + + +def test_cellbudgetfile_write_list_of_arrays(function_tmpdir): + """Test CellBudgetFile.write() with list of arrays.""" + nlay, nrow, ncol = 2, 5, 10 + + # Create list of arrays + storage = [ + np.random.rand(nlay, nrow, ncol).astype(np.float32), + np.random.rand(nlay, nrow, ncol).astype(np.float32), + ] + + outfile = function_tmpdir / "test_list.cbc" + cbb = CellBudgetFile.write( + outfile, + storage, + text="STORAGE", + nlay=nlay, + nrow=nrow, + ncol=ncol, + precision="single", + ) + + # Should create 2 timesteps + assert cbb.get_times() == [1.0, 2.0] + + # Verify data + data1 = cbb.get_data(totim=1.0, text="STORAGE")[0] + data2 = cbb.get_data(totim=2.0, text="STORAGE")[0] + + np.testing.assert_allclose(data1, storage[0], rtol=1e-6) + np.testing.assert_allclose(data2, storage[1], rtol=1e-6) + cbb.close() + + +def test_headfile_write_xarray_duck(function_tmpdir): + """Test HeadFile.write() with xarray-like duck arrays.""" + nlay, nrow, ncol = 3, 10, 20 + + # Create a mock xarray-like object (duck typing) + class MockXArray: + def __init__(self, data): + self.values = data + self.dims = ["z", "y", "x"] + self.shape = data.shape + + # Test with single xarray-like array + data = np.full((nlay, nrow, ncol), 100.0) + xarr = MockXArray(data) + + outfile = function_tmpdir / "test_xarray.hds" + hds = HeadFile.write(outfile, {(1, 1): xarr}) + + data_read = hds.get_data(totim=1.0) + np.testing.assert_allclose(data_read, 100.0) + hds.close() + + # Test with list of xarray-like arrays + xarrs = [ + MockXArray(np.full((nlay, nrow, ncol), 100.0)), + MockXArray(np.full((nlay, nrow, ncol), 95.0)), + ] + + outfile = function_tmpdir / "test_xarray_list.hds" + hds = HeadFile.write(outfile, xarrs) + + assert hds.get_times() == [1.0, 2.0] + np.testing.assert_allclose(hds.get_data(totim=1.0), 100.0) + np.testing.assert_allclose(hds.get_data(totim=2.0), 95.0) + hds.close() + + # Test with list-of-dicts containing xarray-like + data_list = [ + {"data": MockXArray(np.full((nlay, nrow, ncol), 50.0)), "kstp": 1, "kper": 1}, + {"data": MockXArray(np.full((nlay, nrow, ncol), 45.0)), "kstp": 1, "kper": 2}, + ] + + outfile = function_tmpdir / "test_xarray_dictlist.hds" + hds = HeadFile.write(outfile, data_list) + + assert hds.get_times() == [1.0, 2.0] + np.testing.assert_allclose(hds.get_data(totim=1.0), 50.0) + np.testing.assert_allclose(hds.get_data(totim=2.0), 45.0) + hds.close() + + +def test_cellbudgetfile_write_xarray_duck(function_tmpdir): + """Test CellBudgetFile.write() with xarray-like duck arrays.""" + nlay, nrow, ncol = 2, 5, 10 + + # Create a mock xarray-like object + class MockXArray: + def __init__(self, data): + self.values = data + self.dims = ["z", "y", "x"] + self.shape = data.shape + + # Test with dict of xarray-like arrays + data = { + (1, 1): MockXArray(np.random.rand(nlay, nrow, ncol).astype(np.float32)), + (1, 2): MockXArray(np.random.rand(nlay, nrow, ncol).astype(np.float32)), + } + + outfile = function_tmpdir / "test_xarray.cbc" + cbb = CellBudgetFile.write( + outfile, + data, + text="STORAGE", + nlay=nlay, + nrow=nrow, + ncol=ncol, + precision="single", + ) + + assert cbb.get_times() == [1.0, 2.0] + + # Verify data matches original + data1 = cbb.get_data(totim=1.0, text="STORAGE")[0] + data2 = cbb.get_data(totim=2.0, text="STORAGE")[0] + + np.testing.assert_allclose(data1, data[(1, 1)].values, rtol=1e-6) + np.testing.assert_allclose(data2, data[(1, 2)].values, rtol=1e-6) + cbb.close() + + # Test with list of xarray-like arrays + storage_list = [ + MockXArray(np.random.rand(nlay, nrow, ncol).astype(np.float32)), + MockXArray(np.random.rand(nlay, nrow, ncol).astype(np.float32)), + ] + + outfile = function_tmpdir / "test_xarray_list.cbc" + cbb = CellBudgetFile.write( + outfile, + storage_list, + text="STORAGE", + nlay=nlay, + nrow=nrow, + ncol=ncol, + precision="single", + ) + + assert cbb.get_times() == [1.0, 2.0] + np.testing.assert_allclose( + cbb.get_data(totim=1.0, text="STORAGE")[0], storage_list[0].values, rtol=1e-6 + ) + np.testing.assert_allclose( + cbb.get_data(totim=2.0, text="STORAGE")[0], storage_list[1].values, rtol=1e-6 + ) + cbb.close() + + +def test_headfile_write_scalar_disv_disu(function_tmpdir): + """Test HeadFile.write() with scalars for DISV and DISU grids.""" + # DISV grid (stored as nlay, nrow=1, ncol=ncpl) + outfile = function_tmpdir / "test_scalar_disv.hds" + hds = HeadFile.write(outfile, 75.0, nlay=3, ncpl=100) + + assert hds.get_times() == [1.0] + data_read = hds.get_data(totim=1.0) + assert data_read.shape == (3, 1, 100) + np.testing.assert_allclose(data_read, 75.0) + hds.close() + + # DISU grid (stored as nlay=1, nrow=1, ncol=nnodes) + outfile = function_tmpdir / "test_scalar_disu.hds" + hds = HeadFile.write(outfile, 50.0, nnodes=500) + + assert hds.get_times() == [1.0] + data_read = hds.get_data(totim=1.0) + assert data_read.shape == (1, 1, 500) + np.testing.assert_allclose(data_read, 50.0) + hds.close() + + +def test_empty_list_error(function_tmpdir): + """Test that empty lists raise appropriate errors.""" + with pytest.raises(ValueError, match="Empty data list"): + HeadFile.write(function_tmpdir / "test.hds", []) + + with pytest.raises(ValueError, match="Empty data list"): + CellBudgetFile.write(function_tmpdir / "test.cbc", [], text="STORAGE") diff --git a/autotest/test_binarygrid_util.py b/autotest/test_binarygrid_util.py index fb4f09aa2..5d56e0320 100644 --- a/autotest/test_binarygrid_util.py +++ b/autotest/test_binarygrid_util.py @@ -159,3 +159,240 @@ def test_mfgrddisu_modelgrid(mfgrd_test_path): assert nvert == verts.shape[0], ( f"number of vertex (x, y) pairs ({verts.shape[0]}) does not equal {nvert}" ) + + +def test_write_grb_instance_method(tmp_path, mfgrd_test_path): + original_file = mfgrd_test_path / "nwtp3.dis.grb" + grb_orig = MfGrdFile(original_file, verbose=False) + + output_file = tmp_path / "test_instance.dis.grb" + grb_orig.export(output_file, verbose=False) + + grb_new = MfGrdFile(output_file, verbose=False) + + assert grb_new.grid_type == grb_orig.grid_type + assert grb_new.nodes == grb_orig.nodes + assert grb_new.nlay == grb_orig.nlay + assert grb_new.nrow == grb_orig.nrow + assert grb_new.ncol == grb_orig.ncol + assert grb_new.nja == grb_orig.nja + + np.testing.assert_allclose(grb_new.xorigin, grb_orig.xorigin) + np.testing.assert_allclose(grb_new.yorigin, grb_orig.yorigin) + np.testing.assert_allclose(grb_new.angrot, grb_orig.angrot) + + np.testing.assert_allclose(grb_new.delr, grb_orig.delr) + np.testing.assert_allclose(grb_new.delc, grb_orig.delc) + np.testing.assert_allclose(grb_new.top, grb_orig.top) + np.testing.assert_allclose(grb_new.bot, grb_orig.bot) + + np.testing.assert_array_equal(grb_new.ia, grb_orig.ia) + np.testing.assert_array_equal(grb_new.ja, grb_orig.ja) + np.testing.assert_array_equal(grb_new.idomain, grb_orig.idomain) + + +def test_write_grb_instance_method_precision_conversion(tmp_path, mfgrd_test_path): + original_file = mfgrd_test_path / "nwtp3.dis.grb" + grb = MfGrdFile(original_file, verbose=False) + + single_file = tmp_path / "test_single.grb" + grb.export(single_file, precision="single", verbose=False) + + double_file = tmp_path / "test_double.grb" + grb.export(double_file, precision="double", verbose=False) + + grb_single = MfGrdFile(single_file, verbose=False) + grb_double = MfGrdFile(double_file, verbose=False) + + assert grb_single.nodes == grb.nodes + assert grb_double.nodes == grb.nodes + assert single_file.stat().st_size < double_file.stat().st_size + + +def test_write_grb_disv_roundtrip(tmp_path, mfgrd_test_path): + """Test MfGrdFile.export() for DISV grid with roundtrip validation.""" + from flopy.mf6.utils.binarygrid_util import MfGrdFile + + # Read original DISV grb file + original_file = mfgrd_test_path / "flow.disv.grb" + grb_orig = MfGrdFile(original_file, verbose=False) + + # Write using instance method + output_file = tmp_path / "test_disv.grb" + grb_orig.export(output_file, verbose=False) + + # Read it back + grb_new = MfGrdFile(output_file, verbose=False) + + # Verify grid type and dimensions + assert grb_new.grid_type == "DISV" + assert grb_new.grid_type == grb_orig.grid_type + assert grb_new.nodes == grb_orig.nodes + assert grb_new.nlay == grb_orig.nlay + assert grb_new.ncpl == grb_orig.ncpl + assert grb_new.nja == grb_orig.nja + + # Verify coordinates + np.testing.assert_allclose(grb_new.xorigin, grb_orig.xorigin) + np.testing.assert_allclose(grb_new.yorigin, grb_orig.yorigin) + np.testing.assert_allclose(grb_new.angrot, grb_orig.angrot) + + # Verify elevation arrays + np.testing.assert_allclose(grb_new.top, grb_orig.top) + np.testing.assert_allclose(grb_new.bot, grb_orig.bot) + + # Verify cell connectivity + np.testing.assert_array_equal(grb_new.ia, grb_orig.ia) + np.testing.assert_array_equal(grb_new.ja, grb_orig.ja) + + # Verify DISV-specific data + assert grb_new._datadict["NVERT"] == grb_orig._datadict["NVERT"] + assert grb_new._datadict["NJAVERT"] == grb_orig._datadict["NJAVERT"] + np.testing.assert_allclose( + grb_new._datadict["VERTICES"], grb_orig._datadict["VERTICES"] + ) + np.testing.assert_allclose(grb_new._datadict["CELLX"], grb_orig._datadict["CELLX"]) + np.testing.assert_allclose(grb_new._datadict["CELLY"], grb_orig._datadict["CELLY"]) + np.testing.assert_array_equal( + grb_new._datadict["IAVERT"], grb_orig._datadict["IAVERT"] + ) + np.testing.assert_array_equal( + grb_new._datadict["JAVERT"], grb_orig._datadict["JAVERT"] + ) + np.testing.assert_array_equal(grb_new.idomain, grb_orig.idomain) + np.testing.assert_array_equal( + grb_new._datadict["ICELLTYPE"], grb_orig._datadict["ICELLTYPE"] + ) + + +def test_write_grb_disv_precision_conversion(tmp_path, mfgrd_test_path): + """Test MfGrdFile.export() for DISV grid with precision conversion.""" + from flopy.mf6.utils.binarygrid_util import MfGrdFile + + # Read original DISV grb file + original_file = mfgrd_test_path / "flow.disv.grb" + grb = MfGrdFile(original_file, verbose=False) + + # Write in single and double precision + single_file = tmp_path / "test_disv_single.grb" + grb.export(single_file, precision="single", verbose=False) + + double_file = tmp_path / "test_disv_double.grb" + grb.export(double_file, precision="double", verbose=False) + + # Read them back + grb_single = MfGrdFile(single_file, verbose=False) + grb_double = MfGrdFile(double_file, verbose=False) + + # Verify dimensions are preserved + assert grb_single.nodes == grb.nodes + assert grb_double.nodes == grb.nodes + assert grb_single.grid_type == "DISV" + assert grb_double.grid_type == "DISV" + + # Single precision file should be smaller + assert single_file.stat().st_size < double_file.stat().st_size + + # Verify data values are preserved (with appropriate tolerances) + # Single precision has ~7 decimal digits of precision + np.testing.assert_allclose(grb_single.top, grb.top, rtol=1e-6) + np.testing.assert_allclose(grb_single.bot, grb.bot, rtol=1e-6) + np.testing.assert_allclose( + grb_single._datadict["VERTICES"], grb._datadict["VERTICES"], rtol=1e-6 + ) + np.testing.assert_allclose( + grb_single._datadict["CELLX"], grb._datadict["CELLX"], rtol=1e-6 + ) + np.testing.assert_allclose( + grb_single._datadict["CELLY"], grb._datadict["CELLY"], rtol=1e-6 + ) + + # Double precision should match exactly (same precision as original) + np.testing.assert_allclose(grb_double.top, grb.top, rtol=1e-12) + np.testing.assert_allclose(grb_double.bot, grb.bot, rtol=1e-12) + np.testing.assert_allclose( + grb_double._datadict["VERTICES"], grb._datadict["VERTICES"], rtol=1e-12 + ) + + +def test_write_grb_disu_roundtrip(tmp_path, mfgrd_test_path): + """Test MfGrdFile.export() for DISU grid with roundtrip validation.""" + from flopy.mf6.utils.binarygrid_util import MfGrdFile + + # Read original DISU grb file + original_file = mfgrd_test_path / "flow.disu.grb" + grb_orig = MfGrdFile(original_file, verbose=False) + + # Write using instance method + output_file = tmp_path / "test_disu.grb" + grb_orig.export(output_file, verbose=False) + + # Read it back + grb_new = MfGrdFile(output_file, verbose=False) + + # Verify grid type and dimensions + assert grb_new.grid_type == "DISU" + assert grb_new.grid_type == grb_orig.grid_type + assert grb_new.nodes == grb_orig.nodes + assert grb_new.nja == grb_orig.nja + + # Verify coordinates + np.testing.assert_allclose(grb_new.xorigin, grb_orig.xorigin) + np.testing.assert_allclose(grb_new.yorigin, grb_orig.yorigin) + np.testing.assert_allclose(grb_new.angrot, grb_orig.angrot) + + # Verify elevation arrays (note: DISU uses TOP/BOT not TOP/BOTM) + np.testing.assert_allclose(grb_new.top, grb_orig.top) + np.testing.assert_allclose(grb_new._datadict["BOT"], grb_orig._datadict["BOT"]) + + # Verify cell connectivity + np.testing.assert_array_equal(grb_new.ia, grb_orig.ia) + np.testing.assert_array_equal(grb_new.ja, grb_orig.ja) + + # Verify DISU-specific data + np.testing.assert_array_equal( + grb_new._datadict["ICELLTYPE"], grb_orig._datadict["ICELLTYPE"] + ) + + # IDOMAIN is optional in DISU - check if present + if "IDOMAIN" in grb_orig._datadict: + assert "IDOMAIN" in grb_new._datadict + np.testing.assert_array_equal(grb_new.idomain, grb_orig.idomain) + + +def test_write_grb_disu_precision_conversion(tmp_path, mfgrd_test_path): + """Test MfGrdFile.export() for DISU grid with precision conversion.""" + from flopy.mf6.utils.binarygrid_util import MfGrdFile + + # Read original DISU grb file + original_file = mfgrd_test_path / "flow.disu.grb" + grb = MfGrdFile(original_file, verbose=False) + + # Write in single and double precision + single_file = tmp_path / "test_disu_single.grb" + grb.export(single_file, precision="single", verbose=False) + + double_file = tmp_path / "test_disu_double.grb" + grb.export(double_file, precision="double", verbose=False) + + # Read them back + grb_single = MfGrdFile(single_file, verbose=False) + grb_double = MfGrdFile(double_file, verbose=False) + + # Verify dimensions are preserved + assert grb_single.nodes == grb.nodes + assert grb_double.nodes == grb.nodes + assert grb_single.grid_type == "DISU" + assert grb_double.grid_type == "DISU" + + # Single precision file should be smaller + assert single_file.stat().st_size < double_file.stat().st_size + + # Verify data values are preserved (with appropriate tolerances) + # Single precision has ~7 decimal digits of precision + np.testing.assert_allclose(grb_single.top, grb.top, rtol=1e-6) + np.testing.assert_allclose(grb_single.bot, grb.bot, rtol=1e-6) + + # Double precision should match exactly (same precision as original) + np.testing.assert_allclose(grb_double.top, grb.top, rtol=1e-12) + np.testing.assert_allclose(grb_double.bot, grb.bot, rtol=1e-12) diff --git a/autotest/test_cellbudgetfile.py b/autotest/test_cellbudgetfile.py index 720388f25..40a35308d 100644 --- a/autotest/test_cellbudgetfile.py +++ b/autotest/test_cellbudgetfile.py @@ -1156,3 +1156,53 @@ def test_cellbudgetfile_get_ts_backwards_compatible_idx_format( ts_new_list, ts_old_list, ) + + +@pytest.mark.requires_exe("mf6") +def test_cellbudgetfile_write_preserves_aux_vars(dis_sim, function_tmpdir): + """Test that write() method preserves auxiliary variables in imeth=6 records.""" + from pathlib import Path + + import numpy as np + + sim = dis_sim + sim.write_simulation() + success, _ = sim.run_simulation(silent=True) + assert success + + gwf = sim.get_model() + cbc_orig = gwf.output.budget() + + # Get DATA-SPDIS which has aux vars (node, q, qx, qy, qz) + spdis_orig = cbc_orig.get_data(text="DATA-SPDIS") + assert len(spdis_orig) > 0 + + # Verify aux fields are present + for field in ["node", "q", "qx", "qy", "qz"]: + assert field in spdis_orig[0].dtype.names, ( + f"Field {field} not found in original data" + ) + + # Write to a new file + output_file = Path(function_tmpdir) / "test_aux_rewritten.cbc" + cbc_orig.export(output_file, kstpkper=cbc_orig.kstpkper[:2]) + + # Read back the written file + from flopy.utils import CellBudgetFile + + cbc_rewritten = CellBudgetFile(output_file) + spdis_rewritten = cbc_rewritten.get_data(text="DATA-SPDIS") + + # Verify aux vars were preserved + for field in ["node", "q", "qx", "qy", "qz"]: + assert field in spdis_rewritten[0].dtype.names, ( + f"Field {field} not found in rewritten data" + ) + + # Verify data matches + assert len(spdis_orig) >= len(spdis_rewritten) + for i in range(len(spdis_rewritten)): + for field in ["q", "qx", "qy", "qz"]: + assert np.allclose(spdis_orig[i][field], spdis_rewritten[i][field]), ( + f"Field {field} mismatch at timestep {i}" + ) diff --git a/flopy/mf6/utils/binarygrid_util.py b/flopy/mf6/utils/binarygrid_util.py index 4fe45e0b0..09c256454 100644 --- a/flopy/mf6/utils/binarygrid_util.py +++ b/flopy/mf6/utils/binarygrid_util.py @@ -146,9 +146,9 @@ def __init__(self, filename, precision="double", verbose=False): if dt == np.int32: v = self.read_integer() elif dt == np.float32: - v = self.read_real() + v = self._read_values(dt, 1)[0] elif dt == np.float64: - v = self.read_real() + v = self._read_values(dt, 1)[0] self._datadict[key] = v if self.verbose: @@ -317,8 +317,7 @@ def _get_verts(self): if self._grid_type == "DISU": # modify verts verts = [ - [idx, verts[idx, 0], verts[idx, 1]] - for idx in range(shpvert[0]) + [idx, verts[idx, 0], verts[idx, 1]] for idx in range(shpvert[0]) ] if self.verbose: print(f"returning verts from {self.file.name}") @@ -747,3 +746,188 @@ def cell2d(self): else: vertices, cell2d = None, None return vertices, cell2d + + def export(self, filename, precision=None, version=1, verbose=False): + """ + Export the binary grid file to a new file. + + Parameters + ---------- + filename : str or PathLike + Path to output .grb file + precision : str, optional + 'single' or 'double'. If None, uses the precision from the + original file (default None) + version : int, optional + Grid file version (default 1) + verbose : bool, optional + Print progress messages (default False) + + Examples + -------- + >>> from flopy.mf6.utils import MfGrdFile + >>> grb = MfGrdFile('model.dis.grb') + >>> grb.export('model_copy.dis.grb') + >>> # Convert to single precision + >>> grb.export('model_single.dis.grb', precision='single') + """ + if precision is None: + precision = self.precision + + # Build data dictionary from instance + data_dict = {} + for key in self._recordkeys: + if key in ("IA", "JA"): + # Use original 1-based arrays + data_dict[key] = self._datadict[key] + elif key == "TOP": + data_dict[key] = self.top + elif key == "BOTM": + data_dict[key] = self.bot + elif key in self._datadict: + data_dict[key] = self._datadict[key] + + # Define variable metadata based on grid type + float_type = "SINGLE" if precision.lower() == "single" else "DOUBLE" + + if self.grid_type == "DIS": + var_list = [ + ("NCELLS", "INTEGER", 0, []), + ("NLAY", "INTEGER", 0, []), + ("NROW", "INTEGER", 0, []), + ("NCOL", "INTEGER", 0, []), + ("NJA", "INTEGER", 0, []), + ("XORIGIN", float_type, 0, []), + ("YORIGIN", float_type, 0, []), + ("ANGROT", float_type, 0, []), + ("DELR", float_type, 1, [self.ncol]), + ("DELC", float_type, 1, [self.nrow]), + ("TOP", float_type, 1, [self.nodes]), + ("BOTM", float_type, 1, [self.nodes]), + ("IA", "INTEGER", 1, [self.nodes + 1]), + ("JA", "INTEGER", 1, [self.nja]), + ("IDOMAIN", "INTEGER", 1, [self.nodes]), + ("ICELLTYPE", "INTEGER", 1, [self.nodes]), + ] + elif self.grid_type == "DISV": + # Get dimensions for DISV arrays + nvert = self._datadict["NVERT"] + njavert = self._datadict["NJAVERT"] + var_list = [ + ("NCELLS", "INTEGER", 0, []), + ("NLAY", "INTEGER", 0, []), + ("NCPL", "INTEGER", 0, []), + ("NVERT", "INTEGER", 0, []), + ("NJAVERT", "INTEGER", 0, []), + ("NJA", "INTEGER", 0, []), + ("XORIGIN", float_type, 0, []), + ("YORIGIN", float_type, 0, []), + ("ANGROT", float_type, 0, []), + ("TOP", float_type, 1, [self.nodes]), + ("BOTM", float_type, 1, [self.nodes]), + ("VERTICES", float_type, 2, [nvert, 2]), + ("CELLX", float_type, 1, [self.nodes]), + ("CELLY", float_type, 1, [self.nodes]), + ("IAVERT", "INTEGER", 1, [self.nodes + 1]), + ("JAVERT", "INTEGER", 1, [njavert]), + ("IA", "INTEGER", 1, [self.nodes + 1]), + ("JA", "INTEGER", 1, [self.nja]), + ("IDOMAIN", "INTEGER", 1, [self.nodes]), + ("ICELLTYPE", "INTEGER", 1, [self.nodes]), + ] + elif self.grid_type == "DISU": + var_list = [ + ("NODES", "INTEGER", 0, []), + ("NJA", "INTEGER", 0, []), + ("XORIGIN", float_type, 0, []), + ("YORIGIN", float_type, 0, []), + ("ANGROT", float_type, 0, []), + ("TOP", float_type, 1, [self.nodes]), + ("BOT", float_type, 1, [self.nodes]), + ("IA", "INTEGER", 1, [self.nodes + 1]), + ("JA", "INTEGER", 1, [self.nja]), + ("ICELLTYPE", "INTEGER", 1, [self.nodes]), + ] + # IDOMAIN is optional for DISU + if "IDOMAIN" in self._datadict: + var_list.insert(-1, ("IDOMAIN", "INTEGER", 1, [self.nodes])) + else: + raise NotImplementedError( + f"Grid type {self.grid_type} not yet implemented. " + "Supported grid types: DIS, DISV, DISU" + ) + + ntxt = len(var_list) + lentxt = 100 + + if verbose: + print(f"Writing binary grid file: {filename}") + print(f" Grid type: {self.grid_type}") + print(f" Version: {version}") + print(f" Number of variables: {ntxt}") + + # Create writer with appropriate precision + writer = FlopyBinaryData() + writer.precision = precision + + with open(filename, "wb") as f: + writer.file = f + + # Write text header lines (50 chars each, newline terminated) + header_len = 50 + writer.write_text(f"GRID {self.grid_type}\n", header_len) + writer.write_text(f"VERSION {version}\n", header_len) + writer.write_text(f"NTXT {ntxt}\n", header_len) + writer.write_text(f"LENTXT {lentxt}\n", header_len) + + # Write variable definition lines (100 chars each) + for name, dtype_str, ndim, dims in var_list: + if ndim == 0: + line = f"{name} {dtype_str} NDIM {ndim}\n" + else: + dims_str = " ".join( + str(d) for d in dims[::-1] + ) # Reverse for Fortran order + line = f"{name} {dtype_str} NDIM {ndim} {dims_str}\n" + writer.write_text(line, lentxt) + + # Write binary data for each variable + for name, dtype_str, ndim, dims in var_list: + if name not in data_dict: + raise ValueError(f"Required variable '{name}' not found in grid file") + + value = data_dict[name] + + if verbose: + if ndim == 0: + print(f" Writing {name} = {value}") + else: + if hasattr(value, "min"): + print( + f" Writing {name}: min = {value.min()} max = {value.max()}" + ) + else: + print(f" Writing {name}") + + # Write scalar or array data + if ndim == 0: + # Scalar value + if dtype_str == "INTEGER": + writer.write_integer(int(value)) + elif dtype_str in ("DOUBLE", "SINGLE"): + writer.write_real(float(value)) + else: + # Array data + arr = np.asarray(value) + if dtype_str == "INTEGER": + arr = arr.astype(np.int32) + elif dtype_str == "DOUBLE": + arr = arr.astype(np.float64) + elif dtype_str == "SINGLE": + arr = arr.astype(np.float32) + + # Write array in column-major (Fortran) order + writer.write_record(arr.flatten(order="F"), dtype=arr.dtype) + + if verbose: + print(f"Successfully wrote {filename}") diff --git a/flopy/utils/binaryfile/__init__.py b/flopy/utils/binaryfile/__init__.py index 393f1aae9..c1f40d40e 100644 --- a/flopy/utils/binaryfile/__init__.py +++ b/flopy/utils/binaryfile/__init__.py @@ -25,6 +25,29 @@ from ..gridutil import get_lni +def _pad_text_to_16(text): + """ + Pad text to exactly 16 bytes, left-justified (MODFLOW standard). + + Parameters + ---------- + text : str or bytes + Text to pad + + Returns + ------- + bytes + 16-byte text field with spaces on the right + """ + if isinstance(text, str): + text = text.encode("ascii") + if len(text) > 16: + return text[:16] + elif len(text) < 16: + return text + b" " * (16 - len(text)) + return text + + class BinaryHeader(Header): """ Represents data headers for binary output files. @@ -622,6 +645,433 @@ def __init__( self.header_dtype = BinaryHeader.set_dtype(bintype="Head", precision=precision) super().__init__(filename, precision, verbose, **kwargs) + @classmethod + def write( + cls, + filename, + data, + nrow=None, + ncol=None, + nlay=None, + ncpl=None, + nnodes=None, + text="head", + precision="double", + totim=None, + pertim=None, + kstpkper=None, + verbose=False, + ): + """ + Write head data directly to a binary file. + + This classmethod writes head data arrays to a binary head file and returns + a HeadFile instance with the file open. + + Parameters + ---------- + filename : str or PathLike + Path for the output file + data : ndarray, dict, or list + Head data in one of three formats: + + 1. Array with time dimension: + - Shape (ntimes, nlay, nrow, ncol) or (ntimes, nrow, ncol) + - First dimension is time, creates one record per time step + - Requires kstpkper parameter or uses sequential (1,1), (1,2), (1,3), ... + + 2. Dict mapping (kstp, kper) tuples to arrays: + {(kstp, kper): array, ...} + - Arrays should be 2D (nrow, ncol) or 3D (nlay, nrow, ncol) + + 3. List of dicts with full metadata: + [{'data': array, 'kstp': int, 'kper': int, + 'totim': float, 'pertim': float, 'ilay': int (optional)}, ...] + - Each dict represents one layer at one timestep + - ilay defaults to 1 if not provided + + nrow : int, optional + Number of rows (DIS only). Inferred if None. + ncol : int, optional + Number of columns (DIS only). Inferred if None. + nlay : int, optional + Number of layers (DIS, DISV). Inferred if None. + ncpl : int, optional + Number of cells per layer (DISV only). Inferred if None. + nnodes : int, optional + Total number of nodes (DISU only). Inferred if None. + text : str, default "head" + Text identifier for the head data (will be padded to 16 characters) + precision : str, default "double" + Precision of floating point data: 'single' or 'double' + totim : float, dict, or list, optional + Total time values. Can be: + - float/int: Use same value for all records (only valid with + single timestep) + - dict: Maps (kstp, kper) to totim values + - list: Should match order of data + - None: Defaults to sequential counter (1.0, 2.0, 3.0, ...) + pertim : float, dict, or list, optional + Period time values. Can be: + - float/int: Use same value for all records (only valid with + single timestep) + - dict: Maps (kstp, kper) to pertim values + - list: Should match order of data + - None: Defaults to totim + kstpkper : list of tuples, optional + Time step/period mapping for array data with time dimension. + List of (kstp, kper) tuples, one per time step. + If None, uses sequential numbering: (1,1), (1,2), (1,3), ... + Ignored if data is dict or list format. + verbose : bool, default False + Print progress messages + + Returns + ------- + HeadFile + Instance with the written file open + + Notes + ----- + Discretization types are determined by which parameters are provided: + - DIS (structured): nlay, nrow, ncol + - DISV (vertically staggered): nlay, ncpl + - DISU (unstructured): nnodes + + Examples + -------- + >>> import numpy as np + >>> from flopy.utils import HeadFile + >>> + >>> # Write head data for two time steps + >>> head1 = np.random.rand(3, 10, 20) # 3 layers, 10 rows, 20 cols + >>> head2 = np.random.rand(3, 10, 20) + >>> data = { + ... (1, 1): head1, + ... (1, 2): head2, + ... } + >>> hds = HeadFile.write('output.hds', data) + >>> hds.get_times() + [1.0, 2.0] + >>> + >>> # Or with explicit time values + >>> data_with_times = [ + ... {'data': head1, 'kstp': 1, 'kper': 1, 'totim': 10.0, 'pertim': 10.0}, + ... {'data': head2, 'kstp': 1, 'kper': 2, 'totim': 20.0, 'pertim': 10.0}, + ... ] + >>> HeadFile.write('output.hds', data_with_times) + """ + # xarray duck typing - extract underlying numpy array + if hasattr(data, "values") and hasattr(data, "dims"): + data = data.values + + # Scalar handling - broadcast to shape + if isinstance(data, (int, float, np.number)): + # Determine shape from grid parameters + if nnodes is not None: + shape = (nnodes,) + elif ncpl is not None and nlay is not None: + shape = (nlay, ncpl) + elif nrow is not None and ncol is not None: + if nlay is not None: + shape = (nlay, nrow, ncol) + else: + shape = (nrow, ncol) + else: + raise ValueError( + "Must provide grid dimensions (nlay/nrow/ncol, ncpl, or nnodes) " + "when using scalar data" + ) + + # Default to single timestep if kstpkper not provided + if kstpkper is None: + kstpkper = [(1, 1)] + + # Create array with time dimension + realtype = np.float32 if precision == "single" else np.float64 + arr = np.full((len(kstpkper),) + shape, data, dtype=realtype) + data = arr + + # List handling - convert list of arrays to array with time dimension + if isinstance(data, list): + if not data: + raise ValueError("Empty data list") + + # Check if it's list of dicts (already supported) or list of arrays + if isinstance(data[0], dict): + # List of dicts - let existing code handle it + pass + else: + # List of arrays - convert to numpy array with time dimension + # First extract .values from any xarray elements + arrays = [] + for elem in data: + if hasattr(elem, "values") and hasattr(elem, "dims"): + arrays.append(elem.values) + else: + arrays.append(elem) + + try: + data = np.array(arrays) + except Exception as e: + raise ValueError(f"Could not convert list to array: {e}") + + # Handle array with time dimension - convert to dict format + if isinstance(data, np.ndarray): + arr = data + ntimes = arr.shape[0] + + # Generate or validate kstpkper + if kstpkper is None: + # Sequential stress periods: (1,1), (1,2), (1,3), ... + kstpkper = [(1, i) for i in range(1, ntimes + 1)] + elif len(kstpkper) != ntimes: + raise ValueError( + f"kstpkper must have {ntimes} entries to match " + f"time dimension, got {len(kstpkper)}" + ) + + # Convert to dict format + data = {} + for i, (kstp, kper) in enumerate(kstpkper): + data[(kstp, kper)] = arr[i] + + # Normalize data to list of record dicts + if isinstance(data, dict): + # Validate that single-value times are only used with single timestep + if len(data) > 1: + if isinstance(totim, (int, float)): + raise ValueError( + "totim cannot be a single value when data has " + "multiple time steps. Use a dict mapping (kstp, " + "kper) to time values, or pass data with a single " + "time step." + ) + if isinstance(pertim, (int, float)): + raise ValueError( + "pertim cannot be a single value when data has " + "multiple time steps. Use a dict mapping (kstp, " + "kper) to time values, or pass data with a single " + "time step." + ) + + records = [] + for i, ((kstp, kper), arr) in enumerate(sorted(data.items()), start=1): + # Handle xarray in dict values + if hasattr(arr, "values") and hasattr(arr, "dims"): + arr = arr.values + arr = np.asarray(arr) + + # Allow 1D arrays for DISV/DISU, require 2D+ for DIS + if arr.ndim == 1: + # 1D array - valid for DISV (ncpl) or DISU (nnodes) + if ncpl is not None or nnodes is not None: + # Single layer for DISV/DISU - reshape to (1, ncells) + nlayers = 1 + ncells = arr.shape[0] + arr = arr.reshape(1, ncells) + else: + raise ValueError( + "1D arrays require ncpl or nnodes parameter. " + "For DIS grids, use 2D (nrow, ncol) or 3D " + "(nlay, nrow, ncol) arrays." + ) + elif arr.ndim == 2: + # 2D array + if ncpl is not None: + # DISV: (nlay, ncpl) - already in right shape + nlayers = arr.shape[0] + ncells = arr.shape[1] + elif nnodes is not None: + # DISU: shouldn't have 2D, but treat as single layer + nlayers = 1 + ncells = arr.size + arr = arr.reshape(1, ncells) + else: + # DIS: single layer (nrow, ncol) - reshape to (1, nrow, ncol) + nlayers = 1 + nrows = arr.shape[0] + ncols = arr.shape[1] + arr = arr.reshape(1, arr.shape[0], arr.shape[1]) + else: + # 3D array (nlay, nrow, ncol) for DIS + nlayers = arr.shape[0] + nrows = arr.shape[1] + ncols = arr.shape[2] + + # Get time values + if totim is None: + tot = float(i) + elif isinstance(totim, dict): + tot = totim.get((kstp, kper), float(i)) + elif isinstance(totim, (int, float)): + tot = float(totim) + else: + raise ValueError("totim must be None, number, or dict") + + if pertim is None: + per = tot + elif isinstance(pertim, dict): + per = pertim.get((kstp, kper), tot) + elif isinstance(pertim, (int, float)): + per = float(pertim) + else: + raise ValueError("pertim must be None, number, or dict") + + # Create one record per layer + for ilay in range(nlayers): + records.append( + { + "data": arr[ilay], + "kstp": kstp, + "kper": kper, + "totim": tot, + "pertim": per, + "ilay": ilay + 1, + } + ) + elif isinstance(data, list): + records = [] + for rec in data: + arr = rec["data"] + # Handle xarray in list elements + if hasattr(arr, "values") and hasattr(arr, "dims"): + arr = arr.values + arr = np.asarray(arr) + if arr.ndim == 1: + raise ValueError("Data arrays must be at least 2D") + + # Handle 2D vs 3D + if arr.ndim == 2: + # Single layer record + records.append( + { + "data": arr, + "kstp": rec["kstp"], + "kper": rec["kper"], + "totim": rec.get("totim", float(rec["kper"])), + "pertim": rec.get( + "pertim", rec.get("totim", float(rec["kper"])) + ), + "ilay": rec.get("ilay", 1), + } + ) + else: + # 3D array - create one record per layer + nlayers = arr.shape[0] + for ilay in range(nlayers): + records.append( + { + "data": arr[ilay], + "kstp": rec["kstp"], + "kper": rec["kper"], + "totim": rec.get("totim", float(rec["kper"])), + "pertim": rec.get( + "pertim", rec.get("totim", float(rec["kper"])) + ), + "ilay": ilay + 1, + } + ) + else: + raise ValueError("data must be dict or list") + + if len(records) == 0: + raise ValueError("No data records provided") + + # Determine discretization type and infer dimensions + if nnodes is not None: + # DISU (unstructured) + dis_type = "DISU" + nlay = 1 + nrow = 1 + ncol = nnodes + expected_shape = (nnodes,) + elif ncpl is not None: + # DISV (vertically staggered) + dis_type = "DISV" + if nlay is None: + nlay = max(rec["ilay"] for rec in records) + nrow = 1 + ncol = ncpl + expected_shape = (ncpl,) + else: + # DIS (structured) - default + dis_type = "DIS" + first_data = records[0]["data"] + if nrow is None: + nrow = first_data.shape[0] + if ncol is None: + ncol = first_data.shape[1] + if nlay is None: + nlay = max(rec["ilay"] for rec in records) + expected_shape = (nrow, ncol) + + # Validate dimensions + for rec in records: + if rec["data"].shape != expected_shape: + raise ValueError( + f"Inconsistent array shapes: expected {expected_shape}, " + f"got {rec['data'].shape}" + ) + + # Set precision dtype + realtype = np.float32 if precision == "single" else np.float64 + + # Pad text to 16 bytes + text = _pad_text_to_16(text) + + # Create temporary file if no filename provided + if filename is None: + # Create a temp file that won't be auto-deleted + fd, filename = tempfile.mkstemp(suffix=".hds") + import os + + os.close(fd) # Close the file descriptor, we'll open it for writing + + # Write binary file + if verbose: + print(f"Writing binary head file: {filename}") + print(f" Text identifier: {text.decode().strip()}") + print(f" Precision: {precision}") + print(f" Discretization: {dis_type}") + if dis_type == "DIS": + print(f" Dimensions: {nlay} layers, {nrow} rows, {ncol} columns") + elif dis_type == "DISV": + print(f" Dimensions: {nlay} layers, {ncpl} cells per layer") + elif dis_type == "DISU": + print(f" Dimensions: {nnodes} nodes (unstructured)") + print(f" Number of records: {len(records)}") + + # Use BinaryHeader.create() and write like Util2d.write_bin() does + with open(filename, "wb") as f: + for rec in records: + # Create header using BinaryHeader.create() + header = BinaryHeader.create( + bintype="Head", + precision=precision, + text=text.decode().strip(), + nrow=nrow, + ncol=ncol, + ilay=rec["ilay"], + pertim=rec["pertim"], + totim=rec["totim"], + kstp=rec["kstp"], + kper=rec["kper"], + ) + + # Write header and data + header.tofile(f) + rec["data"].astype(realtype).tofile(f) + + # Explicitly flush and sync to ensure data is written + f.flush() + import os + + os.fsync(f.fileno()) + + # Return an instance with the file open + return cls(filename, precision=precision, verbose=verbose) + def reverse(self, filename: Optional[PathLike] = None): """ Reverse the time order of the currently loaded binary head file. If a head @@ -711,6 +1161,138 @@ def reverse_header(header): move(target, filename) super().__init__(filename, self.precision, self.verbose) + def export( + self, + filename: Union[str, PathLike], + kstpkper: Optional[list] = None, + **kwargs, + ): + """ + Export head data to a binary file. + + Parameters + ---------- + filename : str or PathLike + Path to output head file + kstpkper : list of tuples, optional + Subset of (kstp, kper) tuples to export. If None, exports all time steps. + **kwargs + Additional keyword arguments: + - text : str, identifier for head data (default uses current file's text) + - precision : str, 'single' or 'double' (default is the file's precision) + - verbose : bool, print progress messages + + Examples + -------- + >>> hds = HeadFile('input.hds') + >>> # Export all time steps + >>> hds.export('output.hds') + >>> # Export specific time steps + >>> hds.export('output.hds', kstpkper=[(1, 0), (1, 1)]) + """ + + # Determine which time steps to write + if kstpkper is None: + kstpkper = self.kstpkper + + # Set defaults from current file if not provided + text = kwargs.get("text") + if text is None: + text = self.recordarray["text"][0].decode().strip() + + precision = kwargs.get("precision", self.precision) + verbose = kwargs.get("verbose", False) + + # Set precision + realtype = np.float32 if precision == "single" else np.float64 + + # Pad text to 16 bytes + text_bytes = _pad_text_to_16(text) + + # Pre-allocate header dtype outside loop for better performance + dt = np.dtype( + [ + ("kstp", np.int32), + ("kper", np.int32), + ("pertim", realtype), + ("totim", realtype), + ("text", "S16"), + ("ncol", np.int32), + ("nrow", np.int32), + ("ilay", np.int32), + ] + ) + + # Sort kstpkper upfront for correct output order + sorted_kstpkper = sorted(kstpkper, key=lambda x: (int(x[0]), int(x[1]))) + + if verbose: + print(f"Writing binary head file: {filename}") + print(f" Text identifier: {text_bytes.decode().strip()}") + print(f" Precision: {precision}") + print(f" Number of time steps: {len(sorted_kstpkper)}") + + # Write the file + with open(filename, "wb") as f: + for ksp in sorted_kstpkper: + try: + # Convert numpy int32 to Python int if needed + kstp = int(ksp[0]) + kper = int(ksp[1]) + + # Find the totim for this kstpkper + mask = (self.recordarray["kstp"] == kstp) & ( + self.recordarray["kper"] == kper + ) + matching_records = self.recordarray[mask] + if len(matching_records) == 0: + if verbose: + print(f"Warning: No records found for {ksp}") + continue + + record = matching_records[0] + totim = float(record["totim"]) + pertim = float(record["pertim"]) + + # Get data using totim (works for multi-layer files) + head = np.asarray(self.get_data(totim=totim)) + + # Handle both 3D (nlay, nrow, ncol) and 2D (nrow, ncol) arrays + if head.ndim == 2: + head = head.reshape(1, head.shape[0], head.shape[1]) + + nlay, nrow, ncol = head.shape + + if verbose: + print(f" Writing kstp={kstp}, kper={kper}, totim={totim}") + print(f" Shape: {nlay} layers x {nrow} rows x {ncol} cols") + + # Write one record per layer + for ilay in range(nlay): + h = np.array( + ( + kstp, + kper, + pertim, + totim, + text_bytes, + ncol, + nrow, + ilay + 1, + ), + dtype=dt, + ) + h.tofile(f) + head[ilay].astype(realtype).tofile(f) + + except Exception as e: + if verbose: + print(f"Warning: Could not read data for {ksp}: {e}") + continue + + if verbose: + print(f"Successfully wrote {filename}") + class UcnFile(BinaryLayerFile): """ @@ -1094,6 +1676,672 @@ def __enter__(self): def __exit__(self, *exc): self.close() + @classmethod + def write( + cls, + filename, + data, + text="FLOW-JA-FACE", + imeth=1, + precision="double", + delt=1.0, + pertim=None, + totim=None, + nlay=None, + nrow=None, + ncol=None, + ncpl=None, + nnodes=None, + kstpkper=None, + verbose=False, + ): + """ + Write budget data directly to a binary file. + + This classmethod writes budget data arrays to a binary cell budget + file and returns a CellBudgetFile instance with the file open. + + Parameters + ---------- + filename : str or PathLike + Path for the output file + data : ndarray, dict, or list + Budget data in one of three formats: + + 1. Array with time dimension: + - Shape (ntimes, nlay, nrow, ncol) or (ntimes, ...) for grid data + - First dimension is time, creates one record per time step + - Requires kstpkper parameter or uses sequential (1,1), (1,2), (1,3), ... + + 2. Dict mapping (kstp, kper) tuples to arrays: + {(kstp, kper): array, ...} + - For imeth=1: arrays should be 1D (flattened cell-by-cell data) + + 3. List of dicts with full metadata: + [{'data': array, 'kstp': int, 'kper': int, 'text': str (optional), + 'totim': float (optional), 'pertim': float (optional), + 'delt': float (optional), 'imeth': int (optional)}, ...] + - Allows per-record customization of all parameters + + text : str or list, default "FLOW-JA-FACE" + Budget text identifier (will be padded to 16 characters). + If list, must match length of data records. + imeth : int, default 1 + Method code: + - 1: Full 3D array (most common) + - 6: List-based budget (for MF6 advanced packages) + precision : str, default "double" + Precision of floating point data: 'single' or 'double' + delt : float or dict, default 1.0 + Time step length. Can be: + - float/int: Use same value for all records + - dict: Maps (kstp, kper) to delt values + pertim : float or dict, optional + Period time. Can be: + - float/int: Use same value for all records (only valid with + single timestep) + - dict: Maps (kstp, kper) to pertim values + - None: Defaults to totim + totim : float or dict, optional + Total simulation time. Can be: + - float/int: Use same value for all records (only valid with + single timestep) + - dict: Maps (kstp, kper) to totim values + - None: Defaults to sequential counter (1.0, 2.0, 3.0, ...) + nlay : int, optional + Number of layers (DIS, DISV). Inferred if None. + nrow : int, optional + Number of rows (DIS only). Inferred if None. + ncol : int, optional + Number of columns (DIS only). Inferred if None. + ncpl : int, optional + Number of cells per layer (DISV only). Inferred if None. + nnodes : int, optional + Total number of nodes (DISU only). Inferred if None. + kstpkper : list of tuples, optional + Time step/period mapping for array data with time dimension. + List of (kstp, kper) tuples, one per time step. + If None, uses sequential numbering: (1,1), (1,2), (1,3), ... + Ignored if data is dict or list format. + verbose : bool, default False + Print progress messages + + Returns + ------- + CellBudgetFile + Instance with the written file open + + Notes + ----- + Discretization types are determined by which parameters are provided: + - DIS (structured): nlay, nrow, ncol + - DISV (vertically staggered): nlay, ncpl + - DISU (unstructured): nnodes + + If no dimensions are provided, they will be inferred from array shapes. + + Examples + -------- + >>> import numpy as np + >>> from flopy.utils import CellBudgetFile + >>> + >>> # Write budget data for two time steps + >>> flow1 = np.random.rand(6000) + >>> flow2 = np.random.rand(6000) + >>> data = { + ... (1, 1): flow1, + ... (1, 2): flow2, + ... } + >>> cbb = CellBudgetFile.write( + ... 'output.cbc', data, text='FLOW-JA-FACE', nlay=3, nrow=10, ncol=20 + ... ) + >>> + >>> # Or with list of records (dicts) + >>> CellBudgetFile.write('output.cbc', [ + ... {'data': flow1, 'kstp': 1, 'kper': 1, 'totim': 10.0, + ... 'text': 'FLOW-JA-FACE'}, + ... {'data': flow2, 'kstp': 1, 'kper': 2, 'totim': 20.0, + ... 'text': 'FLOW-JA-FACE'}, + ... ], nlay=3, nrow=10, ncol=20) + """ + # xarray duck typing - extract underlying numpy array + if hasattr(data, "values") and hasattr(data, "dims"): + data = data.values + + # Scalar handling - broadcast to shape + if isinstance(data, (int, float, np.number)): + # Determine shape from grid parameters + if nnodes is not None: + shape = (nnodes,) + elif ncpl is not None and nlay is not None: + shape = (nlay, ncpl) + elif nrow is not None and ncol is not None: + if nlay is not None: + shape = (nlay, nrow, ncol) + else: + shape = (nrow, ncol) + else: + raise ValueError( + "Must provide grid dimensions (nlay/nrow/ncol, ncpl, or nnodes) " + "when using scalar data" + ) + + # Default to single timestep if kstpkper not provided + if kstpkper is None: + kstpkper = [(1, 1)] + + # Create array with time dimension + realtype = np.float32 if precision == "single" else np.float64 + arr = np.full((len(kstpkper),) + shape, data, dtype=realtype) + data = arr + + # List handling - convert list of arrays to array with time dimension + if isinstance(data, list): + if not data: + raise ValueError("Empty data list") + + # Check if it's list of dicts (already supported) or list of arrays + if isinstance(data[0], dict): + # List of dicts - let existing code handle it + pass + else: + # List of arrays - convert to numpy array with time dimension + # First extract .values from any xarray elements + arrays = [] + for elem in data: + if hasattr(elem, "values") and hasattr(elem, "dims"): + arrays.append(elem.values) + else: + arrays.append(elem) + + try: + data = np.array(arrays) + except Exception as e: + raise ValueError(f"Could not convert list to array: {e}") + + # Handle array with time dimension - convert to dict format + if isinstance(data, np.ndarray): + arr = data + ntimes = arr.shape[0] + + # Generate or validate kstpkper + if kstpkper is None: + # Sequential stress periods: (1,1), (1,2), (1,3), ... + kstpkper = [(1, i) for i in range(1, ntimes + 1)] + elif len(kstpkper) != ntimes: + raise ValueError( + f"kstpkper must have {ntimes} entries to match " + f"time dimension, got {len(kstpkper)}" + ) + + # Convert to dict format + data = {} + for i, (kstp, kper) in enumerate(kstpkper): + data[(kstp, kper)] = arr[i] + + # Normalize data to list of record dicts + if isinstance(data, dict): + # Validate that single-value times are only used with single timestep + if len(data) > 1: + if isinstance(totim, (int, float)): + raise ValueError( + "totim cannot be a single value when data has " + "multiple time steps. Use a dict mapping (kstp, " + "kper) to time values, or pass data with a single " + "time step." + ) + if isinstance(pertim, (int, float)): + raise ValueError( + "pertim cannot be a single value when data has " + "multiple time steps. Use a dict mapping (kstp, " + "kper) to time values, or pass data with a single " + "time step." + ) + if isinstance(delt, (int, float)) and delt != 1.0: + raise ValueError( + "delt cannot be a single non-default value when " + "data has multiple time steps. Use a dict mapping " + "(kstp, kper) to delt values, or pass data with a " + "single time step." + ) + + records = [] + inferred_shape = None # Track shape from first shaped array + + for i, ((kstp, kper), arr) in enumerate(sorted(data.items()), start=1): + # Handle xarray in dict values + if hasattr(arr, "values") and hasattr(arr, "dims"): + arr = arr.values + arr = np.asarray(arr) + # Capture shape before flattening for dimension inference + if arr.ndim in (2, 3) and inferred_shape is None: + inferred_shape = arr.shape + arr = arr.flatten() + + # Get time values + if totim is None: + tot = float(i) + elif isinstance(totim, dict): + tot = totim.get((kstp, kper), float(i)) + elif isinstance(totim, (int, float)): + tot = float(totim) + else: + raise ValueError("totim must be None, number, or dict") + + if pertim is None: + per = tot + elif isinstance(pertim, dict): + per = pertim.get((kstp, kper), tot) + elif isinstance(pertim, (int, float)): + per = float(pertim) + else: + raise ValueError("pertim must be None, number, or dict") + + if isinstance(delt, dict): + dt = delt.get((kstp, kper), 1.0) + elif isinstance(delt, (int, float)): + dt = float(delt) + else: + raise ValueError("delt must be number or dict") + + # Get text for this record + if isinstance(text, list): + rec_text = text[len(records)] + else: + rec_text = text + + records.append( + { + "data": arr, + "kstp": kstp, + "kper": kper, + "totim": tot, + "pertim": per, + "delt": dt, + "text": rec_text, + "imeth": imeth, + } + ) + + # Use inferred shape if dimensions not provided + if inferred_shape is not None: + # Determine if user is trying to use DISV (nlay + ncpl) + if ncpl is not None or ( + nlay is not None and nrow is None and ncol is None + ): + # DISV mode + if len(inferred_shape) == 2: + # 2D array: (nlay, ncpl) + nlay = nlay or inferred_shape[0] + ncpl = ncpl or inferred_shape[1] + elif len(inferred_shape) == 1: + # 1D array: assume nlay=1, infer ncpl + nlay = nlay or 1 + ncpl = ncpl or inferred_shape[0] + elif nnodes is None: + # DIS mode (default) + if nlay is None or nrow is None or ncol is None: + if len(inferred_shape) == 3: + # 3D array: (nlay, nrow, ncol) + nlay = nlay or inferred_shape[0] + nrow = nrow or inferred_shape[1] + ncol = ncol or inferred_shape[2] + elif len(inferred_shape) == 2: + # 2D array: (nrow, ncol), assume nlay=1 + nlay = nlay or 1 + nrow = nrow or inferred_shape[0] + ncol = ncol or inferred_shape[1] + + elif isinstance(data, list): + records = [] + inferred_shape = None # Track shape from first shaped array + + for rec in data: + rec_imeth = rec.get("imeth", imeth) + arr = rec["data"] + # Handle xarray in list elements + if hasattr(arr, "values") and hasattr(arr, "dims"): + arr = arr.values + arr = np.asarray(arr) + + # For imeth=1, capture shape before flattening for dimension inference + if rec_imeth == 1: + rec_text = rec.get("text", text).strip() + # Only infer from non-FLOW-JA-FACE data (connection-based) + if rec_text != "FLOW-JA-FACE" and arr.ndim in (2, 3): + if inferred_shape is None: + inferred_shape = arr.shape + arr = arr.flatten() + + records.append( + { + "data": arr, + "kstp": rec["kstp"], + "kper": rec["kper"], + "totim": rec.get("totim", float(rec["kper"])), + "pertim": rec.get( + "pertim", rec.get("totim", float(rec["kper"])) + ), + "delt": rec.get("delt", 1.0), + "text": rec.get("text", text), + "imeth": rec_imeth, + "modelnam": rec.get("modelnam", ""), + "paknam": rec.get("paknam", ""), + "modelnam2": rec.get("modelnam2", ""), + "paknam2": rec.get("paknam2", ""), + } + ) + + # Use inferred shape if dimensions not provided + if inferred_shape is not None: + # Determine if user is trying to use DISV (nlay + ncpl) + if ncpl is not None or ( + nlay is not None and nrow is None and ncol is None + ): + # DISV mode + if len(inferred_shape) == 2: + # 2D array: (nlay, ncpl) + nlay = nlay or inferred_shape[0] + ncpl = ncpl or inferred_shape[1] + elif len(inferred_shape) == 1: + # 1D array: assume nlay=1, infer ncpl + nlay = nlay or 1 + ncpl = ncpl or inferred_shape[0] + elif nnodes is None: + # DIS mode (default) + if nlay is None or nrow is None or ncol is None: + if len(inferred_shape) == 3: + # 3D array: (nlay, nrow, ncol) + nlay = nlay or inferred_shape[0] + nrow = nrow or inferred_shape[1] + ncol = ncol or inferred_shape[2] + elif len(inferred_shape) == 2: + # 2D array: (nrow, ncol), assume nlay=1 + nlay = nlay or 1 + nrow = nrow or inferred_shape[0] + ncol = ncol or inferred_shape[1] + else: + raise ValueError("data must be dict or list") + + if len(records) == 0: + raise ValueError("No data records provided") + + # Check supported imeth values + for rec in records: + if rec["imeth"] not in (1, 6): + raise NotImplementedError( + f"Only imeth=1 and imeth=6 are currently supported, " + f"got imeth={rec['imeth']}" + ) + + # Determine discretization type and calculate nnodes + # DIS: nlay, nrow, ncol + # DISV: nlay, ncpl + # DISU: nnodes + first_imeth = records[0]["imeth"] + + # Determine discretization type + if nnodes is not None: + # DISU (unstructured) + dis_type = "DISU" + # For DISU header: (nnodes, 1, -1) + nlay = 1 # -nlay = -1 in header + nrow = 1 + ncol = nnodes + elif ncpl is not None: + # DISV (vertically staggered) + dis_type = "DISV" + if nlay is None: + nlay = 1 + # Calculate nnodes from nlay * ncpl + nnodes = nlay * ncpl + # For DISV, nrow is always 1 in the header + nrow = 1 + # ncol is set to ncpl for header writing + ncol = ncpl + else: + # DIS (structured) - default + dis_type = "DIS" + + if first_imeth == 1: + first_data = records[0]["data"] + first_text = records[0].get("text", text).strip() + + # FLOW-JA-FACE is connection-based, not node-based + if first_text == "FLOW-JA-FACE": + # For FLOW-JA-FACE, dimensions must be provided + if nlay is None or nrow is None or ncol is None: + raise ValueError( + "For FLOW-JA-FACE data, dimensions must be provided" + ) + nnodes = nlay * nrow * ncol + else: + # For regular node-based data, infer from data size + data_size = len(first_data) + + # If all dimensions provided, validate them + if nlay is not None and nrow is not None and ncol is not None: + expected = nlay * nrow * ncol + if expected != data_size: + raise ValueError( + f"Dimensions don't match: nlay={nlay}, nrow={nrow}, " + f"ncol={ncol} gives {expected} nodes but " + f"data has {data_size}" + ) + nnodes = data_size + else: + # Set defaults to make it work for common cases + nnodes = data_size + if nlay is None: + nlay = 1 + if nrow is None: + nrow = 1 + if ncol is None: + ncol = nnodes + else: + # For imeth=6, use defaults if not provided + if nlay is None: + nlay = 1 + if nrow is None: + nrow = 1 + if ncol is None: + ncol = 1 + nnodes = nlay * nrow * ncol + + # Set precision dtype + realtype = np.float32 if precision == "single" else np.float64 + + # Prepare dtypes for headers + h1dt = np.dtype( + [ + ("kstp", np.int32), + ("kper", np.int32), + ("text", "S16"), + ("ncol", np.int32), + ("nrow", np.int32), + ("nlay", np.int32), + ] + ) + h2dt = np.dtype( + [ + ("imeth", np.int32), + ("delt", realtype), + ("pertim", realtype), + ("totim", realtype), + ] + ) + + # Helper function to pad text (using module-level helper) + pad_text = _pad_text_to_16 + + # Create temporary file if no filename provided + if filename is None: + fd, filename = tempfile.mkstemp(suffix=".cbc") + import os + + os.close(fd) + + # Write binary file + if verbose: + print(f"Writing binary budget file: {filename}") + print(f" Precision: {precision}") + print(f" Discretization: {dis_type}") + if dis_type == "DIS": + print(f" Dimensions: {nlay} layers, {nrow} rows, {ncol} columns") + elif dis_type == "DISV": + print(f" Dimensions: {nlay} layers, {ncpl} cells per layer") + elif dis_type == "DISU": + print(f" Dimensions: {nnodes} nodes (unstructured)") + print(f" Number of records: {len(records)}") + + with open(filename, "wb") as f: + for rec in records: + # Pad text + text_bytes = pad_text(rec["text"]) + rec_imeth = rec["imeth"] + + if rec_imeth == 1: + # Write header 1 for full 3D array + # For FLOW-JA-FACE (connection-based): use (data_size, 1, -1) + # For other records (node-based): use (ncol, nrow, -nlay) + rec_text = rec["text"].strip() + if rec_text == "FLOW-JA-FACE": + # Connection-based data - use actual data size + h1 = np.array( + ( + rec["kstp"], + rec["kper"], + text_bytes, + len(rec["data"]), + 1, + -1, + ), + dtype=h1dt, + ) + else: + # Node-based data - use grid dimensions + h1 = np.array( + (rec["kstp"], rec["kper"], text_bytes, ncol, nrow, -nlay), + dtype=h1dt, + ) + h1.tofile(f) + + # Write header 2 + h2 = np.array( + ( + rec_imeth, + realtype(rec["delt"]), + realtype(rec["pertim"]), + realtype(rec["totim"]), + ), + dtype=h2dt, + ) + h2.tofile(f) + + # Write data + arr = rec["data"].astype(realtype) + # Skip size validation for FLOW-JA-FACE (connection-based) + rec_text = rec["text"].strip() + if rec_text != "FLOW-JA-FACE" and len(arr) != nnodes: + raise ValueError( + f"Inconsistent data sizes: expected {nnodes}, " + f"got {len(arr)}" + ) + arr.tofile(f) + + elif rec_imeth == 6: + # Write header 1 for list-based data + # For imeth=6, use ncol=1, nrow=1, nlay=-1 + h1 = np.array( + (rec["kstp"], rec["kper"], text_bytes, 1, 1, -1), + dtype=h1dt, + ) + h1.tofile(f) + + # Write header 2 + h2 = np.array( + ( + rec_imeth, + realtype(rec["delt"]), + realtype(rec["pertim"]), + realtype(rec["totim"]), + ), + dtype=h2dt, + ) + h2.tofile(f) + + # Write modelnam, paknam, modelnam2, paknam2 + # Use same defaults as binary_util.py write_budget function + defaults = { + "modelnam": "", + "paknam": "", + "modelnam2": "", + "paknam2": "", + } + for name in ["modelnam", "paknam", "modelnam2", "paknam2"]: + name_bytes = pad_text(rec.get(name, defaults[name])) + f.write(name_bytes) + + # Get data and determine columns + arr = rec["data"] + if not isinstance(arr, np.ndarray) or arr.dtype.names is None: + raise ValueError( + "For imeth=6, data must be a structured numpy array " + "with named fields" + ) + + # Calculate ndat (number of floating point columns) + # Expecting fields like: ID1, ID2, FLOW, [aux1, aux2, ...] + colnames = arr.dtype.names + ndat = len(colnames) - 2 # Exclude ID1, ID2 + + # Write ndat + np.array([ndat], dtype=np.int32).tofile(f) + + # Write auxiliary column names (if any) + naux = ndat - 1 # Exclude FLOW + if naux > 0: + aux_names = colnames[3:] # Skip ID1, ID2, FLOW + for aux_name in aux_names: + # Auxiliary names must be space-padded (not null-padded) + # to match MODFLOW 6 expectations + aux_str = ( + aux_name + if isinstance(aux_name, str) + else aux_name.decode("ascii") + ) + aux_bytes = f"{aux_str:16}".encode("ascii") + f.write(aux_bytes) + + # Write nlist + nlist = arr.shape[0] + np.array([nlist], dtype=np.int32).tofile(f) + + # Write data - need to ensure correct dtypes + # Convert to proper precision for floating point fields + dt_list = [] + for i, name in enumerate(colnames): + if i < 2: # ID1, ID2 + dt_list.append((name, np.int32)) + else: # FLOW and auxiliary variables + dt_list.append((name, realtype)) + + # Convert data to correct dtype + arr_typed = np.empty(nlist, dtype=np.dtype(dt_list)) + for name in colnames: + arr_typed[name] = arr[name] + + arr_typed.tofile(f) + + # Explicitly flush to ensure data is written + f.flush() + import os + + os.fsync(f.fileno()) + + # Return an instance with the file open + return cls(filename, precision=precision, verbose=verbose) + def __len__(self): """ Return the number of records (headers) in the file. @@ -2351,6 +3599,243 @@ def get_residual(self, totim, scaled=False): return residual + def export( + self, + filename: Union[str, PathLike], + kstpkper: Optional[list] = None, + text: Optional[Union[str, list]] = None, + **kwargs, + ): + """ + Export budget data to a binary file. + + Parameters + ---------- + filename : str or PathLike + Path to output budget file + kstpkper : list of tuples, optional + Subset of (kstp, kper) tuples to export. If None, exports all time steps. + text : str or list of str, optional + Budget term(s) to export. If None, exports all terms. + Examples: 'FLOW-JA-FACE', ['STORAGE', 'CONSTANT HEAD'] + **kwargs + Additional keyword arguments: + - precision : str, 'single' or 'double' (default is the file's precision) + - verbose : bool, print progress messages + + Examples + -------- + >>> cbc = CellBudgetFile('input.cbc') + >>> # Export all data + >>> cbc.export('output.cbc') + >>> # Export specific time steps + >>> cbc.export('output.cbc', kstpkper=[(1, 0), (1, 1)]) + >>> # Export specific budget terms + >>> cbc.export('output.cbc', text='FLOW-JA-FACE') + >>> # Export specific terms and time steps + >>> cbc.export( + ... 'output.cbc', kstpkper=[(1, 0)], text=['STORAGE', 'FLOW-JA-FACE'] + ... ) + """ + + if kstpkper is None: + kstpkper = self.kstpkper + + if text is None: + textlist = self.textlist + elif isinstance(text, str): + textlist = [_pad_text_to_16(text)] + else: + textlist = [_pad_text_to_16(t) for t in text] + + verbose = kwargs.get("verbose", False) + precision = kwargs.get("precision", self.precision) + realtype = np.float32 if precision == "single" else np.float64 + + # header dtypes + h1dt = np.dtype( + [ + ("kstp", np.int32), + ("kper", np.int32), + ("text", "S16"), + ("ncol", np.int32), + ("nrow", np.int32), + ("nlay", np.int32), + ] + ) + h2dt = np.dtype( + [ + ("imeth", np.int32), + ("delt", realtype), + ("pertim", realtype), + ("totim", realtype), + ] + ) + + sorted_kstpkper = sorted(kstpkper, key=lambda x: (int(x[0]), int(x[1]))) + + text_mapping = {} + for txt in textlist: + txt_str = txt.decode().strip() if isinstance(txt, bytes) else txt.strip() + txt_upper = txt_str.upper() + matching_records = [ + t for t in self.textlist if txt_upper in t.decode().strip().upper() + ] + text_mapping[txt] = matching_records + + nlay = self.nlay if self.nlay > 0 else None + nrow = self.nrow if self.nrow > 0 else None + ncol = self.ncol if self.ncol > 0 else None + + if verbose: + print(f"Writing binary budget file: {filename}") + print(f" Precision: {precision}") + if nlay is not None and nrow is not None and ncol is not None: + print(f" Grid shape: {nlay} layers x {nrow} rows x {ncol} cols") + else: + print(" Grid shape not specified") + + with open(filename, "wb") as f: + for ksp in sorted_kstpkper: + kstp = int(ksp[0]) + kper = int(ksp[1]) + + # get_data() expects 0-based but kstpkper is 1-based + ksp_0based = (kstp - 1, kper - 1) + + if verbose: + print(f"\n Writing kstp={kstp}, kper={kper}") + + for txt in textlist: + for file_txt in text_mapping[txt]: + data_list = self.get_data(kstpkper=ksp_0based, text=file_txt) + if not data_list: + continue + data = data_list[0] + mask = ( + (self.recordarray["kstp"] == kstp) + & (self.recordarray["kper"] == kper) + & (self.recordarray["text"] == file_txt) + ) + records = self.recordarray[mask] + if len(records) == 0: + continue + + record = records[0] + + if isinstance(data, np.recarray): + imeth = 6 # list + else: + imeth = 1 # array + + text_str = file_txt.decode().strip() + text_bytes = _pad_text_to_16(text_str) + delt = float(record["delt"]) + pertim = float(record["pertim"]) + totim = float(record["totim"]) + + if verbose: + print(f" Writing {text_str}: imeth={imeth}") + + is_flowja = text_str.upper() == "FLOW-JA-FACE" + + # Determine dimensions based on data type + if is_flowja and imeth in [0, 1]: + # keep FLOW-JA-FACE flat/size NJA + nja = np.asarray(data).size + ndim1, ndim2, ndim3 = nja, 1, -1 + else: + # Regular budget term: use grid dimensions + if nlay is None or nrow is None or ncol is None: + raise ValueError( + f"Grid dimensions (nlay, nrow, ncol) " + f"required for non-FLOW-JA-FACE " + f"budget term '{text_str}'. " + f"Provided: nlay={nlay}, nrow={nrow}, " + f"ncol={ncol}" + ) + # negative nlay -> compact format + ndim1, ndim2, ndim3 = ncol, nrow, -nlay + + header1 = np.array( + [(kstp, kper, text_bytes, ndim1, ndim2, ndim3)], + dtype=h1dt, + ) + header1.tofile(f) + + header2 = np.array([(imeth, delt, pertim, totim)], dtype=h2dt) + header2.tofile(f) + + if imeth in [0, 1]: + arr = np.asarray(data, dtype=realtype) + # keep FLOW-JA-FACE flat/size NJA. + # reshape other variables to grid. + if is_flowja and arr.ndim != 1: + arr = arr.flatten() + elif arr.ndim == 1: + arr = arr.reshape(nlay, nrow, ncol) + + arr.tofile(f) + + elif imeth == 6: + # write model and package names + modelnam = record["modelnam"].decode().strip() + paknam = record["paknam"].decode().strip() + modelnam2 = record["modelnam2"].decode().strip() + paknam2 = record["paknam2"].decode().strip() + + for name in [modelnam, paknam, modelnam2, paknam2]: + name_bytes = _pad_text_to_16(name) + f.write(name_bytes) + + # write naux and aux var names + standard_fields = {"node", "node2", "q"} + auxtxt = [ + name + for name in data.dtype.names + if name not in standard_fields + ] + naux = len(auxtxt) + np.array([naux + 1], dtype=np.int32).tofile(f) + for auxname in auxtxt: + f.write(_pad_text_to_16(auxname)) + + if not (isinstance(data, np.ndarray) and data.dtype.names): + raise ValueError( + "For imeth=6, data must be a numpy recarray " + "with fields: node, node2, q, and optional " + "auxiliary fields" + ) + + # nrite nlist and list data + nlist = len(data) + np.array([nlist], dtype=np.int32).tofile(f) + dt_list = [ + ("node", np.int32), + ("node2", np.int32), + ("q", realtype), + ] + for auxname in auxtxt: + dt_list.append((auxname, realtype)) + + output_dt = np.dtype(dt_list) + output_data = np.zeros(nlist, dtype=output_dt) + for field in output_dt.names: + if field in data.dtype.names: + output_data[field] = data[field].astype( + output_dt[field] + ) + + output_data.tofile(f) + + else: + raise NotImplementedError( + "Expected imeth=1 (array) or imeth=6 (list)" + ) + + if verbose: + print(f"\nSuccessfully wrote {filename}") + def close(self): """ Close the file handle diff --git a/flopy/utils/utils_def.py b/flopy/utils/utils_def.py index 669674faa..d0779c32f 100644 --- a/flopy/utils/utils_def.py +++ b/flopy/utils/utils_def.py @@ -101,6 +101,36 @@ def read_record(self, count, dtype=None): def _read_values(self, dtype, count): return np.fromfile(self.file, dtype, count) + def write_text(self, text, nchar=20): + """Write text to binary file, padding or truncating to nchar bytes.""" + if isinstance(text, str): + text = text.encode("ascii") + if len(text) > nchar: + text = text[:nchar] + elif len(text) < nchar: + text = text + b" " * (nchar - len(text)) + self.file.write(text) + + def write_integer(self, value): + """Write a single integer to binary file.""" + self._write_values(np.array([value], dtype=self.integer)) + + def write_real(self, value): + """Write a single real number to binary file.""" + self._write_values(np.array([value], dtype=self.real)) + + def write_record(self, array, dtype=None): + """Write an array to binary file.""" + if dtype is None: + dtype = self.real + if not isinstance(array, np.ndarray): + array = np.array(array, dtype=dtype) + self._write_values(array.astype(dtype)) + + def _write_values(self, array): + """Write numpy array to file.""" + array.tofile(self.file) + def totim_to_datetime(totim, start="1-1-1970", timeunit="D"): """ From 6be21ac6ef228e8a0b08dfca9a1a4344b8d4a9b9 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 16 Apr 2026 12:21:48 -0400 Subject: [PATCH 23/90] fix(mfpackage): clear timeseries files on ts initialize (#2749) --- autotest/test_mf6.py | 188 +++++++++++++++++++++++++++++++++++++++++ flopy/mf6/mfpackage.py | 3 +- 2 files changed, 190 insertions(+), 1 deletion(-) diff --git a/autotest/test_mf6.py b/autotest/test_mf6.py index f52242059..4dd29ad5c 100644 --- a/autotest/test_mf6.py +++ b/autotest/test_mf6.py @@ -2605,3 +2605,191 @@ def test_evt_auxiliary_variables(function_tmpdir): aux_name_lower = aux_name.lower() assert aux_name_lower in spd_load.dtype.names assert spd_load[0][aux_name_lower] == float(i + 1) + + +def _make_gwf_sim(function_tmpdir): + """Minimal single-layer GWF simulation used by the ts tests below.""" + sim = MFSimulation( + sim_name="test", version="mf6", exe_name="mf6", sim_ws=str(function_tmpdir) + ) + ModflowTdis(sim, time_units="DAYS", nper=2, perioddata=[(1.0, 1, 1.0)] * 2) + ModflowIms(sim) + gwf = ModflowGwf(sim, modelname="gwf") + ModflowGwfdis(gwf, nlay=1, nrow=3, ncol=3, delr=1.0, delc=1.0, top=10.0, botm=0.0) + ModflowGwfic(gwf, strt=5.0) + ModflowGwfnpf(gwf, k=1.0) + return sim, gwf + + +def test_multi_ts_files_written(function_tmpdir): + """ + Test that multiple time series files are all written to disk when + using initialize() followed by append_package(), and that the file + record only contains the current set of filenames. + + Regression test for https://github.com/modflowpy/flopy/issues/2748. + """ + sim, gwf = _make_gwf_sim(function_tmpdir) + + ghb_spd = {0: [((0, 0, 0), "tide1", 100.0), ((0, 1, 0), "tide2", 100.0)]} + ghb = ModflowGwfghb(gwf, maxbound=2, stress_period_data=ghb_spd) + + ts_data1 = [(0.0, 1.0), (1.0, 2.0)] + ts_data2 = [(0.0, 3.0), (1.0, 4.0)] + + ghb.ts.initialize( + filename="tides1.ts", + timeseries=ts_data1, + time_series_namerecord="tide1", + interpolation_methodrecord="linearend", + ) + ghb.ts.append_package( + filename="tides2.ts", + timeseries=ts_data2, + time_series_namerecord="tide2", + interpolation_methodrecord="linearend", + ) + + assert [p.filename for p in ghb.ts._packages] == ["tides1.ts", "tides2.ts"] + fr = ghb._ts_filerecord.get_data() + assert len(fr) == 2 + assert fr[0][0] == "tides1.ts" + assert fr[1][0] == "tides2.ts" + + sim.write_simulation() + + assert (function_tmpdir / "tides1.ts").exists(), "tides1.ts not written" + assert (function_tmpdir / "tides2.ts").exists(), "tides2.ts not written" + + +def test_initialize_ts_replaces_filerecord(function_tmpdir): + """ + Test that calling initialize() a second time properly replaces the first + ts package — including clearing the stale filename from the file record. + + Before the fix for https://github.com/modflowpy/flopy/issues/2748, + the first filename remained in the file record even after the first + package was removed from the model's package list, causing MF6 to + expect a file that was never written. + """ + sim, gwf = _make_gwf_sim(function_tmpdir) + + ghb_spd = {0: [((0, 0, 0), "tide2", 100.0)]} + ghb = ModflowGwfghb(gwf, maxbound=1, stress_period_data=ghb_spd) + + ts_data1 = [(0.0, 1.0), (1.0, 2.0)] + ts_data2 = [(0.0, 3.0), (1.0, 4.0)] + + # First initialize sets up ts package + ghb.ts.initialize( + filename="tides1.ts", + timeseries=ts_data1, + time_series_namerecord="tide1", + interpolation_methodrecord="linearend", + ) + # Second initialize replaces the first — filerecord must be updated too + ghb.ts.initialize( + filename="tides2.ts", + timeseries=ts_data2, + time_series_namerecord="tide2", + interpolation_methodrecord="linearend", + ) + + assert [p.filename for p in ghb.ts._packages] == ["tides2.ts"] + fr = ghb._ts_filerecord.get_data() + assert len(fr) == 1, ( + f"filerecord should have 1 entry after replace, got {len(fr)}: {fr}" + ) + assert fr[0][0] == "tides2.ts" + + sim.write_simulation() + + # Only tides2.ts should exist; tides1.ts was replaced and must not appear + assert not (function_tmpdir / "tides1.ts").exists(), ( + "tides1.ts should not be written after being replaced" + ) + assert (function_tmpdir / "tides2.ts").exists(), "tides2.ts not written" + + +def test_ts_kwarg_and_append_package(function_tmpdir): + """ + Test that passing timeseries= during package construction (which internally + calls build_child_package) followed by append_package() correctly writes + both ts files. + + Regression test for https://github.com/modflowpy/flopy/issues/2748. + """ + sim, gwf = _make_gwf_sim(function_tmpdir) + + ts_data1 = [(0.0, 1.0), (1.0, 2.0)] + ts_data2 = [(0.0, 3.0), (1.0, 4.0)] + + ghb_spd = {0: [((0, 0, 0), "tide1", 100.0), ((0, 1, 0), "tide2", 100.0)]} + ghb = ModflowGwfghb( + gwf, + maxbound=2, + stress_period_data=ghb_spd, + timeseries={ + "filename": "tides1.ts", + "timeseries": ts_data1, + "time_series_namerecord": "tide1", + "interpolation_methodrecord": "linearend", + }, + ) + ghb.ts.append_package( + filename="tides2.ts", + timeseries=ts_data2, + time_series_namerecord="tide2", + interpolation_methodrecord="linearend", + ) + + assert [p.filename for p in ghb.ts._packages] == ["tides1.ts", "tides2.ts"] + fr = ghb._ts_filerecord.get_data() + assert len(fr) == 2 + assert fr[0][0] == "tides1.ts" + assert fr[1][0] == "tides2.ts" + + sim.write_simulation() + + assert (function_tmpdir / "tides1.ts").exists(), "tides1.ts not written" + assert (function_tmpdir / "tides2.ts").exists(), "tides2.ts not written" + + +def test_ts_write_load_roundtrip(function_tmpdir): + """ + Test that multiple ts files survive a write/load round-trip: both packages + are present in the loaded model and both files exist on disk. + + Regression test for https://github.com/modflowpy/flopy/issues/2748. + """ + sim, gwf = _make_gwf_sim(function_tmpdir) + + ts_data1 = [(0.0, 1.0), (1.0, 2.0)] + ts_data2 = [(0.0, 3.0), (1.0, 4.0)] + + ghb_spd = {0: [((0, 0, 0), "tide1", 100.0), ((0, 1, 0), "tide2", 100.0)]} + ghb = ModflowGwfghb(gwf, maxbound=2, stress_period_data=ghb_spd) + ghb.ts.initialize( + filename="tides1.ts", + timeseries=ts_data1, + time_series_namerecord="tide1", + interpolation_methodrecord="linearend", + ) + ghb.ts.append_package( + filename="tides2.ts", + timeseries=ts_data2, + time_series_namerecord="tide2", + interpolation_methodrecord="linearend", + ) + + sim.write_simulation() + + sim2 = MFSimulation.load("mfsim.nam", sim_ws=str(function_tmpdir)) + ghb2 = sim2.get_model("gwf").get_package("ghb") + + assert len(ghb2.ts._packages) == 2 + fr2 = ghb2._ts_filerecord.get_data() + assert len(fr2) == 2 + fnames = {row[0] for row in fr2} + assert "tides1.ts" in fnames + assert "tides2.ts" in fnames diff --git a/flopy/mf6/mfpackage.py b/flopy/mf6/mfpackage.py index 738280eb2..3b6bc2852 100644 --- a/flopy/mf6/mfpackage.py +++ b/flopy/mf6/mfpackage.py @@ -3669,8 +3669,9 @@ def next_default_file_path(self): def init_package(self, package, fname, remove_packages=True): if remove_packages: - # clear out existing packages + # clear out existing packages and reset file record self._remove_packages() + self._filerecord.set_data(None) elif fname is not None: self._remove_packages(fname) if fname is None: From 71c82d86d0a22fab5420cb40872b581a9270257c Mon Sep 17 00:00:00 2001 From: Eric Morway Date: Wed, 22 Apr 2026 05:18:58 -0600 Subject: [PATCH 24/90] feat(mve): add support for gwe-gwe exchanges that include mve package (#2750) Model-level MVE package was previously supported by virtue of the dfn file. However, simulation-level support for MVE was not supported. A new autotest for MF6 confirms that these changes are sufficient. This missing functionality was discovered when attempting to support parallel energy mover (MVE) transport in MF6. For example, if a stream was split across two flow and transport models and a transport mover was required to properly route solute from an SFT reach in one model to a connected SFT reach in a downstream model, MVT worked, but its complement in GWE, namely MVE, was not working prior to this PR. --- flopy/mf6/data/mfstructure.py | 5 +++++ flopy/mf6/utils/codegen/component.py | 1 + flopy/mf6/utils/codegen/filters.py | 2 ++ 3 files changed, 8 insertions(+) diff --git a/flopy/mf6/data/mfstructure.py b/flopy/mf6/data/mfstructure.py index 3a44b4f9a..41f68d0a4 100644 --- a/flopy/mf6/data/mfstructure.py +++ b/flopy/mf6/data/mfstructure.py @@ -35,6 +35,7 @@ class DfnType(Enum): mvr_file = 9 utl = 10 mvt_file = 11 + mve_file = 12 unknown = 999 @@ -106,6 +107,8 @@ def _file_type(self, file_name): return DfnType.mvr_file, model_type elif file_name[3:6] == "mvt": return DfnType.mvt_file, model_type + elif file_name[3:6] == "mve": + return DfnType.mve_file, model_type else: return DfnType.model_file, model_type @@ -1760,6 +1763,7 @@ def register(self, dfn_file): or dfn_file.dfn_type == DfnType.gnc_file or dfn_file.dfn_type == DfnType.mvr_file or dfn_file.dfn_type == DfnType.mvt_file + or dfn_file.dfn_type == DfnType.mve_file ): model_ver = f"{dfn_file.model_type}6" if model_ver not in self.mdl_spec: @@ -1772,6 +1776,7 @@ def register(self, dfn_file): dfn_file.dfn_type == DfnType.gnc_file or dfn_file.dfn_type == DfnType.mvr_file or dfn_file.dfn_type == DfnType.mvt_file + or dfn_file.dfn_type == DfnType.mve_file ): # gnc and mvr files belong both on the simulation and model level self.mdl_spec[model_ver].pkg_spec[dfn_file.package_type] = ( diff --git a/flopy/mf6/utils/codegen/component.py b/flopy/mf6/utils/codegen/component.py index 76845819b..e1238329c 100644 --- a/flopy/mf6/utils/codegen/component.py +++ b/flopy/mf6/utils/codegen/component.py @@ -30,6 +30,7 @@ def get_component_names(dfn: dict) -> list[tuple[str, str]]: ["gwf", "mvr"], ["gwf", "gnc"], ["gwt", "mvt"], + ["gwe", "mve"], ]: # TODO: deduplicate mfmvr.py/mfgwfmvr.py etc and remove special cases return [ diff --git a/flopy/mf6/utils/codegen/filters.py b/flopy/mf6/utils/codegen/filters.py index 3d4ee82a4..9f7b52636 100644 --- a/flopy/mf6/utils/codegen/filters.py +++ b/flopy/mf6/utils/codegen/filters.py @@ -110,6 +110,8 @@ def dfn_file_name(component_name: tuple[str, str]) -> str: return f"gwf-{component_name[1]}.dfn" if tuple(component_name) in [(None, "mvt")]: return f"gwt-{component_name[1]}.dfn" + if tuple(component_name) in [(None, "mve")]: + return f"gwe-{component_name[1]}.dfn" return f"{component_name[0] or 'sim'}-{component_name[1]}.dfn" From 68880321ee3bd1fe34eed0053b697ab787758b00 Mon Sep 17 00:00:00 2001 From: Joshua Larsen Date: Wed, 22 Apr 2026 15:30:47 -0700 Subject: [PATCH 25/90] update(_remap_array): support non-layered external data (#2747) * Add entry point to "layered" data specification in MFArray --- flopy/mf6/data/mfdataarray.py | 6 ++++++ flopy/mf6/utils/model_splitter.py | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/flopy/mf6/data/mfdataarray.py b/flopy/mf6/data/mfdataarray.py index 15f2bd6a6..0f192263e 100644 --- a/flopy/mf6/data/mfdataarray.py +++ b/flopy/mf6/data/mfdataarray.py @@ -55,6 +55,12 @@ def __init__( sim_data, model_or_sim, structure, enable, path, dimensions ) self._block = block + if isinstance(data, list): + if len(data) == 1: + if isinstance(data[0], dict): + default = self.structure.layered + self.structure.layered = data[0].pop("layered", default) + if self.structure.layered: try: self._layer_shape = self.layer_shape() diff --git a/flopy/mf6/utils/model_splitter.py b/flopy/mf6/utils/model_splitter.py index 8c264cad1..e5b3f13e1 100644 --- a/flopy/mf6/utils/model_splitter.py +++ b/flopy/mf6/utils/model_splitter.py @@ -1479,6 +1479,7 @@ def _remap_array(self, item, mfarray, mapped_data, **kwargs): how = kwargs.pop("how", []) binary = kwargs.pop("binary", []) fnames = kwargs.pop("fnames", None) + layered = True if not hasattr(mfarray, "size"): if mfarray.array is None: if item == "idomain": @@ -1498,6 +1499,7 @@ def _remap_array(self, item, mfarray, mapped_data, **kwargs): i.fname for i in mfarray._data_storage.layer_storage.multi_dim_list ] + layered = mfarray._data_storage.layered mfarray = mfarray.array nlay = 1 @@ -1536,7 +1538,10 @@ def _remap_array(self, item, mfarray, mapped_data, **kwargs): if how and item != "idomain": new_input = [] i0 = 0 - i1 = new_ncpl + if layered: + i1 = new_ncpl + else: + i1 = new_ncpl * nlay lay = 0 for h in how: if h == 1: @@ -1559,6 +1564,7 @@ def _remap_array(self, item, mfarray, mapped_data, **kwargs): "iprn": 1, "data": new_array[i0:i1], "binary": binary[lay], + "layered": layered } new_input.append(cr) From 5ab303dc0c8dcb1d618e1ccc441023312bcee8d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 04:30:57 -0700 Subject: [PATCH 26/90] chore(deps): bump dawidd6/action-download-artifact from 20 to 21 (#2751) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e54ac5a94..d08c744d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -175,7 +175,7 @@ jobs: # actions/download-artifact won't look at previous workflow runs but we need to in order to get changelog - name: Download artifacts - uses: dawidd6/action-download-artifact@v20 + uses: dawidd6/action-download-artifact@v21 - name: Draft release env: From 2eaad437f8757ec93613e49cad2ee4c22f02df9e Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Sun, 10 May 2026 09:01:27 -0400 Subject: [PATCH 27/90] ci: drop setup-micromamba (#2753) 732a55b introduced setup-uv and switched most jobs off setup-micromamba. Some were not changed because dependencies were only available from conda-forge, not PyPI - windows pymetis - arm mac vtk >=9.4.0 VTK is available from PyPI, and we can use the MF6 pixi rtd environment with setup-pixi where setup-micromamba is otherwise used, dropping setup-micromamba. --- .github/workflows/examples.yml | 28 ++++++++++------------------ .github/workflows/rtd.yml | 34 ++++++++++++++-------------------- 2 files changed, 24 insertions(+), 38 deletions(-) diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 13952f09f..f5fe0cb81 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -15,35 +15,27 @@ jobs: python-version: [ "3.10", "3.11", "3.12" ] defaults: run: - shell: bash -l {0} + shell: bash timeout-minutes: 90 steps: - name: Checkout repo uses: actions/checkout@v6 - - name: Setup Micromamba - uses: mamba-org/setup-micromamba@v3 + - name: Setup Python ${{ matrix.python-version }} + uses: astral-sh/setup-uv@v7 with: - environment-file: etc/environment.yml - cache-environment: true - cache-downloads: true - create-args: >- - python=${{ matrix.python-version }} - init-shell: >- - bash - powershell + python-version: ${{ matrix.python-version }} + cache-dependency-glob: "**/pyproject.toml" - name: Install FloPy - run: | - pip install . - pip install ".[codegen]" + run: uv sync --all-extras - name: OpenGL workaround on Linux if: runner.os == 'Linux' run: | # referenced from https://github.com/pyvista/pyvista/blob/main/.github/workflows/vtk-pre-test.yml#L53 - pip uninstall -y vtk - pip install --extra-index-url https://wheels.vtk.org trame vtk-osmesa + uv pip uninstall vtk + uv pip install --extra-index-url https://wheels.vtk.org trame vtk-osmesa - name: Install OpenGL on Windows if: runner.os == 'Windows' @@ -71,11 +63,11 @@ jobs: subset: triangle - name: Update FloPy packages - run: python -m flopy.mf6.utils.generate_classes --ref develop + run: uv run python -m flopy.mf6.utils.generate_classes --ref develop - name: Run example tests working-directory: autotest - run: pytest -v test_example_notebooks.py -n=auto -s --durations=0 --keep-failed=.failed + run: uv run pytest -v test_example_notebooks.py -n=auto -s --durations=0 --keep-failed=.failed env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/rtd.yml b/.github/workflows/rtd.yml index dc5205dac..af0b01379 100644 --- a/.github/workflows/rtd.yml +++ b/.github/workflows/rtd.yml @@ -86,31 +86,25 @@ jobs: echo $GITHUB_REF echo $GITHUB_EVENT_NAME - - name: Setup Micromamba - uses: mamba-org/setup-micromamba@v3 + - name: Setup pixi + uses: prefix-dev/setup-pixi@v0.9.5 with: - environment-file: flopy/etc/environment.yml - cache-environment: true - cache-downloads: true - create-args: >- - python=3.12 - init-shell: >- - bash - powershell + pixi-version: v0.41.4 + manifest-path: modflow6/pixi.toml - name: Install Python dependencies - working-directory: flopy + working-directory: modflow6 run: | - pip install --upgrade pip - pip install . - pip install meson ninja + pixi run -e rtd install + pixi run -e rtd pip install "../flopy[optional,test]" - name: Workaround OpenGL issue on Linux if: runner.os == 'Linux' + working-directory: modflow6 run: | # referenced from https://github.com/pyvista/pyvista/blob/main/.github/workflows/vtk-pre-test.yml#L53 - pip uninstall -y vtk - pip install --extra-index-url https://wheels.vtk.org trame vtk-osmesa + pixi run -e rtd pip uninstall vtk + pixi run -e rtd pip install --extra-index-url https://wheels.vtk.org trame vtk-osmesa - name: Install fonts on Linux if: runner.os == 'Linux' @@ -142,14 +136,14 @@ jobs: - name: Build MODFLOW 6 working-directory: modflow6 run: | - meson setup builddir --buildtype=debugoptimized --prefix=$(pwd) --libdir=bin - meson install -C builddir - meson test --verbose --no-rebuild -C builddir + pixi run -e rtd meson setup builddir --buildtype=debugoptimized --prefix=$(pwd) --libdir=bin + pixi run -e rtd meson install -C builddir + pixi run -e rtd meson test --verbose --no-rebuild -C builddir echo "$(pwd)/bin" >> $GITHUB_PATH - name: Run tutorial and example notebooks working-directory: flopy/autotest - run: pytest -v -n auto test_example_notebooks.py + run: pixi run --manifest-path=../../modflow6/pixi.toml -e rtd pytest -v -n auto test_example_notebooks.py - name: Upload notebooks artifact for ReadtheDocs if: | From 66b442354d124243e989ea3015b9ab58b3d1c6a7 Mon Sep 17 00:00:00 2001 From: Joseph Hughes Date: Sun, 10 May 2026 13:42:38 -0500 Subject: [PATCH 28/90] fix(mnw1): fix mnw1 load (#2752) * add tests for the PR changes --- autotest/test_mnw.py | 178 +++++++++++++++++++++++++++++++++++++++ flopy/modflow/mfmnw1.py | 36 ++++++-- flopy/utils/util_list.py | 8 +- 3 files changed, 216 insertions(+), 6 deletions(-) diff --git a/autotest/test_mnw.py b/autotest/test_mnw.py index 1979c4c5a..a917db98c 100644 --- a/autotest/test_mnw.py +++ b/autotest/test_mnw.py @@ -441,3 +441,181 @@ def test_checks(mnw2_examples_path): assert "MNWI package present without MNW2 package." in ".".join( chk.summary_array.desc ) + + +def test_mnw1_add_flag(function_tmpdir): + """Test MNW1 ADD flag parsing and writing""" + mnw1_str = """ 120 -90 0 REFERENCE SP = 2 +Well model will use SKIN +# + 2 SP 1 + 1 1 1 0 100 0.5 1 SITE:Well-A + 1 2 2 0 200 0.5 1 +# Multi-node switch Switch to specify Hlim Auxiliary + 2 ADD + 1 1 1 -100.0 100 0.5 1 DD 50 1.e16 1 SITE:Well-A + 1 2 2 -200.0 200 0.5 1 DD 50 1.e16 1 +# + 1 + 1 1 1 -100.0 100 0.5 1 DD 50 1.e16 1 SITE:Well-A +# + 1 + 1 1 1 -100.0 100 0.5 1 DD 50 1.e16 1 SITE:Well-A +""" # noqa: E501 + ws = function_tmpdir + fpth = ws / "test_add.mnw" + with open(fpth, "w") as f: + f.write(mnw1_str) + + # Create minimal model files + disstr = """1 1 1 3 1 1 + 0 0 0 +constant 1 +constant 1 +constant 0 top of model +constant -1 bottom of layer 1 + 1. 1 1. Tr + 1. 1 1. Tr + 1. 1 1. Tr +""" # noqa: E501 + dis_path = ws / "test_add.dis" + with open(dis_path, "w") as f: + f.write(disstr) + + basstr = """ 0 0 1 1 1 1 0 0 + 1 1 1 + 0. +""" # noqa: E501 + bas_path = ws / "test_add.bas" + with open(bas_path, "w") as f: + f.write(basstr) + + namstr = """lst 101 test_add.lst +dis 102 test_add.dis +bas6 103 test_add.bas +mnw1 104 test_add.mnw""" # noqa: E501 + nam_path = ws / "test_add.nam" + with open(nam_path, "w") as f: + f.write(namstr) + + # Load and test ADD flag parsing + m = Modflow.load( + "test_add.nam", + model_ws=ws, + load_only=["mnw1"], + verbose=True, + forgive=False, + ) + + # Verify add parameter was loaded correctly + assert hasattr(m.mnw1, "add"), "MNW1 package missing 'add' attribute" + assert len(m.mnw1.add) == 3, ( + f"Expected 3 stress periods in add, got {len(m.mnw1.add)}" + ) + assert m.mnw1.add[0] is False, "SP 1: add should be False" + assert m.mnw1.add[1] is True, "SP 2: add should be True" + assert m.mnw1.add[2] is False, "SP 3: add should be False" + + # Write to new file and verify round-trip + m.mnw1.fn_path = ws / "test_add_write.mnw" + m.mnw1.write_file() + + # Create a new name file pointing to the written MNW file + namstr_write = """lst 101 test_add_write.lst +dis 102 test_add.dis +bas6 103 test_add.bas +mnw1 104 test_add_write.mnw""" # noqa: E501 + with open(ws / "test_add_write.nam", "w") as f: + f.write(namstr_write) + + # Reload the model with the written MNW file + m2 = Modflow.load( + "test_add_write.nam", + model_ws=ws, + load_only=["mnw1"], + verbose=False, + forgive=False, + ) + + # Verify add flag was preserved after write and reload + assert hasattr(m2.mnw1, "add"), "Reloaded MNW1 package missing 'add' attribute" + assert m2.mnw1.add[0] is False, "Reloaded SP 1: add should be False" + assert m2.mnw1.add[1] is True, "Reloaded SP 2: add should be True" + assert m2.mnw1.add[2] is False, "Reloaded SP 3: add should be False" + + +def test_mnw1_stress_period_no_wells(function_tmpdir): + """Test MNW1 loading with stress periods that have no wells (itmp <= 0)""" + # This tests the fix for handling empty stress periods in the load routine + mnw1_str = """ 984 2 0 10000 !! Item 1: MXMNW IWL2CB IWELPT +SKIN !! Item 2: LOSSTYPE +# + 1 # Item 4, SP 1 +# lay row col Q rw Skin Hlim Href QWZN + 1 1 1 -10.0 0.5 2.0 0.5 1.e9 0 SITE:Well-A +# stress period 2 has no wells (itmp=0) - should be handled without error + 0 # Item 4, SP 2 + 1 # Item 4, SP 3 +# Multi-node switch Switch to specify Hlim Auxiliary +# | as difference from Href definitions +# lay row col Q rw Skin Hlim Href QWZN + 1 1 1 -100.0 0.5 1.0 DD 50 1.e16 1 SITE:Well-A +""" # noqa: E501 + ws = function_tmpdir + fpth = ws / "test_no_wells.mnw" + with open(fpth, "w") as f: + f.write(mnw1_str) + + # Create minimal model files with only 2 stress periods (to match the MNW file) + disstr = """1 1 1 3 1 1 + 0 0 0 +constant 1 +constant 1 +constant 0 top of model +constant -1 bottom of layer 1 + 1. 1 1. Tr + 1. 1 1. Tr + 1. 1 1. Tr + 1. 1 1. Tr +""" # noqa: E501 + dis_path = ws / "test_no_wells.dis" + with open(dis_path, "w") as f: + f.write(disstr) + + basstr = """ 0 0 1 1 1 1 0 0 + 1 1 1 + 0. +""" # noqa: E501 + bas_path = ws / "test_no_wells.bas" + with open(bas_path, "w") as f: + f.write(basstr) + + namstr = """lst 101 test_no_wells.lst +dis 102 test_no_wells.dis +bas6 103 test_no_wells.bas +mnw1 104 test_no_wells.mnw""" # noqa: E501 + nam_path = ws / "test_no_wells.nam" + with open(nam_path, "w") as f: + f.write(namstr) + + # Load the file - should not fail when SP 2 has no wells (itmp=0) + m = Modflow.load( + "test_no_wells.nam", + model_ws=ws, + load_only=["mnw1"], + verbose=False, + forgive=False, + ) + + # Verify the package loaded successfully + assert m.mnw1 is not None, "MNW1 package failed to load" + assert m.mnw1.stress_period_data is not None, "stress_period_data is None" + + # Verify SP 1 has wells + assert len(m.mnw1.stress_period_data[0]) >= 1, "SP 1 should have wells" + + # Verify SP 2 has no wells + assert len(m.mnw1.stress_period_data[1]) == 0, "SP 2 should have no wells" + + # Verify SP 3 has wells + assert len(m.mnw1.stress_period_data[2]) >= 1, "SP 3 should have wells" diff --git a/flopy/modflow/mfmnw1.py b/flopy/modflow/mfmnw1.py index 81a862ccf..bcc053f63 100644 --- a/flopy/modflow/mfmnw1.py +++ b/flopy/modflow/mfmnw1.py @@ -37,6 +37,11 @@ class ModflowMnw1(Package): if None, these optional external filenames and unit numbers are not written out itmp : array number of wells to be simulated for each stress period (shape : (NPER)) + add : bool, list of bool, or None + flag to indicate whether the number of wells specified in itmp should be + added to the number of wells from previous stress periods (True) or if + the number of wells specified in itmp is the total number of wells for + that stress period (False) lay_row_col_qdes_mn_multi : list of arrays lay, row, col, qdes, and MN or MULTI flag for all well nodes (length : NPER) @@ -88,6 +93,7 @@ def __init__( kspref=1, wel1_bynode_qsum=None, losstype="skin", + add=None, stress_period_data=None, dtype=None, mnwname=None, @@ -125,6 +131,13 @@ def __init__( # nested list containing file names, unit numbers, and ALLTIME flag for # auxiliary output, e.g. [['test.ByNode',92,'ALLTIME']] self.wel1_bynode_qsum = wel1_bynode_qsum + if add is None: + add = [False] * self.nper + elif isinstance(add, bool): + add = [add] * self.nper + elif isinstance(add, np.ndarray): + add = add.tolist() + self.add = add if dtype is not None: self.dtype = dtype else: @@ -215,14 +228,16 @@ def load(cls, f, model, nper=None, gwt=False, nsol=1, ext_unit_dict=None): qfrcmx_default = None qcut_default = "" - # not sure what 'add' means - add = True if "add" in line.lower() else False - + add = [] for per in range(nper): if per > 0: line = skipcomments(next(f), f) - add = True if "add" in line.lower() else False itmp = int(line_parse(line)[0]) + if itmp < 1: + tadd = False + else: + tadd = True if "add" in line.lower() else False + add.append(tadd) if itmp > 0: # dataset 5 data, qfrcmn_default, qfrcmx_default, qcut_default = _parse_5( @@ -235,6 +250,8 @@ def load(cls, f, model, nper=None, gwt=False, nsol=1, ext_unit_dict=None): for n in dtype.descr: spd[n[0]] = tmp[n[0]] stress_period_data[per] = spd + else: + stress_period_data[per] = ModflowMnw1.get_empty_stress_period_data(0) if openfile: f.close() @@ -248,6 +265,7 @@ def load(cls, f, model, nper=None, gwt=False, nsol=1, ext_unit_dict=None): kspref=kspref, wel1_bynode_qsum=wel1_bynode_qsum, losstype=losstype, + add=add, stress_period_data=stress_period_data, ) @@ -303,10 +321,18 @@ def write_file(self): "FILE:%s QSUM:%-10i %s\n" % (each[0], int(each[1]), each[2]) ) + # process additional data for Section 4 (itmp and ADD flag) + additional_data = [] + for per in range(self.nper): + if self.add[per]: + additional_data.append(" ADD ") + else: + additional_data.append(" ") + spd = self.stress_period_data.drop("mnw_no") # force write_transient to keep the list arrays internal because MNW1 # doesn't allow open/close - spd.write_transient(f, forceInternal=True) + spd.write_transient(f, forceInternal=True, additional_data=additional_data) # -Un-numbered section PREFIX:MNWNAME if self.mnwname: diff --git a/flopy/utils/util_list.py b/flopy/utils/util_list.py index 3017df393..576d1e602 100644 --- a/flopy/utils/util_list.py +++ b/flopy/utils/util_list.py @@ -697,6 +697,7 @@ def write_transient( forceInternal=False, write_header=True, cln_data=None, + additional_data=None, ): # forceInternal overrides isExternal (set below) for cases where # external arrays are not supported (oh hello MNW1!) @@ -744,8 +745,13 @@ def write_transient( kper_vtype = int if write_header: - if cln_data is None: + if cln_data is None and additional_data is None: f.write(f" {itmp:9d} {0:9d} # stress period {kper + 1}\n") + elif additional_data is not None: + f.write( + f" {itmp:9d} {additional_data[kper]} " + + f"# stress period {kper + 1}\n" + ) elif cln_data.get_itmp(kper) is None: f.write(f" {itmp:9d} {0:9d} # stress period {kper + 1}\n") else: From 3405517c7d2fed2667722fe8dc07b0d2af269fbe Mon Sep 17 00:00:00 2001 From: Joshua Larsen Date: Mon, 11 May 2026 14:53:58 -0700 Subject: [PATCH 29/90] Feat(hfb_util): add method for creating HFBs (#2745) * New feats: updates to Grid, GridIntersect, and VoronoiGrid Updates support development of generalized HFB creation methods * Grid: add cell_area property via the shoelace algorithm * GridIntersect: add "experimental" support for UnstructuredGrid * VoronoiGrid: add support for get_disu6_gridprops() that can build MF6 DISU packages * Feat(hfb_util): add hfb builder method * add make_hfb_array and supporting methods to build HFB recarrays from LineString / Grid intersections * Linting * update hfb_util: add perturbation routine for edge cases * catches "mid cell split" and "colinear with cell boundary" edge cases * Added tests for hfb_util * fix spelling * Add notebook documentation for `make_hfb_array` utility --- .../horizontal_flow_barriers_from_linework.py | 305 ++++++++++++ autotest/test_hfb_util.py | 439 ++++++++++++++++++ flopy/discretization/grid.py | 23 + flopy/utils/__init__.py | 1 + flopy/utils/gridintersect.py | 26 +- flopy/utils/hfb_util.py | 366 +++++++++++++++ flopy/utils/voronoi.py | 90 +++- 7 files changed, 1246 insertions(+), 4 deletions(-) create mode 100644 .docs/Notebooks/horizontal_flow_barriers_from_linework.py create mode 100644 autotest/test_hfb_util.py create mode 100644 flopy/utils/hfb_util.py diff --git a/.docs/Notebooks/horizontal_flow_barriers_from_linework.py b/.docs/Notebooks/horizontal_flow_barriers_from_linework.py new file mode 100644 index 000000000..6db2ed3cc --- /dev/null +++ b/.docs/Notebooks/horizontal_flow_barriers_from_linework.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python +# coding: utf-8 + +# # Creating HFB input data from shapefile linework +# +# This notebook shows examples of how to create a horizontal flow barrier package (HFB) from shapefile information in FloPy. FloPy has support for creating HFB inputs from LineString like vector data for all discretization types (`DIS`, `DISV`, `DISU`). +# +# This notebook starts off by generating two simple models: +# - a structured (DIS) model +# - a vertex (DISV) model +# +# And then shows how to generate HFB input for each of these models. + +# In[1]: + + +from tempfile import TemporaryDirectory + +import geopandas as gpd +import matplotlib.pyplot as plt +import numpy as np +from shapely.geometry import LineString + +import flopy +from flopy.utils import make_hfb_array + +temp_dir = TemporaryDirectory() +workspace = temp_dir.name + + +# ### Generate a Structured (DIS) model + +# In[2]: + + +def simple_structured_model(): + """ + Method to generate a 10x10 structured grid model + """ + lx = 100 + ly = 100 + nlay = 1 + nrow = 10 + ncol = 10 + delc = np.full((nrow,), ly / nrow) + delr = np.full((ncol,), ly / ncol) + top = np.full((nrow, ncol), 10) + botm = np.zeros((nlay, nrow, ncol)) + idomain = np.ones(botm.shape, dtype=int) + + sim = flopy.mf6.MFSimulation(sim_ws="tmp_struct") + ims = flopy.mf6.ModflowIms(sim, complexity="SIMPLE") + tdis = flopy.mf6.ModflowTdis(sim) + + gwf = flopy.mf6.ModflowGwf(sim, modelname="hfb_model") + dis = flopy.mf6.ModflowGwfdis( + gwf, + nlay=nlay, + nrow=nrow, + ncol=ncol, + delc=delc, + delr=delr, + top=top, + botm=botm, + idomain=idomain, + ) + npf = flopy.mf6.ModflowGwfnpf(gwf) + sto = flopy.mf6.ModflowGwfsto(gwf) + ic = flopy.mf6.ModflowGwfic(gwf, strt=np.full((nlay, nrow, ncol), 9)) + + return sim + + +# ### Generate a voronoi (DISV) model + +# In[3]: + + +def simple_vertex_model(workspace): + """ + Method to generate a voroni vertex grid model + """ + from flopy.utils.triangle import Triangle + from flopy.utils.voronoi import VoronoiGrid + + geom = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)] + tri = Triangle(angle=30, model_ws=workspace) + tri.add_polygon(geom) + tri.add_region((5, 5), 0, maximum_area=40) + tri.build() + + vor = VoronoiGrid(tri) + pkg_props = vor.get_disv_gridprops() + ncpl = pkg_props["ncpl"] + nlay = 1 + top = np.full((ncpl,), 10) + botm = np.zeros((nlay, ncpl)) + idomain = np.ones(botm.shape, dtype=int) + + sim = flopy.mf6.MFSimulation(sim_ws="tmp_struct") + ims = flopy.mf6.ModflowIms(sim, complexity="SIMPLE") + tdis = flopy.mf6.ModflowTdis(sim) + + gwf = flopy.mf6.ModflowGwf(sim, modelname="hfb_model") + disv = flopy.mf6.ModflowGwfdisv( + gwf, nlay=1, top=top, botm=botm, idomain=idomain, **pkg_props + ) + npf = flopy.mf6.ModflowGwfnpf(gwf) + sto = flopy.mf6.ModflowGwfsto(gwf) + ic = flopy.mf6.ModflowGwfic(gwf, strt=np.full((nlay, ncpl), 9)) + + return sim + + +# ### Create HFB inputs for a Structured (DIS) model + +# The `make_hfb_array` utility is used to intersect `LineString` like data with `modelgrid` instances to produce HFB data. The `make_hfb_array` utility accepts two parameters: +# - `modelgrid` : a flopy Grid instance +# - `geom` : a geospatial LineString like object that can be: an iterable of points, GeoJSON, Shapely.geometry.LineString, or a shapefile.Shape object. + +# First define a fault line and visualize it with the model + +# In[4]: + + +# define a fault line +fault = [(0, 4), (50, 55), (100, 55)] + +# build simulation +sim = simple_structured_model() +gwf = sim.get_model() +modelgrid = gwf.modelgrid + +fig, ax = plt.subplots(figsize=(5, 5)) +pmv = flopy.plot.PlotMapView(modelgrid=modelgrid, ax=ax) +pmv.plot_grid() +plt.plot(np.array(fault).T[0], np.array(fault).T[1], "r--") +# now create hfb data with the `make_hfb_array` utility + +# In[5]: + + +hfbs = flopy.utils.make_hfb_array(modelgrid, fault) +hfbs[0:5] + + +# notice that the `hydchr` parameter is not filled out; this is left up to the user + +# In[6]: + + +hfbs["hydchr"] = 1e-05 + + +# Now we can build an HFB package and compare it to the fault line that was defined above + +# In[7]: + + +hfb = flopy.mf6.ModflowGwfhfb(gwf, stress_period_data={0: hfbs}) + + +# In[8]: + + +# plot up the results +fig, ax = plt.subplots(figsize=(5, 5)) +pmv = flopy.plot.PlotMapView(modelgrid=modelgrid) +pmv.plot_grid() +pmv.plot_bc(package=hfb, color="b", lw=2) +plt.plot(np.array(fault).T[0], np.array(fault).T[1], "r--") +# ### Create HFB inputs for a Vertex (DISV) model + +# The `make_hfb_array` utility is used to intersect `LineString` like data with `modelgrid` instances to produce HFB data. The `make_hfb_array` utility accepts two parameters: +# - `modelgrid` : a flopy Grid instance +# - `geom` : a geospatial LineString like object that can be: an iterable of points, GeoJSON, Shapely.geometry.LineString, or a shapefile.Shape object. + +# First define a fault line and visualize it with the model + +# In[9]: + + +# define a fault line +fault = [(0, 4), (50, 55), (100, 55)] + +# build simulation +sim = simple_vertex_model(workspace) +gwf = sim.get_model() +modelgrid = gwf.modelgrid + +fig, ax = plt.subplots(figsize=(5, 5)) +pmv = flopy.plot.PlotMapView(modelgrid=modelgrid, ax=ax) +pmv.plot_grid() +plt.plot(np.array(fault).T[0], np.array(fault).T[1], "r--") +# now create hfb data with the `make_hfb_array` utility + +# In[10]: + + +hfbs = flopy.utils.make_hfb_array(modelgrid, fault) +hfbs[0:5] + + +# notice that the `hydchr` parameter is not filled out; this is left up to the user + +# In[11]: + + +hfbs["hydchr"] = 3e-06 + + +# Now we can build an HFB package and compare it to the fault line that was defined above + +# In[12]: + + +hfb = flopy.mf6.ModflowGwfhfb(gwf, stress_period_data={0: hfbs}) + + +# In[13]: + + +# plot up the results +fig, ax = plt.subplots(figsize=(5, 5)) +pmv = flopy.plot.PlotMapView(modelgrid=modelgrid) +pmv.plot_grid() +pmv.plot_bc(package=hfb, color="b", lw=2) +plt.plot(np.array(fault).T[0], np.array(fault).T[1], "r--") +# ### Working with multiple fault geometries +# +# The `make_hfb_array` only accepts a single LineString geometry at a time. This was done by design for a couple reasons. +# 1) This gives the user an opportunity to set the `hydchr` parameter to a unique value for each fault line +# 2) This also gives the user an opportunity to save the boundary condition data (cellid1, cellid2) to an external file for each fault line that can be useful during model calibration with automated software (e.g., PEST++) +# +# As a result of these design descisions recarray data will need to be concatenated when working with multiple LineString geometries. + +# For this example, a geodataframe of two fault lines is created and then used to build a HFB package + +# In[14]: + + +geom1 = LineString([(0, 4), (50, 44)]) +geom2 = LineString([(50, 44), (88, 100)]) +d = {"name": ["fault1", "fault2"], "geometry": [geom1, geom2]} +gdf = gpd.GeoDataFrame(d) +gdf + + +# Visualize the faults on the modelgrid + +# In[15]: + + +# build simulation +sim = simple_structured_model() +gwf = sim.get_model() +modelgrid = gwf.modelgrid + +fig, ax = plt.subplots(figsize=(5, 5)) +pmv = flopy.plot.PlotMapView(modelgrid=modelgrid, ax=ax) +pmv.plot_grid() +gdf.geometry.plot(color="r", linestyle="--", ax=ax) +# now create hfb data with the `make_hfb_array` utility and concatenate it + +# In[16]: + + +hydchrs = {"fault1": 1e-05, "fault2": 1e-06} + +hfb_ras = [] +for name, geom in zip(gdf.name, gdf.geometry): + hfbs = make_hfb_array(modelgrid, geom) + hfbs["hydchr"] = hydchrs[name] + hfb_ras.append(hfbs) + +hfbs = np.concatenate(hfb_ras) +hfbs = hfbs.view(np.recarray) +hfbs + + +# Now create the hfb package from this data and visualize it for comparison with the fault lines + +# In[17]: + + +hfb = flopy.mf6.ModflowGwfhfb(gwf, stress_period_data={0: hfbs}) + + +# In[18]: + + +# plot up the results +fig, ax = plt.subplots(figsize=(5, 5)) +pmv = flopy.plot.PlotMapView(modelgrid=modelgrid) +pmv.plot_grid() +pmv.plot_bc(package=hfb, color="b", lw=2) +gdf.geometry.plot(color="r", linestyle="--", ax=ax) +# In[19]: + + +try: + temp_dir.cleanup() +except PermissionError: + # can occur on windows: https://docs.python.org/3/library/tempfile.html#tempfile.TemporaryDirectory + pass diff --git a/autotest/test_hfb_util.py b/autotest/test_hfb_util.py new file mode 100644 index 000000000..31f9958df --- /dev/null +++ b/autotest/test_hfb_util.py @@ -0,0 +1,439 @@ +import numpy as np +from modflow_devtools.markers import requires_exe + +import flopy +from flopy.utils.hfb_util import make_hfb_array +from flopy.utils.triangle import Triangle +from flopy.utils.voronoi import VoronoiGrid + + +def structured_sim(): + lx = 100 + ly = 100 + nlay = 1 + nrow = 10 + ncol = 10 + delc = np.full((nrow,), ly / nrow) + delr = np.full((ncol,), lx / ncol) + top = np.full((nrow, ncol), 10) + botm = np.zeros((nlay, nrow, ncol)) + idomain = np.ones(botm.shape, dtype=int) + + sim = flopy.mf6.MFSimulation(sim_ws="tmp_struct") + ims = flopy.mf6.ModflowIms(sim, complexity="SIMPLE") + tdis = flopy.mf6.ModflowTdis(sim) + + gwf = flopy.mf6.ModflowGwf(sim, modelname="hfb_model") + dis = flopy.mf6.ModflowGwfdis( + gwf, + nlay=nlay, + nrow=nrow, + ncol=ncol, + delc=delc, + delr=delr, + top=top, + botm=botm, + idomain=idomain, + ) + + return sim + + +def vertex_sim(path): + geom = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)] + tri = Triangle(angle=30, model_ws=path) + tri.add_polygon(geom) + tri.add_region((5, 5), 0, maximum_area=40) + tri.build() + + vor = VoronoiGrid(tri) + pkg_props = vor.get_disv_gridprops() + ncpl = pkg_props["ncpl"] + nlay = 1 + top = np.full((ncpl,), 10) + botm = np.zeros((nlay, ncpl)) + idomain = np.ones(botm.shape, dtype=int) + + sim = flopy.mf6.MFSimulation(sim_ws="tmp_struct") + ims = flopy.mf6.ModflowIms(sim, complexity="SIMPLE") + tdis = flopy.mf6.ModflowTdis(sim) + + gwf = flopy.mf6.ModflowGwf(sim, modelname="hfb_model") + disv = flopy.mf6.ModflowGwfdisv( + gwf, nlay=1, top=top, botm=botm, idomain=idomain, **pkg_props + ) + + return sim + + +def unstructured_sim(path): + geom = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)] + tri = Triangle(angle=30, model_ws=path) + tri.add_polygon(geom) + tri.add_region((5, 5), 0, maximum_area=40) + tri.build() + + vor = VoronoiGrid(tri) + grid_props = vor.get_disu6_gridprops() + + top = np.full((grid_props["nodes"],), 10) + botm = np.zeros((grid_props["nodes"])) + idomain = np.ones(botm.shape, dtype=int) + + sim = flopy.mf6.MFSimulation(sim_ws="tmp_struct") + ims = flopy.mf6.ModflowIms(sim, complexity="SIMPLE") + tdis = flopy.mf6.ModflowTdis(sim) + + gwf = flopy.mf6.ModflowGwf(sim, modelname="hfb_model") + disu = flopy.mf6.ModflowGwfdisu( + gwf, top=top, bot=botm, idomain=idomain, **grid_props + ) + + return sim + + +def test_simple_structured(): + validation = [ + (0, 0, 8, 0, 1, 8), + (0, 0, 8, 0, 0, 9), + (0, 1, 7, 0, 2, 7), + (0, 1, 7, 0, 1, 8), + (0, 2, 6, 0, 3, 6), + (0, 2, 6, 0, 2, 7), + (0, 3, 5, 0, 4, 5), + (0, 3, 5, 0, 3, 6), + (0, 4, 4, 0, 4, 5), + (0, 4, 4, 0, 5, 4), + (0, 5, 3, 0, 5, 4), + (0, 5, 3, 0, 6, 3), + (0, 6, 2, 0, 6, 3), + (0, 6, 2, 0, 7, 2), + (0, 7, 1, 0, 7, 2), + (0, 7, 1, 0, 8, 1), + (0, 8, 0, 0, 8, 1), + (0, 8, 0, 0, 9, 0), + ] + + fault = [(0, 4), (94.0, 100)] + sim = structured_sim() + gwf = sim.get_model() + modelgrid = gwf.modelgrid + hfbs = flopy.utils.make_hfb_array(modelgrid, fault) + for row in hfbs: + cid1 = row.cellid1 + cid2 = row.cellid2 + x = sorted([cid1, cid2]) + test_set = x[0] + x[1] + if test_set not in validation: + raise AssertionError( + f"HFB Line {x[0]} and {x[1]} are outside of validation data set" + ) + + if len(hfbs) != len(validation): + raise AssertionError( + f"HFB data length {len(hfbs)} not equal to validation {len(validation)}" + ) + + +@requires_exe("triangle") +def test_simple_vertex(function_tmpdir): + validation = [ + (0, 53, 0, 92), + (0, 65, 0, 162), + (0, 64, 0, 65), + (0, 69, 0, 71), + (0, 64, 0, 72), + (0, 45, 0, 77), + (0, 53, 0, 77), + (0, 78, 0, 79), + (0, 76, 0, 78), + (0, 78, 0, 162), + (0, 79, 0, 82), + (0, 11, 0, 79), + (0, 69, 0, 80), + (0, 45, 0, 80), + (0, 11, 0, 84), + (0, 69, 0, 84), + (0, 94, 0, 114), + (0, 4, 0, 94), + (0, 4, 0, 96), + (0, 97, 0, 101), + (0, 97, 0, 99), + (0, 98, 0, 99), + (0, 96, 0, 98), + (0, 108, 0, 130), + (0, 108, 0, 109), + (0, 101, 0, 108), + (0, 92, 0, 114), + (0, 107, 0, 130), + (0, 125, 0, 130), + (0, 125, 0, 132), + (0, 149, 0, 171), + (0, 133, 0, 149), + (0, 150, 0, 151), + (0, 151, 0, 171), + (0, 150, 0, 154), + (0, 125, 0, 169), + (0, 119, 0, 169), + (0, 149, 0, 169), + ] + + fault = [(0, 4), (94.0, 100)] + sim = vertex_sim(function_tmpdir) + gwf = sim.get_model() + modelgrid = gwf.modelgrid + hfbs = flopy.utils.make_hfb_array(modelgrid, fault) + for row in hfbs: + cid1 = row.cellid1 + cid2 = row.cellid2 + x = sorted([cid1, cid2]) + test_set = x[0] + x[1] + if test_set not in validation: + raise AssertionError( + f"HFB Line {x[0]} and {x[1]} are outside of validation data set" + ) + + if len(hfbs) != len(validation): + raise AssertionError( + f"HFB data length {len(hfbs)} not equal to validation {len(validation)}" + ) + + +@requires_exe("triangle") +def test_simple_unstructured(function_tmpdir): + validation = [ + (53, 92), + (65, 162), + (64, 65), + (69, 71), + (64, 72), + (45, 77), + (53, 77), + (78, 79), + (76, 78), + (78, 162), + (79, 82), + (11, 79), + (69, 80), + (45, 80), + (11, 84), + (69, 84), + (94, 114), + (4, 94), + (4, 96), + (97, 101), + (97, 99), + (98, 99), + (96, 98), + (108, 130), + (108, 109), + (101, 108), + (92, 114), + (107, 130), + (125, 130), + (125, 132), + (149, 171), + (133, 149), + (150, 151), + (151, 171), + (150, 154), + (125, 169), + (119, 169), + (149, 169), + ] + fault = [(0, 4), (94.0, 100)] + sim = unstructured_sim(function_tmpdir) + gwf = sim.get_model() + modelgrid = gwf.modelgrid + hfbs = flopy.utils.make_hfb_array(modelgrid, fault) + for row in hfbs: + cid1 = row.cellid1 + cid2 = row.cellid2 + x = sorted([cid1, cid2]) + test_set = x[0] + x[1] + if test_set not in validation: + raise AssertionError( + f"HFB Line {x[0]} and {x[1]} are outside of validation data set" + ) + + if len(hfbs) != len(validation): + raise AssertionError( + f"HFB data length {len(hfbs)} not equal to validation {len(validation)}" + ) + + +def test_multisegment_structured(): + validation = [ + (0, 0, 8, 0, 1, 8), + (0, 0, 8, 0, 0, 9), + (0, 1, 7, 0, 1, 8), + (0, 1, 7, 0, 2, 7), + (0, 2, 6, 0, 2, 7), + (0, 3, 6, 0, 4, 6), + (0, 3, 6, 0, 3, 7), + (0, 4, 5, 0, 4, 6), + (0, 4, 5, 0, 5, 5), + (0, 5, 4, 0, 5, 5), + (0, 6, 3, 0, 7, 3), + (0, 6, 3, 0, 6, 4), + (0, 5, 4, 0, 6, 4), + (0, 7, 1, 0, 8, 1), + (0, 6, 2, 0, 7, 2), + (0, 7, 1, 0, 7, 2), + (0, 8, 0, 0, 9, 0), + (0, 8, 0, 0, 8, 1), + ] + + fault = [[0, 11], [55, 45], [89, 100]] + sim = structured_sim() + gwf = sim.get_model() + modelgrid = gwf.modelgrid + hfbs = flopy.utils.make_hfb_array(modelgrid, fault) + for row in hfbs: + cid1 = row.cellid1 + cid2 = row.cellid2 + x = sorted([cid1, cid2]) + test_set = x[0] + x[1] + if test_set not in validation: + raise AssertionError( + f"HFB Line {x[0]} and {x[1]} are outside of validation data set" + ) + + if len(hfbs) != len(validation): + raise AssertionError( + f"HFB data length {len(hfbs)} not equal to validation {len(validation)}" + ) + + +@requires_exe("triangle") +def test_multisegment_vertex(function_tmpdir): + validation = [ + (0, 11, 0, 84), + (0, 11, 0, 79), + (0, 40, 0, 103), + (0, 4, 0, 40), + (0, 40, 0, 114), + (0, 40, 0, 53), + (0, 45, 0, 53), + (0, 45, 0, 77), + (0, 45, 0, 80), + (0, 29, 0, 53), + (0, 64, 0, 72), + (0, 64, 0, 65), + (0, 69, 0, 80), + (0, 69, 0, 71), + (0, 69, 0, 84), + (0, 78, 0, 79), + (0, 76, 0, 78), + (0, 78, 0, 162), + (0, 79, 0, 82), + (0, 97, 0, 110), + (0, 97, 0, 102), + (0, 98, 0, 102), + (0, 98, 0, 103), + (0, 4, 0, 103), + (0, 107, 0, 130), + (0, 107, 0, 108), + (0, 108, 0, 110), + (0, 125, 0, 132), + (0, 125, 0, 130), + (0, 133, 0, 149), + (0, 150, 0, 151), + (0, 150, 0, 154), + (0, 65, 0, 162), + (0, 125, 0, 169), + (0, 119, 0, 169), + (0, 149, 0, 169), + (0, 149, 0, 171), + (0, 151, 0, 171), + ] + + fault = [[0, 11], [55, 45], [89, 100]] + sim = vertex_sim(function_tmpdir) + gwf = sim.get_model() + modelgrid = gwf.modelgrid + hfbs = flopy.utils.make_hfb_array(modelgrid, fault) + for row in hfbs: + cid1 = row.cellid1 + cid2 = row.cellid2 + x = sorted([cid1, cid2]) + test_set = x[0] + x[1] + if test_set not in validation: + raise AssertionError( + f"HFB Line {x[0]} and {x[1]} are outside of validation data set" + ) + + if len(hfbs) != len(validation): + raise AssertionError( + f"HFB data length {len(hfbs)} not equal to validation {len(validation)}" + ) + + +def test_colinear_hfb(): + validation = [ + (0, 0, 4, 0, 0, 5), + (0, 1, 4, 0, 1, 5), + (0, 2, 4, 0, 2, 5), + (0, 3, 4, 0, 3, 5), + (0, 4, 4, 0, 4, 5), + (0, 5, 4, 0, 5, 5), + (0, 6, 4, 0, 6, 5), + (0, 7, 4, 0, 7, 5), + (0, 8, 4, 0, 8, 5), + (0, 9, 4, 0, 9, 5), + ] + + fault = [(50, 0), (50, 100)] + sim = structured_sim() + gwf = sim.get_model() + modelgrid = gwf.modelgrid + hfbs = flopy.utils.make_hfb_array(modelgrid, fault) + for row in hfbs: + cid1 = row.cellid1 + cid2 = row.cellid2 + x = sorted([cid1, cid2]) + test_set = x[0] + x[1] + if test_set not in validation: + raise AssertionError( + f"HFB Line {x[0]} and {x[1]} are outside of validation data set" + ) + + if len(hfbs) != len(validation): + raise AssertionError( + f"HFB data length {len(hfbs)} not equal to validation {len(validation)}" + ) + + +def test_split_cell_hfb(): + validation = [ + (0, 4, 0, 0, 5, 0), + (0, 4, 1, 0, 5, 1), + (0, 4, 2, 0, 5, 2), + (0, 4, 3, 0, 5, 3), + (0, 4, 4, 0, 5, 4), + (0, 4, 5, 0, 5, 5), + (0, 4, 6, 0, 5, 6), + (0, 4, 7, 0, 5, 7), + (0, 4, 8, 0, 5, 8), + (0, 4, 9, 0, 5, 9), + ] + + fault = [(0, 55), (100, 55)] + sim = structured_sim() + gwf = sim.get_model() + modelgrid = gwf.modelgrid + hfbs = flopy.utils.make_hfb_array(modelgrid, fault) + for row in hfbs: + cid1 = row.cellid1 + cid2 = row.cellid2 + x = sorted([cid1, cid2]) + test_set = x[0] + x[1] + if test_set not in validation: + raise AssertionError( + f"HFB Line {x[0]} and {x[1]} are outside of validation data set" + ) + + if len(hfbs) != len(validation): + raise AssertionError( + f"HFB data length {len(hfbs)} not equal to validation {len(validation)}" + ) diff --git a/flopy/discretization/grid.py b/flopy/discretization/grid.py index 4c0b74e50..0a50597c9 100644 --- a/flopy/discretization/grid.py +++ b/flopy/discretization/grid.py @@ -411,6 +411,29 @@ def laycbd(self): else: return self._laycbd + @property + def cell_area(self): + """ + Use shoelace algorithm for non-self-intersecting polygons to + calculate area. + + Returns + ------- + area : np.ndarray + numpy array of cell areas in L^2 + """ + from ..plot.plotutil import UnstructuredPlotUtilities + + xverts, yverts = self.cross_section_vertices + xverts, yverts = UnstructuredPlotUtilities.irregular_shape_patch(xverts, yverts) + area_x2 = np.zeros((1, len(xverts))) + for i in range(xverts.shape[-1]): + # calculate the determinant of each line in polygon + area_x2 += xverts[:, i - 1] * yverts[:, i] - yverts[:, i - 1] * xverts[:, i] + + area = np.abs(area_x2 / 2.0) + return np.ravel(area) + @property def cell_thickness(self): """ diff --git a/flopy/utils/__init__.py b/flopy/utils/__init__.py index bd15a59b8..0da0e3d10 100644 --- a/flopy/utils/__init__.py +++ b/flopy/utils/__init__.py @@ -30,6 +30,7 @@ get_modflow = get_modflow_module.run_main from .gridintersect import GridIntersect +from .hfb_util import make_hfb_array from .mflistfile import ( Mf6ListBudget, MfListBudget, diff --git a/flopy/utils/gridintersect.py b/flopy/utils/gridintersect.py index f8c9f46c8..1304fe41d 100644 --- a/flopy/utils/gridintersect.py +++ b/flopy/utils/gridintersect.py @@ -106,6 +106,8 @@ def __init__(self, mfgrid, rtree=True, local=False): self.geoms, self.cellids = self._rect_grid_to_geoms_cellids() elif self.mfgrid.grid_type == "vertex": self.geoms, self.cellids = self._vtx_grid_to_geoms_cellids() + elif self.mfgrid.grid_type == "unstructured": + self.geoms, self.cellids = self._usg_grid_to_geoms_cellids() else: raise NotImplementedError( f"Grid type {self.mfgrid.grid_type} not supported" @@ -375,7 +377,29 @@ def _usg_grid_to_geoms_cellids(self): cellids : array_like array of cellids """ - raise NotImplementedError() + warnings.warn( + "UnstructuredGrid intersection is experimental", category=UserWarning + ) + shapely = import_optional_dependency("shapely") + if self.local: + geoms = [ + shapely.polygons( + list( + zip( + *self.mfgrid.get_local_coords( + *np.array(self.mfgrid.get_cell_vertices(node)).T + ) + ) + ) + ) + for node in range(self.mfgrid.nnodes) + ] + else: + geoms = [ + shapely.polygons(self.mfgrid.get_cell_vertices(node)) + for node in range(self.mfgrid.nnodes) + ] + return np.array(geoms), np.arange(self.mfgrid.nnodes) def _vtx_grid_to_geoms_cellids(self): """internal method, return shapely polygons and cellids for vertex diff --git a/flopy/utils/hfb_util.py b/flopy/utils/hfb_util.py new file mode 100644 index 000000000..97d6c8398 --- /dev/null +++ b/flopy/utils/hfb_util.py @@ -0,0 +1,366 @@ +import numpy as np +import pandas as pd + +from .geometry import distance +from .geospatial_utils import GeoSpatialUtil +from .gridintersect import GridIntersect + + +def _min_distance_index(x0, x1, y0, y1): + """ + Method to get the index of the minimum distance between points + + Parameters + ---------- + x0 : np.ndarray + array of x coordinates + x1 : float + x coordinate of a point + y0 : np.ndarray + array of y coordinates + y1 : float + y coordinate of a point + + Returns + ------- + int : index location + """ + dist = distance(x0, y0, x1, y1) + idx = np.where(dist == np.min(dist))[0] + return idx + + +def _edge_length_lut(xyverts): + """ + Method to create a look-up table based on index of + grid cell edge lengths + + Parameters + ---------- + xyverts: np.ndarray + numpy array of xy coordinate pairs + + Returns + ------- + dict + """ + xy0 = xyverts.T[:, :-1] + xy1 = xyverts.T[:, 1:] + dist = distance(xy0[0], xy0[1], xy1[0], xy1[1]) + dist_lu = {} + for v, d in enumerate(dist): + if v < len(dist) - 1: + dist_lu[(v, v + 1)] = d + else: + dist_lu[(0, v)] = d + return dist_lu + + +def _minimize_hfb_deviance(idxs, xyverts, pts): + """ + Method to minimumize the distance of the HFB fault trace from + the intersection points of the fault and model grid cell. Used + primarily for tie-breakers where there is not a clear "routing" + option based on vertex to vertex edge length distance + + Parameters + ---------- + idxs : iterable + list, tuple, or numpy array of polygon vertex indices for test hfb path + xyverts : np.array + numpy array of (x,y) polygon vertices + pts : np.array + numpy array of (x,y) points of intersection between line and polygon + + Returns + ------- + float : sum of minimum distances between edge centroids across a potential + fault routing option and the intersection points between the fault line + and grid node + """ + lcs = [] + xyverts = xyverts.T + pts = pts.T + for ix in range(1, len(idxs)): + ixx = (idxs[ix - 1], idxs[ix]) + xc = np.mean(xyverts[0, ixx]) + yc = np.mean(xyverts[1, ixx]) + lcs.append([xc, yc]) + + mins = [] + for xc, yc in lcs: + dists = distance(pts[0], pts[1], xc, yc) + mins.append(np.min(dists)) + + return np.sum(mins) + + +def _perturb_intersection_coords(idxs, xyverts, ipt, epsilon=1e-06): + """ + Method to perturb an intersection location by a small amount which is used + to handle colinear intersection and intersection at the midpoint of a cell edge. + + Parameters + ---------- + idxs : list + list of vertex indices + xyverts : np.array + numpy array of x,y vertices for a cell + ipt : iterable + intersection point x,y coordinate + epsilon : float + perturbation value + + Returns + ------- + ipt : list holding x, y coordinate pair of the perturbed intersection + """ + xyverts = xyverts.T + xverts = xyverts[0][idxs] + yverts = xyverts[1][idxs] + + if (xverts[0] - xverts[1]) == 0: + # vertical line + new_vrt = [xverts[0], np.mean(yverts) - epsilon] + elif (yverts[0] - yverts[1]) == 0: + # horizontal line + new_vrt = [np.mean(xverts) - epsilon, yverts[0]] + else: + # need to adjust across the line + m = (yverts[1] - yverts[0]) / [xverts[1] - xverts[0]] + if xverts[0] != 0: + vidx = 0 + else: + vidx = 1 + b = yverts[vidx] / (m * xverts[vidx]) + cx = ipt[0] - epsilon + cy = m * cx + b + new_vrt = [cx, cy] + + ipt = new_vrt + return ipt + + +def _edge_neighbors(modelgrid): + """ + Method to get a dictionary of unique node edges (by ivert) and the nodes the + edge is shared between + + Parameters + ---------- + modelgrid : flopy.discretization.Grid object + + Returns + ------- + dict : dictionary of {edge iverts : [nodes]} + """ + node_num = 0 + geoms = [] + node_nums = [] + + for poly in modelgrid.iverts: + poly = [int(i) for i in poly] + if poly[0] == poly[-1]: + poly = poly[:-1] + for v in range(len(poly)): + geoms.append(tuple(sorted([poly[v - 1], poly[v]]))) + node_nums += [node_num] * len(poly) + node_num += 1 + + edge_nodes = {} + for i, item in enumerate(geoms): + if item not in edge_nodes: + edge_nodes[item] = { + node_nums[i], + } + else: + edge_nodes[item].add(node_nums[i]) + + return edge_nodes + + +def make_hfb_array(modelgrid, geom): + """ + Method to make a HFB recarray from geospatial linestring information. + Note: this method was developed to only accept a single linestring at a + time to give the user an opportunity to save unique fault information + for calibration purposes. + + Parameters + ---------- + modelgrid : flopy.discretization.Grid object + FloPy StructuredGrid, VertexGrid, and UnstructuredGrid are supported + geom : geospatial object + geom parameter is a geospatial LineString representation. + Shapely.geometry.LineString, GeoJson, List of vertices, + shapefile.shape types are supported. + + Returns + ------- + np.recarray + numpy recarray of cellid1, cellid2, and hydchr. The hydchr field must + be set by the user, is set to NaN, and is provided for convenience. + """ + gu = GeoSpatialUtil(geom, "LineString") + if gu.shapetype.lower() != "linestring": + raise AssertionError( + f"{gu.shapetype} is not supported, only LineStrings are supported" + ) + + geom = gu.points + + if modelgrid.idomain is not None: + idomain = modelgrid.idomain.ravel() + else: + idomain = np.ones((modelgrid.nnodes,), dtype=int) + edge_set = _edge_neighbors(modelgrid) + vert_ivert = {tuple(i): cnt for cnt, i in enumerate(modelgrid.verts)} + iverts = modelgrid.iverts + xverts, yverts = modelgrid.cross_section_vertices + verts = [] + for node, xv in enumerate(xverts): + yv = yverts[node] + vrts = list(zip(xv, yv)) + if len(vrts) == len(iverts[node]): + if iverts[node][0] != iverts[node][-1]: + vrts.append(vrts[0]) + verts.append(np.array(vrts)) + + ixs = GridIntersect(modelgrid) + result = ixs.intersect(geom, shapetype="LineString") + result_adj = [] + for record in result: + node = record.cellid + ixshp = record.ixshapes + # todo: numpy array this and Transpose + coords = np.array(ixshp.coords.xy).T + x0, y0 = coords[0, 0], coords[0, 1] + x1, y1 = coords[-1, 0], coords[-1, 1] + + xycell = verts[node] + xcell = xycell.T[0][:-1] + ycell = xycell.T[1][:-1] + + vidx0 = _min_distance_index(xcell, x0, ycell, y0) + if len(vidx0) > 1: + # perturb line by small epsilon + coords[0] = _perturb_intersection_coords(vidx0, xycell, coords[0]) + x0, y0 = coords[0, 0], coords[0, 1] + vidx0 = _min_distance_index(xcell, x0, ycell, y0) + + vidx0 = vidx0[0] + + vidx1 = _min_distance_index(xcell, x1, ycell, y1) + if len(vidx1) > 1: + # perturb line by small epsilon + coords[-1] = _perturb_intersection_coords(vidx1, xycell, coords[-1]) + x1, y1 = coords[-1, 0], coords[-1, 1] + vidx1 = _min_distance_index(xcell, x1, ycell, y1) + + vidx1 = vidx1[0] + + if vidx0 == vidx1: + continue + + tmp = tuple(sorted([vidx0, vidx1])) + + if tmp[1] - tmp[0] > 1: + nvert = len(xycell) - 1 + elens = _edge_length_lut(xycell) + # construct line routing options + o1 = list(range(tmp[0], tmp[1] + 1)) + o2 = list(range(tmp[1], nvert)) + list(range(0, tmp[0] + 1)) + # calculate routing distance + d1 = np.sum( + [elens[tuple(sorted([o1[ix - 1], o1[ix]]))] for ix in range(1, len(o1))] + ) + d2 = np.sum( + [elens[tuple(sorted([o2[ix - 1], o2[ix]]))] for ix in range(1, len(o2))] + ) + + # evaluate distance and break ties if necessary + if d1 < d2: + tmp = o1 + elif d2 < d1: + tmp = o2 + else: + om1 = _minimize_hfb_deviance(o1, xycell, coords) + om2 = _minimize_hfb_deviance(o2, xycell, coords) + if om1 <= om2: + tmp = o1 + else: + tmp = o2 + + edges = [] + for ix in range(1, len(tmp)): + eix0 = tmp[ix - 1] + eix1 = tmp[ix] + + xyv0 = tuple(xycell[eix0]) + xyv1 = tuple(xycell[eix1]) + + iv0 = vert_ivert[xyv0] + iv1 = vert_ivert[xyv1] + + edges.append(tuple(sorted([iv0, iv1]))) + + hfb_neighs = [] + for edge in edges: + for n in edge_set[edge]: + if n == node: + continue + hfb_neighs.append(n) + + res = [int(record.cellid), hfb_neighs] + result_adj.append(res) + + hfb_data = [] + visited = [] + if modelgrid.nlay is not None and modelgrid.grid_type != "unstructured": + for lay in range(modelgrid.nlay): + ncpl_adj = lay * modelgrid.ncpl + for cid0, hfb_neighs in result_adj: + if not idomain[cid0 + ncpl_adj]: + continue + + if modelgrid.grid_type == "structured": + cellid0 = modelgrid.get_lrc(cid0 + ncpl_adj)[0] + else: + cellid0 = (lay, cid0) + + for cid1 in hfb_neighs: + if not idomain[cid1 + ncpl_adj]: + continue + + if modelgrid.grid_type == "structured": + cellid1 = modelgrid.get_lrc(cid1 + ncpl_adj)[0] + else: + cellid1 = (lay, cid1) + + if cellid0 + cellid1 in visited: + continue + elif cellid1 + cellid0 in visited: + continue + + visited.append(cellid0 + cellid1) + hfb_data.append((cellid0, cellid1)) + else: + for cellid0, hfb_neighs in result_adj: + if not idomain[cellid0]: + continue + + for cellid1 in hfb_neighs: + if not idomain[cellid1]: + continue + + if (cellid0, cellid1) in visited: + continue + elif (cellid1, cellid0) in visited: + continue + + visited.append((cellid0, cellid1)) + hfb_data.append(((cellid0,), (cellid1,))) + + df = pd.DataFrame(hfb_data, columns=["cellid1", "cellid2"]) + df["hydchr"] = np.nan + return df.to_records(index=False) diff --git a/flopy/utils/voronoi.py b/flopy/utils/voronoi.py index c9085e2c4..e2ef04f49 100644 --- a/flopy/utils/voronoi.py +++ b/flopy/utils/voronoi.py @@ -4,7 +4,7 @@ import numpy as np from .cvfdutil import get_disv_gridprops -from .geometry import point_in_polygon +from .geometry import distance, point_in_polygon from .utl_import import import_optional_dependency @@ -309,8 +309,92 @@ def get_disu5_gridprops(self): raise NotImplementedError(msg) def get_disu6_gridprops(self): - msg = "This method is not implemented yet." - raise NotImplementedError(msg) + """ + Get a dictionary of arguments that can be passed in to the + flopy.mf6.ModflowGwfdisu class + + Returns + ------- + disu_gridprops : dict + Dictionary of arguments that can be unpacked into the + flopy.mf6.ModflowGwfdisu constructor + + """ + from ..discretization import UnstructuredGrid + + gridprops = self.get_gridprops_unstructuredgrid() + ugrid = UnstructuredGrid(**gridprops) + + xcenters = ugrid.xcellcenters + ycenters = ugrid.ycellcenters + xvertices = ugrid.xvertices + yvertices = ugrid.yvertices + iverts = ugrid.iverts + neighbors = ugrid.neighbors() + iac = [] + ja = [] + ihc = [] + cl12 = [] + hwva = [] + angldegx = [] + for node, nnodes in neighbors.items(): + iac.append(len(nnodes) + 1) + ja.extend([node] + nnodes) + ihc.extend( + [ + 1, + ] + * iac[-1] + ) + # cell center to cell center distance... + cl12.append(0) + hwva.append(0) + angldegx.append(0) + + xc = xcenters[node] + yc = ycenters[node] + nxc = xcenters[nnodes] + nyc = ycenters[nnodes] + dists = distance(nxc, nyc, xc, yc) + cl12.extend(dists) + + angles = np.arctan2(nxc - xc, nyc - yc) * 180.0 / np.pi + angles = np.where(angles < 0, angles + 360, angles) + angldegx.extend(angles) + + ivrts = iverts[node] + xverts = xvertices[node] + yverts = yvertices[node] + for n in nnodes: + nivrts = iverts[n] + common = set(ivrts) & set(nivrts) + idxs = [ivrts.index(i) for i in common] + hwv = distance( + xverts[idxs[0]], yverts[idxs[0]], xverts[idxs[1]], yverts[idxs[1]] + ) + hwva.append(hwv) + + cell2d = [] + for ix, ivrts in enumerate(iverts): + rec = [ix, xcenters[ix], ycenters[ix], len(ivrts)] + ivrts + cell2d.append(rec) + + gridprops["iac"] = iac + gridprops["ja"] = ja + gridprops["ihc"] = ihc + gridprops["cl12"] = cl12 + gridprops["hwva"] = hwva + gridprops["angldegx"] = angldegx + gridprops["area"] = ugrid.cell_area + gridprops["nodes"] = len(iac) + gridprops["nja"] = len(ja) + gridprops["nvert"] = len(gridprops["vertices"]) + gridprops["cell2d"] = cell2d + gridprops.pop("ncpl") + gridprops.pop("iverts") + gridprops.pop("xcenters") + gridprops.pop("ycenters") + return gridprops def get_gridprops_vertexgrid(self): """ From b2ceda31a620b135ea4cafc10f55e2cc148af4d9 Mon Sep 17 00:00:00 2001 From: Joshua Larsen Date: Tue, 12 May 2026 16:30:39 -0700 Subject: [PATCH 30/90] update(intersect): update days calculation to fix dropped precision (#2754) * update(intersect): update days calculation to fix dropped precision * replace timedelta.days with timedelta.total_seconds() / 86400 --- autotest/test_modeltime.py | 4 ++-- flopy/discretization/modeltime.py | 9 ++++----- flopy/utils/hfb_util.py | 1 - 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/autotest/test_modeltime.py b/autotest/test_modeltime.py index 966d21d6b..fbdb54e33 100644 --- a/autotest/test_modeltime.py +++ b/autotest/test_modeltime.py @@ -270,7 +270,7 @@ def test_mf2005_modeltime(): raise AssertionError("start_datetime improperly stored") result = modeltime.intersect("3/06/2024 23:59:59") - if result != (2, 0): + if result != (2, 1): raise AssertionError("ModelTime intersect not working correctly") @@ -317,7 +317,7 @@ def test_mf6_modeltime(): raise AssertionError("start_datetime improperly stored") result = modeltime.intersect("3/06/2024 23:59:59") - if result != (2, 0): + if result != (2, 1): raise AssertionError("ModelTime intersect not working correctly") diff --git a/flopy/discretization/modeltime.py b/flopy/discretization/modeltime.py index f072d1107..2056c0110 100644 --- a/flopy/discretization/modeltime.py +++ b/flopy/discretization/modeltime.py @@ -618,15 +618,14 @@ def intersect( "objects, set time units or use totim for intersection" ) - elif self.time_units == "days": - totim = timedelta.days - - elif self.time_units in {"hours", "minutes", "seconds"}: + elif self.time_units in {"days", "hours", "minutes", "seconds"}: totim = timedelta.total_seconds() if self.time_units == "minutes": totim /= 60 elif self.time_units == "hours": totim /= 3600 + elif self.time_units == "days": + totim /= 86400.0 else: # years condition @@ -647,7 +646,7 @@ def intersect( ) timedelta = datetime_obj - dt_iyear - days = timedelta.days + days = timedelta.total_seconds() / 86400.0 yr_frac = days / ndays totim += yr_frac diff --git a/flopy/utils/hfb_util.py b/flopy/utils/hfb_util.py index 97d6c8398..036d33c87 100644 --- a/flopy/utils/hfb_util.py +++ b/flopy/utils/hfb_util.py @@ -232,7 +232,6 @@ def make_hfb_array(modelgrid, geom): for record in result: node = record.cellid ixshp = record.ixshapes - # todo: numpy array this and Transpose coords = np.array(ixshp.coords.xy).T x0, y0 = coords[0, 0], coords[0, 1] x1, y1 = coords[-1, 0], coords[-1, 1] From fe620d5f076e21c6084c382efb8315dc48e6dbf0 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 14 May 2026 08:52:31 -0400 Subject: [PATCH 31/90] test(get-modflow): accommodate zonbud3 -> zonbud rename (#2756) --- autotest/test_get_modflow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/autotest/test_get_modflow.py b/autotest/test_get_modflow.py index e73e273eb..b8506dea9 100644 --- a/autotest/test_get_modflow.py +++ b/autotest/test_get_modflow.py @@ -52,7 +52,7 @@ "triangle", "vs2dt", "zbud6", - "zonbud3", + "zonbud", "zonbudusg", "libmf6", ], From 9bc7aac3c7179c143950a064c6cd2e355d21e6ab Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Sat, 16 May 2026 06:59:03 -0400 Subject: [PATCH 32/90] test: accommodate slight triangle grid variations (#2757) Triangle can give slightly different results depending on platform and compiler --- autotest/test_grid.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/autotest/test_grid.py b/autotest/test_grid.py index 8b6b53a5b..d19347c30 100644 --- a/autotest/test_grid.py +++ b/autotest/test_grid.py @@ -2,6 +2,7 @@ import re import warnings from contextlib import nullcontext +from platform import system from warnings import warn import matplotlib @@ -1492,17 +1493,9 @@ def test_voronoi_grid(request, function_tmpdir, grid_case): name = request.node.name.replace("/", "_").replace("\\", "_").replace(":", "_") ncpl, vor, gridprops, grid = grid_case() - # TODO: debug off-by-3 issue - # could be a rounding error as described here: - # https://github.com/modflowpy/flopy/issues/1492#issuecomment-1210596349 - - # ensure proper number of cells - almost_right = ncpl == 538 and gridprops["ncpl"] == 535 - if almost_right: - warn("off-by-3") - # ensure that all cells have 3 or more points invalid_cells = [i for i, ivts in enumerate(vor.iverts) if len(ivts) < 3] + assert len(invalid_cells) == 0 # make a plot including invalid cells fig = plt.figure(figsize=(10, 10)) @@ -1512,10 +1505,12 @@ def test_voronoi_grid(request, function_tmpdir, grid_case): ax.plot(grid.xcellcenters[invalid_cells], grid.ycellcenters[invalid_cells], "ro") plt.savefig(function_tmpdir / f"{name}.png") - assert ncpl == gridprops["ncpl"] or almost_right - assert len(invalid_cells) == 0, ( - f"The following cells do not have 3 or more vertices.\n{invalid_cells}" - ) + # ensure proper number of cells. the grid may be slightly different + # on different platforms depending which compiler triangle is built + # with, so allow small variations. + tol = 10 if system() == "Windows" else 3 + assert abs(ncpl - gridprops["ncpl"]) <= tol + assert len(invalid_cells) == 0 @pytest.fixture From 0eaf1382b09247162c12650244ce541c60625b1b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 06:54:46 -0700 Subject: [PATCH 33/90] chore(deps): bump prefix-dev/setup-pixi from 0.9.5 to 0.9.6 (#2759) --- .github/workflows/commit.yml | 2 +- .github/workflows/mf6.yml | 4 ++-- .github/workflows/rtd.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index 9dcda5b5a..7f9cafcc2 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -150,7 +150,7 @@ jobs: version: 13 - name: Setup pixi - uses: prefix-dev/setup-pixi@v0.9.5 + uses: prefix-dev/setup-pixi@v0.9.6 with: pixi-version: v0.41.4 manifest-path: modflow6/pixi.toml diff --git a/.github/workflows/mf6.yml b/.github/workflows/mf6.yml index 49e9806de..da692e974 100644 --- a/.github/workflows/mf6.yml +++ b/.github/workflows/mf6.yml @@ -39,7 +39,7 @@ jobs: version: 13 - name: Setup pixi - uses: prefix-dev/setup-pixi@v0.9.5 + uses: prefix-dev/setup-pixi@v0.9.6 with: pixi-version: v0.41.4 manifest-path: modflow6/pixi.toml @@ -122,7 +122,7 @@ jobs: version: 13 - name: Setup pixi - uses: prefix-dev/setup-pixi@v0.9.5 + uses: prefix-dev/setup-pixi@v0.9.6 with: pixi-version: v0.41.4 manifest-path: modflow6/pixi.toml diff --git a/.github/workflows/rtd.yml b/.github/workflows/rtd.yml index af0b01379..bb89af287 100644 --- a/.github/workflows/rtd.yml +++ b/.github/workflows/rtd.yml @@ -87,7 +87,7 @@ jobs: echo $GITHUB_EVENT_NAME - name: Setup pixi - uses: prefix-dev/setup-pixi@v0.9.5 + uses: prefix-dev/setup-pixi@v0.9.6 with: pixi-version: v0.41.4 manifest-path: modflow6/pixi.toml From e9f513d3a17cda9698477752599c42c75778fb7b Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Tue, 26 May 2026 14:02:00 -0400 Subject: [PATCH 34/90] test: relax grid comparison (#2760) followup on #2757 --- autotest/test_grid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/autotest/test_grid.py b/autotest/test_grid.py index d19347c30..9ff8a1608 100644 --- a/autotest/test_grid.py +++ b/autotest/test_grid.py @@ -1508,7 +1508,7 @@ def test_voronoi_grid(request, function_tmpdir, grid_case): # ensure proper number of cells. the grid may be slightly different # on different platforms depending which compiler triangle is built # with, so allow small variations. - tol = 10 if system() == "Windows" else 3 + tol = 10 assert abs(ncpl - gridprops["ncpl"]) <= tol assert len(invalid_cells) == 0 From 579507e227149d481212148835420d2af2da3eed Mon Sep 17 00:00:00 2001 From: JLarsen Date: Thu, 28 May 2026 14:07:49 -0700 Subject: [PATCH 35/90] update(splitter): updates for advanced transport packages (#2761) * update(splitter): updates for advanced transport packages * update flow package name mapping for transport models --------- Co-authored-by: jlarsen-usgs --- flopy/mf6/utils/model_splitter.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/flopy/mf6/utils/model_splitter.py b/flopy/mf6/utils/model_splitter.py index e5b3f13e1..4966e491a 100644 --- a/flopy/mf6/utils/model_splitter.py +++ b/flopy/mf6/utils/model_splitter.py @@ -1677,10 +1677,23 @@ def _remap_adv_transport(self, package, item, pkg_remap, mapped_data): """ flow_package_name = package.flow_package_name.array packagedata = package.packagedata.array - perioddata = package.perioddata.data + if isinstance(package, modflow.ModflowGwtsft): + key = "reachperioddata" + perioddata = package.reachperioddata.data + elif isinstance(package, modflow.ModflowGwtlkt): + key = "lakeperioddata" + perioddata = package.lakeperioddata.data + elif isinstance(package, modflow.ModflowGwtuzt): + key = "uztperioddata" + perioddata = package.uztperioddata.data + elif isinstance(package, modflow.ModflowGwtmwt): + key = "mwtperioddata" + perioddata = package.mwtperioddata.data + else: + key = "perioddata" + perioddata = package.perioddata.data for mkey in self._model_dict.keys(): - flow_package_const = flow_package_name.split(".") new_packagedata = self._remap_adv_tag( mkey, packagedata, item, pkg_remap ) @@ -1694,10 +1707,9 @@ def _remap_adv_transport(self, package, item, pkg_remap, mapped_data): ) spd[per] = new_recarray - flow_package_const[-2] += f"_{mkey :0{self._fdigits}d}" - new_flow_package_name = ".".join(flow_package_const) + new_flow_package_name = flow_package_name mapped_data[mkey]["packagedata"] = new_packagedata - mapped_data[mkey]["perioddata"] = spd + mapped_data[mkey][key] = spd mapped_data[mkey]["flow_package_name"] = new_flow_package_name return mapped_data @@ -2038,7 +2050,7 @@ def _remap_sfr(self, package, mapped_data): connectiondata = package.connectiondata.array diversions = package.diversions.array perioddata = package.perioddata.data - name = package.filename + name = package.name[0] self._sfr_remaps[name] = {} sfr_remaps = {} div_mvr_conn = {} From 866bb586380d934f18e7e666357e6625cbdf874c Mon Sep 17 00:00:00 2001 From: Joshua Larsen Date: Thu, 28 May 2026 14:08:15 -0700 Subject: [PATCH 36/90] update(ModflowWel): add basic support for mfnwt tabfile based well files (#2755) * update(intersect): update days calculation to fix dropped precision * replace timedelta.days with timedelta.total_seconds() / 86400 * linting * update test_modeltime.py, remove note in hfb_util.py * update(ModflowWel): add basic support for tabfile based well files * Linting * remove "flux" sfac_column for tabfile wel records * update tabfile option check * update aux variable check for tabfile type list records * update option block check for tabfiles --- flopy/modflow/mfwel.py | 48 ++++++++++++++++++++++++++++++------------ flopy/pakbase.py | 14 ++++++++++++ 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/flopy/modflow/mfwel.py b/flopy/modflow/mfwel.py index 86c815c37..be6324819 100644 --- a/flopy/modflow/mfwel.py +++ b/flopy/modflow/mfwel.py @@ -163,6 +163,7 @@ def __init__( self.url = "wel.html" self.np = 0 + tabfiles = False if options is None: options = [] self.specify = False @@ -178,6 +179,9 @@ def __init__( self.phiramp = self.options.phiramp self.iunitramp = self.options.iunitramp # this is to grab the aux variables... + if self.options.tabfiles: + tabfiles = True + options = [] else: @@ -196,7 +200,9 @@ def __init__( self.dtype = self.get_default_dtype(structured=self.parent.structured) # determine if any aux variables in dtype - dt = self.get_default_dtype(structured=self.parent.structured) + dt = self.get_default_dtype( + structured=self.parent.structured, tabfiles=tabfiles + ) if len(self.dtype.names) > len(dt.names): for name in self.dtype.names[len(dt.names) :]: ladd = True @@ -295,24 +301,38 @@ def add_record(self, kper, index, values): raise Exception(f"mfwel error adding record to list: {e!s}") @staticmethod - def get_default_dtype(structured=True): - if structured: - dtype = np.dtype( - [ - ("k", int), - ("i", int), - ("j", int), - ("flux", np.float32), - ] - ) + def get_default_dtype(structured=True, tabfiles=False): + if not tabfiles: + if structured: + dtype = np.dtype( + [ + ("k", int), + ("i", int), + ("j", int), + ("flux", np.float32), + ] + ) + else: + dtype = np.dtype([("node", int), ("flux", np.float32)]) else: - dtype = np.dtype([("node", int), ("flux", np.float32)]) + if structured: + dtype = np.dtype( + [ + ("tabunit", int), + ("tabval", int), + ("k", int), + ("i", int), + ("j", int), + ] + ) + else: + dtype = np.dtype([("tabunit", int), ("tabval", int), ("node", int)]) return dtype @staticmethod - def get_empty(ncells=0, aux_names=None, structured=True): + def get_empty(ncells=0, aux_names=None, structured=True, tabfiles=False): # get an empty recarray that corresponds to dtype - dtype = ModflowWel.get_default_dtype(structured=structured) + dtype = ModflowWel.get_default_dtype(structured=structured, tabfiles=tabfiles) if aux_names is not None: dtype = Package.add_to_dtype(dtype, aux_names, np.float32) return create_empty_recarray(ncells, dtype, default_value=-1.0e10) diff --git a/flopy/pakbase.py b/flopy/pakbase.py index b9bb0d9fa..64c744e94 100644 --- a/flopy/pakbase.py +++ b/flopy/pakbase.py @@ -961,6 +961,8 @@ def load( nwt_options = OptionBlock.load_options(f, pak_type) line = f.readline() + nwt_tabfiles = False + # check for parameters nppak = 0 if "parameter" in line.lower(): @@ -1062,6 +1064,11 @@ def load( options = nwt_options else: f.seek(ipos) + + if isinstance(options, OptionBlock): + if options.tabfiles: + nwt_tabfiles = True + elif "flopy.modflow.mfchd.modflowchd".lower() in pak_type_str: partype = ["shead", "ehead"] @@ -1085,6 +1092,13 @@ def load( bnd_output_cln = None stress_period_data_cln = {} current_cln = None + + if nwt_tabfiles: + # pass tabfile flag using the existing usg_args dict, change nper to 1 + nper = 1 + sfac_columns = [] + usg_args["tabfiles"] = True + for iper in range(nper): if model.verbose: msg = f" loading {pak_type} for kper {iper + 1:5d}" From c4d445e16ae8f82902aaa8fbaf8ddece0c000243 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Tue, 2 Jun 2026 10:34:39 -0400 Subject: [PATCH 37/90] fix(mf6): defensive devtools compatibility fix (#2762) Compatibility fix anticipating some minor changes in devtools. Comment header lines will soon be returned verbatim, without stripping the leading "# [flopy] [mf6]". --- flopy/mf6/utils/codegen/filters.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flopy/mf6/utils/codegen/filters.py b/flopy/mf6/utils/codegen/filters.py index 9f7b52636..af7dc75fd 100644 --- a/flopy/mf6/utils/codegen/filters.py +++ b/flopy/mf6/utils/codegen/filters.py @@ -363,6 +363,7 @@ def _filter_metadata(metadata): legacy_dfn = dfn.get("legacy_dfn", {}) legacy_meta = dfn.get("legacy_meta", []) + legacy_meta = [s.replace("#", "").replace("flopy", "").replace("mf6", "").strip() for s in legacy_meta] legacy_dfn = _dfn(legacy_dfn, _filter_metadata(legacy_meta)) if component_base == "MFPackage": attrs.extend( From 140d5c20276c836038febbbc6ae9c38bcbd4d7f4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:28:09 -0700 Subject: [PATCH 38/90] chore(deps): bump codecov/codecov-action from 6 to 7 (#2764) --- .github/workflows/commit.yml | 4 ++-- .github/workflows/mf6.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index 7f9cafcc2..dd89c0a31 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -103,7 +103,7 @@ jobs: - name: Upload coverage if: github.repository_owner == 'modflowpy' && (github.event_name == 'push' || github.event_name == 'pull_request') - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: files: autotest/coverage.xml @@ -194,6 +194,6 @@ jobs: - name: Upload coverage if: github.repository_owner == 'modflowpy' && (github.event_name == 'push' || github.event_name == 'pull_request') - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: files: flopy/autotest/coverage.xml diff --git a/.github/workflows/mf6.yml b/.github/workflows/mf6.yml index da692e974..f402c3946 100644 --- a/.github/workflows/mf6.yml +++ b/.github/workflows/mf6.yml @@ -86,7 +86,7 @@ jobs: - name: Upload coverage to Codecov if: github.repository_owner == 'modflowpy' && (github.event_name == 'push' || github.event_name == 'pull_request') - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: files: ./modflow6/autotest/coverage.xml From 8a2d0552ab48f0b7fdc4a952d416f690c9b3cab0 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Mon, 8 Jun 2026 15:23:28 -0400 Subject: [PATCH 39/90] fix(GeoSpatialUtil): accommodate stricter pyshp API (#2765) pyshp 3.0.11 got stricter about its arguments: https://github.com/GeospatialPython/pyshp#3011 --- .github/workflows/commit.yml | 2 +- .github/workflows/mf6.yml | 4 ++-- .github/workflows/rtd.yml | 2 +- flopy/utils/geospatial_utils.py | 11 ++++++++++- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index dd89c0a31..f3ab791b1 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -152,7 +152,7 @@ jobs: - name: Setup pixi uses: prefix-dev/setup-pixi@v0.9.6 with: - pixi-version: v0.41.4 + pixi-version: v0.70.1 manifest-path: modflow6/pixi.toml - name: Install dependencies diff --git a/.github/workflows/mf6.yml b/.github/workflows/mf6.yml index f402c3946..adc8a7341 100644 --- a/.github/workflows/mf6.yml +++ b/.github/workflows/mf6.yml @@ -41,7 +41,7 @@ jobs: - name: Setup pixi uses: prefix-dev/setup-pixi@v0.9.6 with: - pixi-version: v0.41.4 + pixi-version: v0.70.1 manifest-path: modflow6/pixi.toml - name: Install dependencies @@ -124,7 +124,7 @@ jobs: - name: Setup pixi uses: prefix-dev/setup-pixi@v0.9.6 with: - pixi-version: v0.41.4 + pixi-version: v0.70.1 manifest-path: modflow6/pixi.toml - name: Install dependencies diff --git a/.github/workflows/rtd.yml b/.github/workflows/rtd.yml index bb89af287..7da3cbb97 100644 --- a/.github/workflows/rtd.yml +++ b/.github/workflows/rtd.yml @@ -89,7 +89,7 @@ jobs: - name: Setup pixi uses: prefix-dev/setup-pixi@v0.9.6 with: - pixi-version: v0.41.4 + pixi-version: v0.70.1 manifest-path: modflow6/pixi.toml - name: Install Python dependencies diff --git a/flopy/utils/geospatial_utils.py b/flopy/utils/geospatial_utils.py index 8164afc3e..69c5d05b2 100644 --- a/flopy/utils/geospatial_utils.py +++ b/flopy/utils/geospatial_utils.py @@ -210,7 +210,16 @@ def shape(self): """ if self.__shapefile is not None: if self._shape is None: - self._shape = self.__shapefile.Shape._from_geojson(self.__geo_interface) + geo_iface = self.__geo_interface + # pyshp >= 3.0.11 requires list (not tuple) for Point coordinates + if geo_iface.get("type") == "Point" and isinstance( + geo_iface.get("coordinates"), tuple + ): + geo_iface = { + **geo_iface, + "coordinates": list(geo_iface["coordinates"]), + } + self._shape = self.__shapefile.Shape._from_geojson(geo_iface) return self._shape @property From c27af43d2be01e335ed3d908c2af67cd5f851b78 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Mon, 8 Jun 2026 16:00:49 -0400 Subject: [PATCH 40/90] fix(mfdataplist): raise proper MFDataException on wrong col count (#2766) Fix a minor issue mentioned in #2758. When a dataframe passed to set_data() has the wrong column count, the intended error would not be raised, instead you'd get a KeyError. --- autotest/test_mf6.py | 24 ++++++++++++++++++++++++ flopy/mf6/data/mfdataplist.py | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/autotest/test_mf6.py b/autotest/test_mf6.py index 4dd29ad5c..3b0912b11 100644 --- a/autotest/test_mf6.py +++ b/autotest/test_mf6.py @@ -4,6 +4,7 @@ from shutil import copytree, which import numpy as np +import pandas as pd import pytest from modflow_devtools.markers import requires_exe, requires_pkg from modflow_devtools.misc import set_dir @@ -58,6 +59,7 @@ ) from flopy.mf6.data.mffileaccess import MFFileAccessArray from flopy.mf6.data.mfstructure import MFDataItemStructure, MFDataStructure +from flopy.mf6.mfbase import MFDataException from flopy.mf6.mfsimbase import MFSimulationData from flopy.mf6.modflow import ( mfgwf, @@ -1450,6 +1452,28 @@ def test_get_set_data_record(function_tmpdir): sim.write_simulation() +def test_set_data_dataframe_column_mismatch_error(function_tmpdir): + """ + DataFrame with a wrong column count should raise MFDataException. + Previously the error message code referenced len(data[0]), which on a + DataFrame accesses the column named 0 and raises KeyError instead. + """ + sim = MFSimulation(sim_ws=str(function_tmpdir), exe_name="mf6") + ModflowTdis(sim, nper=1, perioddata=[(1.0, 1, 1.0)]) + ModflowIms(sim) + gwf = ModflowGwf(sim, modelname="gwf") + ModflowGwfdis(gwf, nlay=1, nrow=10, ncol=10) + ModflowGwfic(gwf, strt=0.0) + ModflowGwfnpf(gwf) + ModflowGwfwel(gwf, stress_period_data={0: [[(0, 0, 0), -1.0]]}) + spd = gwf.get_package("WEL").stress_period_data + + # 3 columns: matches neither _header_names (4) nor _data_item_names (2) + df = pd.DataFrame({"a": [0], "b": [3], "c": [-500.0]}) + with pytest.raises(MFDataException): + spd.set_data({0: df}) + + @requires_exe("mf6") def test_output(function_tmpdir, example_data_path): ex_name = "test001e_UZF_3lay" diff --git a/flopy/mf6/data/mfdataplist.py b/flopy/mf6/data/mfdataplist.py index e2377d490..c5adfb6e4 100644 --- a/flopy/mf6/data/mfdataplist.py +++ b/flopy/mf6/data/mfdataplist.py @@ -757,7 +757,7 @@ def set_data(self, data, autofill=False, check_data=True, append=False): message = ( f"ERROR: Data list {self._data_name} supplied the " f"wrong number of columns of data, expected " - f"{len(self._data_item_names)} got {len(data[0])}.\n" + f"{len(self._data_item_names)} got {len(data.columns)}.\n" f"Data columns supplied: {data.columns}\n" f"Data columns expected: {self._header_names}" ) From 228668c548e97fcfdeea71bb354df99e2eb1065d Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Mon, 8 Jun 2026 21:53:30 -0400 Subject: [PATCH 41/90] fix(mfdataplist): accept DataFrame with tuple cellid (#2767) Fix an inconsistency mentioned in #2758. pd.DataFrame with a tuple cellid column was rejected by .set_data() while the equivalent recarray form was accepted. Accept tuple cellid for DataFrame too. --- autotest/test_mf6.py | 21 +++++++++++++++++++++ flopy/mf6/data/mfdataplist.py | 16 +++++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/autotest/test_mf6.py b/autotest/test_mf6.py index 3b0912b11..4a73f90ba 100644 --- a/autotest/test_mf6.py +++ b/autotest/test_mf6.py @@ -1474,6 +1474,27 @@ def test_set_data_dataframe_column_mismatch_error(function_tmpdir): spd.set_data({0: df}) +def test_set_data_dataframe_tupled_cellid(function_tmpdir): + """DataFrame with a tuple 'cellid' is accepted, consistent with recarrays.""" + sim = MFSimulation(sim_ws=str(function_tmpdir), exe_name="mf6") + ModflowTdis(sim, nper=1, perioddata=[(1.0, 1, 1.0)]) + ModflowIms(sim) + gwf = ModflowGwf(sim, modelname="gwf") + ModflowGwfdis(gwf, nlay=1, nrow=10, ncol=10) + ModflowGwfic(gwf, strt=0.0) + ModflowGwfnpf(gwf) + ModflowGwfwel(gwf, stress_period_data={0: [[(0, 0, 0), -1.0]]}) + spd = gwf.get_package("WEL").stress_period_data + + df = pd.DataFrame({"cellid": [(0, 3, 4)], "q": [-500.0]}) + spd.set_data({0: df}) + result = spd.get_data(key=0) + assert result is not None + assert len(result) == 1 + assert result[0]["cellid"] == (0, 3, 4) + assert result[0]["q"] == pytest.approx(-500.0) + + @requires_exe("mf6") def test_output(function_tmpdir, example_data_path): ex_name = "test001e_UZF_3lay" diff --git a/flopy/mf6/data/mfdataplist.py b/flopy/mf6/data/mfdataplist.py index c5adfb6e4..bbef1ae15 100644 --- a/flopy/mf6/data/mfdataplist.py +++ b/flopy/mf6/data/mfdataplist.py @@ -753,7 +753,16 @@ def set_data(self, data, autofill=False, check_data=True, append=False): # make sure columns are still in correct order data = pandas.DataFrame(data, columns=self._header_names) elif isinstance(data, pandas.DataFrame): - if len(data.columns) != len(self._header_names): + if len(data.columns) == len(self._data_item_names) and len( + self._data_item_names + ) != len(self._header_names): + # data supplied with cellids as tuples (data_item_names format), + # consistent with how recarrays are handled above + if list(data.columns) != self._data_item_names: + data = data.set_axis(self._data_item_names, axis=1) + data = self._untuple_cellids(data)[0] + data = pandas.DataFrame(data, columns=self._header_names) + elif len(data.columns) != len(self._header_names): message = ( f"ERROR: Data list {self._data_name} supplied the " f"wrong number of columns of data, expected " @@ -775,8 +784,9 @@ def set_data(self, data, autofill=False, check_data=True, append=False): message, self._simulation_data.debug, ) - # set correct data header names - data = data.set_axis(self._header_names, axis=1) + else: + # set correct data header names + data = data.set_axis(self._header_names, axis=1) else: message = ( f"ERROR: Data list {self._data_name} is an unsupported type: " From 6a5d271fd41d7234b37ecc4be4c765ed2799ee15 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Tue, 9 Jun 2026 15:07:18 -0400 Subject: [PATCH 42/90] fix(model_attributes_to_shapefile): use modelgrid kwarg if provided (#2768) The model_attributes_to_shapefile() function is described as accepting a "modelgrid" kwarg, but when provided it went unused. Pop it from kwargs, call to_geodataframe() on it if provided to build an initial gdf, then pass that as an initial gdf to the model's to_geodataframe(). Also, add a trap for base Grid in model-level .to_geodataframe() methods. The base Grid.to_geodataframe() method requires a features param since the grid contains no geometry info. Related to #2744 --- autotest/test_shapefile_utils.py | 70 ++++++++++++++++++++++++++++++++ flopy/export/shapefile_utils.py | 9 +++- flopy/mbase.py | 13 ++++-- flopy/mf6/mfmodel.py | 13 ++++-- 4 files changed, 97 insertions(+), 8 deletions(-) diff --git a/autotest/test_shapefile_utils.py b/autotest/test_shapefile_utils.py index a96440abc..144960518 100644 --- a/autotest/test_shapefile_utils.py +++ b/autotest/test_shapefile_utils.py @@ -3,10 +3,12 @@ """ import numpy as np +import pytest from modflow_devtools.markers import requires_pkg import flopy from flopy.discretization import StructuredGrid, UnstructuredGrid, VertexGrid +from flopy.discretization.grid import Grid from flopy.export.shapefile_utils import model_attributes_to_shapefile, shp2recarray from flopy.utils.crs import get_shapefile_crs @@ -49,6 +51,74 @@ def test_model_attributes_to_shapefile(example_data_path, function_tmpdir): assert shpfile_path.exists() +@requires_pkg("geopandas") +def test_model_attributes_to_shapefile_modelgrid_kwarg(function_tmpdir): + """Repro https://github.com/modflowpy/flopy/issues/2744 + + The modelgrid kwarg to model_attributes_to_shapefile should be used as + the geometry source if provided, overriding the model's own modelgrid. + """ + import warnings + + nrow, ncol = 3, 4 + delr = np.ones(ncol) * 10.0 + delc = np.ones(nrow) * 10.0 + crs = 26916 + + # Model without a DIS package: modelgrid is a bare Grid with no geometry. + # Without the fix, this reproduces the reported TypeError. + sim = flopy.mf6.MFSimulation(sim_name="test", sim_ws=str(function_tmpdir)) + gwf = flopy.mf6.ModflowGwf(sim, modelname="test") + mg = StructuredGrid(delr=delr, delc=delc, nlay=1, crs=crs) + shpfile = function_tmpdir / "test_no_dis.shp" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + model_attributes_to_shapefile(shpfile, gwf, modelgrid=mg) + assert shpfile.exists() + + # The error in the issue could also have been avoided by retrieving the + # modelgrid after attaching the DIS to the model. Before DIS is added, + # gwf.modelgrid is the bare base Grid class. After DIS is attached, it + # becomes a StructuredGrid whose to_geodataframe() needs no 'features'. + sim1 = flopy.mf6.MFSimulation(sim_name="test_b", sim_ws=str(function_tmpdir)) + gwf1 = flopy.mf6.ModflowGwf(sim1, modelname="test_b") + assert isinstance(gwf1.modelgrid, Grid) + flopy.mf6.ModflowGwfdis(gwf1, nlay=1, nrow=nrow, ncol=ncol) + mg1 = gwf1.modelgrid + assert isinstance(mg1, StructuredGrid) + shpfile1 = function_tmpdir / "test_dis_first.shp" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + model_attributes_to_shapefile( + shpfile1, gwf1, package_names=["dis"], modelgrid=mg1 + ) + assert shpfile1.exists() + + # Without a modelgrid kwarg and no DIS, to_geodataframe raises a clear + # AttributeError rather than the cryptic TypeError about 'features'. + sim_err = flopy.mf6.MFSimulation(sim_name="test_err", sim_ws=str(function_tmpdir)) + gwf_err = flopy.mf6.ModflowGwf(sim_err, modelname="test_err") + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + with pytest.raises(AttributeError, match="discretization package"): + model_attributes_to_shapefile(function_tmpdir / "test_err.shp", gwf_err) + + # Model with a DIS package but a different modelgrid passed via kwarg: + # the kwarg modelgrid's CRS should take precedence. + sim2 = flopy.mf6.MFSimulation(sim_name="test2", sim_ws=str(function_tmpdir)) + gwf2 = flopy.mf6.ModflowGwf(sim2, modelname="test2") + flopy.mf6.ModflowGwfdis(gwf2, nlay=1, nrow=nrow, ncol=ncol) + + mg2 = StructuredGrid(delr=delr, delc=delc, nlay=1, crs=crs) + shpfile2 = function_tmpdir / "test_with_dis.shp" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + model_attributes_to_shapefile( + shpfile2, gwf2, package_names=["dis"], modelgrid=mg2 + ) + assert shpfile2.exists() + + @requires_pkg("geopandas") def test_create_geodataframe( minimal_unstructured_grid_info, minimal_vertex_grid_info, function_tmpdir diff --git a/flopy/export/shapefile_utils.py b/flopy/export/shapefile_utils.py index 8a75f7eaa..aa1397e9a 100644 --- a/flopy/export/shapefile_utils.py +++ b/flopy/export/shapefile_utils.py @@ -220,10 +220,15 @@ def model_attributes_to_shapefile( else: package_names = [pak.name[0] for pak in ml.packagelist] - gdf = ml.to_geodataframe(package_names=package_names, shorten_attr=True) + modelgrid = kwargs.pop("modelgrid", None) + init_gdf = modelgrid.to_geodataframe() if modelgrid is not None else None + gdf = ml.to_geodataframe( + gdf=init_gdf, package_names=package_names, shorten_attr=True + ) if array_dict: - modelgrid = ml.modelgrid + if modelgrid is None: + modelgrid = ml.modelgrid for name, array in array_dict.items(): if modelgrid.grid_type() == "unstructured": gdf[name] = array.ravel() diff --git a/flopy/mbase.py b/flopy/mbase.py index f73dacc93..ae9e3fd24 100644 --- a/flopy/mbase.py +++ b/flopy/mbase.py @@ -790,13 +790,20 @@ def to_geodataframe(self, gdf=None, kper=0, package_names=None, shorten_attr=Fal gdf : GeoDataFrame """ if gdf is None: + from .discretization.grid import Grid + modelgrid = self.modelgrid - if modelgrid is not None: - gdf = modelgrid.to_geodataframe() - else: + if modelgrid is None: raise AttributeError( "model does not have a grid instance, please supply a geodataframe" ) + if type(modelgrid) is Grid: + raise AttributeError( + "model does not have a discretization package; cannot build a " + "GeoDataFrame without geometry. Attach a discretization package " + "or supply a gdf." + ) + gdf = modelgrid.to_geodataframe() if package_names is None: package_names = [pak.name[0] for pak in self.packagelist] diff --git a/flopy/mf6/mfmodel.py b/flopy/mf6/mfmodel.py index bb2d668a7..61493c389 100644 --- a/flopy/mf6/mfmodel.py +++ b/flopy/mf6/mfmodel.py @@ -816,14 +816,21 @@ def to_geodataframe(self, gdf=None, kper=0, package_names=None, shorten_attr=Fal gdf : GeoDataFrame """ if gdf is None: + from ..discretization.grid import Grid + modelgrid = self.modelgrid - if modelgrid is not None: - gdf = modelgrid.to_geodataframe() - else: + if modelgrid is None: raise AttributeError( "model does not have a grid instance, " "please supply a geodataframe" ) + if type(modelgrid) is Grid: + raise AttributeError( + "model does not have a discretization package; cannot build a " + "GeoDataFrame without geometry. Attach a discretization package " + "or supply a gdf." + ) + gdf = modelgrid.to_geodataframe() if package_names is None: package_names = [pak.name[0] for pak in self.packagelist] From 8d5c41001cd86b8286981317eda314ac3d477352 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Tue, 9 Jun 2026 15:22:47 -0400 Subject: [PATCH 43/90] fix(model_splitter): copy on boundname remap (#2769) Fix #2735 --- autotest/test_model_splitter.py | 33 +++++++++++++++++++++++++++++++ flopy/mf6/utils/model_splitter.py | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/autotest/test_model_splitter.py b/autotest/test_model_splitter.py index c08995c2c..c341da058 100644 --- a/autotest/test_model_splitter.py +++ b/autotest/test_model_splitter.py @@ -352,6 +352,39 @@ def test_save_load_node_mapping_unstructured(function_tmpdir): np.testing.assert_allclose(new_heads, original_heads, err_msg=err_msg) +@requires_pkg("h5py") +def test_save_node_mapping_with_boundnames(function_tmpdir): + # regression test for https://github.com/modflowpy/flopy/issues/2735 + # boundnames in stress packages were being inserted into _node_map, + # causing save_node_mapping to fail with ValueError on int() conversion + sim = flopy.mf6.MFSimulation(sim_name="test", sim_ws=str(function_tmpdir)) + flopy.mf6.ModflowTdis(sim) + flopy.mf6.ModflowIms(sim) + gwf = flopy.mf6.ModflowGwf(sim, modelname="test") + flopy.mf6.ModflowGwfdis(gwf, nlay=1, nrow=10, ncol=10, top=10, botm=0) + flopy.mf6.ModflowGwfic(gwf, strt=10) + flopy.mf6.ModflowGwfnpf(gwf) + flopy.mf6.ModflowGwfwel( + gwf, + stress_period_data={0: [((0, 2, 2), -1.0, "my_well")]}, + boundnames=True, + ) + + array = np.zeros((10, 10), dtype=int) + array[:, 5:] = 1 + mfsplit = Mf6Splitter(sim) + mfsplit.split_model(array) + + non_int_keys = [ + k for k in mfsplit._node_map if not isinstance(k, (int, np.integer)) + ] + assert not non_int_keys, f"boundnames leaked into _node_map: {non_int_keys}" + + hdf_file = function_tmpdir / "node_map.hdf5" + mfsplit.save_node_mapping(hdf_file) + assert hdf_file.exists() + + def test_control_records(function_tmpdir): nrow = 10 ncol = 10 diff --git a/flopy/mf6/utils/model_splitter.py b/flopy/mf6/utils/model_splitter.py index 4966e491a..72bd0f3a9 100644 --- a/flopy/mf6/utils/model_splitter.py +++ b/flopy/mf6/utils/model_splitter.py @@ -3484,7 +3484,7 @@ def _remap_package(self, package, ismvr=False): pass if hasattr(package, "obs"): - obs_map = {"cellid": self._node_map} + obs_map = {"cellid": dict(self._node_map)} for mkey, mdict in mapped_data.items(): if "stress_period_data" in mdict: for _, ra in mdict["stress_period_data"].items(): From 60db7e721ffe8961c5a9f71382598d54cf19115b Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Tue, 9 Jun 2026 22:41:18 -0400 Subject: [PATCH 44/90] fix(mfusgt): fix multiple bugs (#2770) Fix what look like copy/paste errors in mfusgrch.py, missing leading f in front of format strings in several places, and a bad format specifier --- autotest/test_usg_transport.py | 33 +++++++++++++++++++++++++++++++++ flopy/mfusg/mfusgoc.py | 10 +++++----- flopy/mfusg/mfusgrch.py | 8 ++++---- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/autotest/test_usg_transport.py b/autotest/test_usg_transport.py index 2de8f6a0b..f4a3d33e6 100644 --- a/autotest/test_usg_transport.py +++ b/autotest/test_usg_transport.py @@ -735,3 +735,36 @@ def test_usg_load_Ex9_PFAS(function_tmpdir, mfusg_transport_Ex9_PFAS_model_path) success, buff = m.run_model() msg = "flopy failed on running PFAS_C1.nam" assert success, msg + + +def test_mfusgrch_selev_iznrch(function_tmpdir): + """Repro #2763""" + from flopy.modflow import ModflowDis + + m = MfUsg( + version="mfusg", + structured=True, + model_ws=function_tmpdir, + modelname="test_rch", + ) + ModflowDis(m, nlay=1, nrow=3, ncol=3, nper=2) + + rech = 1e-3 + selev_data = np.full((3, 3), 5.0, dtype=np.float32) + iznrch_data = np.full((3, 3), 2, dtype=np.int32) + + rch = MfUsgRch( + m, + nrchop=3, + rech=rech, + seepelev=1, + selev=selev_data, + iznrch=iznrch_data, + ) + + assert np.allclose(rch.selev[0].array, selev_data) + assert np.array_equal(rch.iznrch[0].array, iznrch_data) + + rch.write_file(check=False) + content = (function_tmpdir / "test_rch.rch").read_text() + assert " # Stress period 1" in content diff --git a/flopy/mfusg/mfusgoc.py b/flopy/mfusg/mfusgoc.py index 8b60b6bd0..010770b77 100644 --- a/flopy/mfusg/mfusgoc.py +++ b/flopy/mfusg/mfusgoc.py @@ -955,11 +955,11 @@ def load(cls, f, model, nper=None, nstp=None, nlay=None, ext_unit_dict=None): if kwargs["atsa"]: line = f.readline() lnlst = line.strip().split() - lines.append("DELTAT {float(lnlst[0]):11.4e}") - lines.append("TMINAT {float(lnlst[1]):11.4e}") - lines.append("TMAXAT {float(lnlst[2]):11.4e}") - lines.append("TADJAT {float(lnlst[3]):11.4e}") - lines.append("TCUTAT {float(lnlst[4]):11.4e}") + lines.append(f"DELTAT {float(lnlst[0]):11.4e}") + lines.append(f"TMINAT {float(lnlst[1]):11.4e}") + lines.append(f"TMAXAT {float(lnlst[2]):11.4e}") + lines.append(f"TADJAT {float(lnlst[3]):11.4e}") + lines.append(f"TCUTAT {float(lnlst[4]):11.4e}") line = f.readline() lnlst = line.strip().split() diff --git a/flopy/mfusg/mfusgrch.py b/flopy/mfusg/mfusgrch.py index eb9713ab9..83f45245f 100644 --- a/flopy/mfusg/mfusgrch.py +++ b/flopy/mfusg/mfusgrch.py @@ -118,14 +118,14 @@ def __init__( if selev is not None: selev_u2d_shape = get_pak_vals_shape(model, selev) self.selev = Transient2d( - model, selev_u2d_shape, np.float32, rech, name="rech_selev" + model, selev_u2d_shape, np.float32, selev, name="rech_selev" ) self.iznrch = None if iznrch is not None: iznrch_u2d_shape = get_pak_vals_shape(model, iznrch) self.iznrch = Transient2d( - model, iznrch_u2d_shape, np.int32, rech, name="rech_izn" + model, iznrch_u2d_shape, np.int32, iznrch, name="rech_izn" ) self.rchconc = None @@ -173,7 +173,7 @@ def write_file(self, check=True, f=None): if self.iconc: f_rch.write(" CONC") if self.mxrtzones: - f_rch.write(f" RTS {self.mxrtzones:4.0d}") + f_rch.write(f" RTS {self.mxrtzones:4d}") f_rch.write("\n") mcomp = self.parent.mcomp @@ -219,7 +219,7 @@ def write_file(self, check=True, f=None): f_rch.write(" INSELEV 1") if self.rchconc is not None: f_rch.write(" INCONC 1") - f_rch.write("# Stress period {kper + 1}\n") + f_rch.write(f" # Stress period {kper + 1}\n") if inrech >= 0: f_rch.write(file_entry_rech) From 6910978ce3d72cb1cb0d6f35327206a442bf2106 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Fri, 12 Jun 2026 07:01:05 -0400 Subject: [PATCH 45/90] fix(mf6-dis): auto-set max_columns_of_data to ncol for external writes (#2771) Fix the issue described in #2572 (comment) so max_columns_of_data is auto-set to ncol for all DIS models regardless whether arrays are written internally or externally. This change does not address DISV/DISU. --- autotest/test_mf6_max_columns_setting.py | 67 ++++++++++++++++++++++++ flopy/mf6/mfmodel.py | 18 +++---- flopy/mf6/mfsimbase.py | 31 ++++++++--- 3 files changed, 96 insertions(+), 20 deletions(-) diff --git a/autotest/test_mf6_max_columns_setting.py b/autotest/test_mf6_max_columns_setting.py index 1d9738799..baa947373 100644 --- a/autotest/test_mf6_max_columns_setting.py +++ b/autotest/test_mf6_max_columns_setting.py @@ -164,6 +164,73 @@ def check_columns(expected): check_columns(expected=1) +def test_auto_set_ncol_before_set_all_data_external(function_tmpdir): + """ + Auto-set to ncol before set_all_data_external(), not just write_simulation(). + Repro https://github.com/modflowpy/flopy/issues/2572#issuecomment-3164881948 + """ + ncol = 25 + sim_ws = function_tmpdir / "test_auto_set_ncol" + sim_ws.mkdir(exist_ok=True) + + sim = flopy.mf6.MFSimulation(sim_name="test_sim", sim_ws=sim_ws) + flopy.mf6.ModflowTdis(sim, time_units="DAYS", nper=1, perioddata=[(1.0, 1, 1.0)]) + gwf = flopy.mf6.ModflowGwf(sim, modelname="model", model_nam_file="model.nam") + flopy.mf6.ModflowGwfdis( + gwf, nlay=1, nrow=2, ncol=ncol, delr=100.0, delc=100.0, top=100.0, botm=0.0 + ) + flopy.mf6.ModflowGwfic( + gwf, strt=np.arange(2 * ncol).reshape(1, 2, ncol).astype(float) + ) + flopy.mf6.ModflowGwfnpf(gwf, save_flows=True, icelltype=0, k=1.0) + ims = flopy.mf6.ModflowIms(sim) + sim.register_ims_package(ims, [gwf.name]) + + sim.set_all_data_external() + assert sim.simulation_data.max_columns_of_data == ncol + sim.write_simulation(silent=True) + + strt_file = sim_ws / "model.ic_strt.txt" + assert strt_file.exists() + data_lines = [ + line.strip() + for line in strt_file.read_text().splitlines() + if line.strip() and not line.strip().startswith("#") + ] + for line in data_lines: + assert len(line.split()) == ncol + + # also test the model-level entry point + sim2_ws = function_tmpdir / "test_auto_set_ncol_model" + sim2_ws.mkdir(exist_ok=True) + sim2 = flopy.mf6.MFSimulation(sim_name="test_sim2", sim_ws=sim2_ws) + flopy.mf6.ModflowTdis(sim2, time_units="DAYS", nper=1, perioddata=[(1.0, 1, 1.0)]) + gwf2 = flopy.mf6.ModflowGwf(sim2, modelname="model", model_nam_file="model.nam") + flopy.mf6.ModflowGwfdis( + gwf2, nlay=1, nrow=2, ncol=ncol, delr=100.0, delc=100.0, top=100.0, botm=0.0 + ) + flopy.mf6.ModflowGwfic( + gwf2, strt=np.arange(2 * ncol).reshape(1, 2, ncol).astype(float) + ) + flopy.mf6.ModflowGwfnpf(gwf2, save_flows=True, icelltype=0, k=1.0) + ims2 = flopy.mf6.ModflowIms(sim2) + sim2.register_ims_package(ims2, [gwf2.name]) + + gwf2.set_all_data_external() + assert sim2.simulation_data.max_columns_of_data == ncol + sim2.write_simulation(silent=True) + + strt_file2 = sim2_ws / "model.ic_strt.txt" + assert strt_file2.exists() + data_lines2 = [ + line.strip() + for line in strt_file2.read_text().splitlines() + if line.strip() and not line.strip().startswith("#") + ] + for line in data_lines2: + assert len(line.split()) == ncol + + reason_ext = ( "set_all_data_external() writes files immediately. Changing output settings" "afterward has no effect unless set_all_data_external() is called again. " diff --git a/flopy/mf6/mfmodel.py b/flopy/mf6/mfmodel.py index 61493c389..7f4b9ef23 100644 --- a/flopy/mf6/mfmodel.py +++ b/flopy/mf6/mfmodel.py @@ -1044,13 +1044,9 @@ def load_base( print(f" loading package {ftype}...") # load package instance.load_package(ftype, fname, pname, strict, None) - sim_data = simulation.simulation_data - if ftype == "dis" and sim_data._max_columns_set_by != 'user': - # set column wrap to ncol - dis = instance.get_package("dis", type_only=True) - if dis is not None and hasattr(dis, "ncol"): - sim_data._max_columns_of_data = dis.ncol.get_data() - sim_data._max_columns_set_by = 'auto' + if ftype == "dis": + # instance is not yet in simulation._models at this point + simulation._auto_set_max_columns(models=[instance]) # load referenced packages if modelname in instance.simulation_data.referenced_files: for ref_file in instance.simulation_data.referenced_files[ @@ -1376,11 +1372,7 @@ def write(self, ext_file_action=ExtFileAction.copy_relative_paths): self.name_file.write(ext_file_action=ext_file_action) - if self.simulation_data._max_columns_set_by != 'user': - grid_type = self.get_grid_type() - if grid_type == DiscretizationType.DIS: - self.simulation_data._max_columns_of_data = self.dis.ncol.get_data() - self.simulation_data._max_columns_set_by = 'auto' + self.simulation._auto_set_max_columns() # write packages for pp in self.packagelist: @@ -1895,6 +1887,8 @@ def set_all_data_external( will not be rewritten. Default is False. """ + self.simulation._auto_set_max_columns() + for package in self.packagelist: package.set_all_data_external( check_data, diff --git a/flopy/mf6/mfsimbase.py b/flopy/mf6/mfsimbase.py index 273970bb6..20f3f133f 100644 --- a/flopy/mf6/mfsimbase.py +++ b/flopy/mf6/mfsimbase.py @@ -1597,6 +1597,26 @@ def rename_all_packages(self, name): for model in self._models.values(): model.rename_all_packages(name) + def _auto_set_max_columns(self, models=None): + """Set max_columns_of_data to ncol of the first structured DIS model found, + unless the value has already been explicitly set by the user. + + Parameters + ---------- + models : iterable, optional + Models to search. Defaults to all registered models. Pass an + explicit list when a model is not yet registered (e.g. mid-load). + """ + sim_data = self.simulation_data + if sim_data._max_columns_set_by == "user": + return + for model in (models if models is not None else self._models.values()): + dis = model.get_package("dis", type_only=True) + if dis is not None and hasattr(dis, "ncol"): + sim_data._max_columns_of_data = dis.ncol.get_data() + sim_data._max_columns_set_by = "auto" + return + def set_all_data_external( self, check_data=True, @@ -1641,6 +1661,8 @@ def set_all_data_external( will not be rewritten. Default is False. """ + self._auto_set_max_columns() + # copy any files whose paths have changed self.simulation_data.mfpath.copy_files() # set data external for all packages in all models @@ -1710,14 +1732,7 @@ def write_simulation( Writes out the simulation in silent mode (verbosity_level = 0) """ - sim_data = self.simulation_data - if sim_data._max_columns_set_by != 'user': - # search for dis packages - for model in self._models.values(): - dis = model.get_package("dis", type_only=True) - if dis is not None and hasattr(dis, "ncol"): - sim_data._max_columns_of_data = dis.ncol.get_data() - sim_data._max_columns_set_by = 'auto' + self._auto_set_max_columns() saved_verb_lvl = self.simulation_data.verbosity_level if silent: From 7465a329ab50f45251f40f92a1d18db6a51296fb Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 17 Jun 2026 11:19:41 -0400 Subject: [PATCH 46/90] fix(CellBudgetFile): trap OSError when detecting precision (#2772) Fix the precision detection issue mentioned in https://github.com/MODFLOW-ORG/modflow6/issues/2838#issuecomment-4722369441 --- flopy/utils/binaryfile/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flopy/utils/binaryfile/__init__.py b/flopy/utils/binaryfile/__init__.py index c1f40d40e..c7838021c 100644 --- a/flopy/utils/binaryfile/__init__.py +++ b/flopy/utils/binaryfile/__init__.py @@ -2427,7 +2427,7 @@ def _set_precision(self, precision="single"): try: self._build_index() - except (BudgetIndexError, EOFError) as e: + except (BudgetIndexError, EOFError, OSError) as e: success = False self.__reset() From 6cafcf24744577920b8f39e1c5a841c3cdfe334b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 04:47:46 -0700 Subject: [PATCH 47/90] chore(deps): bump actions/checkout from 6 to 7 (#2773) --- .github/workflows/benchmark.yml | 4 ++-- .github/workflows/codegen.yml | 2 +- .github/workflows/commit.yml | 10 +++++----- .github/workflows/examples.yml | 2 +- .github/workflows/mf6.yml | 10 +++++----- .github/workflows/optional.yml | 2 +- .github/workflows/release.yml | 8 ++++---- .github/workflows/rtd.yml | 6 +++--- 8 files changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index cb1b7152b..da18b45e0 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Python ${{ matrix.python-version }} uses: astral-sh/setup-uv@v7 @@ -77,7 +77,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Python uses: astral-sh/setup-uv@v7 diff --git a/.github/workflows/codegen.yml b/.github/workflows/codegen.yml index 144183584..c3620f6a1 100644 --- a/.github/workflows/codegen.yml +++ b/.github/workflows/codegen.yml @@ -21,7 +21,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Python ${{ matrix.python-version }} uses: astral-sh/setup-uv@v7 diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index f3ab791b1..10e065bfd 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -18,7 +18,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Python uses: astral-sh/setup-uv@v7 @@ -41,7 +41,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Python uses: astral-sh/setup-uv@v7 @@ -75,7 +75,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Python uses: astral-sh/setup-uv@v7 @@ -122,12 +122,12 @@ jobs: timeout-minutes: 60 steps: - name: Checkout repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: path: flopy - name: Checkout MODFLOW 6 - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: MODFLOW-ORG/modflow6 path: modflow6 diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index f5fe0cb81..bd81af457 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -19,7 +19,7 @@ jobs: timeout-minutes: 90 steps: - name: Checkout repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Python ${{ matrix.python-version }} uses: astral-sh/setup-uv@v7 diff --git a/.github/workflows/mf6.yml b/.github/workflows/mf6.yml index adc8a7341..bce569707 100644 --- a/.github/workflows/mf6.yml +++ b/.github/workflows/mf6.yml @@ -22,12 +22,12 @@ jobs: steps: - name: Checkout flopy repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: path: flopy - name: Checkout MODFLOW 6 - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: MODFLOW-ORG/modflow6 path: modflow6 @@ -99,18 +99,18 @@ jobs: steps: - name: Checkout flopy repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: path: flopy - name: Checkout MODFLOW 6 - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: MODFLOW-ORG/modflow6 path: modflow6 - name: Checkout MF6 examples - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: MODFLOW-ORG/modflow6-examples path: modflow6-examples diff --git a/.github/workflows/optional.yml b/.github/workflows/optional.yml index e68746d3e..0eb50aad9 100644 --- a/.github/workflows/optional.yml +++ b/.github/workflows/optional.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Python uses: astral-sh/setup-uv@v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d08c744d2..acdaa4aaf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout release branch - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -140,7 +140,7 @@ jobs: steps: - name: Checkout release branch - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: ${{ github.ref_name }} @@ -169,7 +169,7 @@ jobs: steps: - name: Checkout master branch - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: master @@ -206,7 +206,7 @@ jobs: steps: - name: Checkout master branch - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: master diff --git a/.github/workflows/rtd.yml b/.github/workflows/rtd.yml index 7da3cbb97..62ea00e0b 100644 --- a/.github/workflows/rtd.yml +++ b/.github/workflows/rtd.yml @@ -39,7 +39,7 @@ jobs: echo "ref=$ref" >> $GITHUB_OUTPUT - name: Checkout flopy repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: ${{ steps.set_ref.outputs.ref }} @@ -68,13 +68,13 @@ jobs: steps: - name: Checkout flopy repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: ${{ needs.set_options.outputs.ref }} path: flopy - name: Checkout MODFLOW 6 - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: MODFLOW-ORG/modflow6 path: modflow6 From 018bd9ca60a40995c5cba5e88e1630f30e7532e1 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Mon, 29 Jun 2026 10:55:27 -0400 Subject: [PATCH 48/90] fix(mfdataplist): fix multiple issues (#2775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix some cases where MFPandasList behaved inconsistently with MFList/MFTransientList - support None period data, fixed in set_record (added record["data"] is not None) - convert 0- to 1-based indexing for non-cell-ID fields marked with numeric_index - support external file paths via open/close, fixed with new branch in set_data() These were flushed out by MODFLOW-ORG/modflow6#2868, which added # package-type stress-package to a number of DFNs. Since the pandas alternative list impl was added in #1955, list blocks in stress packages use MFPandasList by default while other packages use the old MFList, due evidently to some gaps in the pandas approach. Also some related refactoring/fixes/cleanup - support DIS2D/DISV1D/DISV2D cell IDs - add _get_cellid_columns() helper to centralize cellid column logic - delete _unique_column_name(), _untuple_manually() - fix a typo in mffileaccess.py (cellid_nodes → cellid_node) --- flopy/mf6/data/mfdataplist.py | 426 ++++++++++----------------------- flopy/mf6/data/mffileaccess.py | 2 +- 2 files changed, 129 insertions(+), 299 deletions(-) diff --git a/flopy/mf6/data/mfdataplist.py b/flopy/mf6/data/mfdataplist.py index bbef1ae15..669b9af66 100644 --- a/flopy/mf6/data/mfdataplist.py +++ b/flopy/mf6/data/mfdataplist.py @@ -4,6 +4,7 @@ import os import sys import warnings +from collections.abc import Iterable import numpy as np import pandas @@ -15,7 +16,6 @@ from ...utils import datautil from ..data import mfdata from ..mfbase import ExtFileAction, MFDataException, VerbosityLevel -from ..utils.mfenums import DiscretizationType from .mfdatalist import MFList from .mfdatastorage import DataStorageType, DataStructureType from .mfdatautil import MFComment, list_to_array, process_open_close_line @@ -209,9 +209,7 @@ def __init__( package=None, block=None, ): - super().__init__( - sim_data, model_or_sim, structure, enable, path, dimensions - ) + super().__init__(sim_data, model_or_sim, structure, enable, path, dimensions) self._data_storage = self._new_storage() self._package = package self._block = block @@ -298,56 +296,18 @@ def _process_open_close_line(self, arr_line, store=True): def _add_cellid_fields(self, data, keep_existing=False): """ - Add cellid fields to a Pandas DataFrame and drop the layer, + Add cellid column to a Pandas DataFrame and drop the layer, row, column, cell, node, fields that the cellid is based on """ for data_item in self.structure.data_item_structures: if data_item.type == DatumType.integer: if data_item.name.lower() == "cellid": - columns = data.columns.tolist() - if isinstance(self._mg, StructuredGrid): - if ( - "cellid_layer" in columns - and "cellid_row" in columns - and "cellid_column" in columns - ): - data["cellid"] = data[ - ["cellid_layer", "cellid_row", "cellid_column"] - ].apply(tuple, axis=1) - if not keep_existing: - data = data.drop( - columns=[ - "cellid_layer", - "cellid_row", - "cellid_column", - ] - ) - elif isinstance(self._mg, VertexGrid): - cell_2 = None - if "cellid_cell" in columns: - cell_2 = "cellid_cell" - elif "ncpl" in columns: - cell_2 = "cellid_ncpl" - if cell_2 is not None and "cellid_layer" in columns: - data["cellid"] = data[ - ["cellid_layer", cell_2] - ].apply(tuple, axis=1) - if not keep_existing: - data = data.drop( - columns=["cellid_layer", cell_2] - ) - elif isinstance(self._mg, UnstructuredGrid): - if "cellid_node" in columns: - data["cellid"] = data[["cellid_node"]].apply( - tuple, axis=1 - ) - if not keep_existing: - data = data.drop(columns=["cellid_node"]) - else: - raise MFDataException( - "ERROR: Unrecognized model grid " - "{str(self._mg)} not supported by MFBasicList" - ) + cols = data.columns.tolist() + cellid_cols = self._get_cellid_columns(cols) + data["cellid"] = data[cellid_cols].apply(tuple, axis=1) + if not keep_existing: + data = data.drop(columns=cellid_cols) + # reorder columns column_headers = data.columns.tolist() column_headers.insert(0, column_headers.pop()) @@ -375,6 +335,29 @@ def _get_cellid_size(self, data_item_name): model_grid = self.data_dimensions.get_model_grid(model_num=model_num) return model_grid.get_num_spatial_coordinates() + def _get_cellid_columns(self, columns: Iterable[str] | None = None): + """Return ordered column names for the cellid field based on the model grid.""" + + if isinstance(self._mg, StructuredGrid): + if self._get_cellid_size("cellid") == 3: # DIS + return ["cellid_layer", "cellid_row", "cellid_column"] + else: # DIS2D + return ["cellid_row", "cellid_column"] + elif isinstance(self._mg, VertexGrid): + if self._get_cellid_size("cellid") == 2: # DISV + if columns and "cellid_ncpl" in columns: + return ["cellid_layer", "cellid_ncpl"] + return ["cellid_layer", "cellid_cell"] + else: # DISV1D/DISV2D + return ["cellid_node"] + elif isinstance(self._mg, UnstructuredGrid): + return ["cellid_node"] # DISU + else: + raise MFDataException( + "ERROR: Unrecognized model grid " + f"{str(self._mg)} not supported by MFBasicList" + ) + def _build_data_header(self): """ Constructs lists of data column header names and data column types @@ -415,28 +398,8 @@ def _build_data_header(self): self._append_type_list(data_item.name, s_type) elif data_item.type == DatumType.integer: if data_item.name.lower() == "cellid": - # get the appropriate cellid column headings for the - # model's discretization type - if isinstance(self._mg, StructuredGrid): - self._append_type_list( - "cellid_layer", i_type, True - ) - self._append_type_list("cellid_row", i_type, True) - self._append_type_list( - "cellid_column", i_type, True - ) - elif isinstance(self._mg, VertexGrid): - self._append_type_list( - "cellid_layer", i_type, True - ) - self._append_type_list("cellid_cell", i_type, True) - elif isinstance(self._mg, UnstructuredGrid): - self._append_type_list("cellid_node", i_type, True) - else: - raise MFDataException( - "ERROR: Unrecognized model grid " - "{str(self._mg)} not supported by MFBasicList" - ) + for col_name in self._get_cellid_columns(): + self._append_type_list(col_name, i_type, True) else: self._append_type_list(data_item.name, i_type) elif data_item.type == DatumType.double_precision: @@ -446,39 +409,6 @@ def _build_data_header(self): self._header_names = None self._data_item_names.append(data_item.name) - @staticmethod - def _unique_column_name(data, col_base_name): - """generate a unique column name based on "col_base_name" """ - col_name = col_base_name - idx = 2 - while col_name in data: - col_name = f"{col_base_name}_{idx}" - idx += 1 - return col_name - - @staticmethod - def _untuple_manually(pdata, loc, new_column_name, column_name, index): - """ - Loop through pandas DataFrame removing tuples from cellid columns. - Used when pandas "insert" method to perform the same task fails. - """ - # build new column list - new_column = [] - for idx, row in pdata.iterrows(): - if isinstance(row[column_name], tuple) or isinstance( - row[column_name], list - ): - new_column.append(row[column_name][index]) - else: - new_column.append(row[column_name]) - - # insert list as new column - pdata.insert( - loc=loc, - column=new_column_name, - value=new_column, - ) - def _untuple_cellids(self, pdata): """ For all cellids in "pdata", convert them to layer, row, column fields and @@ -501,109 +431,49 @@ def _untuple_cellids(self, pdata): fields_to_correct.append((data_idx, columns[data_idx])) data_idx += 1 else: - data_idx += self._get_cellid_size(data_item.name) + scalar_col_names = self._get_cellid_columns() + if len(scalar_col_names) == 1: + # Single-component grids (DISU/DISV1D/DISV2D): if the + # column is still named "cellid" (e.g. from a recarray + # field), rename it to the component name (e.g. + # "cellid_node") so that _remove_cellid_fields, which + # looks for a bare "cellid" column to drop, does not + # accidentally destroy the scalar data. Multi-component + # grids are not affected because their expanded columns + # are already named cellid_layer/row/column/cell and + # _remove_cellid_fields never touches them. + old_col = columns[data_idx] + pdata = pdata.rename(columns={old_col: scalar_col_names[0]}) + columns = pdata.columns + data_idx += len(scalar_col_names) else: data_idx += 1 # fix columns + col_names = self._get_cellid_columns() for field_idx, column_name in fields_to_correct: - # add individual layer/row/column/cell/node columns - if isinstance(self._mg, StructuredGrid): + # Extract component values before modifying pdata so that dropping + # the source column frees its name to be reused. This avoids the + # name collision that occurs on single-component grids (DISU etc.) + # where the tuple column and the target scalar column share the + # same name (e.g. both "cellid_node"). + extracted = [] + for i in range(len(col_names)): try: - pdata.insert( - loc=field_idx, - column=self._unique_column_name(pdata, "cellid_layer"), - value=pdata.apply(lambda x: x[column_name][0], axis=1), + extracted.append( + pdata.apply(lambda x, ci=i: x[column_name][ci], axis=1) ) except (ValueError, TypeError): - self._untuple_manually( - pdata, - field_idx, - self._unique_column_name(pdata, "cellid_layer"), - column_name, - 0, - ) - try: - pdata.insert( - loc=field_idx + 1, - column=self._unique_column_name(pdata, "cellid_row"), - value=pdata.apply(lambda x: x[column_name][1], axis=1), - ) - except (ValueError, TypeError): - self._untuple_manually( - pdata, - field_idx + 1, - self._unique_column_name(pdata, "cellid_row"), - column_name, - 1, - ) - try: - pdata.insert( - loc=field_idx + 2, - column=self._unique_column_name( - pdata, "cellid_column" - ), - value=pdata.apply(lambda x: x[column_name][2], axis=1), - ) - except (ValueError, TypeError): - self._untuple_manually( - pdata, - field_idx + 2, - self._unique_column_name(pdata, "cellid_column"), - column_name, - 2, - ) - elif isinstance(self._mg, VertexGrid): - try: - pdata.insert( - loc=field_idx, - column=self._unique_column_name(pdata, "cellid_layer"), - value=pdata.apply(lambda x: x[column_name][0], axis=1), - ) - except (ValueError, TypeError): - self._untuple_manually( - pdata, - field_idx, - self._unique_column_name(pdata, "cellid_layer"), - column_name, - 0, - ) - try: - pdata.insert( - loc=field_idx + 1, - column=self._unique_column_name(pdata, "cellid_cell"), - value=pdata.apply(lambda x: x[column_name][1], axis=1), - ) - except (ValueError, TypeError): - self._untuple_manually( - pdata, - field_idx + 1, - self._unique_column_name(pdata, "cellid_cell"), - column_name, - 1, - ) - elif isinstance(self._mg, UnstructuredGrid): - if column_name == "cellid_node": - # fixing a problem where node was specified as a tuple - # make sure new column is named properly - column_name = "cellid_node_2" - pdata = pdata.rename(columns={"cellid_node": column_name}) - try: - pdata.insert( - loc=field_idx, - column=self._unique_column_name(pdata, "cellid_node"), - value=pdata.apply(lambda x: x[column_name][0], axis=1), - ) - except (ValueError, TypeError): - self._untuple_manually( - pdata, - field_idx, - self._unique_column_name(pdata, "cellid_node"), - column_name, - 0, - ) - # remove cellid tuple + col_data = [] + for _, row in pdata.iterrows(): + val = row[column_name] + col_data.append( + val[i] if isinstance(val, (tuple, list)) else val + ) + extracted.append(col_data) pdata = pdata.drop(column_name, axis=1) + for i, (series, new_col) in enumerate(zip(extracted, col_names)): + pdata.insert(loc=field_idx + i, column=new_col, value=series) return pdata, len(fields_to_correct) def _resolve_columns(self, data): @@ -741,10 +611,7 @@ def set_data(self, data, autofill=False, check_data=True, append=False): else: # create dataset data = pandas.DataFrame(data, columns=columns) - if ( - self._data_item_names[-1] == "boundname" - and "boundname" not in columns - ): + if self._data_item_names[-1] == "boundname" and "boundname" not in columns: # add empty boundname column data["boundname"] = "" # get rid of tuples from cellids @@ -787,6 +654,29 @@ def set_data(self, data, autofill=False, check_data=True, append=False): else: # set correct data header names data = data.set_axis(self._header_names, axis=1) + elif isinstance(data, str): + parts = data.strip().split() + if len(parts) >= 2 and parts[0].lower() == "open/close": + MFPandasList.set_record(self, {"filename": parts[1]}) + return + message = ( + f"ERROR: Data list {self._data_name} is an unsupported type: " + f"{type(data)}." + ) + type_, value_, traceback_ = sys.exc_info() + raise MFDataException( + self.data_dimensions.structure.get_model(), + self.data_dimensions.structure.get_package(), + self.data_dimensions.structure.path, + "setting list data", + self.data_dimensions.structure.name, + inspect.stack()[0][3], + type_, + value_, + traceback_, + message, + self._simulation_data.debug, + ) else: message = ( f"ERROR: Data list {self._data_name} is an unsupported type: " @@ -959,7 +849,7 @@ def set_record(self, record, autofill=False, check_data=True): self._simulation_data.debug, ) data_storage.binary = record["binary"] - if "data" in record: + if "data" in record and record["data"] is not None: # data gets written out to file MFPandasList.set_data(self, record["data"]) # get file path @@ -993,22 +883,15 @@ def append_data(self, data): if self._get_storage_obj() is None: self._data_storage = self._new_storage() data_storage = self._get_storage_obj() - if ( - data_storage.data_storage_type - == DataStorageType.internal_array - ): + if data_storage.data_storage_type == DataStorageType.internal_array: # update internal data MFPandasList.set_data(self, data, append=True) - elif ( - data_storage.data_storage_type == DataStorageType.external_file - ): + elif data_storage.data_storage_type == DataStorageType.external_file: # get external data from file external_data = self._get_dataframe() if isinstance(data, list): # build dataframe - data = pandas.DataFrame( - data, columns=external_data.columns - ) + data = pandas.DataFrame(data, columns=external_data.columns) # concatenate data = pandas.concat([external_data, data]) # store @@ -1108,9 +991,7 @@ def store_internal( internal_data = { "data": data, } - MFPandasList.set_record( - self, internal_data, check_data=check_data - ) + MFPandasList.set_record(self, internal_data, check_data=check_data) def store_as_external_file( self, @@ -1144,8 +1025,7 @@ def store_as_external_file( replace_existing_external or storage is None or storage.data_storage_type == DataStorageType.internal_array - or storage.data_storage_type - == DataStorageType.internal_constant + or storage.data_storage_type == DataStorageType.internal_constant ): data = self._get_dataframe() # if not empty dataset @@ -1155,7 +1035,7 @@ def store_as_external_file( >= VerbosityLevel.verbose.value ): print( - "Storing {} to external file {}.." ".".format( + "Storing {} to external file {}...".format( self.structure.name, external_file_path ) ) @@ -1164,9 +1044,7 @@ def store_as_external_file( "data": data, "binary": binary, } - MFPandasList.set_record( - self, external_data, check_data=check_data - ) + MFPandasList.set_record(self, external_data, check_data=check_data) def external_file_name(self): """Returns external file name, or None if this is not external data.""" @@ -1216,10 +1094,7 @@ def _dataframe_check(self, data_frame): valid = data_frame.shape[0] > 0 if valid: for name in self._header_names: - if ( - name != "boundname" - and data_frame[name].isnull().values.any() - ): + if name != "boundname" and data_frame[name].isnull().values.any(): valid = False break return valid @@ -1306,9 +1181,7 @@ def _read_text_data(self, fd_data_file, first_line, external_file=False): # build header self._build_data_header() - file_data, next_line = self._file_data_to_memory( - fd_data_file, first_line - ) + file_data, next_line = self._file_data_to_memory(fd_data_file, first_line) io_file_data = io.StringIO("\n".join(file_data)) if external_file: data_frame = self._try_pandas_read(io_file_data, fd_data_file.name) @@ -1317,9 +1190,7 @@ def _read_text_data(self, fd_data_file, first_line, external_file=False): else: # get number of rows of data if len(file_data) > 0: - data_frame = self._try_pandas_read( - io_file_data, fd_data_file.name - ) + data_frame = self._try_pandas_read(io_file_data, fd_data_file.name) if data_frame is not None: self._decrement_id_fields(data_frame) return_val = [True, fd_data_file.readline()] @@ -1459,9 +1330,7 @@ def load( # parse first line to determine if this is internal or external data datautil.PyListUtil.reset_delimiter_used() line = datautil.PyListUtil.split_data_line(first_line) - if line and ( - len(line[0]) >= 2 and line[0][:3].upper() == "END" - ): + if line and (len(line[0]) >= 2 and line[0][:3].upper() == "END"): return [False, line] if len(line) >= 2 and line[0].upper() == "OPEN/CLOSE": try: @@ -1530,9 +1399,7 @@ def _get_id_fields(self, data_frame): """ id_fields = [] # loop through the data structure - for idx, data_item_struct in enumerate( - self.structure.data_item_structures - ): + for idx, data_item_struct in enumerate(self.structure.data_item_structures): if data_item_struct.type == DatumType.keystring: # handle id fields for keystring # ***Code not necessary for this version @@ -1548,9 +1415,7 @@ def _get_id_fields(self, data_frame): if isinstance(data_item_ks, MFDataStructure): dis = data_item_ks.data_item_structures for data_item in dis: - self._update_id_fields( - id_fields, data_item, data_frame - ) + self._update_id_fields(id_fields, data_item, data_frame) else: self._update_id_fields(id_fields, data_item_ks, data_frame) else: @@ -1565,20 +1430,7 @@ def _update_id_fields(self, id_fields, data_item_struct, data_frame): if data_item_struct.numeric_index or data_item_struct.is_cellid: name = data_item_struct.name.lower() if name.startswith("cellid"): - if isinstance(self._mg, StructuredGrid): - id_fields.append(f"{name}_layer") - id_fields.append(f"{name}_row") - id_fields.append(f"{name}_column") - elif isinstance(self._mg, VertexGrid): - id_fields.append(f"{name}_layer") - id_fields.append(f"{name}_cell") - elif isinstance(self._mg, UnstructuredGrid): - id_fields.append(f"{name}_node") - else: - raise MFDataException( - "ERROR: Unrecognized model grid " - "{str(self._mg)} not supported by MFBasicList" - ) + id_fields.extend(self._get_cellid_columns()) else: for col in data_frame.columns: if col.startswith(data_item_struct.name): @@ -1593,6 +1445,9 @@ def _update_id_fields(self, id_fields, data_item_struct, data_frame): id_fields.append(col) else: id_fields.append(data_item_struct.name) + else: + # exact column name match + id_fields.append(col) def _increment_id_fields(self, data_frame): """increment all id fields by 1 (reverse for negative values)""" @@ -1620,9 +1475,7 @@ def _resolve_ext_file_path(self, data_storage): model_name = data_dim.package_dim.model_dim[0].model_name fp_relative = data_storage.fname if model_name is not None and fp_relative is not None: - rel_path = self._simulation_data.mfpath.model_relative_path[ - model_name - ] + rel_path = self._simulation_data.mfpath.model_relative_path[model_name] if rel_path is not None and len(rel_path) > 0 and rel_path != ".": # include model relative path in external file path # only if model relative path is not already in external @@ -1632,9 +1485,7 @@ def _resolve_ext_file_path(self, data_storage): for i, rp in enumerate(rp_l_r): if rp != fp_rp_l[len(rp_l_r) - i - 1]: fp_relative = os.path.join(rp, fp_relative) - fp = self._simulation_data.mfpath.resolve_path( - fp_relative, model_name - ) + fp = self._simulation_data.mfpath.resolve_path(fp_relative, model_name) else: if fp_relative is not None: fp = os.path.join( @@ -1661,9 +1512,7 @@ def _get_dataframe(self): """get and return dataframe for this list data""" data_storage = self._get_storage_obj() if data_storage is None or data_storage.data_storage_type is None: - block_exists = self._block.header_exists( - self._current_key, self.path - ) + block_exists = self._block.header_exists(self._current_key, self.path) if block_exists: self._build_data_header() return pandas.DataFrame(columns=self._header_names) @@ -1896,9 +1745,7 @@ def _write_file_entry( # convert data to 1-based self._increment_id_fields(data) # write converted data - float_format = ( - f"%{self._simulation_data.reg_format_str[2:-1]}" - ) + float_format = f"%{self._simulation_data.reg_format_str[2:-1]}" result = data.to_csv( fd_data_file, sep=" ", @@ -1911,10 +1758,7 @@ def _write_file_entry( # clean up data_storage.modified = False self._decrement_id_fields(data) - if ( - data_storage.data_storage_type - == DataStorageType.external_file - ): + if data_storage.data_storage_type == DataStorageType.external_file: data_storage.internal_data = None if data_storage.internal_data is not None: @@ -2044,9 +1888,7 @@ def plot( ) -class MFPandasTransientList( - MFPandasList, mfdata.MFTransient, DataListInterface -): +class MFPandasTransientList(MFPandasList, mfdata.MFTransient, DataListInterface): """ Provides an interface for the user to access and update MODFLOW transient pandas list data. @@ -2091,7 +1933,9 @@ def __init__( self.repeating = True self.empty_keys = {} - def to_geodataframe(self, gdf=None, kper=0, full_grid=True, shorten_attr=False, **kwargs): + def to_geodataframe( + self, gdf=None, kper=0, full_grid=True, shorten_attr=False, **kwargs + ): """ Method to add data to a GeoDataFrame for exporting as a geospatial file @@ -2153,7 +1997,6 @@ def to_geodataframe(self, gdf=None, kper=0, full_grid=True, shorten_attr=False, return gdf - @property def data_type(self): return DataType.transientlist @@ -2394,9 +2237,7 @@ def get_data(self, key=None, apply_mult=False, dataframe=False, **kwargs): if dataframe: output[key] = super().get_dataframe() else: - output[key] = super().get_data( - apply_mult=apply_mult - ) + output[key] = super().get_data(apply_mult=apply_mult) return output self.get_data_prep(key) if dataframe: @@ -2496,7 +2337,9 @@ def _set_data_record( # If replacing, remove keys not in the new data if replace and self._data_storage: - keys_to_remove = set(self._data_storage.keys()) - set(data_record.keys()) + keys_to_remove = set(self._data_storage.keys()) - set( + data_record.keys() + ) for k in keys_to_remove: self.remove_transient_key(k) if k in self.empty_keys: @@ -2532,10 +2375,7 @@ def _set_data_record( super().set_data(data_record, autofill) else: if is_record: - comment = ( - "Set record method requires that data_record is a " - "dictionary." - ) + comment = "Set record method requires that data_record is a dictionary." type_, value_, traceback_ = sys.exc_info() raise MFDataException( self.structure.get_model(), @@ -2553,10 +2393,7 @@ def _set_data_record( if key is None: # search for a key new_key_index = self.structure.first_non_keyword_index() - if ( - new_key_index is not None - and len(data_record) > new_key_index - ): + if new_key_index is not None and len(data_record) > new_key_index: key = data_record[new_key_index] else: key = 0 @@ -2628,9 +2465,7 @@ def write_file_entry( fd_main=fd_main, ) - def get_file_entry( - self, key=0, ext_file_action=ExtFileAction.copy_relative_paths - ): + def get_file_entry(self, key=0, ext_file_action=ExtFileAction.copy_relative_paths): """Returns a string containing the data at time `key` formatted for a MODFLOW 6 file. @@ -2650,9 +2485,7 @@ def get_file_entry( return "" else: self._get_file_entry_prep(key) - return super()._write_file_entry( - None, ext_file_action=ext_file_action - ) + return super()._write_file_entry(None, ext_file_action=ext_file_action) def load( self, @@ -2734,10 +2567,7 @@ def _get_storage_obj(self, first_record=False): for value in self._data_storage.values(): return value return None - if ( - self._current_key is None - or self._current_key not in self._data_storage - ): + if self._current_key is None or self._current_key not in self._data_storage: return None return self._data_storage[self._current_key] diff --git a/flopy/mf6/data/mffileaccess.py b/flopy/mf6/data/mffileaccess.py index c4429a82a..608c221f7 100644 --- a/flopy/mf6/data/mffileaccess.py +++ b/flopy/mf6/data/mffileaccess.py @@ -1217,7 +1217,7 @@ def _get_cell_header(self, data_item, data_set, index): (f"{data_item.name}_cell", np.int32), ] else: - return [(f"{data_item.name}_nodes", np.int32)] + return [(f"{data_item.name}_node", np.int32)] def load_from_package( self, first_line, file_handle, storage, pre_data_comments=None From 098060699b6d34efabff690ac35b0209148b23f3 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Mon, 29 Jun 2026 19:44:59 -0400 Subject: [PATCH 49/90] feat(cellbudgetfile): support aux vars with full3D in get_data() (#2776) Add a 'variable' parameter (default "q") to get_data() and get_record() to allow specifying the field to extract to a 3D grid-shaped array. Previously "q" was returned unconditionally. 'variable' is only supported when full3D=True and the method's default behavior is unchanged. sat = cbc.get_data(text="DATA-SAT", full3D=True, variable="sat")[0] This is consistent with the existing `variable` parameter in get_ts(), added in #2568 Addresses #2774. --- autotest/test_cellbudgetfile.py | 88 ++++++++++++++++++++++++++++++ flopy/utils/binaryfile/__init__.py | 50 +++++++++++++---- 2 files changed, 126 insertions(+), 12 deletions(-) diff --git a/autotest/test_cellbudgetfile.py b/autotest/test_cellbudgetfile.py index 40a35308d..f99382be2 100644 --- a/autotest/test_cellbudgetfile.py +++ b/autotest/test_cellbudgetfile.py @@ -1158,6 +1158,94 @@ def test_cellbudgetfile_get_ts_backwards_compatible_idx_format( ) +@pytest.mark.requires_exe("mf6") +def test_cellbudgetfile_full3D_aux_var(function_tmpdir): + """ + Reproduce GitHub issue #2774: get_data(full3D=True) should return + correct values for auxiliary variables (e.g. sat), not just for q. + """ + from flopy.mf6 import ( + MFSimulation, + ModflowGwf, + ModflowGwfchd, + ModflowGwfdis, + ModflowGwfic, + ModflowGwfnpf, + ModflowGwfoc, + ModflowIms, + ModflowTdis, + ) + + sim_name = "test_full3d_sat" + sim = MFSimulation(sim_name=sim_name, sim_ws=function_tmpdir, exe_name="mf6") + ModflowTdis(sim, nper=2, perioddata=[(1.0, 1, 1.0), (1.0, 1, 1.0)]) + ModflowIms(sim) + gwf = ModflowGwf(sim, modelname=sim_name, save_flows=True) + nlay, nrow, ncol = 1, 5, 5 + ModflowGwfdis( + gwf, nlay=nlay, nrow=nrow, ncol=ncol, delr=10.0, delc=10.0, top=10.0, botm=[0.0] + ) + ModflowGwfic(gwf, strt=5.0) + ModflowGwfnpf(gwf, k=1.0, icelltype=1, save_saturation=True) + ModflowGwfchd(gwf, stress_period_data=[[(0, 0, 0), 9.0], [(0, 4, 4), 1.0]]) + ModflowGwfoc( + gwf, + budget_filerecord=f"{sim_name}.cbc", + head_filerecord=f"{sim_name}.hds", + saverecord=[("HEAD", "ALL"), ("BUDGET", "ALL")], + ) + + sim.write_simulation() + success, _ = sim.run_simulation(silent=True) + assert success + + cbc = gwf.output.budget() + + # non-full3D + sat_rec = cbc.get_data(text="DATA-SAT") + assert len(sat_rec) > 0 + assert "sat" in sat_rec[0].dtype.names + assert "q" in sat_rec[0].dtype.names + assert np.allclose(sat_rec[0]["q"], 0.0), "q should be zero in DATA-SAT records" + assert not np.allclose(sat_rec[0]["sat"], 0.0), "sat values should be non-zero" + assert not np.allclose(sat_rec[0]["sat"], sat_rec[0]["sat"][0]), ( + "sat values should vary across cells" + ) + + # full3D with default variable="q" + q_3d = cbc.get_data(text="DATA-SAT", full3D=True) + assert q_3d[0].shape == (nlay, nrow, ncol) + assert np.allclose(np.ma.filled(q_3d[0], 0.0), 0.0) + + # full3D with variable="sat" + sat_3d = cbc.get_data(text="DATA-SAT", full3D=True, variable="sat") + assert sat_3d[0].shape == (nlay, nrow, ncol) + assert not np.allclose(np.ma.filled(sat_3d[0], 0.0), 0.0) + + # check recarray and full 3D array match + rec = sat_rec[0] + arr = sat_3d[0] + for node, sat_val in zip(rec["node"], rec["sat"]): + k = (node - 1) // (nrow * ncol) + rc = (node - 1) % (nrow * ncol) + r = rc // ncol + c = rc % ncol + np.testing.assert_allclose( + arr[k, r, c], + sat_val, + err_msg=f"3D SAT value mismatch at node {node}", + ) + + +def test_cellbudgetfile_get_data_variable_without_full3D(example_data_path): + """variable= is only meaningful with full3D=True; without it, raise ValueError.""" + mf2005_model_path = example_data_path / "mf2005_test" + cbc = CellBudgetFile(mf2005_model_path / "test1tr.gitcbc") + with pytest.raises(ValueError, match="only used when full3D=True"): + cbc.get_data(text="WELLS", variable="IFACE") + cbc.close() + + @pytest.mark.requires_exe("mf6") def test_cellbudgetfile_write_preserves_aux_vars(dis_sim, function_tmpdir): """Test that write() method preserves auxiliary variables in imeth=6 records.""" diff --git a/flopy/utils/binaryfile/__init__.py b/flopy/utils/binaryfile/__init__.py index c7838021c..fc349eeb5 100644 --- a/flopy/utils/binaryfile/__init__.py +++ b/flopy/utils/binaryfile/__init__.py @@ -2894,6 +2894,7 @@ def get_data( paknam=None, paknam2=None, full3D=False, + variable="q", ) -> Union[list, np.ndarray]: """ Get data from the binary budget file. @@ -2922,6 +2923,10 @@ def get_data( If true, then return the record as a three dimensional numpy array, even for those list-style records written as part of a 'COMPACT BUDGET' MODFLOW budget file. (Default is False.) + variable : str + The variable name to extract when full3D is True and the record + contains auxiliary variables (e.g. 'sat' for 'DATA-SAT' records, + or 'qx'/'qy'/'qz' for 'DATA-SPDIS' records). Default is 'q'. Returns ------- @@ -2999,8 +3004,14 @@ def get_data( "get_data() missing 1 required argument: 'kstpkper', 'totim', " "'idx', or 'text'" ) + if variable != "q" and not full3D: + raise ValueError( + "'variable' is only used when full3D=True. " + "To access a specific field without full3D, index the " + "returned recarray directly, e.g. get_data(...)[0]['sat']." + ) return [ - self.get_record(idx, full3D=full3D) + self.get_record(idx, full3D=full3D, variable=variable) for idx, t in enumerate(select_indices) if t ] @@ -3298,7 +3309,7 @@ def _cellid_to_node(self, cellids) -> list[int]: nodes_0based = self.modelgrid.get_node(cellids) return (np.array(nodes_0based) + 1).tolist() - def get_record(self, idx, full3D=False): + def get_record(self, idx, full3D=False, variable="q"): """ Get a single data record from the budget file. @@ -3310,6 +3321,10 @@ def get_record(self, idx, full3D=False): If true, then return the record as a three dimensional numpy array, even for those list-style records written as part of a 'COMPACT BUDGET' MODFLOW budget file. (Default is False.) + variable : str + The variable name to extract when full3D is True and the record + contains auxiliary variables (e.g. 'sat' for 'DATA-SAT' records, + or 'qx'/'qy'/'qz' for 'DATA-SPDIS' records). Default is 'q'. Returns ------- @@ -3425,7 +3440,7 @@ def get_record(self, idx, full3D=False): if self.verbose: s += f"a list array of shape ({nlay}, {nrow}, {ncol})" print(s) - return self.__create3D(data) + return self.__create3D(data, variable=variable) else: if self.verbose: s += f"a numpy recarray of size ({nlist}, {2 + naux})" @@ -3451,7 +3466,7 @@ def get_record(self, idx, full3D=False): s += f"a numpy recarray of size ({nlist}, 2)" print(s) if full3D: - data = self.__create3D(data) + data = self.__create3D(data, variable=variable) if self.modelgrid is not None: return np.reshape(data, self.shape) else: @@ -3464,28 +3479,39 @@ def get_record(self, idx, full3D=False): # should not reach this point return - def __create3D(self, data): + def __create3D(self, data, variable="q"): """ - Convert a dictionary of {node: q, ...} into a numpy masked array. + Convert list budget data into a numpy masked array. Used to create full grid arrays when the full3D keyword is set to True in get_data. Parameters ---------- - data : dictionary - Dictionary with node keywords and flows (q) items. + data : numpy recarray + Record array with at least 'node' and the specified variable field. + variable : str + The field name to map into the 3D array. Default is 'q'. Returns ------- out : numpy masked array - List contains unique simulation times (totim) in binary file. + Masked array of shape self.shape with values from the specified + variable mapped to their grid positions. """ - out = np.ma.zeros(self.nnodes, dtype=data["q"].dtype) + if variable not in data.dtype.names: + raise ValueError( + f"variable '{variable}' not found in record. " + f"Available variables: {list(data.dtype.names)}" + ) + out = np.ma.zeros(self.nnodes, dtype=data[variable].dtype) out.mask = True - for [node, q] in zip(data["node"], data["q"]): + for node, val in zip(data["node"], data[variable]): idx = node - 1 - out.data[idx] += q + if variable == "q": + out.data[idx] += val + else: + out.data[idx] = val out.mask[idx] = False return np.ma.reshape(out, self.shape) From 012d0f1ee4da911eafc7789d45de21bf5793f525 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 05:05:38 -0700 Subject: [PATCH 50/90] chore(deps): bump prefix-dev/setup-pixi from 0.9.6 to 0.10.0 (#2777) --- .github/workflows/commit.yml | 2 +- .github/workflows/mf6.yml | 4 ++-- .github/workflows/rtd.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index 10e065bfd..b2f2ee1ff 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -150,7 +150,7 @@ jobs: version: 13 - name: Setup pixi - uses: prefix-dev/setup-pixi@v0.9.6 + uses: prefix-dev/setup-pixi@v0.10.0 with: pixi-version: v0.70.1 manifest-path: modflow6/pixi.toml diff --git a/.github/workflows/mf6.yml b/.github/workflows/mf6.yml index bce569707..4c86ad08e 100644 --- a/.github/workflows/mf6.yml +++ b/.github/workflows/mf6.yml @@ -39,7 +39,7 @@ jobs: version: 13 - name: Setup pixi - uses: prefix-dev/setup-pixi@v0.9.6 + uses: prefix-dev/setup-pixi@v0.10.0 with: pixi-version: v0.70.1 manifest-path: modflow6/pixi.toml @@ -122,7 +122,7 @@ jobs: version: 13 - name: Setup pixi - uses: prefix-dev/setup-pixi@v0.9.6 + uses: prefix-dev/setup-pixi@v0.10.0 with: pixi-version: v0.70.1 manifest-path: modflow6/pixi.toml diff --git a/.github/workflows/rtd.yml b/.github/workflows/rtd.yml index 62ea00e0b..c610baf24 100644 --- a/.github/workflows/rtd.yml +++ b/.github/workflows/rtd.yml @@ -87,7 +87,7 @@ jobs: echo $GITHUB_EVENT_NAME - name: Setup pixi - uses: prefix-dev/setup-pixi@v0.9.6 + uses: prefix-dev/setup-pixi@v0.10.0 with: pixi-version: v0.70.1 manifest-path: modflow6/pixi.toml From 079298e866e54d0e7e95c07030752d382225f144 Mon Sep 17 00:00:00 2001 From: mjreno Date: Tue, 30 Jun 2026 10:56:16 -0400 Subject: [PATCH 51/90] mfgrdfile: add version and crs properties (#2711) --- autotest/test_binarygrid_util.py | 233 +++++++++++++++++ .../data/mfgrd_test/disv_layered.disv.grb | Bin 0 -> 3988 bytes examples/data/mfgrd_test/flow_v2.dis.grb | Bin 0 -> 5174 bytes flopy/mf6/utils/binarygrid_util.py | 240 +++++++++++------- 4 files changed, 375 insertions(+), 98 deletions(-) create mode 100644 examples/data/mfgrd_test/disv_layered.disv.grb create mode 100644 examples/data/mfgrd_test/flow_v2.dis.grb diff --git a/autotest/test_binarygrid_util.py b/autotest/test_binarygrid_util.py index 5d56e0320..6c88fd4c8 100644 --- a/autotest/test_binarygrid_util.py +++ b/autotest/test_binarygrid_util.py @@ -17,6 +17,8 @@ def mfgrd_test_path(example_data_path): def test_mfgrddis_MfGrdFile(mfgrd_test_path): grb = MfGrdFile(mfgrd_test_path / "nwtp3.dis.grb", verbose=True) + assert grb.version == 1 + nodes = grb.nodes ia = grb.ia shape = ia.shape[0] @@ -30,6 +32,28 @@ def test_mfgrddis_MfGrdFile(mfgrd_test_path): modelgrid = grb.modelgrid assert isinstance(modelgrid, StructuredGrid) + assert grb.crs is None + + +def test_mfgrddis_MfGrdFile_v2(mfgrd_test_path): + grb = MfGrdFile(mfgrd_test_path / "flow_v2.dis.grb", verbose=True) + assert grb.version == 2 + + nodes = grb.nodes + ia = grb.ia + shape = ia.shape[0] + assert shape == nodes + 1, f"ia size ({shape}) not equal to {nodes + 1}" + + nnz = ia[-1] + ja = grb.ja + shape = ja.shape[0] + assert shape == nnz, f"ja size ({shape}) not equal to {nnz}" + + modelgrid = grb.modelgrid + assert isinstance(modelgrid, StructuredGrid) + + assert grb.crs == "EPSG:26916" + def test_mfgrddis_modelgrid(mfgrd_test_path): fn = mfgrd_test_path / "nwtp3.dis.grb" @@ -62,6 +86,7 @@ def test_mfgrddis_modelgrid(mfgrd_test_path): def test_mfgrddisv_MfGrdFile(mfgrd_test_path): fn = mfgrd_test_path / "flow.disv.grb" grb = MfGrdFile(fn, verbose=True) + assert grb.version == 1 nodes = grb.nodes ia = grb.ia @@ -114,6 +139,7 @@ def test_mfgrddisv_modelgrid(mfgrd_test_path): def test_mfgrddisu_MfGrdFile(mfgrd_test_path): fn = mfgrd_test_path / "keating.disu.grb" grb = MfGrdFile(fn, verbose=True) + assert grb.version == 1 nodes = grb.nodes ia = grb.ia @@ -164,11 +190,13 @@ def test_mfgrddisu_modelgrid(mfgrd_test_path): def test_write_grb_instance_method(tmp_path, mfgrd_test_path): original_file = mfgrd_test_path / "nwtp3.dis.grb" grb_orig = MfGrdFile(original_file, verbose=False) + _ = grb_orig.modelgrid # trigger _set_modelgrid so BOTM is reshaped before export output_file = tmp_path / "test_instance.dis.grb" grb_orig.export(output_file, verbose=False) grb_new = MfGrdFile(output_file, verbose=False) + _ = grb_new.modelgrid assert grb_new.grid_type == grb_orig.grid_type assert grb_new.nodes == grb_orig.nodes @@ -191,6 +219,37 @@ def test_write_grb_instance_method(tmp_path, mfgrd_test_path): np.testing.assert_array_equal(grb_new.idomain, grb_orig.idomain) +def test_write_grb_instance_method_disv(tmp_path, mfgrd_test_path): + # disv_layered.disv.grb has distinct BOTM values per layer (-10/-20/-30), + # which distinguishes C from F flatten order and guards against order regression. + original_file = mfgrd_test_path / "disv_layered.disv.grb" + grb_orig = MfGrdFile(original_file, verbose=False) + _ = grb_orig.modelgrid # trigger _set_modelgrid so BOTM is reshaped before export + + output_file = tmp_path / "test_instance.disv.grb" + grb_orig.export(output_file, verbose=False) + + grb_new = MfGrdFile(output_file, verbose=False) + _ = grb_new.modelgrid + + assert grb_new.grid_type == grb_orig.grid_type + assert grb_new.nodes == grb_orig.nodes + assert grb_new.nlay == grb_orig.nlay + assert grb_new.ncpl == grb_orig.ncpl + assert grb_new.nja == grb_orig.nja + + np.testing.assert_allclose(grb_new.xorigin, grb_orig.xorigin) + np.testing.assert_allclose(grb_new.yorigin, grb_orig.yorigin) + np.testing.assert_allclose(grb_new.angrot, grb_orig.angrot) + + np.testing.assert_allclose(grb_new.top, grb_orig.top) + np.testing.assert_allclose(grb_new.bot, grb_orig.bot) + + np.testing.assert_array_equal(grb_new.ia, grb_orig.ia) + np.testing.assert_array_equal(grb_new.ja, grb_orig.ja) + np.testing.assert_array_equal(grb_new.idomain, grb_orig.idomain) + + def test_write_grb_instance_method_precision_conversion(tmp_path, mfgrd_test_path): original_file = mfgrd_test_path / "nwtp3.dis.grb" grb = MfGrdFile(original_file, verbose=False) @@ -396,3 +455,177 @@ def test_write_grb_disu_precision_conversion(tmp_path, mfgrd_test_path): # Double precision should match exactly (same precision as original) np.testing.assert_allclose(grb_double.top, grb.top, rtol=1e-12) np.testing.assert_allclose(grb_double.bot, grb.bot, rtol=1e-12) + + +def test_write_grb_v2_roundtrip(tmp_path, mfgrd_test_path): + """Round-trip a version 2 GRB: version and CRS are preserved.""" + grb_orig = MfGrdFile(mfgrd_test_path / "flow_v2.dis.grb", verbose=False) + assert grb_orig.version == 2 + assert grb_orig.crs == "EPSG:26916" + + output_file = tmp_path / "flow_v2_copy.dis.grb" + grb_orig.export(output_file, verbose=False) + + grb_new = MfGrdFile(output_file, verbose=False) + assert grb_new.version == 2 + assert grb_new.crs == "EPSG:26916" + assert grb_new.nodes == grb_orig.nodes + np.testing.assert_allclose(grb_new.top, grb_orig.top) + np.testing.assert_allclose(grb_new.bot, grb_orig.bot) + np.testing.assert_array_equal(grb_new.ia, grb_orig.ia) + np.testing.assert_array_equal(grb_new.ja, grb_orig.ja) + + +def test_write_grb_v1_upgrade_to_v2(tmp_path, mfgrd_test_path): + """Exporting a v1 GRB with crs= automatically produces a v2 file.""" + grb_orig = MfGrdFile(mfgrd_test_path / "nwtp3.dis.grb", verbose=False) + assert grb_orig.version == 1 + assert grb_orig.crs is None + + output_file = tmp_path / "nwtp3_v2.dis.grb" + grb_orig.export(output_file, crs="EPSG:26916", verbose=False) + + grb_new = MfGrdFile(output_file, verbose=False) + assert grb_new.version == 2 + assert grb_new.crs == "EPSG:26916" + assert grb_new.nodes == grb_orig.nodes + np.testing.assert_array_equal(grb_new.ia, grb_orig.ia) + + +def test_write_grb_v1_force_override(tmp_path, mfgrd_test_path): + """Explicit version=1 suppresses CRS even when crs= is provided.""" + grb = MfGrdFile(mfgrd_test_path / "nwtp3.dis.grb", verbose=False) + output_file = tmp_path / "forced_v1.dis.grb" + grb.export(output_file, version=1, crs="EPSG:26916") + + grb_new = MfGrdFile(output_file, verbose=False) + assert grb_new.version == 1 + assert grb_new.crs is None + assert grb_new.nodes == grb.nodes + + +def test_write_grb_v2_requires_crs(tmp_path, mfgrd_test_path): + """Requesting version=2 without a CRS raises ValueError.""" + grb = MfGrdFile(mfgrd_test_path / "nwtp3.dis.grb", verbose=False) + with pytest.raises(ValueError, match="version=2 requires a CRS"): + grb.export(tmp_path / "out.grb", version=2) + + +def test_write_grb_v2_crs_too_long(tmp_path, mfgrd_test_path): + """A CRS string exceeding 5000 characters raises ValueError.""" + grb = MfGrdFile(mfgrd_test_path / "nwtp3.dis.grb", verbose=False) + with pytest.raises(ValueError, match="exceeds the MODFLOW 6 maximum"): + grb.export(tmp_path / "out.grb", crs="X" * 5001) + + +def test_crs_to_string(): + """_crs_to_string normalizes CRS inputs to strings.""" + from flopy.mf6.utils.binarygrid_util import _crs_to_string + + # str pass-through + assert _crs_to_string("EPSG:26916") == "EPSG:26916" + assert _crs_to_string("some WKT...") == "some WKT..." + + # int → EPSG authority string + assert _crs_to_string(26916) == "EPSG:26916" + + # pyproj.CRS with a known EPSG code → prefers short EPSG form + pyproj = pytest.importorskip("pyproj") + crs_obj = pyproj.CRS.from_epsg(26916) + assert _crs_to_string(crs_obj) == "EPSG:26916" + + # pyproj.CRS built from WKT that has an EPSG code still normalizes + crs_from_wkt = pyproj.CRS.from_wkt(crs_obj.to_wkt()) + assert _crs_to_string(crs_from_wkt) == "EPSG:26916" + + +def test_write_grb_v2_crs_int(tmp_path, mfgrd_test_path): + """Passing an integer EPSG code writes 'EPSG:N' to the GRB.""" + grb_orig = MfGrdFile(mfgrd_test_path / "nwtp3.dis.grb", verbose=False) + output_file = tmp_path / "nwtp3_v2_int.dis.grb" + grb_orig.export(output_file, crs=26916) + + grb_new = MfGrdFile(output_file, verbose=False) + assert grb_new.version == 2 + assert grb_new.crs == "EPSG:26916" + + +def test_write_grb_v2_crs_pyproj(tmp_path, mfgrd_test_path): + """Passing a pyproj.CRS object normalizes to EPSG string in the GRB.""" + pyproj = pytest.importorskip("pyproj") + grb_orig = MfGrdFile(mfgrd_test_path / "nwtp3.dis.grb", verbose=False) + output_file = tmp_path / "nwtp3_v2_pyproj.dis.grb" + grb_orig.export(output_file, crs=pyproj.CRS.from_epsg(26916)) + + grb_new = MfGrdFile(output_file, verbose=False) + assert grb_new.version == 2 + assert grb_new.crs == "EPSG:26916" + + +def test_write_grb_disv_v1_upgrade_to_v2(tmp_path, mfgrd_test_path): + """Upgrading a v1 DISV GRB to v2 preserves geometry and writes CRS.""" + grb_orig = MfGrdFile(mfgrd_test_path / "flow.disv.grb", verbose=False) + assert grb_orig.version == 1 + assert grb_orig.crs is None + + output_file = tmp_path / "flow_v2.disv.grb" + grb_orig.export(output_file, crs="EPSG:26916") + + grb_new = MfGrdFile(output_file, verbose=False) + assert grb_new.version == 2 + assert grb_new.crs == "EPSG:26916" + assert grb_new.grid_type == "DISV" + assert grb_new.nodes == grb_orig.nodes + assert grb_new.nja == grb_orig.nja + np.testing.assert_array_equal(grb_new.ia, grb_orig.ia) + np.testing.assert_array_equal(grb_new.ja, grb_orig.ja) + np.testing.assert_allclose(grb_new.top, grb_orig.top) + np.testing.assert_allclose(grb_new.bot, grb_orig.bot) + np.testing.assert_allclose( + grb_new._datadict["VERTICES"], grb_orig._datadict["VERTICES"] + ) + np.testing.assert_allclose(grb_new._datadict["CELLX"], grb_orig._datadict["CELLX"]) + np.testing.assert_allclose(grb_new._datadict["CELLY"], grb_orig._datadict["CELLY"]) + + +def test_write_grb_disv_v2_roundtrip(tmp_path, mfgrd_test_path): + """Round-trip a v2 DISV GRB: version and CRS are preserved.""" + # First create a v2 DISV file by upgrading the v1 source + v2_file = tmp_path / "flow_v2.disv.grb" + MfGrdFile(mfgrd_test_path / "flow.disv.grb").export(v2_file, crs="EPSG:26916") + + grb_orig = MfGrdFile(v2_file, verbose=False) + assert grb_orig.version == 2 + + output_file = tmp_path / "flow_v2_copy.disv.grb" + grb_orig.export(output_file, verbose=False) + + grb_new = MfGrdFile(output_file, verbose=False) + assert grb_new.version == 2 + assert grb_new.crs == "EPSG:26916" + assert grb_new.nodes == grb_orig.nodes + np.testing.assert_array_equal(grb_new.ia, grb_orig.ia) + np.testing.assert_allclose( + grb_new._datadict["VERTICES"], grb_orig._datadict["VERTICES"] + ) + + +def test_write_grb_disu_v1_upgrade_to_v2(tmp_path, mfgrd_test_path): + """Upgrading a v1 DISU GRB to v2 preserves connectivity and writes CRS.""" + grb_orig = MfGrdFile(mfgrd_test_path / "flow.disu.grb", verbose=False) + assert grb_orig.version == 1 + assert grb_orig.crs is None + + output_file = tmp_path / "flow_v2.disu.grb" + grb_orig.export(output_file, crs="EPSG:26916") + + grb_new = MfGrdFile(output_file, verbose=False) + assert grb_new.version == 2 + assert grb_new.crs == "EPSG:26916" + assert grb_new.grid_type == "DISU" + assert grb_new.nodes == grb_orig.nodes + assert grb_new.nja == grb_orig.nja + np.testing.assert_array_equal(grb_new.ia, grb_orig.ia) + np.testing.assert_array_equal(grb_new.ja, grb_orig.ja) + np.testing.assert_allclose(grb_new.top, grb_orig.top) + np.testing.assert_allclose(grb_new.bot, grb_orig.bot) diff --git a/examples/data/mfgrd_test/disv_layered.disv.grb b/examples/data/mfgrd_test/disv_layered.disv.grb new file mode 100644 index 0000000000000000000000000000000000000000..069f9ab4fe5abe130d2903937edc3511cfd78912 GIT binary patch literal 3988 zcmchY%Wl&^6o$hchD*7W`+||$MOjeAra+MpC~d6BRcc3xTO}J72#FON76=ImHoOC` z!&~q={9o`wr|ISeP8i$`%!-or-=_L7I>Fr!_0@x zs`=i#ZZxh5npKNjvK@8110N?@)QS2&X~(<1=~sOCsygkAOqX<9qe+J6Gs@-)OGmFe z$?y4EOTrs98*+;HOlaAnD*mNocMOSx7Cdf zzAHLwt~U$MvE3dWbfZ(?XeFI~nk`a>cUjsi5Cp!~@0J~OTWPjiBB}9+k6#iuTo!Lf zg91o!0zvJ&HRc#Cm?NHY%mW`(y|<4(6$cSF?J5a;eHRJeRb0AC0v~Q3 zv{1`PI*Qko_Nms(YOJH3?zVJa75)e%roO*H?G+zyHtI!-V{wlgjmA0Qif~hSCj1ai z=UM&ppXa1@BVXsF_HDk-$?UJczgyDvEw^hWzgO}t&uk-)Tb|XMU$$0f^Hy(lWovmh zZ}sL|z1i}-)mz-^&0o&@f0Nsfxt(|3>dMyEEn8c^V9)49TokSfeBXD4N5T_fRd_0_ z3*?;@$h~mL=WJZc=ZWK=7cL9To`XQhAQa(={ zpE@)!k5A9^0$7XqE#aPE8do%46FAqQuqnI~B4J19346jwcrSbqJ_(`SYwO(B7=HNJ1Gw(wW9I1x zuH}3TpY0{@o_zSoB@a&ai!r>mCcVLZUtnxn$)kq{0=1@>+=l|z^b#kRcY!s{b-Mwr;CQyY<()HED87)22zAq`#)q>4qQ(f*=Tj zoFE8-AP9mW2!bF8!axuNAs`5XAP9mW2!bF8!axuNL7!)R7iqO7lh(f)`r_&5`6m75 za(8K=TFgiJVl8U*5zfceTCq}&`dWTPxxQGB(u1u&KZ?Rqy!@I}s>K(D4U)uLj# z9v9+jRL&Qtqg2!xrCa_JO?L{*rR>tC?w}(tS1Swuut(E3cO6gAXH_X}`W}s&!c=c+)uY)TTW397`XBblmJ8KNeKQXu3iENP zx@w1XlwRkU|7-0%a+|$Jy)v`OTSVb_r9QoOla2ctx%Tmf#WlBwPDg5kPe{1-7EDJQ zdjsl7!eYKMoz*?K>WkM0)9N>O^Q$c^`n$cpG!t*$E@7@(i*gg$YBpD2`S;w<%bAcFG9ViZtdkisARF_nlNmA~8*{9a88RRn zb=JuY8IX+{>tu!u$VQcQGD8MrW0rL?Lk47HhIKMS1}lAKc|9PLGBcS>Gm}Y)nM|gb z$z+n5Op458GQmtH1!gii$4n+MGnwR>$t1^2CgaRxl4T~74D(8kF`p&RkfY>ja)cZv zhsZ&4fILN>B>TxeGEJt)Uh+8ELmnZ!$u9B`8Ik+Ry<`Wui)<$Ya^?P5zPD}&g5V~( zMQ)YbK%12Qb5;+t?>VwsRBDa)+XWkJr%1-U4fx8>5xO)KZy5a6H+#P|tqj1*)cgNuFINY6pyI#0^5bjcNmxjAOxa)_zlW=zm z?grp)5blQHZW!)H;O;csjl$g-xH}7XV{n&&yDZ#|!(9&U@^BZ!-8r}`z}*Dg72$3Y z?xx_b1b5SLSBAR^+|9t>> from flopy.mf6.utils import MfGrdFile >>> grb = MfGrdFile('model.dis.grb') >>> grb.export('model_copy.dis.grb') - >>> # Convert to single precision - >>> grb.export('model_single.dis.grb', precision='single') + >>> # Round-trip a v2 file preserving CRS + >>> grb2 = MfGrdFile('model_v2.dis.grb') + >>> grb2.export('model_v2_copy.dis.grb') + >>> # Upgrade a v1 file to v2 with an explicit CRS + >>> grb.export('model_v2.dis.grb', crs='EPSG:26916') """ if precision is None: precision = self.precision - # Build data dictionary from instance - data_dict = {} - for key in self._recordkeys: - if key in ("IA", "JA"): - # Use original 1-based arrays - data_dict[key] = self._datadict[key] - elif key == "TOP": - data_dict[key] = self.top - elif key == "BOTM": - data_dict[key] = self.bot - elif key in self._datadict: - data_dict[key] = self._datadict[key] - - # Define variable metadata based on grid type + raw_crs = crs if crs is not None else self.crs + effective_crs = _crs_to_string(raw_crs) if raw_crs is not None else None + if version is None: + version = 2 if effective_crs else 1 + float_type = "SINGLE" if precision.lower() == "single" else "DOUBLE" - if self.grid_type == "DIS": - var_list = [ - ("NCELLS", "INTEGER", 0, []), - ("NLAY", "INTEGER", 0, []), - ("NROW", "INTEGER", 0, []), - ("NCOL", "INTEGER", 0, []), - ("NJA", "INTEGER", 0, []), - ("XORIGIN", float_type, 0, []), - ("YORIGIN", float_type, 0, []), - ("ANGROT", float_type, 0, []), - ("DELR", float_type, 1, [self.ncol]), - ("DELC", float_type, 1, [self.nrow]), - ("TOP", float_type, 1, [self.nodes]), - ("BOTM", float_type, 1, [self.nodes]), - ("IA", "INTEGER", 1, [self.nodes + 1]), - ("JA", "INTEGER", 1, [self.nja]), - ("IDOMAIN", "INTEGER", 1, [self.nodes]), - ("ICELLTYPE", "INTEGER", 1, [self.nodes]), - ] - elif self.grid_type == "DISV": - # Get dimensions for DISV arrays - nvert = self._datadict["NVERT"] - njavert = self._datadict["NJAVERT"] - var_list = [ - ("NCELLS", "INTEGER", 0, []), - ("NLAY", "INTEGER", 0, []), - ("NCPL", "INTEGER", 0, []), - ("NVERT", "INTEGER", 0, []), - ("NJAVERT", "INTEGER", 0, []), - ("NJA", "INTEGER", 0, []), - ("XORIGIN", float_type, 0, []), - ("YORIGIN", float_type, 0, []), - ("ANGROT", float_type, 0, []), - ("TOP", float_type, 1, [self.nodes]), - ("BOTM", float_type, 1, [self.nodes]), - ("VERTICES", float_type, 2, [nvert, 2]), - ("CELLX", float_type, 1, [self.nodes]), - ("CELLY", float_type, 1, [self.nodes]), - ("IAVERT", "INTEGER", 1, [self.nodes + 1]), - ("JAVERT", "INTEGER", 1, [njavert]), - ("IA", "INTEGER", 1, [self.nodes + 1]), - ("JA", "INTEGER", 1, [self.nja]), - ("IDOMAIN", "INTEGER", 1, [self.nodes]), - ("ICELLTYPE", "INTEGER", 1, [self.nodes]), - ] - elif self.grid_type == "DISU": - var_list = [ - ("NODES", "INTEGER", 0, []), - ("NJA", "INTEGER", 0, []), - ("XORIGIN", float_type, 0, []), - ("YORIGIN", float_type, 0, []), - ("ANGROT", float_type, 0, []), - ("TOP", float_type, 1, [self.nodes]), - ("BOT", float_type, 1, [self.nodes]), - ("IA", "INTEGER", 1, [self.nodes + 1]), - ("JA", "INTEGER", 1, [self.nja]), - ("ICELLTYPE", "INTEGER", 1, [self.nodes]), - ] - # IDOMAIN is optional for DISU - if "IDOMAIN" in self._datadict: - var_list.insert(-1, ("IDOMAIN", "INTEGER", 1, [self.nodes])) - else: - raise NotImplementedError( - f"Grid type {self.grid_type} not yet implemented. " - "Supported grid types: DIS, DISV, DISU" - ) + # Build var_list and data_dict from the parsed record structure so that + # array dimensions come directly from the source file rather than being + # recomputed. This avoids shape mismatches and naturally handles any + # grid type. + var_list = [] + data_dict = {} + for key in self._recordkeys: + if key == "CRS": + continue # handled by the version check below + dt, nd, shp = self._recorddict[key] + if dt == np.int32: + dtype_str = "INTEGER" + elif dt in (np.float32, np.float64): + dtype_str = float_type + elif dt == str: + dtype_str = "CHARACTER" + else: + dtype_str = float_type + # shp is stored in reversed (numpy) order; the write loop reverses + # it back to Fortran (definition-line) order via dims[::-1]. + dims = list(shp) if nd > 0 else [] + var_list.append((key, dtype_str, nd, dims)) + data_dict[key] = self._datadict[key] + + if version >= 2: + if not effective_crs: + raise ValueError( + "version=2 requires a CRS string. Provide crs= or use a " + "version 2 source file." + ) + _MF6_CRS_MAXLEN = 5000 + if len(effective_crs) > _MF6_CRS_MAXLEN: + raise ValueError( + f"CRS string length {len(effective_crs)} exceeds the " + f"MODFLOW 6 maximum of {_MF6_CRS_MAXLEN} characters." + ) + var_list.append(("CRS", "CHARACTER", 1, [len(effective_crs)])) + data_dict["CRS"] = effective_crs ntxt = len(var_list) lentxt = 100 @@ -873,28 +894,36 @@ def export(self, filename, precision=None, version=1, verbose=False): with open(filename, "wb") as f: writer.file = f - # Write text header lines (50 chars each, newline terminated) + # Write text header lines and definition lines in MODFLOW 6 format: + # content left-aligned, space-padded to (nchar-1), then newline at + # position (nchar-1). This matches what MF6 writes and is required + # for Fortran readers that parse these as fixed-length text records. + def write_line(text, nchar): + writer.file.write(text.encode("ascii").ljust(nchar - 1) + b"\n") + header_len = 50 - writer.write_text(f"GRID {self.grid_type}\n", header_len) - writer.write_text(f"VERSION {version}\n", header_len) - writer.write_text(f"NTXT {ntxt}\n", header_len) - writer.write_text(f"LENTXT {lentxt}\n", header_len) + write_line(f"GRID {self.grid_type}", header_len) + write_line(f"VERSION {version}", header_len) + write_line(f"NTXT {ntxt}", header_len) + write_line(f"LENTXT {lentxt}", header_len) # Write variable definition lines (100 chars each) for name, dtype_str, ndim, dims in var_list: if ndim == 0: - line = f"{name} {dtype_str} NDIM {ndim}\n" + line = f"{name} {dtype_str} NDIM {ndim}" else: dims_str = " ".join( str(d) for d in dims[::-1] ) # Reverse for Fortran order - line = f"{name} {dtype_str} NDIM {ndim} {dims_str}\n" - writer.write_text(line, lentxt) + line = f"{name} {dtype_str} NDIM {ndim} {dims_str}" + write_line(line, lentxt) # Write binary data for each variable for name, dtype_str, ndim, dims in var_list: if name not in data_dict: - raise ValueError(f"Required variable '{name}' not found in grid file") + raise ValueError( + f"Required variable '{name}' not found in grid file" + ) value = data_dict[name] @@ -910,7 +939,9 @@ def export(self, filename, precision=None, version=1, verbose=False): print(f" Writing {name}") # Write scalar or array data - if ndim == 0: + if dtype_str == "CHARACTER": + writer.write_text(value, len(value)) + elif ndim == 0: # Scalar value if dtype_str == "INTEGER": writer.write_integer(int(value)) @@ -926,8 +957,21 @@ def export(self, filename, precision=None, version=1, verbose=False): elif dtype_str == "SINGLE": arr = arr.astype(np.float32) - # Write array in column-major (Fortran) order - writer.write_record(arr.flatten(order="F"), dtype=arr.dtype) + writer.write_record(np.ravel(arr, order="C"), dtype=arr.dtype) if verbose: print(f"Successfully wrote {filename}") + + @property + def crs(self): + """ + CRS user input string (version 2 GRB file only). + + Returns + ------- + crs : str or None + """ + crs = None + if "CRS" in self._datadict: + crs = self._datadict["CRS"] + return crs From 7c717126d81259eb9ae6196f420e56aa56744a86 Mon Sep 17 00:00:00 2001 From: mjreno Date: Wed, 1 Jul 2026 18:35:32 -0400 Subject: [PATCH 52/90] mfgrdfile: export follow-on (#2779) --- autotest/test_binarygrid_util.py | 52 ++++++++++++++++++++++++++++++ flopy/mf6/utils/binarygrid_util.py | 26 +++++++++++++-- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/autotest/test_binarygrid_util.py b/autotest/test_binarygrid_util.py index 6c88fd4c8..143710f09 100644 --- a/autotest/test_binarygrid_util.py +++ b/autotest/test_binarygrid_util.py @@ -629,3 +629,55 @@ def test_write_grb_disu_v1_upgrade_to_v2(tmp_path, mfgrd_test_path): np.testing.assert_array_equal(grb_new.ja, grb_orig.ja) np.testing.assert_allclose(grb_new.top, grb_orig.top) np.testing.assert_allclose(grb_new.bot, grb_orig.bot) + + +def test_write_grb_export_is_keyword_only(tmp_path, mfgrd_test_path): + """A positional call beyond filename raises.""" + grb = MfGrdFile(mfgrd_test_path / "nwtp3.dis.grb", verbose=False) + with pytest.raises(TypeError): + grb.export(tmp_path / "out.grb", "double", 1, "EPSG:26916", True) + + +def test_write_grb_empty_crs_argument_falls_back(tmp_path, mfgrd_test_path): + """An explicit empty/blank crs argument is treated like crs=None: it + falls back to the source file's CRS instead of overriding it.""" + grb = MfGrdFile(mfgrd_test_path / "flow_v2.dis.grb", verbose=False) + assert grb.crs is not None + + output_file = tmp_path / "out.grb" + grb.export(output_file, crs="") + grb_new = MfGrdFile(output_file, verbose=False) + assert grb_new.crs == grb.crs + + output_file2 = tmp_path / "out2.grb" + grb.export(output_file2, crs=" ") + grb_new2 = MfGrdFile(output_file2, verbose=False) + assert grb_new2.crs == grb.crs + + +def test_write_grb_empty_crs_argument_no_source_crs(tmp_path, mfgrd_test_path): + """An explicit empty/blank crs argument with no source CRS to fall + back on results in a version 1 file with no CRS, same as crs=None.""" + grb = MfGrdFile(mfgrd_test_path / "nwtp3.dis.grb", verbose=False) + assert grb.crs is None + + output_file = tmp_path / "out.grb" + grb.export(output_file, crs="") + grb_new = MfGrdFile(output_file, verbose=False) + assert grb_new.version == 1 + assert grb_new.crs is None + + +def test_write_grb_blank_source_crs_treated_as_absent(tmp_path, mfgrd_test_path): + """A source file's blank CRS is treated as absent on re-export: + version drops to 1 and no CRS field is written.""" + grb = MfGrdFile(mfgrd_test_path / "flow_v2.dis.grb", verbose=False) + grb._datadict["CRS"] = "" + + output_file = tmp_path / "blank_crs.grb" + grb.export(output_file) + + grb_new = MfGrdFile(output_file, verbose=False) + assert grb_new.version == 1 + assert grb_new.crs is None + assert "CRS" not in grb_new._datadict diff --git a/flopy/mf6/utils/binarygrid_util.py b/flopy/mf6/utils/binarygrid_util.py index 360a4bdaa..88e9b565c 100644 --- a/flopy/mf6/utils/binarygrid_util.py +++ b/flopy/mf6/utils/binarygrid_util.py @@ -795,7 +795,15 @@ def cell2d(self): vertices, cell2d = None, None return vertices, cell2d - def export(self, filename, precision=None, version=None, crs=None, verbose=False): + def export( + self, + filename, + *, + precision=None, + version=None, + crs=None, + verbose=False, + ): """ Export the binary grid file to a new file. @@ -818,6 +826,11 @@ def export(self, filename, precision=None, version=None, crs=None, verbose=False verbose : bool, optional Print progress messages (default False) + All parameters except ``filename`` are keyword-only, so adding a + new parameter here in the future (e.g. a dedicated parameter and + property for a new GRB field, mirroring ``crs``/``.crs``) can + never silently change the meaning of an existing positional call. + Examples -------- >>> from flopy.mf6.utils import MfGrdFile @@ -832,10 +845,17 @@ def export(self, filename, precision=None, version=None, crs=None, verbose=False if precision is None: precision = self.precision + if isinstance(crs, str) and crs.strip() == "": + crs = None + raw_crs = crs if crs is not None else self.crs effective_crs = _crs_to_string(raw_crs) if raw_crs is not None else None + if effective_crs is not None and effective_crs.strip() == "": + # A blank CRS carries no usable information. Treat it the + # same as no CRS rather than writing an empty crs string. + effective_crs = None if version is None: - version = 2 if effective_crs else 1 + version = 2 if effective_crs is not None else 1 float_type = "SINGLE" if precision.lower() == "single" else "DOUBLE" @@ -864,7 +884,7 @@ def export(self, filename, precision=None, version=None, crs=None, verbose=False data_dict[key] = self._datadict[key] if version >= 2: - if not effective_crs: + if effective_crs is None: raise ValueError( "version=2 requires a CRS string. Provide crs= or use a " "version 2 source file." From 38b6a10f19c0192369ec7fcbe3be7fba8ae5a5c2 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Sat, 11 Jul 2026 08:26:35 -0400 Subject: [PATCH 53/90] fix(rasters): accommodate rasterio>=1.5 (#2781) Pass adjust=False to rasterio.plot.show() unless the caller sets it True. Accommodate the default switch in rasterio/rasterio#3171. Looks like the default may be switched back rasterio/rasterio#3549, but in any case this guarantees the right behavior by default, and a caller choosing to pass adjust=True can set vmin/vmax accordingly. Close #2780 --- flopy/utils/rasters.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flopy/utils/rasters.py b/flopy/utils/rasters.py index 1c8989d89..c2c72a596 100644 --- a/flopy/utils/rasters.py +++ b/flopy/utils/rasters.py @@ -996,6 +996,11 @@ def plot(self, ax=None, contour=False, **kwargs): import_optional_dependency("rasterio") from rasterio.plot import show + # rasterio >= 1.5 defaults to adjusting data to [0, 1], which + # can clash with user-supplied vmin/vmax. don't adjust unless + # the caller explicitly asks for it + kwargs.setdefault("adjust", False) + if self._dataset is not None: ax = show(self._dataset, ax=ax, contour=contour, **kwargs) From 085e6a11f8fdb4091755034dbe6ca9ac2604506b Mon Sep 17 00:00:00 2001 From: Joseph Hughes Date: Tue, 4 Aug 2026 18:01:36 -0500 Subject: [PATCH 54/90] feat(gnc): add ghost node correction data generation (#2787) _check_gnc dereferenced the node numbers of a record without checking them, so a record built by hand rather than by get_gnc could fail in two ways. A node past the end of ia raised an IndexError from the slice instead of reporting the bad record, and a negative node wrapped silently and checked the connections of a different cell, which let an invalid record pass the check. Both node numbers are now range checked before they are used. --- autotest/test_gnc.py | 339 ++++++++++++++++++++++++++++++++ flopy/mfusg/__init__.py | 1 - flopy/utils/__init__.py | 1 + flopy/utils/gnc.py | 414 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 754 insertions(+), 1 deletion(-) create mode 100644 autotest/test_gnc.py create mode 100644 flopy/utils/gnc.py diff --git a/autotest/test_gnc.py b/autotest/test_gnc.py new file mode 100644 index 000000000..9b3b05157 --- /dev/null +++ b/autotest/test_gnc.py @@ -0,0 +1,339 @@ +""" +Tests for ghost node correction (GNC) data computed from a grid. + +Cases: + - synthetic : a hand built grid with known contributing cells and factors. + - gridgen : the computed data must reproduce gridgen's qtg.gnc.dat. +""" + +import numpy as np +import pytest +from modflow_devtools.markers import requires_exe, requires_pkg + +import flopy +from flopy.discretization import UnstructuredGrid, VertexGrid +from flopy.utils.gnc import ( + _check_gnc, + get_gnc, + get_gnc_dtype, + get_gridprops_gnc6, + get_numalphaj, +) +from flopy.utils.gridgen import Gridgen + + +def synthetic_grid(connectivity=True): + """A coarse cell with four fine cells north and two fine cells east + + Cell 0 is a size 4 cell centered on the origin. Cells 1 and 2 are size 2 + cells on its east face and cells 3 to 6 are size 1 cells on its north + face, so the north face has a two level refinement jump. + """ + centers = [ + (0.0, 0.0, 4.0), + (3.0, 1.0, 2.0), + (3.0, -1.0, 2.0), + (-1.5, 3.0, 1.0), + (-0.5, 3.0, 1.0), + (0.5, 3.0, 1.0), + (1.5, 3.0, 1.0), + ] + vertices, iverts = [], [] + for x, y, size in centers: + half = size / 2.0 + iv = [] + for vx, vy in [ + (x - half, y - half), + (x + half, y - half), + (x + half, y + half), + (x - half, y + half), + ]: + iv.append(len(vertices)) + vertices.append([len(vertices), vx, vy]) + iverts.append(iv) + + # every cell is connected to cell 0 only + conn = {} + if connectivity: + conn["iac"] = np.array([7, 2, 2, 2, 2, 2, 2]) + conn["ja"] = np.array([0, 1, 2, 3, 4, 5, 6, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0]) + grid = UnstructuredGrid( + vertices=vertices, + iverts=iverts, + xcenters=np.array([c[0] for c in centers]), + ycenters=np.array([c[1] for c in centers]), + ncpl=np.array([len(centers)]), + **conn, + ) + level = np.array([0, 1, 1, 2, 2, 2, 2]) + return grid, level + + +def gnc_key(gnc): + """Sorted view of ghost node records for order independent comparison""" + numalphaj = get_numalphaj(gnc) + nodes = np.sort(np.column_stack([gnc[f"j{i}"] for i in range(numalphaj)]), axis=1) + alpha = np.sort( + np.column_stack([gnc[f"alpha{i}"] for i in range(numalphaj)]), axis=1 + ) + key = np.column_stack([gnc["n"], gnc["m"], nodes, alpha]) + return key[np.lexsort(key.T[::-1])] + + +def build_gridgen(ws, nlay=3, level=3, layers=None, **kwargs): + """Build a gridgen grid with a refined block in the middle""" + from shapely.geometry import Polygon + + botm = [1.0 - k * (1.0 / nlay) for k in range(1, nlay + 1)] + sim = flopy.mf6.MFSimulation(sim_name="base", sim_ws=ws) + gwf = flopy.mf6.ModflowGwf(sim, modelname="base") + flopy.mf6.ModflowGwfdis( + gwf, nlay=nlay, nrow=10, ncol=10, delr=1.0, delc=1.0, top=1.0, botm=botm + ) + g = Gridgen(gwf.modelgrid, model_ws=ws, **kwargs) + g.add_refinement_features( + [Polygon([(4, 4), (6, 4), (6, 6), (4, 6)])], + "polygon", + level, + range(nlay) if layers is None else layers, + ) + g.build() + return g + + +def test_get_gnc_dtype(): + dtype = get_gnc_dtype(3) + assert dtype.names == ( + "n", + "m", + "j0", + "j1", + "j2", + "alpha0", + "alpha1", + "alpha2", + ) + assert get_numalphaj(np.recarray((0,), dtype=dtype)) == 3 + + +@pytest.mark.parametrize("use_level", [True, False]) +def test_get_gnc_synthetic(use_level): + grid, level = synthetic_grid() + gnc = get_gnc(grid, level=level if use_level else None) + + # cell 0 has four contributing cells on its north face + assert get_numalphaj(gnc) == 4 + assert len(gnc) == 3 + + # connections without a contributing cell on the offset side are dropped + assert sorted(zip(gnc["n"], gnc["m"])) == [(0, 1), (0, 5), (0, 6)] + + records = {(rec["n"], rec["m"]): rec for rec in gnc} + + # cell 1 is offset 1.0 from cell 0 and the north cells are 3.0 away, so + # the factors total 1/3 and are shared by four contributing cells + rec = records[(0, 1)] + assert [rec[f"j{i}"] for i in range(4)] == [3, 4, 5, 6] + assert np.allclose([rec[f"alpha{i}"] for i in range(4)], 1.0 / 12.0) + + # cell 6 is offset 1.5 and the east cells are 3.0 away, so the factors + # total 0.5 shared by two contributing cells, the first of which is + # repeated three times to fill numalphaj + rec = records[(0, 6)] + assert [rec[f"j{i}"] for i in range(4)] == [1, 1, 1, 2] + assert np.allclose( + [rec[f"alpha{i}"] for i in range(4)], [1 / 12, 1 / 12, 1 / 12, 0.25] + ) + + +def factor_totals(gnc): + """Total contributing factor of each cell, keyed by ghost node""" + numalphaj = get_numalphaj(gnc) + totals = {} + for rec in gnc: + entry = totals.setdefault((rec["n"], rec["m"]), {}) + for i in range(numalphaj): + node = rec[f"j{i}"] + entry[node] = entry.get(node, 0.0) + rec[f"alpha{i}"] + return totals + + +def test_get_gnc_padding_preserves_factors(): + grid, level = synthetic_grid() + unpadded = get_gnc(grid, level=level) + padded = get_gnc(grid, level=level, numalphaj=6) + assert get_numalphaj(unpadded) == 4 + assert get_numalphaj(padded) == 6 + + # padding repeats a contributing cell and splits its factor, so the total + # factor of every cell must be unchanged + expected, actual = factor_totals(unpadded), factor_totals(padded) + assert expected.keys() == actual.keys() + for ghost, cells in expected.items(): + assert cells.keys() == actual[ghost].keys() + for node, alpha in cells.items(): + assert np.allclose(alpha, actual[ghost][node]) + assert sum(cells.values()) < 1.0 + + +def test_get_gnc_numalphaj_too_small(): + grid, level = synthetic_grid() + with pytest.raises(ValueError, match="more than numalphaj"): + get_gnc(grid, level=level, numalphaj=2) + + +def test_get_gnc_supplied_connectivity(): + grid, level = synthetic_grid() + bare, _ = synthetic_grid(connectivity=False) + assert bare.iac is None + + # the synthetic cells do not share vertices, so connectivity cannot be + # built from shared edges and has to be supplied + assert len(get_gnc(bare, level=level)) == 0 + + gnc = get_gnc(bare, level=level, iac=grid.iac, ja=grid.ja) + assert np.array_equal(gnc_key(gnc), gnc_key(get_gnc(grid, level=level))) + + +def test_check_gnc(): + dtype = get_gnc_dtype(2) + gnc = np.recarray((1,), dtype=dtype) + gnc[0] = (0, 1, 2, 2, 0.6, 0.6) + with pytest.raises(ValueError, match="must be less than one"): + _check_gnc(gnc) + + gnc[0] = (0, 1, 2, 2, 0.1, 0.1) + _check_gnc(gnc) + + # cell 0 is connected to cell 2 but not to cell 1 + ia = np.array([0, 2, 3, 5]) + ja = np.array([0, 2, 1, 2, 0]) + with pytest.raises(ValueError, match="is not connected to cell"): + _check_gnc(gnc, ia=ia, ja=ja) + + +@pytest.mark.parametrize("n,m", [(5, 1), (1, 5), (-3, 1), (1, -3)]) +def test_check_gnc_node_out_of_range(n, m): + """A node outside the grid is reported rather than indexed""" + gnc = np.recarray((1,), dtype=get_gnc_dtype(1)) + gnc[0] = (n, m, 0, 0.1) + # a negative node would otherwise wrap and check the wrong cell + with pytest.raises(ValueError, match="which is not a cell of a grid"): + _check_gnc(gnc, ia=np.array([0, 2, 3]), ja=np.array([0, 1, 1, 0])) + + +def test_get_gridprops_gnc6_requires_ncpl(): + grid, level = synthetic_grid() + gnc = get_gnc(grid, level=level) + with pytest.raises(ValueError, match="ncpl is required"): + get_gridprops_gnc6(gnc, dis_type="disv") + with pytest.raises(ValueError, match="Unknown dis_type"): + get_gridprops_gnc6(gnc, dis_type="dis") + + gridprops = get_gridprops_gnc6(gnc, dis_type="disu") + assert gridprops["numalphaj"] == 4 + assert gridprops["numgnc"] == len(gnc) + assert gridprops["gncdata"][0][0] == (gnc["n"][0],) + + +@requires_exe("gridgen") +@requires_pkg("shapely", "geopandas") +@pytest.mark.parametrize( + "nlay,level,layers,smoothing", + [ + (3, 3, None, 1), + (1, 2, None, 1), + (1, 4, None, 1), + (3, 3, [0], 1), + (1, 3, None, 2), + ], +) +def test_get_gnc_matches_gridgen(function_tmpdir, nlay, level, layers, smoothing): + g = build_gridgen( + function_tmpdir, + nlay=nlay, + level=level, + layers=layers, + smoothing_level_horizontal=smoothing, + smoothing_level_vertical=smoothing, + ) + # gridgen writes the ghost node data whenever it exports a grid + expected = np.atleast_1d( + np.genfromtxt(function_tmpdir / "qtg.gnc.dat", dtype=get_gnc_dtype(2)) + ) + for name in ("n", "m", "j0", "j1"): + expected[name] -= 1 + assert len(expected) > 0 + + grid = UnstructuredGrid(**g.get_gridprops_unstructuredgrid()) + gnc = get_gnc(grid, numalphaj=2) + + assert len(gnc) == len(expected) + # gridgen writes the factors with six significant digits + assert np.allclose(gnc_key(gnc), gnc_key(expected), atol=2.0e-6) + + +def square_cells(centers): + """Build vertices and iverts for a list of (x, y, size) squares""" + vertices, iverts = [], [] + for x, y, size in centers: + half = size / 2.0 + iv = [] + for vx, vy in [ + (x - half, y - half), + (x + half, y - half), + (x + half, y + half), + (x - half, y + half), + ]: + iv.append(len(vertices)) + vertices.append([len(vertices), vx, vy]) + iverts.append(iv) + return vertices, iverts + + +def test_get_gnc_ihc(): + """A connection marked vertical is not corrected and does not contribute""" + grid, level = synthetic_grid() + + # cells 0 and 1 are connected at positions 1 and 8 of ja + ihc = np.ones(len(grid.ja), dtype=int) + ihc[[1, 8]] = 0 + gnc = get_gnc(grid, level=level, ihc=ihc) + + # the ghost node on the 0 to 1 connection is gone, and cell 1 is no longer + # available as a contributing cell, which leaves cell 2 on its own + assert sorted((rec["n"], rec["m"]) for rec in gnc) == [(0, 5), (0, 6)] + assert get_numalphaj(gnc) == 1 + assert np.allclose(sorted(gnc["alpha0"]), [1.0 / 6.0, 0.5]) + + +def test_get_gnc_aligned_finer_cell(): + """A finer cell centered on the face of a coarse cell needs no ghost node""" + centers = [(0.0, 0.0, 4.0), (3.0, 0.0, 2.0)] + vertices, iverts = square_cells(centers) + grid = UnstructuredGrid( + vertices=vertices, + iverts=iverts, + xcenters=np.array([c[0] for c in centers]), + ycenters=np.array([c[1] for c in centers]), + ncpl=np.array([len(centers)]), + iac=np.array([2, 2]), + ja=np.array([0, 1, 1, 0]), + ) + assert len(get_gnc(grid, level=np.array([0, 1]))) == 0 + + +def test_get_gnc_connectivity_needs_constant_ncpl(): + """Shared edges cannot give the layer layout when ncpl varies by layer""" + centers = [(0.5, 0.5, 1.0), (1.5, 0.5, 1.0), (2.5, 0.5, 1.0), (0.5, 1.5, 1.0)] + vertices, iverts = square_cells(centers) + grid = UnstructuredGrid( + vertices=vertices, + iverts=iverts, + xcenters=np.array([c[0] for c in centers]), + ycenters=np.array([c[1] for c in centers]), + ncpl=np.array([3, 1]), + ) + assert grid.iac is None + with pytest.raises(ValueError, match="different"): + get_gnc(grid) diff --git a/flopy/mfusg/__init__.py b/flopy/mfusg/__init__.py index 579db55f9..b5d4b621a 100644 --- a/flopy/mfusg/__init__.py +++ b/flopy/mfusg/__init__.py @@ -36,7 +36,6 @@ "MfUsgDpt", "MfUsgEvt", "MfUsgGnc", - "MfUsgGnc", "MfUsgLak", "MfUsgLpf", "MfUsgMdt", diff --git a/flopy/utils/__init__.py b/flopy/utils/__init__.py index 0da0e3d10..b4129b4a7 100644 --- a/flopy/utils/__init__.py +++ b/flopy/utils/__init__.py @@ -29,6 +29,7 @@ from .formattedfile import FormattedHeadFile get_modflow = get_modflow_module.run_main +from .gnc import get_gnc, get_gridprops_gnc6 from .gridintersect import GridIntersect from .hfb_util import make_hfb_array from .mflistfile import ( diff --git a/flopy/utils/gnc.py b/flopy/utils/gnc.py new file mode 100644 index 000000000..9d86b174d --- /dev/null +++ b/flopy/utils/gnc.py @@ -0,0 +1,414 @@ +""" +Ghost node correction (GNC) data for quadtree-like grids. + +Ghost node data is computed from a grid, a grid conforming array of refinement +levels, and the grid connectivity by :func:`get_gnc`, and is converted to +MODFLOW 6 package input by :func:`get_gridprops_gnc6`. +""" + +import numpy as np + + +def get_gnc_dtype(numalphaj): + """ + Get the record dtype for ghost node data with numalphaj contributing + cells + + Parameters + ---------- + numalphaj : int + Number of contributing cells per ghost node + + Returns + ------- + dtype : np.dtype + + """ + dtype = [("n", int), ("m", int)] + dtype += [(f"j{i}", int) for i in range(numalphaj)] + dtype += [(f"alpha{i}", float) for i in range(numalphaj)] + return np.dtype(dtype) + + +def get_numalphaj(gnc): + """ + Get the number of contributing cells in a ghost node record array + + Parameters + ---------- + gnc : np.recarray + Ghost node data + + Returns + ------- + numalphaj : int + + """ + return len([name for name in gnc.dtype.names if name.startswith("j")]) + + +def _gnc_nodes(gnc): + """Return the cellid column names of a ghost node record array""" + numalphaj = get_numalphaj(gnc) + return ["n", "m"] + [f"j{i}" for i in range(numalphaj)] + + +def _get_ia(ia=None, iac=None): + """Build the zero-based ia array from ia or iac""" + if ia is not None: + return np.asarray(ia, dtype=int) + if iac is None: + return None + return np.concatenate(([0], np.cumsum(np.asarray(iac, dtype=int)))) + + +def _check_gnc(gnc, ia=None, ja=None, iac=None): + """ + Raise if ghost node records are not valid MODFLOW input + + Parameters + ---------- + gnc : np.recarray + Ghost node data + ia : array_like + Zero-based CRS row pointer. Connectivity is not checked if ja is + None or if both ia and iac are None. + ja : array_like + Zero-based CRS column indices + iac : array_like + Number of connections per cell, used if ia is None + + """ + ia = _get_ia(ia, iac) + numalphaj = get_numalphaj(gnc) + alpha = np.zeros(len(gnc)) + for i in range(numalphaj): + alpha += gnc[f"alpha{i}"] + for irec, total in enumerate(alpha): + if total >= 1.0: + raise ValueError( + f"gnc record {irec}: contributing factors sum to {total}, " + "which must be less than one" + ) + + if ia is None or ja is None: + return + ia = np.asarray(ia, dtype=int) + ja = np.asarray(ja, dtype=int) + nodes = ia.shape[0] - 1 + for irec, rec in enumerate(gnc): + n, m = rec["n"], rec["m"] + # a node number outside the grid would either index past the end of ia + # or, when it is negative, silently wrap and check the wrong cell + for name, node in (("n", n), ("m", m)): + if not 0 <= node < nodes: + raise ValueError( + f"gnc record {irec}: cell {name} is {node}, which is not a " + f"cell of a grid with {nodes} cells" + ) + # MODFLOW 6 rejects a ghost node whose n-m connection is absent + if m not in ja[ia[n] : ia[n + 1]]: + raise ValueError( + f"gnc record {irec}: cell {n} is not connected to cell {m}" + ) + + +def _node_centers(modelgrid): + """Return cell center arrays with one value per node""" + xc = np.asarray(modelgrid.xcellcenters).ravel() + yc = np.asarray(modelgrid.ycellcenters).ravel() + nnodes = modelgrid.nnodes + if xc.shape[0] == nnodes: + return xc, yc + # a vertex grid stores one value per cell2d, repeated for every layer + nlay = nnodes // xc.shape[0] + return np.tile(xc, nlay), np.tile(yc, nlay) + + +def _node_layers(modelgrid): + """Return the layer index of every node""" + ncpl = modelgrid.ncpl + if np.isscalar(ncpl): + ncpl = np.full(modelgrid.nlay, ncpl, dtype=int) + return np.repeat(np.arange(len(ncpl)), ncpl) + + +def _shared_edge_connectivity(modelgrid): + """Build ia and ja from the cells that share an edge in every layer""" + neighbors = modelgrid.neighbors(method="rook") + ncpl = modelgrid.ncpl + if not np.isscalar(ncpl): + if ncpl.min() != ncpl.max(): + raise ValueError( + "Connectivity cannot be built for a grid with a different " + "number of cells in each layer, supply ia or iac and ja" + ) + ncpl = int(ncpl.min()) + nlay = modelgrid.nnodes // ncpl + + # ghost nodes are horizontal, so only the connections within a layer + # are needed and the same layout is repeated for every layer + iac, ja = [], [] + for k in range(nlay): + for icpl in range(ncpl): + conn = sorted(neighbors.get(icpl, [])) + iac.append(len(conn) + 1) + ja.append(k * ncpl + icpl) + ja.extend(k * ncpl + j for j in conn) + return _get_ia(iac=iac), np.array(ja, dtype=int) + + +def _contributing_cells(dn, naxis, d_nm, rtol): + """ + Select the cells that contribute to the ghost node on one connection + + Parameters + ---------- + dn : np.ndarray + Offset from cell n to each of its neighbors, one row per neighbor + naxis : np.ndarray + Connection axis of each neighbor of cell n + d_nm : np.ndarray + Offset from cell n to the connected cell m + rtol : float + Relative tolerance on the transverse offset + + Returns + ------- + sel : np.ndarray or None + Mask of the contributing neighbors, None when the connection needs no + ghost node + alpha : float + Total contributing factor + + """ + axis = int(np.argmax(np.abs(d_nm))) + trans = 1 - axis + offset = d_nm[trans] + # the cell centers line up across the face, so there is nothing to correct + if abs(offset) <= rtol * abs(d_nm[axis]): + return None, 0.0 + + # the contributing cells are the neighbors of n on the side of the offset + sel = (naxis == trans) & (np.sign(dn[:, trans]) == np.sign(offset)) + if not sel.any(): + return None, 0.0 + return sel, abs(offset) / np.abs(dn[sel, trans]).mean() + + +def _cell_areas(modelgrid): + """Return the area of every node from the cell vertices""" + verts = np.asarray(modelgrid.verts) + iverts = modelgrid.iverts + areas = np.empty(len(iverts)) + for i, iv in enumerate(iverts): + iv = [j for j in iv if j is not None] + x, y = verts[iv, 0], verts[iv, 1] + areas[i] = 0.5 * abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))) + if areas.shape[0] != modelgrid.nnodes: + nlay = modelgrid.nnodes // areas.shape[0] + areas = np.tile(areas, nlay) + return areas + + +def get_gnc( + modelgrid, + level=None, + ia=None, + ja=None, + iac=None, + ihc=None, + numalphaj=None, + rtol=1.0e-6, +): + """ + Compute ghost node correction data for a quadtree-like grid + + A ghost node is added in cell n for every horizontal connection to a + finer cell m whose center is offset from the center of n transverse to + the connection. The head at the ghost node is interpolated between cell + n and the neighbors of n on the side of the offset. + + Parameters + ---------- + modelgrid : flopy.discretization.UnstructuredGrid or VertexGrid + Grid the ghost nodes are computed for. Connectivity is taken from + the grid when ia, ja, and iac are None, either from the iac and ja + the grid carries or from the cells that share an edge. + level : array_like + Grid conforming array of refinement levels, where a larger value is a + finer cell. Cell areas are used if None. + ia : array_like + Zero-based CRS row pointer + ja : array_like + Zero-based CRS column indices, with the diagonal first in each row + iac : array_like + Number of connections per cell, used if ia is None + ihc : array_like + Connection type for each entry in ja, where 0 is a vertical + connection. Connections between cells in different layers are + treated as vertical if None. + numalphaj : int + Number of contributing cells written per ghost node. The largest + number found is used if None. Records with fewer contributing cells + repeat a cell rather than pad with zeros, which MODFLOW-USG requires. + rtol : float + Relative tolerance used to decide whether the center of cell m is + offset from the center of cell n (default is 1.0e-6). + + Returns + ------- + gnc : np.recarray + Record array with fields n, m, j0 to j[numalphaj-1], and alpha0 to + alpha[numalphaj-1]. Node numbers are zero-based. + + Notes + ----- + Only horizontal corrections are computed, matching gridgen. + + """ + ia = _get_ia(ia, iac) + if ia is None: + ia = _get_ia(iac=getattr(modelgrid, "iac", None)) + if ja is None: + ja = getattr(modelgrid, "ja", None) + if ia is None or ja is None: + # a vertex grid does not carry connectivity, so build it from the + # cells that share an edge + ia, ja = _shared_edge_connectivity(modelgrid) + ja = np.asarray(ja, dtype=int) + + nnodes = modelgrid.nnodes + xc, yc = _node_centers(modelgrid) + + # a larger size is a coarser cell + if level is None: + size = _cell_areas(modelgrid) + else: + size = -np.asarray(level, dtype=float).ravel() + if size.shape[0] != nnodes: + size = np.tile(size, nnodes // size.shape[0]) + + if ihc is None: + layer = _node_layers(modelgrid) + horizontal = layer[ja] == layer[np.repeat(np.arange(nnodes), np.diff(ia))] + else: + horizontal = np.asarray(ihc) != 0 + + records = [] + for n in range(nnodes): + ipos = np.arange(ia[n] + 1, ia[n + 1]) + ipos = ipos[horizontal[ipos]] + conn = ja[ipos] + if conn.size == 0: + continue + + # column 0 is the x offset and column 1 the y offset to each neighbor + d = np.column_stack((xc[conn] - xc[n], yc[conn] - yc[n])) + axis = np.argmax(np.abs(d), axis=1) + + for k, m in enumerate(conn): + if size[m] >= size[n]: + continue + sel, alpha = _contributing_cells(d, axis, d[k], rtol) + if sel is None: + continue + js = conn[sel] + records.append((n, m, js, alpha / js.size)) + + if numalphaj is None: + numalphaj = max((len(rec[2]) for rec in records), default=1) + dtype = get_gnc_dtype(numalphaj) + + gnc = np.recarray((len(records),), dtype=dtype) + for irec, (n, m, js, alpha) in enumerate(records): + if js.size > numalphaj: + raise ValueError( + f"gnc record {irec}: cell {n} has {js.size} contributing cells, " + f"which is more than numalphaj of {numalphaj}" + ) + # pad by repeating the first contributing cell and splitting its + # factor, which both MODFLOW 6 and MODFLOW-USG accumulate + nrepeat = numalphaj - js.size + 1 + nodes = np.concatenate((np.repeat(js[:1], nrepeat), js[1:])) + alphas = np.concatenate( + (np.full(nrepeat, alpha / nrepeat), np.full(js.size - 1, alpha)) + ) + gnc["n"][irec] = n + gnc["m"][irec] = m + for i in range(numalphaj): + gnc[f"j{i}"][irec] = nodes[i] + gnc[f"alpha{i}"][irec] = alphas[i] + + return gnc + + +def get_gridprops_gnc6( + gnc, dis_type="disv", ncpl=None, ia=None, ja=None, iac=None, check=True +): + """ + Get a dictionary of information needed to create a MODFLOW 6 GNC + Package. The returned dictionary can be unpacked directly into the + ModflowGwfgnc constructor. + + Parameters + ---------- + gnc : np.recarray + Ghost node data with zero-based node numbers + dis_type : str + Discretization the cellids are built for. Valid options are 'disv' + (default) and 'disu'. + ncpl : int + Number of cells per layer, required for 'disv' + ia : array_like + Zero-based CRS row pointer, used to check connectivity + ja : array_like + Zero-based CRS column indices, used to check connectivity + iac : array_like + Number of connections per cell, used if ia is None + check : bool + Verify that each n-m pair is connected and that the contributing + factors sum to less than one (default is True). + + Returns + ------- + gridprops : dict + + Notes + ----- + The correction is applied implicitly unless the explicit option is set, + so the BICGSTAB linear acceleration option should be specified in the IMS + Package. numgnc is zero for a grid without ghost nodes, in which case + the package should not be created. + + """ + if check: + _check_gnc(gnc, ia=ia, ja=ja, iac=iac) + + dis_type = dis_type.lower() + if dis_type == "disv": + if ncpl is None: + raise ValueError("ncpl is required to build disv cellids") + + def cellid(node): + return (node // ncpl, node % ncpl) + elif dis_type == "disu": + + def cellid(node): + return (node,) + else: + raise ValueError(f"Unknown dis_type {dis_type}, expected 'disv' or 'disu'") + + numalphaj = get_numalphaj(gnc) + names = _gnc_nodes(gnc) + gncdata = [ + tuple(cellid(rec[name]) for name in names) + + tuple(rec[f"alpha{i}"] for i in range(numalphaj)) + for rec in gnc + ] + + return { + "numgnc": len(gncdata), + "numalphaj": numalphaj, + "gncdata": gncdata, + } From 4c8c52394878a8bd27093661950824ad144ee799 Mon Sep 17 00:00:00 2001 From: Joseph Hughes Date: Wed, 5 Aug 2026 08:43:16 -0500 Subject: [PATCH 55/90] feat(gridgen): build GNC package input from gridgen output (#2789) Gridgen computes the ghost node data whenever it exports a grid and writes it to qtg.gnc.dat, but the file was not read, so the data it already had was not reaching the GNC Package. Gridgen now reads the file with get_gnc and converts it to MODFLOW 6 and MODFLOW-USG GNC Package input with get_gridprops_gnc6 and get_gridprops_gnc5. Two notebooks show the workflow, and the MODFLOW 6 notebook compares the corrected and uncorrected solutions against the exact solution for a confined homogeneous problem with a linear head field. Also corrects MfUsgGnc.write_file, which formatted the contributing factors with %10.2e and truncated them to three significant digits, wrote the options list with its Python repr, and wrote the record fields with no separator so that a value filling its width ran into the next value. Closes #2788 --- .docs/Notebooks/gnc_example.py | 328 +++++++++++++++++++++++++++ .docs/Notebooks/mfusg_gnc_example.py | 202 +++++++++++++++++ .gitignore | 4 + autotest/test_gnc.py | 195 +++++++++++++++- autotest/test_gridgen.py | 289 ++++++++++++++++++++++- flopy/mfusg/mfusg.py | 5 +- flopy/mfusg/mfusggnc.py | 10 +- flopy/utils/__init__.py | 2 +- flopy/utils/gnc.py | 75 +++++- flopy/utils/gridgen.py | 146 ++++++++++++ 10 files changed, 1247 insertions(+), 9 deletions(-) create mode 100644 .docs/Notebooks/gnc_example.py create mode 100644 .docs/Notebooks/mfusg_gnc_example.py diff --git a/.docs/Notebooks/gnc_example.py b/.docs/Notebooks/gnc_example.py new file mode 100644 index 000000000..206e08a36 --- /dev/null +++ b/.docs/Notebooks/gnc_example.py @@ -0,0 +1,328 @@ +# --- +# jupyter: +# jupytext: +# notebook_metadata_filter: all +# text_representation: +# extension: .py +# format_name: light +# format_version: '1.5' +# jupytext_version: 1.14.5 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# metadata: +# section: dis +# authors: +# - name: Joseph Hughes +# --- + +# # Ghost Node Correction (GNC) Data for MODFLOW 6 +# +# The control volume finite difference formulation used by MODFLOW assumes that the line connecting two cell centers crosses the shared face at a right angle through the middle of the face. A quadtree grid violates that assumption wherever a coarse cell connects to a finer cell, because the shared face is offset from the center of the coarse cell. The Ghost Node Correction (GNC) Package corrects the resulting error by interpolating the head at a ghost node, which is the point in the coarse cell that does lie on the perpendicular through the middle of the face. +# +# FloPy builds GNC Package input two ways, and we demonstrate both here. The first uses the ghost node data GRIDGEN writes when it exports a grid. The second computes the ghost node data from a model grid, a grid conforming array of refinement levels, and the grid connectivity, and does not require GRIDGEN. +# +# We also compare the ghost node correction against XT3D, which is the other MODFLOW 6 option for improving accuracy on a quadtree grid, in terms of both the answer and what the correction costs. + +# + +import re +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +import matplotlib as mpl +import matplotlib.pyplot as plt +import numpy as np +from shapely.geometry import Polygon + +import flopy +from flopy.utils import flopy_io, get_gnc, get_gridprops_gnc6 +from flopy.utils.gridgen import Gridgen + +print(sys.version) +print(f"numpy version: {np.__version__}") +print(f"matplotlib version: {mpl.__version__}") +print(f"flopy version: {flopy.__version__}") +# - + +# The FloPy GRIDGEN module requires that the gridgen executable can be called using subprocess **(i.e., gridgen is in your path)**. + +gridgen_exe = flopy.which("gridgen") +if gridgen_exe is None: + msg = ( + "Warning, gridgen is not in your path. " + "When you create the gridgen object you will need to " + "provide a full path to the gridgen binary executable." + ) + print(msg) +else: + print(f"gridgen executable was found at: {flopy_io.relpath_safe(gridgen_exe)}") + +# + +temp_dir = TemporaryDirectory() +workspace = Path(temp_dir.name) +gridgen_ws = workspace / "gridgen" +gridgen_ws.mkdir(parents=True, exist_ok=True) + +print(f"Model workspace is : {flopy_io.scrub_login(str(workspace))}") +print(f"Gridgen workspace is : {flopy_io.scrub_login(str(gridgen_ws))}") +# - + +# ## Build the quadtree grid +# +# GRIDGEN works from a base MODFLOW grid. We use a 3 layer grid of 20 rows and 20 columns and refine a square in the middle of the grid by three levels, which produces cells one eighth the width of the base grid cells. + +# + +nlay, nrow, ncol = 3, 20, 20 +delr = delc = 1.0 +top = 1.0 +botm = [top - (k + 1) * top / nlay for k in range(nlay)] + +base_grid = flopy.discretization.StructuredGrid( + delr=np.full(ncol, delr, dtype=float), + delc=np.full(nrow, delc, dtype=float), + top=np.full((nrow, ncol), top), + botm=np.array([np.full((nrow, ncol), b) for b in botm]), +) +# - + +# + +center, half_width = ncol / 2.0, 3.0 +corners = [ + (center - half_width, center - half_width), + (center + half_width, center - half_width), + (center + half_width, center + half_width), + (center - half_width, center + half_width), +] + +g = Gridgen(base_grid, model_ws=str(gridgen_ws)) +g.add_refinement_features([Polygon(corners)], "polygon", 3, range(nlay)) +g.build(verbose=False) + +disv_gridprops = g.get_gridprops_disv() +ncpl = disv_gridprops["ncpl"] +print(f"Number of cells per layer: {ncpl}") +# - + +# ## Ghost node data from GRIDGEN +# +# GRIDGEN computes the ghost node data whenever it exports a grid and writes it to the `qtg.gnc.dat` file. The `get_gnc()` method reads that file and returns a record array with zero-based node numbers, where cell `n` contains the ghost node, cell `m` is the connecting cell, and cells `j0` and `j1` are the contributing cells whose heads are interpolated. + +gnc = g.get_gnc() +print(f"Number of ghost nodes: {len(gnc)}") +print(gnc[:5]) + +# GRIDGEN always writes two contributing cells. When a ghost node has only one contributing cell, that cell is repeated and its contributing factor is halved, which MODFLOW accumulates into the same matrix position. The contributing factors always sum to less than one, because one minus the sum is the factor applied to the head in cell `n`. + +alpha = gnc["alpha0"] + gnc["alpha1"] +print(f"Contributing factors range from {alpha.min():.4f} to {alpha.max():.4f}") + +# A grid that is not refined has no ghost nodes and the record array is empty. The GNC Package should not be created in that case. + +# The `get_gridprops_gnc6()` method converts the node numbers to cellids and returns a dictionary that can be unpacked directly into the `ModflowGwfgnc` constructor. Cellids are built for a DISV grid here; pass `dis_type="disu"` for a DISU grid. + +gnc_gridprops = g.get_gridprops_gnc6(dis_type="disv") +print(f"numgnc: {gnc_gridprops['numgnc']}") +print(f"numalphaj: {gnc_gridprops['numalphaj']}") +print(f"first record: {gnc_gridprops['gncdata'][0]}") + +# ## Ghost node data from the model grid +# +# The ghost node data can also be computed from the model grid, without running GRIDGEN. All that is needed is the grid, which provides the cell centers and the connectivity, and a grid conforming array of refinement levels. Cell areas are used when levels are not supplied. + +vgrid = flopy.discretization.VertexGrid(**g.get_gridprops_vertexgrid()) + +# A vertex grid does not carry connectivity, so `get_gnc()` builds it from the cells that share an edge. Connectivity can also be passed with the `ia` or `iac` and `ja` arguments, which is what an unstructured grid already provides. +# +# The refinement level of each cell follows from the cell area, where level 0 is a base grid cell and each level halves the cell width. We compute the areas from the cell vertices so that nothing in this section depends on GRIDGEN. The levels are given for one layer, and `get_gnc()` applies them to every layer. + + +# + +def cell_area(icpl): + x, y = np.array(vgrid.get_cell_vertices(icpl)).T + return 0.5 * abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))) + + +area = np.array([cell_area(icpl) for icpl in range(ncpl)]) +level = np.round(np.log2(np.sqrt(area.max() / area))).astype(int) +print(f"Refinement levels present: {np.unique(level)}") +# - + +# The `get_gnc()` function returns the same record array that GRIDGEN wrote. We ask for two contributing cells so the result can be compared directly. + +gnc_grid = get_gnc(vgrid, level=level, numalphaj=2) +print(f"Number of ghost nodes: {len(gnc_grid)}") +print(gnc_grid[:5]) + + +# The two record arrays hold the same ghost nodes. We sort the records because the two routines visit the cells in a different order, and we sort the contributing cells within each record because the two cells are sometimes listed in the opposite order. That ordering does not matter, since MODFLOW accumulates the contribution of each cell. + + +# + +def sort_gnc(recarray): + nodes = np.sort(np.column_stack([recarray["j0"], recarray["j1"]]), axis=1) + alpha = np.sort(np.column_stack([recarray["alpha0"], recarray["alpha1"]]), axis=1) + key = np.column_stack([recarray["n"], recarray["m"], nodes, alpha]) + return key[np.lexsort(key.T[::-1])] + + +# GRIDGEN writes the contributing factors with six significant digits +assert np.allclose(sort_gnc(gnc_grid), sort_gnc(gnc), atol=2e-6) +print("The computed ghost node data matches the GRIDGEN ghost node data.") +# - + +# The dictionary for the GNC Package is built with the `get_gridprops_gnc6()` function, which also verifies that each cell `n` is connected to cell `m` and that the contributing factors sum to less than one. + +gnc_gridprops = get_gridprops_gnc6(gnc_grid, dis_type="disv", ncpl=ncpl) +print(f"numgnc: {gnc_gridprops['numgnc']}") + +# ## Where the ghost nodes are +# +# Every ghost node lies on a connection between a coarse cell and a finer cell, so the ghost nodes trace the boundary of the refined area. We plot the connections in the upper layer. + +# + +fig, ax = plt.subplots(figsize=(7, 7)) +ax.set_aspect("equal") +pmv = flopy.plot.PlotMapView(modelgrid=vgrid, ax=ax, layer=0) +pmv.plot_grid(colors="0.5", lw=0.5) + +xc, yc = vgrid.xcellcenters, vgrid.ycellcenters +for rec in gnc_grid[gnc_grid["n"] < ncpl]: + n, m = rec["n"], rec["m"] + ax.plot([xc[n], xc[m]], [yc[n], yc[m]], color="C3", lw=1.0, zorder=2) + ax.plot(xc[m], yc[m], "o", color="C3", ms=2.5, zorder=3) + for j in (rec["j0"], rec["j1"]): + ax.plot(xc[j], yc[j], "s", color="C0", ms=3.0, zorder=3) + +ax.plot([], [], color="C3", lw=1.0, label="ghost node connection") +ax.plot([], [], "s", color="C0", ms=3.0, lw=0, label="contributing cell") +ax.legend(loc="upper right", framealpha=1.0) +ax.set_title("Ghost node connections in layer 1") +# - + +# ## Effect of the correction +# +# We build the same model three ways and compare the results. The uncorrected model uses the standard formulation, the corrected model adds the GNC Package, and the third model uses XT3D. The correction is applied implicitly by default, so the BICGSTAB linear acceleration option is specified in the IMS Package. +# +# The model is confined and homogeneous, with constant heads on the left and right edges and no flow across the top and bottom edges. Head then varies linearly between the two constant head columns, which gives an exact solution to compare against. + +# + +h_left, h_right = 1.0, 0.0 +xcenters = vgrid.xcellcenters +left = [icpl for icpl in range(ncpl) if xcenters[icpl] < delr] +right = [icpl for icpl in range(ncpl) if xcenters[icpl] > ncol - delr] + +chdspd = [[(k, icpl), h_left] for k in range(nlay) for icpl in left] +chdspd += [[(k, icpl), h_right] for k in range(nlay) for icpl in right] + +x_left, x_right = xcenters[left].mean(), xcenters[right].mean() +exact = h_left + (h_right - h_left) * (xcenters - x_left) / (x_right - x_left) +exact = np.tile(exact, nlay) +print(f"Number of constant head cells: {len(chdspd)}") +# - + + +# MODFLOW 6 reports the memory it allocates at the end of the simulation listing file, which we read back for each model along with the simulated heads. + + +# + +def run_model(name, gnc=False, xt3d=False): + ws = workspace / name + sim = flopy.mf6.MFSimulation(sim_name=name, sim_ws=str(ws), exe_name="mf6") + flopy.mf6.ModflowTdis(sim) + flopy.mf6.ModflowIms( + sim, + linear_acceleration="bicgstab", + inner_maximum=1000, + inner_dvclose=1e-10, + outer_dvclose=1e-10, + ) + gwf = flopy.mf6.ModflowGwf(sim, modelname=name) + flopy.mf6.ModflowGwfdisv(gwf, **disv_gridprops) + flopy.mf6.ModflowGwfic(gwf, strt=0.5 * (h_left + h_right)) + flopy.mf6.ModflowGwfnpf(gwf, xt3doptions=xt3d, icelltype=0, k=1.0) + flopy.mf6.ModflowGwfchd(gwf, stress_period_data=chdspd) + flopy.mf6.ModflowGwfoc( + gwf, head_filerecord=f"{name}.hds", saverecord=[("HEAD", "ALL")] + ) + if gnc: + flopy.mf6.ModflowGwfgnc(gwf, **gnc_gridprops) + sim.write_simulation(silent=True) + success, buff = sim.run_simulation(silent=True) + assert success, f"{name} did not converge" + + listing = (ws / "mfsim.lst").open().read() + memory = float(re.search(r"Total\s+([0-9.E+-]+)\s*\n\s*Virtual", listing).group(1)) + return gwf.output.head().get_data().flatten(), memory + + +# + +heads, error, memory = {}, {}, {} +for name, kwargs in [ + ("uncorrected", {}), + ("gnc", {"gnc": True}), + ("xt3d", {"xt3d": True}), +]: + heads[name], memory[name] = run_model(name, **kwargs) + error[name] = np.abs(heads[name] - exact) + +print(f"{'variant':14s}{'max error':>12s}{'rms error':>12s}{'memory, MB':>13s}") +for name in ("uncorrected", "gnc", "xt3d"): + rms = np.sqrt((error[name] ** 2).mean()) + print(f"{name:14s}{error[name].max():12.3e}{rms:12.3e}{memory[name]:13.1f}") +# - + +# The ghost node correction removes about 18 times the head error introduced by the refinement. XT3D reproduces a linear head field exactly by construction, so it is exact on this problem; that is a property of this test rather than a general ranking of the two corrections. + +for name in ("gnc", "xt3d"): + removed = 1.0 - error[name].max() / error["uncorrected"].max() + print(f"{name:5s} removes {100 * removed:.1f} percent of the error") + +# ## Cost of the correction +# +# The two corrections reach a comparable answer by different means. XT3D replaces the flow calculation on every connection in the model, which extends the stencil of every cell. The ghost node correction only adds terms on the connections that have a ghost node, which are the connections between a coarse cell and a finer cell, and there are far fewer of those. + +print(f"Cells in the model: {ncpl * nlay}") +print(f"Ghost nodes: {gnc_gridprops['numgnc']}") +print( + f"Ghost nodes are on {100 * gnc_gridprops['numgnc'] / (ncpl * nlay):.1f} " + "percent of the cells" +) + +# That shows up in the memory MODFLOW 6 allocates. XT3D nearly doubles it, because the extended stencil applies to every cell in the model. The ghost node correction adds a couple of percent. Both corrections remove nearly all of the error introduced by the refinement, and the ghost node correction does so in about half the memory. + +for name in ("gnc", "xt3d"): + print( + f"{name:5s} memory relative to the uncorrected model: " + f"{memory[name] / memory['uncorrected']:.3f}" + ) +print(f"gnc memory relative to xt3d: {memory['gnc'] / memory['xt3d']:.3f}") + +# Run times are not compared here. They depend on how many iterations the solver takes, and the ordering of the two corrections changes with the problem, so run time is not a reliable way to choose between them. +# +# The practical difference is in what each one asks of the user. XT3D is a single keyword in the NPF Package and needs no other input. The ghost node correction needs the ghost node data, which was the difficult part of using the GNC Package and is what the FloPy functionality shown in this notebook provides. + +# ## Where the error is +# +# The error in the uncorrected model is concentrated on the boundary of the refined area, which is where the ghost nodes are. The ghost node correction removes most of it. + +# + +vmax = error["uncorrected"].max() + +fig, axes = plt.subplots(1, 2, figsize=(11, 5), constrained_layout=True) +for ax, name in zip(axes, ("uncorrected", "gnc")): + ax.set_aspect("equal") + pmv = flopy.plot.PlotMapView(modelgrid=vgrid, ax=ax, layer=0) + cb = pmv.plot_array(error[name], cmap="magma_r", vmin=0.0, vmax=vmax) + pmv.plot_grid(colors="0.5", lw=0.3, alpha=0.5) + ax.set_title(f"{name}, layer 1") +fig.colorbar(cb, ax=axes, shrink=0.7, label="absolute head error") +# - + +# Clean up the temporary workspace. + +try: + temp_dir.cleanup() +except (PermissionError, NotADirectoryError): + pass diff --git a/.docs/Notebooks/mfusg_gnc_example.py b/.docs/Notebooks/mfusg_gnc_example.py new file mode 100644 index 000000000..3f4c4d7aa --- /dev/null +++ b/.docs/Notebooks/mfusg_gnc_example.py @@ -0,0 +1,202 @@ +# --- +# jupyter: +# jupytext: +# notebook_metadata_filter: all +# text_representation: +# extension: .py +# format_name: light +# format_version: '1.5' +# jupytext_version: 1.14.5 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# metadata: +# section: mfusg +# authors: +# - name: Joseph Hughes +# --- + +# # MODFLOW-USG: Ghost Node Correction (GNC) Data for a Quadtree Grid +# +# The control volume finite difference formulation used by MODFLOW-USG assumes that the line connecting two cell centers crosses the shared face at a right angle through the middle of the face. A quadtree grid violates that assumption wherever a coarse cell connects to a finer cell, because the shared face is offset from the center of the coarse cell. The Ghost Node Correction (GNC) Package corrects the resulting error by interpolating the head at a ghost node, which is the point in the coarse cell that does lie on the perpendicular through the middle of the face. +# +# GRIDGEN computes the ghost node data for a quadtree grid, and FloPy converts it to GNC Package input. We build a quadtree grid, create the GNC Package, and compare the corrected and uncorrected solutions. +# +# The same ghost node data can be computed from a model grid without running GRIDGEN, which is shown in the [MODFLOW 6 ghost node correction example](https://flopy.readthedocs.io/en/latest/Notebooks/gnc_example.html). + +# + +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +import matplotlib as mpl +import matplotlib.pyplot as plt +import numpy as np +from shapely.geometry import Polygon + +import flopy +from flopy.utils import flopy_io +from flopy.utils.gridgen import Gridgen + +print(sys.version) +print(f"numpy version: {np.__version__}") +print(f"matplotlib version: {mpl.__version__}") +print(f"flopy version: {flopy.__version__}") +# - + +# The FloPy GRIDGEN module requires that the gridgen executable can be called using subprocess **(i.e., gridgen is in your path)**. + +gridgen_exe = flopy.which("gridgen") +if gridgen_exe is None: + msg = ( + "Warning, gridgen is not in your path. " + "When you create the gridgen object you will need to " + "provide a full path to the gridgen binary executable." + ) + print(msg) +else: + print(f"gridgen executable was found at: {flopy_io.relpath_safe(gridgen_exe)}") + +# + +temp_dir = TemporaryDirectory() +workspace = Path(temp_dir.name) +gridgen_ws = workspace / "gridgen" +gridgen_ws.mkdir(parents=True, exist_ok=True) + +print(f"Model workspace is : {flopy_io.scrub_login(str(workspace))}") +print(f"Gridgen workspace is : {flopy_io.scrub_login(str(gridgen_ws))}") +# - + +# ## Build the quadtree grid +# +# GRIDGEN works from a base MODFLOW grid. We use a single layer grid of 10 rows and 10 columns and refine a square in the middle of the grid by three levels, which produces cells one eighth the width of the base grid cells. + +# + +nlay, nrow, ncol = 1, 10, 10 +delr = delc = 1.0 + +base_grid = flopy.discretization.StructuredGrid( + delr=np.full(ncol, delr, dtype=float), + delc=np.full(nrow, delc, dtype=float), + top=np.full((nrow, ncol), 1.0), + botm=np.zeros((nlay, nrow, ncol)), +) + +g = Gridgen(base_grid, model_ws=str(gridgen_ws)) +refinement = [Polygon([(4, 4), (6, 4), (6, 6), (4, 6)])] +g.add_refinement_features(refinement, "polygon", 3, layers=[0]) +g.build(verbose=False) + +disu_gridprops = g.get_gridprops_disu5() +print(f"Number of cells: {g.get_nodes()}") +# - + +# ## Ghost node data +# +# GRIDGEN computes the ghost node data whenever it exports a grid and writes it to the `qtg.gnc.dat` file. The `get_gnc()` method reads that file and returns a record array with zero-based node numbers, where cell `n` contains the ghost node, cell `m` is the connecting cell, and cells `j0` and `j1` are the contributing cells whose heads are interpolated. + +gnc = g.get_gnc() +print(f"Number of ghost nodes: {len(gnc)}") +print(gnc[:5]) + +# Two contributing cells are always written. When a ghost node has only one contributing cell, that cell is repeated and its contributing factor is halved, which MODFLOW-USG accumulates into the same matrix position. MODFLOW-USG reads a fixed number of contributing cells per record and indexes `IBOUND` with each of them, so an unused slot cannot be filled with a dummy cell number of zero the way it can in MODFLOW 6. Repeating a cell keeps every slot valid. + +single = gnc["j0"] == gnc["j1"] +print(f"{single.sum()} of {len(gnc)} ghost nodes have one contributing cell") + +# The contributing factors always sum to less than one, because one minus the sum is the factor applied to the head in cell `n`. + +alpha = gnc["alpha0"] + gnc["alpha1"] +print(f"Contributing factors range from {alpha.min():.4f} to {alpha.max():.4f}") + +# The `get_gridprops_gnc5()` method returns a dictionary that can be unpacked directly into the `MfUsgGnc` constructor. GRIDGEN writes contributing factors rather than conductances, so `iflalphan` is always 0. The `i2kn` and `isymgncn` options can be set through the method. + +gnc_gridprops = g.get_gridprops_gnc5() +for key in ("numgnc", "numalphaj", "i2kn", "isymgncn", "iflalphan"): + print(f"{key}: {gnc_gridprops[key]}") + +# ## Build and run the models +# +# We build the same model with and without the GNC Package. The default `isymgncn` of 0 updates the left-hand side matrix, which makes the matrix asymmetric, so the model is solved with the complex option of the SMS Package. + +# + +chdspd = [] +for x, y, head in [(0.0, 10.0, 1.0), (10.0, 0.0, 0.0)]: + node = g.intersect([(x, y)], "point", 0)["nodenumber"][0] + chdspd.append([node, head, head]) +print(f"Constant head cells: {chdspd}") + + +def build_model(name, gnc=False): + m = flopy.mfusg.MfUsg( + modelname=name, + model_ws=str(workspace / name), + exe_name="mfusg", + structured=False, + ) + flopy.mfusg.MfUsgDisU(m, **disu_gridprops) + flopy.mfusg.MfUsgBas(m) + flopy.mfusg.MfUsgLpf(m) + flopy.modflow.ModflowChd(m, stress_period_data=chdspd) + flopy.mfusg.MfUsgSms(m, options="COMPLEX") + flopy.modflow.ModflowOc(m, stress_period_data={(0, 0): ["save head"]}) + if gnc: + flopy.mfusg.MfUsgGnc(m, **gnc_gridprops) + return m + + +# + +heads = {} +for name, gnc_flag in [("uncorrected", False), ("gnc", True)]: + m = build_model(name, gnc=gnc_flag) + m.write_input() + success, buff = m.run_model(silent=True) + assert success, f"{name} did not converge" + head_file = workspace / name / f"{name}.hds" + heads[name] = np.concatenate(flopy.utils.HeadUFile(head_file).get_data()) + print(f"{name} converged") +# - + +# The GNC Package file lists the cell containing the ghost node, the connecting cell, the two contributing cells, and the two contributing factors, using one-based node numbers. + +gnc_file = workspace / "gnc" / "gnc.gnc" +print("".join(gnc_file.open().readlines()[:8])) + +# ## Effect of the correction +# +# The correction changes the simulated heads around the refined area, where the ghost nodes are. + +# + +diff = heads["gnc"] - heads["uncorrected"] +print(f"Maximum head difference: {np.abs(diff).max():.3e}") + +ugrid = flopy.discretization.UnstructuredGrid(**g.get_gridprops_unstructuredgrid()) +vmax = np.abs(diff).max() + +fig, axes = plt.subplots(1, 2, figsize=(11, 5), constrained_layout=True) + +ax = axes[0] +ax.set_aspect("equal") +pmv = flopy.plot.PlotMapView(modelgrid=ugrid, ax=ax, layer=0) +cb = pmv.plot_array(heads["gnc"], cmap="jet") +pmv.plot_grid(colors="0.5", lw=0.3, alpha=0.5) +pmv.contour_array(heads["gnc"], levels=[0.2, 0.4, 0.6, 0.8], colors="white") +ax.set_title("Corrected head") +fig.colorbar(cb, ax=ax, shrink=0.7, label="head") + +ax = axes[1] +ax.set_aspect("equal") +pmv = flopy.plot.PlotMapView(modelgrid=ugrid, ax=ax, layer=0) +cb = pmv.plot_array(diff, cmap="RdBu_r", vmin=-vmax, vmax=vmax) +pmv.plot_grid(colors="0.5", lw=0.3, alpha=0.5) +ax.set_title("Corrected minus uncorrected head") +fig.colorbar(cb, ax=ax, shrink=0.7, label="head difference") +# - + +# Clean up the temporary workspace. + +try: + temp_dir.cleanup() +except (PermissionError, NotADirectoryError): + pass diff --git a/.gitignore b/.gitignore index ace40e661..5b252fbb2 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,7 @@ flopy/mf6/data/toml/ # uv lockfile uv.lock + +# MODFLOW cell-by-cell budget output +*.cbb +*.CBB diff --git a/autotest/test_gnc.py b/autotest/test_gnc.py index 9b3b05157..db06980c4 100644 --- a/autotest/test_gnc.py +++ b/autotest/test_gnc.py @@ -6,6 +6,8 @@ - gridgen : the computed data must reproduce gridgen's qtg.gnc.dat. """ +import io + import numpy as np import pytest from modflow_devtools.markers import requires_exe, requires_pkg @@ -16,10 +18,11 @@ _check_gnc, get_gnc, get_gnc_dtype, + get_gridprops_gnc5, get_gridprops_gnc6, get_numalphaj, ) -from flopy.utils.gridgen import Gridgen +from flopy.utils.gridgen import Gridgen, get_ia_from_iac def synthetic_grid(connectivity=True): @@ -337,3 +340,193 @@ def test_get_gnc_connectivity_needs_constant_ncpl(): assert grid.iac is None with pytest.raises(ValueError, match="different"): get_gnc(grid) + + +@requires_exe("gridgen") +@requires_pkg("shapely", "geopandas") +def test_get_gnc_inputs_agree(function_tmpdir): + g = build_gridgen(function_tmpdir) + expected = gnc_key(g.get_gnc()) + + grid = UnstructuredGrid(**g.get_gridprops_unstructuredgrid()) + iac = g.get_iac() + ia, ja = get_ia_from_iac(iac), g.get_ja(iac.sum()) + + # refinement level of every cell, where level 0 is the base grid cell + area = g.get_area() + level = np.round(np.log2(np.sqrt(area.max() / area))).astype(int) + assert level.max() > 0 + + ncpl = g.get_gridprops_disv()["ncpl"] + vertex_grid = VertexGrid(**g.get_gridprops_vertexgrid()) + + for tag, gnc in [ + ("level", get_gnc(grid, level=level, numalphaj=2)), + ("level per layer", get_gnc(grid, level=level[:ncpl], numalphaj=2)), + ("vertex grid", get_gnc(vertex_grid, ia=ia, ja=ja, numalphaj=2)), + ("iac", get_gnc(grid, iac=iac, ja=ja, numalphaj=2)), + # connectivity built from the grid, either the iac and ja an + # unstructured grid carries or the cells that share an edge + ("unstructured grid only", get_gnc(grid, numalphaj=2)), + ("vertex grid only", get_gnc(vertex_grid, numalphaj=2)), + ]: + assert np.allclose(gnc_key(gnc), expected, atol=2.0e-6), tag + + +@requires_exe("gridgen") +@requires_pkg("shapely", "geopandas") +def test_get_gridprops_gnc_matches_gridgen(function_tmpdir): + g = build_gridgen(function_tmpdir, nlay=1) + iac = g.get_iac() + ia, ja = get_ia_from_iac(iac), g.get_ja(iac.sum()) + ncpl = g.get_gridprops_disv()["ncpl"] + + grid = UnstructuredGrid(**g.get_gridprops_unstructuredgrid()) + gnc = get_gnc(grid, numalphaj=2) + + gridprops = get_gridprops_gnc6(gnc, dis_type="disv", ncpl=ncpl, ia=ia, ja=ja) + expected = g.get_gridprops_gnc6(dis_type="disv") + assert gridprops["numgnc"] == expected["numgnc"] + assert gridprops["numalphaj"] == expected["numalphaj"] + + gridprops = get_gridprops_gnc5(gnc, ia=ia, ja=ja) + expected = g.get_gridprops_gnc5() + assert gridprops["numgnc"] == expected["numgnc"] + assert gridprops["iflalphan"] == 0 + assert gridprops["gncdata"].dtype == expected["gncdata"].dtype + + +@pytest.mark.slow +@requires_exe("mf6", "gridgen") +@requires_pkg("shapely", "geopandas") +def test_mf6disv_gnc_padding(function_tmpdir): + """Repeating a contributing cell must not change the solution""" + g = build_gridgen(function_tmpdir, nlay=1) + disv_gridprops = g.get_gridprops_disv() + iac = g.get_iac() + ia, ja = get_ia_from_iac(iac), g.get_ja(iac.sum()) + grid = UnstructuredGrid(**g.get_gridprops_unstructuredgrid()) + + chdspd = [] + for x, y, head in [(0, 10, 1.0), (10, 0, 0.0)]: + ic = g.intersect([(x, y)], "point", 0)["nodenumber"][0] + chdspd.append([(0, ic), head]) + + def run(numalphaj): + gnc = get_gnc(grid, numalphaj=numalphaj) + assert get_numalphaj(gnc) == numalphaj + gridprops = get_gridprops_gnc6( + gnc, dis_type="disv", ncpl=disv_gridprops["ncpl"], ia=ia, ja=ja + ) + ws = function_tmpdir / f"j{numalphaj}" + sim = flopy.mf6.MFSimulation(sim_name="m", sim_ws=ws, exe_name="mf6") + flopy.mf6.ModflowTdis(sim) + flopy.mf6.ModflowIms( + sim, + linear_acceleration="bicgstab", + inner_dvclose=1e-11, + outer_dvclose=1e-11, + ) + gwf = flopy.mf6.ModflowGwf(sim, modelname="m") + flopy.mf6.ModflowGwfdisv(gwf, **disv_gridprops) + flopy.mf6.ModflowGwfic(gwf) + flopy.mf6.ModflowGwfnpf(gwf) + flopy.mf6.ModflowGwfchd(gwf, stress_period_data=chdspd) + flopy.mf6.ModflowGwfoc( + gwf, head_filerecord="m.hds", saverecord=[("HEAD", "ALL")] + ) + flopy.mf6.ModflowGwfgnc(gwf, **gridprops) + sim.write_simulation() + success, buff = sim.run_simulation(silent=True) + assert success, "\n".join(buff[-25:]) + return gwf.output.head().get_data().flatten() + + assert np.allclose(run(2), run(4), atol=1e-8) + + +@pytest.mark.slow +@requires_exe("mfusg", "gridgen") +@requires_pkg("shapely", "geopandas") +def test_mfusg_gnc_padding(function_tmpdir): + """Repeating a contributing cell must not change the solution""" + g = build_gridgen(function_tmpdir, nlay=1) + disu_gridprops = g.get_gridprops_disu5() + iac = g.get_iac() + ia, ja = get_ia_from_iac(iac), g.get_ja(iac.sum()) + grid = UnstructuredGrid(**g.get_gridprops_unstructuredgrid()) + + chdspd = [] + for x, y, head in [(0, 10, 1.0), (10, 0, 0.0)]: + ic = g.intersect([(x, y)], "point", 0)["nodenumber"][0] + chdspd.append([ic, head, head]) + + def run(numalphaj): + gridprops = get_gridprops_gnc5(get_gnc(grid, numalphaj=numalphaj), ia=ia, ja=ja) + ws = function_tmpdir / f"j{numalphaj}" + m = flopy.mfusg.MfUsg( + modelname="m", model_ws=ws, exe_name="mfusg", structured=False + ) + flopy.mfusg.MfUsgDisU(m, **disu_gridprops) + flopy.mfusg.MfUsgBas(m) + flopy.mfusg.MfUsgLpf(m) + flopy.modflow.ModflowChd(m, stress_period_data=chdspd) + flopy.mfusg.MfUsgSms(m, options="COMPLEX") + flopy.modflow.ModflowOc(m, stress_period_data={(0, 0): ["save head"]}) + flopy.mfusg.MfUsgGnc(m, **gridprops) + m.write_input() + success, buff = m.run_model(silent=True) + assert success, "\n".join(buff[-25:]) + return np.concatenate(flopy.utils.HeadUFile(ws / "m.hds").get_data()) + + assert np.allclose(run(2), run(4), atol=1e-8) + + +def test_mfusg_gnc_file_fields_stay_separated(function_tmpdir): + """A value that fills its format width must not run into the next field""" + model = flopy.mfusg.MfUsg(modelname="m", model_ws=function_tmpdir, structured=False) + dtype = flopy.mfusg.MfUsgGnc.get_default_dtype(2, 0) + gncdata = np.zeros(2, dtype=dtype) + gncdata[0] = (23, 33, 22, 22, 0.125, 0.166667) + # ten digit node numbers fill the %10d field width + gncdata[1] = (1234567889, 1234567889, 1234567889, 1234567889, 0.125, 0.125) + flopy.mfusg.MfUsgGnc(model, numgnc=2, numalphaj=2, gncdata=gncdata) + model.write_input() + + # the list is read with URWORD, so every record must have one token per field + records = (function_tmpdir / "m.gnc").open().readlines()[2:] + for line in records: + if line.strip(): + assert len(line.split()) == 6 + + # the contributing factors must not be truncated + assert np.allclose(float(records[0].split()[5]), 0.166667, atol=1e-6) + + +def test_fmt_string_separates_free_format_fields(): + """A free format list is read with URWORD, so its fields must be separated + + A value that fills its format width runs into the next value when the + field formats are concatenated, which made a record unreadable. Ten digit + node numbers fill the %10d field width. + """ + from flopy.mfusg.cln_dtypes import MfUsgClnDtypes + from flopy.mfusg.mfusg import fmt_string + + dtypes = { + "gnc": flopy.mfusg.MfUsgGnc.get_default_dtype(2, 0), + "cln node": MfUsgClnDtypes.get_clnnode_dtype(), + } + for name, dtype in dtypes.items(): + record = np.zeros(1, dtype=dtype) + for field in dtype.names: + if np.issubdtype(dtype[field], np.integer): + record[0][field] = 1234567889 + + buff = io.StringIO() + np.savetxt(buff, record, fmt=fmt_string(record, free=True), delimiter="") + assert len(buff.getvalue().split()) == len(dtype.names), name + + # a fixed format list is read by position, so it stays unseparated + buff = io.StringIO() + np.savetxt(buff, record, fmt=fmt_string(record, free=False), delimiter="") + assert len(buff.getvalue().split()) < len(dtype.names), name diff --git a/autotest/test_gridgen.py b/autotest/test_gridgen.py index d369a21cd..27c997aec 100644 --- a/autotest/test_gridgen.py +++ b/autotest/test_gridgen.py @@ -14,7 +14,7 @@ from autotest.test_grid_cases import GridCases from flopy.discretization.unstructuredgrid import UnstructuredGrid from flopy.discretization.vertexgrid import VertexGrid -from flopy.utils.gridgen import Gridgen +from flopy.utils.gridgen import Gridgen, get_ia_from_iac @requires_exe("gridgen") @@ -873,3 +873,290 @@ def test_flopy_issue_1492(function_tmpdir): pmv.contour_array(head, levels=[0.2, 0.4, 0.6, 0.8], linewidths=3.0) pmv.plot_vector(spdis["qx"], spdis["qy"], color="white") plt.show() + + +def build_gnc_gridgen(ws, layers=None, nlay=3): + """Build a gridgen grid with a refined block in the middle""" + from shapely.geometry import Polygon + + nrow = ncol = 10 + top = 1.0 + dz = top / nlay + botm = [top - k * dz for k in range(1, nlay + 1)] + + sim = flopy.mf6.MFSimulation(sim_name="base", sim_ws=ws) + gwf = flopy.mf6.ModflowGwf(sim, modelname="base") + flopy.mf6.ModflowGwfdis( + gwf, + nlay=nlay, + nrow=nrow, + ncol=ncol, + delr=1.0, + delc=1.0, + top=top, + botm=botm, + ) + + g = Gridgen(gwf.modelgrid, model_ws=ws) + polys = [Polygon([(4, 4), (6, 4), (6, 6), (4, 6)])] + g.add_refinement_features( + polys, "polygon", 3, range(nlay) if layers is None else layers + ) + g.build() + return g + + +@pytest.mark.parametrize("nrec", [0, 1, 3]) +def test_read_qtg_gnc_dat(function_tmpdir, nrec): + lines = [ + "89\t125\t88\t88\t0.125\t0.125", + "129\t128\t174\t175\t0.166667\t0.166667", + "163\t164\t124\t124\t0.125\t0.125", + ][:nrec] + (function_tmpdir / "qtg.gnc.dat").write_text("\n".join(lines)) + + gnc = Gridgen.read_qtg_gnc_dat(function_tmpdir) + + assert gnc.dtype.names == ("n", "m", "j0", "j1", "alpha0", "alpha1") + assert len(gnc) == nrec + + if nrec > 0: + # node numbers are converted to zero-based, alphas are not modified + assert gnc["n"][0] == 88 + assert gnc["m"][0] == 124 + assert gnc["j0"][0] == gnc["j1"][0] == 87 + assert gnc["alpha0"][0] == gnc["alpha1"][0] == 0.125 + if nrec > 1: + assert gnc["j0"][1] == 173 + assert gnc["j1"][1] == 174 + assert np.allclose(gnc["alpha1"][1], 0.166667) + + +@requires_exe("gridgen") +@requires_pkg("shapely", "geopandas") +def test_gnc_data(function_tmpdir): + g = build_gnc_gridgen(function_tmpdir) + gnc = g.get_gnc() + + # one record per line of the file gridgen wrote + nlines = len( + [ + line + for line in (function_tmpdir / "qtg.gnc.dat").read_text().splitlines() + if line.strip() + ] + ) + assert len(gnc) == nlines > 0 + + nodes = g.get_nodes() + for name in ("n", "m", "j0", "j1"): + assert gnc[name].min() >= 0 + assert gnc[name].max() < nodes + + # the ghost node is always in the coarser of the two cells + area = g.get_area() + assert np.all(area[gnc["n"]] > area[gnc["m"]]) + + # contributing factors must sum to less than one + assert np.all(gnc["alpha0"] + gnc["alpha1"] < 1.0) + + # n must be connected to m, and each j must be connected to n + iac = g.get_iac() + ia = get_ia_from_iac(iac) + ja = g.get_ja(iac.sum()) + for rec in gnc: + neighbors = ja[ia[rec["n"]] : ia[rec["n"] + 1]] + assert rec["m"] in neighbors + assert rec["j0"] in neighbors + assert rec["j1"] in neighbors + + +@requires_exe("gridgen") +@requires_pkg("shapely", "geopandas") +def test_gridprops_gnc6_disv(function_tmpdir): + g = build_gnc_gridgen(function_tmpdir) + gnc = g.get_gnc() + gridprops = g.get_gridprops_gnc6(dis_type="disv") + + assert gridprops["numalphaj"] == 2 + assert gridprops["numgnc"] == len(gnc) == len(gridprops["gncdata"]) + + ncpl = g.get_gridprops_disv()["ncpl"] + nlay = g.get_nlay() + for rec, (cellidn, cellidm, j0, j1, alpha0, alpha1) in zip( + gnc, gridprops["gncdata"] + ): + for node, cellid in zip( + (rec["n"], rec["m"], rec["j0"], rec["j1"]), (cellidn, cellidm, j0, j1) + ): + assert cellid == (node // ncpl, node % ncpl) + assert 0 <= cellid[0] < nlay + assert 0 <= cellid[1] < ncpl + # gridgen only computes horizontal corrections + assert cellidn[0] == cellidm[0] == j0[0] == j1[0] + assert (alpha0, alpha1) == (rec["alpha0"], rec["alpha1"]) + + +@requires_exe("gridgen") +@requires_pkg("shapely", "geopandas") +def test_gridprops_gnc6_disu(function_tmpdir): + # refining a single layer gives a different number of nodes per layer + g = build_gnc_gridgen(function_tmpdir, layers=[0]) + gnc = g.get_gnc() + gridprops = g.get_gridprops_gnc6(dis_type="disu") + + assert gridprops["numalphaj"] == 2 + assert gridprops["numgnc"] == len(gnc) + for rec, (cellidn, cellidm, j0, j1, _, _) in zip(gnc, gridprops["gncdata"]): + assert (cellidn, cellidm, j0, j1) == ( + (rec["n"],), + (rec["m"],), + (rec["j0"],), + (rec["j1"],), + ) + + # disv cellids cannot be built when nodes per layer are not constant + nodelay = g.get_nodelay() + assert nodelay.min() != nodelay.max() + with pytest.raises(ValueError, match="not the same for all layers"): + g.get_gridprops_gnc6(dis_type="disv") + + +@requires_exe("gridgen") +@requires_pkg("shapely", "geopandas") +def test_gridprops_gnc6_invalid(function_tmpdir): + g = build_gnc_gridgen(function_tmpdir, nlay=1) + + with pytest.raises(ValueError, match="Unknown dis_type"): + g.get_gridprops_gnc6(dis_type="dis") + + # n and m must be connected + (function_tmpdir / "qtg.gnc.dat").write_text("1\t400\t2\t2\t0.125\t0.125\n") + with pytest.raises(ValueError, match="is not connected to cell"): + g.get_gridprops_gnc6(dis_type="disv") + assert g.get_gridprops_gnc6(dis_type="disv", check=False)["numgnc"] == 1 + + # contributing factors must sum to less than one + (function_tmpdir / "qtg.gnc.dat").write_text("24\t34\t23\t23\t0.6\t0.6\n") + with pytest.raises(ValueError, match="must be less than one"): + g.get_gridprops_gnc6(dis_type="disv") + + +@requires_exe("gridgen") +@requires_pkg("shapely", "geopandas") +def test_gridprops_gnc5(function_tmpdir): + g = build_gnc_gridgen(function_tmpdir, nlay=1) + gnc = g.get_gnc() + gridprops = g.get_gridprops_gnc5() + + assert gridprops["numalphaj"] == 2 + assert gridprops["numgnc"] == len(gnc) + # gridgen writes contributing factors, not conductances + assert gridprops["iflalphan"] == 0 + assert gridprops["i2kn"] == 0 + assert gridprops["isymgncn"] == 0 + + gncdata = gridprops["gncdata"] + assert gncdata.dtype == flopy.mfusg.MfUsgGnc.get_default_dtype(2, 0) + assert np.array_equal(gncdata["NodeN"], gnc["n"]) + assert np.array_equal(gncdata["NodeM"], gnc["m"]) + assert np.array_equal(gncdata["Node0"], gnc["j0"]) + assert np.array_equal(gncdata["Node1"], gnc["j1"]) + assert np.allclose(gncdata["Alpha0"], gnc["alpha0"]) + assert np.allclose(gncdata["Alpha1"], gnc["alpha1"]) + + gridprops = g.get_gridprops_gnc5(i2kn=1, isymgncn=1) + assert gridprops["i2kn"] == 1 + assert gridprops["isymgncn"] == 1 + + +@pytest.mark.slow +@requires_exe("mf6", "gridgen") +@requires_pkg("shapely", "geopandas") +def test_mf6disv_gnc(function_tmpdir): + g = build_gnc_gridgen(function_tmpdir) + disv_gridprops = g.get_gridprops_disv() + gnc_gridprops = g.get_gridprops_gnc6(dis_type="disv") + assert gnc_gridprops["numgnc"] > 0 + + chdspd = [] + for x, y, head in [(0, 10, 1.0), (10, 0, 0.0)]: + ra = g.intersect([(x, y)], "point", 0) + chdspd.append([(0, ra["nodenumber"][0]), head]) + + def run(tag, gnc=False, xt3d=False): + ws = function_tmpdir / tag + sim = flopy.mf6.MFSimulation(sim_name="m", sim_ws=ws, exe_name="mf6") + flopy.mf6.ModflowTdis(sim) + flopy.mf6.ModflowIms( + sim, + linear_acceleration="bicgstab", + inner_dvclose=1e-9, + outer_dvclose=1e-9, + ) + gwf = flopy.mf6.ModflowGwf(sim, modelname="m", save_flows=True) + flopy.mf6.ModflowGwfdisv(gwf, **disv_gridprops) + flopy.mf6.ModflowGwfic(gwf) + flopy.mf6.ModflowGwfnpf(gwf, xt3doptions=xt3d) + flopy.mf6.ModflowGwfchd(gwf, stress_period_data=chdspd) + flopy.mf6.ModflowGwfoc( + gwf, head_filerecord="m.hds", saverecord=[("HEAD", "ALL")] + ) + if gnc: + flopy.mf6.ModflowGwfgnc(gwf, **gnc_gridprops) + sim.write_simulation() + success, buff = sim.run_simulation(silent=True) + assert success, "\n".join(buff[-25:]) + return gwf.output.head().get_data().flatten() + + head_none = run("none") + head_gnc = run("gnc", gnc=True) + head_xt3d = run("xt3d", xt3d=True) + + # the correction must move the solution toward the xt3d solution + err_none = np.abs(head_none - head_xt3d).max() + err_gnc = np.abs(head_gnc - head_xt3d).max() + assert err_gnc < err_none / 5.0, f"gnc {err_gnc} vs uncorrected {err_none}" + + +@pytest.mark.slow +@requires_exe("mfusg", "gridgen") +@requires_pkg("shapely", "geopandas") +def test_mfusg_gnc(function_tmpdir): + g = build_gnc_gridgen(function_tmpdir, nlay=1) + disu_gridprops = g.get_gridprops_disu5() + gnc_gridprops = g.get_gridprops_gnc5() + assert gnc_gridprops["numgnc"] > 0 + + chdspd = [] + for x, y, head in [(0, 10, 1.0), (10, 0, 0.0)]: + ra = g.intersect([(x, y)], "point", 0) + chdspd.append([ra["nodenumber"][0], head, head]) + + def run(tag, gnc=False): + ws = function_tmpdir / tag + m = flopy.mfusg.MfUsg( + modelname="m", model_ws=ws, exe_name="mfusg", structured=False + ) + flopy.mfusg.MfUsgDisU(m, **disu_gridprops) + flopy.mfusg.MfUsgBas(m) + flopy.mfusg.MfUsgLpf(m) + flopy.modflow.ModflowChd(m, stress_period_data=chdspd) + flopy.mfusg.MfUsgSms(m, options="COMPLEX") + flopy.modflow.ModflowOc(m, stress_period_data={(0, 0): ["save head"]}) + if gnc: + flopy.mfusg.MfUsgGnc(m, **gnc_gridprops) + m.write_input() + success, buff = m.run_model(silent=True) + assert success, "\n".join(buff[-25:]) + return np.concatenate(flopy.utils.HeadUFile(ws / "m.hds").get_data()) + + head_none = run("none") + head_gnc = run("gnc", gnc=True) + assert np.abs(head_none - head_gnc).max() > 0.0 + + # the written package must round trip gridgen's one-based node numbers + written = np.genfromtxt(function_tmpdir / "gnc" / "m.gnc", skip_header=2) + expected = np.genfromtxt(function_tmpdir / "qtg.gnc.dat") + assert np.array_equal(written[:, :4], expected[:, :4]) + assert np.allclose(written[:, 4:], expected[:, 4:], atol=1e-6) diff --git a/flopy/mfusg/mfusg.py b/flopy/mfusg/mfusg.py index 91c136fd1..fdcf7012b 100644 --- a/flopy/mfusg/mfusg.py +++ b/flopy/mfusg/mfusg.py @@ -576,4 +576,7 @@ def fmt_string(array, free=False): raise TypeError(msg) else: raise TypeError(f"mfusg.fmt_string error: unknown vtype in field: {field}") - return "".join(fmts) + # a free format list is read with URWORD, so the fields are separated the + # way MfList.fmt_string separates them; a fixed format list is read by + # position and relies on the field widths + return (" " if free else "").join(fmts) diff --git a/flopy/mfusg/mfusggnc.py b/flopy/mfusg/mfusggnc.py index 2114e46db..800aaf454 100644 --- a/flopy/mfusg/mfusggnc.py +++ b/flopy/mfusg/mfusggnc.py @@ -174,10 +174,14 @@ def write_file(self, f=None, check=False): f_gnc.write(f"{self.heading}\n") + # options are keywords, so write them as words rather than as a list + options = ( + self.options if isinstance(self.options, str) else " ".join(self.options) + ) f_gnc.write( f" {0:9d} {0:9d} {self.numgnc:9d} {self.numalphaj:9d}" f" {self.i2kn:9d} {self.isymgncn:9d} {self.iflalphan:9d}" - f" {self.options}\n" + f" {options}\n" ) gdata = self.gncdata.copy() @@ -187,7 +191,9 @@ def write_file(self, f=None, check=False): for idx in range(self.numalphaj): gdata[f"Node{idx:d}"] += 1 - np.savetxt(f_gnc, gdata, fmt=fmt_string(gdata), delimiter="") + # the gnc list is read with URWORD, so it is not fixed-width and the + # contributing factors do not need to be truncated to %10.2e + np.savetxt(f_gnc, gdata, fmt=fmt_string(gdata, free=True), delimiter="") f_gnc.write("\n") f_gnc.close() diff --git a/flopy/utils/__init__.py b/flopy/utils/__init__.py index b4129b4a7..9cd1d97ff 100644 --- a/flopy/utils/__init__.py +++ b/flopy/utils/__init__.py @@ -29,7 +29,7 @@ from .formattedfile import FormattedHeadFile get_modflow = get_modflow_module.run_main -from .gnc import get_gnc, get_gridprops_gnc6 +from .gnc import get_gnc, get_gridprops_gnc5, get_gridprops_gnc6 from .gridintersect import GridIntersect from .hfb_util import make_hfb_array from .mflistfile import ( diff --git a/flopy/utils/gnc.py b/flopy/utils/gnc.py index 9d86b174d..099f59ef9 100644 --- a/flopy/utils/gnc.py +++ b/flopy/utils/gnc.py @@ -1,9 +1,12 @@ """ Ghost node correction (GNC) data for quadtree-like grids. -Ghost node data is computed from a grid, a grid conforming array of refinement -levels, and the grid connectivity by :func:`get_gnc`, and is converted to -MODFLOW 6 package input by :func:`get_gridprops_gnc6`. +Ghost node data can be computed from a grid, a grid conforming array of +refinement levels, and the grid connectivity, or read from gridgen output +using :meth:`flopy.utils.gridgen.Gridgen.get_gnc`. The record arrays produced +by either route are converted to MODFLOW 6 and MODFLOW-USG package input by +:func:`get_gridprops_gnc6` and :func:`get_gridprops_gnc5`. + """ import numpy as np @@ -412,3 +415,69 @@ def cellid(node): "numalphaj": numalphaj, "gncdata": gncdata, } + + +def get_gridprops_gnc5(gnc, i2kn=0, isymgncn=0, ia=None, ja=None, iac=None, check=True): + """ + Get a dictionary of information needed to create a MODFLOW-USG GNC + Package. The returned dictionary can be unpacked directly into the + MfUsgGnc constructor. + + Parameters + ---------- + gnc : np.recarray + Ghost node data with zero-based node numbers + i2kn : int + Apply the second-order correction to unconfined transmissivity + (default is 0). + isymgncn : int + Update the right-hand side vector for symmetric systems instead of + the left-hand side matrix (default is 0). + ia : array_like + Zero-based CRS row pointer, used to check connectivity + ja : array_like + Zero-based CRS column indices, used to check connectivity + iac : array_like + Number of connections per cell, used if ia is None + check : bool + Verify that each n-m pair is connected and that the contributing + factors sum to less than one (default is True). + + Returns + ------- + gridprops : dict + + Notes + ----- + Contributing factors are always written, so iflalphan is 0. The default + asymmetric implementation requires an asymmetric solver. numgnc is zero + for a grid without ghost nodes, in which case the package should not be + created. + + """ + # imported here because flopy.mfusg imports flopy.utils + from ..mfusg.mfusggnc import MfUsgGnc + + if check: + _check_gnc(gnc, ia=ia, ja=ja, iac=iac) + + numalphaj = get_numalphaj(gnc) + iflalphan = 0 + gncdata = MfUsgGnc.get_empty( + numgnc=len(gnc), numalphaj=numalphaj, iflalphan=iflalphan + ) + # MfUsgGnc.write_file converts to one-based node numbers + gncdata["NodeN"] = gnc["n"] + gncdata["NodeM"] = gnc["m"] + for i in range(numalphaj): + gncdata[f"Node{i}"] = gnc[f"j{i}"] + gncdata[f"Alpha{i}"] = gnc[f"alpha{i}"] + + return { + "numgnc": len(gncdata), + "numalphaj": numalphaj, + "i2kn": i2kn, + "isymgncn": isymgncn, + "iflalphan": iflalphan, + "gncdata": gncdata, + } diff --git a/flopy/utils/gridgen.py b/flopy/utils/gridgen.py index 1857e8457..2cd4205bb 100644 --- a/flopy/utils/gridgen.py +++ b/flopy/utils/gridgen.py @@ -15,6 +15,7 @@ from ..modflow import ModflowDis from ..utils import import_optional_dependency from ..utils.flopy_io import relpath_safe +from .gnc import get_gnc_dtype, get_gridprops_gnc5, get_gridprops_gnc6 from .util_array import Util2d # todo @@ -23,6 +24,12 @@ # support an asciigrid option for top and bottom interpolation # add intersection capability +# gridgen always writes two contributing cells per ghost node. When only one +# contributing cell exists it is repeated with alpha halved, which both +# MODFLOW 6 and MODFLOW-USG accumulate into the same matrix position. +GNC_NUMALPHAJ = 2 +GNC_DTYPE = get_gnc_dtype(GNC_NUMALPHAJ) + def read1d(f, a): """ @@ -1256,6 +1263,31 @@ def get_anglex(self, fldr=None): anglex = np.where(fldr == 2, 4.712389, anglex) return anglex + def get_gnc(self): + """ + Get the ghost node correction data computed by gridgen + + Returns + ------- + gnc : np.recarray + Record array with fields n, m, j0, j1, alpha0, and alpha1. Node + numbers are zero-based. Cell n is the cell containing the ghost + node, cell m is the connecting cell, and cells j0 and j1 are the + contributing cells. + + Notes + ----- + Gridgen only computes horizontal ghost node corrections and drops + records where a contributing cell is inactive. + + """ + return self.read_qtg_gnc_dat(model_ws=self.model_ws) + + def _gnc_connectivity(self): + """Return the zero-based ia and ja arrays""" + iac = self.get_iac() + return get_ia_from_iac(iac), self.get_ja(iac.sum()) + def get_verts_iverts(self, ncells, verbose=False): """ Return a 2d array of x and y vertices and a list of size ncells that @@ -1511,6 +1543,96 @@ def get_gridprops_disv(self): return gridprops + def _gnc_ncpl(self, dis_type): + """Return the number of cells per layer for dis_type""" + if dis_type.lower() != "disv": + return None + nodelay = self.get_nodelay() + ncpl = nodelay.min() + if ncpl != nodelay.max(): + raise ValueError( + "Cannot create DISV ghost node properties because the " + "number of cells is not the same for all layers" + ) + return ncpl + + def get_gridprops_gnc6(self, dis_type="disv", check=True): + """ + Get a dictionary of information needed to create a MODFLOW 6 GNC + Package. The returned dictionary can be unpacked directly into the + ModflowGwfgnc constructor. + + Parameters + ---------- + dis_type : str + Discretization the cellids are built for. Valid options are + 'disv' (default) and 'disu'. + check : bool + Verify that each n-m pair is connected and that the contributing + factors sum to less than one (default is True). + + Returns + ------- + gridprops : dict + + Notes + ----- + The correction is applied implicitly unless the explicit option is + set, so the BICGSTAB linear acceleration option should be specified + in the IMS Package. numgnc is zero for a grid without ghost nodes, + in which case the package should not be created. + + """ + ia, ja = self._gnc_connectivity() if check else (None, None) + return get_gridprops_gnc6( + self.get_gnc(), + dis_type=dis_type, + ncpl=self._gnc_ncpl(dis_type), + ia=ia, + ja=ja, + check=check, + ) + + def get_gridprops_gnc5(self, i2kn=0, isymgncn=0, check=True): + """ + Get a dictionary of information needed to create a MODFLOW-USG GNC + Package. The returned dictionary can be unpacked directly into the + MfUsgGnc constructor. + + Parameters + ---------- + i2kn : int + Apply the second-order correction to unconfined transmissivity + (default is 0). + isymgncn : int + Update the right-hand side vector for symmetric systems instead + of the left-hand side matrix (default is 0). + check : bool + Verify that each n-m pair is connected and that the contributing + factors sum to less than one (default is True). + + Returns + ------- + gridprops : dict + + Notes + ----- + Gridgen writes contributing factors, so iflalphan is always 0. The + default asymmetric implementation requires an asymmetric solver. + numgnc is zero for a grid without ghost nodes, in which case the + package should not be created. + + """ + ia, ja = self._gnc_connectivity() if check else (None, None) + return get_gridprops_gnc5( + self.get_gnc(), + i2kn=i2kn, + isymgncn=isymgncn, + ia=ia, + ja=ja, + check=check, + ) + def get_gridprops_vertexgrid(self): """ Get a dictionary of information needed to create a flopy VertexGrid. @@ -2103,3 +2225,27 @@ def read_qtg_fahl_dat(model_ws: Union[str, PathLike], nja: int): fname = os.path.join(model_ws, "qtg.fahl.dat") with open(fname, "r") as f: return read1d(f=f, a=np.empty((nja), dtype=np.float32)) + + @staticmethod + def read_qtg_gnc_dat(model_ws: Union[str, PathLike]): + """Read qtg.gnc.dat file + + Parameters + ---------- + model_ws : str or PathLike + Directory where file is stored + + Returns + ------- + np.recarray + Ghost node records with zero-based node numbers. The record is + empty if gridgen did not find any ghost nodes. + """ + fname = os.path.join(model_ws, "qtg.gnc.dat") + # gridgen writes an empty file when the grid has no ghost nodes + if os.path.getsize(fname) == 0: + return np.recarray((0,), dtype=GNC_DTYPE) + gnc = np.atleast_1d(np.genfromtxt(fname, dtype=GNC_DTYPE)) + for name in ("n", "m", "j0", "j1"): + gnc[name] -= 1 + return gnc.view(np.recarray) From 5cc72048e62e94560aeffd551d6077728ff786c2 Mon Sep 17 00:00:00 2001 From: Joseph Hughes Date: Wed, 5 Aug 2026 10:32:01 -0500 Subject: [PATCH 56/90] feat(lgr): add ghost node correction data for a GWF-GWF exchange (#2790) * feat(lgr): add ghost node correction data for a GWF-GWF exchange Ghost node data could only be built for the connections inside one model. The connections between two models joined by a GWF-GWF exchange need the same correction, and a local grid refinement pair is the common case. get_gnc_exchange computes the data from the two model grids and the exchange records, and Lgr.get_gnc_data returns it for a parent and child model. The ghost node and its contributing cells are in the first model of the exchange, which must therefore be the coarser one. MODFLOW 6 requires one record for every exchange record in the same order, so a connection that needs no correction, such as a child cell centered on the face of a parent cell, is written with a cellid of zero and a contributing factor of zero. A notebook shows the workflow and compares the corrected and uncorrected solutions against the exact solution for a confined homogeneous problem with a linear head field. * fix(gnc): keep at least one contributing cell per exchange record get_gnc_exchange writes a record for every exchange record, so a connection that needs no correction contributes a length of zero when numalphaj is taken from the records. A grid where no connection has an active contributing cell therefore gave a numalphaj of zero, and the records were written with no cellidsj and no alphasj at all, which MODFLOW 6 does not accept. numalphaj is now at least one, so those records are written with a cellid of zero and a contributing factor of zero like any other connection that needs no correction. Also covers the branches that take the idomain from the grid, that find no active contributing cell, and that reject a numalphaj that is too small. * fix(gnc): build exchange cellids for the grid type get_gnc_exchange documents its two model grids as any Grid, but it built the cellid of a contributing cell with get_lrc, which only a structured grid has, and it forced the cellid of an exchange record through tuple, which fails on a bare node number. A vertex or unstructured first model therefore raised an AttributeError inside the record loop rather than working. Cellids are now built for the grid type: layer, row, and column for a structured grid, layer and cell2d number for a vertex grid, and the node number for an unstructured grid. A cellid is accepted as a bare node number, a tuple, or an array. Lgr only builds structured grids, so this affects callers that pass their own model grids rather than a parent and child pair. --- .docs/Notebooks/lgr_gnc_example.py | 285 +++++++++++++++++++++++++++++ autotest/test_gnc.py | 231 ++++++++++++++++++++++- flopy/utils/__init__.py | 7 +- flopy/utils/gnc.py | 146 +++++++++++++++ flopy/utils/lgrutil.py | 35 ++++ 5 files changed, 702 insertions(+), 2 deletions(-) create mode 100644 .docs/Notebooks/lgr_gnc_example.py diff --git a/.docs/Notebooks/lgr_gnc_example.py b/.docs/Notebooks/lgr_gnc_example.py new file mode 100644 index 000000000..ae99e6bab --- /dev/null +++ b/.docs/Notebooks/lgr_gnc_example.py @@ -0,0 +1,285 @@ +# --- +# jupyter: +# jupytext: +# notebook_metadata_filter: all +# text_representation: +# extension: .py +# format_name: light +# format_version: '1.5' +# jupytext_version: 1.14.5 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# metadata: +# section: mf6 +# authors: +# - name: Joseph Hughes +# --- + +# # Ghost Node Correction (GNC) Data for a GWF-GWF Exchange +# +# The control volume finite difference formulation used by MODFLOW 6 assumes that the line connecting two cell centers crosses the shared face at a right angle through the middle of the face. A local grid refinement violates that assumption on the exchange between the parent and the child model, because the face a child cell shares with a parent cell is offset from the center of the parent cell. The Ghost Node Correction (GNC) Package of the exchange corrects the resulting error by interpolating the head at a ghost node, which is the point in the parent cell that does lie on the perpendicular through the middle of the face. +# +# The `Lgr` utility builds the ghost node data along with the exchange data, and we use it here to correct a parent and child model. The same data can be built for any two models joined by a GWF-GWF exchange with `flopy.utils.get_gnc_exchange`. +# +# The problem is confined and homogeneous, with constant heads on the left and right edges of the parent model and no flow across the top and bottom edges. Head then varies linearly between the two constant head columns, which gives an exact solution to compare against. + +# + +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +import matplotlib as mpl +import matplotlib.pyplot as plt +import numpy as np + +import flopy +from flopy.utils import flopy_io +from flopy.utils.lgrutil import Lgr + +print(sys.version) +print(f"numpy version: {np.__version__}") +print(f"matplotlib version: {mpl.__version__}") +print(f"flopy version: {flopy.__version__}") +# - + +# + +temp_dir = TemporaryDirectory() +workspace = Path(temp_dir.name) +print(f"Model workspace is : {flopy_io.scrub_login(str(workspace))}") +# - + +# ## Build the parent and child grids +# +# The parent grid is a single layer of 12 rows and 12 columns. The block of parent cells in the middle is made inactive and is replaced by a child model with three cells per parent cell in each direction. + +# + +nlay, nrow, ncol = 1, 12, 12 +delr = delc = 1.0 +top = 1.0 +botm = [0.0] +ncpp = 3 + +refine_mask = np.ones((nlay, nrow, ncol), dtype=int) +refine_mask[:, 4:8, 4:8] = 0 + +lgr = Lgr( + nlay, + nrow, + ncol, + delr, + delc, + np.full((nrow, ncol), top), + np.zeros((nlay, nrow, ncol)), + refine_mask, + ncpp=ncpp, + ncppl=[1], +) + +parent, child = lgr.parent, lgr.child +print(f"Parent grid: {parent.nrow} rows, {parent.ncol} columns") +print(f"Child grid : {child.nrow} rows, {child.ncol} columns") +# - + +# ## Exchange and ghost node data +# +# `get_exchange_data()` returns the connections between the two models, and `get_gnc_data()` returns the ghost node data for those connections. + +# + +exchangedata = lgr.get_exchange_data(angldegx=True, cdist=True) +gnc_gridprops = lgr.get_gnc_data() + +print(f"Number of exchanges: {len(exchangedata)}") +print(f"numgnc: {gnc_gridprops['numgnc']}") +print(f"numalphaj: {gnc_gridprops['numalphaj']}") +# - + +# MODFLOW 6 requires one ghost node record for every exchange record, in the same order, so `numgnc` always equals the number of exchanges. A connection that needs no correction is written with a cellid of zero and a contributing factor of zero, which MODFLOW 6 skips. +# +# With three child cells across the face of a parent cell, the middle one is centered on that face and needs no correction, so one connection in three is written that way. + +# + +corrected = [rec for rec in gnc_gridprops["gncdata"] if rec[-1] != 0.0] +skipped = [rec for rec in gnc_gridprops["gncdata"] if rec[-1] == 0.0] + +print(f"Connections with a ghost node: {len(corrected)}") +print(f"Connections without one : {len(skipped)}") +print(f"first corrected record: {corrected[0]}") +print(f"first skipped record : {skipped[0]}") +# - + +# The ghost node is in the parent cell, and so is the contributing cell whose head is interpolated with it. The contributing factor is the offset of the child cell from the center of the parent cell divided by the distance to the contributing cell. + +alpha = np.array([rec[-1] for rec in corrected]) +print(f"Contributing factors: {np.unique(np.round(alpha, 6))}") + +# ## Where the ghost nodes are +# +# Every ghost node lies on a connection between a parent cell and a child cell, so the ghost nodes trace the boundary of the child model. + +# + +fig, ax = plt.subplots(figsize=(7, 7)) +ax.set_aspect("equal") +pmv = flopy.plot.PlotMapView(modelgrid=parent, ax=ax) +pmv.plot_grid(colors="0.5", lw=0.6) +cmv = flopy.plot.PlotMapView(modelgrid=child, ax=ax) +cmv.plot_grid(colors="0.5", lw=0.3) + +pxc, pyc = parent.xcellcenters, parent.ycellcenters +cxc, cyc = child.xcellcenters, child.ycellcenters +for cellidn, cellidm, cellidj, factor in corrected: + xn, yn = pxc[cellidn[1], cellidn[2]], pyc[cellidn[1], cellidn[2]] + xm, ym = cxc[cellidm[1], cellidm[2]], cyc[cellidm[1], cellidm[2]] + xj, yj = pxc[cellidj[1], cellidj[2]], pyc[cellidj[1], cellidj[2]] + ax.plot([xn, xm], [yn, ym], color="C3", lw=0.9, zorder=2) + ax.plot(xj, yj, "s", color="C0", ms=3.5, zorder=3) + +ax.plot([], [], color="C3", lw=0.9, label="ghost node connection") +ax.plot([], [], "s", color="C0", ms=3.5, lw=0, label="contributing cell") +ax.legend(loc="upper right", framealpha=1.0) +ax.set_title("Ghost node connections on the exchange") +# - + +# ## Build and run the models +# +# The same simulation is built with and without the GNC Package of the exchange. The correction is applied implicitly, so the BICGSTAB linear acceleration option is specified in the IMS Package. + +# + +h_left, h_right = 1.0, 0.0 + + +def exact(grid): + x0, x1 = parent.xcellcenters[0, 0], parent.xcellcenters[0, -1] + return h_left + (h_right - h_left) * (grid.xcellcenters - x0) / (x1 - x0) + + +def build_simulation(name, gnc=False): + sim = flopy.mf6.MFSimulation( + sim_name=name, sim_ws=str(workspace / name), exe_name="mf6" + ) + flopy.mf6.ModflowTdis(sim) + flopy.mf6.ModflowIms( + sim, + linear_acceleration="bicgstab", + inner_maximum=1000, + inner_dvclose=1e-11, + outer_dvclose=1e-11, + ) + + gwfp = flopy.mf6.ModflowGwf(sim, modelname="parent", save_flows=True) + flopy.mf6.ModflowGwfdis( + gwfp, + nlay=nlay, + nrow=nrow, + ncol=ncol, + delr=delr, + delc=delc, + top=top, + botm=botm, + idomain=refine_mask, + ) + flopy.mf6.ModflowGwfic(gwfp, strt=0.5) + flopy.mf6.ModflowGwfnpf(gwfp, icelltype=0, k=1.0) + flopy.mf6.ModflowGwfchd( + gwfp, + stress_period_data=[[(0, i, 0), h_left] for i in range(nrow)] + + [[(0, i, ncol - 1), h_right] for i in range(nrow)], + ) + flopy.mf6.ModflowGwfoc( + gwfp, head_filerecord="parent.hds", saverecord=[("HEAD", "ALL")] + ) + + gwfc = flopy.mf6.ModflowGwf(sim, modelname="child", save_flows=True) + flopy.mf6.ModflowGwfdis( + gwfc, + nlay=child.nlay, + nrow=child.nrow, + ncol=child.ncol, + delr=child.delr, + delc=child.delc, + top=top, + botm=botm, + xorigin=child.xoffset, + yorigin=child.yoffset, + ) + flopy.mf6.ModflowGwfic(gwfc, strt=0.5) + flopy.mf6.ModflowGwfnpf(gwfc, icelltype=0, k=1.0) + flopy.mf6.ModflowGwfoc( + gwfc, head_filerecord="child.hds", saverecord=[("HEAD", "ALL")] + ) + + exchange = flopy.mf6.ModflowGwfgwf( + sim, + exgtype="GWF6-GWF6", + nexg=len(exchangedata), + exgmnamea="parent", + exgmnameb="child", + exchangedata=exchangedata, + auxiliary=["angldegx", "cdist"], + ) + if gnc: + exchange.gnc.initialize(filename=f"{name}.gnc", **gnc_gridprops) + return sim + + +# + +heads = {} +for name, gnc in [("uncorrected", False), ("gnc", True)]: + sim = build_simulation(name, gnc=gnc) + sim.write_simulation(silent=True) + success, buff = sim.run_simulation(silent=True) + assert success, f"{name} did not converge" + ws = workspace / name + heads[name] = ( + flopy.utils.HeadFile(ws / "parent.hds").get_data()[0], + flopy.utils.HeadFile(ws / "child.hds").get_data()[0], + ) + print(f"{name} converged") +# - + +# ## Effect of the correction +# +# The head varies linearly between the constant head columns, so the exact solution is known and the error of each model can be measured directly. + +# + +active = refine_mask[0] > 0 +print(f"{'variant':14s}{'parent max error':>18s}{'child max error':>17s}") +errors = {} +for name in ("uncorrected", "gnc"): + head_parent, head_child = heads[name] + parent_error = np.abs(head_parent - exact(parent))[active].max() + child_error = np.abs(head_child - exact(child)).max() + errors[name] = max(parent_error, child_error) + print(f"{name:14s}{parent_error:18.3e}{child_error:17.3e}") +# - + +# The correction removes the error the exchange introduces. What is left is at the level of the solver tolerance, so the linear head field is reproduced exactly once the ghost nodes are applied. + +assert errors["gnc"] < 1.0e-6 + +# The error in the uncorrected model is largest along the boundary of the child model, which is where the ghost nodes are. + +# + +error = np.abs(heads["uncorrected"][0] - exact(parent)) +error[~active] = np.nan +vmax = np.nanmax(error) + +fig, axes = plt.subplots(1, 2, figsize=(11, 5), constrained_layout=True) +for ax, name in zip(axes, ("uncorrected", "gnc")): + ax.set_aspect("equal") + err = np.abs(heads[name][0] - exact(parent)) + err[~active] = np.nan + pmv = flopy.plot.PlotMapView(modelgrid=parent, ax=ax) + cb = pmv.plot_array(err, cmap="magma_r", vmin=0.0, vmax=vmax) + pmv.plot_grid(colors="0.5", lw=0.3, alpha=0.5) + ax.set_title(f"{name}, parent model") +fig.colorbar(cb, ax=axes, shrink=0.7, label="absolute head error") +# - + +# Clean up the temporary workspace. + +try: + temp_dir.cleanup() +except (PermissionError, NotADirectoryError): + pass diff --git a/autotest/test_gnc.py b/autotest/test_gnc.py index db06980c4..9e21a019d 100644 --- a/autotest/test_gnc.py +++ b/autotest/test_gnc.py @@ -13,11 +13,14 @@ from modflow_devtools.markers import requires_exe, requires_pkg import flopy -from flopy.discretization import UnstructuredGrid, VertexGrid +from flopy.discretization import StructuredGrid, UnstructuredGrid, VertexGrid from flopy.utils.gnc import ( + _as_cellid, _check_gnc, + _node_to_cellid, get_gnc, get_gnc_dtype, + get_gnc_exchange, get_gridprops_gnc5, get_gridprops_gnc6, get_numalphaj, @@ -530,3 +533,229 @@ def test_fmt_string_separates_free_format_fields(): buff = io.StringIO() np.savetxt(buff, record, fmt=fmt_string(record, free=False), delimiter="") assert len(buff.getvalue().split()) < len(dtype.names), name + + +def lgr_pair(ncpp=3, nrowp=12, ncolp=12): + """A parent grid with a refined block in the middle""" + from flopy.utils.lgrutil import Lgr + + idomain = np.ones((1, nrowp, ncolp), dtype=int) + idomain[:, 4:8, 4:8] = 0 + return Lgr( + 1, + nrowp, + ncolp, + 1.0, + 1.0, + np.ones((nrowp, ncolp)), + np.zeros((1, nrowp, ncolp)), + idomain, + ncpp=ncpp, + ncppl=[1], + ) + + +def test_lgr_get_gnc_data(): + lgr = lgr_pair() + exchangedata = lgr.get_exchange_data() + gridprops = lgr.get_gnc_data() + + # MODFLOW 6 requires one ghost node record for every exchange record + assert gridprops["numgnc"] == len(exchangedata) + assert gridprops["numalphaj"] == 1 + + corrected = [rec for rec in gridprops["gncdata"] if rec[-1] != 0.0] + skipped = [rec for rec in gridprops["gncdata"] if rec[-1] == 0.0] + + # with three child cells per parent cell the middle one is centered on the + # face of the parent cell and needs no correction + assert len(corrected) == 2 * len(skipped) + assert np.allclose([rec[-1] for rec in corrected], 1.0 / 3.0) + + # a skipped record carries a cellid of zero, written by flopy as 0 0 0 + assert all(rec[2] == (-1, -1, -1) for rec in skipped) + + # the ghost node and the contributing cell are both in the parent model, + # and the connected cell is in the child model + for cellidn, cellidm, cellidj, _ in corrected: + assert cellidn != cellidj + assert lgr.refine_mask[cellidn] > 0 + assert lgr.refine_mask[cellidj] > 0 + + # the records follow the exchange records, cell for cell + for exchange, gnc in zip(exchangedata, gridprops["gncdata"]): + assert tuple(exchange[0]) == gnc[0] + assert tuple(exchange[1]) == gnc[1] + + +def test_lgr_get_gnc_data_even_refinement(): + """With an even refinement every child cell is offset from the parent""" + gridprops = lgr_pair(ncpp=2).get_gnc_data() + assert all(rec[-1] != 0.0 for rec in gridprops["gncdata"]) + assert np.allclose([rec[-1] for rec in gridprops["gncdata"]], 0.25) + + +@pytest.mark.slow +@requires_exe("mf6") +def test_lgr_gnc_exchange(function_tmpdir): + """The correction must remove the error on an exact linear head field""" + lgr = lgr_pair() + exchangedata = lgr.get_exchange_data(angldegx=True, cdist=True) + gnc_gridprops = lgr.get_gnc_data() + parent, child = lgr.parent, lgr.child + h_left, h_right = 1.0, 0.0 + + def exact(grid): + x = grid.xcellcenters + x0, x1 = parent.xcellcenters[0, 0], parent.xcellcenters[0, -1] + return h_left + (h_right - h_left) * (x - x0) / (x1 - x0) + + def run(name, gnc=False): + ws = function_tmpdir / name + sim = flopy.mf6.MFSimulation(sim_name="s", sim_ws=str(ws), exe_name="mf6") + flopy.mf6.ModflowTdis(sim) + flopy.mf6.ModflowIms( + sim, + linear_acceleration="bicgstab", + inner_maximum=1000, + inner_dvclose=1e-11, + outer_dvclose=1e-11, + ) + p = flopy.mf6.ModflowGwf(sim, modelname="parent") + flopy.mf6.ModflowGwfdis( + p, + nlay=1, + nrow=parent.nrow, + ncol=parent.ncol, + delr=1.0, + delc=1.0, + top=1.0, + botm=[0.0], + idomain=lgr.refine_mask, + ) + flopy.mf6.ModflowGwfic(p, strt=0.5) + flopy.mf6.ModflowGwfnpf(p, icelltype=0, k=1.0) + flopy.mf6.ModflowGwfchd( + p, + stress_period_data=[[(0, i, 0), h_left] for i in range(parent.nrow)] + + [[(0, i, parent.ncol - 1), h_right] for i in range(parent.nrow)], + ) + flopy.mf6.ModflowGwfoc( + p, head_filerecord="parent.hds", saverecord=[("HEAD", "ALL")] + ) + c = flopy.mf6.ModflowGwf(sim, modelname="child") + flopy.mf6.ModflowGwfdis( + c, + nlay=child.nlay, + nrow=child.nrow, + ncol=child.ncol, + delr=child.delr, + delc=child.delc, + top=1.0, + botm=[0.0], + xorigin=child.xoffset, + yorigin=child.yoffset, + ) + flopy.mf6.ModflowGwfic(c, strt=0.5) + flopy.mf6.ModflowGwfnpf(c, icelltype=0, k=1.0) + flopy.mf6.ModflowGwfoc( + c, head_filerecord="child.hds", saverecord=[("HEAD", "ALL")] + ) + exchange = flopy.mf6.ModflowGwfgwf( + sim, + exgtype="GWF6-GWF6", + nexg=len(exchangedata), + exgmnamea="parent", + exgmnameb="child", + exchangedata=exchangedata, + auxiliary=["angldegx", "cdist"], + ) + if gnc: + exchange.gnc.initialize(filename="s.gnc", **gnc_gridprops) + sim.write_simulation() + success, buff = sim.run_simulation(silent=True) + assert success, "\n".join(buff[-20:]) + return ( + flopy.utils.HeadFile(ws / "parent.hds").get_data()[0], + flopy.utils.HeadFile(ws / "child.hds").get_data()[0], + ) + + active = lgr.refine_mask[0] > 0 + errors = {} + for name, gnc in [("uncorrected", False), ("gnc", True)]: + head_parent, head_child = run(name, gnc=gnc) + errors[name] = max( + np.abs(head_parent - exact(parent))[active].max(), + np.abs(head_child - exact(child)).max(), + ) + print(f"{name} max error {errors[name]:.3e}") + + # the linear head field is exact once the correction is applied + assert errors["gnc"] < 1.0e-6 + assert errors["gnc"] < errors["uncorrected"] / 1000.0 + + +def test_lgr_get_gnc_data_without_idomain(): + """The idomain is taken from the grid when it is not supplied""" + lgr = lgr_pair() + supplied = lgr.get_gnc_data() + from_grid = get_gnc_exchange(lgr.parent, lgr.child, lgr.get_exchange_data()) + assert from_grid["numgnc"] == supplied["numgnc"] + assert from_grid["gncdata"] == supplied["gncdata"] + + +def test_lgr_get_gnc_data_no_contributing_cells(): + """A record is still written when no contributing cell is active""" + lgr = lgr_pair() + gridprops = get_gnc_exchange( + lgr.parent, + lgr.child, + lgr.get_exchange_data(), + idomain1=np.zeros_like(lgr.refine_mask), + ) + + # MODFLOW 6 needs one record per exchange and at least one contributing + # cell per record, even when none of them is used + assert gridprops["numgnc"] == len(lgr.get_exchange_data()) + assert gridprops["numalphaj"] == 1 + assert all(rec[2] == (-1, -1, -1) for rec in gridprops["gncdata"]) + assert all(rec[-1] == 0.0 for rec in gridprops["gncdata"]) + + +def test_lgr_get_gnc_data_numalphaj_too_small(): + lgr = lgr_pair() + with pytest.raises(ValueError, match="more than numalphaj"): + get_gnc_exchange(lgr.parent, lgr.child, lgr.get_exchange_data(), numalphaj=0) + + +def test_cellid_conversion_by_grid_type(): + """Cellids are built for the grid type, not only for a structured grid""" + structured = StructuredGrid( + delr=np.ones(3), + delc=np.ones(2), + top=np.ones((2, 3)), + botm=np.zeros((2, 2, 3)), + ) + vertex = VertexGrid( + vertices=[[0, 0.0, 0.0], [1, 1.0, 0.0], [2, 1.0, 1.0], [3, 0.0, 1.0]], + cell2d=[[i, 0.5, 0.5, 4, 0, 1, 2, 3] for i in range(4)], + nlay=2, + top=np.ones(4), + botm=np.zeros((2, 4)), + ) + unstructured, _ = synthetic_grid() + + assert _node_to_cellid(structured, 4) == (0, 1, 1) + assert _node_to_cellid(vertex, 4) == (1, 0) + assert _node_to_cellid(unstructured, 4) == (4,) + + # every cellid must map back to the node it came from + for grid in (structured, vertex, unstructured): + for node in range(grid.nnodes): + cellid = _node_to_cellid(grid, node) + assert int(grid.get_node([cellid])[0]) == node + + # a cellid may be given as a bare node number + assert _as_cellid(5) == (5,) + assert _as_cellid((0, 1, 2)) == (0, 1, 2) + assert _as_cellid(np.array([0, 1, 2])) == (0, 1, 2) diff --git a/flopy/utils/__init__.py b/flopy/utils/__init__.py index 9cd1d97ff..596a16609 100644 --- a/flopy/utils/__init__.py +++ b/flopy/utils/__init__.py @@ -29,7 +29,12 @@ from .formattedfile import FormattedHeadFile get_modflow = get_modflow_module.run_main -from .gnc import get_gnc, get_gridprops_gnc5, get_gridprops_gnc6 +from .gnc import ( + get_gnc, + get_gnc_exchange, + get_gridprops_gnc5, + get_gridprops_gnc6, +) from .gridintersect import GridIntersect from .hfb_util import make_hfb_array from .mflistfile import ( diff --git a/flopy/utils/gnc.py b/flopy/utils/gnc.py index 099f59ef9..3ea63d171 100644 --- a/flopy/utils/gnc.py +++ b/flopy/utils/gnc.py @@ -481,3 +481,149 @@ def get_gridprops_gnc5(gnc, i2kn=0, isymgncn=0, ia=None, ja=None, iac=None, chec "iflalphan": iflalphan, "gncdata": gncdata, } + + +def _as_cellid(cellid): + """Return a cellid as a tuple, accepting a bare node number""" + if np.isscalar(cellid): + return (int(cellid),) + return tuple(int(v) for v in cellid) + + +def _node_to_cellid(modelgrid, node): + """Return the cellid of a node number for any grid type""" + node = int(node) + if hasattr(modelgrid, "get_lrc"): + # a structured grid addresses a cell by layer, row, and column + return tuple(int(v) for v in modelgrid.get_lrc([node])[0]) + ncpl = modelgrid.ncpl + if np.isscalar(ncpl): + # a vertex grid addresses a cell by layer and cell2d number + return (node // int(ncpl), node % int(ncpl)) + # an unstructured grid addresses a cell by node number + return (node,) + + +def get_gnc_exchange( + modelgrid1, + modelgrid2, + exchangedata, + idomain1=None, + numalphaj=None, + rtol=1.0e-6, +): + """ + Compute ghost node correction data for a GWF-GWF exchange + + A ghost node is added in the cell of model 1 for every horizontal exchange + connection to a finer cell of model 2 whose center is offset transverse to + the connection. The head at the ghost node is interpolated between the + cell of model 1 and its own neighbors, so model 1 must be the coarser of + the two models. + + Parameters + ---------- + modelgrid1 : flopy.discretization.Grid + Grid of the first model of the exchange, which holds the ghost nodes + and the contributing cells + modelgrid2 : flopy.discretization.Grid + Grid of the second model of the exchange + exchangedata : list + Exchange records, each starting with cellidm1, cellidm2, and ihc, as + returned by :meth:`flopy.utils.lgrutil.Lgr.get_exchange_data` + idomain1 : array_like + Idomain of the first model, used to skip inactive contributing cells. + Taken from modelgrid1 if None. + numalphaj : int + Number of contributing cells written per ghost node. The largest + number found is used if None. + rtol : float + Relative tolerance used to decide whether the center of the cell of + model 2 is offset from the center of the cell of model 1 (default is + 1.0e-6). + + Returns + ------- + gridprops : dict + Dictionary with numgnc, numalphaj, and gncdata, which can be unpacked + into the gnc package of a ModflowGwfgwf exchange. + + Notes + ----- + MODFLOW 6 requires one ghost node record for every exchange record, in the + same order, so connections that need no correction are written with a + cellid of zero and a contributing factor of zero. Only horizontal + corrections are computed. + + """ + if idomain1 is None: + idomain1 = getattr(modelgrid1, "idomain", None) + + xc1, yc1 = _node_centers(modelgrid1) + xc2, yc2 = _node_centers(modelgrid2) + area1, area2 = _cell_areas(modelgrid1), _cell_areas(modelgrid2) + active1 = None if idomain1 is None else np.asarray(idomain1).ravel() > 0 + neighbors = modelgrid1.neighbors(method="rook") + + # the contributing cells of a cell are the same for every exchange record + # that uses it, so the offsets to its neighbors are built once + cache = {} + + def offsets(node): + if node not in cache: + conn = np.array(sorted(neighbors.get(node, [])), dtype=int) + if active1 is not None and conn.size: + conn = conn[active1[conn]] + if conn.size == 0: + cache[node] = (conn, None, None) + else: + dn = np.column_stack((xc1[conn] - xc1[node], yc1[conn] - yc1[node])) + cache[node] = (conn, dn, np.argmax(np.abs(dn), axis=1)) + return cache[node] + + records = [] + for rec in exchangedata: + cellidn, cellidm, ihc = rec[0], rec[1], rec[2] + n = int(modelgrid1.get_node([_as_cellid(cellidn)])[0]) + m = int(modelgrid2.get_node([_as_cellid(cellidm)])[0]) + + js, alpha = np.array([], dtype=int), 0.0 + if ihc != 0 and area2[m] < area1[n]: + conn, dn, naxis = offsets(n) + if dn is not None: + d_nm = np.array([xc2[m] - xc1[n], yc2[m] - yc1[n]]) + sel, total = _contributing_cells(dn, naxis, d_nm, rtol) + if sel is not None: + js, alpha = conn[sel], total / sel.sum() + records.append((cellidn, cellidm, js, alpha)) + + if numalphaj is None: + # every exchange gets a record, so a connection that needs no + # correction contributes a length of zero; MODFLOW 6 still needs at + # least one contributing cell per record + numalphaj = max(max((len(rec[2]) for rec in records), default=1), 1) + + # a cellid of -1 in every dimension is written as a cellid of zero + none_cellid = tuple([-1] * len(_node_to_cellid(modelgrid1, 0))) + + gncdata = [] + for irec, (cellidn, cellidm, js, alpha) in enumerate(records): + if len(js) > numalphaj: + raise ValueError( + f"gnc record {irec}: cell {cellidn} has {len(js)} contributing " + f"cells, which is more than numalphaj of {numalphaj}" + ) + cellids = [_node_to_cellid(modelgrid1, j) for j in js] + alphas = [float(alpha)] * len(js) + # pad with a cellid of zero, which MODFLOW 6 skips + cellids += [none_cellid] * (numalphaj - len(js)) + alphas += [0.0] * (numalphaj - len(js)) + gncdata.append( + tuple([_as_cellid(cellidn), _as_cellid(cellidm)] + cellids + alphas) + ) + + return { + "numgnc": len(gncdata), + "numalphaj": numalphaj, + "gncdata": gncdata, + } diff --git a/flopy/utils/lgrutil.py b/flopy/utils/lgrutil.py index 496defa1c..d0981ebbb 100644 --- a/flopy/utils/lgrutil.py +++ b/flopy/utils/lgrutil.py @@ -5,6 +5,7 @@ from ..discretization import StructuredGrid from ..modflow import Modflow from .cvfdutil import get_disv_gridprops, gridlist_to_verts +from .gnc import get_gnc_exchange from .util_array import Util2d, Util3d @@ -720,6 +721,40 @@ def child(self): ) return simple_regular_grid + def get_gnc_data(self, numalphaj=None): + """ + Get a dictionary of information needed to create the GNC package of + the GWF-GWF exchange between the parent and child models. The + returned dictionary can be unpacked directly into the gnc package of + the exchange. + + Parameters + ---------- + numalphaj : int + Number of contributing cells written per ghost node. The largest + number found is used if None. + + Returns + ------- + gridprops : dict + + Notes + ----- + The ghost nodes are in the parent model, which must be the first model + of the exchange. MODFLOW 6 requires one record for every exchange + record, so connections that need no correction, such as a child cell + centered on the face of a parent cell, are written with a cellid of + zero and a contributing factor of zero. + + """ + return get_gnc_exchange( + self.parent, + self.child, + self.get_exchange_data(), + idomain1=self.refine_mask, + numalphaj=numalphaj, + ) + def to_disv_gridprops(self): """ Create and return a gridprops dictionary that can be From 39aaa73fa9a6d9fd87483b46cee8915c3e741f5e Mon Sep 17 00:00:00 2001 From: Joshua Larsen Date: Wed, 5 Aug 2026 08:57:29 -0700 Subject: [PATCH 57/90] update(PlotCrossSection): add additional flexibility to cross sectional plotting (#2782) * update(PlotCrossSection): add additional flexibility to cross sectional plotting * new feature: view, users can optionally provide `view="x"` or `view="y"` to force the view angle of the cross section * add in initial and end line points to the intersection routine which can include an additional cell on each side of cross section * add uint and improve mulit-band raster support for Rasters --- flopy/plot/crosssection.py | 14 +++++++++++++- flopy/plot/plotutil.py | 9 +++++++++ flopy/utils/rasters.py | 20 ++++++++++++++------ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/flopy/plot/crosssection.py b/flopy/plot/crosssection.py index e50ad5ed8..5ae25e518 100644 --- a/flopy/plot/crosssection.py +++ b/flopy/plot/crosssection.py @@ -51,6 +51,13 @@ class PlotCrossSection: minimum width of a grid cell polygon to be plotted. Cells with a cross-sectional width less than min_segment_length will be ignored and not included in the plot. Default is 1e-02. + view : str + view can be used to force the view of the cross section when a line is provided. + "auto" is default and mimics the long term behavior of flopy, which decides + on the view by taking the maximum of the x and y direction of the cross + sectional line. "x" forces the view to be plotted from the x direction + (bottom of the unrotated grid). and "y" forces the view to be plotted from the + y-direction "left" or "right" side of the unrotated grid. """ def __init__( @@ -62,7 +69,9 @@ def __init__( extent=None, geographic_coords=False, min_segment_length=1e-02, + view="auto", ): + view = view.lower() self.ax = ax self.geographic_coords = geographic_coords self.model = model @@ -159,7 +168,10 @@ def __init__( yp.append(v2) xp, yp = self.mg.get_local_coords(xp, yp) - if np.max(xp) - np.min(xp) > np.max(yp) - np.min(yp): + if (np.max(xp) - np.min(xp) > np.max(yp) - np.min(yp)) or view not in ( + "auto", + "y", + ): # this is x-projection and we should buffer x by small amount idx0 = np.argmax(xp) idx1 = np.argmin(xp) diff --git a/flopy/plot/plotutil.py b/flopy/plot/plotutil.py index f79b13321..23f6d00f3 100644 --- a/flopy/plot/plotutil.py +++ b/flopy/plot/plotutil.py @@ -1685,6 +1685,15 @@ def line_intersect_grid(ptsin, xgrid, ygrid): if t: vdict[cell] = t + # check if line vertex 0 or -1 should be included in the crosssection + ptschk = [ptsin[0], ptsin[-1]] + for cell, vrts in vdict.items(): + if len(vrts) == 1: + for pt in ptschk: + if np.min(xgrid[cell]) < pt[0] < np.max(xgrid[cell]): + if np.min(ygrid[cell]) < pt[1] < np.max(ygrid[cell]): + vdict[cell] = [vrts[0], tuple(pt)] + return vdict @staticmethod diff --git a/flopy/utils/rasters.py b/flopy/utils/rasters.py index c2c72a596..10489e308 100644 --- a/flopy/utils/rasters.py +++ b/flopy/utils/rasters.py @@ -81,7 +81,10 @@ def __init__( elif array.dtype in Raster.FLOAT64: dtype = "float64" elif array.dtype in Raster.INT8: - dtype = "int8" + if np.max(array) > 127: + dtype = "uint8" + else: + dtype = "int8" elif array.dtype in Raster.INT16: dtype = "int16" elif array.dtype in Raster.INT32: @@ -996,6 +999,7 @@ def plot(self, ax=None, contour=False, **kwargs): import_optional_dependency("rasterio") from rasterio.plot import show + band = kwargs.pop("band", None) # rasterio >= 1.5 defaults to adjusting data to [0, 1], which # can clash with user-supplied vmin/vmax. don't adjust unless # the caller explicitly asks for it @@ -1013,11 +1017,15 @@ def plot(self, ax=None, contour=False, **kwargs): if d1 is None: raise AssertionError("No plottable arrays found") - data = np.zeros((d0, d1, d2), dtype=float) - i = 0 - for _, arr in sorted(self.__arr_dict.items()): - data[i, :, :] = arr - i += 1 + if band is not None: + data = np.zeros((1, d1, d2), dtype=float) + data[0] = self.__arr_dict[band] + else: + data = np.zeros((d0, d1, d2), dtype=float) + i = 0 + for _, arr in sorted(self.__arr_dict.items()): + data[i, :, :] = arr + i += 1 data = np.ma.masked_where(data == self.nodatavals, data) ax = show( From 8e8feaf93744bf7004b7d7b8185439f8c97f323f Mon Sep 17 00:00:00 2001 From: afc98 <58609280+afc98@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:26:45 +0100 Subject: [PATCH 58/90] feat(lakpak_utils): add support for VertexGrid embedded lakes (#2786) * feat(get_lak_connections): add support for embedded disv modelgrids Added a __vertex_lake_connections function that provides the same outputs as __structured_lake_connections but for vertex grids. * test: tests for get_lak_connections with vertex grids * fix(lakpak_utils): correct embedded DISV lake connection geometry * test(lakpak_utils): cover embedded DISV connection geometry * perf(lakpak_utils): cache vertex grid geometry for lake connections Calculate elevation, vertex, and cell-center arrays once when generating embedded DISV lake connections. Reuse cached vertex indices to find shared edges instead of repeatedly rebuilding VertexGrid geometry for every neighboring cell. Add regression coverage to ensure expensive grid properties are accessed only once. * fix(lakpak_utils): handle split DISV lake boundaries Build lake connections from shared edges so boundaries split by hanging vertices retain complete widths and distances. Warn when an embedded lake has no active-cell connections instead of silently emitting zero connections. Expand DIS/DISV equivalence and MODFLOW 6 result tests to cover unstructured geometry, inactive cells, adjacent lakes, and multilayer cases. * style(lakpak_utils): lowercase warning message --------- Co-authored-by: Arran Clarke Co-authored-by: Joseph Hughes --- autotest/test_lake_connections.py | 631 +++++++++++++++++++++++++++++- flopy/mf6/utils/lakpak_utils.py | 175 +++++++-- 2 files changed, 783 insertions(+), 23 deletions(-) diff --git a/autotest/test_lake_connections.py b/autotest/test_lake_connections.py index 86b44f58a..b0250f651 100644 --- a/autotest/test_lake_connections.py +++ b/autotest/test_lake_connections.py @@ -4,12 +4,14 @@ import pytest from modflow_devtools.markers import requires_exe, requires_pkg -from flopy.discretization import StructuredGrid +from flopy.discretization import StructuredGrid, VertexGrid +from flopy.discretization.grid import Grid from flopy.mf6 import ( MFSimulation, ModflowGwf, ModflowGwfchd, ModflowGwfdis, + ModflowGwfdisv, ModflowGwfevta, ModflowGwfic, ModflowGwflak, @@ -623,3 +625,630 @@ def test_embedded_lak_prudic_mixed(example_data_path): assert bedleak == "none", f"bedleak for lake 0 is not 'none' ({bedleak})" else: assert bedleak == 1.0, f"bedleak for lake 1 is not 1.0 ({bedleak})" + + +def build_simple_disv_grid(nlay=1, return_data=False): + vertices = [ + (0, 0.0, 0.0), + (1, 1.0, 0.0), + (2, 2.0, 0.0), + (3, 3.0, 0.0), + (4, 0.0, 1.0), + (5, 1.0, 1.0), + (6, 2.0, 1.0), + (7, 3.0, 1.0), + (8, 0.0, 2.0), + (9, 1.0, 2.0), + (10, 2.0, 2.0), + (11, 3.0, 2.0), + (12, 1.0, 3.0), + (13, 2.0, 3.0), + ] + + cell2d = [ + (0, 1.5, 1.5, 4, 5, 6, 10, 9), + (1, 1.5, 2.5, 4, 9, 10, 13, 12), + (2, 0.5, 1.5, 4, 4, 5, 9, 8), + (3, 2.5, 1.5, 4, 6, 7, 11, 10), + (4, 1.5, 0.5, 4, 1, 2, 6, 5), + ] + + ncpl = len(cell2d) + + top = np.full(ncpl, 1.0) + botm = np.vstack([np.full(ncpl, -(k + 1), dtype=float) for k in range(nlay)]) + + idomain = np.ones((nlay, ncpl), dtype=int) + + grid = VertexGrid( + vertices=vertices, + cell2d=cell2d, + top=top, + botm=botm, + idomain=idomain, + nlay=nlay, + ) + + if return_data: + return grid, vertices, cell2d + + return grid + + +def build_asymmetric_disv_grid(): + vertices = [ + (0, 0.0, 0.0), + (1, 1.0, 0.0), + (2, 4.0, 0.0), + (3, 0.0, 1.0), + (4, 1.0, 1.0), + (5, 4.0, 1.0), + ] + cell2d = [ + (0, 0.5, 0.5, 4, 0, 1, 4, 3), + (1, 2.5, 0.5, 4, 1, 2, 5, 4), + ] + + return VertexGrid( + vertices=vertices, + cell2d=cell2d, + top=np.ones(2), + botm=-np.ones((1, 2)), + idomain=np.ones((1, 2), dtype=int), + nlay=1, + ) + + +def test_disv_horizontal_connections(): + modelgrid = build_simple_disv_grid() + + lake_map = np.full((1, modelgrid.ncpl), -1, dtype=int) + lake_map[0, 0] = 0 + + idomain = np.ones((1, modelgrid.ncpl), dtype=int) + + _, pakdata, connectiondata = get_lak_connections( + modelgrid, + lake_map, + idomain=idomain, + bedleak=1.0, + ) + + assert pakdata[0] == 4 + + expected = { + (0, 1), + (0, 2), + (0, 3), + (0, 4), + } + + returned = {c[2] for c in connectiondata} + + assert returned == expected + + assert all(c[3] == "horizontal" for c in connectiondata) + + +def test_disv_connection_widths(): + modelgrid = build_simple_disv_grid() + + lake_map = np.full((1, modelgrid.ncpl), -1, dtype=int) + lake_map[0, 0] = 0 + + _, _, connectiondata = get_lak_connections( + modelgrid, + lake_map, + bedleak=1.0, + ) + + horizontal = [c for c in connectiondata if c[3] == "horizontal"] + + assert len(horizontal) == 4 + + widths = sorted(conn[8] for conn in horizontal) + assert widths == pytest.approx([1.0, 1.0, 1.0, 1.0]) + + +def test_disv_connection_lengths(): + modelgrid = build_simple_disv_grid() + + lake_map = np.full((1, modelgrid.ncpl), -1, dtype=int) + lake_map[0, 0] = 0 + + _, _, connectiondata = get_lak_connections( + modelgrid, + lake_map, + bedleak=1.0, + ) + + horizontal = [c for c in connectiondata if c[3] == "horizontal"] + + assert len(horizontal) == 4 + + lengths = sorted(conn[7] for conn in horizontal) + + assert lengths == pytest.approx([0.5, 0.5, 0.5, 0.5]) + + +def test_disv_connection_length_uses_aquifer_cell_center(): + modelgrid = build_asymmetric_disv_grid() + + lake_map = np.full((1, modelgrid.ncpl), -1, dtype=int) + lake_map[0, 0] = 0 + + _, _, connectiondata = get_lak_connections( + modelgrid, + lake_map, + bedleak=1.0, + ) + + assert len(connectiondata) == 1 + assert connectiondata[0][2] == (0, 1) + assert connectiondata[0][7] == pytest.approx(1.5) + + +def test_disv_vertical_connection(): + modelgrid = build_simple_disv_grid(nlay=2) + + lake_map = np.full((2, modelgrid.ncpl), -1, dtype=int) + lake_map[0, 0] = 0 + + _, pakdata, connectiondata = get_lak_connections( + modelgrid, + lake_map, + bedleak=1.0, + ) + + assert pakdata[0] == 5 + + horizontal = [c for c in connectiondata if c[3] == "horizontal"] + vertical = [c for c in connectiondata if c[3] == "vertical"] + + assert len(horizontal) == 4 + assert len(vertical) == 1 + + vconn = vertical[0] + + assert vconn[2] == (1, 0) + assert vconn[5:] == [0.0, 0.0, 0.0, 0.0] + + +def test_disv_horizontal_connections_in_nonzero_layer(): + modelgrid = build_simple_disv_grid(nlay=2) + + lake_map = np.full((2, modelgrid.ncpl), -1, dtype=int) + lake_map[1, 0] = 0 + + _, pakdata, connectiondata = get_lak_connections( + modelgrid, + lake_map, + bedleak=1.0, + ) + + assert pakdata[0] == 4 + assert {conn[2] for conn in connectiondata} == { + (1, 1), + (1, 2), + (1, 3), + (1, 4), + } + assert all(conn[3] == "horizontal" for conn in connectiondata) + + +def test_disv_inactive_neighbor(): + modelgrid = build_simple_disv_grid() + + lake_map = np.full((1, modelgrid.ncpl), -1, dtype=int) + lake_map[0, 0] = 0 + + idomain = np.ones((1, modelgrid.ncpl), dtype=int) + + # Make the east neighbour inactive + idomain[0, 3] = 0 + + _, pakdata, connectiondata = get_lak_connections( + modelgrid, + lake_map, + idomain=idomain, + bedleak=1.0, + ) + + assert pakdata[0] == 3 + + expected = { + (0, 1), # north + (0, 2), # west + (0, 4), # south + } + + returned = {c[2] for c in connectiondata} + + assert returned == expected + assert all(c[3] == "horizontal" for c in connectiondata) + + +def test_disv_idomain_update(): + modelgrid = build_simple_disv_grid() + + lake_map = np.full((1, modelgrid.ncpl), -1, dtype=int) + lake_map[0, 0] = 0 + + idomain = np.ones((1, modelgrid.ncpl), dtype=int) + + idomain_out, _, _ = get_lak_connections( + modelgrid, + lake_map, + idomain=idomain, + bedleak=1.0, + ) + + expected = np.ones((1, modelgrid.ncpl), dtype=int) + expected[0, 0] = 0 + + assert np.array_equal(idomain_out, expected) + + +def test_disv_grid_geometry_accessed_once(monkeypatch): + modelgrid = build_simple_disv_grid(nlay=2) + lake_map = np.full((2, modelgrid.ncpl), -1, dtype=int) + lake_map[0, :2] = 0 + + # Build neighbor topology before counting geometry accesses. This is cached + # independently by Grid.neighbors(). + modelgrid.neighbors(method="rook") + + properties = { + "top_botm": VertexGrid, + "verts": VertexGrid, + "iverts": VertexGrid, + "xcellcenters": Grid, + "ycellcenters": Grid, + } + originals = {name: getattr(owner, name) for name, owner in properties.items()} + access_count = dict.fromkeys(properties, 0) + + def counted_property(name): + def getter(grid): + access_count[name] += 1 + return originals[name].fget(grid) + + return property(getter) + + for name, owner in properties.items(): + monkeypatch.setattr(owner, name, counted_property(name)) + + get_lak_connections(modelgrid, lake_map, bedleak=1.0) + + assert all(count <= 1 for count in access_count.values()) + + +def test_disv_shared_boundary_split_by_vertex(): + # vertex 2 splits the common boundary into two shared edges + vertices = [ + (0, 0.0, 0.0), + (1, 1.0, 0.0), + (2, 1.0, 1.0), + (3, 1.0, 2.0), + (4, 0.0, 2.0), + (5, 2.0, 0.0), + (6, 2.0, 2.0), + ] + cell2d = [ + (0, 0.5, 1.0, 5, 0, 1, 2, 3, 4), + (1, 1.5, 1.0, 5, 1, 5, 6, 3, 2), + ] + modelgrid = VertexGrid( + vertices=vertices, + cell2d=cell2d, + top=np.ones(2), + botm=-np.ones((1, 2)), + idomain=np.ones((1, 2), dtype=int), + nlay=1, + ) + + idomain, pakdata, connectiondata = get_lak_connections( + modelgrid, + np.array([[0, -1]], dtype=int), + bedleak=1.0, + ) + + assert pakdata[0] == 1 + assert len(connectiondata) == 1 + conn = connectiondata[0] + assert conn[2] == (0, 1) + assert conn[3] == "horizontal" + assert conn[7] == pytest.approx(0.5) + assert conn[8] == pytest.approx(2.0) + assert np.array_equal(idomain, np.array([[0, 1]])) + + +def test_disv_lake_without_connections_warns(): + # Only the lake cell carries the hanging vertex, so rook adjacency is absent. + vertices = [ + (0, 0.0, 0.0), + (1, 1.0, 0.0), + (2, 1.0, 1.0), + (3, 1.0, 2.0), + (4, 0.0, 2.0), + (5, 2.0, 0.0), + (6, 2.0, 2.0), + ] + cell2d = [ + (0, 0.5, 1.0, 5, 0, 1, 2, 3, 4), + (1, 1.5, 1.0, 4, 1, 5, 6, 3), + ] + modelgrid = VertexGrid( + vertices=vertices, + cell2d=cell2d, + top=np.ones(2), + botm=-np.ones((1, 2)), + idomain=np.ones((1, 2), dtype=int), + nlay=1, + ) + + with pytest.warns(UserWarning, match="embedded lake 0 has no connections"): + idomain, pakdata, connectiondata = get_lak_connections( + modelgrid, + np.array([[0, -1]], dtype=int), + bedleak=1.0, + ) + + assert pakdata[0] == 0 + assert connectiondata == [] + assert np.array_equal(idomain, np.ones((1, 2), dtype=int)) + + +@pytest.mark.parametrize("closed", (False, True)) +def test_disv_nonrectangular_cells(closed): + vertices = [ + (0, 0.0, 0.0), + (1, 1.0, 0.0), + (2, 1.0, 1.0), + (3, 2.0, 0.0), + ] + if closed: + cell2d = [ + (0, 2.0 / 3.0, 1.0 / 3.0, 4, 0, 1, 2, 0), + (1, 4.0 / 3.0, 1.0 / 3.0, 4, 1, 3, 2, 1), + ] + else: + cell2d = [ + (0, 2.0 / 3.0, 1.0 / 3.0, 3, 0, 1, 2), + (1, 4.0 / 3.0, 1.0 / 3.0, 3, 1, 3, 2), + ] + modelgrid = VertexGrid( + vertices=vertices, + cell2d=cell2d, + top=np.ones(2), + botm=-np.ones((1, 2)), + idomain=np.ones((1, 2), dtype=int), + nlay=1, + ) + + _, pakdata, connectiondata = get_lak_connections( + modelgrid, + np.array([[0, -1]], dtype=int), + bedleak=1.0, + ) + + assert pakdata[0] == 1 + assert connectiondata[0][7] == pytest.approx(1.0 / 3.0) + assert connectiondata[0][8] == pytest.approx(1.0) + + +def build_dis_and_equivalent_disv(nlay, nrow, ncol, delr, delc, top, botm): + structured = StructuredGrid( + delr=delr, + delc=delc, + top=top, + botm=botm, + idomain=np.ones((nlay, nrow, ncol), dtype=int), + nlay=nlay, + ) + + xv = np.concatenate(([0.0], np.cumsum(delr))) + yv = delc.sum() - np.concatenate(([0.0], np.cumsum(delc))) + vertices = [] + ivert = {} + for i in range(nrow + 1): + for j in range(ncol + 1): + ivert[(i, j)] = len(vertices) + vertices.append((len(vertices), float(xv[j]), float(yv[i]))) + + cell2d = [] + for i in range(nrow): + for j in range(ncol): + cell2d.append( + ( + i * ncol + j, + 0.5 * (xv[j] + xv[j + 1]), + 0.5 * (yv[i] + yv[i + 1]), + 4, + ivert[(i, j)], + ivert[(i, j + 1)], + ivert[(i + 1, j + 1)], + ivert[(i + 1, j)], + ) + ) + + ncpl = nrow * ncol + vertex = VertexGrid( + vertices=vertices, + cell2d=cell2d, + top=top.flatten(), + botm=botm.reshape(nlay, ncpl), + idomain=np.ones((nlay, ncpl), dtype=int), + nlay=nlay, + ) + return structured, vertex, vertices, cell2d + + +@pytest.mark.parametrize( + "lakes, inactive", + ( + ([[(0, 2, 2)]], []), + ([[(0, 1, 1), (0, 1, 2), (0, 2, 1), (0, 2, 2)]], []), + ([[(0, 0, 0)]], []), + ([[(0, 0, 2)]], []), + ([[(1, 2, 2)]], []), + ([[(0, 2, 2), (1, 2, 2)]], []), + ([[(0, 2, 2)]], [(0, 2, 3)]), + ([[(0, 2, 1)], [(0, 2, 2)]], []), + ([[(0, 1, 1)], [(0, 3, 3)]], []), + ([[(0, 2, 2), (0, 2, 3)]], [(0, 2, 2)]), + ), +) +def test_disv_matches_dis_embedded_lake(lakes, inactive): + nlay, nrow, ncol = 2, 5, 5 + delr = np.array([10.0, 20.0, 30.0, 20.0, 10.0]) + delc = np.array([5.0, 15.0, 25.0, 15.0, 5.0]) + top = np.full((nrow, ncol), 10.0) + botm = np.array([np.zeros((nrow, ncol)), np.full((nrow, ncol), -10.0)]) + ncpl = nrow * ncol + structured, vertex, _, _ = build_dis_and_equivalent_disv( + nlay, nrow, ncol, delr, delc, top, botm + ) + + lake_map = np.full((nlay, nrow, ncol), -1, dtype=int) + for lake_number, cells in enumerate(lakes): + for cell in cells: + lake_map[cell] = lake_number + idomain = np.ones((nlay, nrow, ncol), dtype=int) + for cell in inactive: + idomain[cell] = 0 + + dis_idomain, dis_pakdata, dis_conn = get_lak_connections( + structured, lake_map.copy(), idomain=idomain.copy(), bedleak=1.0 + ) + disv_idomain, disv_pakdata, disv_conn = get_lak_connections( + vertex, + lake_map.reshape(nlay, ncpl).copy(), + idomain=idomain.reshape(nlay, ncpl).copy(), + bedleak=1.0, + ) + + def normalize(conn): + lake_number, _, cellid, claktype, _, _, _, connlen, connwidth = conn + if len(cellid) == 3: + k, i, j = cellid + cellid = (k, i * ncol + j) + return ( + lake_number, + *cellid, + claktype, + round(connlen, 6), + round(connwidth, 6), + ) + + assert dis_pakdata == disv_pakdata + assert sorted(map(normalize, dis_conn)) == sorted(map(normalize, disv_conn)) + assert np.array_equal(dis_idomain.reshape(nlay, ncpl), disv_idomain) + + +@requires_exe("mf6") +def test_disv_lake_matches_dis_run(function_tmpdir): + nlay, nrow, ncol = 1, 3, 3 + delr = np.array([1.0, 1.5, 2.0]) + delc = np.array([2.0, 1.5, 1.0]) + top = np.ones((nrow, ncol)) + botm = np.zeros((nlay, nrow, ncol)) + structured, vertex, vertices, cell2d = build_dis_and_equivalent_disv( + nlay, nrow, ncol, delr, delc, top, botm + ) + + def build_and_run(grid_type): + name = f"{grid_type}_lake_test" + modelgrid = structured if grid_type == "dis" else vertex + lake_map = np.full(modelgrid.shape, -1, dtype=int) + lake_map[(0, 1, 1) if grid_type == "dis" else (0, 4)] = 0 + idomain, pakdata_dict, connectiondata = get_lak_connections( + modelgrid, + lake_map, + bedleak=1.0, + ) + assert pakdata_dict[0] == 4 + + sim = MFSimulation( + sim_name=name, + sim_ws=function_tmpdir / grid_type, + exe_name="mf6", + ) + ModflowTdis(sim, nper=1, perioddata=[(1.0, 1, 1.0)]) + ModflowIms( + sim, + print_option="summary", + linear_acceleration="BICGSTAB", + outer_dvclose=1e-9, + inner_dvclose=1e-10, + ) + gwf = ModflowGwf( + sim, + modelname=name, + newtonoptions="newton under_relaxation", + save_flows=True, + ) + if grid_type == "dis": + ModflowGwfdis( + gwf, + nlay=nlay, + nrow=nrow, + ncol=ncol, + delr=delr, + delc=delc, + top=top, + botm=botm, + idomain=idomain, + ) + else: + ModflowGwfdisv( + gwf, + nlay=nlay, + ncpl=nrow * ncol, + top=top.flatten(), + botm=botm.reshape(nlay, nrow * ncol), + vertices=vertices, + cell2d=cell2d, + idomain=idomain, + ) + ModflowGwfic(gwf, strt=0.5) + ModflowGwfnpf(gwf, icelltype=1, k=1.0) + + chd_spd = [] + for i in range(nrow): + if grid_type == "dis": + chd_spd.extend([((0, i, 0), 0.75), ((0, i, 2), 0.25)]) + else: + chd_spd.extend([((0, i * ncol), 0.75), ((0, i * ncol + 2), 0.25)]) + ModflowGwfchd(gwf, stress_period_data=chd_spd) + + lak = ModflowGwflak( + gwf, + print_stage=True, + stage_filerecord=f"{name}.lak.stage.bin", + nlakes=1, + packagedata=[[0, 0.5, pakdata_dict[0]]], + connectiondata=connectiondata, + perioddata={0: [[0, "rainfall", 0.01]]}, + pname="LAK-1", + ) + ModflowGwfoc( + gwf, + head_filerecord=f"{name}.hds", + saverecord=[("HEAD", "ALL")], + printrecord=[("HEAD", "ALL"), ("BUDGET", "ALL")], + ) + + sim.write_simulation() + success, _ = sim.run_simulation(silent=False) + assert success, f"could not run {sim.name}" + + heads = gwf.output.head().get_data().reshape(nlay, nrow, ncol) + stage = lak.output.stage().get_data() + return heads, stage + + dis_heads, dis_stage = build_and_run("dis") + disv_heads, disv_stage = build_and_run("disv") + + assert np.allclose(dis_heads, disv_heads, rtol=1e-6, atol=1e-6) + assert np.allclose(dis_stage, disv_stage, rtol=1e-6, atol=1e-6) diff --git a/flopy/mf6/utils/lakpak_utils.py b/flopy/mf6/utils/lakpak_utils.py index 94b52a520..0a3ab3fa3 100644 --- a/flopy/mf6/utils/lakpak_utils.py +++ b/flopy/mf6/utils/lakpak_utils.py @@ -1,3 +1,5 @@ +import warnings + import numpy as np @@ -9,8 +11,6 @@ def get_lak_connections(modelgrid, lake_map, idomain=None, bedleak=None): and are vertically connected to cells at the top of the model. Otherwise the lakes are embedded in the grid. - TODO: implement embedded lakes for VertexGrid - TODO: add support for UnstructuredGrid Parameters @@ -47,12 +47,15 @@ def get_lak_connections(modelgrid, lake_map, idomain=None, bedleak=None): connectiondata : list of lists connectiondata block for the lake package + Warns + ----- + UserWarning + If an embedded lake has no connections to active model cells. + """ if modelgrid.grid_type in ("unstructured",): - raise ValueError( - "unstructured grids not supported in get_lak_connections()" - ) + raise ValueError("unstructured grids not supported in get_lak_connections()") embedded = True shape3d = modelgrid.shape @@ -62,9 +65,7 @@ def get_lak_connections(modelgrid, lake_map, idomain=None, bedleak=None): if isinstance(lake_map, (list, tuple)): lake_map = np.array(lake_map, dtype=np.int32) elif isinstance(lake_map, (int, float)): - raise TypeError( - "lake_map must be a Masked Array, ndarray, list, or tuple" - ) + raise TypeError("lake_map must be a Masked Array, ndarray, list, or tuple") # evaluate lake_map shape shape_map = lake_map.shape @@ -87,9 +88,7 @@ def get_lak_connections(modelgrid, lake_map, idomain=None, bedleak=None): # check dimensions of idomain if idomain.shape != shape3d: - raise ValueError( - f"shape of idomain ({idomain.shape}) not equal to {shape3d}" - ) + raise ValueError(f"shape of idomain ({idomain.shape}) not equal to {shape3d}") # convert bedleak to numpy array if necessary if bedleak is None: @@ -102,9 +101,7 @@ def get_lak_connections(modelgrid, lake_map, idomain=None, bedleak=None): # check the dimensions of the bedleak array if bedleak.shape != shape2d: - raise ValueError( - f"shape of bedleak ({bedleak.shape}) not equal to {shape2d}" - ) + raise ValueError(f"shape of bedleak ({bedleak.shape}) not equal to {shape2d}") # get the model grid elevations and reset lake_map using idomain # if lake is embedded and in an inactive cell @@ -129,6 +126,8 @@ def get_lak_connections(modelgrid, lake_map, idomain=None, bedleak=None): unique = unique[idx] dx, dy = None, None + vertices, cell_edges = None, None + xcenters, ycenters = None, None # embedded lakes for lake_number in unique: @@ -154,8 +153,30 @@ def get_lak_connections(modelgrid, lake_map, idomain=None, bedleak=None): lake_map, idomain, cell_index, dx, dy, elevations ) elif modelgrid.grid_type == "vertex": - raise NotImplementedError( - "embedded lakes have not been implemented" + if vertices is None: + vertices = modelgrid.verts + cell_edges = tuple( + __cell_edges(poly) for poly in modelgrid.iverts + ) + xcenters = modelgrid.xcellcenters + ycenters = modelgrid.ycellcenters + ( + cellids, + claktypes, + belevs, + televs, + connlens, + connwidths, + ) = __vertex_lake_connections( + lake_map, + idomain, + cell_index, + modelgrid, + elevations, + vertices, + cell_edges, + xcenters, + ycenters, ) else: cellid = (0,) + cell_index @@ -197,19 +218,23 @@ def get_lak_connections(modelgrid, lake_map, idomain=None, bedleak=None): # set number of connections for lake connection_dict[lake_number] = iconn + if embedded and iconn == 0: + warnings.warn( + f"embedded lake {lake_number} has no connections to active " + "model cells.", + UserWarning, + stacklevel=2, + ) + # reset idomain for lake if iconn > 0: - idx = np.asarray( - (lake_map == lake_number) & (idomain > 0) - ).nonzero() + idx = np.asarray((lake_map == lake_number) & (idomain > 0)).nonzero() idomain[idx] = 0 return idomain, connection_dict, connectiondata -def __structured_lake_connections( - lake_map, idomain, cell_index, dx, dy, elevations -): +def __structured_lake_connections(lake_map, idomain, cell_index, dx, dy, elevations): nlay, nrow, ncol = lake_map.shape cellids = [] claktypes = [] @@ -281,3 +306,109 @@ def __structured_lake_connections( connwidths.append(0.0) return cellids, claktypes, belevs, televs, connlens, connwidths + + +def __vertex_lake_connections( + lake_map, + idomain, + cell_index, + modelgrid, + elevations, + vertices, + cell_edges, + xcenters, + ycenters, +): + nlay, ncpl = lake_map.shape + cellids = [] + claktypes = [] + belevs = [] + televs = [] + connlens = [] + connwidths = [] + + k, icpl = cell_index + if idomain[cell_index] > 0: + node = k * ncpl + icpl + neighbors = modelgrid.neighbors(node=node, method="rook") + + for neighbor in neighbors: + # neighbors() returns 2D adjacency offset to the requested layer + nicpl = neighbor % ncpl + ci = (k, nicpl) + + if not (np.ma.is_masked(lake_map[ci]) and idomain[ci] > 0): + continue + + # A face can contain multiple edges when split by hanging vertices. + shared = cell_edges[icpl] & cell_edges[nicpl] + if not shared: + continue + + centre = (xcenters[nicpl], ycenters[nicpl]) + connwidth = 0.0 + connlen = None + for v0, v1 in shared: + p0 = vertices[v0] + p1 = vertices[v1] + connwidth += np.linalg.norm(p1 - p0) + distance = __distance_to_segment(centre, p0, p1) + if connlen is None or distance < connlen: + connlen = distance + + cellids.append(ci) + claktypes.append("horizontal") + belevs.append(elevations[k + 1, nicpl]) + televs.append(elevations[k, nicpl]) + connlens.append(connlen) + connwidths.append(connwidth) + + # vertical connection + if k < nlay - 1: + cell_below = (k + 1, icpl) + + if np.ma.is_masked(lake_map[cell_below]) and idomain[cell_below] > 0: + cellids.append(cell_below) + claktypes.append("vertical") + belevs.append(0.0) + televs.append(0.0) + connlens.append(0.0) + connwidths.append(0.0) + + return cellids, claktypes, belevs, televs, connlens, connwidths + + +def __cell_edges(poly): + """Return normalized vertex pairs defining a cell's edges.""" + if poly[0] == poly[-1]: + poly = poly[:-1] + + return {tuple(sorted((poly[v - 1], poly[v]))) for v in range(len(poly))} + + +def __distance_to_segment(cell_centre, p0, p1): + """ + Shortest distance from a point to a line segment. + + Projections beyond the segment are clamped to the nearest endpoint. + """ + + P = np.asarray(cell_centre, dtype=float) + A = np.asarray(p0, dtype=float) + B = np.asarray(p1, dtype=float) + + AB = B - A + AP = P - A + + denom = np.dot(AB, AB) + + # clamp projections that fall outside the edge + if denom == 0: + return np.linalg.norm(P - A) + + t = np.dot(AP, AB) / denom + t = np.clip(t, 0.0, 1.0) + + C = A + t * AB + + return np.linalg.norm(P - C) From d1657318ee0342d60b97a19022db44fa37b5dc7c Mon Sep 17 00:00:00 2001 From: Joseph Hughes Date: Wed, 5 Aug 2026 14:39:54 -0500 Subject: [PATCH 59/90] docs(create_rstfiles): order the notebook galleries by when a notebook was added (#2792) The galleries were built from an alphabetical list, so a new notebook landed wherever its name happened to fall and the reader had no way to tell what had just been added. Notebooks are now ordered by the commit that added them, and a notebook that is not in the history yet, as when it is added in the same pull request, sorts last. Notebooks added in the same commit keep their alphabetical order, so the majority of the galleries are unchanged. The order falls back to alphabetical when the history is not available, as in a shallow clone, rather than failing the documentation build. --- .docs/create_rstfiles.py | 46 ++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/.docs/create_rstfiles.py b/.docs/create_rstfiles.py index b63a70c56..a2d99a62d 100644 --- a/.docs/create_rstfiles.py +++ b/.docs/create_rstfiles.py @@ -1,8 +1,34 @@ +import subprocess +from functools import lru_cache from pathlib import Path project_root_path = Path(__file__).parent.parent +@lru_cache(maxsize=None) +def added_order(path): + """Sort key that puts notebooks in the order they were added + + Notebooks added in the same commit keep their alphabetical order, and one + that is not in the history yet sorts last. The order falls back to + alphabetical when the history is not available, as in a shallow clone. + """ + try: + # git wants a pathspec relative to the repository it is run in + pathspec = str(path.relative_to(project_root_path)) + added = subprocess.run( + ["git", "log", "--diff-filter=A", "--format=%at", "-1", "--", pathspec], + capture_output=True, + text=True, + cwd=project_root_path, + timeout=10, + check=False, + ).stdout.strip() + except (OSError, ValueError, subprocess.SubprocessError): + added = "" + return (float(added) if added else float("inf"), path.name) + + def get_section(f): lines = Path(f).open().readlines() line = next(iter([l for l in lines if "# section:" in l]), None) @@ -33,9 +59,13 @@ def create_gallery_section(f, name, title, stems): def create_tutorials_rst(): rst_path = project_root_path / ".docs" / "tutorials.rst" nbs_path = project_root_path / ".docs" / "Notebooks" - filenames = sorted( - [path.name for path in nbs_path.rglob("*.py") if "tutorial" in path.name] - ) + filenames = [ + path.name + for path in sorted( + (p for p in nbs_path.rglob("*.py") if "tutorial" in p.name), + key=added_order, + ) + ] print(f"Creating {rst_path}") with open(rst_path, "w") as rst_file: @@ -73,9 +103,13 @@ def create_tutorials_rst(): def create_examples_rst(): rst_path = project_root_path / ".docs" / "examples.rst" nbs_path = project_root_path / ".docs" / "Notebooks" - filenames = sorted( - [path.name for path in nbs_path.rglob("*.py") if "example" in path.name] - ) + filenames = [ + path.name + for path in sorted( + (p for p in nbs_path.rglob("*.py") if "example" in p.name), + key=added_order, + ) + ] print(f"Creating {rst_path}") with open(rst_path, "w") as rst_file: From d0bd367407219a15b4d4bd1b68e719f45d7e796a Mon Sep 17 00:00:00 2001 From: Joseph Hughes Date: Wed, 5 Aug 2026 14:40:36 -0500 Subject: [PATCH 60/90] docs(lakpak_utils): add embedded lake connection examples (#2793) get_lak_connections builds the Lake Package connection data for a lake embedded in the model domain, and support for a vertex grid was added in #2786, but no notebook showed either one. Two notebooks are added. The first embeds a lake in a structured grid, and the second embeds the same lake in the equivalent vertex grid and checks that the two give the same connections. Both run the model and put the lake stage into the head array so that a single map shows the water surface across the lake and the aquifer rather than leaving a hole where the lake is. --- .../Notebooks/dis_lake_connections_example.py | 189 ++++++++++++++ .../disv_lake_connections_example.py | 230 ++++++++++++++++++ 2 files changed, 419 insertions(+) create mode 100644 .docs/Notebooks/dis_lake_connections_example.py create mode 100644 .docs/Notebooks/disv_lake_connections_example.py diff --git a/.docs/Notebooks/dis_lake_connections_example.py b/.docs/Notebooks/dis_lake_connections_example.py new file mode 100644 index 000000000..b27f17967 --- /dev/null +++ b/.docs/Notebooks/dis_lake_connections_example.py @@ -0,0 +1,189 @@ +# --- +# jupyter: +# jupytext: +# notebook_metadata_filter: all +# text_representation: +# extension: .py +# format_name: light +# format_version: '1.5' +# jupytext_version: 1.14.5 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# metadata: +# section: mf6 +# authors: +# - name: Joseph Hughes +# --- + +# # Embedded Lake Connections for a Structured Grid +# +# A lake embedded in the model domain replaces the cells it occupies, so the MODFLOW 6 Lake (LAK) Package needs a connection to every active cell that touches it. `get_lak_connections()` builds those connections from an array of lake numbers, and returns the idomain with the lake cells deactivated, the number of connections in each lake, and the connectiondata block for the package. +# +# We build a lake embedded in one layer of a structured grid, and put the simulated lake stage into the head array so that a single map shows the water surface. The companion notebook does the same thing on a vertex grid. + +# + +import sys +from tempfile import TemporaryDirectory + +import matplotlib as mpl +import matplotlib.pyplot as plt +import numpy as np + +import flopy +from flopy.discretization import StructuredGrid +from flopy.mf6.utils import get_lak_connections + +print(sys.version) +print(f"numpy version: {np.__version__}") +print(f"matplotlib version: {mpl.__version__}") +print(f"flopy version: {flopy.__version__}") +# - + +# ## Build the grid and the lake +# +# We use a grid of two layers with 7 rows and 7 columns. The lake occupies a three by three block in the upper layer, and is given lake number 0. Cells that are not part of a lake are masked. + +# + +nlay, nrow, ncol = 2, 7, 7 +delr = delc = np.full(7, 100.0) +top = np.full((nrow, ncol), 10.0) +botm = np.array([np.full((nrow, ncol), 0.0), np.full((nrow, ncol), -10.0)]) + +modelgrid = StructuredGrid(delr=delr, delc=delc, top=top, botm=botm, nlay=nlay) + +lake_map = np.full((nlay, nrow, ncol), -1, dtype=np.int32) +lake_map[0, 2:5, 2:5] = 0 +lake_map = np.ma.masked_where(lake_map < 0, lake_map) + +print(f"Lake cells in layer 1: {(~lake_map.mask[0]).sum()}") +# - + +# ## Build the lake connections +# +# The lake map is the same shape as the model grid, so the lake is embedded rather than sitting on top of the model. We give the same bed leakance to every connection. + +# + +idomain, connection_dict, connectiondata = get_lak_connections( + modelgrid, + lake_map, + idomain=np.ones((nlay, nrow, ncol), dtype=int), + bedleak=0.1, +) + +print(f"Connections in each lake: {connection_dict}") +print(f"connectiondata rows: {len(connectiondata)}") +# - + +# A connection is horizontal where the lake meets a cell in the same layer, and vertical where it sits on the cell below. The three by three lake has twelve cells around its perimeter and nine beneath it. + +# + +claktype = [row[3] for row in connectiondata] +for kind in ("horizontal", "vertical"): + print(f"{kind:12s}{claktype.count(kind)}") + +print() +print("lakeno iconn cellid claktype bedleak connlen connwidth") +for row in connectiondata[:4]: + lakeno, iconn, cellid, kind, leak, _, _, connlen, connwidth = row + print( + f"{lakeno:6d}{iconn:6d} {cellid!s:15s} {kind:12s}" + f"{leak:8.2f}{connlen:9.1f}{connwidth:10.1f}" + ) +# - + +# The cells the lake occupies are deactivated in the returned idomain, so the lake replaces them rather than sharing the domain with them. + +print(f"idomain in the lake cells: {np.unique(idomain[0, 2:5, 2:5])}") +print(f"active cells remaining : {(idomain > 0).sum()} of {idomain.size}") + +# ## Run the model +# +# The connectiondata and the idomain go straight into the MODFLOW 6 model. We +# set constant heads on the left and right edges to drive flow across the lake. + +# + +temp_dir = TemporaryDirectory() +name = "dis_lake" + +sim = flopy.mf6.MFSimulation(sim_name=name, sim_ws=temp_dir.name, exe_name="mf6") +flopy.mf6.ModflowTdis(sim) +flopy.mf6.ModflowIms( + sim, + linear_acceleration="bicgstab", + outer_dvclose=1e-9, + inner_dvclose=1e-10, + outer_maximum=200, +) +gwf = flopy.mf6.ModflowGwf(sim, modelname=name, newtonoptions="newton under_relaxation") +flopy.mf6.ModflowGwfdis( + gwf, + nlay=nlay, + nrow=nrow, + ncol=ncol, + delr=delr, + delc=delc, + top=top, + botm=botm, + idomain=idomain, +) +flopy.mf6.ModflowGwfic(gwf, strt=8.0) +flopy.mf6.ModflowGwfnpf(gwf, icelltype=1, k=10.0) +flopy.mf6.ModflowGwfchd( + gwf, + stress_period_data=[[(0, i, 0), 9.0] for i in range(nrow)] + + [[(0, i, ncol - 1), 6.0] for i in range(nrow)], +) +lak = flopy.mf6.ModflowGwflak( + gwf, + stage_filerecord=f"{name}.lak.stage.bin", + nlakes=1, + packagedata=[[0, 7.5, connection_dict[0]]], + connectiondata=connectiondata, + perioddata={0: [[0, "rainfall", 0.001]]}, +) +flopy.mf6.ModflowGwfoc(gwf, head_filerecord=f"{name}.hds", saverecord=[("HEAD", "ALL")]) + +sim.write_simulation(silent=True) +success, buff = sim.run_simulation(silent=True) +assert success, "\n".join(buff[-15:]) +print("model converged") +# - + +# ## Put the lake stage into the head array +# +# The lake cells are inactive, so the head array has no value there. We fill +# those cells with the stage of the lake that occupies them, which gives a +# single array of the water surface that can be plotted in one pass. + +# + +head = gwf.output.head().get_data() +stage = lak.output.stage().get_data().flatten() +for lake_number in np.unique(lake_map.compressed()): + head[lake_map == lake_number] = stage[lake_number] + +print(f"lake stage: {stage[0]:.3f}") +print(f"cells with no head value: {(np.abs(head) > 1e29).sum()}") +print(f"head range: {head.min():.3f} to {head.max():.3f}") +# - + +# ## Head map +# +# The lake reads as part of the water surface rather than as a hole in it. + +# + +fig, ax = plt.subplots(figsize=(6, 6)) +ax.set_aspect("equal") +pmv = flopy.plot.PlotMapView(modelgrid=modelgrid, ax=ax, layer=0) +cb = pmv.plot_array(head[0]) +pmv.plot_grid(colors="0.5", lw=0.5) +pmv.contour_array(head[0], colors="white", linewidths=1.0) +ax.set_title("Head with the lake stage embedded") +fig.colorbar(cb, ax=ax, shrink=0.7, label="head") +# - + +try: + temp_dir.cleanup() +except (PermissionError, NotADirectoryError): + pass diff --git a/.docs/Notebooks/disv_lake_connections_example.py b/.docs/Notebooks/disv_lake_connections_example.py new file mode 100644 index 000000000..237fbbdd6 --- /dev/null +++ b/.docs/Notebooks/disv_lake_connections_example.py @@ -0,0 +1,230 @@ +# --- +# jupyter: +# jupytext: +# notebook_metadata_filter: all +# text_representation: +# extension: .py +# format_name: light +# format_version: '1.5' +# jupytext_version: 1.14.5 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# metadata: +# section: mf6 +# authors: +# - name: Joseph Hughes +# --- + +# # Embedded Lake Connections for a Vertex Grid +# +# `get_lak_connections()` builds MODFLOW 6 Lake (LAK) Package connection data for a lake embedded in a vertex grid as well as a structured one. The cells a lake touches are found from the cells that share an edge with it, and the width of a connection is the length of the shared edge. +# +# We put the same lake used in the [structured grid example](https://flopy.readthedocs.io/en/latest/Notebooks/dis_lake_connections_example.html) on a vertex grid, check that the two agree, and put the simulated lake stage into the head array so that a single map shows the water surface. + +# + +import sys +from tempfile import TemporaryDirectory + +import matplotlib as mpl +import matplotlib.pyplot as plt +import numpy as np + +import flopy +from flopy.discretization import StructuredGrid, VertexGrid +from flopy.mf6.utils import get_lak_connections + +print(sys.version) +print(f"numpy version: {np.__version__}") +print(f"matplotlib version: {mpl.__version__}") +print(f"flopy version: {flopy.__version__}") +# - + +# ## Build a vertex grid and the equivalent structured grid +# +# We build a vertex grid that discretizes the same domain as the structured grid, one cell2d for every row and column, so the two must give the same lake connections. + +# + +nlay, nrow, ncol = 2, 7, 7 +delr = delc = np.full(7, 100.0) +top = np.full((nrow, ncol), 10.0) +botm = np.array([np.full((nrow, ncol), 0.0), np.full((nrow, ncol), -10.0)]) +ncpl = nrow * ncol + +structured = StructuredGrid(delr=delr, delc=delc, top=top, botm=botm, nlay=nlay) + +xv = np.concatenate(([0.0], np.cumsum(delr))) +yv = delc.sum() - np.concatenate(([0.0], np.cumsum(delc))) +vertices, ivert = [], {} +for i in range(nrow + 1): + for j in range(ncol + 1): + ivert[(i, j)] = len(vertices) + vertices.append((len(vertices), float(xv[j]), float(yv[i]))) + +cell2d = [ + ( + i * ncol + j, + 0.5 * (xv[j] + xv[j + 1]), + 0.5 * (yv[i] + yv[i + 1]), + 4, + ivert[(i, j)], + ivert[(i, j + 1)], + ivert[(i + 1, j + 1)], + ivert[(i + 1, j)], + ) + for i in range(nrow) + for j in range(ncol) +] + +vertex = VertexGrid( + vertices=vertices, + cell2d=cell2d, + top=top.flatten(), + botm=botm.reshape(nlay, ncpl), + nlay=nlay, +) +print(f"Vertex grid: {vertex.nlay} layers of {vertex.ncpl} cells") +# - + +# ## Build the lake connections +# +# The lake occupies the same three by three block in the upper layer. On the vertex grid the lake map has one value for every cell2d rather than a row and a column. + +# + +lake_map_dis = np.full((nlay, nrow, ncol), -1, dtype=np.int32) +lake_map_dis[0, 2:5, 2:5] = 0 +lake_map_disv = lake_map_dis.reshape(nlay, ncpl) + +dis_idomain, dis_conn, dis_data = get_lak_connections( + structured, + np.ma.masked_where(lake_map_dis < 0, lake_map_dis), + idomain=np.ones((nlay, nrow, ncol), dtype=int), + bedleak=0.1, +) +disv_idomain, disv_conn, disv_data = get_lak_connections( + vertex, + np.ma.masked_where(lake_map_disv < 0, lake_map_disv), + idomain=np.ones((nlay, ncpl), dtype=int), + bedleak=0.1, +) + +print(f"structured connections: {dis_conn}") +print(f"vertex connections : {disv_conn}") +print(f"first vertex record : {disv_data[0]}") +# - + +# The two grids give the same connections. A structured cellid is a layer, row, and column while a vertex cellid is a layer and a cell2d number, so we flatten the structured cellids before comparing the two. + + +# + +def normalize(row): + lakeno, _, cellid, claktype, _, _, _, connlen, connwidth = row + if len(cellid) == 3: + k, i, j = cellid + cellid = (k, i * ncol + j) + return (lakeno, *cellid, claktype), (connlen, connwidth) + + +dis_sorted = sorted(map(normalize, dis_data)) +disv_sorted = sorted(map(normalize, disv_data)) + +# the cells and the connection types must match exactly, and the lengths and +# widths to within rounding, because the two grids compute them differently +assert [cells for cells, _ in dis_sorted] == [cells for cells, _ in disv_sorted] +assert np.allclose([size for _, size in dis_sorted], [size for _, size in disv_sorted]) +assert np.array_equal(dis_idomain.reshape(nlay, ncpl), disv_idomain) +print("The vertex grid reproduces the structured grid connections.") +# - + +# ## Run the model +# +# The connectiondata and the idomain from the vertex grid go straight into a +# MODFLOW 6 DISV model. We set constant heads on the left and right edges to +# drive flow across the lake. + +# + +temp_dir = TemporaryDirectory() +name = "disv_lake" + +sim = flopy.mf6.MFSimulation(sim_name=name, sim_ws=temp_dir.name, exe_name="mf6") +flopy.mf6.ModflowTdis(sim) +flopy.mf6.ModflowIms( + sim, + linear_acceleration="bicgstab", + outer_dvclose=1e-9, + inner_dvclose=1e-10, + outer_maximum=200, +) +gwf = flopy.mf6.ModflowGwf(sim, modelname=name, newtonoptions="newton under_relaxation") +flopy.mf6.ModflowGwfdisv( + gwf, + nlay=nlay, + ncpl=ncpl, + top=top.flatten(), + botm=botm.reshape(nlay, ncpl), + vertices=vertices, + cell2d=cell2d, + idomain=disv_idomain, +) +flopy.mf6.ModflowGwfic(gwf, strt=8.0) +flopy.mf6.ModflowGwfnpf(gwf, icelltype=1, k=10.0) +flopy.mf6.ModflowGwfchd( + gwf, + stress_period_data=[[(0, i * ncol), 9.0] for i in range(nrow)] + + [[(0, i * ncol + ncol - 1), 6.0] for i in range(nrow)], +) +lak = flopy.mf6.ModflowGwflak( + gwf, + stage_filerecord=f"{name}.lak.stage.bin", + nlakes=1, + packagedata=[[0, 7.5, disv_conn[0]]], + connectiondata=disv_data, + perioddata={0: [[0, "rainfall", 0.001]]}, +) +flopy.mf6.ModflowGwfoc(gwf, head_filerecord=f"{name}.hds", saverecord=[("HEAD", "ALL")]) + +sim.write_simulation(silent=True) +success, buff = sim.run_simulation(silent=True) +assert success, "\n".join(buff[-15:]) +print("model converged") +# - + +# ## Put the lake stage into the head array +# +# The lake cells are inactive, so the head array has no value there. We fill +# those cells with the stage of the lake that occupies them, which gives a +# single array of the water surface that can be plotted in one pass. + +# + +lake_map_masked = np.ma.masked_where(lake_map_disv < 0, lake_map_disv) + +head = gwf.output.head().get_data().reshape(nlay, ncpl) +stage = lak.output.stage().get_data().flatten() +for lake_number in np.unique(lake_map_masked.compressed()): + head[lake_map_masked == lake_number] = stage[lake_number] + +print(f"lake stage: {stage[0]:.3f}") +print(f"cells with no head value: {(np.abs(head) > 1e29).sum()}") +print(f"head range: {head.min():.3f} to {head.max():.3f}") +# - + +# ## Head map +# +# The lake reads as part of the water surface rather than as a hole in it. + +# + +fig, ax = plt.subplots(figsize=(6, 6)) +ax.set_aspect("equal") +pmv = flopy.plot.PlotMapView(modelgrid=vertex, ax=ax, layer=0) +cb = pmv.plot_array(head[0]) +pmv.plot_grid(colors="0.5", lw=0.5) +pmv.contour_array(head[0], colors="white", linewidths=1.0) +ax.set_title("Head with the lake stage embedded") +fig.colorbar(cb, ax=ax, shrink=0.7, label="head") +# - + +try: + temp_dir.cleanup() +except (PermissionError, NotADirectoryError): + pass From c07f86dbab8b2c4b39cdbd222ccf5a200ad69725 Mon Sep 17 00:00:00 2001 From: Joshua Larsen Date: Wed, 5 Aug 2026 14:36:47 -0700 Subject: [PATCH 61/90] chore: Deprecation warnings maintenance: (#2794) * replace .utcfromtimestamp with fromtimestamp(timestamp, timezone.utc) in ModelTime * maintenance `.shape =` calls replaced with `arr = arr.reshape()` across source code base --- flopy/discretization/modeltime.py | 5 +++-- flopy/discretization/structuredgrid.py | 4 ++-- flopy/discretization/vertexgrid.py | 2 +- flopy/mf6/utils/binaryfile_utils.py | 4 ++-- flopy/mf6/utils/binarygrid_util.py | 6 +++--- flopy/mf6/utils/model_splitter.py | 2 +- flopy/plot/plotutil.py | 10 +++++----- flopy/utils/postprocessing.py | 10 +++++----- flopy/utils/rasters.py | 2 +- flopy/utils/zonbud.py | 2 +- 10 files changed, 24 insertions(+), 23 deletions(-) diff --git a/flopy/discretization/modeltime.py b/flopy/discretization/modeltime.py index 2056c0110..8ef7a93eb 100644 --- a/flopy/discretization/modeltime.py +++ b/flopy/discretization/modeltime.py @@ -1,6 +1,6 @@ import calendar from dataclasses import dataclass, field -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from difflib import SequenceMatcher import numpy as np @@ -479,7 +479,8 @@ def parse_datetime( elif isinstance(datetime_obj, np.datetime64): unix_time_0 = datetime(1970, 1, 1) ts = (datetime_obj - np.datetime64(unix_time_0)) / np.timedelta64(1, "s") - datetime_obj = datetime.utcfromtimestamp(ts) + datetime_obj = datetime.fromtimestamp(ts, tz=timezone.utc) + datetime_obj = datetime_obj.replace(tzinfo=None) elif isinstance(datetime_obj, pd.Timestamp): datetime_obj = datetime_obj.to_pydatetime() elif isinstance(datetime_obj, datetime): diff --git a/flopy/discretization/structuredgrid.py b/flopy/discretization/structuredgrid.py index 18bab69f8..7aa6bcc40 100644 --- a/flopy/discretization/structuredgrid.py +++ b/flopy/discretization/structuredgrid.py @@ -2057,8 +2057,8 @@ def from_binary_grid_file(cls, file_path, verbose=False): nlay, nrow, ncol = (grb_obj.nlay, grb_obj.nrow, grb_obj.ncol) delr, delc = grb_obj.delr, grb_obj.delc top, botm = grb_obj.top, grb_obj.bot - top.shape = (nrow, ncol) - botm.shape = (nlay, nrow, ncol) + top = top.reshape((nrow, ncol)) + botm = botm.reshape((nlay, nrow, ncol)) return cls( delc, delr, diff --git a/flopy/discretization/vertexgrid.py b/flopy/discretization/vertexgrid.py index ddb8da4b5..66f23dd57 100644 --- a/flopy/discretization/vertexgrid.py +++ b/flopy/discretization/vertexgrid.py @@ -847,7 +847,7 @@ def from_binary_grid_file(cls, file_path, verbose=False): nlay, ncpl = grb_obj.nlay, grb_obj.ncpl top = np.ravel(grb_obj.top) botm = grb_obj.bot - botm.shape = (nlay, ncpl) + botm = botm.reshape((nlay, ncpl)) vertices, cell2d = grb_obj.cell2d return cls( diff --git a/flopy/mf6/utils/binaryfile_utils.py b/flopy/mf6/utils/binaryfile_utils.py index 91bcd0abd..941ba31ab 100644 --- a/flopy/mf6/utils/binaryfile_utils.py +++ b/flopy/mf6/utils/binaryfile_utils.py @@ -375,9 +375,9 @@ def _reshape_binary_data(data, dtype=None): return data elif dtype == "V": nodes = len(data[0][0][0]) - data.shape = (time, -1, nodes) + data = data.reshape((time, -1, nodes)) elif dtype == "U": - data.shape = (time, -1) + data = data.reshape((time, -1)) else: err = "Invalid dtype flag supplied, valid are dtype='U', dtype='V'" raise Exception(err) diff --git a/flopy/mf6/utils/binarygrid_util.py b/flopy/mf6/utils/binarygrid_util.py index 88e9b565c..c74cc20f9 100644 --- a/flopy/mf6/utils/binarygrid_util.py +++ b/flopy/mf6/utils/binarygrid_util.py @@ -238,7 +238,7 @@ def _set_modelgrid(self): nlay, ncpl = self.nlay, self.ncpl vertices, cell2d = self.cell2d top = np.ravel(top) - botm.shape = (nlay, ncpl) + botm = botm.reshape((nlay, ncpl)) modelgrid = VertexGrid( vertices, cell2d, @@ -258,8 +258,8 @@ def _set_modelgrid(self): ) delr, delc = self.delr, self.delc - top.shape = (nrow, ncol) - botm.shape = (nlay, nrow, ncol) + top.reshape((nrow, ncol)) + botm = botm.reshape((nlay, nrow, ncol)) modelgrid = StructuredGrid( delc, delr, diff --git a/flopy/mf6/utils/model_splitter.py b/flopy/mf6/utils/model_splitter.py index 72bd0f3a9..8b333cb5e 100644 --- a/flopy/mf6/utils/model_splitter.py +++ b/flopy/mf6/utils/model_splitter.py @@ -833,7 +833,7 @@ def reconstruct_array(self, arrays): new_array[new_nodes] = array[old_nodes] - new_array.shape = shape + new_array = new_array.reshape(shape) return new_array def reconstruct_recarray(self, recarrays): diff --git a/flopy/plot/plotutil.py b/flopy/plot/plotutil.py index 23f6d00f3..fdce65021 100644 --- a/flopy/plot/plotutil.py +++ b/flopy/plot/plotutil.py @@ -1482,11 +1482,11 @@ def saturated_thickness(head, top, botm, laytyp, mask_values=None): head = np.copy(head) nlay, nrow, ncol = head.shape ncpl = nrow * ncol - head.shape = (nlay, ncpl) - top.shape = (ncpl,) - botm.shape = (nlay, ncpl) + head = head.reshape((nlay, ncpl)) + top = top.reshape((ncpl,)) + botm = botm.reshape((nlay, ncpl)) if laytyp.ndim == 3: - laytyp.shape = (nlay, ncpl) + laytyp = laytyp.reshape((nlay, ncpl)) else: nrow, ncol = None, None @@ -1531,7 +1531,7 @@ def saturated_thickness(head, top, botm, laytyp, mask_values=None): sat_thk = np.where(laytyp != 0, sat_thk_unconf, sat_thk_conf) if nrow is not None and ncol is not None: - sat_thk.shape = (nlay, nrow, ncol) + sat_thk = sat_thk.reshape((nlay, nrow, ncol)) return sat_thk diff --git a/flopy/utils/postprocessing.py b/flopy/utils/postprocessing.py index ec9416626..656f7d0f4 100644 --- a/flopy/utils/postprocessing.py +++ b/flopy/utils/postprocessing.py @@ -801,7 +801,7 @@ def get_specific_discharge( modelgrid = model.modelgrid if head is not None: - head.shape = modelgrid.shape + head = head.reshape(modelgrid.shape) if isinstance(vectors, (list, tuple)): classical_budget = True @@ -857,7 +857,7 @@ def get_specific_discharge( head, mask=[model.hdry, model.hnoflo] ) - saturated_thickness.shape = modelgrid.shape + saturated_thickness = saturated_thickness.reshape(modelgrid.shape) # inform modelgrid of no-flow and dry cells modelgrid = model.modelgrid @@ -927,9 +927,9 @@ def get_specific_discharge( qx[idx] = spdis["qx"] qy[idx] = spdis["qy"] qz[idx] = spdis["qz"] - qx.shape = modelgrid.shape - qy.shape = modelgrid.shape - qz.shape = modelgrid.shape + qx = qx.reshape(modelgrid.shape) + qy = qy.reshape(modelgrid.shape) + qz = qz.reshape(modelgrid.shape) # set no-flow and dry cells to NaN if head is not None and position == "centers": diff --git a/flopy/utils/rasters.py b/flopy/utils/rasters.py index 10489e308..caae365f1 100644 --- a/flopy/utils/rasters.py +++ b/flopy/utils/rasters.py @@ -588,7 +588,7 @@ def resample_to_grid( data = np.where(np.isnan(data), extrapolate, data) # step 4: return grid to user in shape provided - data.shape = data_shape + data = data.reshape(data_shape) # step 5: re-apply nodata values data[np.isnan(data)] = self.nodatavals[0] diff --git a/flopy/utils/zonbud.py b/flopy/utils/zonbud.py index b8dd2a8ae..ee87abb93 100644 --- a/flopy/utils/zonbud.py +++ b/flopy/utils/zonbud.py @@ -2677,7 +2677,7 @@ def _read_zb_csv2(fname, add_prefix=True, aliases=None): array = np.genfromtxt(foo, delimiter=",").T if len(array) != len(dtype): array = array[:-1] - array.shape = (len(dtype), -1) + array = array.reshape((len(dtype), -1)) data = {name[0]: list(array[ix]) for ix, name in enumerate(dtype)} data["KPER"] = list(np.array(data["KPER"]) - 1) data["KSTP"] = list(np.array(data["KSTP"]) - 1) From 1885436ac5e829b597e4fa970ff03094b31d9ad8 Mon Sep 17 00:00:00 2001 From: Joseph Hughes Date: Fri, 7 Aug 2026 09:23:27 -0500 Subject: [PATCH 62/90] fix(autotest): skip tests that need an optional dependency (#2797) The nightly optional dependency job runs the test suite with only the required dependencies installed. Tests that build a DISV or DISU grid, an hfb array, or a Delaunay triangulation failed there with an ImportError for shapely or scipy rather than being skipped, and a module level import of pyshp or scipy stopped test_export, test_grid, and test_shapefile_utils from being collected at all. Those tests are now marked with the package they need and the module level imports have been moved into the tests that use them. The parameter list for test_voronoi_grid was emptied when shapely or scipy was missing while its list of ids was not, which is a collection error; the test is skipped by its requires_pkg mark, so the guard has been removed. --- autotest/test_binaryfile.py | 4 +++- autotest/test_cellbudgetfile.py | 5 +++++ autotest/test_export.py | 5 ++++- autotest/test_grid.py | 13 +++++++++---- autotest/test_gridintersect.py | 2 ++ autotest/test_hfb_util.py | 5 ++++- autotest/test_lgrutil.py | 2 ++ autotest/test_plot_cross_section.py | 6 ++++++ autotest/test_plot_map_view.py | 6 ++++++ autotest/test_postprocessing.py | 4 +++- 10 files changed, 44 insertions(+), 8 deletions(-) diff --git a/autotest/test_binaryfile.py b/autotest/test_binaryfile.py index e3308ef9c..101f6a1cc 100644 --- a/autotest/test_binaryfile.py +++ b/autotest/test_binaryfile.py @@ -10,7 +10,7 @@ import pytest from matplotlib import pyplot as plt from matplotlib.axes import Axes -from modflow_devtools.markers import requires_exe +from modflow_devtools.markers import requires_exe, requires_pkg import flopy from flopy.utils import ( @@ -679,6 +679,7 @@ def dis_sim(function_tmpdir): @pytest.mark.requires_exe("mf6") +@requires_pkg("shapely") def test_headfile_get_ts_disv_grid(dis_sim, function_tmpdir): """Test HeadFile.get_ts() with DISV grid using both new and old index formats.""" from flopy.mf6 import ModflowGwfchd, ModflowGwfdisv @@ -744,6 +745,7 @@ def test_headfile_get_ts_disv_grid(dis_sim, function_tmpdir): @pytest.mark.requires_exe("mf6") +@requires_pkg("shapely") def test_headfile_get_ts_disu_grid(dis_sim, function_tmpdir): """Test HeadFile.get_ts() with DISU grid using both new and old index formats.""" from flopy.mf6 import ModflowGwfchd, ModflowGwfdisu diff --git a/autotest/test_cellbudgetfile.py b/autotest/test_cellbudgetfile.py index f99382be2..e01d598f0 100644 --- a/autotest/test_cellbudgetfile.py +++ b/autotest/test_cellbudgetfile.py @@ -3,6 +3,7 @@ import numpy as np import pandas as pd import pytest +from modflow_devtools.markers import requires_pkg from autotest.conftest import get_example_data_path from flopy.mf6.modflow.mfsimulation import MFSimulation @@ -861,6 +862,7 @@ def test_cellbudgetfile_get_ts_aux_vars_mf6_dis(dis_sim): @pytest.mark.requires_exe("mf6") +@requires_pkg("shapely") def test_cellbudgetfile_get_ts_aux_vars_mf6_disv(dis_sim): from flopy.mf6 import ModflowGwfchd, ModflowGwfdisv @@ -938,6 +940,7 @@ def test_cellbudgetfile_get_ts_aux_vars_mf6_disv(dis_sim): @pytest.mark.requires_exe("mf6") +@requires_pkg("shapely") def test_cellbudgetfile_get_ts_aux_vars_mf6_disu(dis_sim): from flopy.mf6 import ModflowGwfchd, ModflowGwfdisu @@ -1013,6 +1016,7 @@ def test_cellbudgetfile_get_ts_aux_vars_mf6_disu(dis_sim): @pytest.mark.requires_exe("mf6") +@requires_pkg("shapely") def test_cellbudgetfile_get_ts_imeth1_disv_grid(dis_sim, function_tmpdir): """Test that IMETH=1 budget terms (like STO-SS) work with DISV grids. @@ -1074,6 +1078,7 @@ def test_cellbudgetfile_get_ts_imeth1_disv_grid(dis_sim, function_tmpdir): @pytest.mark.requires_exe("mf6") +@requires_pkg("shapely") def test_cellbudgetfile_get_ts_backwards_compatible_idx_format( dis_sim, function_tmpdir ): diff --git a/autotest/test_export.py b/autotest/test_export.py index 0c107cb2f..1bd9a9514 100644 --- a/autotest/test_export.py +++ b/autotest/test_export.py @@ -7,7 +7,6 @@ import matplotlib.pyplot as plt import numpy as np import pytest -import shapefile from flaky import flaky from modflow_devtools.markers import excludes_platform, requires_exe, requires_pkg from modflow_devtools.misc import has_pkg @@ -2132,6 +2131,8 @@ def test_to_shapefile_raises_attributeerror(): @pytest.mark.parametrize("use_pandas", [True]) # TODO: test non-pandas @pytest.mark.parametrize("sparse", [True, False]) def test_mf6_chd_shapefile_export_structured(function_tmpdir, use_pandas, sparse): + import shapefile + from flopy.mf6 import ( MFSimulation, ModflowGwf, @@ -2183,6 +2184,8 @@ def test_mf6_chd_shapefile_export_structured(function_tmpdir, use_pandas, sparse @pytest.mark.parametrize("sparse", [True]) # TODO: test non-sparse def test_mf6_chd_shapefile_export_unstructured(function_tmpdir, use_pandas, sparse): """Test CHD package shapefile export for DISU (unstructured) grids""" + import shapefile + from flopy.mf6 import ( MFSimulation, ModflowGwf, diff --git a/autotest/test_grid.py b/autotest/test_grid.py index 9ff8a1608..704324da7 100644 --- a/autotest/test_grid.py +++ b/autotest/test_grid.py @@ -12,7 +12,6 @@ from matplotlib import pyplot as plt from modflow_devtools.markers import requires_exe, requires_pkg from modflow_devtools.misc import has_pkg -from scipy.spatial import Delaunay from autotest.test_dis_cases import case_dis, case_disv from autotest.test_grid_cases import GridCases @@ -223,6 +222,7 @@ def test_structured_grid_get_cell_vertices(): assert v2 == v5, "Named i,j should match" +@requires_pkg("shapely") def test_vertex_grid_get_cell_vertices(): """Test VertexGrid.get_cell_vertices() with various input forms""" disv_props = get_disv_kwargs(2, 10, 10, 10.0, 10.0, 100.0, [50.0, 0.0]) @@ -250,6 +250,7 @@ def test_vertex_grid_get_cell_vertices(): assert v4 == v5, "Node and (layer, cell2d) should match" +@requires_pkg("shapely") def test_unstructured_grid_get_cell_vertices(): """Test UnstructuredGrid.get_cell_vertices() with various input forms""" disu_props = get_disu_kwargs( @@ -548,8 +549,11 @@ def test_structured_grid_intersect_array(simple_structured_grid): assert np.isnan(rows_mixed[1]) # Second point out of bounds +@requires_pkg("scipy") def test_vertex_grid_intersect_array(): """Test VertexGrid.intersect() with array inputs.""" + from scipy.spatial import Delaunay + # Create a simple vertex grid using Delaunay triangulation np.random.seed(42) n_points = 50 @@ -589,8 +593,11 @@ def test_vertex_grid_intersect_array(): assert np.isnan(results_mixed[1]) # Second point out of bounds +@requires_pkg("scipy") def test_unstructured_grid_intersect_array(): """Test UnstructuredGrid.intersect() with array inputs.""" + from scipy.spatial import Delaunay + # Create a simple unstructured grid using Delaunay triangulation np.random.seed(42) n_points = 50 @@ -1477,9 +1484,7 @@ def test_voronoi_vertex_grid(function_tmpdir): GridCases.voronoi_nested_circles, GridCases.voronoi_polygons, GridCases.voronoi_many_polygons, - ] - if (has_pkg("shapely", True) and has_pkg("scipy", True)) - else [], + ], ids=[ "voronoi_polygon", "voronoi_rectangle", diff --git a/autotest/test_gridintersect.py b/autotest/test_gridintersect.py index 2a1a876fd..e925929fa 100644 --- a/autotest/test_gridintersect.py +++ b/autotest/test_gridintersect.py @@ -1804,6 +1804,7 @@ def test_tri_grid_intersect_multiple_polygon_array(rtree): ix.intersect(p, geo_dataframe=df_toggle) +@requires_pkg("shapely") def test_rtree_false_raises_in_points_to_cellids(): """rtree=False raises error in points_to_cellids.""" gr = get_rect_grid() @@ -1816,6 +1817,7 @@ def test_rtree_false_raises_in_points_to_cellids(): ix.points_to_cellids(pts) +@requires_pkg("shapely") def test_rtree_false_raises_with_arrays_in_intersects(): """rtree=False raises error in points_to_cellids.""" gr = get_rect_grid() diff --git a/autotest/test_hfb_util.py b/autotest/test_hfb_util.py index 31f9958df..9f2a194b2 100644 --- a/autotest/test_hfb_util.py +++ b/autotest/test_hfb_util.py @@ -1,11 +1,14 @@ import numpy as np -from modflow_devtools.markers import requires_exe +from modflow_devtools.markers import requires_exe, requires_pkg import flopy from flopy.utils.hfb_util import make_hfb_array from flopy.utils.triangle import Triangle from flopy.utils.voronoi import VoronoiGrid +# every test builds the hfb array, which is done with shapely +pytestmark = requires_pkg("shapely") + def structured_sim(): lx = 100 diff --git a/autotest/test_lgrutil.py b/autotest/test_lgrutil.py index 9ff50c419..4c5b35673 100644 --- a/autotest/test_lgrutil.py +++ b/autotest/test_lgrutil.py @@ -1,5 +1,6 @@ import numpy as np import pytest +from modflow_devtools.markers import requires_pkg from flopy.discretization import StructuredGrid from flopy.utils.cvfdutil import get_disv_gridprops, gridlist_to_verts @@ -148,6 +149,7 @@ def test_lgr_variable_rc_spacing(): assert np.allclose(lgr.delc, answer), f"{lgr.delc} /= {answer}" +@requires_pkg("shapely") def test_lgr_hanging_vertices(): # Define parent grid information xoffp = 0.0 diff --git a/autotest/test_plot_cross_section.py b/autotest/test_plot_cross_section.py index 460babdf0..e5829b45f 100644 --- a/autotest/test_plot_cross_section.py +++ b/autotest/test_plot_cross_section.py @@ -298,6 +298,9 @@ def hfb_xc_model(request): from flopy.utils.gridutil import get_disu_kwargs, get_disv_kwargs grid_type = request.param + if grid_type != "dis": + # the vertex and unstructured grids are built with shapely + pytest.importorskip("shapely") # Create simulation sim = flopy.mf6.MFSimulation(sim_name=f"test_hfb_xc_{grid_type}") @@ -414,6 +417,9 @@ def vertical_hfb_xc_model(request): from flopy.utils.gridutil import get_disv_kwargs grid_type = request.param + if grid_type != "dis": + # the vertex and unstructured grids are built with shapely + pytest.importorskip("shapely") # Create simulation sim = flopy.mf6.MFSimulation(sim_name=f"test_vhfb_xc_{grid_type}") diff --git a/autotest/test_plot_map_view.py b/autotest/test_plot_map_view.py index 7eed743ca..d42ff156d 100644 --- a/autotest/test_plot_map_view.py +++ b/autotest/test_plot_map_view.py @@ -421,6 +421,9 @@ def hfb_model(request): from flopy.utils.gridutil import get_disu_kwargs, get_disv_kwargs grid_type = request.param + if grid_type != "dis": + # the vertex and unstructured grids are built with shapely + pytest.importorskip("shapely") # Create simulation sim = flopy.mf6.MFSimulation(sim_name=f"test_hfb_{grid_type}") @@ -554,6 +557,9 @@ def vertical_hfb_model(request): from flopy.utils.gridutil import get_disv_kwargs grid_type = request.param + if grid_type != "dis": + # the vertex and unstructured grids are built with shapely + pytest.importorskip("shapely") # Create simulation sim = flopy.mf6.MFSimulation(sim_name=f"test_vhfb_{grid_type}") diff --git a/autotest/test_postprocessing.py b/autotest/test_postprocessing.py index d3fbbd2f6..fcebe5fbf 100644 --- a/autotest/test_postprocessing.py +++ b/autotest/test_postprocessing.py @@ -2,7 +2,7 @@ import matplotlib.pyplot as plt import numpy as np import pytest -from modflow_devtools.markers import requires_exe +from modflow_devtools.markers import requires_exe, requires_pkg import flopy from flopy.mf6 import ( @@ -556,6 +556,7 @@ def test_get_transmissivities_mf6_structured(function_tmpdir): assert np.array_equal(Tcoords, Tcellids) +@requires_pkg("shapely") def test_get_transmissivities_mf6_vertex(function_tmpdir): nl = 1 nr = 8 @@ -593,6 +594,7 @@ def test_get_transmissivities_mf6_vertex(function_tmpdir): assert "r, c parameters only valid for structured grids" in str(e.value) +@requires_pkg("shapely") def test_get_transmissivities_mf6_unstructured(function_tmpdir): nl = 1 nr = 8 From 8c0059d1e008d3707a1c9578de1d22ec6b336cab Mon Sep 17 00:00:00 2001 From: Joseph Hughes Date: Fri, 7 Aug 2026 13:25:33 -0500 Subject: [PATCH 63/90] fix(autotest): skip export tests that need geopandas (#2800) The nightly optional dependency job installs the optional dependencies and then removes three of them chosen by the date, so it reaches combinations the job that installs none of them does not. Shapefile export goes through geopandas, but the export tests were marked as needing only pyshp and failed with an ImportError rather than being skipped on the night geopandas was removed. The two export tests that do not go through geopandas keep their existing mark. The mark for sklearn on test_save_load_node_mapping_structured was added commented out and never took effect, so that test failed the same way when scikit-learn was removed. --- autotest/test_export.py | 16 ++++++++-------- autotest/test_model_splitter.py | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/autotest/test_export.py b/autotest/test_export.py index 1bd9a9514..1640940f0 100644 --- a/autotest/test_export.py +++ b/autotest/test_export.py @@ -160,7 +160,7 @@ def unstructured_grid(example_data_path): ) -@requires_pkg("pyshp", name_map={"pyshp": "shapefile"}) +@requires_pkg("pyshp", "geopandas", name_map={"pyshp": "shapefile"}) @pytest.mark.parametrize("pathlike", (True, False)) def test_output_helper_shapefile_export(pathlike, function_tmpdir, example_data_path): ml = Modflow.load( @@ -179,7 +179,7 @@ def test_output_helper_shapefile_export(pathlike, function_tmpdir, example_data_ ) -@requires_pkg("pyshp", name_map={"pyshp": "shapefile"}) +@requires_pkg("pyshp", "geopandas", name_map={"pyshp": "shapefile"}) @pytest.mark.slow def test_freyberg_export(function_tmpdir, example_data_path): # steady state @@ -323,7 +323,7 @@ def test_write_gridlines_shapefile(function_tmpdir): assert len(sf) == 22 -@requires_pkg("pyshp", name_map={"pyshp": "shapefile"}) +@requires_pkg("pyshp", "geopandas", name_map={"pyshp": "shapefile"}) def test_export_shapefile_polygon_closed(function_tmpdir): from shapefile import Reader @@ -442,7 +442,7 @@ def test_netcdf_classmethods(function_tmpdir, example_data_path): new_f.nc.close() -@requires_pkg("pyshp", name_map={"pyshp": "shapefile"}) +@requires_pkg("pyshp", "geopandas", name_map={"pyshp": "shapefile"}) def test_shapefile_ibound(function_tmpdir, example_data_path): from shapefile import Reader @@ -465,7 +465,7 @@ def test_shapefile_ibound(function_tmpdir, example_data_path): shape.close() -@requires_pkg("pyshp", name_map={"pyshp": "shapefile"}) +@requires_pkg("pyshp", "geopandas", name_map={"pyshp": "shapefile"}) @pytest.mark.slow @pytest.mark.parametrize("namfile", namfiles()) def test_shapefile(function_tmpdir, namfile): @@ -555,7 +555,7 @@ def test_export_netcdf(function_tmpdir, namfile): nc.close() -@requires_pkg("pyshp", name_map={"pyshp": "shapefile"}) +@requires_pkg("pyshp", "geopandas", name_map={"pyshp": "shapefile"}) def test_export_array2(function_tmpdir): nrow = 7 ncol = 11 @@ -586,7 +586,7 @@ def test_export_array2(function_tmpdir): @pytest.mark.mf6 -@requires_pkg("pyshp", name_map={"pyshp": "shapefile"}) +@requires_pkg("pyshp", "geopandas", name_map={"pyshp": "shapefile"}) def test_array3d_export_structured(function_tmpdir): from shapefile import Reader @@ -2127,7 +2127,7 @@ def test_to_shapefile_raises_attributeerror(): @pytest.mark.mf6 -@requires_pkg("pyshp", name_map={"pyshp": "shapefile"}) +@requires_pkg("pyshp", "geopandas", name_map={"pyshp": "shapefile"}) @pytest.mark.parametrize("use_pandas", [True]) # TODO: test non-pandas @pytest.mark.parametrize("sparse", [True, False]) def test_mf6_chd_shapefile_export_structured(function_tmpdir, use_pandas, sparse): diff --git a/autotest/test_model_splitter.py b/autotest/test_model_splitter.py index c341da058..0f0d46515 100644 --- a/autotest/test_model_splitter.py +++ b/autotest/test_model_splitter.py @@ -221,7 +221,7 @@ def test_metis_splitting_with_lak_sfr(function_tmpdir): @requires_exe("mf6") @requires_pkg("pymetis") @requires_pkg("h5py") -# @requires_pkg("sklearn") +@requires_pkg("sklearn") def test_save_load_node_mapping_structured(function_tmpdir): import pymetis From 79a031a413e4c6f7acfdfb2996a899039ee4e6a1 Mon Sep 17 00:00:00 2001 From: Joseph Hughes Date: Fri, 7 Aug 2026 13:25:49 -0500 Subject: [PATCH 64/90] fix(zonebudget_example): index the budget values by position (#2801) volumetric_budget_bar_plot labels each bar by indexing the values with the position of the bar, but the values are passed in as a series indexed by zone name, so the label lookup raised a KeyError. Pandas used to fall back to positional indexing for a series that is not indexed by integers and no longer does. The values are now taken out of the series before they are indexed. --- .docs/Notebooks/zonebudget_example.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.docs/Notebooks/zonebudget_example.py b/.docs/Notebooks/zonebudget_example.py index 0dada6671..5ef183cd7 100644 --- a/.docs/Notebooks/zonebudget_example.py +++ b/.docs/Notebooks/zonebudget_example.py @@ -267,6 +267,11 @@ def volumetric_budget_bar_plot(values_in, values_out, labels, **kwargs): else: ax = plt.gca() + # the values are indexed by position below, and a series passed in is + # indexed by zone name, so the values are taken out of the series + values_in = np.asarray(values_in) + values_out = np.asarray(values_out) + x_pos = np.arange(len(values_in)) rects_in = ax.bar(x_pos, values_in, align="center", alpha=0.5) From 74d7b9ef2d232d3917f9590f8bfaeb9df2a7a3f8 Mon Sep 17 00:00:00 2001 From: Joseph Hughes Date: Fri, 7 Aug 2026 13:26:49 -0500 Subject: [PATCH 65/90] fix(vtk): transpose the vector after the point scalar expansion (#2799) * fix(vtk): transpose the vector after the point scalar expansion add_vector builds a row per component and numpy_to_vtk flattens in row-major order, so the array has to be transposed to give a row per cell or point. The transpose was applied to the vector sized 3 * nnodes before the point scalar expansion, which left three of the four paths through the method wrong: the point scalar loop indexed a row per cell rather than a row per component and raised an IndexError, and the vector sized 3 * ncpl was never transposed at all and silently wrote the first component to all three. The transpose is now applied once, after the expansion, so a cell gets its own three components in every case. The vector sized 3 * nnodes without point scalars is the one path that was already correct and is unchanged. test_vtk_vector covers only one of the four paths and is marked slow, so the smoke test used by the test matrix skips it and only the nightly optional dependency workflow runs it. A test covering all four paths has been added without the slow mark. * test(test_export): drop the slow mark from test_vtk_vector The smoke test used by the test matrix skips the tests marked slow, so test_vtk_vector only ran in the nightly optional dependency workflow. It loads a model and exports it but does not run one, and takes about half a second, which is below the median of the tests that carry the mark. It now runs wherever vtk is installed. --- autotest/test_export.py | 35 ++++++++++++++++++++++++++++++++++- flopy/export/vtk.py | 7 +++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/autotest/test_export.py b/autotest/test_export.py index 1640940f0..9fc25638a 100644 --- a/autotest/test_export.py +++ b/autotest/test_export.py @@ -1417,7 +1417,6 @@ def test_vtk_cbc(function_tmpdir, example_data_path): @requires_pkg("vtk") -@pytest.mark.slow def test_vtk_vector(function_tmpdir, example_data_path): # test mf 2005 freyberg mpth = example_data_path / "freyberg_multilayer_transient" @@ -1480,6 +1479,40 @@ def test_vtk_vector(function_tmpdir, example_data_path): assert info["pointdata_names"] == [] +@requires_pkg("vtk") +@pytest.mark.parametrize("point_scalars", [False, True]) +@pytest.mark.parametrize("size", ["nnodes", "ncpl"]) +def test_vtk_add_vector_components(size, point_scalars): + """Cell i must get (x[i], y[i], z[i]) for either input size""" + from vtk.util import numpy_support + + nlay, nrow, ncol = 2, 3, 4 + grid = StructuredGrid( + delr=np.full(ncol, 10.0), + delc=np.full(nrow, 10.0), + top=np.full((nrow, ncol), 10.0), + botm=np.array([np.full((nrow, ncol), 0.0), np.full((nrow, ncol), -10.0)]), + nlay=nlay, + ) + n = nlay * nrow * ncol if size == "nnodes" else nrow * ncol + + # a constant field is used because inverse distance weighting of a + # constant returns the constant, so the point and the cell case have the + # same expected value + vector = np.array([np.full(n, 1.0), np.full(n, 2.0), np.full(n, 3.0)]) + vtk = Vtk(modelgrid=grid, point_scalars=point_scalars) + vtk.add_vector(vector, "v") + + data = vtk.vtk_grid.GetPointData() if point_scalars else vtk.vtk_grid.GetCellData() + arr = numpy_support.vtk_to_numpy(data.GetVectors()) + assert arr.shape[1] == 3 + + # cells that the ncpl sized vector does not reach are filled with nan + finite = arr[np.isfinite(arr).all(axis=1)] + assert len(finite) > 0 + assert np.allclose(finite, [1.0, 2.0, 3.0]) + + @requires_pkg("vtk") def test_vtk_unstructured(function_tmpdir, unstructured_grid): from vtkmodules.util.numpy_support import vtk_to_numpy diff --git a/flopy/export/vtk.py b/flopy/export/vtk.py index 9fdb4741f..387e5d7e8 100644 --- a/flopy/export/vtk.py +++ b/flopy/export/vtk.py @@ -897,7 +897,7 @@ def add_vector(self, vector, name, masked_values=None): else: raise AssertionError("Size of vector must be 3 * nnodes or 3 * ncpl") else: - vector = np.reshape(vector, (3, self.nnodes)).T + vector = np.reshape(vector, (3, self.nnodes)) if self.point_scalars: tmp = [] @@ -907,8 +907,11 @@ def add_vector(self, vector, name, masked_values=None): vector = self._mask_values(vector, masked_values) + # a row per component is built above, but numpy_to_vtk flattens in + # row-major order and vtk reads each row as one tuple, so the array + # is transposed to give a row per cell or point vtk_arr = numpy_support.numpy_to_vtk( - num_array=vector, array_type=self.__vtk.VTK_FLOAT + num_array=vector.T, array_type=self.__vtk.VTK_FLOAT ) vtk_arr.SetName(name) vtk_arr.SetNumberOfComponents(3) From 7732f39959fc920dba8017d1080157709fd8d0f0 Mon Sep 17 00:00:00 2001 From: Mike Taves Date: Sat, 8 Aug 2026 07:52:05 +1200 Subject: [PATCH 66/90] chore: improve license metadata (PEP 639) (#2783) Co-authored-by: Joseph Hughes --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a98ca7407..eb0b5bb60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,11 +12,11 @@ maintainers = [ {name = "Joseph D. Hughes", email = "jdhughes@usgs.gov"}, ] keywords = ["MODFLOW", "groundwater", "hydrogeology"] -license = {text = "CC0"} +license = "CC0-1.0" +license-files = ["LICENSE.md"] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Science/Research", - "License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", From 3b5ed8a327828f4ef8e00070ffefd93b3bc53ca7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:07:42 -0700 Subject: [PATCH 67/90] chore(deps): bump prefix-dev/setup-pixi from 0.10.0 to 0.10.1 (#2802) --- .github/workflows/commit.yml | 2 +- .github/workflows/mf6.yml | 4 ++-- .github/workflows/rtd.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index b2f2ee1ff..1f5ff1156 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -150,7 +150,7 @@ jobs: version: 13 - name: Setup pixi - uses: prefix-dev/setup-pixi@v0.10.0 + uses: prefix-dev/setup-pixi@v0.10.1 with: pixi-version: v0.70.1 manifest-path: modflow6/pixi.toml diff --git a/.github/workflows/mf6.yml b/.github/workflows/mf6.yml index 4c86ad08e..c445db480 100644 --- a/.github/workflows/mf6.yml +++ b/.github/workflows/mf6.yml @@ -39,7 +39,7 @@ jobs: version: 13 - name: Setup pixi - uses: prefix-dev/setup-pixi@v0.10.0 + uses: prefix-dev/setup-pixi@v0.10.1 with: pixi-version: v0.70.1 manifest-path: modflow6/pixi.toml @@ -122,7 +122,7 @@ jobs: version: 13 - name: Setup pixi - uses: prefix-dev/setup-pixi@v0.10.0 + uses: prefix-dev/setup-pixi@v0.10.1 with: pixi-version: v0.70.1 manifest-path: modflow6/pixi.toml diff --git a/.github/workflows/rtd.yml b/.github/workflows/rtd.yml index c610baf24..7ebd0bbe7 100644 --- a/.github/workflows/rtd.yml +++ b/.github/workflows/rtd.yml @@ -87,7 +87,7 @@ jobs: echo $GITHUB_EVENT_NAME - name: Setup pixi - uses: prefix-dev/setup-pixi@v0.10.0 + uses: prefix-dev/setup-pixi@v0.10.1 with: pixi-version: v0.70.1 manifest-path: modflow6/pixi.toml From 9b617b237818f9c498488b6d5a942161e6db166d Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Tue, 11 Aug 2026 06:40:31 -0700 Subject: [PATCH 68/90] test(binaryfile): move markers from fixture to test (#2803) Close #2798. Markers can't be applied to fixtures, only tests. --- autotest/test_binaryfile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/autotest/test_binaryfile.py b/autotest/test_binaryfile.py index 101f6a1cc..c2616482c 100644 --- a/autotest/test_binaryfile.py +++ b/autotest/test_binaryfile.py @@ -468,8 +468,6 @@ def test_binaryfile_read_context(freyberg_model_path): @pytest.fixture -@pytest.mark.mf6 -@requires_exe("mf6") def mf6_gwf_2sp_st_tr(function_tmpdir): """ A basic flow model with 2 stress periods, @@ -543,6 +541,8 @@ def mf6_gwf_2sp_st_tr(function_tmpdir): return sim +@pytest.mark.mf6 +@requires_exe("mf6") def test_read_mf6_2sp(mf6_gwf_2sp_st_tr): sim = mf6_gwf_2sp_st_tr gwf = sim.get_model() From 78ab37dabc67c00ecd77c65fdd86f7f4e82a7ef8 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Tue, 11 Aug 2026 19:51:43 -0700 Subject: [PATCH 69/90] fix(mf6): omit removed fields from generated code (#2804) Filter out fields marked in DFNs as removed from the generated classes in the MF6 module. Before, removed fields were included in the generated classes and would be written to input files, which would cause MF6 to raise an error. And remove usages of options recently been removed from MF6. --- .docs/Notebooks/dis_triangle_example.py | 4 ++-- .docs/Notebooks/export_vtk_tutorial.py | 4 ++-- .docs/Notebooks/mf6_data_tutorial10.py | 4 ++-- .docs/Notebooks/modpath7_create_simulation_example.py | 4 ++-- .docs/Notebooks/plot_cross_section_example.py | 4 ++-- .docs/Notebooks/plot_map_view_example.py | 4 ++-- autotest/regression/test_mf6.py | 2 +- autotest/test_modpathfile.py | 4 ++-- autotest/test_specific_discharge.py | 4 ++-- flopy/mf6/mfmodel.py | 4 ++-- flopy/mf6/utils/codegen/__init__.py | 10 ++++++++++ flopy/mf6/utils/codegen/filters.py | 2 ++ 12 files changed, 31 insertions(+), 19 deletions(-) diff --git a/.docs/Notebooks/dis_triangle_example.py b/.docs/Notebooks/dis_triangle_example.py index 1a43b94d4..91acdc9a6 100644 --- a/.docs/Notebooks/dis_triangle_example.py +++ b/.docs/Notebooks/dis_triangle_example.py @@ -204,8 +204,8 @@ sim, print_option="SUMMARY", complexity="complex", - outer_hclose=1.0e-8, - inner_hclose=1.0e-8, + outer_dvclose=1.0e-8, + inner_dvclose=1.0e-8, ) cell2d = tri.get_cell2d() vertices = tri.get_vertices() diff --git a/.docs/Notebooks/export_vtk_tutorial.py b/.docs/Notebooks/export_vtk_tutorial.py index a45c3b5b0..60dc80ac4 100644 --- a/.docs/Notebooks/export_vtk_tutorial.py +++ b/.docs/Notebooks/export_vtk_tutorial.py @@ -480,11 +480,11 @@ def run_vertex_grid_example(ws): pname="ims", print_option="SUMMARY", complexity="SIMPLE", - outer_hclose=1.0e-5, + outer_dvclose=1.0e-5, outer_maximum=100, under_relaxation="NONE", inner_maximum=100, - inner_hclose=1.0e-6, + inner_dvclose=1.0e-6, rcloserecord=0.1, linear_acceleration="BICGSTAB", scaling_method="NONE", diff --git a/.docs/Notebooks/mf6_data_tutorial10.py b/.docs/Notebooks/mf6_data_tutorial10.py index 4a74df676..de0631a10 100644 --- a/.docs/Notebooks/mf6_data_tutorial10.py +++ b/.docs/Notebooks/mf6_data_tutorial10.py @@ -62,11 +62,11 @@ sim, print_option="SUMMARY", complexity="SIMPLE", - outer_hclose=0.0001, + outer_dvclose=0.0001, outer_maximum=500, under_relaxation="NONE", inner_maximum=100, - inner_hclose=0.0001, + inner_dvclose=0.0001, rcloserecord=0.001, linear_acceleration="CG", scaling_method="NONE", diff --git a/.docs/Notebooks/modpath7_create_simulation_example.py b/.docs/Notebooks/modpath7_create_simulation_example.py index 7fd621bcc..7a4bdca65 100644 --- a/.docs/Notebooks/modpath7_create_simulation_example.py +++ b/.docs/Notebooks/modpath7_create_simulation_example.py @@ -100,8 +100,8 @@ def get_nodes(locs): sim, pname="ims", complexity="SIMPLE", - outer_hclose=1e-6, - inner_hclose=1e-6, + outer_dvclose=1e-6, + inner_dvclose=1e-6, rcloserecord=1e-6, ) diff --git a/.docs/Notebooks/plot_cross_section_example.py b/.docs/Notebooks/plot_cross_section_example.py index 38f3062bb..a5d28db87 100644 --- a/.docs/Notebooks/plot_cross_section_example.py +++ b/.docs/Notebooks/plot_cross_section_example.py @@ -685,11 +685,11 @@ def run_vertex_grid_example(ws): pname="ims", print_option="SUMMARY", complexity="SIMPLE", - outer_hclose=1.0e-5, + outer_dvclose=1.0e-5, outer_maximum=100, under_relaxation="NONE", inner_maximum=100, - inner_hclose=1.0e-6, + inner_dvclose=1.0e-6, rcloserecord=0.1, linear_acceleration="BICGSTAB", scaling_method="NONE", diff --git a/.docs/Notebooks/plot_map_view_example.py b/.docs/Notebooks/plot_map_view_example.py index 059881851..00dadcec9 100644 --- a/.docs/Notebooks/plot_map_view_example.py +++ b/.docs/Notebooks/plot_map_view_example.py @@ -863,11 +863,11 @@ def run_vertex_grid_example(ws): pname="ims", print_option="SUMMARY", complexity="SIMPLE", - outer_hclose=1.0e-5, + outer_dvclose=1.0e-5, outer_maximum=100, under_relaxation="NONE", inner_maximum=100, - inner_hclose=1.0e-6, + inner_dvclose=1.0e-6, rcloserecord=0.1, linear_acceleration="BICGSTAB", scaling_method="NONE", diff --git a/autotest/regression/test_mf6.py b/autotest/regression/test_mf6.py index 49a5fcf02..15ac19792 100644 --- a/autotest/regression/test_mf6.py +++ b/autotest/regression/test_mf6.py @@ -1897,7 +1897,7 @@ def test004_create_tests_bcfss(function_tmpdir, example_data_path): ims_package = ModflowIms( sim, print_option="ALL", - csv_output_filerecord="bcf2ss.ims.csv", + csv_outer_output_filerecord="bcf2ss.ims.csv", complexity="SIMPLE", outer_dvclose=0.000001, outer_maximum=500, diff --git a/autotest/test_modpathfile.py b/autotest/test_modpathfile.py index 883d11f1b..dcb606097 100644 --- a/autotest/test_modpathfile.py +++ b/autotest/test_modpathfile.py @@ -76,8 +76,8 @@ def get_nodes(locs): sim, pname="ims", complexity="SIMPLE", - outer_hclose=1e-6, - inner_hclose=1e-6, + outer_dvclose=1e-6, + inner_dvclose=1e-6, rcloserecord=1e-6, ) diff --git a/autotest/test_specific_discharge.py b/autotest/test_specific_discharge.py index 0b7045680..a029708db 100644 --- a/autotest/test_specific_discharge.py +++ b/autotest/test_specific_discharge.py @@ -211,11 +211,11 @@ def mf6_model(function_tmpdir): pname="ims", print_option="SUMMARY", complexity="SIMPLE", - outer_hclose=1.0e-5, + outer_dvclose=1.0e-5, outer_maximum=50, under_relaxation="NONE", inner_maximum=30, - inner_hclose=1.0e-5, + inner_dvclose=1.0e-5, rcloserecord=rcloserecord, linear_acceleration="CG", scaling_method="NONE", diff --git a/flopy/mf6/mfmodel.py b/flopy/mf6/mfmodel.py index 7f4b9ef23..ceeb59c85 100644 --- a/flopy/mf6/mfmodel.py +++ b/flopy/mf6/mfmodel.py @@ -744,7 +744,7 @@ def solver_tols(self): Returns ------- - inner_hclose, rclose : float, float + inner_dvclose, rclose : float, float """ ims = self.get_ims_package() @@ -752,7 +752,7 @@ def solver_tols(self): rclose = ims.rcloserecord.get_data() if rclose is not None: rclose = rclose[0][0] - return ims.inner_hclose.get_data(), rclose + return ims.inner_dvclose.get_data(), rclose return None @property diff --git a/flopy/mf6/utils/codegen/__init__.py b/flopy/mf6/utils/codegen/__init__.py index e70f86b5e..2b984e0fd 100644 --- a/flopy/mf6/utils/codegen/__init__.py +++ b/flopy/mf6/utils/codegen/__init__.py @@ -135,6 +135,10 @@ def _add_nam_suffix(dfn): # parsed haphazardly throughout the mf6 module. TODO: when # the legacy DFN is no longer needed at runtime, remove. if version == 2: + # import here instead of module so we don't + # expect optional deps at module init time + from boltons.dictutils import OMD + assert legacydir is not None, ( "legacydir must be provided for version 2 DFNs" ) @@ -144,6 +148,12 @@ def _add_nam_suffix(dfn): for dfn_name, dfn in dfns.items(): with open(legacydir / f"{dfn_name}.dfn") as df: legacy_dfn, legacy_meta = Dfn._load_v1_flat(df, common=common) + # drop fields removed as of this DFN's MF6 version + legacy_dfn = OMD( + (k, v) + for k, v in legacy_dfn.items(multi=True) + if not v.get("removed", False) + ) dfn["legacy_dfn"] = legacy_dfn dfn["legacy_meta"] = legacy_meta diff --git a/flopy/mf6/utils/codegen/filters.py b/flopy/mf6/utils/codegen/filters.py index af7dc75fd..62a7da6ac 100644 --- a/flopy/mf6/utils/codegen/filters.py +++ b/flopy/mf6/utils/codegen/filters.py @@ -23,6 +23,8 @@ def visit(p, k, v): if ( isinstance(v, dict) and "type" in v + # skip vars removed as of the DFN's MF6 version + and not v.get("removed", False) # support 'prerelease' for now but it's been deprecated for 'developmode' and (developmode or not v.get("developmode", v.get("prerelease", False))) ): From e949c410cc22982f21ee7a5822797cc441694235 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 12 Aug 2026 04:37:56 -0700 Subject: [PATCH 70/90] chore(get-modflow): workaround broken local CA trust store (#2806) Motivated by CI errors caused by unreliably provisioned GitHub Actions runners --- flopy/utils/get_modflow.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/flopy/utils/get_modflow.py b/flopy/utils/get_modflow.py index cd20bf55e..a26da6de6 100755 --- a/flopy/utils/get_modflow.py +++ b/flopy/utils/get_modflow.py @@ -11,6 +11,7 @@ import json import os import shutil +import ssl import sys import tempfile import urllib @@ -94,6 +95,30 @@ def get_request(url, params={}): return urllib.request.Request(url, headers=headers) +def urlopen(request, timeout=10, quiet=False): + """Open a URL, working around a broken/incomplete local CA trust store. + + Some Python installs (e.g. a freshly provisioned Homebrew Python) resolve + ``ssl.create_default_context()`` to a CA bundle that isn't populated, + causing HTTPS requests to fail verification even though the certificate + is fine. If that happens and certifi is already installed, retry once + using certifi's CA bundle. + """ + try: + return urllib.request.urlopen(request, timeout=timeout) + except urllib.error.URLError as err: + if not isinstance(err.reason, ssl.SSLCertVerificationError): + raise + try: + import certifi + except ImportError: + raise + if not quiet: + print("certificate verification failed, retrying with certifi") + context = ssl.create_default_context(cafile=certifi.where()) + return urllib.request.urlopen(request, timeout=timeout, context=context) + + def get_releases(owner=None, repo=None, quiet=False, per_page=None) -> List[str]: """Get list of available releases.""" owner = default_owner if owner is None else owner @@ -111,7 +136,7 @@ def get_releases(owner=None, repo=None, quiet=False, per_page=None) -> List[str] while True: num_tries += 1 try: - with urllib.request.urlopen(request, timeout=10) as resp: + with urlopen(request, timeout=10, quiet=quiet) as resp: result = resp.read() break except urllib.error.HTTPError as err: @@ -153,7 +178,7 @@ def get_release(owner=None, repo=None, tag="latest", quiet=False) -> dict: while True: num_tries += 1 try: - with urllib.request.urlopen(request, timeout=10) as resp: + with urlopen(request, timeout=10, quiet=quiet) as resp: result = resp.read() remaining = resp.headers.get("x-ratelimit-remaining", None) if remaining and int(remaining) <= 10: From b8dfaeb5456708f9034a9701862b3a215c732d21 Mon Sep 17 00:00:00 2001 From: Joshua Larsen Date: Wed, 12 Aug 2026 10:28:29 -0700 Subject: [PATCH 71/90] feat(dis_properties): add grid method to get discretization properties (#2795) * returns dictionary of keyword arguments to build DIS, DISV, and DISU depending on grid type * added cell `.area` calculation via shoelace algorithm to `Grid` * remove deprecated flopy.mf6.utils/reference.py which housed "pre-modelgrid" spatial reference support for MF6 models closes #2388 closes #2661 --- .docs/code.rst | 1 - .docs/md/optional_dependencies.md | 1 - autotest/test_grid.py | 263 ++++++- flopy/discretization/grid.py | 43 +- flopy/discretization/structuredgrid.py | 38 + flopy/discretization/unstructuredgrid.py | 52 ++ flopy/discretization/vertexgrid.py | 27 + flopy/mf6/utils/reference.py | 950 ----------------------- flopy/utils/voronoi.py | 2 +- tach.toml | 8 - 10 files changed, 400 insertions(+), 985 deletions(-) delete mode 100644 flopy/mf6/utils/reference.py diff --git a/.docs/code.rst b/.docs/code.rst index b9a408ff0..ce360eeb1 100644 --- a/.docs/code.rst +++ b/.docs/code.rst @@ -196,7 +196,6 @@ Contents: ./source/flopy.mf6.utils.mfobservation.rst ./source/flopy.mf6.utils.output_util.rst ./source/flopy.mf6.utils.postprocessing.rst - ./source/flopy.mf6.utils.reference.rst ./source/flopy.mf6.utils.lakpak_utils.rst ./source/flopy.mf6.utils.model_splitter.rst diff --git a/.docs/md/optional_dependencies.md b/.docs/md/optional_dependencies.md index 2ce5319c2..6db9b2cb5 100644 --- a/.docs/md/optional_dependencies.md +++ b/.docs/md/optional_dependencies.md @@ -11,7 +11,6 @@ Dependencies for optional features are listed below. These may be installed with | `.export(*.tif)` | **rasterio** | | `.export_array(*.asc)` in `flopy.export.utils` | **scipy.ndimage** | | `.resample_to_grid()` in `flopy.utils.rasters` | **scipy.interpolate** | -| `.interpolate()` in `flopy.mf6.utils.reference` `StructuredSpatialReference` class | **scipy.interpolate** | | `.get_authority_crs()` in `flopy.utils.crs` | **pyproj** >= 2.2.0 | | `.generate_classes()` in `flopy.mf6.utils` | [**modflow-devtools**](https://github.com/MODFLOW-ORG/modflow-devtools) | | `GridIntersect()` in `flopy.utils.gridintersect` | **shapely** | diff --git a/autotest/test_grid.py b/autotest/test_grid.py index 704324da7..2cd4e9096 100644 --- a/autotest/test_grid.py +++ b/autotest/test_grid.py @@ -16,7 +16,13 @@ from autotest.test_dis_cases import case_dis, case_disv from autotest.test_grid_cases import GridCases from flopy.discretization import StructuredGrid, UnstructuredGrid, VertexGrid -from flopy.mf6 import MFSimulation +from flopy.mf6 import ( + MFSimulation, + ModflowGwf, + ModflowGwfdis, + ModflowGwfdisu, + ModflowGwfdisv, +) from flopy.modflow import Modflow, ModflowDis from flopy.utils import import_optional_dependency from flopy.utils.crs import get_authority_crs @@ -1903,3 +1909,258 @@ def test_unstructured_grid_get_node(): with pytest.raises(IndexError, match=r"Node .* out of range"): ug.get_node(200) + + +@pytest.mark.mf6 +def test_structured_mf6_gridprops(example_data_path): + sim = MFSimulation.load(sim_ws=example_data_path / "mf6-freyberg") + gwf = sim.get_model() + dis = gwf.dis + modelgrid = gwf.modelgrid + + new_sim = MFSimulation() + new_gwf = ModflowGwf(new_sim) + new_dis = ModflowGwfdis(new_gwf, **modelgrid.dis_properties()) + attrs = ("delc", "delr", "top", "botm", "idomain", "xorigin", "yorigin", "angrot") + for attr in attrs: + v0 = getattr(dis, attr).array + v1 = getattr(new_dis, attr).array + if attr in ("xorigin", "yorigin", "angrot") and v0 is None: + v0 = 0 + np.testing.assert_allclose( + v0, v1, err_msg=f"{attr} not consistent with valid array data" + ) + + +def test_structured_mf2005_gridprops(example_data_path): + mf = Modflow.load("freyberg.nam", model_ws=example_data_path / "freyberg") + dis = mf.dis + modelgrid = mf.modelgrid + modelgrid.set_coord_info(0, 0, 0) + + new_model = Modflow() + new_dis = ModflowDis(new_model, **modelgrid.dis_properties(mf2005=True)) + attrs = ("delc", "delr", "top", "botm", "nlay", "nrow", "ncol") + for attr in attrs: + v0 = getattr(dis, attr) + v1 = getattr(new_dis, attr) + if hasattr(v0, "array"): + v0 = v0.array + v1 = v1.array + + np.testing.assert_allclose( + v0, v1, err_msg=f"{attr} not consistent with valid array data" + ) + + +@pytest.mark.mf6 +def test_vertex_mf6_gridprops(example_data_path): + sim = MFSimulation.load(sim_ws=example_data_path / "mf6" / "test003_gwftri_disv") + gwf = sim.get_model() + disv = gwf.disv + modelgrid = gwf.modelgrid + + new_sim = MFSimulation() + new_gwf = ModflowGwf(new_sim) + new_disv = ModflowGwfdisv(new_gwf, **modelgrid.disv_properties()) + + attrs = ( + "vertices", + "top", + "botm", + "idomain", + "xorigin", + "yorigin", + "angrot", + "cell2d", + ) + for attr in attrs: + v0 = getattr(disv, attr).array + v1 = getattr(new_disv, attr).array + if attr in ("xorigin", "yorigin", "angrot") and v0 is None: + v0 = 0 + + if attr in ("cell2d", "vertices"): + for col in v0.dtype.names: + np.testing.assert_allclose( + v0[col], + v1[col], + err_msg=f"{attr} not consistent with valid array data", + ) + else: + np.testing.assert_allclose( + v0, v1, err_msg=f"{attr} not consistent with valid array data" + ) + + +@pytest.mark.mf6 +def test_unstructured_mf6_gridprops(example_data_path): + sim = MFSimulation.load(sim_ws=example_data_path / "mf6" / "test006_gwf3") + gwf = sim.get_model() + disu = gwf.disu + modelgrid = gwf.modelgrid + + new_sim = MFSimulation() + new_gwf = ModflowGwf(new_sim) + dis_props = modelgrid.disu_properties() + dis_props["area"] = disu.area.array + dis_props["cl12"] = disu.cl12.array + new_disu = ModflowGwfdisu(new_gwf, **dis_props) + + attrs = ("top", "bot", "iac", "ja", "nodes", "ihc", "xorigin", "yorigin", "angrot") + for attr in attrs: + v0 = getattr(disu, attr).array + v1 = getattr(new_disu, attr).array + if attr in ("xorigin", "yorigin", "angrot") and v0 is None: + v0 = 0 + + np.testing.assert_allclose( + v0, v1, err_msg=f"{attr} not consistent with valid array data" + ) + + +def test_unstructured_mf6_gridprops2(): + nnodes = 2 + top = np.ones((nnodes,)) + botm = np.zeros((nnodes,)) + area = np.full((nnodes,), 10) + idomain = np.ones((nnodes,), dtype=int) + iac = [2, 2] + ja = [1, 2, 2, 1] + ihc = [0, 1, 0, 1] + cl12 = [ + 0, + 10, + 0, + 10, + ] + hwva = [ + 0, + 100, + 0, + 100, + ] + + vertices = [ + [0, 0, 0], + [1, 0, 10], + [2, 10, 10], + [3, 10, 0], + [4, 10, 20], + [5, 20, 20], + ] + + cell2d = [[0, 5, 5, 5, 0, 1, 2, 3, 0], [1, 15, 5, 5, 3, 2, 4, 5, 3]] + xoff = 100 + yoff = 100 + angrot = 10 + + sim = MFSimulation() + gwf = ModflowGwf(sim, modelname="usg_test2") + disu = ModflowGwfdisu( + gwf, + xorigin=xoff, + yorigin=yoff, + angrot=angrot, + nodes=nnodes, + nja=len(ja), + nvert=len(vertices), + top=top, + bot=botm, + area=area, + idomain=idomain, + iac=iac, + ja=ja, + ihc=ihc, + cl12=cl12, + hwva=hwva, + vertices=vertices, + cell2d=cell2d, + ) + modelgrid = gwf.modelgrid + + sim2 = MFSimulation() + gwf2 = ModflowGwf(sim2) + disu2 = ModflowGwfdisu(gwf2, cl12=cl12, hwva=hwva, **modelgrid.disu_properties()) + + attrs = ( + "top", + "bot", + "iac", + "ja", + "nodes", + "cl12", + "hwva", + "ihc", + "cell2d", + "vertices", + "xorigin", + "yorigin", + "angrot", + ) + for attr in attrs: + v0 = getattr(disu, attr).array + v1 = getattr(disu2, attr).array + if attr in ("cell2d", "vertices"): + for col in v0.dtype.names: + np.testing.assert_allclose( + v0[col], + v1[col], + err_msg=f"{attr} column: {col} not " + f"consistent with valid array data", + ) + else: + np.testing.assert_allclose( + v0, v1, err_msg=f"{attr} not consistent with valid array data" + ) + + +def test_area(): + import random + + nlay = 1 + nrow = 1 + ncol = 1 + dy = random.random() * 10 + dx = random.random() * 10 + valid_area = dx * dy + delc = np.full((nrow,), dy) + delr = np.full((ncol,), dx) + top = np.ones((nrow, ncol)) + botm = np.zeros((nlay, nrow, ncol), dtype=int) + sgrid = StructuredGrid(delc=delc, delr=delr, nlay=1, top=top, botm=botm) + cell_area = sgrid.area + np.testing.assert_allclose( + [ + valid_area, + ], + cell_area, + err_msg="shoelace algorithm not returning valid area within tolerance", + ) + + # triangle test + x1 = random.random() * 10 + x2 = x1 / 2 + y2 = random.random() * 10 + verts = np.array([[0, 0, 0], [1, x1, 0], [2, x2, y2]]) + # a = 0.5 * b * h + valid_area = 0.5 * x1 * y2 + + xc = np.mean(verts.T[1]) + yc = np.mean(verts.T[2]) + cell2d = [ + (0, xc, yc, 4, 0, 1, 2, 0), + ] + nlay = 1 + top = np.ones((len(cell2d),)) + botm = np.zeros((nlay, len(cell2d))) + + vgrid = VertexGrid(vertices=verts, cell2d=cell2d, nlay=nlay, top=top, botm=botm) + cell_area = vgrid.area + np.testing.assert_allclose( + [ + valid_area, + ], + cell_area, + err_msg="shoelace algorithm not returning valid area within tolerance", + ) diff --git a/flopy/discretization/grid.py b/flopy/discretization/grid.py index 0a50597c9..a51845e83 100644 --- a/flopy/discretization/grid.py +++ b/flopy/discretization/grid.py @@ -411,29 +411,6 @@ def laycbd(self): else: return self._laycbd - @property - def cell_area(self): - """ - Use shoelace algorithm for non-self-intersecting polygons to - calculate area. - - Returns - ------- - area : np.ndarray - numpy array of cell areas in L^2 - """ - from ..plot.plotutil import UnstructuredPlotUtilities - - xverts, yverts = self.cross_section_vertices - xverts, yverts = UnstructuredPlotUtilities.irregular_shape_patch(xverts, yverts) - area_x2 = np.zeros((1, len(xverts))) - for i in range(xverts.shape[-1]): - # calculate the determinant of each line in polygon - area_x2 += xverts[:, i - 1] * yverts[:, i] - yverts[:, i - 1] * xverts[:, i] - - area = np.abs(area_x2 / 2.0) - return np.ravel(area) - @property def cell_thickness(self): """ @@ -558,6 +535,26 @@ def xyzextent(self): np.max(self.xyzvertices[2]), ) + @property + def area(self): + """ + Returns a numpy array of cell areas calculated using the shoelace algorithm + + """ + # irregular_shape_patch + from ..plot.plotutil import UnstructuredPlotUtilities + + # when looping through to create determinants, need to start at -1 + xverts, yverts = self.cross_section_vertices + xverts, yverts = UnstructuredPlotUtilities.irregular_shape_patch(xverts, yverts) + area_x2 = np.zeros((1, len(xverts))) + for i in range(xverts.shape[-1]): + # calculate the determinant of each line in polygon + area_x2 += xverts[:, i - 1] * yverts[:, i] - yverts[:, i - 1] * xverts[:, i] + + area = np.abs(area_x2 / 2.0) + return np.ravel(area) + @property def grid_lines(self): raise NotImplementedError("must define grid_lines in child class") diff --git a/flopy/discretization/structuredgrid.py b/flopy/discretization/structuredgrid.py index 7aa6bcc40..690cf306e 100644 --- a/flopy/discretization/structuredgrid.py +++ b/flopy/discretization/structuredgrid.py @@ -759,6 +759,44 @@ def map_polygons(self): return self._polygons + def dis_properties(self, mf2005=False): + """ + Method to get DIS package properties + + Parameters + ---------- + mf2005 : bool + flag to get legacy mf2005/mfnwt discretization package properties + from the modelgrid object + + Returns + ------- + dict : dictionary of discretization properties that can be used to build a + DIS package + """ + dis_props = { + "delc": self.__delc, + "delr": self.__delr, + "top": self.top, + "botm": self.botm, + "nlay": self.nlay, + "nrow": self.nrow, + "ncol": self.ncol, + } + + if mf2005: + if self.is_valid: + dis_props["xul"] = self.xvertices[0, 0] + dis_props["yul"] = self.yvertices[0, 0] + dis_props["rotation"] = self.angrot + else: + dis_props["xorigin"] = self.xoffset + dis_props["yorigin"] = self.yoffset + dis_props["angrot"] = self.angrot + dis_props["idomain"] = self.idomain + + return dis_props + def to_geodataframe(self): """ Returns a geopandas GeoDataFrame of the model grid diff --git a/flopy/discretization/unstructuredgrid.py b/flopy/discretization/unstructuredgrid.py index 2d81e7b4a..20e67a6a8 100644 --- a/flopy/discretization/unstructuredgrid.py +++ b/flopy/discretization/unstructuredgrid.py @@ -591,6 +591,58 @@ def map_polygons(self): return copy.copy(self._polygons) + def disu_properties(self, mfusg=False): + """ + Method that returns disu properties from the grid for constructing + DISU packages for MFUSG and MF6. Note: not all required information for + DISU construction is stored in the UnstructuredGrid class, CL12 and HWVA + is not available from the Grid. Please double-check the properties returned + on a case by case basis and fill in where necessary for individual applications + + Parameters + ---------- + mfusg : bool + boolean flag for specifying modflow USG DISU properties + + Returns + ------- + dict : dictionary of unstructured discretization properties + """ + + dis_props = { + "top": self._top, + "bot": self._botm, + "iac": self._iac, + "ja": self._ja, + } + if mfusg: + dis_props["nodelay"] = self.ncpl + dis_props["ivc"] = np.where(self._ihc < 1, 1, 0) + + else: + dis_props["nodes"] = self.nnodes + dis_props["ihc"] = self._ihc + dis_props["idomain"] = self.idomain + dis_props["xorigin"] = self.xoffset + dis_props["yorigin"] = self.yoffset + dis_props["angrot"] = self.angrot + + if self.is_valid: + dis_props["vertices"] = [[int(i) for i in v] for v in self._vertices] + cell2d = [] + for ix, iv in enumerate(self._iverts): + c2d = tuple( + [ix, self._xc[ix], self._yc[ix], len(iv)] + + [int(i) for i in list(iv)] + ) + cell2d.append(c2d) + dis_props["cell2d"] = cell2d + + if self.is_valid: + dis_props["area"] = self.area + + return dis_props + def to_geodataframe(self): """ Returns a geopandas GeoDataFrame of the model grid diff --git a/flopy/discretization/vertexgrid.py b/flopy/discretization/vertexgrid.py index 66f23dd57..7794e8461 100644 --- a/flopy/discretization/vertexgrid.py +++ b/flopy/discretization/vertexgrid.py @@ -300,6 +300,33 @@ def map_polygons(self): return copy.copy(self._polygons) + def disv_properties(self): + """ + Method to get DISV package properties + + Returns + ------- + dict : dictionary of properties that can be used to build a DISV package + """ + dis_props = { + "nlay": self.nlay, + "ncpl": self.ncpl, + "vertices": self._vertices, + "top": self.top, + "botm": self.botm, + "idomain": self.idomain, + "xorigin": self.xoffset, + "yorigin": self.yoffset, + "angrot": self.angrot, + } + + if self._cell2d is None and self._cell1d is not None: + dis_props["cell1d"] = self._cell1d + else: + dis_props["cell2d"] = self._cell2d + + return dis_props + def to_geodataframe(self): """ Returns a geopandas GeoDataFrame of the model grid diff --git a/flopy/mf6/utils/reference.py b/flopy/mf6/utils/reference.py deleted file mode 100644 index 3517f12df..000000000 --- a/flopy/mf6/utils/reference.py +++ /dev/null @@ -1,950 +0,0 @@ -""" -Module spatial referencing for flopy model objects - -.. deprecated:: 3.9 - This module will be removed in FloPy 3.10+. Use - the :mod:`flopy.discretization` module instead. - -""" - -import numpy as np - - -class StructuredSpatialReference: - """ - a simple class to locate the model grid in x-y space - - .. deprecated:: 3.9 - This class will be removed in FloPy 3.10+. Use - :class:`~flopy.discretization.structuredgrid.StructuredGrid` - instead. - - Parameters - ---------- - - delr : numpy ndarray - the model discretization delr vector - - delc : numpy ndarray - the model discretization delc vector - - lenuni : int - the length units flag from the discretization package - - xul : float - the x coordinate of the upper left corner of the grid - - yul : float - the y coordinate of the upper left corner of the grid - - rotation : float - the counter-clockwise rotation (in degrees) of the grid - - proj4_str: str - a PROJ4 string that identifies the grid in space. warning: case - sensitive! - - Attributes - ---------- - xedge : ndarray - array of column edges - - yedge : ndarray - array of row edges - - xgrid : ndarray - numpy meshgrid of xedges - - ygrid : ndarray - numpy meshgrid of yedges - - xcenter : ndarray - array of column centers - - ycenter : ndarray - array of row centers - - xcentergrid : ndarray - numpy meshgrid of column centers - - ycentergrid : ndarray - numpy meshgrid of row centers - - Notes - ----- - - xul and yul can be explicitly (re)set after SpatialReference - instantiation, but only before any of the other attributes and methods are - accessed - - """ - - def __init__( - self, - delr=1.0, - delc=1.0, - lenuni=1, - nlay=1, - xul=None, - yul=None, - rotation=0.0, - proj4_str=None, - **kwargs, - ): - self.delc = np.atleast_1d(np.array(delc)) - self.delr = np.atleast_1d(np.array(delr)) - self.nlay = nlay - self.lenuni = lenuni - self.proj4_str = proj4_str - self._reset() - self.set_spatialreference(xul, yul, rotation) - - @classmethod - def from_namfile_header(cls, namefile): - # check for reference info in the nam file header - header = [] - with open(namefile) as f: - for line in f: - if not line.startswith("#"): - break - header.extend(line.strip().replace("#", "").split(",")) - - xul, yul = None, None - rotation = 0.0 - proj4_str = None - start_datetime = "1/1/1970" - - for item in header: - if "xul" in item.lower(): - try: - xul = float(item.split(":")[1]) - except: - pass - elif "yul" in item.lower(): - try: - yul = float(item.split(":")[1]) - except: - pass - elif "rotation" in item.lower(): - try: - rotation = float(item.split(":")[1]) - except: - pass - elif "proj4_str" in item.lower(): - try: - proj4_str = ":".join(item.split(":")[1:]).strip() - except: - pass - elif "start" in item.lower(): - try: - start_datetime = item.split(":")[1].strip() - except: - pass - - return ( - cls(xul=xul, yul=yul, rotation=rotation, proj4_str=proj4_str), - start_datetime, - ) - - def __setattr__(self, key, value): - reset = True - if key == "delr": - super().__setattr__("delr", np.atleast_1d(np.array(value))) - elif key == "delc": - super().__setattr__("delc", np.atleast_1d(np.array(value))) - elif key == "xul": - super().__setattr__("xul", float(value)) - elif key == "yul": - super().__setattr__("yul", float(value)) - elif key == "rotation": - super().__setattr__("rotation", float(value)) - elif key == "lenuni": - super().__setattr__("lenuni", int(value)) - elif key == "nlay": - super().__setattr__("nlay", int(value)) - else: - super().__setattr__(key, value) - reset = False - if reset: - self._reset() - - def reset(self, **kwargs): - for key, value in kwargs.items(): - setattr(self, key, value) - - def _reset(self): - self._xgrid = None - self._ygrid = None - self._ycentergrid = None - self._xcentergrid = None - - @property - def nrow(self): - return self.delc.shape[0] - - @property - def ncol(self): - return self.delr.shape[0] - - def __eq__(self, other): - if not isinstance(other, StructuredSpatialReference): - return False - if other.xul != self.xul: - return False - if other.yul != self.yul: - return False - if other.rotation != self.rotation: - return False - if other.proj4_str != self.proj4_str: - return False - return True - - @classmethod - def from_gridspec(cls, gridspec_file, lenuni=0): - f = open(gridspec_file, "r") - raw = f.readline().strip().split() - nrow = int(raw[0]) - ncol = int(raw[1]) - raw = f.readline().strip().split() - xul, yul, rot = float(raw[0]), float(raw[1]), float(raw[2]) - delr = [] - j = 0 - while j < ncol: - raw = f.readline().strip().split() - for r in raw: - if "*" in r: - rraw = r.split("*") - for n in range(int(rraw[0])): - delr.append(float(rraw[1])) - j += 1 - else: - delr.append(float(r)) - j += 1 - delc = [] - i = 0 - while i < nrow: - raw = f.readline().strip().split() - for r in raw: - if "*" in r: - rraw = r.split("*") - for n in range(int(rraw[0])): - delc.append(float(rraw[1])) - i += 1 - else: - delc.append(float(r)) - i += 1 - f.close() - return cls( - np.array(delr), - np.array(delc), - lenuni, - xul=xul, - yul=yul, - rotation=rot, - ) - - @property - def attribute_dict(self): - return { - "xul": self.xul, - "yul": self.yul, - "rotation": self.rotation, - "proj4_str": self.proj4_str, - } - - def set_spatialreference(self, xul=None, yul=None, rotation=0.0): - """ - set spatial reference - can be called from model instance - """ - - # Set origin and rotation - if xul is None: - self.xul = 0.0 - else: - self.xul = xul - if yul is None: - self.yul = np.add.reduce(self.delc) - else: - self.yul = yul - self.rotation = rotation - self._reset() - - def __repr__(self): - s = f"xul:{self.xul: Date: Wed, 12 Aug 2026 12:24:31 -0700 Subject: [PATCH 72/90] fix(get-modflow): use urlopen for download (#2807) Followup on #2806 --- flopy/utils/get_modflow.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flopy/utils/get_modflow.py b/flopy/utils/get_modflow.py index a26da6de6..73e791edf 100755 --- a/flopy/utils/get_modflow.py +++ b/flopy/utils/get_modflow.py @@ -464,7 +464,11 @@ def run_main( else: if not quiet: print(f"downloading '{download_url}' to '{download_pth}'") - urllib.request.urlretrieve(download_url, download_pth) + with urlopen( + urllib.request.Request(download_url), timeout=120, quiet=quiet + ) as resp: + with open(download_pth, "wb") as f: + shutil.copyfileobj(resp, f) if subset: if isinstance(subset, str): From 301eb8fff71cca74a42fefd3e191dd78515802f8 Mon Sep 17 00:00:00 2001 From: Mike Taves Date: Fri, 14 Aug 2026 00:11:22 +1200 Subject: [PATCH 73/90] chore: use dependency groups (#2809) This PR separates a few optional dependencies into dependency groups, as defined by PEP 735. The optional dependencies (or "extras") are kept, and are intended for user-facing install options: * optional: this is the primary group, with documentation and support functions * codegen: includes other dependencies needed for code generation Dependency groups are intended for internal or developer-facing install options: * lint, test and dev are re-listed as dependency groups * doc is renamed as docs group, plural to match the folder name "docs" * The dev group is similar to as it was as an extra, but doc (now docs) is removed, as not all developers need to create docs (? maybe?); nevertheless it can be installed via --group docs A few sections of DEVELOPER.md were revised to describe how to install the extras or dependency groups in different Python environments. The pytest header (in autotest/conftest.py) is modified to only show "optional" packages and not the "test" dependency group packages. If this is important, it could be re-enabled, but it would need to parse the group directly from pyproject.toml, as this information is not part of the project's metadata. --- .github/workflows/optional.yml | 8 ++--- .github/workflows/rtd.yml | 2 +- DEVELOPER.md | 36 ++++++++++++++----- autotest/conftest.py | 8 ++--- flopy/utils/utl_import.py | 2 +- pyproject.toml | 65 ++++++++++++++++++++-------------- 6 files changed, 76 insertions(+), 45 deletions(-) diff --git a/.github/workflows/optional.yml b/.github/workflows/optional.yml index 0eb50aad9..1e644a47d 100644 --- a/.github/workflows/optional.yml +++ b/.github/workflows/optional.yml @@ -32,8 +32,8 @@ jobs: with: cache-dependency-glob: "**/pyproject.toml" - - name: Install FloPy - run: uv sync --extra test + - name: Install FloPy without optional dependencies + run: uv sync --only-group test - name: Install other dependencies run: | @@ -41,14 +41,14 @@ jobs: # If matrix.optdeps is "some" remove 3 optional dependencies # selected randomly using the current date as the seed. if [[ ! "${{ matrix.optdeps }}" == *"no"* ]]; then - uv pip install ".[optional]" + uv sync --extra optional fi if [[ "${{ matrix.optdeps }}" == *"some"* ]]; then deps=$(sed '/optional =/,/]/!d' pyproject.toml | sed -e '1d;$d' -e 's/\"//g' -e 's/,//g' | tr -d ' ' | cut -f 1 -d ';') rmvd=$(echo $deps | tr ' ' '\n' | shuf --random-source <(yes date +%d.%m.%y) | head -n 3) echo "Removing optional dependencies: $rmvd" >> removed_dependencies.txt cat removed_dependencies.txt - uv pip uninstall --yes $rmvd + uv pip uninstall $rmvd fi - name: Upload removed dependencies log diff --git a/.github/workflows/rtd.yml b/.github/workflows/rtd.yml index 7ebd0bbe7..3e21502f3 100644 --- a/.github/workflows/rtd.yml +++ b/.github/workflows/rtd.yml @@ -96,7 +96,7 @@ jobs: working-directory: modflow6 run: | pixi run -e rtd install - pixi run -e rtd pip install "../flopy[optional,test]" + pixi run -e rtd pip install "../flopy[optional]" - name: Workaround OpenGL issue on Linux if: runner.os == 'Linux' diff --git a/DEVELOPER.md b/DEVELOPER.md index 0f0943a75..bc275dd5d 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -62,21 +62,39 @@ git config blame.ignoreRevsFile .git-blame-ignore-revs This project has historically aimed to support a wide range of [Python versions](https://devguide.python.org/versions/). In current and future development this window may narrow to follow [SPEC 0](https://scientific-python.org/specs/spec-0000/#support-window) instead. -Install Python >=3.10 via [standalone download](https://www.python.org/downloads/) or a distribution like [Anaconda](https://www.anaconda.com/products/individual) or [miniconda](https://docs.conda.io/en/latest/miniconda.html). +Install Python >=3.10 via [standalone download](https://www.python.org/downloads/), [uv](https://docs.astral.sh/uv/), or a conda-like distribution like [Anaconda](https://www.anaconda.com/products/individual), [miniconda](https://docs.conda.io/en/latest/miniconda.html) or [miniforge](https://github.com/conda-forge/miniforge) . -Then install FloPy and core dependencies from the project root: +Then install FloPy and core dependencies from the project root path: ```sh -pip install . +pip install -e . ``` -The FloPy package has a number of [optional dependencies](.docs/optional_dependencies.md), as well as extra dependencies required for linting, testing, and building documentation. Extra dependencies are listed in the `test`, `lint`, `optional`, and `doc` groups under the `[project.optional-dependencies]` section in `pyproject.toml`. Core, linting, testing and optional dependencies are included in the Conda environment in `etc/environment.yml`. Only core dependencies are included in the PyPI package — to install extra dependency groups with pip, use `pip install ".[]"`. For instance, to install all development dependencies: +The `-e` option installs FloPy as an "editable" package to implement and test changes iteratively, and is recommended for developers. + +FloPy has a number of user-facing [optional dependencies](.docs/md/optional_dependencies.md) and developer-facing dependencies needed for linting and testing. These are handled differently, depending on the Python developer environment. + +#### Pip-like + +Pip and related tools (pipenv, uv) declare their optional dependencies under the `[project.optional-dependencies]` section in `pyproject.toml`, and can be installed with: ```sh -pip install ".[dev]" +pip install -e ".[optional]" ``` -Alternatively, with Anaconda or Miniconda: +Developer-facing dependencies used for internal development, and are grouped by the following group labels: `test`, `lint` and `docs`. A `dev` group combines `test` and `lint` with the `optional` and `codegen` extras, and is recommended for developers. These dependencies are listed under the `[dependency-groups]` section in `pyproject.toml` + +Dependency groups can be installed with `uv` or `pip` version 25.1 (2025-04-26), which support the `--group ` option: + +```sh +pip install --group dev +``` + +Note that `uv sync` includes the `dev` group by default. + +#### Conda-like + +Conda environments can be created using a file `etc/environment.yml`, which includes optional dependencies, and dependencies for development. This development environment can be installed and activated with: ```sh conda env create -f etc/environment.yml @@ -180,10 +198,10 @@ To convert a Python example script to an `.ipynb` notebook, run: jupytext --from py --to ipynb path/to/script.py ``` -To work with `.ipynb` notebooks from a browser interface, you will need `jupyter` installed (`jupyter` is included with the `test` optional dependency group in `pyproject.toml`). Some of the notebooks use testing dependencies and [optional dependencies](.docs/optional_dependencies.md) as well. The conda environment provided in `etc/environment.yml` already includes all dependencies needed to run the examples. To install all development dependencies at once using `pip`: +To work with `.ipynb` notebooks from a browser interface, you will need `jupyter` installed (`jupyter` is included with the `test` dependency group in `pyproject.toml`). Some of the notebooks use testing dependencies and [optional dependencies](.docs/md/optional_dependencies.md) as well. The conda environment provided in `etc/environment.yml` already includes all dependencies needed to run the examples. To install all development dependencies at once using `pip`: ```sh -pip install ".[dev]" +pip install --group dev ``` To start a local Jupyter notebook server, run: @@ -242,7 +260,7 @@ Each example should create and (attempt to) dispose of its own isolated temporar ## Tests -To run the tests you will need `pytest` and a few plugins, including [`pytest-xdist`](https://pytest-xdist.readthedocs.io/en/latest/), [`pytest-dotenv`](https://github.com/quiqua/pytest-dotenv), and [`pytest-benchmark`](https://pytest-benchmark.readthedocs.io/en/latest/index.html). Test dependencies are specified in the `test` extras group in `pyproject.toml` (with pip, use `pip install ".[test]"`). Test dependencies are included in the Conda environment `etc/environment`. +To run the tests you will need `pytest` and a few plugins, including [`pytest-xdist`](https://pytest-xdist.readthedocs.io/en/latest/), [`pytest-dotenv`](https://github.com/quiqua/pytest-dotenv), and [`pytest-benchmark`](https://pytest-benchmark.readthedocs.io/en/latest/index.html). See details installing test dependencies for [pip-like](#pip-like) or [Conda-like](#conda-like) environments. **Note:** tests require the [`modflow-devtools`](https://github.com/MODFLOW-ORG/modflow-devtools) package, which is a grab bag of utilities and `pytest` fixtures shared by FloPy, MODFLOW 6, and other related projects. If you see testing errors that don't seem related to the contents of the tests, updating to the latest `modflow-devtools` is recommended as a first troubleshooting step. diff --git a/autotest/conftest.py b/autotest/conftest.py index 036ce9e67..2684820b7 100644 --- a/autotest/conftest.py +++ b/autotest/conftest.py @@ -134,10 +134,10 @@ def pytest_report_header(config): except metadata.PackageNotFoundError: items.append(f"{name} (not found)") lines.append("required packages: " + ", ".join(items)) - for optional in ["optional", "test"]: + for group in ["optional"]: installed = [] not_found = [] - for name in extra[optional]: + for name in extra[group]: if name in processed: continue processed.add(name) @@ -147,7 +147,7 @@ def pytest_report_header(config): except metadata.PackageNotFoundError: not_found.append(name) if installed: - lines.append(f"{optional} packages: {', '.join(installed)}") + lines.append(f"{group} packages: {', '.join(installed)}") if not_found: - lines.append(f"{optional} packages not found: {', '.join(not_found)}") + lines.append(f"{group} packages not found: {', '.join(not_found)}") return "\n".join(lines) diff --git a/flopy/utils/utl_import.py b/flopy/utils/utl_import.py index a936344bf..688005cfd 100644 --- a/flopy/utils/utl_import.py +++ b/flopy/utils/utl_import.py @@ -45,7 +45,7 @@ from .parse_version import Version -# Update .docs/optional_dependencies.md when updating versions! +# Update .docs/md/optional_dependencies.md when updating versions! VERSIONS = { "shapefile": "2.0.0", diff --git a/pyproject.toml b/pyproject.toml index eb0b5bb60..55bfb0c58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,6 @@ dependencies = [ dynamic = ["version", "readme"] [project.optional-dependencies] -dev = ["flopy[codegen,lint,test,optional,doc]", "tach"] codegen = [ "Jinja2>=3.0", "boltons", @@ -41,28 +40,6 @@ codegen = [ "tomli", "tomli-w" ] -lint = ["cffconvert", "codespell[toml] >=2.2.2", "ruff"] -test = [ - "flopy[lint]", - "boltons", - "coverage !=7.6.5", - "flaky", - "filelock", - "jupyter", - "jupyter_client >=8.4.0", # avoid datetime.utcnow() deprecation warning - "jupytext", - "modflow-devtools>=1.7.0,!=1.9.0,<2", - "pytest !=8.1.0", - "pytest-benchmark", - "pytest-cov", - "pytest-dotenv", - "pytest-xdist", - "pyzmq >=25.1.2", - "syrupy <5.0.0", - "tomli", - "tomli-w", - "virtualenv" -] optional = [ "affine", "descartes", @@ -86,9 +63,38 @@ optional = [ "h5py", "scikit-learn" ] -doc = [ - "flopy[optional]", - "ipython[kernel]", + +[dependency-groups] +lint = [ + "cffconvert", + "codespell[toml] >=2.2.2", + "ruff", +] +test = [ + "boltons", + "coverage !=7.6.5", + "filelock", + "flaky", + "jupyter", + "jupyter_client >=8.4.0", # avoid datetime.utcnow() deprecation warning + "jupytext", + "modflow-devtools>=1.7.0,!=1.9.0,<2", + "pytest !=8.1.0", + "pytest-benchmark", + "pytest-cov", + "pytest-dotenv", + "pytest-xdist", + "pyzmq >=25.1.2", + "syrupy <5.0.0", + "tomli", + "tomli-w", + "virtualenv", + {include-group = "lint"}, +] +docs = [ + "flopy[optional]", # self-reference from project.optional-dependencies + "ipython", + "ipykernel", "jupytext", "myst-parser", "nbconvert <7.14.0", @@ -98,6 +104,13 @@ doc = [ "sphinx ==7.1.2", "sphinx-rtd-theme >=1", ] +dev = [ + "flopy[codegen,optional]", # self-reference from project.optional-dependencies + "tach", + {include-group = "lint"}, + {include-group = "test"}, + # {include-group = "docs"}, +] [project.scripts] get-modflow = "flopy.utils.get_modflow:cli_main" From 99982f54ecbef960c0ac8cf8bc6338f68f575f9c Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 13 Aug 2026 06:19:39 -0700 Subject: [PATCH 74/90] test: add missing shapely requires_pkg markers (#2810) Some requires_pkg markers were missing, causing optional dependency CI testing to fail --- autotest/test_export.py | 4 ++-- autotest/test_gridgen.py | 4 ++-- autotest/test_model_splitter.py | 2 +- autotest/test_modeltime.py | 2 ++ autotest/test_particledata.py | 2 +- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/autotest/test_export.py b/autotest/test_export.py index 9fc25638a..1812418ef 100644 --- a/autotest/test_export.py +++ b/autotest/test_export.py @@ -241,7 +241,7 @@ def test_freyberg_export(function_tmpdir, example_data_path): assert m.drn.stress_period_data.mg.angrot == m.modelgrid.angrot -@requires_pkg("pyshp", name_map={"pyshp": "shapefile"}) +@requires_pkg("pyshp", "shapely", name_map={"pyshp": "shapefile"}) @pytest.mark.parametrize("missing_arrays", [True, False]) @pytest.mark.slow def test_disu_export(function_tmpdir, missing_arrays): @@ -623,7 +623,7 @@ def test_array3d_export_structured(function_tmpdir): ] -@requires_pkg("pyshp", name_map={"pyshp": "shapefile"}) +@requires_pkg("pyshp", "shapely", name_map={"pyshp": "shapefile"}) def test_array3d_export_unstructured(function_tmpdir): from shapefile import Reader diff --git a/autotest/test_gridgen.py b/autotest/test_gridgen.py index 27c997aec..b2589383e 100644 --- a/autotest/test_gridgen.py +++ b/autotest/test_gridgen.py @@ -59,7 +59,7 @@ def get_structured_grid(): @requires_exe("gridgen") -@requires_pkg("pyshp", name_map={"pyshp": "shapefile"}) +@requires_pkg("pyshp", "shapely", name_map={"pyshp": "shapefile"}) @pytest.mark.parametrize("grid_type", ["vertex", "unstructured"]) def test_add_active_domain(function_tmpdir, grid_type): bgrid = get_structured_grid() @@ -95,7 +95,7 @@ def test_add_active_domain(function_tmpdir, grid_type): @requires_exe("gridgen") -@requires_pkg("pyshp", name_map={"pyshp": "shapefile"}) +@requires_pkg("pyshp", "shapely", name_map={"pyshp": "shapefile"}) @pytest.mark.parametrize("grid_type", ["vertex", "unstructured"]) def test_add_refinement_feature(function_tmpdir, grid_type): bgrid = get_structured_grid() diff --git a/autotest/test_model_splitter.py b/autotest/test_model_splitter.py index 0f0d46515..60bbb8330 100644 --- a/autotest/test_model_splitter.py +++ b/autotest/test_model_splitter.py @@ -1031,7 +1031,7 @@ def test_unstructured_complex_disu(function_tmpdir): @requires_exe("mf6") -@requires_pkg("pymetis", "scipy") +@requires_pkg("pymetis", "scipy", "shapely") def test_multi_model(function_tmpdir): from scipy.spatial import KDTree diff --git a/autotest/test_modeltime.py b/autotest/test_modeltime.py index fbdb54e33..61729cb50 100644 --- a/autotest/test_modeltime.py +++ b/autotest/test_modeltime.py @@ -3,6 +3,7 @@ import numpy as np import pandas as pd import pytest +from modflow_devtools.markers import requires_pkg import flopy from flopy.discretization.modeltime import ModelTime @@ -360,6 +361,7 @@ def test_from_headers_test005_advgw_tidal(example_data_path): assert np.allclose(mt.tsmult, tdis.perioddata.get_data()["tsmult"]) +@requires_pkg("pyshp", "shapely", name_map={"pyshp": "shapefile"}) def test_from_headers_disu(function_tmpdir): from autotest.test_export import disu_sim as _disu_sim diff --git a/autotest/test_particledata.py b/autotest/test_particledata.py index bc3036339..4a7fa56bf 100644 --- a/autotest/test_particledata.py +++ b/autotest/test_particledata.py @@ -676,7 +676,7 @@ def test_nodeparticledata_to_prp_dis_1_per_face(): assert len(rpts) == num_cells * 6 -@requires_pkg("pyshp", name_map={"pyshp": "shapefile"}) +@requires_pkg("pyshp", "shapely", name_map={"pyshp": "shapefile"}) def test_nodeparticledata_prp_disv_big(function_tmpdir): Lx = 10000.0 Ly = 10500.0 From 081bb9dacf7f02ca7c72f4c67c43f6b6f63264a5 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 13 Aug 2026 06:43:04 -0700 Subject: [PATCH 75/90] docs(examples): fix multiple example notebooks (#2811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close #2808 and fix some other example notebook failures * grid_intersection_example.py: removed leading # %% before the YAML front-matter, which was causing jupytext to fail to parse and consequently fail to auto-detect a kernel * feat_working_stack_examples.py: change df_flux.groupby(lambda x: x.split("_")[-1], axis=1) → df_flux.T.groupby(lambda x: x.split("_")[-1]), since pandas 3.0 removed the axis parameter from DataFrame.groupby() * plot_map_view_example.py and plot_cross_section_example.py: these used to use PyCharm-style cell markers until chore(dependencies): update conda environment #2414, but that PR missed a few # + lines that now have no matching closing markers. Jupytext's light-format parser swallows everything after these into one giant cell until it processes some markdown further down that superficially looks like another # + marker, which messes up the notebook's cell rendering. --- .docs/Notebooks/feat_working_stack_examples.py | 2 +- .docs/Notebooks/grid_intersection_example.py | 1 - .docs/Notebooks/plot_cross_section_example.py | 2 -- .docs/Notebooks/plot_map_view_example.py | 3 --- 4 files changed, 1 insertion(+), 7 deletions(-) diff --git a/.docs/Notebooks/feat_working_stack_examples.py b/.docs/Notebooks/feat_working_stack_examples.py index f4dd1a904..a14744e7c 100644 --- a/.docs/Notebooks/feat_working_stack_examples.py +++ b/.docs/Notebooks/feat_working_stack_examples.py @@ -131,7 +131,7 @@ df_flux # + -groups = df_flux.groupby(lambda x: x.split("_")[-1], axis=1).groups +groups = df_flux.T.groupby(lambda x: x.split("_")[-1]).groups df_flux_in = df_flux.loc[:, groups["IN"]] df_flux_in.columns = df_flux_in.columns.map(lambda x: x.split("_")[0]) diff --git a/.docs/Notebooks/grid_intersection_example.py b/.docs/Notebooks/grid_intersection_example.py index 1b947faa2..e67d4a5f3 100644 --- a/.docs/Notebooks/grid_intersection_example.py +++ b/.docs/Notebooks/grid_intersection_example.py @@ -1,4 +1,3 @@ -# %% # --- # jupyter: # jupytext: diff --git a/.docs/Notebooks/plot_cross_section_example.py b/.docs/Notebooks/plot_cross_section_example.py index a5d28db87..76be8346a 100644 --- a/.docs/Notebooks/plot_cross_section_example.py +++ b/.docs/Notebooks/plot_cross_section_example.py @@ -89,7 +89,6 @@ # ### Load and Run an Existing MODFLOW-2005 Model # A model called the "Freyberg Model" is located in the loadpth folder. In the following code block, we load that model, then change into a new workspace (modelpth) where we recreate and run the model. For this to work properly, the MODFLOW-2005 executable (mf2005) must be in the path. We verify that it worked correctly by checking for the presence of freyberg.hds and freyberg.cbc. -# + ml = flopy.modflow.Modflow.load( "freyberg.nam", model_ws=data_path / sim_name, exe_name=exe_name_2005, version=v2005 ) @@ -495,7 +494,6 @@ known_hash=fhash, ) -# + # load the Freyberg model into mf6-flopy and run the simulation sim = flopy.mf6.MFSimulation.load( sim_name="mfsim.nam", diff --git a/.docs/Notebooks/plot_map_view_example.py b/.docs/Notebooks/plot_map_view_example.py index 00dadcec9..d76e4589b 100644 --- a/.docs/Notebooks/plot_map_view_example.py +++ b/.docs/Notebooks/plot_map_view_example.py @@ -24,7 +24,6 @@ # -# + import os import sys from pathlib import Path @@ -92,7 +91,6 @@ known_hash=fhash, ) -# + ml = flopy.modflow.Modflow.load( "freyberg.nam", model_ws=data_path / sim_name, exe_name=exe_name_2005, version=v2005 ) @@ -114,7 +112,6 @@ # # The MODFLOW-2005 model created in the previous code block will be used to create a endpoint capture zone and pathline analysis for the pumping wells in the model. -# + mp = flopy.modpath.Modpath6( "freybergmp", exe_name=exe_mp, modflowmodel=ml, model_ws=modelpth ) From a222eba82d3679a5c870833616161ba92cbf4f4d Mon Sep 17 00:00:00 2001 From: Martin Vonk <66305055+martinvonk@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:07:57 +0200 Subject: [PATCH 76/90] fix(mfusglpf): fix condition for richards laytyp check (#2812) This check (self.laytyp==5) always defaults to False as self.laytyp is a MFArray due to the super().__init__. Therefore, if laytyp=5 parsed to the MfUsgLpf.init, self.richards will always be False and unsaturated flow won't be simulated. --- flopy/mfusg/mfusglpf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flopy/mfusg/mfusglpf.py b/flopy/mfusg/mfusglpf.py index b8f5e6916..a15ff83b7 100644 --- a/flopy/mfusg/mfusglpf.py +++ b/flopy/mfusg/mfusglpf.py @@ -335,7 +335,7 @@ def __init__( model, (njag,), np.float32, ksat, "ksat", locat=self.unit_number[0] ) - if self.laytyp == 5: + if (self.laytyp.array == 5).any(): self.richards = True bas = model.get_package("BAS6") if not hasattr(bas, "richards") or not bas.richards: From 7868ed304718e269af6f9e1e333ff9ce0c71b4f9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:23:27 -0700 Subject: [PATCH 77/90] chore(deps): bump dawidd6/action-download-artifact from 21 to 23 (#2815) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index acdaa4aaf..92fac8aaa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -175,7 +175,7 @@ jobs: # actions/download-artifact won't look at previous workflow runs but we need to in order to get changelog - name: Download artifacts - uses: dawidd6/action-download-artifact@v21 + uses: dawidd6/action-download-artifact@v23 - name: Draft release env: From fa1ecc9363210546f7bdab6762cdf504c89fa5c7 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Tue, 18 Aug 2026 19:56:15 -0400 Subject: [PATCH 78/90] chore(dependencies): bump min python to 3.11 (#2817) Related discussion in #2466 --- .github/workflows/benchmark.yml | 2 +- .github/workflows/codegen.yml | 2 +- .github/workflows/commit.yml | 4 ++-- .github/workflows/examples.yml | 2 +- .github/workflows/optional.yml | 2 +- DEVELOPER.md | 2 +- README.md | 2 +- etc/environment.yml | 2 +- pyproject.toml | 4 ++-- 9 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index da18b45e0..5048bc238 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: os: [ ubuntu-latest, macos-latest, windows-latest ] - python-version: [ "3.10", "3.11", "3.12" ] + python-version: [ "3.11", "3.12", "3.13", "3.14" ] defaults: run: shell: bash -l {0} diff --git a/.github/workflows/codegen.yml b/.github/workflows/codegen.yml index c3620f6a1..59f2e15ea 100644 --- a/.github/workflows/codegen.yml +++ b/.github/workflows/codegen.yml @@ -13,7 +13,7 @@ jobs: fail-fast: false matrix: os: [ ubuntu-latest, macos-latest, windows-latest ] - python-version: [ "3.10", "3.11", "3.12" ] + python-version: [ "3.11", "3.11", "3.12", "3.13", "3.14" ] ref: ["develop", "master", "6.4.1", "6.4.0"] defaults: run: diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index 1f5ff1156..b72adcb55 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -71,7 +71,7 @@ jobs: shell: bash timeout-minutes: 10 env: - PYTHON_VERSION: '3.10' + PYTHON_VERSION: '3.11' steps: - name: Checkout repo @@ -115,7 +115,7 @@ jobs: fail-fast: false matrix: os: [ ubuntu-latest, macos-latest, windows-latest ] - python-version: [ "3.10", "3.11", "3.12" ] + python-version: [ "3.11", "3.12", "3.13", "3.14" ] defaults: run: shell: bash -l {0} diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index bd81af457..6f8f1afcc 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: os: [ ubuntu-latest, macos-latest, windows-latest ] - python-version: [ "3.10", "3.11", "3.12" ] + python-version: [ "3.11", "3.12", "3.13", "3.14" ] defaults: run: shell: bash diff --git a/.github/workflows/optional.yml b/.github/workflows/optional.yml index 1e644a47d..d99169d37 100644 --- a/.github/workflows/optional.yml +++ b/.github/workflows/optional.yml @@ -14,7 +14,7 @@ jobs: shell: bash timeout-minutes: 10 env: - PYTHON_VERSION: '3.10' + PYTHON_VERSION: '3.11' strategy: fail-fast: false matrix: diff --git a/DEVELOPER.md b/DEVELOPER.md index bc275dd5d..9399fcf0a 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -62,7 +62,7 @@ git config blame.ignoreRevsFile .git-blame-ignore-revs This project has historically aimed to support a wide range of [Python versions](https://devguide.python.org/versions/). In current and future development this window may narrow to follow [SPEC 0](https://scientific-python.org/specs/spec-0000/#support-window) instead. -Install Python >=3.10 via [standalone download](https://www.python.org/downloads/), [uv](https://docs.astral.sh/uv/), or a conda-like distribution like [Anaconda](https://www.anaconda.com/products/individual), [miniconda](https://docs.conda.io/en/latest/miniconda.html) or [miniforge](https://github.com/conda-forge/miniforge) . +Install Python >=3.11 via [standalone download](https://www.python.org/downloads/), [uv](https://docs.astral.sh/uv/), or a conda-like distribution like [Anaconda](https://www.anaconda.com/products/individual), [miniconda](https://docs.conda.io/en/latest/miniconda.html) or [miniforge](https://github.com/conda-forge/miniforge) . Then install FloPy and core dependencies from the project root path: diff --git a/README.md b/README.md index 3955f98d4..8491c1641 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Documentation Installation ----------------------------------------------- -FloPy requires **Python** 3.10+ with: +FloPy requires **Python** 3.11+ with: ``` numpy >=1.20.3 diff --git a/etc/environment.yml b/etc/environment.yml index a3228f805..36284adfc 100644 --- a/etc/environment.yml +++ b/etc/environment.yml @@ -6,7 +6,7 @@ dependencies: - pip # required - - python>=3.10 + - python>=3.11 - numpy>=1.20.3 - matplotlib>=1.4.0 - pandas>=2.0.0,<3 diff --git a/pyproject.toml b/pyproject.toml index 55bfb0c58..481623182 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,13 +18,13 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Science/Research", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Scientific/Engineering :: Hydrology", ] -requires-python = ">=3.10" +requires-python = ">=3.11" dependencies = [ "numpy>=1.20.3,<3.0", "matplotlib >=1.4.0", From 65b6c5541cf1deb7a73d92839e58a3f5876e873b Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 19 Aug 2026 11:18:29 -0400 Subject: [PATCH 79/90] fix(crosssection): make view x/y override symmetric, validate input (#2805) little fixes for #2782 * only view="x" could force a direction, view="y" couldn't * make invalid values an error instead of silently using "x" * state that view has no effect for row/column-based cross sections in the docstring --- autotest/test_plot_cross_section.py | 50 +++++++++++++++++++++++++++++ flopy/plot/crosssection.py | 24 ++++++++------ 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/autotest/test_plot_cross_section.py b/autotest/test_plot_cross_section.py index e5829b45f..4e523b031 100644 --- a/autotest/test_plot_cross_section.py +++ b/autotest/test_plot_cross_section.py @@ -195,6 +195,56 @@ def test_cross_section_invalid_line_representations_fail(line): flopy.plot.PlotCrossSection(modelgrid=grid, line={"line": line}) +@requires_pkg("shapely") +@pytest.mark.parametrize("view", ["bogus", "diagonal", "X", "Y"]) +def test_cross_section_invalid_view_raises_error(view): + grid = structured_square_grid(side=10) + if view in ("X", "Y"): + # view is case-insensitive, these should not raise + flopy.plot.PlotCrossSection( + modelgrid=grid, line={"line": [(0, 0), (10, 10)]}, view=view + ) + return + with pytest.raises(ValueError): + flopy.plot.PlotCrossSection( + modelgrid=grid, line={"line": [(0, 0), (10, 10)]}, view=view + ) + + +@requires_pkg("shapely") +@pytest.mark.parametrize( + "line, expected_auto_direction", + [ + # horizontal: x-span > y-span + ([(0, 5.5), (10, 5.5)], "x"), + # vertical: y-span > x-span + ([(5.5, 0), (5.5, 10)], "y"), + # diagonal, equal spans: falls to the "else" (y) branch since the + # comparison is a strict ">" + ([(0, 0), (10, 10)], "y"), + ], +) +def test_cross_section_view_forces_direction(line, expected_auto_direction): + grid = structured_square_grid(side=10) + + auto_xc = flopy.plot.PlotCrossSection(modelgrid=grid, line={"line": line}) + assert auto_xc.direction == expected_auto_direction + + x_xc = flopy.plot.PlotCrossSection(modelgrid=grid, line={"line": line}, view="x") + assert x_xc.direction == "x" + + y_xc = flopy.plot.PlotCrossSection(modelgrid=grid, line={"line": line}, view="y") + assert y_xc.direction == "y" + + +@pytest.mark.parametrize("onkey, expected_direction", [("row", "x"), ("column", "y")]) +@pytest.mark.parametrize("view", ["auto", "x", "y"]) +def test_cross_section_view_ignored_for_row_column(onkey, expected_direction, view): + grid = structured_square_grid(side=10) + xc = flopy.plot.PlotCrossSection(modelgrid=grid, line={onkey: 4}, view=view) + assert xc.direction == expected_direction + + def test_plot_limits(): xymin, xymax = 0, 1000 cellsize = 50 diff --git a/flopy/plot/crosssection.py b/flopy/plot/crosssection.py index 5ae25e518..39ae15a3f 100644 --- a/flopy/plot/crosssection.py +++ b/flopy/plot/crosssection.py @@ -52,12 +52,16 @@ class PlotCrossSection: cross-sectional width less than min_segment_length will be ignored and not included in the plot. Default is 1e-02. view : str - view can be used to force the view of the cross section when a line is provided. - "auto" is default and mimics the long term behavior of flopy, which decides - on the view by taking the maximum of the x and y direction of the cross - sectional line. "x" forces the view to be plotted from the x direction - (bottom of the unrotated grid). and "y" forces the view to be plotted from the - y-direction "left" or "right" side of the unrotated grid. + view can be used to force the view of the cross section when an + arbitrary line (line["line"]) is provided. "auto" is default and + mimics the long term behavior of flopy, which decides on the view + by taking the maximum of the x and y direction of the cross + sectional line. "x" forces the view to be plotted from the x + direction (bottom of the unrotated grid) and "y" forces the view to + be plotted from the y-direction ("left" or "right" side of the + unrotated grid). view has no effect when the cross section is + specified with line["row"] or line["column"], since the view is + already unambiguous in that case. """ def __init__( @@ -72,6 +76,8 @@ def __init__( view="auto", ): view = view.lower() + if view not in ("auto", "x", "y"): + raise ValueError(f"view must be 'auto', 'x', or 'y', got {view!r}") self.ax = ax self.geographic_coords = geographic_coords self.model = model @@ -168,10 +174,8 @@ def __init__( yp.append(v2) xp, yp = self.mg.get_local_coords(xp, yp) - if (np.max(xp) - np.min(xp) > np.max(yp) - np.min(yp)) or view not in ( - "auto", - "y", - ): + xspan_gt_yspan = np.max(xp) - np.min(xp) > np.max(yp) - np.min(yp) + if view == "x" or (view == "auto" and xspan_gt_yspan): # this is x-projection and we should buffer x by small amount idx0 = np.argmax(xp) idx1 = np.argmin(xp) From 6c8b29bab37f604ae4a42bd7f92490519434b575 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 20 Aug 2026 13:51:02 -0400 Subject: [PATCH 80/90] fix(utils): fix zone off-by-one, add test for #2612 (#2818) Two separate things. Fix an off-by-one bug in EndpointFile: zone numbers read back from a MODPATH 7 endpoint file were silently decremented by 1. And add a test reproducing #2613. Bisect confirms the issue was fixed some time ago by #1415. --- autotest/test_mp7_disv_issue_2612.py | 236 +++++++++++++++++++++++++++ flopy/utils/modpathfile.py | 2 - 2 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 autotest/test_mp7_disv_issue_2612.py diff --git a/autotest/test_mp7_disv_issue_2612.py b/autotest/test_mp7_disv_issue_2612.py new file mode 100644 index 000000000..043c2c38d --- /dev/null +++ b/autotest/test_mp7_disv_issue_2612.py @@ -0,0 +1,236 @@ +""" +Tests for issue #2612: MODPATH 7 izone/zones handling on DISV grids. +""" + +import numpy as np +import pandas as pd +import pytest + +from autotest.test_grid_cases import GridCases +from flopy.mf6 import ( + MFSimulation, + ModflowEms, + ModflowGwf, + ModflowGwfchd, + ModflowGwfdisv, + ModflowGwfic, + ModflowGwfnpf, + ModflowGwfoc, + ModflowIms, + ModflowPrt, + ModflowPrtdisv, + ModflowPrtfmi, + ModflowPrtmip, + ModflowPrtoc, + ModflowPrtprp, + ModflowTdis, +) +from flopy.modpath import Modpath7, Modpath7Bas, Modpath7Sim, ParticleGroup +from flopy.modpath.mp7particledata import ParticleData +from flopy.utils.modpathfile import EndpointFile + +SOURCE_CELL = 1 +JUNCTION_CELL = 2 +SINK_CELL = 4 +SOURCE_HEAD = 8.0 +SINK_HEAD = 6.0 +STOPZONE = 2 + + +def build_gwf_sim(name, ws): + grid = GridCases.vertex_small() + sim = MFSimulation(sim_name=name, version="mf6", exe_name="mf6", sim_ws=ws) + ModflowTdis(sim, time_units="DAYS", nper=1, perioddata=[(1.0, 1, 1.0)]) + ModflowIms( + sim, + complexity="SIMPLE", + outer_dvclose=1e-6, + outer_maximum=200, + inner_dvclose=1e-7, + inner_maximum=200, + ) + gwf = ModflowGwf(sim, modelname=name, save_flows=True) + ModflowGwfdisv( + gwf, + nlay=grid.nlay, + ncpl=grid.ncpl, + nvert=grid.nvert, + vertices=grid._vertices, + cell2d=grid.cell2d, + top=grid.top, + botm=grid.botm, + ) + # k33 near zero decouples the layers vertically, so flow (and the + # tracked particle) stay in layer 0 where the zones are defined + ModflowGwfnpf( + gwf, + k=1.0, + k33=1e-3, + save_flows=True, + save_specific_discharge=True, + save_saturation=True, + ) + ModflowGwfic(gwf, strt=7.0) + ModflowGwfchd( + gwf, + stress_period_data=[ + [(0, SOURCE_CELL), SOURCE_HEAD], + [(0, SINK_CELL), SINK_HEAD], + ], + ) + ModflowGwfoc( + gwf, + budget_filerecord=f"{name}.cbc", + head_filerecord=f"{name}.hds", + saverecord=[("HEAD", "ALL"), ("BUDGET", "ALL")], + ) + return sim, grid + + +def make_zones(grid, shape): + zones2d = np.ones((grid.nlay, grid.ncpl), dtype=np.int32) + zones2d[0, JUNCTION_CELL] = STOPZONE + if shape == "2d": + return zones2d + elif shape == "3d": + return np.expand_dims(zones2d, axis=1) + raise ValueError(shape) + + +def make_particle_data(): + # node 1 == (layer 0, cell2d SOURCE_CELL), 0-based + return ParticleData( + partlocs=[SOURCE_CELL], + structured=False, + localx=[0.5], + localy=[0.5], + localz=[0.5], + drape=0, + ) + + +@pytest.mark.parametrize("shape", ["2d", "3d"]) +def test_mp7_disv_zones(function_tmpdir, shape): + gwf_name = "gwf" + sim, grid = build_gwf_sim(gwf_name, function_tmpdir / "mf6") + sim.write_simulation() + success, buff = sim.run_simulation() + assert success, buff + + mp7_ws = function_tmpdir / "mp7" + gwf = sim.get_model() + mp7 = Modpath7(modelname="mp7", flowmodel=gwf, model_ws=mp7_ws, exe_name="mp7") + Modpath7Bas(mp7) + Modpath7Sim( + mp7, + simulationtype="pathline", + trackingdirection="forward", + weaksinkoption="stop_at", + zonedataoption="on", + stopzone=STOPZONE, + zones=make_zones(grid, shape), + particlegroups=[ParticleGroup(particledata=make_particle_data())], + ) + mp7.write_input() + success, buff = mp7.run_model() + assert success, buff + + ep = EndpointFile(mp7_ws / "mp7.mpend").get_data() + assert len(ep) == 1 + assert ep["k"][0] == 0 + assert ep["node"][0] == JUNCTION_CELL + assert ep["zone"][0] == STOPZONE + + +def test_mp7_disv_zones_2d_3d_equivalent(function_tmpdir): + sim, grid = build_gwf_sim("gwf", function_tmpdir / "mf6") + gwf = sim.get_model() + + def zones_array_for(shape): + mp7 = Modpath7( + modelname="mp7", + flowmodel=gwf, + model_ws=function_tmpdir / f"mp7_{shape}", + exe_name="mp7", + ) + Modpath7Bas(mp7) + mp7sim = Modpath7Sim( + mp7, + zonedataoption="on", + stopzone=STOPZONE, + zones=make_zones(grid, shape), + particlegroups=[ParticleGroup(particledata=make_particle_data())], + ) + return mp7sim.zones.array + + np.testing.assert_array_equal(zones_array_for("2d"), zones_array_for("3d")) + + +def test_prt_disv_zones(function_tmpdir): + gwf_name = "gwf" + mf6_ws = function_tmpdir / "mf6" + gwf_sim, grid = build_gwf_sim(gwf_name, mf6_ws) + gwf_sim.write_simulation() + success, buff = gwf_sim.run_simulation() + assert success, buff + gwf = gwf_sim.get_model() + + prt_name = "prt" + prt_ws = function_tmpdir / "prt" + prt_sim = MFSimulation( + sim_name=prt_name, version="mf6", exe_name="mf6", sim_ws=prt_ws + ) + ModflowTdis(prt_sim, time_units="DAYS", nper=1, perioddata=[(1.0, 1, 1.0)]) + prt = ModflowPrt(prt_sim, modelname=prt_name) + ModflowPrtdisv( + prt, + nlay=grid.nlay, + ncpl=grid.ncpl, + nvert=grid.nvert, + vertices=grid._vertices, + cell2d=grid.cell2d, + top=grid.top, + botm=grid.botm, + ) + + izone = np.ones((grid.nlay, grid.ncpl), dtype=np.int32) + izone[0, JUNCTION_CELL] = STOPZONE + ModflowPrtmip(prt, porosity=0.3, izone=izone) + + releasepts = list(make_particle_data().to_prp(gwf.modelgrid)) + ModflowPrtprp( + prt, + nreleasepts=len(releasepts), + packagedata=releasepts, + perioddata={0: ["FIRST"]}, + istopzone=STOPZONE, + coordinate_check_method=None, + ) + ModflowPrtoc( + prt, + budget_filerecord=f"{prt_name}.bud", + track_filerecord=f"{prt_name}.trk", + trackcsv_filerecord=f"{prt_name}.trk.csv", + saverecord=[("BUDGET", "ALL")], + ) + ModflowPrtfmi( + prt, + packagedata=[ + ("GWFHEAD", f"../{mf6_ws.name}/{gwf_name}.hds"), + ("GWFBUDGET", f"../{mf6_ws.name}/{gwf_name}.cbc"), + ], + ) + ems = ModflowEms(prt_sim, filename=f"{prt_name}.ems") + prt_sim.register_solution_package(ems, [prt.name]) + + prt_sim.write_simulation() + success, buff = prt_sim.run_simulation() + assert success, buff + + trk = pd.read_csv(prt_ws / f"{prt_name}.trk.csv") + term = trk[trk.ireason == 3] # termination event + assert len(term) == 1 + # 1-based layer/cell2d indices in the PRT track file + assert term.iloc[0]["ilay"] == 1 + assert term.iloc[0]["icell"] == JUNCTION_CELL + 1 + assert term.iloc[0]["izone"] == STOPZONE diff --git a/flopy/utils/modpathfile.py b/flopy/utils/modpathfile.py index 5a34175d5..6c7715256 100644 --- a/flopy/utils/modpathfile.py +++ b/flopy/utils/modpathfile.py @@ -532,8 +532,6 @@ class EndpointFile(ModpathFile): "particleid", "particlegroup", "particleidloc", - "zone0", - "zone", ] def __init__(self, filename: Union[str, PathLike], verbose: bool = False): From 7a6af59de2077a7ddf4c469446495b50710185c6 Mon Sep 17 00:00:00 2001 From: Joseph Hughes Date: Sun, 23 Aug 2026 06:08:40 -0500 Subject: [PATCH 81/90] test(markers): fix the scikit-learn requires_pkg marker (#2821) requires_pkg() resolves a distribution name and imports the matching module, and scikit-learn matches neither name on its own: the distribution is scikit-learn and the module is sklearn. test_save_load_node_mapping_structured was therefore skipped everywhere, including the nightly optional dependency build, and had never run. Map the distribution to its module so the test runs. --- autotest/test_model_splitter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/autotest/test_model_splitter.py b/autotest/test_model_splitter.py index 60bbb8330..6bd90dff8 100644 --- a/autotest/test_model_splitter.py +++ b/autotest/test_model_splitter.py @@ -221,7 +221,7 @@ def test_metis_splitting_with_lak_sfr(function_tmpdir): @requires_exe("mf6") @requires_pkg("pymetis") @requires_pkg("h5py") -@requires_pkg("sklearn") +@requires_pkg("scikit-learn", name_map={"scikit-learn": "sklearn"}) def test_save_load_node_mapping_structured(function_tmpdir): import pymetis From fe600ab87d1de0696cbfcd1f0b6cf678846c12fd Mon Sep 17 00:00:00 2001 From: Joseph Hughes Date: Sun, 23 Aug 2026 08:01:05 -0500 Subject: [PATCH 82/90] ci(optional): stop uv run from reinstalling the removed dependencies (#2823) uv run re-syncs the project before it runs the command, which undoes both the --only-group test install and the random uninstall that precede it. Both matrix variants therefore run the full test suite with every optional dependency present, and the workflow has not been testing the absence of any of them. In the 22 Aug run the no optional dependencies job reported all 21 optional packages installed and passed tests that require pymetis. Run pytest with --no-sync so the environment the steps built is the one used, and add a workflow_dispatch trigger so the workflow can be run on demand. --- .github/workflows/optional.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/optional.yml b/.github/workflows/optional.yml index d99169d37..4e1b18b3c 100644 --- a/.github/workflows/optional.yml +++ b/.github/workflows/optional.yml @@ -5,6 +5,7 @@ name: Optional dependency testing on: schedule: - cron: '0 8 * * *' # run at 8 AM UTC (12 am PST) + workflow_dispatch: jobs: test: name: Test @@ -62,7 +63,7 @@ jobs: - name: Smoke test (${{ matrix.optdeps }}) working-directory: autotest - run: uv run pytest -v -n=auto -m "not regression and not example" --cov=flopy --cov-report=xml --durations=0 --keep-failed=.failed + run: uv run --no-sync pytest -v -n=auto -m "not regression and not example" --cov=flopy --cov-report=xml --durations=0 --keep-failed=.failed env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From e81048afdc5a49a7879ca7f9b979a52abb0ba8ae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 05:56:19 -0700 Subject: [PATCH 83/90] chore(deps): bump dawidd6/action-download-artifact from 23 to 24 (#2830) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 92fac8aaa..25e0ec781 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -175,7 +175,7 @@ jobs: # actions/download-artifact won't look at previous workflow runs but we need to in order to get changelog - name: Download artifacts - uses: dawidd6/action-download-artifact@v23 + uses: dawidd6/action-download-artifact@v24 - name: Draft release env: From 2dcc3b9ec769694e52fd506c1b7c2262327ff5d9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 05:56:39 -0700 Subject: [PATCH 84/90] chore(deps): bump pyvista/setup-headless-display-action from 4 to 5 (#2829) --- .github/workflows/rtd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/rtd.yml b/.github/workflows/rtd.yml index 3e21502f3..d6d21337f 100644 --- a/.github/workflows/rtd.yml +++ b/.github/workflows/rtd.yml @@ -131,7 +131,7 @@ jobs: version: 13 - name: Set up headless display - uses: pyvista/setup-headless-display-action@v4 + uses: pyvista/setup-headless-display-action@v5 - name: Build MODFLOW 6 working-directory: modflow6 From b4b5431a394b54beb562a5e83402e5d3e4ef346f Mon Sep 17 00:00:00 2001 From: Joseph Hughes Date: Mon, 24 Aug 2026 07:59:04 -0500 Subject: [PATCH 85/90] ci(commit): add optional dependencies to the pixi environment (#2822) Installing h5py from pypi into an environment that already carries the HDF5 library from conda loaded the wrong library on windows, so add the optional dependencies FloPy tests need with pixi instead. conda-forge builds pymetis for windows, which pypi does not, so the metis tests run on all three platforms. Adding pymetis runs the metis splitting tests for the first time on a pull request, and test_multi_model fails on macOS. Add h5py and scikit-learn now so the node mapping tests run, and add pymetis once that failure is resolved. --- .github/workflows/commit.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index b72adcb55..d2068c9b5 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -158,6 +158,10 @@ jobs: - name: Install dependencies working-directory: modflow6 run: | + # optional dependencies of FloPy come from conda so they are + # consistent with the libraries already in the environment. pymetis + # is held back until the metis tests pass on macOS + pixi add h5py scikit-learn pixi run install pixi run pip install coverage pytest-cov netcdf4 From 4366f695c3ad7e2b2491dffdc1182d3f841bb089 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:07:20 -0700 Subject: [PATCH 86/90] chore(deps): bump prefix-dev/setup-pixi from 0.10.1 to 0.10.2 (#2832) --- .github/workflows/commit.yml | 2 +- .github/workflows/mf6.yml | 4 ++-- .github/workflows/rtd.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index d2068c9b5..3db764b93 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -150,7 +150,7 @@ jobs: version: 13 - name: Setup pixi - uses: prefix-dev/setup-pixi@v0.10.1 + uses: prefix-dev/setup-pixi@v0.10.2 with: pixi-version: v0.70.1 manifest-path: modflow6/pixi.toml diff --git a/.github/workflows/mf6.yml b/.github/workflows/mf6.yml index c445db480..755fb9f50 100644 --- a/.github/workflows/mf6.yml +++ b/.github/workflows/mf6.yml @@ -39,7 +39,7 @@ jobs: version: 13 - name: Setup pixi - uses: prefix-dev/setup-pixi@v0.10.1 + uses: prefix-dev/setup-pixi@v0.10.2 with: pixi-version: v0.70.1 manifest-path: modflow6/pixi.toml @@ -122,7 +122,7 @@ jobs: version: 13 - name: Setup pixi - uses: prefix-dev/setup-pixi@v0.10.1 + uses: prefix-dev/setup-pixi@v0.10.2 with: pixi-version: v0.70.1 manifest-path: modflow6/pixi.toml diff --git a/.github/workflows/rtd.yml b/.github/workflows/rtd.yml index d6d21337f..aea526465 100644 --- a/.github/workflows/rtd.yml +++ b/.github/workflows/rtd.yml @@ -87,7 +87,7 @@ jobs: echo $GITHUB_EVENT_NAME - name: Setup pixi - uses: prefix-dev/setup-pixi@v0.10.1 + uses: prefix-dev/setup-pixi@v0.10.2 with: pixi-version: v0.70.1 manifest-path: modflow6/pixi.toml From 73af2a7395a8fa8ceaf7a056970bb50d353be9e4 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Mon, 31 Aug 2026 13:07:03 -0400 Subject: [PATCH 87/90] fix(binarygrid_util): restore top assignment dropped with reshape (#2833) Fix #2831. #2794 switched several places where .shape was set directly to .reshape() calls instead, but dropped an assignment on one of them. --- flopy/mf6/utils/binarygrid_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flopy/mf6/utils/binarygrid_util.py b/flopy/mf6/utils/binarygrid_util.py index c74cc20f9..d23007017 100644 --- a/flopy/mf6/utils/binarygrid_util.py +++ b/flopy/mf6/utils/binarygrid_util.py @@ -258,7 +258,7 @@ def _set_modelgrid(self): ) delr, delc = self.delr, self.delc - top.reshape((nrow, ncol)) + top = top.reshape((nrow, ncol)) botm = botm.reshape((nlay, nrow, ncol)) modelgrid = StructuredGrid( delc, From d9517a8bd541d9c39cda5bbf53fde9a41d4ebde3 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 3 Sep 2026 08:22:56 -0400 Subject: [PATCH 88/90] test(get-modflow): remove mac.zip from expected assets (#2835) With MF6.8.0 there is no longer a distribution for Intel macOS --- autotest/test_get_modflow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/autotest/test_get_modflow.py b/autotest/test_get_modflow.py index b8506dea9..103e573d8 100644 --- a/autotest/test_get_modflow.py +++ b/autotest/test_get_modflow.py @@ -115,7 +115,7 @@ def test_get_release(repo): tag = "latest" release = get_release(repo=repo, tag=tag) assets = release["assets"] - expected_assets = ["linux.zip", "mac.zip", "macarm.zip", "win64.zip"] + expected_assets = ["linux.zip", "macarm.zip", "win64.zip"] expected_ostags = [a.replace(".zip", "") for a in expected_assets] actual_assets = [asset["name"] for asset in assets] From 3a547035ec55a0d75dc0ef427f3bc260761974ad Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 3 Sep 2026 09:53:41 -0400 Subject: [PATCH 89/90] chore(mf6): regenerate mf6 modules (#2834) For the MF6.8.0 release --- flopy/mf6/modflow/__init__.py | 2 +- flopy/mf6/modflow/mfgweadv.py | 4 +- flopy/mf6/modflow/mfgwectp.py | 8 +- flopy/mf6/modflow/mfgwedis.py | 11 +- flopy/mf6/modflow/mfgwedisu.py | 11 +- flopy/mf6/modflow/mfgwedisv.py | 11 +- flopy/mf6/modflow/mfgweesl.py | 2 +- flopy/mf6/modflow/mfgwelke.py | 18 +- flopy/mf6/modflow/mfgwemwe.py | 18 +- flopy/mf6/modflow/mfgweoc.py | 9 +- flopy/mf6/modflow/mfgwesfe.py | 18 +- flopy/mf6/modflow/mfgweuze.py | 18 +- flopy/mf6/modflow/mfgwfchdg.py | 21 +- flopy/mf6/modflow/mfgwfcsub.py | 51 ++++- flopy/mf6/modflow/mfgwfdis.py | 11 +- flopy/mf6/modflow/mfgwfdisu.py | 11 +- flopy/mf6/modflow/mfgwfdisv.py | 11 +- flopy/mf6/modflow/mfgwfdrng.py | 20 +- flopy/mf6/modflow/mfgwfevta.py | 9 +- flopy/mf6/modflow/mfgwfghbg.py | 21 +- flopy/mf6/modflow/mfgwfhfb.py | 9 +- flopy/mf6/modflow/mfgwflak.py | 85 +++++++- flopy/mf6/modflow/mfgwfmaw.py | 139 +++++++++++- flopy/mf6/modflow/mfgwfnpf.py | 3 + flopy/mf6/modflow/mfgwfoc.py | 9 +- flopy/mf6/modflow/mfgwfrcha.py | 8 +- flopy/mf6/modflow/mfgwfrivg.py | 20 +- flopy/mf6/modflow/mfgwfsfr.py | 37 +++- flopy/mf6/modflow/mfgwfuzf.py | 14 ++ flopy/mf6/modflow/mfgwfwel.py | 43 +++- flopy/mf6/modflow/mfgwfwelg.py | 21 +- flopy/mf6/modflow/mfgwtcnc.py | 8 +- flopy/mf6/modflow/mfgwtdis.py | 11 +- flopy/mf6/modflow/mfgwtdisu.py | 11 +- flopy/mf6/modflow/mfgwtdisv.py | 11 +- flopy/mf6/modflow/mfgwtlkt.py | 18 +- flopy/mf6/modflow/mfgwtmwt.py | 18 +- flopy/mf6/modflow/mfgwtoc.py | 9 +- flopy/mf6/modflow/mfgwtsft.py | 18 +- flopy/mf6/modflow/mfgwtsrc.py | 2 +- flopy/mf6/modflow/mfgwtuzt.py | 18 +- flopy/mf6/modflow/mfims.py | 193 ++++++++--------- flopy/mf6/modflow/mfmve.py | 246 +++++++++++++++++++++ flopy/mf6/modflow/mfnam.py | 2 +- flopy/mf6/modflow/mfprtdis.py | 11 +- flopy/mf6/modflow/mfprtdisv.py | 11 +- flopy/mf6/modflow/mfprtoc.py | 108 ++-------- flopy/mf6/modflow/mfprtprp.py | 103 --------- flopy/mf6/modflow/mfpts.py | 340 ------------------------------ flopy/mf6/modflow/mfsimulation.py | 2 +- flopy/mf6/modflow/mfutlncf.py | 74 +++++-- flopy/mf6/modflow/mfutlspc.py | 10 +- flopy/mf6/modflow/mfutlspca.py | 2 + flopy/mf6/modflow/mfutltvk.py | 15 +- flopy/mf6/modflow/mfutltvs.py | 11 +- 55 files changed, 1171 insertions(+), 754 deletions(-) create mode 100644 flopy/mf6/modflow/mfmve.py delete mode 100644 flopy/mf6/modflow/mfpts.py diff --git a/flopy/mf6/modflow/__init__.py b/flopy/mf6/modflow/__init__.py index c009179e2..9c76165ac 100644 --- a/flopy/mf6/modflow/__init__.py +++ b/flopy/mf6/modflow/__init__.py @@ -82,6 +82,7 @@ from .mfgwtssm import ModflowGwtssm from .mfgwtuzt import ModflowGwtuzt from .mfims import ModflowIms +from .mfmve import ModflowMve from .mfmvr import ModflowMvr from .mfmvt import ModflowMvt from .mfnam import ModflowNam @@ -93,7 +94,6 @@ from .mfprtnam import ModflowPrtnam from .mfprtoc import ModflowPrtoc from .mfprtprp import ModflowPrtprp -from .mfpts import ModflowPts from .mfsimulation import MFSimulation from .mftdis import ModflowTdis from .mfutlats import ModflowUtlats diff --git a/flopy/mf6/modflow/mfgweadv.py b/flopy/mf6/modflow/mfgweadv.py index 2a3775067..41b131437 100644 --- a/flopy/mf6/modflow/mfgweadv.py +++ b/flopy/mf6/modflow/mfgweadv.py @@ -20,8 +20,8 @@ class ModflowGweadv(MFPackage): Do not set this parameter. It is intended for debugging and internal processing purposes only. scheme : string - scheme used to solve the advection term. can be upstream, central, or tvd. if - not specified, upstream weighting is the default weighting scheme. + scheme used to solve the advection term. can be upstream, central, tvd, or + utvd. if not specified, upstream weighting is the default weighting scheme. ats_percel : double precision fractional cell distance submitted by the adv package to the adaptive time stepping (ats) package. if ats_percel is specified and the ats package is diff --git a/flopy/mf6/modflow/mfgwectp.py b/flopy/mf6/modflow/mfgwectp.py index 937f047b9..2cf4332c3 100644 --- a/flopy/mf6/modflow/mfgwectp.py +++ b/flopy/mf6/modflow/mfgwectp.py @@ -105,7 +105,7 @@ class ModflowGwectp(MFPackage): _package_type = "ctp" dfn_file_name = "gwe-ctp.dfn" dfn = [ - ["header", "multi-package"], + ["header", "multi-package", "package-type stress-package"], [ "block options", "name auxiliary", @@ -136,7 +136,7 @@ class ModflowGwectp(MFPackage): "type keyword", "reader urword", "optional true", - "mf6internal iprflow", + "mf6internal iprpak", ], [ "block options", @@ -144,7 +144,7 @@ class ModflowGwectp(MFPackage): "type keyword", "reader urword", "optional true", - "mf6internal ipakcb", + "mf6internal iprflow", ], [ "block options", @@ -152,7 +152,7 @@ class ModflowGwectp(MFPackage): "type keyword", "reader urword", "optional true", - "mf6internal iprpak", + "mf6internal ipakcb", ], [ "block options", diff --git a/flopy/mf6/modflow/mfgwedis.py b/flopy/mf6/modflow/mfgwedis.py index 304cc6e10..8dc4dbd6d 100644 --- a/flopy/mf6/modflow/mfgwedis.py +++ b/flopy/mf6/modflow/mfgwedis.py @@ -54,6 +54,13 @@ class ModflowGwedis(MFPackage): modflow 6 simulation input. this option only has an effect when an output model netcdf file is configured and the simulation is run in validate mode, otherwise it is ignored. + crs : [string] + is a real-world coordinate reference system (crs) for the model, for example, + an epsg integer code (e.g. 26915), authority string (i.e. epsg:26915), or open + geospatial consortium well-known text (wkt) specification. limited to 5000 + characters. the entry for crs does not affect the model simulation, but it is + written to the binary grid file so that postprocessors can locate the grid in + space. packagedata : record ncf6 filein ncf6_filename Contains data for the ncf package. Data can be passed as a dictionary to the ncf package with variable names as keys and package data as values. Data for @@ -95,6 +102,7 @@ class ModflowGwedis(MFPackage): """ grb_filerecord = ListTemplateGenerator(("gwe6", "dis", "options", "grb_filerecord")) + crs = ArrayTemplateGenerator(("gwe6", "dis", "options", "crs")) ncf_filerecord = ListTemplateGenerator(("gwe6", "dis", "options", "ncf_filerecord")) delr = ArrayTemplateGenerator(("gwe6", "dis", "griddata", "delr")) delc = ArrayTemplateGenerator(("gwe6", "dis", "griddata", "delc")) @@ -202,7 +210,6 @@ class ModflowGwedis(MFPackage): "preserve_case true", "reader urword", "optional true", - "developmode true", ], [ "block options", @@ -330,6 +337,7 @@ def __init__( angrot=None, export_array_ascii=None, export_array_netcdf=None, + crs=None, packagedata=None, nlay=1, nrow=2, @@ -365,6 +373,7 @@ def __init__( self.export_array_netcdf = self.build_mfdata( "export_array_netcdf", export_array_netcdf ) + self.crs = self.build_mfdata("crs", crs) self._ncf_filerecord = self.build_mfdata("ncf_filerecord", None) self._ncf_package = self.build_child_package( "ncf", packagedata, "packagedata", self._ncf_filerecord diff --git a/flopy/mf6/modflow/mfgwedisu.py b/flopy/mf6/modflow/mfgwedisu.py index 7339edf08..b3c858905 100644 --- a/flopy/mf6/modflow/mfgwedisu.py +++ b/flopy/mf6/modflow/mfgwedisu.py @@ -58,6 +58,13 @@ class ModflowGwedisu(MFPackage): export_array_ascii : keyword keyword that specifies input griddata arrays should be written to layered ascii output files. + crs : [string] + is a real-world coordinate reference system (crs) for the model, for example, + an epsg integer code (e.g. 26915), authority string (i.e. epsg:26915), or open + geospatial consortium well-known text (wkt) specification. limited to 5000 + characters. the entry for crs does not affect the model simulation, but it is + written to the binary grid file so that postprocessors can locate the grid in + space. nodes : integer is the number of cells in the model grid. nja : integer @@ -177,6 +184,7 @@ class ModflowGwedisu(MFPackage): grb_filerecord = ListTemplateGenerator( ("gwe6", "disu", "options", "grb_filerecord") ) + crs = ArrayTemplateGenerator(("gwe6", "disu", "options", "crs")) top = ArrayTemplateGenerator(("gwe6", "disu", "griddata", "top")) bot = ArrayTemplateGenerator(("gwe6", "disu", "griddata", "bot")) area = ArrayTemplateGenerator(("gwe6", "disu", "griddata", "area")) @@ -290,7 +298,6 @@ class ModflowGwedisu(MFPackage): "preserve_case true", "reader urword", "optional true", - "developmode true", ], [ "block dimensions", @@ -498,6 +505,7 @@ def __init__( angrot=None, vertical_offset_tolerance=0.0, export_array_ascii=None, + crs=None, nodes=None, nja=None, nvert=None, @@ -539,6 +547,7 @@ def __init__( self.export_array_ascii = self.build_mfdata( "export_array_ascii", export_array_ascii ) + self.crs = self.build_mfdata("crs", crs) self.nodes = self.build_mfdata("nodes", nodes) self.nja = self.build_mfdata("nja", nja) self.nvert = self.build_mfdata("nvert", nvert) diff --git a/flopy/mf6/modflow/mfgwedisv.py b/flopy/mf6/modflow/mfgwedisv.py index 389c20f60..f61bca8f0 100644 --- a/flopy/mf6/modflow/mfgwedisv.py +++ b/flopy/mf6/modflow/mfgwedisv.py @@ -57,6 +57,13 @@ class ModflowGwedisv(MFPackage): modflow 6 simulation input. this option only has an effect when an output model netcdf file is configured and the simulation is run in validate mode, otherwise it is ignored. + crs : [string] + is a real-world coordinate reference system (crs) for the model, for example, + an epsg integer code (e.g. 26915), authority string (i.e. epsg:26915), or open + geospatial consortium well-known text (wkt) specification. limited to 5000 + characters. the entry for crs does not affect the model simulation, but it is + written to the binary grid file so that postprocessors can locate the grid in + space. packagedata : record ncf6 filein ncf6_filename Contains data for the ncf package. Data can be passed as a dictionary to the ncf package with variable names as keys and package data as values. Data for @@ -123,6 +130,7 @@ class ModflowGwedisv(MFPackage): grb_filerecord = ListTemplateGenerator( ("gwe6", "disv", "options", "grb_filerecord") ) + crs = ArrayTemplateGenerator(("gwe6", "disv", "options", "crs")) ncf_filerecord = ListTemplateGenerator( ("gwe6", "disv", "options", "ncf_filerecord") ) @@ -232,7 +240,6 @@ class ModflowGwedisv(MFPackage): "preserve_case true", "reader urword", "optional true", - "developmode true", ], [ "block options", @@ -429,6 +436,7 @@ def __init__( angrot=None, export_array_ascii=None, export_array_netcdf=None, + crs=None, packagedata=None, nlay=None, ncpl=None, @@ -464,6 +472,7 @@ def __init__( self.export_array_netcdf = self.build_mfdata( "export_array_netcdf", export_array_netcdf ) + self.crs = self.build_mfdata("crs", crs) self._ncf_filerecord = self.build_mfdata("ncf_filerecord", None) self._ncf_package = self.build_child_package( "ncf", packagedata, "packagedata", self._ncf_filerecord diff --git a/flopy/mf6/modflow/mfgweesl.py b/flopy/mf6/modflow/mfgweesl.py index b02626d8f..3af905a24 100644 --- a/flopy/mf6/modflow/mfgweesl.py +++ b/flopy/mf6/modflow/mfgweesl.py @@ -106,7 +106,7 @@ class ModflowGweesl(MFPackage): _package_type = "esl" dfn_file_name = "gwe-esl.dfn" dfn = [ - ["header", "multi-package"], + ["header", "multi-package", "package-type stress-package"], [ "block options", "name auxiliary", diff --git a/flopy/mf6/modflow/mfgwelke.py b/flopy/mf6/modflow/mfgwelke.py index 11ecf74fb..691058ba2 100644 --- a/flopy/mf6/modflow/mfgwelke.py +++ b/flopy/mf6/modflow/mfgwelke.py @@ -87,6 +87,11 @@ class ModflowGwelke(MFPackage): obs package with variable names as keys and package data as values. Data for the observations variable is also acceptable. See obs package documentation for more information. + dev_nonexpanding_matrix : keyword + keyword that solves the feature temperature with a general mixing equation and + adds the result to the right-hand side of the gwe equations, instead of adding + a row to the solution matrix for each feature. this development option is not + supported. packagedata : [(lakeno, strt, ktf, rbthcnd, aux, boundname)] * lakeno : integer integer value that defines the lake number associated with the specified PERIOD @@ -205,7 +210,7 @@ class ModflowGwelke(MFPackage): _package_type = "lke" dfn_file_name = "gwe-lke.dfn" dfn = [ - ["header", "multi-package"], + ["header", "multi-package", "package-type advanced-stress-package"], [ "block options", "name flow_package_name", @@ -440,6 +445,13 @@ class ModflowGwelke(MFPackage): "reader urword", "optional false", ], + [ + "block options", + "name dev_nonexpanding_matrix", + "type keyword", + "reader urword", + "optional true", + ], [ "block packagedata", "name packagedata", @@ -657,6 +669,7 @@ def __init__( budgetcsv_filerecord=None, timeseries=None, observations=None, + dev_nonexpanding_matrix=None, packagedata=None, lakeperioddata=None, filename=None, @@ -704,6 +717,9 @@ def __init__( self._obs_package = self.build_child_package( "obs", observations, "continuous", self._obs_filerecord ) + self.dev_nonexpanding_matrix = self.build_mfdata( + "dev_nonexpanding_matrix", dev_nonexpanding_matrix + ) self.packagedata = self.build_mfdata("packagedata", packagedata) self.lakeperioddata = self.build_mfdata("lakeperioddata", lakeperioddata) diff --git a/flopy/mf6/modflow/mfgwemwe.py b/flopy/mf6/modflow/mfgwemwe.py index a62a614fd..ceddcec31 100644 --- a/flopy/mf6/modflow/mfgwemwe.py +++ b/flopy/mf6/modflow/mfgwemwe.py @@ -87,6 +87,11 @@ class ModflowGwemwe(MFPackage): obs package with variable names as keys and package data as values. Data for the observations variable is also acceptable. See obs package documentation for more information. + dev_nonexpanding_matrix : keyword + keyword that solves the feature temperature with a general mixing equation and + adds the result to the right-hand side of the gwe equations, instead of adding + a row to the solution matrix for each feature. this development option is not + supported. packagedata : [(mawno, strt, ktf, fthk, aux, boundname)] * mawno : integer integer value that defines the well number associated with the specified PERIOD @@ -189,7 +194,7 @@ class ModflowGwemwe(MFPackage): _package_type = "mwe" dfn_file_name = "gwe-mwe.dfn" dfn = [ - ["header", "multi-package"], + ["header", "multi-package", "package-type advanced-stress-package"], [ "block options", "name flow_package_name", @@ -424,6 +429,13 @@ class ModflowGwemwe(MFPackage): "reader urword", "optional false", ], + [ + "block options", + "name dev_nonexpanding_matrix", + "type keyword", + "reader urword", + "optional true", + ], [ "block packagedata", "name packagedata", @@ -611,6 +623,7 @@ def __init__( budgetcsv_filerecord=None, timeseries=None, observations=None, + dev_nonexpanding_matrix=None, packagedata=None, mweperioddata=None, filename=None, @@ -658,6 +671,9 @@ def __init__( self._obs_package = self.build_child_package( "obs", observations, "continuous", self._obs_filerecord ) + self.dev_nonexpanding_matrix = self.build_mfdata( + "dev_nonexpanding_matrix", dev_nonexpanding_matrix + ) self.packagedata = self.build_mfdata("packagedata", packagedata) self.mweperioddata = self.build_mfdata("mweperioddata", mweperioddata) diff --git a/flopy/mf6/modflow/mfgweoc.py b/flopy/mf6/modflow/mfgweoc.py index 1270a025d..6734ba080 100644 --- a/flopy/mf6/modflow/mfgweoc.py +++ b/flopy/mf6/modflow/mfgweoc.py @@ -21,13 +21,16 @@ class ModflowGweoc(MFPackage): processing purposes only. budget_filerecord : (budgetfile) * budgetfile : string - name of the output file to write budget information. + name of the output file to write budget information. Data are written to this + file only for time steps in which SAVE BUDGET is specified in a PERIOD block. + If SAVE BUDGET is specified in a PERIOD block and this file is not specified, + MODFLOW 6 will terminate with an error. budgetcsv_filerecord : (budgetcsvfile) * budgetcsvfile : string name of the comma-separated value (CSV) output file to write budget summary - information. A budget summary record will be written to this file for each - time step of the simulation. + information. If specified, a budget summary record will be written to this file + for every time step of the simulation. temperature_filerecord : (temperaturefile) * temperaturefile : string diff --git a/flopy/mf6/modflow/mfgwesfe.py b/flopy/mf6/modflow/mfgwesfe.py index 750ce5275..832388387 100644 --- a/flopy/mf6/modflow/mfgwesfe.py +++ b/flopy/mf6/modflow/mfgwesfe.py @@ -87,6 +87,11 @@ class ModflowGwesfe(MFPackage): obs package with variable names as keys and package data as values. Data for the observations variable is also acceptable. See obs package documentation for more information. + dev_nonexpanding_matrix : keyword + keyword that solves the feature temperature with a general mixing equation and + adds the result to the right-hand side of the gwe equations, instead of adding + a row to the solution matrix for each feature. this development option is not + supported. packagedata : [(rno, strt, ktf, rbthcnd, aux, boundname)] * rno : integer integer value that defines the reach number associated with the specified @@ -210,7 +215,7 @@ class ModflowGwesfe(MFPackage): _package_type = "sfe" dfn_file_name = "gwe-sfe.dfn" dfn = [ - ["header", "multi-package"], + ["header", "multi-package", "package-type advanced-stress-package"], [ "block options", "name flow_package_name", @@ -445,6 +450,13 @@ class ModflowGwesfe(MFPackage): "reader urword", "optional false", ], + [ + "block options", + "name dev_nonexpanding_matrix", + "type keyword", + "reader urword", + "optional true", + ], [ "block packagedata", "name packagedata", @@ -662,6 +674,7 @@ def __init__( budgetcsv_filerecord=None, timeseries=None, observations=None, + dev_nonexpanding_matrix=None, packagedata=None, reachperioddata=None, filename=None, @@ -709,6 +722,9 @@ def __init__( self._obs_package = self.build_child_package( "obs", observations, "continuous", self._obs_filerecord ) + self.dev_nonexpanding_matrix = self.build_mfdata( + "dev_nonexpanding_matrix", dev_nonexpanding_matrix + ) self.packagedata = self.build_mfdata("packagedata", packagedata) self.reachperioddata = self.build_mfdata("reachperioddata", reachperioddata) diff --git a/flopy/mf6/modflow/mfgweuze.py b/flopy/mf6/modflow/mfgweuze.py index 90d449bdd..bf61bdc0e 100644 --- a/flopy/mf6/modflow/mfgweuze.py +++ b/flopy/mf6/modflow/mfgweuze.py @@ -87,6 +87,11 @@ class ModflowGweuze(MFPackage): obs package with variable names as keys and package data as values. Data for the observations variable is also acceptable. See obs package documentation for more information. + dev_nonexpanding_matrix : keyword + keyword that solves the feature temperature with a general mixing equation and + adds the result to the right-hand side of the gwe equations, instead of adding + a row to the solution matrix for each feature. this development option is not + supported. packagedata : [(uzfno, strt, aux, boundname)] * uzfno : integer integer value that defines the UZF cell number associated with the specified @@ -192,7 +197,7 @@ class ModflowGweuze(MFPackage): _package_type = "uze" dfn_file_name = "gwe-uze.dfn" dfn = [ - ["header", "multi-package"], + ["header", "multi-package", "package-type advanced-stress-package"], [ "block options", "name flow_package_name", @@ -427,6 +432,13 @@ class ModflowGweuze(MFPackage): "reader urword", "optional false", ], + [ + "block options", + "name dev_nonexpanding_matrix", + "type keyword", + "reader urword", + "optional true", + ], [ "block packagedata", "name packagedata", @@ -606,6 +618,7 @@ def __init__( budgetcsv_filerecord=None, timeseries=None, observations=None, + dev_nonexpanding_matrix=None, packagedata=None, uzeperioddata=None, filename=None, @@ -653,6 +666,9 @@ def __init__( self._obs_package = self.build_child_package( "obs", observations, "continuous", self._obs_filerecord ) + self.dev_nonexpanding_matrix = self.build_mfdata( + "dev_nonexpanding_matrix", dev_nonexpanding_matrix + ) self.packagedata = self.build_mfdata("packagedata", packagedata) self.uzeperioddata = self.build_mfdata("uzeperioddata", uzeperioddata) diff --git a/flopy/mf6/modflow/mfgwfchdg.py b/flopy/mf6/modflow/mfgwfchdg.py index 8b6148cec..49c6fa1bd 100644 --- a/flopy/mf6/modflow/mfgwfchdg.py +++ b/flopy/mf6/modflow/mfgwfchdg.py @@ -19,6 +19,14 @@ class ModflowGwfchdg(MFPackage): loading_package : bool, default False Do not set this parameter. It is intended for debugging and internal processing purposes only. + readarraygrid : keyword + indicates that array-based grid input will be used for the constant head + package. this keyword must be specified to use array-based grid input. when + readarraygrid is specified, values must be provided for every cell within a + model grid, even those cells that have an idomain value less than one. values + assigned to cells with idomain values less than one are not used and have no + effect on simulation results. no data cells should contain the value dnodata + (3.0e+30). auxiliary : [string] defines an array of one or more auxiliary variable names. there is no limit on the number of auxiliary variables that can be provided on this line; however, @@ -65,9 +73,12 @@ class ModflowGwfchdg(MFPackage): aux : [double precision] is an array of values for auxiliary variable aux(iaux), where iaux is a value from 1 to naux, and aux(iaux) must be listed as part of the auxiliary - variables. a separate array can be specified for each auxiliary variable. if - the value specified here for the auxiliary variable is the same as auxmultname, - then the head array will be multiplied by this array. + variables. a separate array can be specified for each auxiliary variable. if + an array is not specified for an auxiliary variable, then it will retain its + value from the most recently specified period block. if an auxiliary variable + array has never been specified, its value is zero. if the value specified here + for the auxiliary variable is the same as auxmultname, then the head array will + be multiplied by this array. filename : str or PathLike, optional Name or path of file where this package is stored. @@ -95,7 +106,7 @@ class ModflowGwfchdg(MFPackage): "type keyword", "reader urword", "optional false", - "developmode true", + "developmode false", "default true", ], [ @@ -243,6 +254,7 @@ def __init__( self, model, loading_package=False, + readarraygrid=True, auxiliary=None, auxmultname=None, print_input=None, @@ -268,6 +280,7 @@ def __init__( **kwargs, ) + self.readarraygrid = self.build_mfdata("readarraygrid", readarraygrid) self.auxiliary = self.build_mfdata("auxiliary", auxiliary) self.auxmultname = self.build_mfdata("auxmultname", auxmultname) self.print_input = self.build_mfdata("print_input", print_input) diff --git a/flopy/mf6/modflow/mfgwfcsub.py b/flopy/mf6/modflow/mfgwfcsub.py index e59a3ac51..b6dfcaf79 100644 --- a/flopy/mf6/modflow/mfgwfcsub.py +++ b/flopy/mf6/modflow/mfgwfcsub.py @@ -36,6 +36,24 @@ class ModflowGwfcsub(MFPackage): compressibility of water. typical values of beta are 4.6512e-10 1/pa or 2.2270e-8 lb/square foot in si and english units, respectively. by default, beta is 4.6512e-10 1/pa. + elastic_inelastic_smoothing : keyword + keyword to activate smoothing of the delay interbed skeletal specific storage + as the effective stress exceeds the preconsolidation stress. when active, the + skeletal specific storage is smoothly transitioned from the elastic value to + the inelastic value over a small window (0.001 of the preconsolidation stress) + instead of switching discontinuously at the preconsolidation stress. activating + this option can improve convergence of models with delay interbeds. by default, + the elastic to inelastic transition is discontinuous. + strict_effective_stress : keyword + keyword to terminate the simulation when the calculated effective stress is + small or negative, instead of regularizing it. by default the effective stress + used to calculate the skeletal specific storage is not allowed to fall below a + small fraction (0.001) of the geostatic stress, which keeps the specific + storage bounded and allows the simulation to continue; a warning reports the + number of time steps in which a negative effective stress was regularized. + negative effective stress typically occurs in uppermost cells where simulated + water levels rise above land surface. this keyword is deprecated and will be + removed in a future release. head_based : keyword keyword to indicate the head-based formulation will be used to simulate coarse- grained aquifer materials and no-delay and delay interbeds. specifying @@ -198,7 +216,13 @@ class ModflowGwfcsub(MFPackage): block, PCS0 is the initial offset from the calculated initial head or initial preconsolidation head in the CSUB interbed and the initial preconsolidation stress is calculated from the calculated initial effective stress or calculated - initial geostatic stress, respectively. + initial geostatic stress, respectively. When PCS0 is a relative offset, the + sign depends on the formulation. For the default effective-stress formulation, + specify a positive PCS0 for an overconsolidated interbed since the + preconsolidation stress is greater than the initial effective stress. If + HEAD_BASED is specified, specify a negative PCS0 for an overconsolidated + interbed since the preconsolidation head is less than the initial head. A PCS0 + of zero is normally consolidated in both formulations. * thick_frac : double precision is the interbed thickness or cell fraction of the interbed. Interbed thickness is specified as a fraction of the cell thickness if CELL_FRACTION is specified @@ -341,6 +365,23 @@ class ModflowGwfcsub(MFPackage): "optional true", "default 4.6512e-10", ], + [ + "block options", + "name elastic_inelastic_smoothing", + "type keyword", + "reader urword", + "optional true", + "mf6internal ei_smoothing", + ], + [ + "block options", + "name strict_effective_stress", + "type keyword", + "reader urword", + "optional true", + "deprecated 6.8.0", + "mf6internal strict_stress", + ], [ "block options", "name head_based", @@ -1026,6 +1067,8 @@ def __init__( save_flows=None, gammaw=9806.65, beta=4.6512e-10, + elastic_inelastic_smoothing=None, + strict_effective_stress=None, head_based=None, initial_preconsolidation_head=None, ndelaycells=None, @@ -1074,6 +1117,12 @@ def __init__( self.save_flows = self.build_mfdata("save_flows", save_flows) self.gammaw = self.build_mfdata("gammaw", gammaw) self.beta = self.build_mfdata("beta", beta) + self.elastic_inelastic_smoothing = self.build_mfdata( + "elastic_inelastic_smoothing", elastic_inelastic_smoothing + ) + self.strict_effective_stress = self.build_mfdata( + "strict_effective_stress", strict_effective_stress + ) self.head_based = self.build_mfdata("head_based", head_based) self.initial_preconsolidation_head = self.build_mfdata( "initial_preconsolidation_head", initial_preconsolidation_head diff --git a/flopy/mf6/modflow/mfgwfdis.py b/flopy/mf6/modflow/mfgwfdis.py index d28c91ea5..3cd158bd9 100644 --- a/flopy/mf6/modflow/mfgwfdis.py +++ b/flopy/mf6/modflow/mfgwfdis.py @@ -54,6 +54,13 @@ class ModflowGwfdis(MFPackage): modflow 6 simulation input. this option only has an effect when an output model netcdf file is configured and the simulation is run in validate mode, otherwise it is ignored. + crs : [string] + is a real-world coordinate reference system (crs) for the model, for example, + an epsg integer code (e.g. 26915), authority string (i.e. epsg:26915), or open + geospatial consortium well-known text (wkt) specification. limited to 5000 + characters. the entry for crs does not affect the model simulation, but it is + written to the binary grid file so that postprocessors can locate the grid in + space. packagedata : record ncf6 filein ncf6_filename Contains data for the ncf package. Data can be passed as a dictionary to the ncf package with variable names as keys and package data as values. Data for @@ -95,6 +102,7 @@ class ModflowGwfdis(MFPackage): """ grb_filerecord = ListTemplateGenerator(("gwf6", "dis", "options", "grb_filerecord")) + crs = ArrayTemplateGenerator(("gwf6", "dis", "options", "crs")) ncf_filerecord = ListTemplateGenerator(("gwf6", "dis", "options", "ncf_filerecord")) delr = ArrayTemplateGenerator(("gwf6", "dis", "griddata", "delr")) delc = ArrayTemplateGenerator(("gwf6", "dis", "griddata", "delc")) @@ -202,7 +210,6 @@ class ModflowGwfdis(MFPackage): "preserve_case true", "reader urword", "optional true", - "developmode true", ], [ "block options", @@ -330,6 +337,7 @@ def __init__( angrot=None, export_array_ascii=None, export_array_netcdf=None, + crs=None, packagedata=None, nlay=1, nrow=2, @@ -365,6 +373,7 @@ def __init__( self.export_array_netcdf = self.build_mfdata( "export_array_netcdf", export_array_netcdf ) + self.crs = self.build_mfdata("crs", crs) self._ncf_filerecord = self.build_mfdata("ncf_filerecord", None) self._ncf_package = self.build_child_package( "ncf", packagedata, "packagedata", self._ncf_filerecord diff --git a/flopy/mf6/modflow/mfgwfdisu.py b/flopy/mf6/modflow/mfgwfdisu.py index e45634f64..8de18ce06 100644 --- a/flopy/mf6/modflow/mfgwfdisu.py +++ b/flopy/mf6/modflow/mfgwfdisu.py @@ -58,6 +58,13 @@ class ModflowGwfdisu(MFPackage): export_array_ascii : keyword keyword that specifies input griddata arrays should be written to layered ascii output files. + crs : [string] + is a real-world coordinate reference system (crs) for the model, for example, + an epsg integer code (e.g. 26915), authority string (i.e. epsg:26915), or open + geospatial consortium well-known text (wkt) specification. limited to 5000 + characters. the entry for crs does not affect the model simulation, but it is + written to the binary grid file so that postprocessors can locate the grid in + space. nodes : integer is the number of cells in the model grid. nja : integer @@ -177,6 +184,7 @@ class ModflowGwfdisu(MFPackage): grb_filerecord = ListTemplateGenerator( ("gwf6", "disu", "options", "grb_filerecord") ) + crs = ArrayTemplateGenerator(("gwf6", "disu", "options", "crs")) top = ArrayTemplateGenerator(("gwf6", "disu", "griddata", "top")) bot = ArrayTemplateGenerator(("gwf6", "disu", "griddata", "bot")) area = ArrayTemplateGenerator(("gwf6", "disu", "griddata", "area")) @@ -290,7 +298,6 @@ class ModflowGwfdisu(MFPackage): "preserve_case true", "reader urword", "optional true", - "developmode true", ], [ "block dimensions", @@ -498,6 +505,7 @@ def __init__( angrot=None, vertical_offset_tolerance=0.0, export_array_ascii=None, + crs=None, nodes=None, nja=None, nvert=None, @@ -539,6 +547,7 @@ def __init__( self.export_array_ascii = self.build_mfdata( "export_array_ascii", export_array_ascii ) + self.crs = self.build_mfdata("crs", crs) self.nodes = self.build_mfdata("nodes", nodes) self.nja = self.build_mfdata("nja", nja) self.nvert = self.build_mfdata("nvert", nvert) diff --git a/flopy/mf6/modflow/mfgwfdisv.py b/flopy/mf6/modflow/mfgwfdisv.py index 457ba7c05..8cc96a614 100644 --- a/flopy/mf6/modflow/mfgwfdisv.py +++ b/flopy/mf6/modflow/mfgwfdisv.py @@ -57,6 +57,13 @@ class ModflowGwfdisv(MFPackage): modflow 6 simulation input. this option only has an effect when an output model netcdf file is configured and the simulation is run in validate mode, otherwise it is ignored. + crs : [string] + is a real-world coordinate reference system (crs) for the model, for example, + an epsg integer code (e.g. 26915), authority string (i.e. epsg:26915), or open + geospatial consortium well-known text (wkt) specification. limited to 5000 + characters. the entry for crs does not affect the model simulation, but it is + written to the binary grid file so that postprocessors can locate the grid in + space. packagedata : record ncf6 filein ncf6_filename Contains data for the ncf package. Data can be passed as a dictionary to the ncf package with variable names as keys and package data as values. Data for @@ -123,6 +130,7 @@ class ModflowGwfdisv(MFPackage): grb_filerecord = ListTemplateGenerator( ("gwf6", "disv", "options", "grb_filerecord") ) + crs = ArrayTemplateGenerator(("gwf6", "disv", "options", "crs")) ncf_filerecord = ListTemplateGenerator( ("gwf6", "disv", "options", "ncf_filerecord") ) @@ -232,7 +240,6 @@ class ModflowGwfdisv(MFPackage): "preserve_case true", "reader urword", "optional true", - "developmode true", ], [ "block options", @@ -429,6 +436,7 @@ def __init__( angrot=None, export_array_ascii=None, export_array_netcdf=None, + crs=None, packagedata=None, nlay=None, ncpl=None, @@ -464,6 +472,7 @@ def __init__( self.export_array_netcdf = self.build_mfdata( "export_array_netcdf", export_array_netcdf ) + self.crs = self.build_mfdata("crs", crs) self._ncf_filerecord = self.build_mfdata("ncf_filerecord", None) self._ncf_package = self.build_child_package( "ncf", packagedata, "packagedata", self._ncf_filerecord diff --git a/flopy/mf6/modflow/mfgwfdrng.py b/flopy/mf6/modflow/mfgwfdrng.py index e1386a111..1c3cc038b 100644 --- a/flopy/mf6/modflow/mfgwfdrng.py +++ b/flopy/mf6/modflow/mfgwfdrng.py @@ -19,6 +19,13 @@ class ModflowGwfdrng(MFPackage): loading_package : bool, default False Do not set this parameter. It is intended for debugging and internal processing purposes only. + readarraygrid : keyword + indicates that array-based grid input will be used for the drain package. this + keyword must be specified to use array-based grid input. when readarraygrid is + specified, values must be provided for every cell within a model grid, even + those cells that have an idomain value less than one. values assigned to cells + with idomain values less than one are not used and have no effect on simulation + results. no data cells should contain the value dnodata (3.0e+30). auxiliary : [string] defines an array of one or more auxiliary variable names. there is no limit on the number of auxiliary variables that can be provided on this line; however, @@ -85,9 +92,12 @@ class ModflowGwfdrng(MFPackage): aux : [double precision] is an array of values for auxiliary variable aux(iaux), where iaux is a value from 1 to naux, and aux(iaux) must be listed as part of the auxiliary - variables. a separate array can be specified for each auxiliary variable. if - the value specified here for the auxiliary variable is the same as auxmultname, - then the conductance array will be multiplied by this array. + variables. a separate array can be specified for each auxiliary variable. if + an array is not specified for an auxiliary variable, then it will retain its + value from the most recently specified period block. if an auxiliary variable + array has never been specified, its value is zero. if the value specified here + for the auxiliary variable is the same as auxmultname, then the conductance + array will be multiplied by this array. filename : str or PathLike, optional Name or path of file where this package is stored. @@ -116,7 +126,7 @@ class ModflowGwfdrng(MFPackage): "type keyword", "reader urword", "optional false", - "developmode true", + "developmode false", "default true", ], [ @@ -290,6 +300,7 @@ def __init__( self, model, loading_package=False, + readarraygrid=True, auxiliary=None, auxmultname=None, auxdepthname=None, @@ -318,6 +329,7 @@ def __init__( **kwargs, ) + self.readarraygrid = self.build_mfdata("readarraygrid", readarraygrid) self.auxiliary = self.build_mfdata("auxiliary", auxiliary) self.auxmultname = self.build_mfdata("auxmultname", auxmultname) self.auxdepthname = self.build_mfdata("auxdepthname", auxdepthname) diff --git a/flopy/mf6/modflow/mfgwfevta.py b/flopy/mf6/modflow/mfgwfevta.py index 4fd535f6c..cbdfbfd42 100644 --- a/flopy/mf6/modflow/mfgwfevta.py +++ b/flopy/mf6/modflow/mfgwfevta.py @@ -86,10 +86,11 @@ class ModflowGwfevta(MFPackage): is an array of values for auxiliary variable aux(iaux), where iaux is a value from 1 to naux, and aux(iaux) must be listed as part of the auxiliary variables. a separate array can be specified for each auxiliary variable. if - an array is not specified for an auxiliary variable, then a value of zero is - assigned. if the value specified here for the auxiliary variable is the same - as auxmultname, then the evapotranspiration rate will be multiplied by this - array. + an array is not specified for an auxiliary variable, then it will retain its + value from the most recently specified period block. if an auxiliary variable + array has never been specified, its value is zero. if the value specified here + for the auxiliary variable is the same as auxmultname, then the + evapotranspiration rate will be multiplied by this array. filename : str or PathLike, optional Name or path of file where this package is stored. diff --git a/flopy/mf6/modflow/mfgwfghbg.py b/flopy/mf6/modflow/mfgwfghbg.py index 415872ee4..66429e0ff 100644 --- a/flopy/mf6/modflow/mfgwfghbg.py +++ b/flopy/mf6/modflow/mfgwfghbg.py @@ -19,6 +19,14 @@ class ModflowGwfghbg(MFPackage): loading_package : bool, default False Do not set this parameter. It is intended for debugging and internal processing purposes only. + readarraygrid : keyword + indicates that array-based grid input will be used for the general-head + boundary package. this keyword must be specified to use array-based grid + input. when readarraygrid is specified, values must be provided for every cell + within a model grid, even those cells that have an idomain value less than one. + values assigned to cells with idomain values less than one are not used and + have no effect on simulation results. no data cells should contain the value + dnodata (3.0e+30). auxiliary : [string] defines an array of one or more auxiliary variable names. there is no limit on the number of auxiliary variables that can be provided on this line; however, @@ -72,9 +80,12 @@ class ModflowGwfghbg(MFPackage): aux : [double precision] is an array of values for auxiliary variable aux(iaux), where iaux is a value from 1 to naux, and aux(iaux) must be listed as part of the auxiliary - variables. a separate array can be specified for each auxiliary variable. if - the value specified here for the auxiliary variable is the same as auxmultname, - then the conductance array will be multiplied by this array. + variables. a separate array can be specified for each auxiliary variable. if + an array is not specified for an auxiliary variable, then it will retain its + value from the most recently specified period block. if an auxiliary variable + array has never been specified, its value is zero. if the value specified here + for the auxiliary variable is the same as auxmultname, then the conductance + array will be multiplied by this array. filename : str or PathLike, optional Name or path of file where this package is stored. @@ -103,7 +114,7 @@ class ModflowGwfghbg(MFPackage): "type keyword", "reader urword", "optional false", - "developmode true", + "developmode false", "default true", ], [ @@ -261,6 +272,7 @@ def __init__( self, model, loading_package=False, + readarraygrid=True, auxiliary=None, auxmultname=None, print_input=None, @@ -287,6 +299,7 @@ def __init__( **kwargs, ) + self.readarraygrid = self.build_mfdata("readarraygrid", readarraygrid) self.auxiliary = self.build_mfdata("auxiliary", auxiliary) self.auxmultname = self.build_mfdata("auxmultname", auxmultname) self.print_input = self.build_mfdata("print_input", print_input) diff --git a/flopy/mf6/modflow/mfgwfhfb.py b/flopy/mf6/modflow/mfgwfhfb.py index 90e3ef1b1..a3c2f1a18 100644 --- a/flopy/mf6/modflow/mfgwfhfb.py +++ b/flopy/mf6/modflow/mfgwfhfb.py @@ -32,11 +32,10 @@ class ModflowGwfhfb(MFPackage): file, CELLID1 is the layer, row, and column numbers of the cell. For a grid that uses the DISV input file, CELLID1 is the layer number and CELL2D number for the two cells. If the model uses the unstructured discretization (DISU) - input file, then CELLID1 is the node numbers for the cell. The barrier is - located between cells designated as CELLID1 and CELLID2. For models that use - the DIS and DISV grid types, the layer number for CELLID1 and CELLID2 must be - the same. For all grid types, cells must be horizontally or vertically - adjacent. + input file, then CELLID1 is the node number for the cell. The barrier is + located between cells designated as CELLID1 and CELLID2. The program will + terminate with an error if CELLID1 and CELLID2 are not horizontally or + vertically adjacent. * cellid2 : [integer] identifier for the second cell. See CELLID1 for description of how to specify. * hydchr : double precision diff --git a/flopy/mf6/modflow/mfgwflak.py b/flopy/mf6/modflow/mfgwflak.py index f761adb64..fab618f1e 100644 --- a/flopy/mf6/modflow/mfgwflak.py +++ b/flopy/mf6/modflow/mfgwflak.py @@ -84,22 +84,61 @@ class ModflowGwflak(MFPackage): water mover (mvr) package. when the mover option is specified, additional memory is allocated within the package to store the available, provided, and received water. + dev_force_fallback : keyword + keyword that solves every active lake with the substitution fallback under the + implicit formulation, instead of only those lakes that are too poorly + conditioned to solve as a matrix unknown. this development option is not + supported. + dev_groundwater_head_conductance : keyword + keyword that calculates the conductance for horizontal connections using the + groundwater head instead of the lake stage. this development option is not + supported. + dev_maximum_outlet_depth : double precision + real number value that defines the maximum depth used to calculate outlet flow. + this development option is not supported. surfdep : double precision real value that defines the surface depression depth for vertical lake-gwf connections. if specified, surfdep must be greater than or equal to zero. if surfdep is not specified, a default value of zero is used for all vertical lake-gwf connections. + implicit : keyword + keyword that activates the implicit formulation. with this option each lake + stage is solved as an additional unknown in the groundwater flow matrix, + instead of by the default substitution-iteration solver. the implicit + formulation typically converges in far fewer outer iterations for steady-state + and strongly coupled lakes and can solve problems for which the default + formulation fails to converge. converged results are equivalent to the default + formulation. the implicit formulation makes the coefficient matrix asymmetric + and therefore requires the bicgstab linear acceleration in the iterative model + solution (ims). each lake must have at least one groundwater connection when + the implicit option is active because the implicit formulation solves the lake + stage through its lakebed seepage; the program will terminate with an error if + a lake with no groundwater connections is specified with the implicit option. + if a lake stage becomes too poorly conditioned to solve as a matrix unknown, + which can happen for a weakly connected or disconnected lake, that lake is + solved automatically by the default substitution solver instead, so the + implicit formulation remains as robust as the default formulation. the newton + formulation is recommended for lakes that may become perched (disconnected from + the aquifer), because results obtained with the standard rewetting (rewet) + capability can otherwise differ between the two approaches. maximum_iterations : integer integer value that defines the maximum number of newton-raphson iterations allowed for a lake. by default, maximum_iterations is equal to 100. maximum_iterations would only need to be increased from the default value if - one or more lakes in a simulation has a large water budget error. + one or more lakes in a simulation has a large water budget error. this option + applies only to the default substitution-iteration solver; when the implicit + option is active the lake stage is solved within the groundwater flow matrix + and its convergence is controlled by the iterative model solution (ims) + settings instead. maximum_stage_change : double precision real value that defines the lake stage closure tolerance. by default, maximum_stage_change is equal to :math:`1 times 10^{-5}`. the maximum_stage_change would only need to be increased or decreased from the default value if the water budget error for one or more lakes is too small or - too large, respectively. + too large, respectively. this option applies only to the default substitution- + iteration solver; when the implicit option is active the lake stage is solved + within the groundwater flow matrix and its convergence is controlled by the + iterative model solution (ims) settings instead. time_conversion : double precision real value that is used to convert user-specified manning's roughness coefficients or gravitational acceleration used to calculate outlet flows from @@ -679,6 +718,27 @@ class ModflowGwflak(MFPackage): "reader urword", "optional true", ], + [ + "block options", + "name dev_force_fallback", + "type keyword", + "reader urword", + "optional true", + ], + [ + "block options", + "name dev_groundwater_head_conductance", + "type keyword", + "reader urword", + "optional true", + ], + [ + "block options", + "name dev_maximum_outlet_depth", + "type double precision", + "reader urword", + "optional true", + ], [ "block options", "name surfdep", @@ -686,6 +746,13 @@ class ModflowGwflak(MFPackage): "reader urword", "optional true", ], + [ + "block options", + "name implicit", + "type keyword", + "reader urword", + "optional true", + ], [ "block options", "name maximum_iterations", @@ -1226,7 +1293,11 @@ def __init__( timeseries=None, observations=None, mover=None, + dev_force_fallback=None, + dev_groundwater_head_conductance=None, + dev_maximum_outlet_depth=None, surfdep=None, + implicit=None, maximum_iterations=None, maximum_stage_change=None, time_conversion=None, @@ -1278,7 +1349,17 @@ def __init__( "obs", observations, "continuous", self._obs_filerecord ) self.mover = self.build_mfdata("mover", mover) + self.dev_force_fallback = self.build_mfdata( + "dev_force_fallback", dev_force_fallback + ) + self.dev_groundwater_head_conductance = self.build_mfdata( + "dev_groundwater_head_conductance", dev_groundwater_head_conductance + ) + self.dev_maximum_outlet_depth = self.build_mfdata( + "dev_maximum_outlet_depth", dev_maximum_outlet_depth + ) self.surfdep = self.build_mfdata("surfdep", surfdep) + self.implicit = self.build_mfdata("implicit", implicit) self.maximum_iterations = self.build_mfdata( "maximum_iterations", maximum_iterations ) diff --git a/flopy/mf6/modflow/mfgwfmaw.py b/flopy/mf6/modflow/mfgwfmaw.py index 09cea44c3..7d3aa0891 100644 --- a/flopy/mf6/modflow/mfgwfmaw.py +++ b/flopy/mf6/modflow/mfgwfmaw.py @@ -74,6 +74,23 @@ class ModflowGwfmaw(MFPackage): when flow corrections are activated, unit head gradients are used to calculate the flow between a multi-aquifer well and a connected gwf cell. by default, flow corrections are not made. + non_vertical_wells : keyword + keyword that activates support for non-vertical (slanted) multi-aquifer well + connections. when this option is specified, an angledata block can be used to + assign a tilt angle (deviation from vertical) to individual multi-aquifer well + connections. the saturated conductance for a connection listed in the + angledata block is scaled by the in-cell screen length, which is calculated + from the screen top, screen bottom, and tilt angle (or specified directly), + instead of the vertical screen thickness. by default, all multi-aquifer well + connections are assumed to be vertical. the tilt angle and connection length + scale the conductance for a single connection only and do not route flow + through intervening cells; a well that passes through more than one cell (a + slanted well or a horizontal lateral) must have a separate connection to each + cell that it penetrates. for a connection that uses the specified conductance + equation, the saturated conductance is used unchanged (the length correction is + not applied because the conductance is provided by the user), but the screen + top and bottom are honored so that the connection saturation is calculated over + the correct interval rather than over the full cell. flowing_wells : keyword keyword that activates the flowing wells option for the multi-aquifer well package. @@ -120,6 +137,10 @@ class ModflowGwfmaw(MFPackage): water mover (mvr) package. when the mover option is specified, additional memory is allocated within the package to store the available, provided, and received water. + dev_peaceman_effective_radius : keyword + keyword that calculates the effective radius for structured grids using the + approach of peaceman (1983) instead of the default approach. this development + option is not supported. nmawwells : integer integer value specifying the number of multi-aquifer wells that will be simulated for all stress periods. @@ -188,8 +209,8 @@ class ModflowGwfmaw(MFPackage): NMAWWELLS. * icon : integer integer value that defines the GWF connection number for this multi-aquifer - well connection entry. ICONN must be greater than zero and less than or equal - to NGWFNODES for multi-aquifer well IFNO. + well connection entry. ICON must be greater than zero and less than or equal to + NGWFNODES for multi-aquifer well IFNO. * cellid : [integer] is the cell identifier, and depends on the type of grid that is used for the simulation. For a structured grid that uses the DIS input file, CELLID is the @@ -234,6 +255,48 @@ class ModflowGwfmaw(MFPackage): CONDEQN is SKIN, CUMULATIVE, or MEAN, the program will terminate with an error if RADIUS_SKIN is less than or equal to the RADIUS for the multi-aquifer well. + angledata : [(ifno, icon, angle, conn_length)] + * ifno : integer + integer value that defines the well number associated with the specified PERIOD + data on the line. IFNO must be greater than zero and less than or equal to + NMAWWELLS. + * icon : integer + integer value that defines the GWF connection number for this multi-aquifer + well connection entry. ICON must be greater than zero and less than or equal to + NGWFNODES for multi-aquifer well IFNO. + * angle : double precision + value that defines the tilt angle of the multi-aquifer well connection, in + degrees measured as a deviation from vertical. ANGLE must be greater than or + equal to 0.0 (a vertical connection) and less than or equal to 90.0 (a + horizontal connection) degrees. The in-cell screen length used to calculate + the saturated conductance is computed from the screen top, screen bottom, well + radius, and ANGLE, unless CONN_LENGTH is specified. The horizontal distance + spanned by a connection grows rapidly as ANGLE approaches 90.0 degrees, and a + warning is issued if it is greater than the maximum horizontal extent of the + connected cell. A connection that is intended to pass through more than one + cell should be specified as a separate connection to each cell that it passes + through. If a connection passes through more than one cell unintentionally, + ANGLE or CONN_LENGTH should be reduced. + * conn_length : double precision + optional value that defines the length of the multi-aquifer well screen within + the connected GWF cell. If CONN_LENGTH is specified (and greater than zero), + it is used directly as the in-cell screen length; otherwise, the in-cell screen + length is calculated from the screen top, screen bottom, well radius, and + ANGLE. CONN_LENGTH must be specified for horizontal connections (ANGLE equal + to 90.0 degrees) because the in-cell screen length cannot be derived from the + screen elevations for a horizontal connection. A warning is issued if the + horizontal distance spanned by the connection is greater than the maximum + horizontal extent of the connected cell. The MEAN conductance equation is + recommended for horizontal connections. For a horizontal connection using the + MEAN conductance equation, SCRN_TOP and SCRN_BOT do not affect the magnitude of + the saturated conductance but do determine the elevation range over which the + connection saturates and dewaters; SCRN_TOP and SCRN_BOT should be set to the + top and bottom of the horizontal borehole so that the vertical screen extent + (SCRN_TOP :math:`-` SCRN_BOT) equals the well diameter (2 times RADIUS). The + program snaps SCRN_TOP to SCRN_BOT plus the well diameter when the specified + extent is essentially equal to the well diameter, and otherwise issues a + warning. + perioddata : [(ifno, mawsetting)] * ifno : integer integer value that defines the well number associated with the specified period @@ -381,6 +444,7 @@ class ModflowGwfmaw(MFPackage): connectiondata = ListTemplateGenerator( ("gwf6", "maw", "connectiondata", "connectiondata") ) + angledata = ListTemplateGenerator(("gwf6", "maw", "angledata", "angledata")) perioddata = ListTemplateGenerator(("gwf6", "maw", "period", "perioddata")) package_abbr = "gwfmaw" _package_type = "maw" @@ -545,6 +609,13 @@ class ModflowGwfmaw(MFPackage): "reader urword", "optional true", ], + [ + "block options", + "name non_vertical_wells", + "type keyword", + "reader urword", + "optional true", + ], [ "block options", "name flowing_wells", @@ -678,6 +749,13 @@ class ModflowGwfmaw(MFPackage): "reader urword", "optional true", ], + [ + "block options", + "name dev_peaceman_effective_radius", + "type keyword", + "reader urword", + "optional true", + ], [ "block dimensions", "name nmawwells", @@ -839,6 +917,53 @@ class ModflowGwfmaw(MFPackage): "in_record true", "reader urword", ], + [ + "block angledata", + "name angledata", + "type recarray ifno icon angle conn_length", + "reader urword", + "optional true", + ], + [ + "block angledata", + "name ifno", + "type integer", + "shape", + "tagged false", + "in_record true", + "reader urword", + "numeric_index true", + ], + [ + "block angledata", + "name icon", + "type integer", + "shape", + "tagged false", + "in_record true", + "reader urword", + "numeric_index true", + ], + [ + "block angledata", + "name angle", + "type double precision", + "shape", + "tagged false", + "in_record true", + "reader urword", + "numeric_index false", + ], + [ + "block angledata", + "name conn_length", + "type double precision", + "shape", + "tagged false", + "in_record true", + "reader urword", + "optional true", + ], [ "block period", "name iper", @@ -1082,6 +1207,7 @@ def __init__( budgetcsv_filerecord=None, no_well_storage=None, flow_correction=None, + non_vertical_wells=None, flowing_wells=None, shutdown_theta=None, shutdown_kappa=None, @@ -1089,9 +1215,11 @@ def __init__( timeseries=None, observations=None, mover=None, + dev_peaceman_effective_radius=None, nmawwells=None, packagedata=None, connectiondata=None, + angledata=None, perioddata=None, filename=None, pname=None, @@ -1122,6 +1250,9 @@ def __init__( ) self.no_well_storage = self.build_mfdata("no_well_storage", no_well_storage) self.flow_correction = self.build_mfdata("flow_correction", flow_correction) + self.non_vertical_wells = self.build_mfdata( + "non_vertical_wells", non_vertical_wells + ) self.flowing_wells = self.build_mfdata("flowing_wells", flowing_wells) self.shutdown_theta = self.build_mfdata("shutdown_theta", shutdown_theta) self.shutdown_kappa = self.build_mfdata("shutdown_kappa", shutdown_kappa) @@ -1137,9 +1268,13 @@ def __init__( "obs", observations, "continuous", self._obs_filerecord ) self.mover = self.build_mfdata("mover", mover) + self.dev_peaceman_effective_radius = self.build_mfdata( + "dev_peaceman_effective_radius", dev_peaceman_effective_radius + ) self.nmawwells = self.build_mfdata("nmawwells", nmawwells) self.packagedata = self.build_mfdata("packagedata", packagedata) self.connectiondata = self.build_mfdata("connectiondata", connectiondata) + self.angledata = self.build_mfdata("angledata", angledata) self.perioddata = self.build_mfdata("perioddata", perioddata) self._init_complete = True diff --git a/flopy/mf6/modflow/mfgwfnpf.py b/flopy/mf6/modflow/mfgwfnpf.py index 1a1f1af63..0a0196981 100644 --- a/flopy/mf6/modflow/mfgwfnpf.py +++ b/flopy/mf6/modflow/mfgwfnpf.py @@ -350,6 +350,7 @@ class ModflowGwfnpf(MFPackage): "in_record true", "reader urword", "optional false", + "tagged true", ], [ "block options", @@ -358,6 +359,7 @@ class ModflowGwfnpf(MFPackage): "in_record true", "reader urword", "optional false", + "tagged true", ], [ "block options", @@ -366,6 +368,7 @@ class ModflowGwfnpf(MFPackage): "in_record true", "reader urword", "optional false", + "tagged true", ], [ "block options", diff --git a/flopy/mf6/modflow/mfgwfoc.py b/flopy/mf6/modflow/mfgwfoc.py index a5a083b4f..e9f8991dc 100644 --- a/flopy/mf6/modflow/mfgwfoc.py +++ b/flopy/mf6/modflow/mfgwfoc.py @@ -21,13 +21,16 @@ class ModflowGwfoc(MFPackage): processing purposes only. budget_filerecord : (budgetfile) * budgetfile : string - name of the output file to write budget information. + name of the output file to write budget information. Data are written to this + file only for time steps in which SAVE BUDGET is specified in a PERIOD block. + If SAVE BUDGET is specified in a PERIOD block and this file is not specified, + MODFLOW 6 will terminate with an error. budgetcsv_filerecord : (budgetcsvfile) * budgetcsvfile : string name of the comma-separated value (CSV) output file to write budget summary - information. A budget summary record will be written to this file for each - time step of the simulation. + information. If specified, a budget summary record will be written to this file + for every time step of the simulation. head_filerecord : (headfile) * headfile : string diff --git a/flopy/mf6/modflow/mfgwfrcha.py b/flopy/mf6/modflow/mfgwfrcha.py index e319c0131..21632b32a 100644 --- a/flopy/mf6/modflow/mfgwfrcha.py +++ b/flopy/mf6/modflow/mfgwfrcha.py @@ -85,9 +85,11 @@ class ModflowGwfrcha(MFPackage): is an array of values for auxiliary variable aux(iaux), where iaux is a value from 1 to naux, and aux(iaux) must be listed as part of the auxiliary variables. a separate array can be specified for each auxiliary variable. if - an array is not specified for an auxiliary variable, then a value of zero is - assigned. if the value specified here for the auxiliary variable is the same - as auxmultname, then the recharge array will be multiplied by this array. + an array is not specified for an auxiliary variable, then it will retain its + value from the most recently specified period block. if an auxiliary variable + array has never been specified, its value is zero. if the value specified here + for the auxiliary variable is the same as auxmultname, then the recharge array + will be multiplied by this array. filename : str or PathLike, optional Name or path of file where this package is stored. diff --git a/flopy/mf6/modflow/mfgwfrivg.py b/flopy/mf6/modflow/mfgwfrivg.py index 3472aef27..4145923f6 100644 --- a/flopy/mf6/modflow/mfgwfrivg.py +++ b/flopy/mf6/modflow/mfgwfrivg.py @@ -19,6 +19,13 @@ class ModflowGwfrivg(MFPackage): loading_package : bool, default False Do not set this parameter. It is intended for debugging and internal processing purposes only. + readarraygrid : keyword + indicates that array-based grid input will be used for the river package. this + keyword must be specified to use array-based grid input. when readarraygrid is + specified, values must be provided for every cell within a model grid, even + those cells that have an idomain value less than one. values assigned to cells + with idomain values less than one are not used and have no effect on simulation + results. no data cells should contain the value dnodata (3.0e+30). auxiliary : [string] defines an array of one or more auxiliary variable names. there is no limit on the number of auxiliary variables that can be provided on this line; however, @@ -72,9 +79,12 @@ class ModflowGwfrivg(MFPackage): aux : [double precision] is an array of values for auxiliary variable aux(iaux), where iaux is a value from 1 to naux, and aux(iaux) must be listed as part of the auxiliary - variables. a separate array can be specified for each auxiliary variable. if - the value specified here for the auxiliary variable is the same as auxmultname, - then the conductance array will be multiplied by this array. + variables. a separate array can be specified for each auxiliary variable. if + an array is not specified for an auxiliary variable, then it will retain its + value from the most recently specified period block. if an auxiliary variable + array has never been specified, its value is zero. if the value specified here + for the auxiliary variable is the same as auxmultname, then the conductance + array will be multiplied by this array. filename : str or PathLike, optional Name or path of file where this package is stored. @@ -104,7 +114,7 @@ class ModflowGwfrivg(MFPackage): "type keyword", "reader urword", "optional false", - "developmode true", + "developmode false", "default True", ], [ @@ -272,6 +282,7 @@ def __init__( self, model, loading_package=False, + readarraygrid=True, auxiliary=None, auxmultname=None, print_input=None, @@ -299,6 +310,7 @@ def __init__( **kwargs, ) + self.readarraygrid = self.build_mfdata("readarraygrid", readarraygrid) self.auxiliary = self.build_mfdata("auxiliary", auxiliary) self.auxmultname = self.build_mfdata("auxmultname", auxmultname) self.print_input = self.build_mfdata("print_input", print_input) diff --git a/flopy/mf6/modflow/mfgwfsfr.py b/flopy/mf6/modflow/mfgwfsfr.py index 0f3e2d113..753582615 100644 --- a/flopy/mf6/modflow/mfgwfsfr.py +++ b/flopy/mf6/modflow/mfgwfsfr.py @@ -22,6 +22,16 @@ class ModflowGwfsfr(MFPackage): storage : keyword keyword that activates storage contributions to the stream-flow routing package continuity equation. + ats_courant : double precision + real number value that defines the target courant number submitted by the sfr + package to the adaptive time stepping (ats) package. if ats_courant is + specified and the ats package is active, the sfr package will compute the time + step that achieves the target courant number for the most constraining reach + (the reach with the highest wave celerity-to-length ratio) and submit that time + step to the ats package. the storage option must be active when ats_courant is + specified. ats_courant must be greater than zero. a recommended baseline + value is 1.0, which targets unit courant number; values between 0.5 and 2.0 are + generally acceptable for kinematic wave routing. auxiliary : [string] defines an array of one or more auxiliary variable names. there is no limit on the number of auxiliary variables that can be provided on this line; however, @@ -127,8 +137,13 @@ class ModflowGwfsfr(MFPackage): seconds. dev_storage_weight : double precision real number value that defines the time weighting factor used to calculate the - change in channel storage. storage_weight must have a value between 0.5 and 1. - default storage_weight value is 1. + change in channel storage. dev_storage_weight must have a value between 0.5 and + 1. the default dev_storage_weight value is 1, which is fully implicit and is + recommended for all practical applications. + dev_no_check : keyword + keyword that deactivates the checks of reach geometry relative to the model + grid and of reach parameters for reasonable values. this development option is + not supported. nreaches : integer integer value specifying the number of stream reaches. there must be nreaches entries in the packagedata block. @@ -458,6 +473,13 @@ class ModflowGwfsfr(MFPackage): "reader urword", "optional true", ], + [ + "block options", + "name ats_courant", + "type double precision", + "reader urword", + "optional true", + ], [ "block options", "name auxiliary", @@ -763,6 +785,13 @@ class ModflowGwfsfr(MFPackage): "reader urword", "optional true", ], + [ + "block options", + "name dev_no_check", + "type keyword", + "reader urword", + "optional true", + ], [ "block dimensions", "name nreaches", @@ -1319,6 +1348,7 @@ def __init__( model, loading_package=False, storage=None, + ats_courant=None, auxiliary=None, boundnames=None, print_input=None, @@ -1339,6 +1369,7 @@ def __init__( length_conversion=None, time_conversion=None, dev_storage_weight=None, + dev_no_check=None, nreaches=None, packagedata=None, crosssections=None, @@ -1361,6 +1392,7 @@ def __init__( ) self.storage = self.build_mfdata("storage", storage) + self.ats_courant = self.build_mfdata("ats_courant", ats_courant) self.auxiliary = self.build_mfdata("auxiliary", auxiliary) self.boundnames = self.build_mfdata("boundnames", boundnames) self.print_input = self.build_mfdata("print_input", print_input) @@ -1403,6 +1435,7 @@ def __init__( self.dev_storage_weight = self.build_mfdata( "dev_storage_weight", dev_storage_weight ) + self.dev_no_check = self.build_mfdata("dev_no_check", dev_no_check) self.nreaches = self.build_mfdata("nreaches", nreaches) self.packagedata = self.build_mfdata("packagedata", packagedata) self.crosssections = self.build_mfdata("crosssections", crosssections) diff --git a/flopy/mf6/modflow/mfgwfuzf.py b/flopy/mf6/modflow/mfgwfuzf.py index a867e687f..89358247b 100644 --- a/flopy/mf6/modflow/mfgwfuzf.py +++ b/flopy/mf6/modflow/mfgwfuzf.py @@ -82,6 +82,9 @@ class ModflowGwfuzf(MFPackage): water mover (mvr) package. when the mover option is specified, additional memory is allocated within the package to store the available, provided, and received water. + dev_no_final_check : keyword + keyword that deactivates the final convergence check of the change in uzf + recharge. this development option is not supported. simulate_et : keyword keyword specifying that et in the unsaturated (uzf) and saturated zones (gwf) will be simulated. et can be simulated in the uzf cell and not the gwf cell by @@ -526,6 +529,13 @@ class ModflowGwfuzf(MFPackage): "reader urword", "optional true", ], + [ + "block options", + "name dev_no_final_check", + "type keyword", + "reader urword", + "optional true", + ], [ "block options", "name simulate_et", @@ -836,6 +846,7 @@ def __init__( timeseries=None, observations=None, mover=None, + dev_no_final_check=None, simulate_et=None, linear_gwet=None, square_gwet=None, @@ -886,6 +897,9 @@ def __init__( "obs", observations, "continuous", self._obs_filerecord ) self.mover = self.build_mfdata("mover", mover) + self.dev_no_final_check = self.build_mfdata( + "dev_no_final_check", dev_no_final_check + ) self.simulate_et = self.build_mfdata("simulate_et", simulate_et) self.linear_gwet = self.build_mfdata("linear_gwet", linear_gwet) self.square_gwet = self.build_mfdata("square_gwet", square_gwet) diff --git a/flopy/mf6/modflow/mfgwfwel.py b/flopy/mf6/modflow/mfgwfwel.py index 54da47dcf..ae22cc9a2 100644 --- a/flopy/mf6/modflow/mfgwfwel.py +++ b/flopy/mf6/modflow/mfgwfwel.py @@ -54,11 +54,13 @@ class ModflowGwfwel(MFPackage): rates are adjusted to 0 or a smaller negative value when the head in the cell is equal to or less than the calculated interval above the cell bottom. auto_flow_reduce is set to 0.1 if the specified value is less than or equal to - zero. by default, negative pumping rates are not reduced during a simulation. - this auto_flow_reduce option only applies to wells in model cells that are - marked as 'convertible' (icelltype /= 0) in the node property flow (npf) input - file. reduction in flow will not occur for wells in cells marked as confined - (icelltype = 0). + zero. auto_flow_reduce is set to 1.0 if the specified value is greater than 1.0 + and the flow_reduction_length option is not specified (that is, the value is + interpreted as a fraction of the cell thickness). by default, negative pumping + rates are not reduced during a simulation. this auto_flow_reduce option only + applies to wells in model cells that are marked as 'convertible' (icelltype /= + 0) in the node property flow (npf) input file. reduction in flow will not occur + for wells in cells marked as confined (icelltype = 0). afrcsv_filerecord : (afrcsvfile) * afrcsvfile : string name of the comma-separated value (CSV) output file to write information about @@ -72,6 +74,24 @@ class ModflowGwfwel(MFPackage): not specified in the options block. the program will terminate with an error if the flow_reduction_length option is specified and the auto_flow_reduce value specified in the options block is less than or equal to zero. + auto_flow_reduce_auxname : string + name of a variable listed in auxiliary that defines the per-well + auto_flow_reduce value used to compute the flow reduction threshold for each + well. the auxiliary variable is interpreted the same way as the global + auto_flow_reduce value: as a length above the cell bottom when + flow_reduction_length is specified, or otherwise as a fraction of the cell + thickness. when specified, the per-well auxiliary value overrides the global + auto_flow_reduce value for that well. a warning will be issued if + auto_flow_reduce_auxname is specified but auto_flow_reduce is not specified in + the options block. a warning will be issued if auto_flow_reduce_auxname is + specified but flow_reduction_length is not specified in the options block. the + program will terminate with an error if auto_flow_reduce_auxname is specified + but no auxiliary variables are specified, or if the named auxiliary variable + cannot be found. the per-well value must be greater than zero and less than or + equal to 1 when flow_reduction_length is not specified, or greater than zero + and less than or equal to the cell thickness when flow_reduction_length is + specified; the program will terminate with an error if a value is outside the + valid range. timeseries : record ts6 filein ts6_filename Contains data for the ts package. Data can be passed as a dictionary to the ts package with variable names as keys and package data as values. Data for the @@ -245,6 +265,15 @@ class ModflowGwfwel(MFPackage): "optional true", "mf6internal iflowredlen", ], + [ + "block options", + "name auto_flow_reduce_auxname", + "type string", + "shape", + "reader urword", + "optional true", + "mf6internal afrauxname", + ], [ "block options", "name ts_filerecord", @@ -410,6 +439,7 @@ def __init__( auto_flow_reduce=None, afrcsv_filerecord=None, flow_reduction_length=None, + auto_flow_reduce_auxname=None, timeseries=None, observations=None, mover=None, @@ -442,6 +472,9 @@ def __init__( self.flow_reduction_length = self.build_mfdata( "flow_reduction_length", flow_reduction_length ) + self.auto_flow_reduce_auxname = self.build_mfdata( + "auto_flow_reduce_auxname", auto_flow_reduce_auxname + ) self._ts_filerecord = self.build_mfdata("ts_filerecord", None) self._ts_package = self.build_child_package( "ts", timeseries, "timeseries", self._ts_filerecord diff --git a/flopy/mf6/modflow/mfgwfwelg.py b/flopy/mf6/modflow/mfgwfwelg.py index 5fec9ff50..67e32d67a 100644 --- a/flopy/mf6/modflow/mfgwfwelg.py +++ b/flopy/mf6/modflow/mfgwfwelg.py @@ -19,6 +19,14 @@ class ModflowGwfwelg(MFPackage): loading_package : bool, default False Do not set this parameter. It is intended for debugging and internal processing purposes only. + readarraygrid : keyword + indicates that array-based grid input will be used for the well boundary + package. this keyword must be specified to use array-based grid input. when + readarraygrid is specified, values must be provided for every cell within a + model grid, even those cells that have an idomain value less than one. values + assigned to cells with idomain values less than one are not used and have no + effect on simulation results. no data cells should contain the value dnodata + (3.0e+30). auxiliary : [string] defines an array of one or more auxiliary variable names. there is no limit on the number of auxiliary variables that can be provided on this line; however, @@ -94,9 +102,12 @@ class ModflowGwfwelg(MFPackage): aux : [double precision] is an array of values for auxiliary variable aux(iaux), where iaux is a value from 1 to naux, and aux(iaux) must be listed as part of the auxiliary - variables. a separate array can be specified for each auxiliary variable. if - the value specified here for the auxiliary variable is the same as auxmultname, - then the well rate array will be multiplied by this array. + variables. a separate array can be specified for each auxiliary variable. if + an array is not specified for an auxiliary variable, then it will retain its + value from the most recently specified period block. if an auxiliary variable + array has never been specified, its value is zero. if the value specified here + for the auxiliary variable is the same as auxmultname, then the well rate array + will be multiplied by this array. filename : str or PathLike, optional Name or path of file where this package is stored. @@ -127,7 +138,7 @@ class ModflowGwfwelg(MFPackage): "type keyword", "reader urword", "optional false", - "developmode true", + "developmode false", "default true", ], [ @@ -333,6 +344,7 @@ def __init__( self, model, loading_package=False, + readarraygrid=True, auxiliary=None, auxmultname=None, print_input=None, @@ -361,6 +373,7 @@ def __init__( **kwargs, ) + self.readarraygrid = self.build_mfdata("readarraygrid", readarraygrid) self.auxiliary = self.build_mfdata("auxiliary", auxiliary) self.auxmultname = self.build_mfdata("auxmultname", auxmultname) self.print_input = self.build_mfdata("print_input", print_input) diff --git a/flopy/mf6/modflow/mfgwtcnc.py b/flopy/mf6/modflow/mfgwtcnc.py index 05d9516b5..be320494c 100644 --- a/flopy/mf6/modflow/mfgwtcnc.py +++ b/flopy/mf6/modflow/mfgwtcnc.py @@ -105,7 +105,7 @@ class ModflowGwtcnc(MFPackage): _package_type = "cnc" dfn_file_name = "gwt-cnc.dfn" dfn = [ - ["header", "multi-package"], + ["header", "multi-package", "package-type stress-package"], [ "block options", "name auxiliary", @@ -136,7 +136,7 @@ class ModflowGwtcnc(MFPackage): "type keyword", "reader urword", "optional true", - "mf6internal iprflow", + "mf6internal iprpak", ], [ "block options", @@ -144,7 +144,7 @@ class ModflowGwtcnc(MFPackage): "type keyword", "reader urword", "optional true", - "mf6internal ipakcb", + "mf6internal iprflow", ], [ "block options", @@ -152,7 +152,7 @@ class ModflowGwtcnc(MFPackage): "type keyword", "reader urword", "optional true", - "mf6internal iprpak", + "mf6internal ipakcb", ], [ "block options", diff --git a/flopy/mf6/modflow/mfgwtdis.py b/flopy/mf6/modflow/mfgwtdis.py index a32722915..e5bdb39b9 100644 --- a/flopy/mf6/modflow/mfgwtdis.py +++ b/flopy/mf6/modflow/mfgwtdis.py @@ -54,6 +54,13 @@ class ModflowGwtdis(MFPackage): modflow 6 simulation input. this option only has an effect when an output model netcdf file is configured and the simulation is run in validate mode, otherwise it is ignored. + crs : [string] + is a real-world coordinate reference system (crs) for the model, for example, + an epsg integer code (e.g. 26915), authority string (i.e. epsg:26915), or open + geospatial consortium well-known text (wkt) specification. limited to 5000 + characters. the entry for crs does not affect the model simulation, but it is + written to the binary grid file so that postprocessors can locate the grid in + space. packagedata : record ncf6 filein ncf6_filename Contains data for the ncf package. Data can be passed as a dictionary to the ncf package with variable names as keys and package data as values. Data for @@ -95,6 +102,7 @@ class ModflowGwtdis(MFPackage): """ grb_filerecord = ListTemplateGenerator(("gwt6", "dis", "options", "grb_filerecord")) + crs = ArrayTemplateGenerator(("gwt6", "dis", "options", "crs")) ncf_filerecord = ListTemplateGenerator(("gwt6", "dis", "options", "ncf_filerecord")) delr = ArrayTemplateGenerator(("gwt6", "dis", "griddata", "delr")) delc = ArrayTemplateGenerator(("gwt6", "dis", "griddata", "delc")) @@ -202,7 +210,6 @@ class ModflowGwtdis(MFPackage): "preserve_case true", "reader urword", "optional true", - "developmode true", ], [ "block options", @@ -330,6 +337,7 @@ def __init__( angrot=None, export_array_ascii=None, export_array_netcdf=None, + crs=None, packagedata=None, nlay=1, nrow=2, @@ -365,6 +373,7 @@ def __init__( self.export_array_netcdf = self.build_mfdata( "export_array_netcdf", export_array_netcdf ) + self.crs = self.build_mfdata("crs", crs) self._ncf_filerecord = self.build_mfdata("ncf_filerecord", None) self._ncf_package = self.build_child_package( "ncf", packagedata, "packagedata", self._ncf_filerecord diff --git a/flopy/mf6/modflow/mfgwtdisu.py b/flopy/mf6/modflow/mfgwtdisu.py index df4674d08..826dbf6ac 100644 --- a/flopy/mf6/modflow/mfgwtdisu.py +++ b/flopy/mf6/modflow/mfgwtdisu.py @@ -58,6 +58,13 @@ class ModflowGwtdisu(MFPackage): export_array_ascii : keyword keyword that specifies input griddata arrays should be written to layered ascii output files. + crs : [string] + is a real-world coordinate reference system (crs) for the model, for example, + an epsg integer code (e.g. 26915), authority string (i.e. epsg:26915), or open + geospatial consortium well-known text (wkt) specification. limited to 5000 + characters. the entry for crs does not affect the model simulation, but it is + written to the binary grid file so that postprocessors can locate the grid in + space. nodes : integer is the number of cells in the model grid. nja : integer @@ -177,6 +184,7 @@ class ModflowGwtdisu(MFPackage): grb_filerecord = ListTemplateGenerator( ("gwt6", "disu", "options", "grb_filerecord") ) + crs = ArrayTemplateGenerator(("gwt6", "disu", "options", "crs")) top = ArrayTemplateGenerator(("gwt6", "disu", "griddata", "top")) bot = ArrayTemplateGenerator(("gwt6", "disu", "griddata", "bot")) area = ArrayTemplateGenerator(("gwt6", "disu", "griddata", "area")) @@ -290,7 +298,6 @@ class ModflowGwtdisu(MFPackage): "preserve_case true", "reader urword", "optional true", - "developmode true", ], [ "block dimensions", @@ -498,6 +505,7 @@ def __init__( angrot=None, vertical_offset_tolerance=0.0, export_array_ascii=None, + crs=None, nodes=None, nja=None, nvert=None, @@ -539,6 +547,7 @@ def __init__( self.export_array_ascii = self.build_mfdata( "export_array_ascii", export_array_ascii ) + self.crs = self.build_mfdata("crs", crs) self.nodes = self.build_mfdata("nodes", nodes) self.nja = self.build_mfdata("nja", nja) self.nvert = self.build_mfdata("nvert", nvert) diff --git a/flopy/mf6/modflow/mfgwtdisv.py b/flopy/mf6/modflow/mfgwtdisv.py index 469999a33..05169ef97 100644 --- a/flopy/mf6/modflow/mfgwtdisv.py +++ b/flopy/mf6/modflow/mfgwtdisv.py @@ -57,6 +57,13 @@ class ModflowGwtdisv(MFPackage): modflow 6 simulation input. this option only has an effect when an output model netcdf file is configured and the simulation is run in validate mode, otherwise it is ignored. + crs : [string] + is a real-world coordinate reference system (crs) for the model, for example, + an epsg integer code (e.g. 26915), authority string (i.e. epsg:26915), or open + geospatial consortium well-known text (wkt) specification. limited to 5000 + characters. the entry for crs does not affect the model simulation, but it is + written to the binary grid file so that postprocessors can locate the grid in + space. packagedata : record ncf6 filein ncf6_filename Contains data for the ncf package. Data can be passed as a dictionary to the ncf package with variable names as keys and package data as values. Data for @@ -123,6 +130,7 @@ class ModflowGwtdisv(MFPackage): grb_filerecord = ListTemplateGenerator( ("gwt6", "disv", "options", "grb_filerecord") ) + crs = ArrayTemplateGenerator(("gwt6", "disv", "options", "crs")) ncf_filerecord = ListTemplateGenerator( ("gwt6", "disv", "options", "ncf_filerecord") ) @@ -232,7 +240,6 @@ class ModflowGwtdisv(MFPackage): "preserve_case true", "reader urword", "optional true", - "developmode true", ], [ "block options", @@ -429,6 +436,7 @@ def __init__( angrot=None, export_array_ascii=None, export_array_netcdf=None, + crs=None, packagedata=None, nlay=None, ncpl=None, @@ -464,6 +472,7 @@ def __init__( self.export_array_netcdf = self.build_mfdata( "export_array_netcdf", export_array_netcdf ) + self.crs = self.build_mfdata("crs", crs) self._ncf_filerecord = self.build_mfdata("ncf_filerecord", None) self._ncf_package = self.build_child_package( "ncf", packagedata, "packagedata", self._ncf_filerecord diff --git a/flopy/mf6/modflow/mfgwtlkt.py b/flopy/mf6/modflow/mfgwtlkt.py index 475fa7525..38ee2e07c 100644 --- a/flopy/mf6/modflow/mfgwtlkt.py +++ b/flopy/mf6/modflow/mfgwtlkt.py @@ -87,6 +87,11 @@ class ModflowGwtlkt(MFPackage): obs package with variable names as keys and package data as values. Data for the observations variable is also acceptable. See obs package documentation for more information. + dev_nonexpanding_matrix : keyword + keyword that solves the feature concentration with a general mixing equation + and adds the result to the right-hand side of the gwt equations, instead of + adding a row to the solution matrix for each feature. this development option + is not supported. packagedata : [(ifno, strt, aux, boundname)] * ifno : integer integer value that defines the feature (lake) number associated with the @@ -202,7 +207,7 @@ class ModflowGwtlkt(MFPackage): _package_type = "lkt" dfn_file_name = "gwt-lkt.dfn" dfn = [ - ["header", "multi-package"], + ["header", "multi-package", "package-type advanced-stress-package"], [ "block options", "name flow_package_name", @@ -437,6 +442,13 @@ class ModflowGwtlkt(MFPackage): "reader urword", "optional false", ], + [ + "block options", + "name dev_nonexpanding_matrix", + "type keyword", + "reader urword", + "optional true", + ], [ "block packagedata", "name packagedata", @@ -636,6 +648,7 @@ def __init__( budgetcsv_filerecord=None, timeseries=None, observations=None, + dev_nonexpanding_matrix=None, packagedata=None, lakeperioddata=None, filename=None, @@ -683,6 +696,9 @@ def __init__( self._obs_package = self.build_child_package( "obs", observations, "continuous", self._obs_filerecord ) + self.dev_nonexpanding_matrix = self.build_mfdata( + "dev_nonexpanding_matrix", dev_nonexpanding_matrix + ) self.packagedata = self.build_mfdata("packagedata", packagedata) self.lakeperioddata = self.build_mfdata("lakeperioddata", lakeperioddata) diff --git a/flopy/mf6/modflow/mfgwtmwt.py b/flopy/mf6/modflow/mfgwtmwt.py index 5451783a3..2cdcd4f48 100644 --- a/flopy/mf6/modflow/mfgwtmwt.py +++ b/flopy/mf6/modflow/mfgwtmwt.py @@ -87,6 +87,11 @@ class ModflowGwtmwt(MFPackage): obs package with variable names as keys and package data as values. Data for the observations variable is also acceptable. See obs package documentation for more information. + dev_nonexpanding_matrix : keyword + keyword that solves the feature concentration with a general mixing equation + and adds the result to the right-hand side of the gwt equations, instead of + adding a row to the solution matrix for each feature. this development option + is not supported. packagedata : [(ifno, strt, aux, boundname)] * ifno : integer integer value that defines the feature (well) number associated with the @@ -182,7 +187,7 @@ class ModflowGwtmwt(MFPackage): _package_type = "mwt" dfn_file_name = "gwt-mwt.dfn" dfn = [ - ["header", "multi-package"], + ["header", "multi-package", "package-type advanced-stress-package"], [ "block options", "name flow_package_name", @@ -417,6 +422,13 @@ class ModflowGwtmwt(MFPackage): "reader urword", "optional false", ], + [ + "block options", + "name dev_nonexpanding_matrix", + "type keyword", + "reader urword", + "optional true", + ], [ "block packagedata", "name packagedata", @@ -586,6 +598,7 @@ def __init__( budgetcsv_filerecord=None, timeseries=None, observations=None, + dev_nonexpanding_matrix=None, packagedata=None, mwtperioddata=None, filename=None, @@ -633,6 +646,9 @@ def __init__( self._obs_package = self.build_child_package( "obs", observations, "continuous", self._obs_filerecord ) + self.dev_nonexpanding_matrix = self.build_mfdata( + "dev_nonexpanding_matrix", dev_nonexpanding_matrix + ) self.packagedata = self.build_mfdata("packagedata", packagedata) self.mwtperioddata = self.build_mfdata("mwtperioddata", mwtperioddata) diff --git a/flopy/mf6/modflow/mfgwtoc.py b/flopy/mf6/modflow/mfgwtoc.py index 7137af663..ae4f58b53 100644 --- a/flopy/mf6/modflow/mfgwtoc.py +++ b/flopy/mf6/modflow/mfgwtoc.py @@ -21,13 +21,16 @@ class ModflowGwtoc(MFPackage): processing purposes only. budget_filerecord : (budgetfile) * budgetfile : string - name of the output file to write budget information. + name of the output file to write budget information. Data are written to this + file only for time steps in which SAVE BUDGET is specified in a PERIOD block. + If SAVE BUDGET is specified in a PERIOD block and this file is not specified, + MODFLOW 6 will terminate with an error. budgetcsv_filerecord : (budgetcsvfile) * budgetcsvfile : string name of the comma-separated value (CSV) output file to write budget summary - information. A budget summary record will be written to this file for each - time step of the simulation. + information. If specified, a budget summary record will be written to this file + for every time step of the simulation. concentration_filerecord : (concentrationfile) * concentrationfile : string diff --git a/flopy/mf6/modflow/mfgwtsft.py b/flopy/mf6/modflow/mfgwtsft.py index c8c79f053..33b7d5651 100644 --- a/flopy/mf6/modflow/mfgwtsft.py +++ b/flopy/mf6/modflow/mfgwtsft.py @@ -87,6 +87,11 @@ class ModflowGwtsft(MFPackage): obs package with variable names as keys and package data as values. Data for the observations variable is also acceptable. See obs package documentation for more information. + dev_nonexpanding_matrix : keyword + keyword that solves the feature concentration with a general mixing equation + and adds the result to the right-hand side of the gwt equations, instead of + adding a row to the solution matrix for each feature. this development option + is not supported. packagedata : [(ifno, strt, aux, boundname)] * ifno : integer integer value that defines the feature (reach) number associated with the @@ -204,7 +209,7 @@ class ModflowGwtsft(MFPackage): _package_type = "sft" dfn_file_name = "gwt-sft.dfn" dfn = [ - ["header", "multi-package"], + ["header", "multi-package", "package-type advanced-stress-package"], [ "block options", "name flow_package_name", @@ -439,6 +444,13 @@ class ModflowGwtsft(MFPackage): "reader urword", "optional false", ], + [ + "block options", + "name dev_nonexpanding_matrix", + "type keyword", + "reader urword", + "optional true", + ], [ "block packagedata", "name packagedata", @@ -638,6 +650,7 @@ def __init__( budgetcsv_filerecord=None, timeseries=None, observations=None, + dev_nonexpanding_matrix=None, packagedata=None, reachperioddata=None, filename=None, @@ -685,6 +698,9 @@ def __init__( self._obs_package = self.build_child_package( "obs", observations, "continuous", self._obs_filerecord ) + self.dev_nonexpanding_matrix = self.build_mfdata( + "dev_nonexpanding_matrix", dev_nonexpanding_matrix + ) self.packagedata = self.build_mfdata("packagedata", packagedata) self.reachperioddata = self.build_mfdata("reachperioddata", reachperioddata) diff --git a/flopy/mf6/modflow/mfgwtsrc.py b/flopy/mf6/modflow/mfgwtsrc.py index 653f2305b..c36344341 100644 --- a/flopy/mf6/modflow/mfgwtsrc.py +++ b/flopy/mf6/modflow/mfgwtsrc.py @@ -120,7 +120,7 @@ class ModflowGwtsrc(MFPackage): _package_type = "src" dfn_file_name = "gwt-src.dfn" dfn = [ - ["header", "multi-package"], + ["header", "multi-package", "package-type stress-package"], [ "block options", "name auxiliary", diff --git a/flopy/mf6/modflow/mfgwtuzt.py b/flopy/mf6/modflow/mfgwtuzt.py index ad8ed01e1..f4be064b9 100644 --- a/flopy/mf6/modflow/mfgwtuzt.py +++ b/flopy/mf6/modflow/mfgwtuzt.py @@ -88,6 +88,11 @@ class ModflowGwtuzt(MFPackage): obs package with variable names as keys and package data as values. Data for the observations variable is also acceptable. See obs package documentation for more information. + dev_nonexpanding_matrix : keyword + keyword that solves the feature concentration with a general mixing equation + and adds the result to the right-hand side of the gwt equations, instead of + adding a row to the solution matrix for each feature. this development option + is not supported. packagedata : [(ifno, strt, aux, boundname)] * ifno : integer integer value that defines the feature (UZF object) number associated with the @@ -192,7 +197,7 @@ class ModflowGwtuzt(MFPackage): _package_type = "uzt" dfn_file_name = "gwt-uzt.dfn" dfn = [ - ["header", "multi-package"], + ["header", "multi-package", "package-type advanced-stress-package"], [ "block options", "name flow_package_name", @@ -427,6 +432,13 @@ class ModflowGwtuzt(MFPackage): "reader urword", "optional false", ], + [ + "block options", + "name dev_nonexpanding_matrix", + "type keyword", + "reader urword", + "optional true", + ], [ "block packagedata", "name packagedata", @@ -606,6 +618,7 @@ def __init__( budgetcsv_filerecord=None, timeseries=None, observations=None, + dev_nonexpanding_matrix=None, packagedata=None, uztperioddata=None, filename=None, @@ -653,6 +666,9 @@ def __init__( self._obs_package = self.build_child_package( "obs", observations, "continuous", self._obs_filerecord ) + self.dev_nonexpanding_matrix = self.build_mfdata( + "dev_nonexpanding_matrix", dev_nonexpanding_matrix + ) self.packagedata = self.build_mfdata("packagedata", packagedata) self.uztperioddata = self.build_mfdata("uztperioddata", uztperioddata) diff --git a/flopy/mf6/modflow/mfims.py b/flopy/mf6/modflow/mfims.py index 8590d03cd..2910449be 100644 --- a/flopy/mf6/modflow/mfims.py +++ b/flopy/mf6/modflow/mfims.py @@ -47,17 +47,6 @@ class ModflowIms(MFPackage): linear solver parameters assigned using a specified complexity can be modified in the nonlinear and linear blocks. if the complexity option is not specified, nonlinear and linear variables will be assigned the simple complexity values. - csv_output_filerecord : (csvfile) - * csvfile : string - name of the ascii comma separated values output file to write solver - convergence information. If PRINT_OPTION is NONE or SUMMARY, comma separated - values output includes maximum head change convergence information at the end - of each outer iteration for each time step. If PRINT_OPTION is ALL, comma - separated values output includes maximum head change and maximum residual - convergence information for the solution and each model (if the solution - includes more than one model) and linear acceleration information for each - inner iteration. - csv_outer_output_filerecord : (outer_csvfile) * outer_csvfile : string name of the ascii comma separated values output file to write maximum @@ -107,14 +96,30 @@ class ModflowIms(MFPackage): allowable outer iterations minus the product of this value and the maximum allowable outer iterations, then the ats (if active) will decrease the time step length by a factor of 1 / dtadj. - outer_hclose : double precision - real value defining the head change criterion for convergence of the outer - (nonlinear) iterations, in units of length. when the maximum absolute value of - the head change at all nodes during an iteration is less than or equal to - outer_hclose, iteration stops. commonly, outer_hclose equals 0.01. the - outer_hclose option has been deprecated in favor of the more general - outer_dvclose (for dependent variable), however either one can be specified in - order to maintain backward compatibility. + dev_ptc : keyword + keyword that activates pseudo-transient continuation (ptc). ptc is active by + default, so this keyword has an effect only when it follows no_ptc in the + options block. this development option is not supported. + dev_ptc_output_filerecord : (dev_ptcfile) + * dev_ptcfile : string + name of the ascii output file to write pseudo-transient continuation + information. + + dev_ptc_option : keyword + keyword that uses the norm of the right-hand side and the l2 norm of the + residual to set the initial pseudo-transient continuation value, instead of the + default approach. specifying the keyword also activates pseudo-transient + continuation. this development option is not supported. + dev_ptc_exponent : double precision + real number value that defines the exponent used to reduce the pseudo-transient + continuation value between outer iterations. the value must be greater than + zero and is 1 by default. specifying the value also activates pseudo-transient + continuation. this development option is not supported. + dev_ptc_del0 : double precision + real number value that defines the initial pseudo-transient continuation value, + which is equivalent to an initial time step length. the value must be greater + than zero. specifying the value also activates pseudo-transient continuation. + this development option is not supported. outer_dvclose : double precision real value defining the dependent-variable (for example, head) change criterion for convergence of the outer (nonlinear) iterations, in units of the dependent- @@ -125,17 +130,6 @@ class ModflowIms(MFPackage): for backward compatibility with previous versions of modflow 6 but eventually outer_hclose will be deprecated and specification of outer_hclose will cause modflow 6 to terminate with an error. - outer_rclosebnd : double precision - real value defining the residual tolerance for convergence of model packages - that solve a separate equation not solved by the ims linear solver. this value - represents the maximum allowable residual between successive outer iterations - at any single model package element. an example of a model package that would - use outer_rclosebnd to evaluate convergence is the sfr package which solves a - continuity equation for each reach. the outer_rclosebnd option is deprecated - and has no effect on simulation results as of version 6.1.1. the keyword, - outer_rclosebnd can be still be specified for backward compatibility with - previous versions of modflow 6 but eventually specification of outer_rclosebnd - will cause modflow 6 to terminate with an error. outer_maximum : integer integer value defining the maximum number of outer (nonlinear) iterations -- that is, calls to the solution routine. for a linear problem outer_maximum @@ -232,16 +226,6 @@ class ModflowIms(MFPackage): number typically depends on the characteristics of the matrix solution scheme being used. for nonlinear problems, inner_maximum usually ranges from 60 to 600; a value of 100 will be sufficient for most linear problems. - inner_hclose : double precision - real value defining the head change criterion for convergence of the inner - (linear) iterations, in units of length. when the maximum absolute value of the - head change at all nodes during an iteration is less than or equal to - inner_hclose, the matrix solver assumes convergence. commonly, inner_hclose is - set equal to or an order of magnitude less than the outer_hclose value - specified for the nonlinear block. the inner_hclose keyword has been - deprecated in favor of the more general inner_dvclose (for dependent variable), - however either one can be specified in order to maintain backward - compatibility. inner_dvclose : double precision real value defining the dependent-variable (for example, head) change criterion for convergence of the inner (linear) iterations, in units of the dependent- @@ -336,9 +320,6 @@ class ModflowIms(MFPackage): """ - csv_output_filerecord = ListTemplateGenerator( - ("ims", "options", "csv_output_filerecord") - ) csv_outer_output_filerecord = ListTemplateGenerator( ("ims", "options", "csv_outer_output_filerecord") ) @@ -346,6 +327,9 @@ class ModflowIms(MFPackage): ("ims", "options", "csv_inner_output_filerecord") ) no_ptcrecord = ListTemplateGenerator(("ims", "options", "no_ptcrecord")) + dev_ptc_output_filerecord = ListTemplateGenerator( + ("ims", "options", "dev_ptc_output_filerecord") + ) rcloserecord = ListTemplateGenerator(("ims", "linear", "rcloserecord")) package_abbr = "ims" _package_type = "ims" @@ -366,39 +350,6 @@ class ModflowIms(MFPackage): "reader urword", "optional true", ], - [ - "block options", - "name csv_output_filerecord", - "type record csv_output fileout csvfile", - "shape", - "reader urword", - "tagged true", - "optional true", - "deprecated 6.1.1", - ], - [ - "block options", - "name csv_output", - "type keyword", - "shape", - "in_record true", - "reader urword", - "tagged true", - "optional false", - "deprecated 6.1.1", - ], - [ - "block options", - "name csvfile", - "type string", - "preserve_case true", - "shape", - "in_record true", - "reader urword", - "tagged false", - "optional false", - "deprecated 6.1.1", - ], [ "block options", "name csv_outer_output_filerecord", @@ -502,27 +453,69 @@ class ModflowIms(MFPackage): "optional true", ], [ - "block nonlinear", - "name outer_hclose", + "block options", + "name dev_ptc", + "type keyword", + "reader urword", + "optional true", + ], + [ + "block options", + "name dev_ptc_output_filerecord", + "type record dev_ptc_output fileout dev_ptcfile", + "shape", + "reader urword", + "tagged true", + "optional true", + ], + [ + "block options", + "name dev_ptc_output", + "type keyword", + "shape", + "in_record true", + "reader urword", + "tagged true", + "optional false", + ], + [ + "block options", + "name dev_ptcfile", + "type string", + "preserve_case true", + "shape", + "in_record true", + "reader urword", + "tagged false", + "optional false", + ], + [ + "block options", + "name dev_ptc_option", + "type keyword", + "reader urword", + "optional true", + ], + [ + "block options", + "name dev_ptc_exponent", "type double precision", "reader urword", "optional true", - "deprecated 6.1.1", ], [ - "block nonlinear", - "name outer_dvclose", + "block options", + "name dev_ptc_del0", "type double precision", "reader urword", - "optional false", + "optional true", ], [ "block nonlinear", - "name outer_rclosebnd", + "name outer_dvclose", "type double precision", "reader urword", - "optional true", - "deprecated 6.1.1", + "optional false", ], [ "block nonlinear", @@ -601,14 +594,6 @@ class ModflowIms(MFPackage): "reader urword", "optional false", ], - [ - "block linear", - "name inner_hclose", - "type double precision", - "reader urword", - "optional true", - "deprecated 6.1.1", - ], [ "block linear", "name inner_dvclose", @@ -698,14 +683,16 @@ def __init__( loading_package=False, print_option=None, complexity=None, - csv_output_filerecord=None, csv_outer_output_filerecord=None, csv_inner_output_filerecord=None, no_ptcrecord=None, ats_outer_maximum_fraction=None, - outer_hclose=None, + dev_ptc=None, + dev_ptc_output_filerecord=None, + dev_ptc_option=None, + dev_ptc_exponent=None, + dev_ptc_del0=None, outer_dvclose=None, - outer_rclosebnd=None, outer_maximum=None, under_relaxation=None, under_relaxation_gamma=None, @@ -717,7 +704,6 @@ def __init__( backtracking_reduction_factor=None, backtracking_residual_limit=None, inner_maximum=None, - inner_hclose=None, inner_dvclose=None, rcloserecord=None, linear_acceleration=None, @@ -743,9 +729,6 @@ def __init__( self.print_option = self.build_mfdata("print_option", print_option) self.complexity = self.build_mfdata("complexity", complexity) - self.csv_output_filerecord = self.build_mfdata( - "csv_output_filerecord", csv_output_filerecord - ) self.csv_outer_output_filerecord = self.build_mfdata( "csv_outer_output_filerecord", csv_outer_output_filerecord ) @@ -756,9 +739,14 @@ def __init__( self.ats_outer_maximum_fraction = self.build_mfdata( "ats_outer_maximum_fraction", ats_outer_maximum_fraction ) - self.outer_hclose = self.build_mfdata("outer_hclose", outer_hclose) + self.dev_ptc = self.build_mfdata("dev_ptc", dev_ptc) + self.dev_ptc_output_filerecord = self.build_mfdata( + "dev_ptc_output_filerecord", dev_ptc_output_filerecord + ) + self.dev_ptc_option = self.build_mfdata("dev_ptc_option", dev_ptc_option) + self.dev_ptc_exponent = self.build_mfdata("dev_ptc_exponent", dev_ptc_exponent) + self.dev_ptc_del0 = self.build_mfdata("dev_ptc_del0", dev_ptc_del0) self.outer_dvclose = self.build_mfdata("outer_dvclose", outer_dvclose) - self.outer_rclosebnd = self.build_mfdata("outer_rclosebnd", outer_rclosebnd) self.outer_maximum = self.build_mfdata("outer_maximum", outer_maximum) self.under_relaxation = self.build_mfdata("under_relaxation", under_relaxation) self.under_relaxation_gamma = self.build_mfdata( @@ -786,7 +774,6 @@ def __init__( "backtracking_residual_limit", backtracking_residual_limit ) self.inner_maximum = self.build_mfdata("inner_maximum", inner_maximum) - self.inner_hclose = self.build_mfdata("inner_hclose", inner_hclose) self.inner_dvclose = self.build_mfdata("inner_dvclose", inner_dvclose) self.rcloserecord = self.build_mfdata("rcloserecord", rcloserecord) self.linear_acceleration = self.build_mfdata( diff --git a/flopy/mf6/modflow/mfmve.py b/flopy/mf6/modflow/mfmve.py new file mode 100644 index 000000000..62af479a5 --- /dev/null +++ b/flopy/mf6/modflow/mfmve.py @@ -0,0 +1,246 @@ +# autogenerated file, do not modify + +from os import PathLike, curdir +from typing import Union + +from flopy.mf6.data.mfdatautil import ArrayTemplateGenerator, ListTemplateGenerator +from flopy.mf6.mfpackage import MFChildPackages, MFPackage + + +class ModflowMve(MFPackage): + """ + ModflowMve defines a MVE package. + + Parameters + ---------- + parent_model_or_package + Parent_model_or_package that this package is a part of. Package is automatically + added to parent_model_or_package when it is initialized. + loading_package : bool, default False + Do not set this parameter. It is intended for debugging and internal + processing purposes only. + print_input : keyword + keyword to indicate that the list of mover information will be written to the + listing file immediately after it is read. + print_flows : keyword + keyword to indicate that the list of lake flow rates will be printed to the + listing file for every stress period time step in which 'budget print' is + specified in output control. if there is no output control option and + 'print_flows' is specified, then flow rates are printed for the last time step + of each stress period. + save_flows : keyword + keyword to indicate that lake flow terms will be written to the file specified + with 'budget fileout' in output control. + budget_filerecord : record + budgetcsv_filerecord : record + + filename : str or PathLike, optional + Name or path of file where this package is stored. + pname : str, optional + Package name. + **kwargs + Extra keywords for :class:`flopy.mf6.mfpackage.MFPackage`. + + """ + + budget_filerecord = ListTemplateGenerator(("mve", "options", "budget_filerecord")) + budgetcsv_filerecord = ListTemplateGenerator( + ("mve", "options", "budgetcsv_filerecord") + ) + package_abbr = "mve" + _package_type = "mve" + dfn_file_name = "gwe-mve.dfn" + dfn = [ + ["header"], + [ + "block options", + "name print_input", + "type keyword", + "reader urword", + "optional true", + ], + [ + "block options", + "name print_flows", + "type keyword", + "reader urword", + "optional true", + ], + [ + "block options", + "name save_flows", + "type keyword", + "reader urword", + "optional true", + ], + [ + "block options", + "name budget_filerecord", + "type record budget fileout budgetfile", + "shape", + "reader urword", + "tagged true", + "optional true", + ], + [ + "block options", + "name budget", + "type keyword", + "shape", + "in_record true", + "reader urword", + "tagged true", + "optional false", + ], + [ + "block options", + "name fileout", + "type keyword", + "shape", + "in_record true", + "reader urword", + "tagged true", + "optional false", + ], + [ + "block options", + "name budgetfile", + "type string", + "preserve_case true", + "shape", + "in_record true", + "reader urword", + "tagged false", + "optional false", + ], + [ + "block options", + "name budgetcsv_filerecord", + "type record budgetcsv fileout budgetcsvfile", + "shape", + "reader urword", + "tagged true", + "optional true", + ], + [ + "block options", + "name budgetcsv", + "type keyword", + "shape", + "in_record true", + "reader urword", + "tagged true", + "optional false", + ], + [ + "block options", + "name budgetcsvfile", + "type string", + "preserve_case true", + "shape", + "in_record true", + "reader urword", + "tagged false", + "optional false", + ], + ] + + def __init__( + self, + parent_model_or_package, + loading_package=False, + print_input=None, + print_flows=None, + save_flows=None, + budget_filerecord=None, + budgetcsv_filerecord=None, + filename=None, + pname=None, + **kwargs, + ): + """Initialize ModflowMve.""" + super().__init__( + parent=parent_model_or_package, + package_type="mve", + filename=filename, + pname=pname, + loading_package=loading_package, + **kwargs, + ) + + self.print_input = self.build_mfdata("print_input", print_input) + self.print_flows = self.build_mfdata("print_flows", print_flows) + self.save_flows = self.build_mfdata("save_flows", save_flows) + self.budget_filerecord = self.build_mfdata( + "budget_filerecord", budget_filerecord + ) + self.budgetcsv_filerecord = self.build_mfdata( + "budgetcsv_filerecord", budgetcsv_filerecord + ) + + self._init_complete = True + + +class MvePackages(MFChildPackages): + """ + MvePackages is a container class for the ModflowMve class. + """ + + package_abbr = "mvepackages" + + def initialize( + self, + print_input=None, + print_flows=None, + save_flows=None, + budget_filerecord=None, + budgetcsv_filerecord=None, + filename=None, + pname=None, + ): + """ + Initialize a new ModflowMve package, removing any sibling + child packages attached to the same parent package. + + See :class:`ModflowMve` for parameter definitions. + """ + new_package = ModflowMve( + self._cpparent, + print_input=print_input, + print_flows=print_flows, + save_flows=save_flows, + budget_filerecord=budget_filerecord, + budgetcsv_filerecord=budgetcsv_filerecord, + filename=filename, + pname=pname, + child_builder_call=True, + ) + self.init_package(new_package, filename) + + def append_package( + self, + print_input=None, + print_flows=None, + save_flows=None, + budget_filerecord=None, + budgetcsv_filerecord=None, + filename=None, + pname=None, + ): + """ + Add a new ModflowMve package to the container. + + See :class:`ModflowMve` for parameter definitions. + """ + new_package = ModflowMve( + self._cpparent, + print_input=print_input, + print_flows=print_flows, + save_flows=save_flows, + budget_filerecord=budget_filerecord, + budgetcsv_filerecord=budgetcsv_filerecord, + filename=filename, + pname=pname, + child_builder_call=True, + ) + self._append_package(new_package, filename) diff --git a/flopy/mf6/modflow/mfnam.py b/flopy/mf6/modflow/mfnam.py index 3cad43c6f..debd1e89f 100644 --- a/flopy/mf6/modflow/mfnam.py +++ b/flopy/mf6/modflow/mfnam.py @@ -60,7 +60,7 @@ class ModflowNam(MFPackage): is the user-assigned name of the model. The model name cannot exceed 16 characters and must not have blanks within the name. The model name is case insensitive; any lowercase letters are converted and stored as upper case - letters. + letters. Model names must be unique. exchanges : [(exgtype, exgfile, exgmnamea, exgmnameb)] is the list of exchange types, exchange files, and model names. diff --git a/flopy/mf6/modflow/mfprtdis.py b/flopy/mf6/modflow/mfprtdis.py index 89ea68f76..9b197c363 100644 --- a/flopy/mf6/modflow/mfprtdis.py +++ b/flopy/mf6/modflow/mfprtdis.py @@ -54,6 +54,13 @@ class ModflowPrtdis(MFPackage): modflow 6 simulation input. this option only has an effect when an output model netcdf file is configured and the simulation is run in validate mode, otherwise it is ignored. + crs : [string] + is a real-world coordinate reference system (crs) for the model, for example, + an epsg integer code (e.g. 26915), authority string (i.e. epsg:26915), or open + geospatial consortium well-known text (wkt) specification. limited to 5000 + characters. the entry for crs does not affect the model simulation, but it is + written to the binary grid file so that postprocessors can locate the grid in + space. packagedata : record ncf6 filein ncf6_filename Contains data for the ncf package. Data can be passed as a dictionary to the ncf package with variable names as keys and package data as values. Data for @@ -95,6 +102,7 @@ class ModflowPrtdis(MFPackage): """ grb_filerecord = ListTemplateGenerator(("prt6", "dis", "options", "grb_filerecord")) + crs = ArrayTemplateGenerator(("prt6", "dis", "options", "crs")) ncf_filerecord = ListTemplateGenerator(("prt6", "dis", "options", "ncf_filerecord")) delr = ArrayTemplateGenerator(("prt6", "dis", "griddata", "delr")) delc = ArrayTemplateGenerator(("prt6", "dis", "griddata", "delc")) @@ -201,7 +209,6 @@ class ModflowPrtdis(MFPackage): "preserve_case true", "reader urword", "optional true", - "developmode true", ], [ "block options", @@ -322,6 +329,7 @@ def __init__( angrot=None, export_array_ascii=None, export_array_netcdf=None, + crs=None, packagedata=None, nlay=1, nrow=2, @@ -357,6 +365,7 @@ def __init__( self.export_array_netcdf = self.build_mfdata( "export_array_netcdf", export_array_netcdf ) + self.crs = self.build_mfdata("crs", crs) self._ncf_filerecord = self.build_mfdata("ncf_filerecord", None) self._ncf_package = self.build_child_package( "ncf", packagedata, "packagedata", self._ncf_filerecord diff --git a/flopy/mf6/modflow/mfprtdisv.py b/flopy/mf6/modflow/mfprtdisv.py index 6c87de4e2..ca5c31cae 100644 --- a/flopy/mf6/modflow/mfprtdisv.py +++ b/flopy/mf6/modflow/mfprtdisv.py @@ -55,6 +55,13 @@ class ModflowPrtdisv(MFPackage): modflow 6 simulation input. this option only has an effect when an output model netcdf file is configured and the simulation is run in validate mode, otherwise it is ignored. + crs : [string] + is a real-world coordinate reference system (crs) for the model, for example, + an epsg integer code (e.g. 26915), authority string (i.e. epsg:26915), or open + geospatial consortium well-known text (wkt) specification. limited to 5000 + characters. the entry for crs does not affect the model simulation, but it is + written to the binary grid file so that postprocessors can locate the grid in + space. packagedata : record ncf6 filein ncf6_filename Contains data for the ncf package. Data can be passed as a dictionary to the ncf package with variable names as keys and package data as values. Data for @@ -121,6 +128,7 @@ class ModflowPrtdisv(MFPackage): grb_filerecord = ListTemplateGenerator( ("prt6", "disv", "options", "grb_filerecord") ) + crs = ArrayTemplateGenerator(("prt6", "disv", "options", "crs")) ncf_filerecord = ListTemplateGenerator( ("prt6", "disv", "options", "ncf_filerecord") ) @@ -229,7 +237,6 @@ class ModflowPrtdisv(MFPackage): "preserve_case true", "reader urword", "optional true", - "developmode true", ], [ "block options", @@ -421,6 +428,7 @@ def __init__( angrot=None, export_array_ascii=None, export_array_netcdf=None, + crs=None, packagedata=None, nlay=None, ncpl=None, @@ -456,6 +464,7 @@ def __init__( self.export_array_netcdf = self.build_mfdata( "export_array_netcdf", export_array_netcdf ) + self.crs = self.build_mfdata("crs", crs) self._ncf_filerecord = self.build_mfdata("ncf_filerecord", None) self._ncf_package = self.build_child_package( "ncf", packagedata, "packagedata", self._ncf_filerecord diff --git a/flopy/mf6/modflow/mfprtoc.py b/flopy/mf6/modflow/mfprtoc.py index 9eb5e9be6..7bfe501bf 100644 --- a/flopy/mf6/modflow/mfprtoc.py +++ b/flopy/mf6/modflow/mfprtoc.py @@ -21,13 +21,16 @@ class ModflowPrtoc(MFPackage): processing purposes only. budget_filerecord : (budgetfile) * budgetfile : string - name of the output file to write budget information. + name of the output file to write budget information. Data are written to this + file only for time steps in which SAVE BUDGET is specified in a PERIOD block. + If SAVE BUDGET is specified in a PERIOD block and this file is not specified, + MODFLOW 6 will terminate with an error. budgetcsv_filerecord : (budgetcsvfile) * budgetcsvfile : string name of the comma-separated value (CSV) output file to write budget summary - information. A budget summary record will be written to this file for each - time step of the simulation. + information. If specified, a budget summary record will be written to this file + for every time step of the simulation. track_filerecord : (trackfile) * trackfile : string @@ -64,18 +67,17 @@ class ModflowPrtoc(MFPackage): track_dropped : keyword keyword to indicate that particle tracking output is to be written when a particle is dropped to the water table via dry_tracking_method drop. - track_timesrecord : (track_times, times) - * track_times : keyword - keyword indicating tracking times will follow - * times : [double precision] - times to track, relative to the beginning of the simulation. - - track_timesfilerecord : (timesfile) - * timesfile : string - name of the tracking times file - dev_dump_event_trace : keyword print a verbose particle tracking event trace to standard output + scratch_buffer : keyword + keyword to stage track events in a temporary (scratch) file instead of an in- + memory buffer. particle tracking results calculated during a time step are + buffered, and output is deferred, until successful completion of the time step. + by default, results are buffered in memory. however, for simulations in which a + very large number of track events is generated during a time step, memory + limits can be exceeded. in such cases, the scratch_buffer option can be invoked + to buffer results in a scratch file until successful completion of the time + step. buffering in a scratch file is typically slower than buffering in memory. ntracktimes : integer is the number of user-specified particle tracking times in the tracktimes block. @@ -122,12 +124,6 @@ class ModflowPrtoc(MFPackage): trackcsv_filerecord = ListTemplateGenerator( ("prt6", "oc", "options", "trackcsv_filerecord") ) - track_timesrecord = ListTemplateGenerator( - ("prt6", "oc", "options", "track_timesrecord") - ) - track_timesfilerecord = ListTemplateGenerator( - ("prt6", "oc", "options", "track_timesfilerecord") - ) tracktimes = ListTemplateGenerator(("prt6", "oc", "tracktimes", "tracktimes")) saverecord = ListTemplateGenerator(("prt6", "oc", "period", "saverecord")) printrecord = ListTemplateGenerator(("prt6", "oc", "period", "printrecord")) @@ -329,76 +325,18 @@ class ModflowPrtoc(MFPackage): ], [ "block options", - "name track_timesrecord", - "type record track_times times", - "shape", - "reader urword", - "tagged true", - "optional true", - "mf6internal ttimesrec", - "removed 6.6.0", - ], - [ - "block options", - "name track_times", + "name dev_dump_event_trace", "type keyword", "reader urword", - "in_record true", - "tagged true", - "shape", - "removed 6.6.0", - ], - [ - "block options", - "name times", - "type double precision", - "shape (any1d)", - "reader urword", - "in_record true", - "tagged false", - "repeating true", - "removed 6.6.0", - ], - [ - "block options", - "name track_timesfilerecord", - "type record track_timesfile timesfile", - "shape", - "reader urword", - "tagged true", "optional true", - "mf6internal ttimesfilerec", - "removed 6.6.0", - ], - [ - "block options", - "name track_timesfile", - "type keyword", - "reader urword", - "in_record true", - "tagged true", - "shape", - "removed 6.6.0", - ], - [ - "block options", - "name timesfile", - "type string", - "preserve_case true", - "shape", - "in_record true", - "reader urword", - "tagged false", - "optional false", - "removed 6.6.0", + "mf6internal dev_dump_evtrace", ], [ "block options", - "name dev_dump_event_trace", + "name scratch_buffer", "type keyword", "reader urword", "optional true", - "mf6internal dev_dump_evtrace", ], [ "block dimensions", @@ -554,9 +492,8 @@ def __init__( track_weaksink=None, track_usertime=None, track_dropped=None, - track_timesrecord=None, - track_timesfilerecord=None, dev_dump_event_trace=None, + scratch_buffer=None, ntracktimes=None, tracktimes=None, saverecord=None, @@ -595,15 +532,10 @@ def __init__( self.track_weaksink = self.build_mfdata("track_weaksink", track_weaksink) self.track_usertime = self.build_mfdata("track_usertime", track_usertime) self.track_dropped = self.build_mfdata("track_dropped", track_dropped) - self.track_timesrecord = self.build_mfdata( - "track_timesrecord", track_timesrecord - ) - self.track_timesfilerecord = self.build_mfdata( - "track_timesfilerecord", track_timesfilerecord - ) self.dev_dump_event_trace = self.build_mfdata( "dev_dump_event_trace", dev_dump_event_trace ) + self.scratch_buffer = self.build_mfdata("scratch_buffer", scratch_buffer) self.ntracktimes = self.build_mfdata("ntracktimes", ntracktimes) self.tracktimes = self.build_mfdata("tracktimes", tracktimes) self.saverecord = self.build_mfdata("saverecord", saverecord) diff --git a/flopy/mf6/modflow/mfprtprp.py b/flopy/mf6/modflow/mfprtprp.py index 410b77002..cd8bd6455 100644 --- a/flopy/mf6/modflow/mfprtprp.py +++ b/flopy/mf6/modflow/mfprtprp.py @@ -98,18 +98,6 @@ class ModflowPrtprp(MFPackage): note that drape does not apply to attempted release into a cell for which idomain <= 0, which is considered not to exist in the simulation. attempted release into a such a cell results in an error. - release_timesrecord : (release_times, times) - * release_times : keyword - keyword indicating release times will follow - * times : [double precision] - times to release, relative to the beginning of the simulation. RELEASE_TIMES - and RELEASE_TIMESFILE are mutually exclusive. - - release_timesfilerecord : (timesfile) - * timesfile : string - name of the release times file. RELEASE_TIMES and RELEASE_TIMESFILE are - mutually exclusive. - dry_tracking_method : string is a string indicating how particles should behave in dry-but-active cells (as can occur with the newton formulation). the value can be 'drop', 'stop', or @@ -238,12 +226,6 @@ class ModflowPrtprp(MFPackage): trackcsv_filerecord = ListTemplateGenerator( ("prt6", "prp", "options", "trackcsv_filerecord") ) - release_timesrecord = ListTemplateGenerator( - ("prt6", "prp", "options", "release_timesrecord") - ) - release_timesfilerecord = ListTemplateGenerator( - ("prt6", "prp", "options", "release_timesfilerecord") - ) packagedata = ListTemplateGenerator(("prt6", "prp", "packagedata", "packagedata")) releasetimes = ListTemplateGenerator( ("prt6", "prp", "releasetimes", "releasetimes") @@ -410,72 +392,6 @@ class ModflowPrtprp(MFPackage): "reader urword", "optional true", ], - [ - "block options", - "name release_timesrecord", - "type record release_times times", - "shape", - "reader urword", - "tagged true", - "optional true", - "mf6internal releasetr", - "removed 6.6.0", - ], - [ - "block options", - "name release_times", - "type keyword", - "reader urword", - "in_record true", - "tagged true", - "shape", - "removed 6.6.0", - ], - [ - "block options", - "name times", - "type double precision", - "shape (any1d)", - "reader urword", - "in_record true", - "tagged false", - "repeating true", - "removed 6.6.0", - ], - [ - "block options", - "name release_timesfilerecord", - "type record release_timesfile timesfile", - "shape", - "reader urword", - "tagged true", - "optional true", - "mf6internal release_timesfr", - "removed 6.6.0", - ], - [ - "block options", - "name release_timesfile", - "type keyword", - "reader urword", - "in_record true", - "tagged true", - "shape", - "mf6internal release_timesfn", - "removed 6.6.0", - ], - [ - "block options", - "name timesfile", - "type string", - "preserve_case true", - "shape", - "in_record true", - "reader urword", - "tagged false", - "optional false", - "removed 6.6.0", - ], [ "block options", "name dry_tracking_method", @@ -691,17 +607,6 @@ class ModflowPrtprp(MFPackage): "in_record true", "reader urword", ], - [ - "block period", - "name fraction", - "type double precision", - "shape ( Date: Thu, 3 Sep 2026 14:03:40 +0000 Subject: [PATCH 90/90] ci(release): set version to 3.11.0, update components from DFN files, update changelog --- .docs/md/version_changes.md | 97 +++++++++++++++++++++++++++++++++++++ CITATION.cff | 4 +- README.md | 4 +- docs/PyPI_release.md | 2 +- flopy/version.py | 4 +- version.txt | 2 +- 6 files changed, 105 insertions(+), 8 deletions(-) diff --git a/.docs/md/version_changes.md b/.docs/md/version_changes.md index c57d2cc64..8b1906914 100644 --- a/.docs/md/version_changes.md +++ b/.docs/md/version_changes.md @@ -1,4 +1,101 @@ # Changelog +### Version 3.11.0 + +#### New features + +* [feat(compare_cell_budget)](https://github.com/modflowpy/flopy/commit/a84ca567b5f5b344d7592c82c2ce3895e04ef04c): Move cbb comparison function from mf6 tests (#2636). Committed by wpbonelli on 2025-10-14. +* [feat(grid)](https://github.com/modflowpy/flopy/commit/2f24768fa3fac668f6cb988a57c5c5abe6e2e237): Add array support to .intersect() (#2646). Committed by aacovski on 2025-11-17. +* [feat](https://github.com/modflowpy/flopy/commit/3e1b7a9d43398fc1ca5c7b900bf328e7e3f046b7): Add optional replace_existing param to set_all_data_external (#2666). Committed by wpbonelli on 2025-12-16. +* [feat(lgr)](https://github.com/modflowpy/flopy/commit/1ad30ae6720939085523756e7f25ca94c01da2e9): Add from_parent_grid classmethod to Lgr class (#2668). Committed by wpbonelli on 2025-12-18. +* [feat](https://github.com/modflowpy/flopy/commit/8d8097a71cd233d7327ab11ed1a2e57508c33c7d): Optional replacement semantics for stress period data (#2664). Committed by wpbonelli on 2026-01-08. +* [feat(GridIntersect)](https://github.com/modflowpy/flopy/commit/cd4e6e9013b9c5fa9231742857ba3fe5ae722cc8): Add array support, fast point locating and support for points with z-coordinates (#2657). Committed by Davíd Brakenhoff on 2026-01-16. +* [feat(grid)](https://github.com/modflowpy/flopy/commit/94f3c8ca17f22d7f7abf09417d4d0053bc8e8301): Add get_node() method to all grid types (#2680). Committed by wpbonelli on 2026-01-16. +* [feat(grid)](https://github.com/modflowpy/flopy/commit/905d20b3a70fa0cb8f02f8e78fb7d03e5cc82c0d): Add ihc to UnstructuredGrid (#2681). Committed by wpbonelli on 2026-01-16. +* [feat(GeoDataFrame support)](https://github.com/modflowpy/flopy/commit/ae1533fc0df95edfd7587c14195b40c13b72b293): Geopandas support for shapefile exporting (#2671). Committed by Joshua Larsen on 2026-01-21. +* [feat(plot_bc)](https://github.com/modflowpy/flopy/commit/fea6fe60efcec7320230e94c5e6052a7c9c65f9d): Add subset argument (#2694). Committed by Joseph Hughes on 2026-01-28. +* [feat(get_transmissivities)](https://github.com/modflowpy/flopy/commit/b48d7e3d20ce07fbcebb58340813326bcc3e8a67): Interpret head=None as fully saturated (#2690). Committed by wpbonelli on 2026-01-29. +* [feat(arrays)](https://github.com/modflowpy/flopy/commit/5e64da82af11b4cbf0bf145be86fdcbe0c183add): Add model.free_format_npl to control values-per-line in free-format array output (#2696). Committed by René Angermeyer on 2026-01-30. +* [feat(binaryfile)](https://github.com/modflowpy/flopy/commit/e6951c4639387ae0935a3d3a3a9291aee186d02e): Add write methods (#2722). Committed by wpbonelli on 2026-04-10. +* [feat(mve)](https://github.com/modflowpy/flopy/commit/71c82d86d0a22fab5420cb40872b581a9270257c): Add support for gwe-gwe exchanges that include mve package (#2750). Committed by Eric Morway on 2026-04-22. +* [feat(hfb_util)](https://github.com/modflowpy/flopy/commit/3405517c7d2fed2667722fe8dc07b0d2af269fbe): Add method for creating HFBs (#2745). Committed by Joshua Larsen on 2026-05-11. +* [feat(cellbudgetfile)](https://github.com/modflowpy/flopy/commit/098060699b6d34efabff690ac35b0209148b23f3): Support aux vars with full3D in get_data() (#2776). Committed by wpbonelli on 2026-06-29. +* [feat(gnc)](https://github.com/modflowpy/flopy/commit/085e6a11f8fdb4091755034dbe6ca9ac2604506b): Add ghost node correction data generation (#2787). Committed by Joseph Hughes on 2026-08-04. +* [feat(gridgen)](https://github.com/modflowpy/flopy/commit/4c8c52394878a8bd27093661950824ad144ee799): Build GNC package input from gridgen output (#2789). Committed by Joseph Hughes on 2026-08-05. +* [feat(lgr)](https://github.com/modflowpy/flopy/commit/5cc72048e62e94560aeffd551d6077728ff786c2): Add ghost node correction data for a GWF-GWF exchange (#2790). Committed by Joseph Hughes on 2026-08-05. +* [feat(lakpak_utils)](https://github.com/modflowpy/flopy/commit/8e8feaf93744bf7004b7d7b8185439f8c97f323f): Add support for VertexGrid embedded lakes (#2786). Committed by afc98 on 2026-08-05. +* [feat(dis_properties)](https://github.com/modflowpy/flopy/commit/b8dfaeb5456708f9034a9701862b3a215c732d21): Add grid method to get discretization properties (#2795). Committed by Joshua Larsen on 2026-08-12. + +#### Bug fixes + +* [fix(mflist)](https://github.com/modflowpy/flopy/commit/d5cc96208db3247fb097b6c0a0520a3e6a8601f3): Fix open/close bugs (#2638). Committed by Matt Dumont on 2025-10-17. +* [fix(mp7)](https://github.com/modflowpy/flopy/commit/44c90717afd0f4d6dc58907b88b8093736af8906): Fix shape assignment for DISU package (#2651). Committed by Mason Marchildon on 2025-11-18. +* [fix(MfUsgWel)](https://github.com/modflowpy/flopy/commit/5a945268913b6f4d07104d7c4ab5a964a37211e1): Add get_empty() override to support WELLBOT parameter (#2656). Committed by René Angermeyer on 2025-12-02. +* [fix](https://github.com/modflowpy/flopy/commit/75b34a09a629b60fda5863135a3d4b24dfb760f4): Fix duplicate unit numbers in NAM file for MFUSG external files (#2659). Committed by René Angermeyer on 2025-12-02. +* [fix(mfusg)](https://github.com/modflowpy/flopy/commit/7468302bdf33c13e770888f2e71c636510e8b259): Fix precision loss in CLN/GNC packages writing coordinates (#2658). Committed by René Angermeyer on 2025-12-04. +* [fix(mfsimulation)](https://github.com/modflowpy/flopy/commit/543130b224bff0c12c81d6c381048f92275129b4): Respect max_columns_of_data for internal arrays (#2665). Committed by wpbonelli on 2025-12-12. +* [fix(cellbudgetfile)](https://github.com/modflowpy/flopy/commit/b16eb2ba93b7f8b09ba0f1455360cd919e1ff887): Fix get_ts support for aux vars (#2648). Committed by wpbonelli on 2025-12-16. +* [fix(lgrutil)](https://github.com/modflowpy/flopy/commit/ada6e63972c3b0ba33d4c0e6abc4e7cfa917341f): Remove duplicate hanging vertices (#2672). Committed by wpbonelli on 2025-12-20. +* [fix(plot)](https://github.com/modflowpy/flopy/commit/aad068289ef41e375986bd46dff3977ba5cc2320): Support plotting hfb packages (#2677). Committed by wpbonelli on 2026-01-09. +* [fix(mp7)](https://github.com/modflowpy/flopy/commit/bef063f321b6e1387bebb83cc2ec2165e6f6367f): Fix input file writing (#2679). Committed by wpbonelli on 2026-01-09. +* [fix(evt)](https://github.com/modflowpy/flopy/commit/a88f0e179f49768f1d6c13549b7f8fcc0ff96779): Fix optional field handling at write time (#2684). Committed by wpbonelli on 2026-01-24. +* [fix(get_transmissivities)](https://github.com/modflowpy/flopy/commit/978d7d04346e109798575b24f50d0542eda53224): Support DISU (#2685). Committed by wpbonelli on 2026-01-24. +* [fix(rasters)](https://github.com/modflowpy/flopy/commit/61e9e3196f2fd2dfaf119a7f734be94e84c317ce): Fix raster_from_array transformation order (#2689). Committed by wpbonelli on 2026-01-28. +* [fix(get_transmissivities)](https://github.com/modflowpy/flopy/commit/7758c77edf90309a2c7618bc4c85de5d91a56253): Handle single r/c (#2697). Committed by wpbonelli on 2026-01-29. +* [fix(cvfdutil)](https://github.com/modflowpy/flopy/commit/289394901778916aa63f53362d17d906427e0d9a): Add non-convergence check to shapefile_to_cvfd (#2688). Committed by wpbonelli on 2026-01-30. +* [fix(HeadUFile)](https://github.com/modflowpy/flopy/commit/6bb9c1aa1ac43300b36cbcf7bf51c638ce4dd65f): Disable get_alldata without layer (#2686). Committed by wpbonelli on 2026-01-30. +* [fix(mfdataarray)](https://github.com/modflowpy/flopy/commit/2092f70225fbd1e4e7eb1a92975e5574d30d2fc9): Support time array series (#2703). Committed by wpbonelli on 2026-02-04. +* [fix(mfdataarray)](https://github.com/modflowpy/flopy/commit/ff26d7238a8f70dc6649c327319d83d86d1a7577): Use integer indexed filenames (#2704). Committed by wpbonelli on 2026-02-04. +* [fix(model_splitter)](https://github.com/modflowpy/flopy/commit/7746e0995a0e72716e0012b4c84d1aac4327a566): Fix spurious ssm creation (#2716). Committed by wpbonelli on 2026-02-25. +* [fix(UcnFile)](https://github.com/modflowpy/flopy/commit/224432b81ce862632463ed652e48d97c3952ff43): Add check for modflow-6 GWT concentration file header type (#2718). Committed by Joshua Larsen on 2026-03-05. +* [fix](https://github.com/modflowpy/flopy/commit/066270c1e1bcfb5df80d5e3ff9479f0d06a5d340): Wipe toml dfn dir before regenerating mf6 module (#2730). Committed by wpbonelli on 2026-03-14. +* [fix(vtk)](https://github.com/modflowpy/flopy/commit/20ce356115ec8012b435e8c074cdc46a06e6d108): Fix add_pathline_points prt data handling (#2732). Committed by wpbonelli on 2026-03-18. +* [fix(vtk)](https://github.com/modflowpy/flopy/commit/11432e43a81f62ada5d62425ab13bf030294add2): Transpose vector before numpy_to_vtk (#2733). Committed by wpbonelli on 2026-03-19. +* [fix(model_splitter)](https://github.com/modflowpy/flopy/commit/c80fc6c6e4b3a15ba1b8e9e90e7b6ccd2a052ec0): Multiple bug fixes (#2728). Committed by Joshua Larsen on 2026-03-23. +* [fix(mp7particledata)](https://github.com/modflowpy/flopy/commit/f0c043255a7134e121b42a4dbdeca988b26394cd): To_prp bug fix (#2737). Committed by Kalle Jahn on 2026-03-26. +* [fix(mfpackage)](https://github.com/modflowpy/flopy/commit/6be21ac6ef228e8a0b08dfca9a1a4344b8d4a9b9): Clear timeseries files on ts initialize (#2749). Committed by wpbonelli on 2026-04-16. +* [fix(mnw1)](https://github.com/modflowpy/flopy/commit/66b442354d124243e989ea3015b9ab58b3d1c6a7): Fix mnw1 load (#2752). Committed by Joseph Hughes on 2026-05-10. +* [fix(mf6)](https://github.com/modflowpy/flopy/commit/c4d445e16ae8f82902aaa8fbaf8ddece0c000243): Defensive devtools compatibility fix (#2762). Committed by wpbonelli on 2026-06-02. +* [fix(GeoSpatialUtil)](https://github.com/modflowpy/flopy/commit/8a2d0552ab48f0b7fdc4a952d416f690c9b3cab0): Accommodate stricter pyshp API (#2765). Committed by wpbonelli on 2026-06-08. +* [fix(mfdataplist)](https://github.com/modflowpy/flopy/commit/c27af43d2be01e335ed3d908c2af67cd5f851b78): Raise proper MFDataException on wrong col count (#2766). Committed by wpbonelli on 2026-06-08. +* [fix(mfdataplist)](https://github.com/modflowpy/flopy/commit/228668c548e97fcfdeea71bb354df99e2eb1065d): Accept DataFrame with tuple cellid (#2767). Committed by wpbonelli on 2026-06-09. +* [fix(model_attributes_to_shapefile)](https://github.com/modflowpy/flopy/commit/6a5d271fd41d7234b37ecc4be4c765ed2799ee15): Use modelgrid kwarg if provided (#2768). Committed by wpbonelli on 2026-06-09. +* [fix(model_splitter)](https://github.com/modflowpy/flopy/commit/8d5c41001cd86b8286981317eda314ac3d477352): Copy on boundname remap (#2769). Committed by wpbonelli on 2026-06-09. +* [fix(mfusgt)](https://github.com/modflowpy/flopy/commit/60db7e721ffe8961c5a9f71382598d54cf19115b): Fix multiple bugs (#2770). Committed by wpbonelli on 2026-06-10. +* [fix(mf6-dis)](https://github.com/modflowpy/flopy/commit/6910978ce3d72cb1cb0d6f35327206a442bf2106): Auto-set max_columns_of_data to ncol for external writes (#2771). Committed by wpbonelli on 2026-06-12. +* [fix(CellBudgetFile)](https://github.com/modflowpy/flopy/commit/7465a329ab50f45251f40f92a1d18db6a51296fb): Trap OSError when detecting precision (#2772). Committed by wpbonelli on 2026-06-17. +* [fix(mfdataplist)](https://github.com/modflowpy/flopy/commit/018bd9ca60a40995c5cba5e88e1630f30e7532e1): Fix multiple issues (#2775). Committed by wpbonelli on 2026-06-29. +* [fix(rasters)](https://github.com/modflowpy/flopy/commit/38b6a10f19c0192369ec7fcbe3be7fba8ae5a5c2): Accommodate rasterio>=1.5 (#2781). Committed by wpbonelli on 2026-07-11. +* [fix(autotest)](https://github.com/modflowpy/flopy/commit/1885436ac5e829b597e4fa970ff03094b31d9ad8): Skip tests that need an optional dependency (#2797). Committed by Joseph Hughes on 2026-08-07. +* [fix(autotest)](https://github.com/modflowpy/flopy/commit/8c0059d1e008d3707a1c9578de1d22ec6b336cab): Skip export tests that need geopandas (#2800). Committed by Joseph Hughes on 2026-08-07. +* [fix(zonebudget_example)](https://github.com/modflowpy/flopy/commit/79a031a413e4c6f7acfdfb2996a899039ee4e6a1): Index the budget values by position (#2801). Committed by Joseph Hughes on 2026-08-07. +* [fix(vtk)](https://github.com/modflowpy/flopy/commit/74d7b9ef2d232d3917f9590f8bfaeb9df2a7a3f8): Transpose the vector after the point scalar expansion (#2799). Committed by Joseph Hughes on 2026-08-07. +* [fix(mf6)](https://github.com/modflowpy/flopy/commit/78ab37dabc67c00ecd77c65fdd86f7f4e82a7ef8): Omit removed fields from generated code (#2804). Committed by wpbonelli on 2026-08-12. +* [fix(get-modflow)](https://github.com/modflowpy/flopy/commit/38acfae4ca0039943890f795c8f0ba2a4205c7b5): Use urlopen for download (#2807). Committed by wpbonelli on 2026-08-12. +* [fix(mfusglpf)](https://github.com/modflowpy/flopy/commit/a222eba82d3679a5c870833616161ba92cbf4f4d): Fix condition for richards laytyp check (#2812). Committed by Martin Vonk on 2026-08-13. +* [fix(crosssection)](https://github.com/modflowpy/flopy/commit/65b6c5541cf1deb7a73d92839e58a3f5876e873b): Make view x/y override symmetric, validate input (#2805). Committed by wpbonelli on 2026-08-19. +* [fix(utils)](https://github.com/modflowpy/flopy/commit/6c8b29bab37f604ae4a42bd7f92490519434b575): Fix zone off-by-one, add test for #2612 (#2818). Committed by wpbonelli on 2026-08-20. +* [fix(binarygrid_util)](https://github.com/modflowpy/flopy/commit/73af2a7395a8fa8ceaf7a056970bb50d353be9e4): Restore top assignment dropped with reshape (#2833). Committed by wpbonelli on 2026-08-31. + +#### Mfgrdfile + +* [mfgrdfile](https://github.com/modflowpy/flopy/commit/079298e866e54d0e7e95c07030752d382225f144): Add version and crs properties (#2711). Committed by mjreno on 2026-06-30. +* [mfgrdfile](https://github.com/modflowpy/flopy/commit/7c717126d81259eb9ae6196f420e56aa56744a86): Export follow-on (#2779). Committed by mjreno on 2026-07-01. + +#### Refactoring + +* [refactor](https://github.com/modflowpy/flopy/commit/6b40d8efe2f361bdbdc8db1b3b790f0e6fb75092): Remove get_version_string from MFStructure (#2630). Committed by wpbonelli on 2025-10-04. +* [refactor(codegen)](https://github.com/modflowpy/flopy/commit/32b71c56c8729c65b752765d9b151b823c211a62): Store new spec on generated classes (#2631). Committed by wpbonelli on 2025-10-04. +* [refactor(mfstructure)](https://github.com/modflowpy/flopy/commit/2be45f7ad8d9ef9841bee76e8397363548a5b456): Miscellaneous (#2633). Committed by wpbonelli on 2025-10-09. +* [refactor(codegen)](https://github.com/modflowpy/flopy/commit/123487fcf8e62ee093cae750e809e0e0683077ba): Support 'prerelease' or 'developmode' in dfns (#2635). Committed by wpbonelli on 2025-10-11. +* [refactor(mflistfile)](https://github.com/modflowpy/flopy/commit/c0cae8112cbceb9fe2931e7d273db41dff8daabc): Deprecate set_budget_key for init (#2637). Committed by wpbonelli on 2025-10-14. +* [refactor(grid)](https://github.com/modflowpy/flopy/commit/aaa38e7b8b34bd1e0215bb8237ade390e6c8dac3): Consistent API for get_cell_vertices (#2678). Committed by wpbonelli on 2026-01-08. +* [refactor(plot)](https://github.com/modflowpy/flopy/commit/41f1840bc55370c5eb4be4f4313400048f64bf43): Better shared face finding for HFB plotting (#2682). Committed by wpbonelli on 2026-01-31. +* [refactor(_remap_array)](https://github.com/modflowpy/flopy/commit/68880321ee3bd1fe34eed0053b697ab787758b00): Support non-layered external data (#2747). Committed by Joshua Larsen on 2026-04-22. +* [refactor(intersect)](https://github.com/modflowpy/flopy/commit/b2ceda31a620b135ea4cafc10f55e2cc148af4d9): Update days calculation to fix dropped precision (#2754). Committed by Joshua Larsen on 2026-05-12. +* [refactor(splitter)](https://github.com/modflowpy/flopy/commit/579507e227149d481212148835420d2af2da3eed): Updates for advanced transport packages (#2761). Committed by JLarsen on 2026-05-28. +* [refactor(ModflowWel)](https://github.com/modflowpy/flopy/commit/866bb586380d934f18e7e666357e6625cbdf874c): Add basic support for mfnwt tabfile based well files (#2755). Committed by Joshua Larsen on 2026-05-28. +* [refactor(PlotCrossSection)](https://github.com/modflowpy/flopy/commit/39aaa73fa9a6d9fd87483b46cee8915c3e741f5e): Add additional flexibility to cross sectional plotting (#2782). Committed by Joshua Larsen on 2026-08-05. + ### Version 3.10.0 #### New features diff --git a/CITATION.cff b/CITATION.cff index 4d0e1c562..102a981c1 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -3,8 +3,8 @@ message: If you use this software, please cite both the article from preferred-c references, and the software itself. type: software title: FloPy -version: 3.11.0.dev0 -date-released: '2026-02-06' +version: 3.11.0 +date-released: '2026-09-03' doi: 10.5066/F7BK19FH abstract: A Python package to create, run, and post-process MODFLOW-based models. repository-artifact: https://pypi.org/project/flopy diff --git a/README.md b/README.md index 8491c1641..f2f308e13 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ flopy3 -### Version 3.11.0.dev0 +### Version 3.11.0 [![flopy continuous integration](https://github.com/modflowpy/flopy/actions/workflows/commit.yml/badge.svg?branch=develop)](https://github.com/modflowpy/flopy/actions/workflows/commit.yml) [![Read the Docs](https://github.com/modflowpy/flopy/actions/workflows/rtd.yml/badge.svg?branch=develop)](https://github.com/modflowpy/flopy/actions/workflows/rtd.yml) @@ -150,7 +150,7 @@ How to Cite ##### ***Software/Code citation for FloPy:*** -[Bakker, Mark, Post, Vincent, Hughes, J. D., Langevin, C. D., White, J. T., Leaf, A. T., Paulinski, S. R., Bellino, J. C., Morway, E. D., Toews, M. W., Larsen, J. D., Fienen, M. N., Starn, J. J., Brakenhoff, D. A., and Bonelli, W. P., 2026, FloPy v3.11.0.dev0: U.S. Geological Survey Software Release, 06 February 2026, https://doi.org/10.5066/F7BK19FH](https://doi.org/10.5066/F7BK19FH) +[Bakker, Mark, Post, Vincent, Hughes, J. D., Langevin, C. D., White, J. T., Leaf, A. T., Paulinski, S. R., Bellino, J. C., Morway, E. D., Toews, M. W., Larsen, J. D., Fienen, M. N., Starn, J. J., Brakenhoff, D. A., and Bonelli, W. P., 2026, FloPy v3.11.0: U.S. Geological Survey Software Release, 03 September 2026, https://doi.org/10.5066/F7BK19FH](https://doi.org/10.5066/F7BK19FH) Additional FloPy Related Publications diff --git a/docs/PyPI_release.md b/docs/PyPI_release.md index 0506ad080..f79ad3f76 100644 --- a/docs/PyPI_release.md +++ b/docs/PyPI_release.md @@ -30,4 +30,4 @@ How to Cite *Software/Code citation for FloPy:* -[Bakker, Mark, Post, Vincent, Hughes, J. D., Langevin, C. D., White, J. T., Leaf, A. T., Paulinski, S. R., Bellino, J. C., Morway, E. D., Toews, M. W., Larsen, J. D., Fienen, M. N., Starn, J. J., Brakenhoff, D. A., and Bonelli, W. P., 2026, FloPy v3.11.0.dev0: U.S. Geological Survey Software Release, 06 February 2026, https://doi.org/10.5066/F7BK19FH](https://doi.org/10.5066/F7BK19FH) +[Bakker, Mark, Post, Vincent, Hughes, J. D., Langevin, C. D., White, J. T., Leaf, A. T., Paulinski, S. R., Bellino, J. C., Morway, E. D., Toews, M. W., Larsen, J. D., Fienen, M. N., Starn, J. J., Brakenhoff, D. A., and Bonelli, W. P., 2026, FloPy v3.11.0: U.S. Geological Survey Software Release, 03 September 2026, https://doi.org/10.5066/F7BK19FH](https://doi.org/10.5066/F7BK19FH) diff --git a/flopy/version.py b/flopy/version.py index f61209fa0..1fadd91cb 100644 --- a/flopy/version.py +++ b/flopy/version.py @@ -1,4 +1,4 @@ # flopy version file automatically created using -# update_version.py on February 06, 2026 18:48:16 +# update_version.py on September 03, 2026 13:56:50 -__version__ = "3.11.0.dev0" +__version__ = "3.11.0" diff --git a/version.txt b/version.txt index df007b9dd..e0af1238f 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -3.11.0.dev0 \ No newline at end of file +3.11.0 \ No newline at end of file