Using a Modflow UZF model as a stressmodel in Pastas

Please note that the showcased workflow is not how we intend to keep things. Major breaking changes in the next release.

import os

import flopy
import pandas as pd
import pastas as ps
from pastas.timer import SolveTimer

import pastas_plugins.modflow as ppmf

ps.set_log_level("ERROR")

bindir = "bin"
mf6_exe = os.path.join(bindir, "mf6")
if not os.path.isfile(mf6_exe):
    if not os.path.isdir(bindir):
        os.makedirs(bindir)
    flopy.utils.get_modflow("bin", repo="modflow6")
/home/docs/checkouts/readthedocs.org/user_builds/pastas-plugins/envs/latest/lib/python3.11/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm
---------------------------------------------------------------------------
ImportError                               Traceback (most recent call last)
Cell In[1], line 8
      5 import pastas as ps
      6 from pastas.timer import SolveTimer
----> 8 import pastas_plugins.modflow as ppmf
     10 ps.set_log_level("ERROR")
     12 bindir = "bin"

File ~/checkouts/readthedocs.org/user_builds/pastas-plugins/envs/latest/lib/python3.11/site-packages/pastas_plugins/modflow/__init__.py:9
      1 # ruff: noqa: F401
      2 from pastas_plugins.modflow.modflow import (
      3     ModflowDrn,
      4     ModflowDrnSto,
   (...)      7     ModflowUzf,
      8 )
----> 9 from pastas_plugins.modflow.stressmodels import ModflowModel
     10 from pastas_plugins.modflow.version import __version__

File ~/checkouts/readthedocs.org/user_builds/pastas-plugins/envs/latest/lib/python3.11/site-packages/pastas_plugins/modflow/stressmodels.py:7
      5 from pastas.stressmodels import StressModelBase
      6 from pastas.timeseries import TimeSeries
----> 7 from pastas.typing import ArrayLike, TimestampType
      9 from .modflow import Modflow
     11 logger = getLogger(__name__)

ImportError: cannot import name 'TimestampType' from 'pastas.typing' (/home/docs/checkouts/readthedocs.org/user_builds/pastas-plugins/envs/latest/lib/python3.11/site-packages/pastas/typing/__init__.py)

Data Groundwater Article

ds = ps.load_dataset("collenteur_2019")
head = ds["head"].squeeze()
prec = ds["rain"].squeeze().resample("D").asfreq().fillna(0.0)
evap = ds["evap"].squeeze()

tmin = pd.Timestamp("2003-01-01")
tmax = pd.Timestamp("2018-12-25")

ps.plots.series(head, [prec, evap], hist=False);
../_images/1c457e03d69a69ccaefe0650a9dbd34e7d9e6449804244b7f08bb77876f4984b.png

Standard exponential model

mlexp = ps.Model(head)
mlexp.add_stressmodel(
    ps.RechargeModel(prec=prec, evap=evap, rfunc=ps.Exponential(), name="exp")
)
mlexp.solve(tmin=tmin, tmax=tmax)
mlexp.plot()
Fit report Head                   Fit Statistics
================================================
nfev    11                     EVP         87.10
nobs    5737                   R2           0.87
noise   False                  RMSE         0.42
tmin    2003-01-01 00:00:00    AICc     -9870.13
tmax    2018-12-25 00:00:00    BIC      -9843.52
freq    D                      Obj        512.71
warmup  3650 days 00:00:00     ___              
solver  LeastSquares           Interp.        No

Parameters (4 optimized)
================================================
               optimal    initial  vary
exp_A       331.311203  29.925748  True
exp_a        98.430976  10.000000  True
exp_f        -0.854493  -1.000000  True
constant_d  -14.150486 -11.740288  True
<Axes: xlabel='Date', ylabel='Head'>
../_images/b2f02d02bc3ede894787d95961e2a0ce1c0439b79ff62d34e7ffa0b377aa636e.png

FLEX model

mlflex = ps.Model(head)
rm = ps.RechargeModel(
    prec=prec.multiply(1e3),
    evap=evap.multiply(1e3),
    rfunc=ps.Exponential(),
    recharge=ps.rch.FlexModel(),
    name="flex",
)
mlflex.add_stressmodel(rm)
mlflex.set_parameter(f"{rm.name}_srmax", vary=False)
mlflex.solve(tmin=tmin, tmax=tmax)
mlflex.plot()
Fit report Head                    Fit Statistics
=================================================
nfev    32                     EVP          89.09
nobs    5737                   R2            0.89
noise   False                  RMSE          0.39
tmin    2003-01-01 00:00:00    AICc     -10827.00
tmax    2018-12-25 00:00:00    BIC      -10787.08
freq    D                      Obj         433.65
warmup  3650 days 00:00:00     ___               
solver  LeastSquares           Interp.         No

Parameters (6 optimized)
=================================================
               optimal     initial   vary
flex_A        0.472212    0.074380   True
flex_a       99.246846   10.000000   True
flex_srmax  250.000000  250.000000  False
flex_lp       0.250000    0.250000  False
flex_ks     167.022904  100.000000   True
flex_gamma    3.833278    2.000000   True
flex_kv       0.956051    1.000000   True
flex_simax    2.000000    2.000000  False
constant_d  -15.288913  -11.740288   True
<Axes: xlabel='Date', ylabel='Head'>
../_images/ed9c987d335694f1977597497df31f26b42277cbbe5c20cb425786f9ac7fc4ba.png

UZF model

With some optimal parameters from exponential model

mlumf = ps.Model(head)

expmf = ppmf.ModflowUzf(exe_name=mf6_exe, sim_ws="mf_files/uzf_gw", head=head)
expsm = ppmf.ModflowModel(prec=prec, evap=evap, modflow=expmf, name="uzfsm")
mlumf.add_stressmodel(expsm)
mlumf.set_parameter(
    f"{expsm.name}_s",
    initial=mlexp.parameters.at["exp_a", "optimal"]
    / mlexp.parameters.at["exp_A", "optimal"],
    vary=False,
)
mlumf.set_parameter(
    f"{expsm.name}_c", initial=mlexp.parameters.at["exp_A", "optimal"], vary=False
)
constant_d = mlexp.parameters.at["constant_d", "optimal"]
if "constant_d" in mlumf.parameters.index:
    mlumf.set_parameter("constant_d", initial=constant_d, vary=False)
    if expmf._head is not None:
        mlumf.del_constant()
        mlumf.set_parameter(f"{expsm.name}_d", initial=constant_d, vary=False)

# choose height high enough above constant_d and the largest head value
# otherwise you get runoff via the drn package (originally simulate_gwseep=True)
mlumf.set_parameter(f"{expsm.name}_height", initial=10.0, vary=False)

ax = mlumf.plot()
<flopy.mf6.data.mfstructure.MFDataItemStructure object at 0x7f2fc8237940>
../_images/17f9a8a2d27047ec4c8faf5c9322e53f74627f0e64a4ccbb456410d3afec1129.png
mlumf.parameters
initial pmin pmax vary name dist stderr optimal
uzfsm_d -14.150486 -14.500 -5.420000e+00 False uzfsm uniform NaN NaN
uzfsm_c 331.311203 10.000 1.000000e+08 False uzfsm uniform NaN NaN
uzfsm_s 0.297095 0.001 5.000000e-01 False uzfsm uniform NaN NaN
uzfsm_height 10.000000 0.010 1.000000e+01 False uzfsm uniform NaN NaN
uzfsm_vks 1.000000 0.000 1.000000e+01 True uzfsm uniform NaN NaN
uzfsm_thtr 0.100000 0.000 2.000000e-01 True uzfsm uniform NaN NaN
uzfsm_thts 0.300000 0.200 4.000000e-01 True uzfsm uniform NaN NaN
uzfsm_thextfrac 0.100000 0.000 1.000000e+00 True uzfsm uniform NaN NaN
uzfsm_eps 5.000000 3.500 1.000000e+01 True uzfsm uniform NaN NaN
uzfsm_extdpfrac 0.500000 0.000 1.000000e+00 True uzfsm uniform NaN NaN

UZF model calibrated

ml = ps.Model(head)
ml.del_constant()

# shorten the warmup to speed up the modflow calculation somewhat.
ml.settings["warmup"] = pd.Timedelta(days=4 * 365)
mf = ppmf.ModflowUzf(
    exe_name=mf6_exe,
    sim_ws="mf_files/mfuzf_gw",
    head=head,
    ntrailwaves=35,
    nwavesets=100,
)
sm = ppmf.ModflowModel(prec, evap, modflow=mf, name="mfsm")
ml.add_stressmodel(sm)

ml.set_parameter(f"{sm.name}_d", pmin=-15.0)

with SolveTimer() as st:
    ml.solve(callback=st.timer, tmin=tmin, tmax=tmax, diff_step=0.1)
Optimization progress: 0it [00:00, ?it/s]
<flopy.mf6.data.mfstructure.MFDataItemStructure object at 0x7fd5c80c79d0>
Optimization progress: 201it [18:38,  5.56s/it]
Fit report Head                    Fit Statistics
=================================================
nfev    30                     EVP          91.54
nobs    5737                   R2            0.92
noise   False                  RMSE          0.34
tmin    2003-01-01 00:00:00    AICc     -12279.67
tmax    2018-12-25 00:00:00    BIC      -12219.81
freq    D                      Obj         336.29
warmup  1460 days 00:00:00     ___               
solver  LeastSquares           Interp.         No

Parameters (9 optimized)
=================================================
                   optimal     initial  vary
mfsm_d          -14.214659  -11.740288  True
mfsm_c          315.829023  220.000000  True
mfsm_s            0.275418    0.050000  True
mfsm_height       8.603391    1.000000  True
mfsm_vks          9.477940    1.000000  True
mfsm_thtr         0.032361    0.100000  True
mfsm_thts         0.237595    0.300000  True
mfsm_eps          5.854889    5.000000  True
mfsm_extdpfrac    1.000000    0.500000  True

Warnings! (2)
=================================================
Parameter 'mfsm_extdpfrac' on upper bound: 1.00e+00
Response tmax for 'mfsm' > than calibration period.

Compare

ax = ml.observations().plot(
    marker=".", color="k", linestyle="None", legend=False, figsize=(7.5, 6)
)
mlexp.simulate().plot(ax=ax, label="Exponential")
mlflex.simulate().plot(ax=ax, label="FlexModel")
ml.simulate().plot(ax=ax, label="UZF")
ax.legend(ncol=4, loc="upper right")
ax.set_xlim(tmin + pd.Timedelta(days=2 * 365), tmax)
(12783.0, 17890.0)
../_images/8d11a319fdab8f2fabe2531c0406d8b7cc7e00504cf1cf7e41ff5231c3f1b580.png

Synthetic data

ds = ps.load_dataset("vonk_2024")
head = ds["heads"].loc[:, ("SandyLoam", "D85", "x00")].rename("usg_head").iloc[1:]
prec = ds["stresses"].loc[:, "prec [m/d]"].rename("prec")
evap = ds["stresses"].loc[:, "evap [m/d]"].rename("evap")

tmin = pd.Timestamp("2000-01-01")
tmax = head.index[-1]

UZF model with parameters we “know” from USG

mlu = ps.Model(head)
mlu.del_constant()

mf = ppmf.ModflowUzf(
    exe_name=mf6_exe,
    sim_ws="mf_files/mfuzf_usg",
    head=head,
    ntrailwaves=35,
    nwavesets=100,
)
sm = ppmf.ModflowModel(prec, evap, modflow=mf, name="mfsm")
mlu.add_stressmodel(sm)

# only parameter we don't know
mlu.set_parameter(f"{sm.name}_c", initial=100.0)  # select by hand
# plugin "true" parameters from modflow usg model
mlu.set_parameter(f"{sm.name}_d", initial=8.5)  # level in canal
mlu.set_parameter(
    f"{sm.name}_s", initial=0.271
)  # specific yield from theta_s - theta_fc
mlu.set_parameter(f"{sm.name}_height", initial=1.5)  # freeboard
mlu.set_parameter(f"{sm.name}_vks", initial=1.0061)  # m/day
mlu.set_parameter(f"{sm.name}_thtr", initial=0.065)  # theta_r
mlu.set_parameter(f"{sm.name}_thts", initial=0.41, pmax=0.45)  # theta_s
mlu.set_parameter(f"{sm.name}_eps", initial=4.954)  # brooks in mfusg
mlu.set_parameter(f"{sm.name}_extdpfrac", initial=1.0 / 1.5)  # root depth 1 m

mlu.plot()
<flopy.mf6.data.mfstructure.MFDataItemStructure object at 0x7fd5c80c79d0>
<Axes: ylabel='Head'>
../_images/d7ea83198127e217ad094404fe8a7c1dcb14f866b177e87b8f0219cd015875d6.png

UZF model calibrated

with SolveTimer() as st:
    mlu.solve(callback=st.timer, diff_step=0.1)
Optimization progress: 160it [42:34, 15.96s/it]
Fit report usg_head                Fit Statistics
=================================================
nfev    16                     EVP          97.57
nobs    9862                   R2            0.98
noise   False                  RMSE          0.02
tmin    1996-01-01 00:00:00    AICc     -76352.72
tmax    2022-12-31 00:00:00    BIC      -76287.97
freq    D                      Obj           2.14
warmup  3650 days 00:00:00     ___               
solver  LeastSquares           Interp.         No

Parameters (9 optimized)
=================================================
                   optimal     initial  vary
mfsm_d            8.454669    8.500000  True
mfsm_c          161.112127  100.000000  True
mfsm_s            0.217382    0.271000  True
mfsm_height       2.429316    1.500000  True
mfsm_vks          1.074913    1.006100  True
mfsm_thtr         0.124601    0.065000  True
mfsm_thts         0.250819    0.410000  True
mfsm_eps          4.862579    4.954000  True
mfsm_extdpfrac    0.926662    0.666667  True

Warnings! (1)
=================================================
Response tmax for 'mfsm' > than calibration period.

mlu.plot()
<Axes: ylabel='Head'>
../_images/69d2258e56c9448169ffe961fa33269fc969e30aeb7fdb4211379c953ff4264c.png