Using a Modflow model as a stressmodel in Pastas

This notebook shows how to use a simple Modflow model as stress model in Pastas.

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

Packages

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")
/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")

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)

Download MODFLOW executable

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")

Data

# %%
tmin = pd.Timestamp("2001-01-01")
tmax = pd.Timestamp("2014-12-31")

tmin_wu = tmin - pd.Timedelta(days=3651)
tmin_wu = pd.Timestamp("1986-01-01")

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

# head = pd.read_csv("data/obs.csv", index_col=0, parse_dates=True).squeeze()
# prec = (
#     pd.read_csv("data/rain.csv", index_col=0, parse_dates=True)
#     .squeeze()
#     .resample("D")
#     .asfreq()
#     .fillna(0.0)
# )
# evap = pd.read_csv("data/evap.csv", index_col=0, parse_dates=True).squeeze()

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

Time series models

Standard exponential model

# %%
# create model with exponential response function
mlexp = ps.Model(head)
mlexp.add_stressmodel(
    ps.RechargeModel(prec=prec, evap=evap, rfunc=ps.Exponential(), name="test_exp")
)
mlexp.solve(tmin=tmin, tmax=tmax)
mlexp.plot();
Fit report Head                   Fit Statistics
================================================
nfev    10                     EVP         87.68
nobs    4291                   R2           0.88
noise   False                  RMSE         0.42
tmin    2003-01-01 00:00:00    AICc     -7500.17
tmax    2014-12-31 00:00:00    BIC      -7474.72
freq    D                      Obj        372.93
warmup  3650 days 00:00:00     ___              
solver  LeastSquares           Interp.        No

Parameters (4 optimized)
================================================
               optimal    initial  vary
test_exp_A  341.874957  29.925748  True
test_exp_a   97.909322  10.000000  True
test_exp_f   -0.878938  -1.000000  True
constant_d  -14.227685 -11.740288  True
../_images/fc85abc3295ffac155c7ec4083b93643c1443de2ba74acddb6029786b85f377e.png

Uncalibrated MODFLOW time series model

Using parameters based on the Pastas Exponential model.

# %%
# extract resistance and sy from exponential model
# transform exponential parameters to modflow resistance and sy
mlexp_c = mlexp.parameters.loc["test_exp_A", "optimal"]
mlexp_c_i = mlexp.parameters.loc["test_exp_A", "initial"]
mlexp_s = (
    mlexp.parameters.loc["test_exp_a", "optimal"]
    / mlexp.parameters.loc["test_exp_A", "optimal"]
)
mlexp_s_i = (
    mlexp.parameters.loc["test_exp_a", "initial"]
    / mlexp.parameters.loc["test_exp_A", "initial"]
)
mlexp_d = mlexp.parameters.loc["constant_d", "optimal"]
mlexp_d_i = mlexp.parameters.loc["constant_d", "initial"]
mlexp_f = mlexp.parameters.loc["test_exp_f", "optimal"]
mlexp_f_i = mlexp.parameters.loc["test_exp_f", "initial"]
# create modflow pastas model with c and sy
mlexpmf = ps.Model(head)
# shorten the warmup to speed up the modflow calculation somewhat.
mlexpmf.settings["warmup"] = pd.Timedelta(days=4 * 365)
expmf = ppmf.ModflowRch(exe_name=mf6_exe, sim_ws="mf_files/test_expmf", head=head)
expsm = ppmf.ModflowModel(prec=prec, evap=evap, modflow=expmf, name="test_expmfsm")
mlexpmf.add_stressmodel(expsm)
mlexpmf.set_parameter(f"{expsm.name}_s", initial=mlexp_s, vary=False)
mlexpmf.set_parameter(f"{expsm.name}_c", initial=mlexp_c, vary=False)
mlexpmf.set_parameter(f"{expsm.name}_f", initial=mlexp_f, vary=False)
if "constant_d" in mlexpmf.parameters.index:
    mlexpmf.set_parameter("constant_d", initial=mlexp_d, vary=False)
    if expmf._head is not None:
        mlexpmf.del_constant()
        mlexpmf.set_parameter(f"{expsm.name}_d", initial=mlexp_d, vary=False)

sim = mlexpmf.simulate()
ax = mlexpmf.observations().plot(marker=".", color="k", linestyle="None", legend=False)
sim.plot(ax=ax)
<Axes: xlabel='Date'>
../_images/1c825e50663336bf238551fd5e24398ce0fa0e0c3ec3f57865e954e85739217e.png
mlexpmf.parameters
initial pmin pmax vary name dist stderr optimal
test_expmfsm_d -14.227685 -14.500 -5.420000e+00 False test_expmfsm uniform NaN NaN
test_expmfsm_c 341.874957 10.000 1.000000e+08 False test_expmfsm uniform NaN NaN
test_expmfsm_s 0.286389 0.001 5.000000e-01 False test_expmfsm uniform NaN NaN
test_expmfsm_f -0.878938 -2.000 0.000000e+00 False test_expmfsm uniform NaN NaN

Calibrated MODFLOW time series model

Now fit a Pastas Model using the Modflow model as a response function. This takes some time, as the modflow model has to be recomputed for every iteration in the optimization process.

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.ModflowRch(exe_name=mf6_exe, sim_ws="mf_files/test_mfrch", head=head)
sm = ppmf.ModflowModel(prec, evap, modflow=mf, name="test_mfsm")
ml.add_stressmodel(sm)

ml.set_parameter(f"{sm.name}_s", initial=mlexp_s_i, vary=True)
ml.set_parameter(f"{sm.name}_c", initial=mlexp_c_i, vary=True)
ml.set_parameter(f"{sm.name}_f", initial=mlexp_f_i, vary=True)
# ml.set_parameter("constant_d", initial=mlexp_d_i, vary=True)

with SolveTimer() as st:
    ml.solve(callback=st.timer)
Optimization progress: 53it [02:19,  2.63s/it]
Fit report Head                   Fit Statistics
================================================
nfev    17                     EVP         86.85
nobs    5737                   R2           0.87
noise   False                  RMSE         0.43
tmin    2003-01-01 00:00:00    AICc     -9760.95
tmax    2018-12-25 00:00:00    BIC      -9734.34
freq    D                      Obj        522.56
warmup  1460 days 00:00:00     ___              
solver  LeastSquares           Interp.        No

Parameters (4 optimized)
================================================
                optimal    initial  vary
test_mfsm_d  -13.843900 -11.740288  True
test_mfsm_c  319.022453  29.925748  True
test_mfsm_s    0.315744   0.334160  True
test_mfsm_f   -0.980087  -1.000000  True

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

ml.plot();
../_images/aabb9965d23c11c3fced78227c05484715c18682ef4f4dcc12e2a77060d881b9.png

Results

Parameters

ml.parameters.style.set_table_attributes('style="font-size: 12px"').set_caption(
    "Pastas-Modflow"
)
Pastas-Modflow
  initial pmin pmax vary name dist stderr optimal
test_mfsm_d -11.740288 -14.500000 -5.420000 True test_mfsm uniform 0.023597 -13.843900
test_mfsm_c 29.925748 10.000000 100000000.000000 True test_mfsm uniform 2.552015 319.022453
test_mfsm_s 0.334160 0.001000 0.500000 True test_mfsm uniform 0.003336 0.315744
test_mfsm_f -1.000000 -2.000000 0.000000 True test_mfsm uniform 0.004257 -0.980087
mlexp.parameters.style.set_table_attributes('style="font-size: 12px"').set_caption(
    "Pastas-Exponential"
)
Pastas-Exponential
  initial pmin pmax vary name dist stderr optimal
test_exp_A 29.925748 0.000010 2992.574763 True test_exp uniform 2.845206 341.874957
test_exp_a 10.000000 0.010000 1000.000000 True test_exp uniform 0.895851 97.909322
test_exp_f -1.000000 -2.000000 0.000000 True test_exp uniform 0.010571 -0.878938
constant_d -11.740288 nan nan True constant uniform 0.034303 -14.227685

Compare parameters from the Pastas-Modflow model to the “true” parameters derived from the Pastas exponential model.

comparison = pd.DataFrame(
    {
        "True": mlexpmf.parameters["initial"].values,
        "MF6": ml.parameters["optimal"].values,
    },
    index=ml.parameters.index,
)
comparison["Difference"] = comparison["MF6"] - comparison["True"]
comparison["% Difference"] = (comparison["Difference"] / comparison["True"]) * 100
comparison.style.format(precision=2)
  True MF6 Difference % Difference
test_mfsm_d -14.23 -13.84 0.38 -2.70
test_mfsm_c 341.87 319.02 -22.85 -6.68
test_mfsm_s 0.29 0.32 0.03 10.25
test_mfsm_f -0.88 -0.98 -0.10 11.51

Plots

Compare the Pastas-Modflow simulation to the Pastas-Exponential simulation.

ax = ml.plot()  # Pastas-Modflow
mlexp.plot(ax=ax)  # Pastas-Exponential
handles, labels = ax.get_legend_handles_labels()
ax.legend(
    handles[0:2] + handles[3:],
    labels[0:1] + ["MODFLOW", "Exponential"],
    loc="upper left",
)
<matplotlib.legend.Legend at 0x7f63cbf92d40>
../_images/4e6d51be057d34ce4bc92aca8cc10aa148377613ce3ac4c75eab6ba0094bc951.png