Tutorial 0 - SARA-mini

This tutorial introduces the basic classes and methods of the SARAwater package.

The figure below illustrates the workflow of the sarawater package, highlighting its components. In this tutorial, we will focus on the incoming flow time series (\(Q_{nat}\)), the flow requirement (\(Q_{req}\)), and the released flow time series (\(Q_{rel}\)). These three quantities contribute to the mass balance at the diversion, which reads

\begin{equation*} Q_{nat} = Q_{abs} + Q_{rel} \tag{1} \end{equation*}

1ae7a3a0e5ec4b8396f15bff07b36f01

Importing external libraries

Let’s start by importing the necessary external libraries.

[2]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from numpy import ndarray

Defining SARA classes

The classes defined below are a shrinked version of those implemented in the package. However, they are sufficient to get the basics off how SARA works!

The Scenario class and its child classes

The parent class Scenario and its child classes ConstScenario and PropScenario are used to define different water management scenarios for a river reach. Each scenario specifies how much water is required to be released from the reach (i.e., the “flow requirement”) based on different criteria.

[ ]:
class Scenario:
    def __init__(self, name: str, description: str, reach, Qabs_max=None):
        """Parent class for all types of scenarios. Contains the name and description of the scenario.

        Parameters
        ----------
        name : str
            Name of the scenario.
        description : str
            Description of the scenario.
        reach : Reach
            The reach object associated with this scenario.
        Qabs_max : float, optional
            Maximum value for the water abstraction, by default None (which takes the value from the reach object).
        """
        self.name = name
        self.description = description
        self.reach = reach
        self.Qreq = None  # Flow requirement time series
        self.Qrel = None  # Released flow rate time series
        if Qabs_max is None:  # Maximum abstraction
            self.Qabs_max = reach.Qabs_max
        else:
            self.Qabs_max = Qabs_max

    @property
    def Qnat(self) -> ndarray:
        """Get the natural flow rate time series from the associated Reach."""
        return self.reach.Qnat

    @property
    def dates(self) -> list:
        """Get the dates from the associated Reach."""
        return self.reach.dates

    def compute_Qrel(self) -> ndarray:
        """Compute the released flow rate time series for the scenario.

        Returns
        -------
        ndarray
            Released flow rate time series.
        """
        if self.Qreq is None:
            raise ValueError("Qreq must be set before computing Qrel.")

        Qrel = np.zeros_like(self.Qnat)
        case1 = self.Qnat <= self.Qreq
        case2 = (self.Qnat > self.Qreq) & (self.Qnat < self.Qabs_max + self.Qreq)
        case3 = self.Qnat >= self.Qabs_max + self.Qreq
        Qrel[case1] = self.Qnat[case1]
        Qrel[case2] = self.Qreq[case2]
        Qrel[case3] = self.Qnat[case3] - self.Qabs_max
        self.Qrel = Qrel
        return Qrel

ConstScenario: the child class for scenarios with fixed monthly flow requirements

[4]:
class ConstScenario(Scenario):
    def __init__(
        self, name: str, description: str, reach, Qreq_months: list[float], **kwargs
    ):
        """Constant flow rate scenario.

        Parameters
        ----------
        name : str
            Name of the scenario.
        description : str
            Description of the scenario.
        reach : Reach
            The reach object associated with this scenario.
        Qreq_months : list[float]
            Monthly constant flow rates.
        """
        super().__init__(name, description, reach, **kwargs)

        if len(Qreq_months) != 12:
            raise ValueError("Qreq_months must have 12 elements.")
        self.Qreq_months = Qreq_months

        # Map the monthly flow rates to the dates of the reach
        self.Qreq = np.zeros_like(self.Qnat)
        for i, month in enumerate(self.Qreq_months):
            month_mask = np.array([date.month == i + 1 for date in self.dates])
            self.Qreq[month_mask] = month

PropScenario: the child class for scenarios with flow requirements proportional to the incoming flow. In scenarios with a proportional flow requirement, \(Q_{req}\) is computed as a fraction of the incoming flow discharge, \(Q_{in}\), according to the formula:

\begin{equation*} Q_{req} = Q_{base} + c_{in} \cdot Q_{in} \tag{2}, \end{equation*}

where \(Q_{base}\) is a base flow requirement (e.g., to maintain minimum ecological conditions), and \(c_{in}\) is a coefficient that defines the proportion of the incoming flow to be included in the flow requirement.

This formula is then adjusted to ensure that \(Q_{req}\) remains within user-specified minimum and maximum bounds, \(Q_{req,min}\) and \(Q_{req,max}\), respectively. This step is needed because low flow requirements may cause severe alteration in the downstream reach, while high flow requirements may lead to a very low water abstraction, which might not be sufficient. The complete definition of \(Q_{req}\) in proportional scenarios is given by the piecewise function:

\begin{equation*} Q_{req}= \left\{ \begin{array}{lll} Q_{req,min} & \text{if} & Q_{base} + c_{in} \cdot Q_{in} \leq Q_{req,min}\\[1ex] Q_{base} + c_{in} \cdot Q_{in} & \text{if} & Q_{req,min} < Q_{base} + c_{in} \cdot Q_{in} < Q_{req,max}\\[1ex] Q_{req,max} & \text{if} & Q_{base} + c_{in} \cdot Q_{in} > Q_{req,max}\\ \end{array} \right. \tag{3} \end{equation*}

[ ]:
class PropScenario(Scenario):
    def __init__(
        self,
        name: str,
        description: str,
        reach,
        Qbase: float,
        c_in: float,
        Qreq_min: float,
        Qreq_max: float,
        **kwargs,
    ):
        """Proportional flow rate scenario.

        Parameters
        ----------
        name : str
            Name of the scenario.
        description : str
            Description of the scenario.
        reach : Reach
            The reach object associated with this scenario.
        Qbase : float
            Base flow rate.
        c_in : float
            Coefficient for inflow.
        Qreq_min : float
            Minimum value for the prescribed minimum released flow rate.
        Qreq_max : float
            Maximum value for the prescribed minimum released flow rate.
        """
        super().__init__(name, description, reach, **kwargs)
        self.Qbase = Qbase
        self.c_in = c_in
        self.Qreq_min = Qreq_min
        self.Qreq_max = Qreq_max
        self.Qreq = Qbase + c_in * self.Qnat
        self.Qreq[self.Qreq < Qreq_min] = Qreq_min
        self.Qreq[self.Qreq > Qreq_max] = Qreq_max

The Reach class

[ ]:
class Reach:
    def __init__(self, name: str, dates: list, Qnat: ndarray, Qabs_max: float):
        """Represents a river reach.

        Parameters
        ----------
        name : str
            Name of the reach.
        dates : list[datetime]
            List of dates for the time series.
        Qnat : ndarray
            Natural flow rate time series.
        Qabs_max : float
            Maximum value for the water abstraction.
        """
        self.name = name
        self.dates = dates
        self.Qnat = Qnat
        self.Qabs_max = Qabs_max
        self.scenarios: list[Scenario] = []

    def __str__(
        self,
    ):  # This method defines what is returned when we print() a Reach object
        return f"{self.name} is a Reach object with a flow time series with {len(self.Qnat)} elements. The date range starts from {min(self.dates)} and has {len(self.dates)} elements. The maximum flow abstraction is Qabs_max={self.Qabs_max} m3/s. So far, {len(self.scenarios)} scenarios have been added."

    def add_scenario(self, scenario: Scenario):
        """Add a scenario to the reach.

        Parameters
        ----------
        scenario : Scenario
            The scenario to add.

        Returns
        -------
        Reach
            The current reach instance.
        """
        self.scenarios.append(scenario)
        return self

    def print_scenarios(self):
        """Print the list of scenarios added to the reach."""
        for i, scenario in enumerate(self.scenarios):
            print(f"scenarios[{i}]: {scenario.name} | {scenario.description}")
        return None

Let’s create a Reach!

First, let’s specify the path to the input CSV file containing the flow discharge time series for the reach.

NOTE: the CSV file should have at least two columns: “Date” and “Q”, where “Date” contains the timestamps and “Q” contains the flow discharge values in cubic meters per second (m³/s).

[6]:
csv_filepath = "daily_discharge_30y.csv"  # The csv file must be in the same folder as this notebook

Then, let’s extract the timestamps and discharge data from the CSV file. To create a Reach object, we need to convert the “Date” column to a list of datetime objects and the “Q” (flow discharge) column into a numpy array.

[ ]:
# Read CSV with automatic date parsing
input_df = pd.read_csv(csv_filepath, parse_dates=["Date"])

# Convert the "Date" column to a list of datetime objects
datetime_list = input_df["Date"].dt.to_pydatetime().tolist()

# Convert the "Q" (flow discharge) column into a numpy array
discharge_data = np.array(input_df["Q"].to_list())

Initialize a reach object

The only ingredient left to create a Reach is the maximum flow discharge that can be abstracted from the reach.

[8]:
Qabs_max = 3

Finally, let’s create our Reach object!

[9]:
my_reach = Reach("My Reach", datetime_list, discharge_data, Qabs_max)

Adding scenarios to the reach object

Minimum Flow Requirement (MFR) scenario with fixed monthly requirements

[ ]:
Qreq_months = np.array(
    [0.1, 0.1, 0.1, 0.12, 0.12, 0.12, 0.12, 0.1, 0.1, 0.12, 0.12, 0.1]
)  # values are in m3/s, representing minimum release for each month

# Create a constant scenario with these values
const_scenario = ConstScenario(
    name="Constant Scenario",
    description="Minimum release scenario with constant monthly values",
    reach=my_reach,
    Qreq_months=Qreq_months,
)

# Add the scenario to the reach
my_reach.add_scenario(const_scenario)

Proportional release scenario

[ ]:
prop_scenario = PropScenario(
    name="Proportional Scenario",
    description="Proportional scenario with arbitrary parameters",
    reach=my_reach,
    Qbase=0.06,
    c_in=0.3,
    Qreq_min=np.min(Qreq_months),
    Qreq_max=1.3 * np.max(Qreq_months),
)
my_reach.add_scenario(prop_scenario)

Let’s check we added the scenarios correctly

[12]:
my_reach.print_scenarios()
scenarios[0]: Constant Scenario | Minimum release scenario with constant monthly values
scenarios[1]: Proportional Scenario | Proportional scenario with arbitrary parameters

Compute the released flow discharge for each scenario

Given the incoming flow discharge \(Q_{nat}\), the flow requirement \(Q_{req}\) and the maximum abstractable flow \(Q_{max}\), the released flow discharge \(Q_{rel}\) is computed as:

\begin{equation*} Q_{rel}= \left\{ \begin{array}{lll} Q_{nat} & \text { if } & Q_{nat} \leq Q_{req} & (4.1)\\[1ex] Q_{req} & \text { if } & Q_{req} < Q_{nat} < Q_{req}+Q_{abs,max}& (4.2)\\[1ex] Q_{nat}-Q_{abs,max} & \text { if } & Q_{nat} \geq Q_{req}+ Q_{abs,max} & (4.3)\\[1ex] \end{array} \right. \end{equation*}

Where the three cases correspond to the following:

  • \((4.1)\) The incoming flow \(Q_{nat}\) is lower than the flow requirement \(Q_{req}\); therefore, no water is abstracted and the released flow discharge \(Q_{rel}\) equals the incoming flow. This usually happens in low-flow periods.

  • \((4.2)\) There is enough incoming flow to satisfy the flow requirement, and the abstracted flow discharge \(Q_{abs}\) is lower than the maximum abstractable flow \(Q_{abs,max}\). Recall that, according to Equation \((1)\), \(Q_{abs} = Q_{nat}-Q_{rel}\) (where, in this case, \(Q_{rel}=Q_{req}\)). This is the most “common” case, where the flow requirement rule is applied straightforwardly.

  • \((4.3)\) The incoming flow \(Q_{nat}\) is so large that the maximum abstractable flow can be diverted while still releasing a flow rate larger than the flow requirement. This usually happens during flood events.

The piecewise function \((4.1-4.3)\) is implemented in the compute_Qrel method of the class Scenario; therefore, to compute the released flow time series for each scenario we can simply write

[13]:
for scenario in my_reach.scenarios:
    scenario.compute_Qrel()

Plot the released flow discharge time series for each scenario

[14]:
plt.plot(my_reach.dates, my_reach.Qnat, label="Natural Flow")
for scenario in my_reach.scenarios:
    plt.plot(scenario.dates, scenario.Qrel, label=scenario.name)
plt.xticks(rotation=45)
plt.legend()
plt.ylim(0, 0.5)
plt.tight_layout()
../../_images/tutorials_notebooks_tutorial_0_SARA-mini_33_0.png

What a messy plot! Lets focus on one year only. First, let’s create a Boolean array (a mask) to filter the dates within the year 2018.

[15]:
start_date = pd.to_datetime("2018-01-01")
end_date = pd.to_datetime("2018-12-31")
mask = [(dt >= start_date) and (dt <= end_date) for dt in my_reach.dates]

Then, let’s plot the released flow discharge for each scenario in the time interval we specified

[16]:
plt.plot(np.array(my_reach.dates)[mask], my_reach.Qnat[mask], label="Natural Flow")
for scenario in my_reach.scenarios:
    plt.plot(np.array(scenario.dates)[mask], scenario.Qrel[mask], label=scenario.name)
plt.xticks(rotation=45)
plt.legend()
plt.yscale("log")
plt.grid()
plt.tight_layout()
../../_images/tutorials_notebooks_tutorial_0_SARA-mini_37_0.png