Detecting Causality using Convergent Cross Mapping: A Python Demo using the Fisheries Game

This post demonstrates the use of Convergent Cross Mapping (CCM) on the Fisheries Game, a dynamic predator-prey system. To conduct CCM, we’ll use the causal_ccm Python package by Prince Joseph Erneszer Javier, which you can find here. This demo follows the basic procedure used in the tutorial for the causal_ccm package, which you can find here.

All code in this blog post can be found in a Jupyter notebook in this Github repo.

Convergent Cross Mapping

CCM is a technique for understanding causality in dynamical systems where the effects of causal variables cannot be separated or uncoupled from the variables they influence (Sugihara et al., 2012). Rohini wrote a great blog post about CCM in 2021, so I’ll just provide a quick summary here and refer readers to Rohini’s post for more details.

CM harnesses the idea that the dynamics of a system can be represented by an underlying manifold, which can be approximated using lagged information from the time series of each variable of interest. If variable X has a causal effect on variable Y, then information about X should be encoded in variable Y, and we can “recover” historical states of X from the historical time series of Y. Using this concept, CCM develops “shadow manifolds” for system variables, and examines the relationships between shadow manifolds using cross mapping, which involves sampling nearest-neighbor points in one manifold, and determining if they correspond to neighboring points in the other. For a more detailed explanation of CCM, I highly recommend reading Sugihara et al., 2012. I also found the series of videos created by the authors to be extremely helpful. You can find them here.

Simulating the fisheries predator-prey system

We’ll start by simulating the predator-prey system used in Hadjimichael et al., (2020). This system models the population dynamics of two species, a predator and a prey. The simulation uses two differential equations to model the dynamics of the predator-prey system:

\frac{dx}{dt} = bx(1-\frac{x}{K}) - \frac{\alpha x y}{y^{m }+ \alpha h x} - zx


\frac{dy}{dt} = \frac{c \alpha x y}{\alpha h x} -dy

Where x and y represent the prey and predator population densities, t represents the time in years, a is the prey availability, b is the prey growth rate, c is the rate at which prey is converted to new predators, d is the predator death rate, h is the time needed to consume prey (called the handling time), K is the carrying capacity for prey, m is the level of predator interaction, and z is the harvesting rate. In the cell below, we’ll simulate this system for a given set of environmental parameters (a, b etc.), and plot the dynamics over 120 time periods. For more details on the Fisheries system, see the training blog posts by Lillian and Trevor (part 0, part 1, part 2). There is also an interactive Jupyter notebook in our recently published eBook on Uncertainty Characterization for Multisector Dynamics Research.

import numpy as np
from matplotlib import pyplot as plt

# assign default parameters
tsteps = 120 # number of timesteps to simulate
a = 0.01 # prey availability
b = 0.25 # prey growth rate
c = 0.30 # rate that prey is converted to predator
d = 0.1 # predator death rate
h = 0.61 # handling time
K = 1900 # prey carrying capacity
m = .20 # predator interference rate

# create arrays to store predator and prey populations
prey = np.zeros(tsteps+1)
pred = np.zeros(tsteps+1)

# initial population levels
prey[0] = 28
pred[0] = 28

# harvesting, which we will keep at 0 for this example
z = np.zeros(len(prey))

# simulate the system
for t in range(tsteps):
    if prey[t] > 0 and pred[t] > 0:
        prey[t + 1] = (prey[t] + b * prey[t] * (1 - prey[t] / K) - (a * prey[t] * pred[t]) / (np.power(pred[t], m) +
                                                            a * h * prey[t]) - z[t] * prey[t])   # Prey growth equation
        pred[t + 1] = (pred[t] + c * a * prey[t] * pred[t] / (np.power(pred[t], m) + a * h *
                                                            prey[t]) - d * pred[t]) # Predator growth equation

# plot the polulation dynamics
fig = plt.figure(figsize=(6,6))
plt.plot(np.arange(tsteps+1), prey, label = 'Prey')
plt.plot(np.arange(tsteps+1), pred, label = 'Predator')
plt.legend(prop={'size': 12})
plt.xlabel('Timestep', size=12)
plt.ylabel('Population Size', size=12)
plt.title('Population Dynamics', size=15)
Figure 1: predator and prey population dynamics

With these parameters, we can visualize the trajectory and direction field of the system of equations, which is shown below (I’m not including ploting code here for brevity, but see this tutorial if you’re interested in making these plots). In CCM terms, this is a visualization of the underlying manifold of the dynamical system.

Figure 2: Trajectories and direction fields of the predator-prey system

Causal detection with CCM

From the system of equations above, we know there is a causal relationship between the predator and prey, and we have visualized the common manifold. Below, we’ll test whether CCM can detect this relationship. If the algorithm works as it should, we will see a clear indication that the two populations are causally linked.

To conduct CCM, we need to specify the number of dimensions to use for shadow manifolds, E, the lag time, tau, and the library size, L.

The CCM algorithm should converge to a stable approximation of causality as the library size increases. Below we’ll test library sizes from 10 to 100 to see if we achieve convergence for the Fisheries system. We’ll start by assuming shadow manifolds have two dimensions (E=2), and a lag time of one time step (tau=1). To test convergence, we’ll plot the correlation (rho) between the shadow manifold predictions and the historical states of the two variables.

from causal_ccm.causal_ccm import ccm
E = 2 # dimensions of the shadow manifold
tau = 1 # lag time
L_range = range(10, 100, 5) # test a range of library sizes
preyhat_Mpred, predhat_Mprey = [], [] # correlation list
for L in L_range:
    ccm_prey_pred = ccm(prey, pred, tau, E, L) # define new ccm object # Testing for X -> Y
    ccm_pred_prey = ccm(pred, prey, tau, E, L) # define new ccm object # Testing for Y -> X
    preyhat_Mpred.append(ccm_prey_pred.causality()[0])
    predhat_Mprey.append(ccm_pred_prey.causality()[0])

# Plot Cross Mapping Convergence
plt.figure(figsize=(6,6))
plt.plot(L_range, preyhat_Mpred, label='$\hat{Prey}(t)|M_{Prey}$')
plt.plot(L_range, predhat_Mprey, label='$\hat{Pred}(t)|M_{Pred}$')
plt.ylim([0,1])
plt.xlabel('Library Size', size=12)
plt.ylabel(r'$\rho$', size=12)
plt.legend(prop={'size': 12})
Figure 3: Convergence in cross mapping estimates

Figure 3 shows that CCM does seem to detect a causal relationship between the predator and prey (rho is far above 0), and the estimated strength of this relationship starts to stabilize (converge) with a library size of around 60.

Next, we’ll examine how our lag time (tau) used to construct the shadow manifolds impacts our findings.

from causal_ccm.causal_ccm import ccm
E = 2 # dimensions of the shadow manifold
L = 60 # length of library (we'll return to this later)

# first, test different lags for construction of the shadow manifolds
# we'll test lags from 0 to 30 time steps
preyhat_Mpred, predhat_Mprey = [], []  # lists to store correlation
for tau in range(1, 20):
    ccm_prey_pred = ccm(prey, pred, tau, E, L)  # define new ccm object # Testing for prey -> pred
    ccm_pred_prey = ccm(pred, prey, tau, E, L)  # define new ccm object # Testing for pred -> prey
    preyhat_Mpred.append(ccm_prey_pred.causality()[0]) # stores prey -> pred
    predhat_Mprey.append(ccm_pred_prey.causality()[0]) # stores pred -> prey

# plot the correlation for different lag times
plt.figure(figsize=(6,6))
plt.plot(np.arange(1,20), preyhat_Mpred, label='$\hat{Prey}(t)|M_{Prey}$')
plt.plot(np.arange(1,20), predhat_Mprey, label='$\hat{Pred}(t)|M_{Pred}$')
plt.ylim([0,1.01])
plt.xlim([0,20])
plt.xticks(np.arange(20))
plt.xlabel('Lag', size=12)
plt.ylabel(r'$\rho$', size=12)
plt.legend(prop={'size': 12})
Figure 4: The impact of lag time, tau, on CCM estimates

The results in Figure 4 indicate that a lag time of 1 (which we initially assumed) does not adequately capture the causal relationship between the two variables. When lag times are set between 5 and 10, CMM shows a much stronger relationship between the two variables. Using this information, we can again test the convergence across different library sizes.

E = 2 # dimensions of the shadow manifold
tau = 5
L_range = range(10, 100, 5)
preyhat_Mpred, predhat_Mprey = [], [] # correlation list
for L in L_range:
    ccm_prey_pred = ccm(prey, pred, tau, E, L) # define new ccm object # Testing for X -> Y
    ccm_pred_prey = ccm(pred, prey, tau, E, L) # define new ccm object # Testing for Y -> X
    preyhat_Mpred.append(ccm_prey_pred.causality()[0])
    predhat_Mprey.append(ccm_pred_prey.causality()[0])

# Plot Cross Mapping Convergence
plt.figure(figsize=(6,6))
plt.plot(L_range, preyhat_Mpred, label='$\hat{Prey}(t)|M_{Prey}$')
plt.plot(L_range, predhat_Mprey, label='$\hat{Pred}(t)|M_{Pred}$')
plt.ylim([0,1])
plt.xlabel('Library Size', size=12)
plt.ylabel(r'$\rho$', size=12)
plt.legend(prop={'size': 12})
Figure 5: With a lag of 5, CCM converges much faster, and detects a much stronger relationship between predator and prey

In the Figure 5, we observe that CCM with a lag size of 5 converges much faster and generates a stronger correlation than our original estimate using tau = 1. In fact, CCM can reconstruct the historical system states almost perfectly. To see why we can visualize the underlying shadow manifolds and cross mapping conducted for this analysis (this is conveniently available in the causal_ccm package with the visualize_cross_mapping function).

# set lag (tau) to 7 and examine results
tau = 5

# prey -> predator
ccm1 = ccm(prey,pred, tau, E, L) # prey -> predator
print("prey -> predator: " + str(ccm1.causality()[0]))

# visualize the cross mapping from the two shadow manifolds
ccm1.visualize_cross_mapping()
Figure 6: Shadow manifolds and cross mapping between prey (shown as X) and predator (shown as Y).

Figure 6 shows the two shadow manifolds for prey (X in these plots) and predator (Y in these plots). We observe that the shapes of the shadow manifolds preserve the general characteristics of the original manifold, as shown by the trajectories plotted in Figure 2. Figure 6 also shows the the nearest neighbor points sampled in each manifold (blue boxes) and their mapping to the other variable’s shadow manifold (red stars). We can see how similar points on one manifold correspond to similar points on the other in both directions.

We can also use the causal_ccm package to visualize the correlation between the prey and the CCM prey estimates, with very impressive results (Figure 7).

Figure 7: The relationship between observed populations and CCM estimates.

Concluding thoughts

This example demonstrates that CCM can indeed detect the causal relationship between predator and prey in this system and in fact, provides extremely accurate reconstructions of both population sets. This shouldn’t come as a surprise since we knew from the start that a strong causal relationship does exist within this system. Still, I find it almost unnerving how well a job CCM manages to do here. For more info on CCM and coding CCM, see the links below:

A great tutorial of CCM by the author of the causal_ccm Python package

Rohini’s blog post (with a demo of CCM in R)

Video links teaching CCM core concepts

References:

Hadjimichael, A., Reed, P., & Quinn, J. (2020). Navigating Deeply Uncertain Tradeoffs in Harvested Predator-Prey Systems. Complexity, 2020, 1-18. https://doi.org/10.1155/2020/4170453

Sugihara, G., May, R., Ye, H., Hsieh, C. H., Deyle, E., Fogarty, M., & Munch, S. (2012). Detecting causality in complex ecosystems. science338(6106), 496-500.

Intro to PID controllers with a reservoir example

Intro to PID controllers with a reservoir example

Proportional-integral-derivative (PID) control is a method for regulating the state of physical or electronic systems. PID controllers and closely-related variants are critical to many everyday devices such as thermostats and cruise controllers as well as more complex industrial processes. In this blog post, I will give a brief introduction to PID controllers, followed by a demonstration of how to use the simple-pid package in Python to simulate releases from a reservoir using a PID controller that seeks to maintain constant (or seasonally varying) storage targets under stochastic inflows.

PID controllers are not actually used in reservoir operations very commonly (to my knowledge) due to limitations that we will come to at the end of this post. However, they are widely applied to regulate a variety of other industrial, infrastructural, and electronic systems. They are a bedrock of engineering controls methods and are very helpful to understand as a baseline of comparison for more advanced methods. I hope this post will be a helpful introduction!

A brief introduction to PID controllers

A PID controller is a type of closed-loop control system, meaning that it uses feedback to automatically regulate its internal state. This is a model-free control system which does not rely on any internal model of the underlying dynamics of the system. The PID controller regularly measures the process value, or state of the system that we are trying to control, and compares it to the set point, or desired state of the system. For example, a thermostat regularly monitors the temperature of a room (the process value) and compares it to the desired temperature set by the thermostat (the set point). The difference between these two is called the error value.

After measuring the error value, the PID controller acts on the system in order to reduce the error. For example, if the measured room temperature is colder than the set point, the thermostat will expend energy to heat the room. As the room heats up and the error value goes to zero, the thermostat should reduce its heating effort to zero as well. The PID controller scales the system input (i.e., heating energy) in response to the measured error value via three sub-controllers: proportional (P), integral (I), and derivative (D). The simplest is the P controller, which increases its input in direct proportion to the error value. This can generally reduce the error significantly, but as we will see, it often leads to a persistent steady-state error remaining in the system. The I controller, which increases its input in proportion to the integral of the error value over time, is useful for removing this persistent error. Lastly, the D controller, which increases its input in proportion to the derivative of the error value over time, can be used to predict and compensate for changing error dynamics over time. Each time step, the PID controller measures the error value, sums the contributions from the three sub-controllers, and applies the prescribed control input.

Fig. 1: A block diagram of a PID controller in a feedback loop (from Wikipedia). r(t) is the desired process value or setpoint (SP), y(t) is the measured process value (PV), and e(t) is the error value measured as the difference between PV and SP.

This iterative process is quite powerful, and is capable of regulating a broad range of behaviors depending on the tuning of the proportional constants for the P, I, and D controllers. Some applications only require a subset of the three controllers. For example, the D controller can lead to unpredictable oscillatory behavior in many systems, especially those subject to noise, and thus it is common to set the D constant to zero (also known as a PI controller).

Simulating reservoir operations with a PID controller

In the rest of this post, I will demonstrate how to use the simple-pid Python package to simulate the behavior of a regulated system. Simple-pid implements basic PID control logic, plus additional functionality to improve the robustness of the controller following the guide by Brett Beauregard. Although this package is designed to control physical systems, it can easily be embedded in simulation models as well.

I will demonstrate the functionality and tuning of the PID controller by controlling releases from a reservoir to maintain a seasonally-varying storage target under stochastic inflows. All code for this demonstration can be found in the ahamilton144/WPB-PID_controller_reservoir GitHub repository. For brevity I won’t list all of the parameter constant values (e.g., storage_0, target_base), but the interested reader can refer to this file in the repository.

The PID-driven reservoir is simulated using the Reservoir_PID class. Here is the class constructor method:

### reservoir class
class Reservoir_PID:
    '''
    Class to represent simple reservoir operations using PID controller to control reservoir releases,
    attempting to match reservoir storage to seasonally-varying target under random inflows.
    '''
    def __init__(self):
        '''
        Initialization for Reservoir_PID class.
        '''
        self.inflow = 0.
        self.inflow_rand_walk = 0.
        self.storage = storage_0
        ### initialize PID controller
        self.pid = PID(*PID_params, setpoint=target_base, sample_time=None)
        self.pid.output_limits = (0, max_release)

After initializing the inflow and storage variables, we call the PID constructor from the simple-pid package to create a PID object. The PID object is initialized with its PID parameters (the constants associated with P, I, and D control), its set point (aka the storage target), and the time interval in between updates. Because this package is designed to control real physical systems, its operations are typically enacted at regularly spaced intervals in computer time. However, by setting “sample_time=None”, we can override this behavior so that a system update occurs each time the method is called within a simulation without regard to timing.

Next, consider the inflow update method for the reservoir model:

    def update_inflow(self):
        '''
        Method for updating the inflow of the reservoir each time step. Depending on parameters (see top of page),
        this can be (a) constant inflow, (b) combination of seasonally varying sinusoids,
        (c) b plus noise in the form of a Gaussian random walk, or (d) c plus a random jump process.
        :return: None
        '''
        inflow = inflow_base
        if use_inflow_sin:
            inflow += inflow_sin_amp * sin(self.t * 2 * pi) + \
                      inflow_sin2_amp * sin(self.t * pi)
        if use_inflow_rand_walk:
            self.inflow_rand_walk += gauss(0, inflow_rand_walk_sd)
            if use_inflow_jump:
                if uniform(0,1) < inflow_jump_prob:
                    if uniform(0,1) < 0.5:
                        self.inflow_rand_walk += inflow_jump_amp
                    else:
                        self.inflow_rand_walk -= inflow_jump_amp
            inflow += self.inflow_rand_walk
        self.inflow = max(inflow, 0)

This method updates the inflow rate (in units of MAF/year) each daily time step. The inflow update depends on three parameters set by the user. The simplest case (all parameters False) is a constant flow rate. Next, if use_inflow_sin is True, the inflow varies according to a seasonal profile that combines an annual sine curve and a twice-annual sine curve. Next, if use_inflow_rand_walk is True, noise in the form of a Gaussian random walk is imposed on the seasonal signal. Lastly, if use_inflow_jump is True, additional noise in the form of a discrete jump process is added. All inflows are constrained to be non-negative.

Each time step, the reservoir is updated using the update_reservoir method:

    def update_reservoir(self, t):
        '''
        Method for updating the reservoir each time step. This involves updating the current inflow and
        the seasonally-varying target, getting the prescribed release from the PID controller, potentially
        modifying this release to ensure mass balance, and finally updating the storage.
        :param t: Total elapsed time (years) since simulation began.
        :return: None
        '''
        ### update inflow
        self.t = t
        self.update_inflow()

        ### if using variable target, assume it is sin with opposite phase of inflows
        if use_target_sin:
            self.pid.setpoint = target_base - target_sin_amp * sin(t * 2 * pi)

        ### get target release from PID
        release_target = self.pid(self.storage)

        ### update storage & release to preserve mass balance
        self.storage += (self.inflow - release_target) * dt
        if self.storage < 0:
            self.release = release_target + self.storage / dt
            self.storage = 0
        elif self.storage > max_storage:
            self.release = release_target + (self.storage - max_storage) / dt
            self.storage = max_storage
        else:
            self.release = release_target

This update first calls the update_inflow method, and then updates the storage target to follow a seasonal sine curve if the use_target_sin parameter is True. Next, it calls the PID object, which prescribes the daily release based on the error value between the current reservoir storage and target storage (as well as the historical error values in previous time steps in the case of the I and D controllers). Lastly, the reservoir storage is updated in accordance with the current inflow and release. If the PID-dictated release violates mass balance (i.e., a release that is larger than the current storage + inflow, or a release that is too small and results in the storage overtopping the reservoir capacity), it is corrected.

Now with the Reservoir_PID class defined, we can proceed to the simulation. First, we set the PID parameters corresponding to each of the P, I, and D controllers. As we will see in the next section, tuning the PID parameters is an important step in controlling system behavior. Note that the current application is an example of reverse-mode PID control, where the prescribed input (i.e., the release from the reservoir) reduces rather than increases the level of the system (i.e., the reservoir storage). For this reason, all three PID parameters should be negative or zero. Note that throughout this post, I will sometimes refer to parameters by their absolute value for clarity of exposition (e.g., so that I can say “increase the parameter from 15 to 1500” rather than “decrease from -15 to -1500”). But all parameters are negative in reality.

PID_params = -np.array([15, 100, 0])      ### example parameterization of PI controller

Next, we can run the simulation by first initializing the Reservoir_PID object and then iteratively calling its update_reservoir method. With each time step, we store the inflow, release, storage, and target storage so that they can be plotted.

### set up for simulation
ny = 6
dt = 1/365
time_steps = ny / dt
storages, inflows, releases, targets = [storage_0], [], [], []

### initialize reservoir
reservoir = Reservoir_PID()

### now run simulation, calling PID controller each time to track storage to target
for t in np.arange(0, ny+0.01, dt):
    ### update storage & enforce mass balance
    reservoir.update_reservoir(t)
    storages.append(reservoir.storage)
    releases.append(reservoir.release)
    inflows.append(reservoir.inflow)
    targets.append(reservoir.pid.setpoint)

Tuning the PID controller with constant inflows & constant target

We will now explore the role of tuning the three parameters associated with P, I, and D control, under the simplifying assumption of constant inflows (20 MAF/year) and a constant storage target (5 MAF). First, consider the case where all three are set to zero, effectively meaning that no control is taking place. In this case (Fig. 2), if the reservoir storage starts at zero, then no releases will be made initially and the reservoir will fill up as it takes in inflows. Eventually, when the reservoir runs out of space (10 MAF), it will overtop and releases will become equal to inflows in order to maintain mass-balance.

In Fig. 3, the P parameter has been turned on with a magnitude of 1. As the reservoir fills up and the error value begins to reduce, the prescribed release begins to rise. However, this rise is too slow to avoid filling and overtopping the reservoir. Once it has done so, the prescribed release is P * (SV – PV) = -1 * (5 – 10) = 5, which is less than the inflow of 20, meaning that the storage is never able to recover and remains full.

Increasing the magnitude of the P parameter to 15 leads to more satisfactory results (Fig. 4). The releases increase smoothly to the level of the inflows, but storage overshoots the target of 5 MAF and plateaus at ~6 MAF of storage. Even though the reservoir is not full, a steady-state error of ~1 MAF remains in the system. With a P parameter magnitude of 150, the approach to steady state is much faster and results in a smaller but still nonzero amount of overshoot (Fig. 5). This is a common result of proportional control: because it relies on a non-zero error value to produce a non-zero release, this generally leads to some level of overshoot and residual error in the system at equilibrium. If we continue to raise the P parameter magnitude even higher to 1500, the releases become increasingly oscillatory and erratic (Fig. 6). In general, it is advisable to set the P parameter at or just below the level where small oscillations begin to occur.

So how to avoid or remove the steady-state residual error caused by proportional control without causing erratic oscillations? This is where the integral (I) control parameter comes in. By conditioning the release on the integral of the error value over time rather than its instantaneous value, this term is able to reduce the error value to smoothly to zero in many systems (at which point the error value’s integral reaches steady state). This has the effect of removing the overshoot error caused by proportional control, with the speed of removal depending on the magnitude of the I parameter (Figs. 7-8). As with the P parameter, setting the I parameter too large can lead to oscillatory behavior (Fig. 9).

Finally, the D parameter adds a release term that is proportional to the derivative of the error value. The purpose of this term is to predict and counteract any changing dynamics in the error value over time. Unfortunately, D control has a tendency to add unpredictable oscillatory behavior in many systems, especially those characterized by noisy measurement errors or forcings, and for this reason the D parameter is commonly set to zero. As seen below, this is the case for our reservoir example, despite its simple nature and lack of noise. With a very small derivative parameter of magnitude 0.001 (Fig. 10), D control has little impact on the system compared to Fig. 8, except for a slight reduction in the maximum release and an extension of the time to come to equilibrium. When the parameter is increased in magnitude to 0.0025, extreme oscillations appear in the target releases, although the storage trajectory is relatively unharmed (Fig. 11). Increasing the parameter magnitude further to 0.005 leads to an ineffective control system that struggles to reach and maintain the storage target (Fig. 12). For the rest of this post, the D parameter is set to 0.

Assessing performance under variable inflows & storage targets

So far we have considered the very simple case of a reservoir seeking to maintain a constant storage target under constant inflows. But how does the PID controller perform under more challenging conditions? In what follows, the PID parameters are set to (-150, -100000, 0) based on the previous analysis. This parameterization leads to minimal overshoot and a fast return to the target storage under constant conditions.

We can now test the performance under four increasingly complex conditions:

  1. Seasonally-varying inflow pattern
  2. Condition 1, plus seasonally-varying storage target
  3. Condition 2, plus noise added to the inflows (Gaussian random walk process)
  4. Condition 3, plus additional noise added to the inflows (random jump process)

The PID controller is found to very accurately track the storage target over time in each condition, even in the face of seasonally varying storage targets and seasonally varying and noisy inflows (Figs. 13-16).

Limitations of PID control for reservoir operations

In practice, reservoirs are not operated to perfectly track a storage target. In fact, the purpose of the storage target is, in large part, to allow for deviations from it. For example, the storage target is often lowered during flood-prone seasons in order to allow space for the reservoir to fill up and store flood waters during high-flow periods. A control rule that immediately releases this water downstream to quickly return the reservoir to its storage target will largely eliminate the flood control benefits of the reservoir. Instead, smaller releases and a slower return to the storage target are preferred in order to minimize flooding downstream.

For this reason, a lower-magnitude PID parameterization may be preferred. For example, Figs. 17-19 show the performance of a (-15, -100, 0) parameterization under three different inflow scenarios. The releases automatically adjust over time in response to stochastic inflows and the seasonal storage target. Reservoir storage roughly follows the seasonal pattern of the target, but with a bit more flexibility to deviate from the target by temporarily filling up during high-flow periods and drawing down during low-flow periods.

Although this leads to more realistic storage and release patterns under stochastic inflows, the basic PID controller remains rather poorly suited to reservoir operations for at least two major reasons. First, with its singular focus on storage targets, it is challenging to balance tradeoffs between conflicting objectives such as minimizing storage target deviations vs. flood damages vs. release variability. Although the analyst can consider these factors indirectly when tuning the PID parameters, it is challenging to more directly address non-storage objectives without major changes to the PID controller formulation. Second, the basic PID controller is unable to incorporate forecasts or other sources of information beyond the current storage level. Although it may be possible to incorporate this information into a moving storage target, other methods such as Stochastic Dynamic Programming, Model Predictive Control, and Evolutionary Multi-Objective Direct Policy Search may be better suited to this task.