--- title: "Severn_07: Combine tactical and operational planning management" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Severn_07: Combine tactical and operational planning management} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include=FALSE} knitr::opts_chunk$set( comment = "#>", fig.width = 9, fig.asp = 0.68, out.width = "90%", fig.align = "center" ) ``` ```{r setup-lib} library(airGRiwrm) library(lubridate) ``` # Introduction Watershed-scale water resource management involves balancing ecological, social, and economic needs through structured planning approaches. **Tactical** and **operational** planning play distinct but complementary roles in achieving sustainable water use, flood control, and ecosystem health. *Tactical planning* in watershed management focuses on mid-term strategies, such as infrastructure development, policy creation, and risk mitigation to ensure sustainable water use over seasons or years. *Operational planning* handles short-term, day-to-day activities such as water allocation, reservoir release decisions, monitoring, and emergency response. **airGRiwrm** offers various tools for introducing human influences into catchment hydrological modelling, which can be combined to represent these different planning levels. This vignette demonstrates how to integrate both approaches in a single simulation workflow, using a reservoir (dam) project on the Severn River as a case study. # Study Case Presentation ## Project scenario The example in this tutorial takes place on the Severn River in the United Kingdom. The data is extracted from the CAMELS-GB database [Coxon et al. (2020)](https://doi.org/10.5285/8344E4F3-D2EA-44F5-8AFA-86D2987543A9), which provides catchment attributes and hydro-meteorological timeseries for 671 catchments across Great Britain. We consider a **reservoir (dam)** built directly on the river at gauging station "54095", located upstream of gauge "54001". The dam has one objective: | Objective | Description | |------------------|----------------------------------------------------------------------| | **Water supply** | Maintain a minimum daily release of 700,000 m3/day for downstream supply | The reservoir is managed through two control layers: | Planning Level | Mechanism | Time Scale | Purpose | |------------------|----------------------------|------------|--------------------------------------| | **Operational** | Filling curve (controller) | Daily | Guide reservoir releases toward annual volume targets with constrained minimum release | | **Tactical** | Monthly restrictions | Monthly | Set release fraction based on reservoir volume relative to objective | ## How it works 1. **Storage mechanism**: Water flowing through gauging station "54095" enters the reservoir directly. The reservoir stores water according to an annual filling curve that defines target storage volumes throughout the year. 2. **Release mechanism**: The reservoir releases water downstream to node "54001". A PD controller computes the desired release rate to track the filling curve, then clamps it between a scaled minimum and a hard maximum. The minimum scales with the reservoir fill ratio. 3. **Tactical adjustment**: At each monthly step, the reservoir volume is assessed relative to the filling-curve objective. The ratio `release_fraction = min(1, V_current / V_objective)` is computed and stored, proportionally scaling the minimum allowed release for that month. ```{r load-data} data(Severn) Severn$BasinsInfo ``` # Network Setup We construct a self-contained network from the `Severn` dataset. The network includes two gauged stations (`54095` and `54001`) and a reservoir built directly on the river at gauging station `54095`. ## Building the node table ```{r build-nodes} # Extract base node information from Severn data nodes_Severn <- Severn$BasinsInfo[, c( "gauge_id", "downstream_id", "distance_downstream", "area" )] names(nodes_Severn) <- c("id", "down", "length", "area") nodes_Severn$model <- "RunModel_GR4J" # Reduce to the two nodes of interest: 54095 (upstream) and 54001 (downstream) nodes <- nodes_Severn[nodes_Severn$id %in% c("54095", "54001"), ] # Node 54001 becomes the outlet -- no further downstream connection nodes$down[nodes$id == "54001"] <- NA nodes$length[nodes$id == "54001"] <- NA # Add the Reservoir row: dam built directly on the river at 0 km downstream of 54095. # The RunModel_GR4J row for 54095 now feeds into the Reservoir instead of 54001. nodes <- rbind( nodes, data.frame( id = "Reservoir", down = "54001", length = 42, area = NA, model = "RunModel_Reservoir", stringsAsFactors = FALSE ) ) # Update 54095 to feed into the Reservoir (dam built at 0 km downstream) nodes$down[nodes$id == "54095"] <- "Reservoir" nodes$length[nodes$id == "54095"] <- 0 nodes ``` In this network: - **"54095"** is the upstream gauged station where the dam/reservoir is built directly downstream. The GR4J model simulates the naturalized runoff here. - **Reservoir** is the dam that stores water from the river and releases it downstream. It receives all flow from `54095` and releases to `54001`. - **"54001"** is the downstream gauged station receiving the regulated release from the Reservoir. ## Creating the GRiwrm object ```{r create-griwrm} griwrm <- CreateGRiwrm(nodes) griwrm ``` ## Visualising the network ```{r plot-network, fig.asp = 0.7} plot(griwrm) ``` The diagram shows the upstream gauged node (`54095`), the reservoir (`Reservoir`) built directly on the river, and the downstream gauged node (`54001`). All flow from the catchment at `54095` passes through the reservoir before reaching the outlet at `54001`. # Base Simulation Before introducing closed-loop control, we set up the meteorological inputs, model parameters, and run a preliminary open-loop simulation to establish the reservoir's initial state. ## Meteorological and observation data ```{r prepare-meteo} BasinsObs <- Severn$BasinsObs DatesR <- BasinsObs[[1]]$DatesR PrecipTot <- cbind(sapply(BasinsObs, function(x) x$precipitation)) PotEvapTot <- cbind(sapply(BasinsObs, function(x) x$peti)) Qobs <- cbind(sapply(BasinsObs, function(x) x$discharge_spec)) # Convert to sub-catchment scale Precip <- ConvertMeteoSD(griwrm, PrecipTot) PotEvap <- ConvertMeteoSD(griwrm, PotEvapTot) ``` ## Human influence inputs We initialise the reservoir release time series to zero; the PD controller will dynamically compute the release rate at each time step: - **Qrelease (dam release):** Initialised to `1E15` for keeping the reservoir empty during warm-up simulation period — the controller will compute the release rate, clamped between a scaled minimum and a hard maximum (see Operational Control section below). ```{r prepare-quantities} Qrelease <- data.frame(Reservoir = rep(1E15, length(DatesR))) ``` ## Creating the InputsModel object ```{r create-inputs} InputsModel <- suppressWarnings( CreateInputsModel( griwrm, DatesR, Precip, PotEvap, Qrelease = Qrelease ) ) str(InputsModel, max.level = 1) ``` ## Run options and calibration parameters We define a warm-up period covering the initial years of the dataset, followed by a simulation period from 2000 to 2009. The full simulation will be extended month by month in the time-loop section below. ```{r run-options} date_start <- "2002-01-01" date_end <- "2003-12-31" IndPeriod_Run <- seq( which(format(InputsModel[[1]]$DatesR, format = "%Y-%m-%d") == date_start), which(format(InputsModel[[1]]$DatesR, format = "%Y-%m-%d") == date_end) ) IndPeriod_WarmUp <- seq(1, IndPeriod_Run[1] - 1) ``` Default (Michel) calibration parameters are provided for the gauged stations. We also add a parameter set for the Reservoir: `c(Vmax, celerity)`, where `Vmax = 100 × 10⁶ m³` is the maximum reservoir capacity and `celerity = 1` is the inflow time-lag parameter. ```{r params} Param <- list( `54095` = c(256.84415, 0.065046, 57.52368, 2.718095), `54001` = c(1.03, 24.77909, -1.904302, 21.75840, 1.378378), Reservoir = c(100E6, 1) ) ``` # Operational Control: The Filling Curve ## What is a filling curve? A *filling curve* is a tactical-Operational planning tool used by reservoir operators to define target storage volumes throughout the year. It typically prescribes higher volumes during the dry season (to ensure water supply) and lower volumes during the wet season (to reserve spillway capacity for flood events). ## Defining the annual targets We define a yearly filling curve that oscillates between 20 × 10⁶ m³ (winter minimum) and 80 × 10⁶ m³ (summer maximum), staying within the reservoir capacity of 100 × 10⁶ m³: ```{r filling-curve} curve <- approx( x = c(31 * 11 - 365, 30 * 6, 31 * 11, 366 + 30 * 6), y = c(20E6, 80E6, 20E6, 80E6), xout = 1:366 )$y # Visualise the filling curve plot( 1:366, curve / 1E6, type = "l", lwd = 2, xlab = "Day of Year", ylab = expression("Target Volume (10"^6 * " m"^3 * ")"), main = "Annual Filling Curve", col = "steelblue" ) abline(h = 100, lty = 2, col = "red") # Vmax reference abline(h = 80, lty = 3, col = "darkgreen") abline(h = 20, lty = 3, col = "darkgreen") legend( "topright", legend = c("Target curve", "Vmax = 100 hm3", "Operating band"), col = c("steelblue", "red", "darkgreen"), lty = c(1, 2, 3), lwd = c(2, 1, 1) ) ``` ## The PD controller with hard constraints A proportional-derivative (PD) controller compares the current reservoir volume to the filling-curve target and computes a **release** command. The computed release is then clamped between a minimum and maximum bound: | Bound | Value | |------------|---------------------------------------------------------------------| | **Maximum** | `QrelMax` = 200 * 86400 m³/day (~17.3 × 10⁶ m³/day) | | **Minimum** | `release_fraction x QrelMin` where `QrelMin` = 30 * 86400 m³/day (~2.6 × 10⁶ m³/day) | The key mechanism is the `release_fraction`, computed monthly by the tactical layer as `min(1, V_current / V_objective)`. When the reservoir volume is below the filling-curve target, the fraction is less than 1, proportionally reducing the allowed minimum release. When the volume meets or exceeds the target, the full mandatory minimum of 30 m³/s (~2,592,000 m³/day) applies. ```{r controller-factory} fn_filling_curve_factory <- function(sv, curve, QrelMax, QrelMin) { # Shared state across controller calls if (is.null(sv$state)) { sv$state <- list(prevError = 0, release_fraction = 1.0) } # PD gains Kp <- 1 Kd <- 0.1 function(Y) { Vsim <- Y[1] + Y[2] # Current simulated volume with inflows j <- as.numeric(format(sv$ts.date, "%j")) # Day of year Vobj_ts <- approx(x = 1:366, y = curve, xout = j)$y # Target for today error <- Vsim - Vobj_ts dError <- error - sv$state$prevError # Step 1: Compute Qrelease from PD controller ctrl_action <- Kp * error + Kd * dError sv$state$prevError <- error Qrelease <- ctrl_action # Step 2: Apply hard constraints -- clamp between scaled minimum and maximum frac <- sv$state$release_fraction Qrelease <- max(frac * QrelMin, min(QrelMax, Qrelease)) # Return a single-column matrix with the release command return(cbind(Qrelease)) } } ``` ## Setting up the Supervisor ```{r supervisor} sv <- CreateSupervisor(InputsModel) ``` Instantiate the controller function with the supervisor and filling curve: ```{r controller-fun} fn_filling_curve <- fn_filling_curve_factory( sv = sv, curve = curve, QrelMax = 200 * 86400, # Maximum release rate (m3/day) QrelMin = 30 * 86400 # Mandatory minimum release rate (m3/day) ) ``` The factory pattern ensures the controller: - Reads the current reservoir volume through `Y = "Reservoir$Vsim"`. - Computes the PD control action based on the filling-curve error. - Clamps the computed release between `release_fraction * QrelMin` and `QrelMax`. - Returns a single-column matrix with the release command for `"Reservoir$Qrelease"`. - Maintains internal state (previous error and release fraction) across time-steps. Register the filling-curve controller with the supervisor: ```{r controller} CreateController( sv, ctrl.id = "FillingCurve", Y = c("Reservoir$Vsim", "54095"), # Read current reservoir volume with inflows U = "Reservoir$Qrelease", # Release command only FUN = fn_filling_curve # PD control logic ) ``` # Tactical Management: Monthly Restrictions ## Volume-based restriction rule In addition to the filling-curve controller, we implement a tactical rule: at the start of each month, the ratio of the current reservoir volume to the filling-curve objective volume is computed. This ratio, called `release_fraction`, scales the minimum allowed release for the upcoming month. The release fraction is computed as: `release_fraction = min(1, V_current / V_objective)` When `V_current < V_objective`, the fraction is less than 1, proportionally reducing the minimum release below QrelMin (potentially to near zero when the reservoir is nearly empty). When `V_current` meets or exceeds the objective, the fraction is 1, so the full mandatory minimum of 30 m³/s (~2,592,000 m³/day) applies. The final daily release is then computed as: `Qrelease = max(release_fraction * QrelMin, min(QrelMax, PD_controller_output))` This ensures that when the reservoir is below target, both the controller output and the minimum floor are reduced, allowing the reservoir to recover more quickly. ## Month-by-month time-loop simulation The simulation runs in a time loop, processing one month at a time. At each iteration: 1. The tactical restriction is checked and the release fraction is computed as `min(1, V_current / V_objective)`. 2. The controller determines the release rate at each daily time step, clamping the PD output between the scaled minimum and hard maximum. 3. The model advances through the month's time steps. ```{r time-loop} # Define simulation period: from 01/01/2000 up to 31/12/2009 dfTS <- data.frame( DatesR = DatesR, yearmonth = format(DatesR, "%Y-%m") ) tactical_months <- unique(dfTS$yearmonth[ dfTS$yearmonth >= format(as.Date(date_start), "%Y-%m") & dfTS$yearmonth <= format(as.Date(date_end), "%Y-%m") ]) first_run <- TRUE # Store the tactical release fraction; default to 1 (unrestricted) release_fraction <- 1.0 for (ym in tactical_months) { message(sprintf("Processing period: %s", ym), appendLF = FALSE) # Extract the current month's indices ym_IndPeriod_Run <- which(dfTS$yearmonth == ym) if (first_run) { message(" -> Release fraction = 1.0 (initial run)") RunOptions <- CreateRunOptions( InputsModel, IndPeriod_WarmUp = IndPeriod_WarmUp, IndPeriod_Run = ym_IndPeriod_Run, IniStates = list(Reservoir = c("Reservoir.V" = 0)), warnings = FALSE ) OM <- suppressMessages( RunModel( sv, RunOptions = RunOptions, Param = Param ) ) first_run <- FALSE } else { # --- Tactical rule: compute release fraction from volume ratio --- V_current <- OM$Reservoir$StateEnd$Reservoir$V # Get the day-of-year for the first day of this month first_day_ym <- as.Date(paste0(ym, "-01")) j_first <- as.numeric(format(first_day_ym, "%j")) V_objective <- approx(x = 1:366, y = curve, xout = j_first)$y # Compute the release fraction: ratio of current to objective volume # When V_current < V_objective, fraction < 1, reducing the minimum release # When V_current >= V_objective, fraction = 1, full minimum applies release_fraction <- min(1, V_current / V_objective) # Update the supervisor state so the controller reads the new fraction sv$state$release_fraction <- release_fraction message(sprintf( " -> Release fraction: %.2f (V_cur=%.1f, V_obj=%.1f)", release_fraction, V_current / 1E6, V_objective / 1E6 )) # Run the model for the current month with supervisor control OM <- suppressWarnings( RunModel( OM, InputsModel = sv, RunOptions = RunOptions, IndPeriod_Run = ym_IndPeriod_Run ) ) } } ``` # Results ## Reservoir volume tracking We can visualise how the simulated reservoir volume follows (or deviates from) the filling curve target over the simulation period: ```{r plot-results, fig.asp = 0.7} # Expand the annual filling curve to cover the full simulation period # by repeating it for each year ydoys <- as.numeric(format(as.Date(OM[[1]]$DatesR), "%j")) curve_full <- approx(x = 1:366, y = curve, xout = ydoys)$y # Plot Reservoir volume, inflows, and release plot(OM$Reservoir, Vobs = curve_full) ``` One can observe that the reservoir’s flow release follows the restriction ratio during low‑flow periods, preventing the reservoir from emptying. ## Flow components at the downstream gauge ```{r plot-downstream, fig.asp = 0.6} plot(attr(OM, "Qm3s")) ``` # Key Takeaways This vignette demonstrated how **tactical** and **operational** planning layers can be integrated in airGRiwrm through a dam/reservoir project: | Planning Level | Tool | Purpose | |------------------|-------------------------------------|----------------------------------------------------------| | **Tactical** | Monthly release fraction | Scale minimum release based on volume ratio to objective | | **Operational** | Filling curve (PD controller) | Daily volume tracking with constrained release bounds | The `Supervisor` / `Controller` architecture in airGRiwrm provides a flexible framework for implementing custom water management rules. Controllers can read simulated states (flows, volumes, etc.) and adjust model inputs (releases) at each time step, enabling realistic closed-loop simulations of regulated water systems. In this example, a single controller manages the dam release with a key design feature: the release is clamped between a scaled minimum and a hard maximum. The scaling factor (`release_fraction`) is computed monthly as `min(1, V_current / V_objective)`. When the reservoir volume is below the filling-curve target, both the controller's computed output and the minimum floor are proportionally reduced, allowing the reservoir to recover faster. When the volume meets or exceeds the target, the full release range from QrelMin to QrelMax is available. ## Summary of the control workflow 1. The **base simulation** (initial run) sets up the reservoir from an empty starting state and provides the hydrological context. 2. The **operational controller** runs at every time step, comparing the current volume to the filling curve via a PD controller, then clamping the computed release between `release_fraction * QrelMin` and `QrelMax`. 3. The **tactical layer** interrupts the monthly cycle to compute `release_fraction = min(1, V_current / V_objective)`, storing it in the supervisor state for the controller to use during the month. 4. The combined control ensures both daily responsiveness (filling curve tracking) and mid-term sustainability (tactical volume-based restrictions), while adapting the minimum release to the actual reservoir state. # References Coxon, G., Addor, N., Bloomfield, J.P., Freer, J., Fry, M., Hannaford, J., Howden, N.J.K., Lane, R., Lewis, M., Robinson, E.L., Wagener, T. and Woods, R. (2020). Catchment attributes and hydro-meteorological timeseries for 671 catchments across Great Britain (CAMELS-GB). NERC Environmental Information Data Centre. \doi{10.5285/8344E4F3-D2EA-44F5-8AFA-86D2987543A9}