Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Extending functionality with custom parameters

Parameters are a core part of Pywr, allowing you to define how your model behaves. While Pywr comes with a wide range of built-in parameters, you may find that you need to create custom parameters to suit your specific modelling needs. This guide will walk you through the process of creating custom parameters in Pywr.

Currently, Pywr supports custom parameters that are defined in Python. If your parameter is general enough, you may want to consider contributing it to the Pywr project. If you do, please see the Developers Guide for more information on how to do this.

Python functions

The simplest way to create a custom parameter is to define a Python function. This function should accept at least one argument, which is a ParameterInfo object. This object contains information from the model, such as the current time step, scenario index, and any metric values that have been requested. Additional arguments can also be passed to the function.

Here is an example of a simple custom parameter that returns the current time step:

# custom_parameters.py
from pywr import ParameterInfo


def current_time_step(info: ParameterInfo) -> float:
    """Return the current time step."""
    return info.timestep.index

To use this custom parameter in your model it must be defined as a Parameter in your model's JSON file. Below is an example of how to define the current_time_step parameter in your model's JSON file. The source field specifies the path to the Python file containing the function, and the object field specifies the name of the function to call.

{
  "parameters": [
    {
      "meta": {
        "name": "current-time-step"
      },
      "type": "Python",
      "source": {
        "type": "Path",
        "path": "custom_parameters.py"
      },
      "object": {
        "type": "Function",
        "class": "current_time_step"
      },
      "args": [],
      "kwargs": {}
    }
  ]
}

Constant arguments

In reality, your function will likely need to accept additional arguments. These arguments might be constants that change the behaviour of the function, but do not change over time or are a result of the model's simulation state. In this case they can be defined as args or kwargs in the parameter definition. Only simple types that are supported by JSON can be used as arguments, such as strings, numbers, and booleans. However, by parameterising these values, you can easily change them without modifying the Python code or reuse the same function with different values in different parts of the model.

# custom_parameters.py
from pywr import ParameterInfo


def current_timestep(info: ParameterInfo, a: float, b: float, some_condition: str = "foo") -> float:
    """Return the current time step."""
    match some_condition:
        case "foo":
            return info.timestep.index + a
        case "bar":
            return info.timestep.index + b
        case _:
            raise ValueError(f"Invalid condition: {some_condition}")

To pass these arguments to the function, you can define them in the model's JSON file as follows:

{
  "parameters": [
    {
      "meta": {
        "name": "current-time-step"
      },
      "type": "Python",
      "source": {
        "type": "Path",
        "path": "custom_parameters.py"
      },
      "object": {
        "type": "Function",
        "class": "current_timestep"
      },
      "args": [
        1.0,
        2.0
      ],
      "kwargs": {
        "some_condition": "foo"
      }
    }
  ]
}

Metrics from the model

More complex parameters will need information from the model, such as the current volume of a reservoir, or the value of another parameter, etc. These values need to be requested in the JSON definition of parameter, and then they can be accessed in the function using the ParameterInfo object.

# custom_parameters.py
from pywr import ParameterInfo


def factor_volume(info: ParameterInfo, factor: float) -> float:
    """Return the current volume of a reservoir scaled by `factor`."""
    volume = info.get_metric("volume")
    return factor * volume

The JSON definition of the parameter needs to include a metrics and/or indices field that specifies which model metrics to request. Both fields are a dictionary where the keys are the keys used to retrieve the values from the ParameterInfo object, and the values specify the metric to retrieve. Metrics are accessed using get_metric(key), and indices are accessed using get_index(key).

{
  "parameters": [
    {
      "meta": {
        "name": "factor-volume"
      },
      "type": "Python",
      "source": {
        "type": "Path",
        "path": "custom_parameters.py"
      },
      "object": {
        "type": "Function",
        "class": "factor_volume"
      },
      "args": [
        2.0
      ],
      "metrics": {
        "volume": {
          "type": "Node",
          "name": "a-reservoir",
          "attribute": "Volume"
        }
      }
    }
  ]
}

Python classes & stateful parameters

If your parameter needs to maintain state between calls, you can define it as a Python class. This class should implement an __init__ method that setups up the parameter, including any initial state. The __init__ method is passed the args and kwargs defined in the JSON file. Pywr will create an instance of the class for every scenario in a simulation. These instances will be reused for each time step in the scenario, allowing you to maintain state across time steps.

Note: Unlike Pywr v1.x a separate instance of the class is created for each scenario. This means you do not have to worry about state being shared between scenarios, and do not need to implement state for each scenario yourself.

The class should also implement calc method, which is called for each time step in the scenario. This method should accept a ParameterInfo object as its only argument.

Finally, the class may also implement an after method, which is called after the resource allocation has been completed for the time step. This method can be used to perform any final calculations or updates to the parameter state.

Here is an example of a simple stateful parameter that counts the number of time steps:

# custom_parameters.py
from pywr import ParameterInfo


class TimeStepCounter:
    """A parameter that counts the number of time steps."""

    def __init__(self, initial_value: int = 0):
        self.count = initial_value

    def calc(self, _info: ParameterInfo) -> float:
        """Return the current time step count."""
        # Note that `_info` is not used, but it is required by the interface.
        self.count += 1
        return self.count


To use this custom parameter in your model, you can define it in the JSON file as follows:

{
  "parameters": [
    {
      "meta": {
        "name": "time-step-counter"
      },
      "type": "Python",
      "source": {
        "type": "Path",
        "path": "custom_parameters.py"
      },
      "object": {
        "type": "Class",
        "class": "TimeStepCounter"
      },
      "args": [
        0
      ],
      "kwargs": {}
    }
  ]
}

Using modules instead of files

It might be more convenient to define your custom parameters in a Python module instead of a file. This allows you to integrate your custom parameters with other Python code, such as unit tests or other utility functions. To do this, you can use the source field to specify the module name instead of a file path.

Here is an example of how to define a custom parameter in a module (in this case my_model.parameters):

{
  "parameters": [
    {
      "meta": {
        "name": "current-time-step"
      },
      "type": "Python",
      "source": {
        "type": "Module",
        "module": "my_model.parameters"
      },
      "object": {
        "type": "Function",
        "class": "current_time_step"
      },
      "args": [],
      "kwargs": {}
    }
  ]
}

Returning integers or multiple values

In the examples above the custom parameter functions return a single floating point value. However, you can also return integers or multiple values. In the JSON definition of the parameter, you can specify the return_type field to indicate the type of value the function will return. To return an integer, you can set the return_type to "Int". To return multiple values, you can set the return_type to "Dict" and the function should return a dictionary where the keys are the names of the values and the values are the values themselves.

An example of a custom parameter that returns multiple values is shown below:

# custom_parameters.py
from pywr import ParameterInfo


def multiple_values(info: ParameterInfo, factor: float) -> dict:
    """Return multiple values."""
    return {
        "value1": info.timestep.index,
        "value2": info.get_metric("volume") * factor
    }

The corresponding JSON for this parameter would look like this:

{
  "parameters": [
    {
      "meta": {
        "name": "multiple_values"
      },
      "type": "Python",
      "source": {
        "type": "Module",
        "module": "my_model.parameters"
      },
      "object": {
        "type": "Function",
        "class": "multiple_values"
      },
      "return_type": "Dict",
      "args": [
        2.0
      ],
      "metrics": {
        "volume": {
          "type": "Node",
          "name": "a-reservoir",
          "attribute": "Volume"
        }
      }
    }
  ]
}

And the returned values can be accessed in the model using the keys defined in the dictionary.

{
  "type": "Parameter",
  "name": "multiple_values",
  "key": "value1"
  // or "value2" 
}

Cython (and other compiled languages)

Cython functions and classes can be used in Pywr as long as they accessible from Python, and can be imported by Pywr at runtime. In this case using a module for locating the custom parameter is recommended. Otherwise, there is no difference in how you define the custom parameter in the model's JSON file.

Other compiled languages can also be used, but you will need to ensure that the compiled code is accessible from Python. This can be done by using a Python wrapper around the compiled code, or by using a foreign function interface (FFI) such as ctypes or cffi.