Modeling snow#

R.A. Collenteur, University of Graz / Eawag, November 2021

In this notebook it is shown how to account for snowfall and smowmelt on groundwater recharge and groundwater levels, using a degree-day snow model. This notebook is work in progress and will be extended in the future.

import matplotlib.pyplot as plt
import pandas as pd

import pastas as ps

ps.set_log_level("ERROR")
ps.show_versions()
Pastas     : 2.0.0
Python     : 3.14.0
Numpy      : 2.4.6
Pandas     : 3.0.3
Scipy      : 1.17.1
Matplotlib : 3.10.9
Numba      : 0.65.1

1. Load data#

In this notebook we will look at some data for a well near Heby, Sweden. All the meteorological data is taken from the E-OBS database. As can be observed from the temperature time series, the temperature regularly drops below zero in winter. Given this observation, we may expect precipitation to (partially) fall as snow during these periods.

head = pd.read_csv("data/heby_head.csv", index_col=0, parse_dates=True).squeeze()
evap = pd.read_csv("data/heby_evap.csv", index_col=0, parse_dates=True).squeeze()
prec = pd.read_csv("data/heby_prec.csv", index_col=0, parse_dates=True).squeeze()
temp = pd.read_csv("data/heby_temp.csv", index_col=0, parse_dates=True).squeeze()

ps.plots.series(head, stresses=[prec, evap, temp]);
../_images/ca3c83b5af182c13a0f4b7221e01a60624d3ebca1e2cfb09f7d74854a834ccdf.png

2. Make a simple model#

First we create a simple model with precipitation and potential evaporation as input, using the non-linear FlexModel to compute the recharge flux. We not not yet take snowfall into account, and thus assume all precipitation occurs as snowfall. The model is calibrated and the results are visualized for inspection.

# Settings
tmin = "1985"  # Needs warmup
tmax = "2010"
ml1 = ps.Model(head)
sm1 = ps.RechargeModel(
    prec, evap, recharge=ps.rch.FlexModel(), rfunc=ps.Gamma(), name="rch"
)
ml1.add_stressmodel(sm1)

# Solve the Pastas model in two steps
ml1.solve(tmin=tmin, tmax=tmax, fit_constant=False, report=False)
ml1.add_noisemodel(ps.ArNoiseModel())
ml1.set_parameter("rch_srmax", vary=False)
ml1.solve(tmin=tmin, tmax=tmax, fit_constant=False, initial=False)
ml1.plot(figsize=(10, 3));
Fit report solver                   Fit Statistics
==================================================
nfev     38                     EVP          41.03
nobs     590                    R2            0.41
noise    True                   RMSE          0.13
tmin     1985-01-01 00:00:00    AICc      -3224.26
tmax     2010-01-01 00:00:00    BIC       -3193.80
freq     D                      Obj            nan
freq_obs None                   ___               
warmup   3650 days 00:00:00     Interp.         No

Parameters (7 optimized)
==================================================
                 optimal     initial   vary
rch_A           1.957339    1.351713   True
rch_n           1.387195    3.174705   True
rch_a         181.315484   51.823317   True
rch_srmax       2.616582    2.616582  False
rch_lp          0.250000    0.250000  False
rch_ks       5751.412759  107.314920   True
rch_gamma      10.871341    1.121168   True
rch_kv          1.279358    1.999941   True
rch_simax       2.000000    2.000000  False
constant_d     77.771690    0.000000  False
noise_alpha   103.871292   15.000000   True
../_images/6acd448ecfe135fe1e7339a28a1988523f9b78fb788314cc39b79365f50aeae1.png

The model fit with the data is not too bad, but we are clearly missing the highs and lows of the observed groundwater levels. This could have many causes, but in this case we may suspect that the occurrence of snowfall and melt impacts the results.

3. Account for snowfall and snow melt#

A second model is now created that accounts for snowfall and melt through a degree-day snow model (see e.g., Kavetski & Kuczera (2007). To run the model we add the keyword snow=True to the FlexModel and provide a time series of mean daily temperature to the RechargeModel. The temperature time series is used to split the precipitation into snowfall and rainfall.

ml2 = ps.Model(head)
sm2 = ps.RechargeModel(
    prec,
    evap,
    recharge=ps.rch.FlexModel(snow=True),
    rfunc=ps.Gamma(),
    name="rch",
    temp=temp,
)
ml2.add_stressmodel(sm2)

# Solve the Pastas model in two steps
ml2.solve(tmin=tmin, tmax=tmax, fit_constant=False, report=False)
ml2.add_noisemodel(ps.ArNoiseModel())
ml2.set_parameter("rch_srmax", vary=False)
ml2.solve(tmin=tmin, tmax=tmax, fit_constant=False, initial=False)
Fit report solver                   Fit Statistics
==================================================
nfev     30                     EVP          67.35
nobs     590                    R2            0.67
noise    True                   RMSE          0.10
tmin     1985-01-01 00:00:00    AICc      -3374.81
tmax     2010-01-01 00:00:00    BIC       -3340.02
freq     D                      Obj            nan
freq_obs None                   ___               
warmup   3650 days 00:00:00     Interp.         No

Parameters (8 optimized)
==================================================
                optimal     initial   vary
rch_A          0.737762    0.576418   True
rch_n          1.124846    1.454986   True
rch_a        203.038430  106.908348   True
rch_srmax    155.133396  155.133396  False
rch_lp         0.250000    0.250000  False
rch_ks       336.133310  198.170050   True
rch_gamma     13.644744   18.234298   True
rch_kv         0.634780    0.706561   True
rch_simax      2.000000    2.000000  False
rch_tt         0.000000    0.000000  False
rch_k          1.000000    1.000033   True
constant_d    78.221242    0.000000  False
noise_alpha   69.908135   15.000000   True

Warnings! (1)
==================================================
Parameter 'rch_k' on lower bound: 1.00e+00

Compare results#

From the fit_report we can already observe that the model fit improved quite a bit. We can also visualize the results to see how the model improved.

ax = ml2.plot(figsize=(10, 3))
ml1.simulate().plot(ax=ax)
plt.legend(
    [
        "Observations",
        "Model w Snow NSE={:.2f}".format(ml2.stats.nse()),
        "Model w/o Snow NSE={:.2f}".format(ml1.stats.nse()),
    ],
    ncol=3,
)
<matplotlib.legend.Legend at 0x7f03f8fa7b60>
../_images/da6a7426d3eb33943727389ec765e451acafef4a491e44e328d515dc87b1167a.png

Extract the water balance (States & Fluxes)#

df = ml2.stressmodels["rch"].get_water_balance(
    ml2.get_parameters("rch"), tmin=tmin, tmax=tmax
)
df.plot(subplots=True, figsize=(10, 10));
../_images/34de50c56509904c63a16fd1da07ada7b742d41467396f1fb2232e1022d8c222.png

References#

  • Kavetski, D. and Kuczera, G. (2007). Model smoothing strategies to remove microscale discontinuities and spurious secondary optima in objective functions in hydrological calibration. Water Resources Research, 43(3).