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

Introduction

Welcome to the Pywr book! Pywr is an open-source network resource allocation model. This book is a collection of documentation and tutorials for using Pywr. It should be read alongside the Pywr API documentation.

What is Pywr?

Pywr is a Rust crate and Python package for building and running water resource models. It allows users to construct models of water systems using a network of nodes and links, and other data. The model can then be used to simulate the operation of a water system, and to evaluate the performance of the system under different scenarios.

This version is a major update to the original Pywr model, which was written in Python and Cython. The new version is written in Rust, and uses Python bindings to expose the functionality to Python.

Installation

Pywr is both a Rust library and a Python package.

Rust

TBC

Python

Pywr requires Python 3.10 or later. It is currently available on PyPI as a pre-release.

Note: That current Pywr v2.x is in pre-release and may not be suitable for production use. If you require Pywr v1.x please use pip install pywr<2.

Installing from PyPI (pre-release)

Using pip and venv

It is recommended to install Pywr into a virtual environment.

python -m venv .venv
source .venv/bin/activate  # On Windows use `.venv\Scripts\activate`
pip install pywr --pre

Using uv

Alternatively, you can use uv to create and manage virtual environments:

uv init my-project
cd my-project
uv add pywr --pre

Installing from a wheel

Alternatively, wheels are available from the GitHub actions page. Navigate to the latest successful build, and download the archive and extract the wheel for your platform.

pip install pywr-2.0.0b0-cp310-abi3-win_amd64.whl

Checking the installation

To verify the installation, run to see the command line help:

python -m pywr --help

Running a model

Pywr is a modelling system for simulating water resources systems. Models are defined using a JSON schema, and can be run using the pywr command line tool. Below is an example of a simple model definition simple1.json:

{
  "metadata": {
    "title": "Simple 1",
    "description": "A very simple example.",
    "minimum_version": "0.1"
  },
  "time": {
    "start": "2015-01-01",
    "end": "2015-12-31",
    "timestep": {
      "type": "Days",
      "days": 1
    }
  },
  "network": {
    "nodes": [
      {
        "meta": {
          "name": "supply1"
        },
        "type": "Input",
        "max_flow": {
          "type": "Literal",
          "value": 15.0
        }
      },
      {
        "meta": {
          "name": "link1"
        },
        "type": "Link"
      },
      {
        "meta": {
          "name": "demand1"
        },
        "type": "Output",
        "max_flow": {
          "type": "Parameter",
          "name": "demand"
        },
        "cost": {
          "type": "Literal",
          "value": -10
        }
      }
    ],
    "edges": [
      {
        "from_node": "supply1",
        "to_node": "link1"
      },
      {
        "from_node": "link1",
        "to_node": "demand1"
      }
    ],
    "parameters": [
      {
        "meta": {
          "name": "demand"
        },
        "type": "Constant",
        "value": {
          "type": "Literal",
          "value": 10.0
        }
      }
    ],
    "metric_sets": [
      {
        "meta": {"name": "all"},
        "filters": {
          "all_nodes": true,
          "all_virtual_nodes": true,
          "all_parameters": true,
          "all_edges": true
        }
      }
    ],
    "outputs": [
      {
        "meta": {"name": "all"},
        "type": "Memory",
        "metric_set": "all"
      },
      {
        "meta": {"name": "all-hdf5"},
        "type": "HDF5",
        "filename": "outputs.h5",
        "metric_set": "all"
      }
    ]
  }
}

To run the model, use the pywr command line tool:

python -m pywr run simple1.json

Related projects

Core concepts

The network

Parameters

Penalty costs

Reservoirs

Abstraction licences

Scenarios

Pywr has built-in support for running multiple scenarios. Scenarios are a way to define different sets of input data or parameters that can be used to run a model. This is often useful for running sensitivity analysis, stochastic hydrological data, or climate change scenarios. Pywr's scenario system is used to define a set of simulations that are, by default, all run in together. This requires that all scenarios simulate the same time period and have the same time step. However, it means that Pywr can take advantage of efficiencies by running through the same time-domain once. For example, the majority of the data required for the model can be loaded once and then shared between the scenarios. Pywr v2.x system is more flexible and crucially allows for scenarios to be run in parallel without the need for multiprocessing (which duplicates memory usage).

In this section, we will cover how to define scenarios in Pywr and how to run them.

Defining Scenarios

Scenarios are defined in the scenarios section of the model configuration file. A model can have multiple scenario groups, each defining a set of scenarios. By default, Pywr will run the full combination of all scenarios in all groups. If no scenarios are defined, Pywr will run a single scenario.

The simplest scenario definition contains a groups list of a single scenario group with a name and size. The following example defines such scenario domain with a single group containing 5 scenarios. If this Pywr model is run, it will run 5 scenarios.

{
  "groups": [
    {
      "name": "Scenario A",
      "size": 5
    }
  ]
}

By default, the scenarios in a group will be given a numeric label starting from 0. However, it is possible to define a labels list to give scenarios more meaningful names. The following example defines a scenario group with 5 scenarios using Roman numerals as labels.

{
  "groups": [
    {
      "name": "Scenario A",
      "size": 5,
      "labels": [
        "I",
        "II",
        "III",
        "IV",
        "V"
      ]
    }
  ]
}

Additional scenario groups can be defined by adding them to the groups list. The following example groups, "A" and "B", with sizes 5 and 3 respectively. This domain would create 15 simulations.

{
  "groups": [
    {
      "name": "Scenario A",
      "size": 5,
      "labels": [
        "I",
        "II",
        "III",
        "IV",
        "V"
      ]
    },
    {
      "name": "Scenario B",
      "size": 3
    }
  ]
}

Running subsets of scenarios

It is often useful to run only a subset of the scenarios defined in a model. This can be done by either specifying the specific scenarios in each group to run, or by providing specific combinations of scenarios to run.

Note: These approaches are mutually exclusive.

Subsetting groups

To run only a subset of scenarios in a group, the subset key can be used. The following examples shows three groups, each with 5 scenarios. The subset key is used to specify the scenarios to run in each group. The first group is subset using the scenario group's labels, the second group is subset using the scenario group's indices, and the third group is subset using a slice. In all cases the subset will mean the 2nd, 3rd and 4th scenarios are run. This will result in 9 (3 x 3 x 3) simulations using the product of the subsets.

Note: The indices and slice are zero-based.

{
  "groups": [
    {
      "name": "Scenario A",
      "size": 5,
      "labels": [
        "I",
        "II",
        "III",
        "IV",
        "V"
      ],
      "subset": {
        "type": "Labels",
        "labels": [
          "II",
          "III",
          "IV"
        ]
      }
    },
    {
      "name": "Scenario B",
      "size": 5,
      "subset": {
        "type": "Indices",
        "indices": [
          1,
          2,
          3
        ]
      }
    },
    {
      "name": "Scenario C",
      "size": 5,
      "subset": {
        "type": "Slice",
        "start": 1,
        "end": 4
      }
    }
  ]
}

Specifying specific combinations

To run specific combinations of scenarios, the combinations key can be used. The following examples shows three groups, each with 5 scenarios. The combinations key is used to specify the exact scenarios to run. Each combination is a list of scenario indices to run. The example shows that the 1st, 3rd and 5th scenarios in each group are run. This will result in 3 simulations.

{
  "groups": [
    {
      "name": "Scenario A",
      "size": 5,
      "labels": [
        "I",
        "II",
        "III",
        "IV",
        "V"
      ]
    },
    {
      "name": "Scenario B",
      "size": 5
    },
    {
      "name": "Scenario C",
      "size": 5
    }
  ],
  "combinations": [
    [
      "I",
      0,
      0
    ],
    [
      "III",
      2,
      2
    ],
    [
      "V",
      4,
      4
    ]
  ]
}

Input data

External data

Providing data to your Pywr model is essential. While some information can be encoded as constants or other values in the JSON, most real-world models require external data, such as time series or lookup tables. Pywr supports loading data from CSV files using data tables, which can provide both scalar and array values to parameters and nodes. Data tables allow flexible lookup schemes, including row-based, column-based, and combined row/column indexing.

Scalar Data Tables

Scalar data tables provide single constant values indexed by rows and/or columns. Using a data table might allow you to avoid hardcoding values in your model JSON, making it easier to update and manage. For example, you might have a data table that provides asset capacities, and separate table for asset costs. By swapping out the CSV files, you can easily change the model's parameters without modifying the JSON. However, this can make the model less transparent, as the values are not directly visible in the JSON.

Note: Currently, Pywr supports up to 4 keys for scalar data tables. This means you can have up to 4 row indices, or a combination of row and column indices that total 4.

Row-based scalar data tables

Row-based scalar data tables use the row index to look up values. This is useful when you have a list of assets or parameters, and you want to assign a specific value to each one. For example, consider the following CSV file, " tbl-scalar-row.csv":

key,value
A,1.0
B,2.0
C,3.0

This table has two columns: key and value. The key column contains the row index, which can be any string or number. The value column contains the corresponding value for each key. To use this table in your model, you would define a table in your JSON, and then reference it in a parameter, node, etc. For example, to load the above table define the following in your model JSON:

{
  "meta": {
    "name": "scalar-row"
  },
  "type": "Scalar",
  "format": "CSV",
  "lookup": {
    "type": "Row",
    "cols": 1
  },
  "url": "tbl-scalar-row.csv"
}

The JSON snippet above defines a table named scalar-row that loads data from the CSV file. It specifies that the table contains a single row index, and that the table is expected to return a single scalar value. The actual header values in the CSV file are not important, as long as the first column is used for the row index and the second column contains the values. The table assumes that the first row contains the header, and the data starts from the second row.

Once the table is defined, you can reference it in a parameter. For example, to use the scalar-row table to provide a value for a ConstantParameter, you would reference it for the value field. A table reference like this can be used anywhere a Metric or ConstantValue is expected.

{
  "meta": {
    "name": "my-constant-C"
  },
  "type": "Constant",
  "value": {
    "type": "Table",
    "table": "scalar-row",
    "row": "C"
  }
}

It can be useful to organise the data in a table with multiple keys. For example, you might have a table that provides different data for different assets. In this case, you can use one key for the asset and one key for the data type. For example, consider the following CSV file, "tbl-scalar-row-row.csv":

key1,key2,value
A,X,10.0
A,Y,11.0
B,X,20.0
B,Y,21.0

To use a value from this table in the model, it can be referenced in a similar way to the single-key table, but you need to provide both keys:

{
  "meta": {
    "name": "my-constant-A-Y"
  },
  "type": "Constant",
  "value": {
    "type": "Table",
    "table": "scalar-row-row",
    "row": [
      "A",
      "Y"
    ]
  }
}

Column-based scalar data tables

Alternatively a column-based scalar data table can be used. Column-based scalar data tables use the column header to look up values. For example, consider the following CSV file, "tbl-scalar-col.csv":

A,B,C
1.0,2.0,3.0

This is similar to the row-based table, but the column headers are used as the keys. To use this table in your model, you would define a table in your JSON, and then reference it in a parameter, node, etc. For example, to load the above table define the following in your model JSON

{
  "meta": {
    "name": "scalar-col"
  },
  "type": "Scalar",
  "format": "CSV",
  "lookup": {
    "type": "Col",
    "rows": 1
  },
  "url": "tbl-scalar-col.csv"
}

When referencing a column-based table, you need to provide the column key. For example, to use the scalar-col table to provide a value for a ConstantParameter, you would reference it for the value field, and provide the column key.

{
  "meta": {
    "name": "my-constant-B"
  },
  "type": "Constant",
  "value": {
    "type": "Table",
    "table": "scalar-col",
    "row": "B"
  }
}

Row & column-based scalar data tables

Row & column-based scalar data tables use both row and column indices to look up values. This is useful when you have a matrix of values, and you want to assign a specific value to each combination of row and column. For example, consider the following CSV file, "tbl-scalar-row-col.csv":

key,🦀,🐍
A,1.0,2.0
B,3.0,4.0

To use this table in your model, you would define a table in your JSON, and then reference it in a parameter, node, etc. For example, to load the above table define the following in your model JSON:

{
  "meta": {
    "name": "scalar-row-col"
  },
  "type": "Scalar",
  "format": "CSV",
  "lookup": {
    "type": "Both",
    "rows": 1,
    "cols": 1
  },
  "url": "tbl-scalar-row-col.csv"
}

When referencing a row & column-based table, you need to provide both the row and column keys. For example, to use the scalar-row-col table to provide a value for a ConstantParameter, you would reference it for the value field, and provide both the row and column keys.

Note: This example uses an emoji (🐍) as a column key. While this is valid, it may cause issues with some software and libraries, and must be encoded correctly in the JSON (as shown).

{
  "meta": {
    "name": "my-constant-A-python"
  },
  "type": "Constant",
  "value": {
    "type": "Table",
    "table": "scalar-row-col",
    "row": "A",
    "column": "\uD83D\uDC0D"
  }
}

Array Data Tables

Array data tables provide array values indexed by rows or columns. This is useful for certain types of parameters, such as monthly or daily profiles, which require an array of values. The following example shows how to define an array data table in CSV format with a single row index.

Note: Currently, Pywr supports up to 4 keys for array data tables. This means you can have up to 4 row or column indices.

month,1,2,3,4,5,6,7,8,9,10,11,12
profile1,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5
profile2,0.6,0.7,0.75,0.8,0.75,0.7,0.7,0.6,0.6,0.6,0.6,0.6
profile3,0.7,0.8,0.9,0.9,0.9,0.8,0.8,0.75,0.7,0.7,0.65,0.65

To use this table in your model use "type": "Array" in the table definition in your JSON, as shown below.

{
  "meta": {
    "name": "array-row"
  },
  "type": "Array",
  "format": "CSV",
  "lookup": {
    "type": "Row",
    "cols": 1
  },
  "url": "tbl-array-row.csv"
}

The same data can be formatted with a column index instead of a row index, as shown below.

month,profile1,profile2,profile3
1,0.5,0.6,0.7
2,0.5,0.7,0.8
3,0.5,0.75,0.9
4,0.5,0.8,0.9
5,0.5,0.75,0.9
6,0.5,0.7,0.8
7,0.5,0.7,0.8
8,0.5,0.6,0.75
9,0.5,0.6,0.7
10,0.5,0.6,0.7
11,0.5,0.6,0.65
12,0.5,0.6,0.65

And the corresponding table definition in JSON:

{
  "meta": {
    "name": "array-col"
  },
  "type": "Array",
  "format": "CSV",
  "lookup": {
    "type": "Col",
    "rows": 1
  },
  "url": "tbl-array-col.csv"
}

Time Series Data

Time series provide values that vary over a model's time domain. Define input datasets in network.time_series, then reference them from a parameter, node attribute, or any other field that accepts a metric. A dataset has a unique meta.name, a type, and (except for a placeholder) a path.

For example, the following CSV has an ISO 8601 date column and one data column:

date,inflow
2021-01-01,12.5
2021-01-02,11.2
2021-01-03,10.8

Define it with the native Arrow provider:

{
  "meta": {"name": "inflow-data"},
  "type": "Arrow",
  "path": "inflow.csv",
  "time_col": "date"
}

Then reference the inflow column where a metric is expected:

{
  "type": "TimeSeries",
  "name": "inflow-data",
  "columns": {
    "type": "Column",
    "name": "inflow"
  }
}

Relative paths are resolved against the model's data path. Absolute paths are used as given. All path-based providers can also specify a checksum to verify input data before it is loaded:

"checksum": {
  "type": "SHA256",
  "hash": "<sha256 digest>"
}

Time columns and alignment

Set time_col to the name of the column containing timestamps. If it is omitted, Pywr only infers a time column when the first column has an Arrow temporal type. Specifying it explicitly is recommended.

The time column is not a data column. A dataset with a time column and one data column can be referenced directly. For a dataset with multiple data columns, select a named column as above, or use its columns as scenario values:

{
  "type": "TimeSeries",
  "name": "inflow-data",
  "columns": {
    "type": "Scenario",
    "name": "inflow-scenarios"
  }
}

In the scenario form, each non-time-series column supplies values for a scenario in the named scenario group.

Pywr checks that the time values exactly contain the model time domain as a contiguous sequence. It can select the matching portion of a longer input series, but it does not resample, interpolate, aggregate, fill gaps, or otherwise change the input time resolution. Time values must be non-null and must not be repeated. Ensure that the data has already been prepared at the same timestep frequency and timestamps as the model.

Note: This differs from Pywr v1.x, which automatically resampled data while loading it. Resampling is now the responsibility of the model author or their data-preparation workflow.

Available providers and formats

The provider type values and the optional Arrow format values are case-sensitive.

Arrow

"type": "Arrow" is the native Rust loader and does not require Python. It supports:

Formatformat valueRecognised extension when format is omitted
CSV"CSV".csv
Arrow IPC file"IPC".ipc or .arrow

Arrow CSV files must include a header row. Use ISO 8601 values for dates and timestamps so that the Arrow CSV reader can infer temporal columns, for example 2021-01-01 or 2021-01-01T00:00:00. Set format when the filename has a non-standard extension.

{
  "meta": {"name": "hourly-inflow"},
  "type": "Arrow",
  "path": "inflow.ipc",
  "format": "IPC",
  "time_col": "timestamp"
}

Parquet

"type": "Parquet" is also a native Rust loader and does not require Python. It reads Apache Parquet files through the Arrow/Parquet reader. There is no format field because the provider always reads Parquet.

{
  "meta": {"name": "inflow-data"},
  "type": "Parquet",
  "path": "inflow.parquet",
  "time_col": "date"
}

Pandas

"type": "Pandas" uses a callback to the Python environment. It requires a Pywr build with Python support, plus the Python packages pandas and pyarrow in the Python environment used to run Pywr. The built-in loader supports .csv, .xlsx, and .h5 files, using the appropriate Pandas reader. kwargs are passed to that reader. Pywr supplies "parse_dates": true to the CSV and XLSX read functions unless it is explicitly provided.

{
  "meta": {"name": "inflow-data"},
  "type": "Pandas",
  "path": "inflow.xlsx",
  "time_col": "date",
  "kwargs": {"sheet_name": "inflow"}
}

Polars

"type": "Polars" also uses the Python environment. It requires a Python-enabled Pywr build, and the Python packages polars and pyarrow. The built-in loader supports .csv, .parquet, and .json files. Its kwargs are forwarded to the selected Polars reader. Pywr supplies "try_parse_dates": true to the CSV read function unless it is explicitly provided.

{
  "meta": {"name": "inflow-data"},
  "type": "Polars",
  "path": "inflow.json",
  "time_col": "date"
}

Custom Python loader

"type": "Python" invokes a function you provide. It requires a Python-enabled Pywr build and the pyarrow package. The function receives the resolved path and time_col as positional arguments, receives kwargs as keyword arguments, and must return a PyArrow RecordBatch. This is useful for formats or preprocessing that are not covered by the built-in providers.

{
  "meta": {"name": "inflow-data"},
  "type": "Python",
  "module": "my_project.time_series",
  "function": "load_inflow",
  "path": "inflow.custom",
  "time_col": "date",
  "kwargs": {"source_timezone": "UTC"}
}

Placeholder

"type": "Placeholder" reserves a time-series name for model composition. It has no path and cannot load data by itself; replace it with a concrete provider when merging models.

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 read-only object exposes the current timestep, scenario_index, and any declared metric and index dependencies. Additional arguments can also be passed to the function. Functions are evaluated in the before phase, before resource allocation.

info.timestep provides properties such as index, date, day, month, year, days, and is_first. info.scenario_index provides simulation_id and simulation_indices. Declared floating-point metrics and unsigned integer indices can be read using info.get_metric(key) and info.get_index(key) respectively. Requesting an undeclared key raises KeyError.

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",
        "function": "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 result from the model's simulation state. In this case they can be defined as args or kwargs in the parameter definition. Any JSON value can be used, including null, arrays, and objects; these are passed to Python as None, lists, and dictionaries. Non-JSON Python objects cannot be supplied this way. 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",
        "function": "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 dependencies to request. Both fields are dictionaries: their keys are used to retrieve the values from the ParameterInfo object, and their values specify the metric or index to retrieve. metrics provide floating-point values accessed using get_metric(key), while indices provide unsigned integer values accessed using get_index(key).

{
  "parameters": [
    {
      "meta": {
        "name": "factor-volume"
      },
      "type": "Python",
      "source": {
        "type": "Path",
        "path": "custom_parameters.py"
      },
      "object": {
        "type": "Function",
        "function": "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 sets 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 must implement at least one of before or after. These methods accept a ParameterInfo object as their only argument and should return the parameter's configured value type. before is called before resource allocation, and after is called after allocation. A class can implement either method or both.

For a parameter that needs only a post-allocation state update, a class with before may instead implement after_hook. It is called after allocation, must return None, and cannot be used together with after.

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 before(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 float(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",
        "function": "current_time_step"
      },
      "args": [],
      "kwargs": {}
    }
  ]
}

Returning integers or multiple values

In the examples above the custom parameter functions return a single floating point value. You can also return unsigned integers or multiple values by specifying return_type. "Int" creates an unsigned integer (index) parameter, so the returned value must be a non-negative integer that fits in a 64-bit unsigned integer.

Set return_type to "Dict" to return multiple named values. Each dictionary value must be a Python float or a non-negative Python int: floats become named floating-point values and integers become named unsigned index values. Other value types, including negative integers, are not supported.

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[str, float | int]:
    """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",
        "function": "multiple_values"
      },
      "return_type": "Dict",
      "args": [
        2.0
      ],
      "metrics": {
        "volume": {
          "type": "Node",
          "name": "a-reservoir",
          "attribute": "Volume"
        }
      }
    }
  ]
}

The returned values can be accessed in the model using the keys defined in the dictionary. For example:

{
  "type": "Parameter",
  "name": "multiple_values",
  "key": "value1"
}

or

{
  "type": "Parameter",
  "name": "multiple_values",
  "key": "value2"
}

Before and after methods

The majority of parameters will only need to implement the before method, which is called before the resource allocation is performed for the time step1. However, in some cases it may be necessary to perform some calculations after allocation, such as accessing allocated flow or new reservoir volume. A class parameter can implement an after method to return a post-allocation parameter value, or after_hook to update state without returning a value.

The example below lists a custom parameter that implements both before and after methods. It is a simple crop water requirement parameter that calculates the water requirement for a crop based on the current month in before, and then computes a crop yield in after based on the allocated water and the water requirement.

from pathlib import Path


class CropParameter:
    """A simple example of a crop parameter.

    It produces an irrigation requirement value based on the month during `before`. This
    is intended to be used as a demand (or "max_flow") on an irrigation node. The `after`
    method tracks any deficit in irrigation supplied, and at the end of the growing season
    returns the yield for that season.
    """

    def __init__(self):
        self.crop_yield = 0.0
        # Example irrigation requirements by month
        self.irrigation_requirements = {
            1: 0.0,  # January
            2: 0.0,  # February
            3: 10.0,  # March
            4: 20.0,  # April
            5: 30.0,  # May
            6: 40.0,  # June
            7: 30.0,  # July
            8: 20.0,  # August
            9: 10.0,  # September
            10: 0.0,  # October
            11: 0.0,  # November
            12: 0.0,  # December
        }
        self.growing_season_months = {3, 4, 5, 6, 7, 8, 9}

    def before(self, info) -> float:
        """Return the irrigation requirement for the current month."""
        return self.irrigation_requirements.get(info.timestep.month, 0.0)

    def after(self, info) -> float:
        """Track the yield based on irrigation supplied."""

        irrigation_required = self.irrigation_requirements.get(info.timestep.month, 0.0)
        irrigation_supplied = info.get_metric("supplied")
        deficit = irrigation_required - irrigation_supplied

        if info.timestep.month not in self.growing_season_months:
            # Reset yield at the end of the growing season
            if info.timestep.month == 10 and info.timestep.day == 1:
                final_crop_yield = self.crop_yield
                self.crop_yield = 0.0
                return final_crop_yield
        else:
            # Implement a simple crop growth/yield model based on irrigation deficit
            self.crop_yield += max(0.0, 1.0 - (deficit / irrigation_required))

        return 0.0  # Yield is only returned at the end of the season


def run(model_path: Path):
    from pywr import ModelSchema

    schema = ModelSchema.from_path(model_path)
    model = schema.build(model_path.parent, None)
    model.run("clp")
    print("Model run complete!")


if __name__ == "__main__":
    pth = Path(__file__).parent / "model.json"
    run(pth)

When referring to a parameter in the model, the return_value field on the parameter reference (not on the Python parameter definition) selects the calculation phase. The default is "Before"; use "After" to consume the value returned by after. The other supported reference values are "AfterOrElseInitial" and "Both". The selected phase must be compatible with the parameter implementation. The example below uses the CropParameter above in a metric set and selects its after value.

    "metric_sets": [
      {
        "meta": { "name": "parameters" },
        "aggregator": {
          "freq": {
            "type": "Annual"
          },
          "func": {
            "type": "Sum"
          }
        },
        "metrics": [
          {
            "type": "Parameter",
            "name": "crop1",
            "return_value": "After"
          }
        ]
      }
    ],

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.


  1. This also is the same as Pywr v1.x where the before method was the only method that could be implemented. ↩

River routing and attenuation

To account for flow attenuation and travel time in river reaches, Pywr includes a number of routing methods. Currently available methods are:

  • Delay: Delay flow by a fixed number of time-steps
  • Muskingum: Muskingum routing method

Delay routing

The delay routing method simply delays flow by a fixed number of time-steps. This can be implemented using either DelayNode or RiverNode with a routing method of delay. Internally, the delay is implemented using a DelayParameter which simply stores the flow values in a queue and returns the value from the appropriate time-step in the past. The delay must be at least one time-step.

Muskingum routing

The Muskingum routing method is a widely used hydrological method for simulating the movement of flood waves through river channels. It is based on the principle of conservation of mass and momentum, and it uses a simple linear relationship to describe the storage and flow in a river reach. The implementation in Pywr is based on the following equation:

\[ O_t = \left(\frac{\Delta t - 2KX}{2K(1-X) + \Delta t}\right)I_t + \left(\frac{\Delta t + 2KX}{2K(1-X)+\Delta t}\right)I_{t-1} + \left(\frac{2K(1-X)-\Delta t}{2K(1-X)+\Delta t}\right)O_{t-1} \]

This relates the outflow of the reach at time t \( (O_t) \) to the inflow at time t \( (I_t) \) and the inflow and outflow at the previous time step (\( I_{t-1} \) and \( O_{t-1} \) respectively). This is implemented in Pywr using a MuskingumParameter which uses the above equation to calculate the factors in an equality constraint of the form:

\[ O_t - aI_t = b \]

Where \( a \) is the coefficient for the current time-step, \( b \) is the sum of the coefficients for the previous time-step multiplied by their respective values.

Parameters

The Muskingum routing method requires two parameters:

  • K: The storage time constant (in time-steps). This represents the time it takes for water to travel through the reach.
  • X: The weighting factor (dimensionless between 0.0 and 0.5). This represents the relative importance of inflow and outflow in the reach.

The initial condition can also be specified by the user or set to "steady state". The former sets the initial inflow and outflow to the specified values, while the latter modifies the constraint to require that the inflow and outflow are equal at the first time-step.

See also the HEC-HMS documentation on the Muskingum method for a longer explanation of the parameters and the method.

Example

The easiest way to use Muskingum routing is to use a RiverNode with a routing method of Muskingum. This will create a MuskingumParameter internally. An example of a RiverNode with Muskingum routing is shown below:

{
  "meta": {
    "name": "reach1"
  },
  "type": "River",
  "routing_method": {
    "type": "Muskingum",
    "travel_time": {
      "type": "Constant",
      "value": 1.1
    },
    "weight": {
      "type": "Constant",
      "value": 0.25
    },
    "initial_condition": {
      "type": "SteadyState"
    }
  }
}

Migrating from Pywr v1.x

This guide is intended to help users of Pywr v1.x migrate to Pywr v2.x. Pywr v2.x is a complete rewrite of Pywr with a new API and new features. This guide will help you update your models to this new version.

Overview of the process

Pywr v2.x includes a more strict schema for defining models. This schema, along with the pywr-v1-schema crate, provide a way to convert models from v1.x to v2.x. However, this process is not perfect and will more than likely require manual intervention to complete the migration. The migration of larger and/or more complex models will require an iterative process of conversion and testing.

The overall process will follow these steps:

  1. Convert the JSON from v1.x to v2.x using the provided conversion tool.
  2. Handle any errors or warnings from the conversion tool.
  3. Apply any other manual changes to the converted JSON.
  4. (Optional) Save the converted JSON as a new file.
  5. Load and run the new JSON file in Pywr v2.x.
  6. Compare model outputs to ensure it behaves as expected. If necessary, make further changes to the above process and repeat.

Converting a model

The example below is a basic script that demonstrates how to convert a v1.x model to v2.x. This process converts the model at runtime, and does not replace the existing v1.x model with a v2.x definition.

Note: This example is meant to be a starting point for users to build their own conversion process; it is not a complete generic solution.

The function in the listing below is an example of the overall conversion process. The function takes a path to a JSON file containing a v1 Pywr model, and then converts it to v2.x.

  1. The function reads the JSON, and applies the conversion function (convert_model_from_v1_json_string).
  2. The conversion function that takes a JSON string and returns a tuple of the converted JSON string and a list of errors.
  3. The function then handles these errors using the handle_conversion_error function.
  4. After the errors are handled other arbitrary changes are applied using the patch_model function.
  5. Finally, the converted JSON can be saved to a new file and run using Pywr v2.x.
from pywr import (
    ComponentConversionError,
    ConversionError,
    ModelSchema,
    convert_model_from_v1_json_string,
)


def convert(v1_path: Path):
    with open(v1_path) as fh:
        v1_model_str = fh.read()
    # 1. Convert the v1 model to a v2 schema
    schema, errors = convert_model_from_v1_json_string(v1_model_str)

    schema_data = json.loads(schema.to_json_string())
    # 2. Handle any conversion errors
    for error in errors:
        handle_conversion_error(error, schema_data)

    # 3. Apply any other manual changes to the converted JSON.
    patch_model(schema_data)

    schema_data_str = json.dumps(schema_data, indent=4)
    # 4. Save the converted JSON as a new file (uncomment to save)
    # with open(v1_path.parent / "v2-model.json", "w") as fh:
    #     fh.write(schema_data_str)
    print("Conversion complete; running model...")
    # 5. Load and run the new JSON file in Pywr v2.x.
    schema = ModelSchema.from_json_string(schema_data_str)
    model = schema.build(Path(__file__).parent, None)
    model.run("clp")
    print("Model run complete!")


Handling conversion errors

The convert_model_from_v1_json_string function returns a list of errors that occurred during the conversion process. These errors can be handled in a variety of ways, such as modifying the model definition, raising exceptions, or ignoring them. It is suggested to implement a function that can handle these errors in a way that is appropriate for your use case. Begin by matching a few types of errors and then expand the matching as needed. By raising exceptions for unhandled errors, you can ensure that all errors are eventually accounted for, and that new errors are not missed.

The example handles the ComponentConversionError by matching on the error subclass (either Parameter() or Node()), and then handling each case separately. These two classes will contain the name of the component and optionally the attribute that caused the error. In addition, these types contain an inner error (ConversionError) that can be used to provide more detailed information. In the example, the UnrecognisedType() class is handled for Parameter() errors by applying the handle_custom_parameters function.

This second function adds a Pywr v2.x compatible custom parameter to the model definition using the same name and type (class name) as the original parameter.

def handle_conversion_error(error: ComponentConversionError, schema_data):
    """Handle a schema conversion error.

    Raises a `RuntimeError` if an unhandled error case is found.
    """
    match error:
        case ComponentConversionError.Parameter():
            match error.error:
                case ConversionError.UnrecognisedType() as e:
                    print(
                        f"Patching custom parameter of type {e.ty} with name {error.name}"
                    )
                    handle_custom_parameters(schema_data, error.name, e.ty)
                case _:
                    raise RuntimeError(f"Other parameter conversion error: {error}")
        case ComponentConversionError.Node():
            raise RuntimeError(f"Failed to convert node `{error.name}`: {error.error}")
        case _:
            raise RuntimeError(f"Unexpected conversion error: {error}")


def handle_custom_parameters(schema_data, name: str, p_type: str):
    """Patch the v2 schema to add the custom parameter with `name` and `p_type`."""

    # Ensure the network parameters is a list
    if "parameters" not in schema_data["network"]:
        schema_data["network"]["parameters"] = []

    schema_data["network"]["parameters"].append(
        {
            "meta": {"name": name},
            "type": "Python",
            "source": {"type": "Path", "path": "v2_custom_parameter.py"},
            "object": {
                "type": "Class",
                "class": p_type,
            },  # Use the same class name in v1 & v2
            "args": [],
            "kwargs": {},
        }
    )


Other changes

The upgrade to v2.x may require other changes to the model. For example, the conversion process does not currently handle recorders and other model outputs. These will need to be manually added to the model definition. Such manual changes can be applied using, for example a patch_model function. This function will make arbitrary changes to the model definition. The example, below updates the metadata of the model to modify the description.

def patch_model(schema_data):
    """Patch the v2 schema to add any additional changes."""
    # Add any additional patches here
    schema_data["metadata"]["description"] = "Converted from v1 model"


Full example

The complete example below demonstrates the conversion process for a v1.x model to v2.x:

import json
from pathlib import Path

# ANCHOR: convert
from pywr import (
    ComponentConversionError,
    ConversionError,
    ModelSchema,
    convert_model_from_v1_json_string,
)


def convert(v1_path: Path):
    with open(v1_path) as fh:
        v1_model_str = fh.read()
    # 1. Convert the v1 model to a v2 schema
    schema, errors = convert_model_from_v1_json_string(v1_model_str)

    schema_data = json.loads(schema.to_json_string())
    # 2. Handle any conversion errors
    for error in errors:
        handle_conversion_error(error, schema_data)

    # 3. Apply any other manual changes to the converted JSON.
    patch_model(schema_data)

    schema_data_str = json.dumps(schema_data, indent=4)
    # 4. Save the converted JSON as a new file (uncomment to save)
    # with open(v1_path.parent / "v2-model.json", "w") as fh:
    #     fh.write(schema_data_str)
    print("Conversion complete; running model...")
    # 5. Load and run the new JSON file in Pywr v2.x.
    schema = ModelSchema.from_json_string(schema_data_str)
    model = schema.build(Path(__file__).parent, None)
    model.run("clp")
    print("Model run complete!")


# ANCHOR_END: convert
# ANCHOR: handle_conversion_error
def handle_conversion_error(error: ComponentConversionError, schema_data):
    """Handle a schema conversion error.

    Raises a `RuntimeError` if an unhandled error case is found.
    """
    match error:
        case ComponentConversionError.Parameter():
            match error.error:
                case ConversionError.UnrecognisedType() as e:
                    print(
                        f"Patching custom parameter of type {e.ty} with name {error.name}"
                    )
                    handle_custom_parameters(schema_data, error.name, e.ty)
                case _:
                    raise RuntimeError(f"Other parameter conversion error: {error}")
        case ComponentConversionError.Node():
            raise RuntimeError(f"Failed to convert node `{error.name}`: {error.error}")
        case _:
            raise RuntimeError(f"Unexpected conversion error: {error}")


def handle_custom_parameters(schema_data, name: str, p_type: str):
    """Patch the v2 schema to add the custom parameter with `name` and `p_type`."""

    # Ensure the network parameters is a list
    if "parameters" not in schema_data["network"]:
        schema_data["network"]["parameters"] = []

    schema_data["network"]["parameters"].append(
        {
            "meta": {"name": name},
            "type": "Python",
            "source": {"type": "Path", "path": "v2_custom_parameter.py"},
            "object": {
                "type": "Class",
                "class": p_type,
            },  # Use the same class name in v1 & v2
            "args": [],
            "kwargs": {},
        }
    )


# ANCHOR_END: handle_conversion_error
# ANCHOR: patch_model
def patch_model(schema_data):
    """Patch the v2 schema to add any additional changes."""
    # Add any additional patches here
    schema_data["metadata"]["description"] = "Converted from v1 model"


# ANCHOR_END: patch_model

if __name__ == "__main__":
    pth = Path(__file__).parent / "v1-model.json"
    convert(pth)

Converting custom parameters

The main changes to custom parameters in Pywr v2.x are as follows:

  1. Custom parameters are no longer required to be a subclass of Parameter. They instead can be simple Python functions, or classes that implement a before method.
  2. Users are no longer required to handle scenarios within custom parameters. Instead an instance of the custom parameter is created for each scenario in the simulation. This simplifies writing parameters and removes the risk of accidentally contaminating state between scenarios.
  3. Custom parameters are now added to the model using the "Python" parameter type. I.e. the "type" field in the parameter definition should be set to "Python" (not the class name of the custom parameter). This parameter type requires that the user explicitly define which metrics the custom parameter requires.

For more information on custom parameters, see the Custom parameters section of the documentation.

Simple example

v1.x custom parameter:

from pywr.parameters import ConstantParameter


class MyParameter(ConstantParameter):
    def value(self, *args, **kwargs):
        return 42


MyParameter.register()

v2.x custom parameter:

class MyParameter:
    def before(self, *args, **kwargs):
        return 42

Developers Guide

This section is intended for developers who want to contribute to Pywr. It covers the following topics:

  • Parameter types and traits
  • Adding a new parameter

Parameter traits and return types

The pywr-core crate defines a number of traits that are used to implement parameters. These traits are used to define the behaviour of the parameter and how it interacts with the model. Each parameter must implement the Parameter trait and one of the three compute traits: GeneralParameter<T>, SimpleParameter<T>, or ConstParameter<T>.

The Parameter trait

The Parameter trait is the base trait for all parameters in Pywr. It defines the basic behaviour of the parameter and how it interacts with the model. The minimum implementation requires returning the metadata for the parameter. Additional methods can be implemented to provide additional functionality. Please refer to the documentation for the Parameter trait for more information.

The GeneralParameter<T> trait

The GeneralParameter<T> trait is used for parameters that depend on MetricF64 values from the model. Because MetricF64 values can refer to other parameters, general model state or other information implementing this traits provides the most flexibility for a parameter. The compute method is used to calculate the value of the parameter at a given timestep and scenario. This method is resolved in order with other model components such as nodes.

The SimpleParameter<T> trait

The SimpleParameter<T> trait is used for parameters that depend on SimpleMetricF64 or ConstantMetricF64 values only, or no other values at all. The compute method is used to calculate the value of the parameter at a given timestep and scenario, and therefore SimpleParameter<T> can vary with time. This method is resolved in order with other SimpleParameter<T> before GeneralParameter<T> and other model components such as nodes.

The ConstParameter<T> trait

The ConstParameter<T> trait is used for parameters that depend on ConstantMetricF64 values only and do not vary with time. The compute method is used to calculate the value of the parameter at the start of the simulation and is not resolved at each timestep. This method is resolved in order with other ConstParameter<T>.

Implementing multiple traits

A parameter should implement the "lowest" trait in the hierarchy. For example, if a parameter depends on a SimpleParameter<T> and a ConstParameter<T> value, it should implement the SimpleParameter<T> trait. If a parameter depends on a GeneralParameter<T> and a ConstParameter<T> value, it should implement the GeneralParameter<T> trait.

For some parameters it can be beneficial to implement multiple traits. For example, a parameter could be generic to the metric type (e.g. MetricF64, SimpleMetricF64, or ConstantMetricF64) and implement each of the three compute traits. This would allow the parameter to be used in the most efficient way possible depending on the model configuration.

Return types

While the compute traits are generic over the type T, the return type of the compute Pywr currently only supports f64, usize and MultiValue types. The MultiValue type is used to return multiple values from the compute method. This is useful for parameters that return multiple values at a given timestep and scenario. See the documentation for the MultiValue type for more information. Implementations of the compute traits are usually for one of these concrete types.

Adding a new parameter to Pywr.

This guide explains how to add a new parameter to Pywr.

When to add a new parameter?

New parameters can be added to complement the existing parameters in Pywr. These parameters should be generic and reusable across a wide range of models. By adding them to Pywr itself other users are able to use them in their models without having to implement them themselves. They are also typically implemented in Rust, which means they are fast and efficient.

If the parameter is specific to a particular model or data set, it is better to implement it in the model itself using a custom parameter. Custom parameters can be added using, for example, the PythonParameter.

Adding a new parameter

To add new parameter to Pywr you need to do two things:

  • Add the implementation to the pywr-core crate, and
  • Add the schema definition to the pywr-schema crate.

Adding the implementation to pywr-core

The implementation of the parameter should be added to the pywr-core crate. This is typically done by adding a new module to the parameters module in the src directory. It is a good idea to follow the existing structure of the parameters module by making a new module for the new parameter. Developers can follow the existing parameters as examples.

In this example, we will add a new parameter called MaxParameter that calculates the maximum value of a metric. Parameters can depend on other parameters or values from the model via the MetricF64 type. In this case the metric field stores a MetricF64 that will be compared with the threshold field to calculate the maximum value. The threshold is a constant value that is set when the parameter is created. Finally, the meta field stores the metadata for the parameter. The ParameterMeta struct is used to store the metadata for all parameters and can be reused.

#![allow(dead_code)]
use pywr_core::metric::{MetricConsumerPhase, MetricF64, UnresolvedMetricF64};
use pywr_core::network::ResolutionMaps;
use pywr_core::parameters::{
    BuiltParameter, GeneralAfterParameter, GeneralBeforeParameter, GeneralCalculationError, GeneralParameter,
    GeneralParameterContext, GeneralParameterEntry, MaybeBuiltParameter, Parameter, ParameterBuildError,
    ParameterBuilder, ParameterMeta, ParameterName, ParameterState,
};
use pywr_core::resolve_metric_f64;

#[derive(Debug)]
pub struct MaxParameter {
    meta: ParameterMeta,
    metric: MetricF64,
    threshold: f64,
}

impl Parameter for MaxParameter {
    fn meta(&self) -> &ParameterMeta {
        &self.meta
    }
}

impl GeneralParameter for MaxParameter {
    fn as_parameter(&self) -> &dyn Parameter
    where
        Self: Sized,
    {
        self
    }
}

impl GeneralBeforeParameter<f64> for MaxParameter {
    fn before(
        &self,
        ctx: GeneralParameterContext<'_>,
        _internal_state: &mut Option<Box<dyn ParameterState>>,
    ) -> Result<f64, GeneralCalculationError> {
        // Current value
        let x = self.metric.get_value(ctx.network, ctx.state)?;
        Ok(x.max(self.threshold))
    }
}

impl GeneralAfterParameter<f64> for MaxParameter {
    fn after(
        &self,
        ctx: GeneralParameterContext<'_>,
        _internal_state: &mut Option<Box<dyn ParameterState>>,
    ) -> Result<f64, GeneralCalculationError> {
        // Current value
        let x = self.metric.get_value(ctx.network, ctx.state)?;
        Ok(x.max(self.threshold))
    }
}
#[derive(Debug)]
pub struct MaxParameterBuilder {
    meta: ParameterMeta,
    metric: UnresolvedMetricF64,
    threshold: f64,
    phase: MetricConsumerPhase,
}

impl MaxParameterBuilder {
    /// Create a new builder for [`MaxParameter`] that is evaluated in the "before" phase.
    pub fn before(name: ParameterName, metric: UnresolvedMetricF64, threshold: f64) -> Self {
        Self {
            meta: ParameterMeta::new(name),
            metric,
            threshold,
            phase: MetricConsumerPhase::Before,
        }
    }

    /// Create a new builder for [`MaxParameter`] that is evaluated in the "after" phase.
    pub fn after(name: ParameterName, metric: UnresolvedMetricF64, threshold: f64) -> Self {
        Self {
            meta: ParameterMeta::new(name),
            metric,
            threshold,
            phase: MetricConsumerPhase::After,
        }
    }

    /// Create a new builder for [`MaxParameter`] that is evaluated in both "before" and "after" phases.
    pub fn both(name: ParameterName, metric: UnresolvedMetricF64, threshold: f64) -> Self {
        Self {
            meta: ParameterMeta::new(name),
            metric,
            threshold,
            phase: MetricConsumerPhase::Both,
        }
    }
}

impl ParameterBuilder<f64> for MaxParameterBuilder {
    fn name(&self) -> &ParameterName {
        &self.meta.name
    }

    fn build(
        self: Box<Self>,
        resolution_maps: &ResolutionMaps,
    ) -> Result<MaybeBuiltParameter<f64>, ParameterBuildError> {
        let metric = resolve_metric_f64!(self, self.metric, resolution_maps, self.phase, "metric");

        let p = MaxParameter {
            meta: self.meta,
            metric,
            threshold: self.threshold,
        };

        let built = match self.phase {
            MetricConsumerPhase::Before => BuiltParameter::General(GeneralParameterEntry::before(p)),
            MetricConsumerPhase::After => BuiltParameter::General(GeneralParameterEntry::after(p)),
            MetricConsumerPhase::Both => BuiltParameter::General(GeneralParameterEntry::both(p)),
        };

        Ok(built.into())
    }
}

mod schema {
    #[cfg(feature = "core")]
    use pywr_core::parameters::ParameterName;
    use pywr_schema::meta::NamedMeta;
    use pywr_schema::metric::Metric;
    use pywr_schema::parameters::ParameterPhase;
    #[cfg(feature = "core")]
    use pywr_schema::{LoadArgs, SchemaError};
    use schemars::JsonSchema;

    #[derive(serde::Deserialize, serde::Serialize, Debug, Clone, JsonSchema)]
    pub struct MaxParameter {
        pub meta: NamedMeta,
        pub phase: ParameterPhase,
        pub parameter: Metric,
        pub threshold: Option<f64>,
    }

    #[cfg(feature = "core")]
    impl MaxParameter {
        pub fn add_to_network(
            &self,
            network: &mut pywr_core::network::NetworkBuilder,
            args: &LoadArgs,
            parent: Option<&str>,
        ) -> Result<(), SchemaError> {
            let idx = self.parameter.load(network, args, None)?;
            let threshold = self.threshold.unwrap_or(0.0);
            let name = ParameterName::new(&self.meta.name, parent);

            let p = match self.phase {
                ParameterPhase::Before => pywr_core::parameters::MaxParameterBuilder::before(name, idx, threshold),
                ParameterPhase::After => pywr_core::parameters::MaxParameterBuilder::after(name, idx, threshold),
                ParameterPhase::Both => pywr_core::parameters::MaxParameterBuilder::both(name, idx, threshold),
            };

            network.parameters().f64(Box::new(p));

            Ok(())
        }
    }
}

fn main() {
    println!("Hello, world!");
}

To allow the parameter to be used in the model a "builder" is required. This struct must implement the ParameterBuilder<T> trait. This builder will be used by the schema to create the parameter when it is loaded from a model file. Builders will typically have the same fields as the parameter itself, but will use "unresolved" types (e.g. UnresolvedMetricF64 or UnresolvedNode). This allows the builder to be created without first resolving the dependencies of the parameter. The build function is then used to resolve the dependencies and create the parameter used in the model.

The builder may also have a phase field which allows the parameter to be evaluated in either the before, after or both phase. This determines whether the calculation is evaluated at the beginning or end of the timestep. Parameters evaluated in the before phase will be available to other parameters and calculations in the same timestep often using values from the end of the previous timestep.

#![allow(dead_code)]
use pywr_core::metric::{MetricConsumerPhase, MetricF64, UnresolvedMetricF64};
use pywr_core::network::ResolutionMaps;
use pywr_core::parameters::{
    BuiltParameter, GeneralAfterParameter, GeneralBeforeParameter, GeneralCalculationError, GeneralParameter,
    GeneralParameterContext, GeneralParameterEntry, MaybeBuiltParameter, Parameter, ParameterBuildError,
    ParameterBuilder, ParameterMeta, ParameterName, ParameterState,
};
use pywr_core::resolve_metric_f64;

#[derive(Debug)]
pub struct MaxParameter {
    meta: ParameterMeta,
    metric: MetricF64,
    threshold: f64,
}

impl Parameter for MaxParameter {
    fn meta(&self) -> &ParameterMeta {
        &self.meta
    }
}

impl GeneralParameter for MaxParameter {
    fn as_parameter(&self) -> &dyn Parameter
    where
        Self: Sized,
    {
        self
    }
}

impl GeneralBeforeParameter<f64> for MaxParameter {
    fn before(
        &self,
        ctx: GeneralParameterContext<'_>,
        _internal_state: &mut Option<Box<dyn ParameterState>>,
    ) -> Result<f64, GeneralCalculationError> {
        // Current value
        let x = self.metric.get_value(ctx.network, ctx.state)?;
        Ok(x.max(self.threshold))
    }
}

impl GeneralAfterParameter<f64> for MaxParameter {
    fn after(
        &self,
        ctx: GeneralParameterContext<'_>,
        _internal_state: &mut Option<Box<dyn ParameterState>>,
    ) -> Result<f64, GeneralCalculationError> {
        // Current value
        let x = self.metric.get_value(ctx.network, ctx.state)?;
        Ok(x.max(self.threshold))
    }
}
#[derive(Debug)]
pub struct MaxParameterBuilder {
    meta: ParameterMeta,
    metric: UnresolvedMetricF64,
    threshold: f64,
    phase: MetricConsumerPhase,
}

impl MaxParameterBuilder {
    /// Create a new builder for [`MaxParameter`] that is evaluated in the "before" phase.
    pub fn before(name: ParameterName, metric: UnresolvedMetricF64, threshold: f64) -> Self {
        Self {
            meta: ParameterMeta::new(name),
            metric,
            threshold,
            phase: MetricConsumerPhase::Before,
        }
    }

    /// Create a new builder for [`MaxParameter`] that is evaluated in the "after" phase.
    pub fn after(name: ParameterName, metric: UnresolvedMetricF64, threshold: f64) -> Self {
        Self {
            meta: ParameterMeta::new(name),
            metric,
            threshold,
            phase: MetricConsumerPhase::After,
        }
    }

    /// Create a new builder for [`MaxParameter`] that is evaluated in both "before" and "after" phases.
    pub fn both(name: ParameterName, metric: UnresolvedMetricF64, threshold: f64) -> Self {
        Self {
            meta: ParameterMeta::new(name),
            metric,
            threshold,
            phase: MetricConsumerPhase::Both,
        }
    }
}

impl ParameterBuilder<f64> for MaxParameterBuilder {
    fn name(&self) -> &ParameterName {
        &self.meta.name
    }

    fn build(
        self: Box<Self>,
        resolution_maps: &ResolutionMaps,
    ) -> Result<MaybeBuiltParameter<f64>, ParameterBuildError> {
        let metric = resolve_metric_f64!(self, self.metric, resolution_maps, self.phase, "metric");

        let p = MaxParameter {
            meta: self.meta,
            metric,
            threshold: self.threshold,
        };

        let built = match self.phase {
            MetricConsumerPhase::Before => BuiltParameter::General(GeneralParameterEntry::before(p)),
            MetricConsumerPhase::After => BuiltParameter::General(GeneralParameterEntry::after(p)),
            MetricConsumerPhase::Both => BuiltParameter::General(GeneralParameterEntry::both(p)),
        };

        Ok(built.into())
    }
}

mod schema {
    #[cfg(feature = "core")]
    use pywr_core::parameters::ParameterName;
    use pywr_schema::meta::NamedMeta;
    use pywr_schema::metric::Metric;
    use pywr_schema::parameters::ParameterPhase;
    #[cfg(feature = "core")]
    use pywr_schema::{LoadArgs, SchemaError};
    use schemars::JsonSchema;

    #[derive(serde::Deserialize, serde::Serialize, Debug, Clone, JsonSchema)]
    pub struct MaxParameter {
        pub meta: NamedMeta,
        pub phase: ParameterPhase,
        pub parameter: Metric,
        pub threshold: Option<f64>,
    }

    #[cfg(feature = "core")]
    impl MaxParameter {
        pub fn add_to_network(
            &self,
            network: &mut pywr_core::network::NetworkBuilder,
            args: &LoadArgs,
            parent: Option<&str>,
        ) -> Result<(), SchemaError> {
            let idx = self.parameter.load(network, args, None)?;
            let threshold = self.threshold.unwrap_or(0.0);
            let name = ParameterName::new(&self.meta.name, parent);

            let p = match self.phase {
                ParameterPhase::Before => pywr_core::parameters::MaxParameterBuilder::before(name, idx, threshold),
                ParameterPhase::After => pywr_core::parameters::MaxParameterBuilder::after(name, idx, threshold),
                ParameterPhase::Both => pywr_core::parameters::MaxParameterBuilder::both(name, idx, threshold),
            };

            network.parameters().f64(Box::new(p));

            Ok(())
        }
    }
}

fn main() {
    println!("Hello, world!");
}

Finally, the minimum implementation of the Parameter and one of the three types of parameter compute traits should be added for MaxParameter. These traits require the meta function to return the metadata for the parameter, and the compute function to calculate the value of the parameter at a given timestep and scenario. In this case the compute function calculates the maximum value of the metric and the threshold. The value of the metric is obtained from the model using the get_value function. See the documentation about parameter traits and return types for more information.

#![allow(dead_code)]
use pywr_core::metric::{MetricConsumerPhase, MetricF64, UnresolvedMetricF64};
use pywr_core::network::ResolutionMaps;
use pywr_core::parameters::{
    BuiltParameter, GeneralAfterParameter, GeneralBeforeParameter, GeneralCalculationError, GeneralParameter,
    GeneralParameterContext, GeneralParameterEntry, MaybeBuiltParameter, Parameter, ParameterBuildError,
    ParameterBuilder, ParameterMeta, ParameterName, ParameterState,
};
use pywr_core::resolve_metric_f64;

#[derive(Debug)]
pub struct MaxParameter {
    meta: ParameterMeta,
    metric: MetricF64,
    threshold: f64,
}

impl Parameter for MaxParameter {
    fn meta(&self) -> &ParameterMeta {
        &self.meta
    }
}

impl GeneralParameter for MaxParameter {
    fn as_parameter(&self) -> &dyn Parameter
    where
        Self: Sized,
    {
        self
    }
}

impl GeneralBeforeParameter<f64> for MaxParameter {
    fn before(
        &self,
        ctx: GeneralParameterContext<'_>,
        _internal_state: &mut Option<Box<dyn ParameterState>>,
    ) -> Result<f64, GeneralCalculationError> {
        // Current value
        let x = self.metric.get_value(ctx.network, ctx.state)?;
        Ok(x.max(self.threshold))
    }
}

impl GeneralAfterParameter<f64> for MaxParameter {
    fn after(
        &self,
        ctx: GeneralParameterContext<'_>,
        _internal_state: &mut Option<Box<dyn ParameterState>>,
    ) -> Result<f64, GeneralCalculationError> {
        // Current value
        let x = self.metric.get_value(ctx.network, ctx.state)?;
        Ok(x.max(self.threshold))
    }
}
#[derive(Debug)]
pub struct MaxParameterBuilder {
    meta: ParameterMeta,
    metric: UnresolvedMetricF64,
    threshold: f64,
    phase: MetricConsumerPhase,
}

impl MaxParameterBuilder {
    /// Create a new builder for [`MaxParameter`] that is evaluated in the "before" phase.
    pub fn before(name: ParameterName, metric: UnresolvedMetricF64, threshold: f64) -> Self {
        Self {
            meta: ParameterMeta::new(name),
            metric,
            threshold,
            phase: MetricConsumerPhase::Before,
        }
    }

    /// Create a new builder for [`MaxParameter`] that is evaluated in the "after" phase.
    pub fn after(name: ParameterName, metric: UnresolvedMetricF64, threshold: f64) -> Self {
        Self {
            meta: ParameterMeta::new(name),
            metric,
            threshold,
            phase: MetricConsumerPhase::After,
        }
    }

    /// Create a new builder for [`MaxParameter`] that is evaluated in both "before" and "after" phases.
    pub fn both(name: ParameterName, metric: UnresolvedMetricF64, threshold: f64) -> Self {
        Self {
            meta: ParameterMeta::new(name),
            metric,
            threshold,
            phase: MetricConsumerPhase::Both,
        }
    }
}

impl ParameterBuilder<f64> for MaxParameterBuilder {
    fn name(&self) -> &ParameterName {
        &self.meta.name
    }

    fn build(
        self: Box<Self>,
        resolution_maps: &ResolutionMaps,
    ) -> Result<MaybeBuiltParameter<f64>, ParameterBuildError> {
        let metric = resolve_metric_f64!(self, self.metric, resolution_maps, self.phase, "metric");

        let p = MaxParameter {
            meta: self.meta,
            metric,
            threshold: self.threshold,
        };

        let built = match self.phase {
            MetricConsumerPhase::Before => BuiltParameter::General(GeneralParameterEntry::before(p)),
            MetricConsumerPhase::After => BuiltParameter::General(GeneralParameterEntry::after(p)),
            MetricConsumerPhase::Both => BuiltParameter::General(GeneralParameterEntry::both(p)),
        };

        Ok(built.into())
    }
}

mod schema {
    #[cfg(feature = "core")]
    use pywr_core::parameters::ParameterName;
    use pywr_schema::meta::NamedMeta;
    use pywr_schema::metric::Metric;
    use pywr_schema::parameters::ParameterPhase;
    #[cfg(feature = "core")]
    use pywr_schema::{LoadArgs, SchemaError};
    use schemars::JsonSchema;

    #[derive(serde::Deserialize, serde::Serialize, Debug, Clone, JsonSchema)]
    pub struct MaxParameter {
        pub meta: NamedMeta,
        pub phase: ParameterPhase,
        pub parameter: Metric,
        pub threshold: Option<f64>,
    }

    #[cfg(feature = "core")]
    impl MaxParameter {
        pub fn add_to_network(
            &self,
            network: &mut pywr_core::network::NetworkBuilder,
            args: &LoadArgs,
            parent: Option<&str>,
        ) -> Result<(), SchemaError> {
            let idx = self.parameter.load(network, args, None)?;
            let threshold = self.threshold.unwrap_or(0.0);
            let name = ParameterName::new(&self.meta.name, parent);

            let p = match self.phase {
                ParameterPhase::Before => pywr_core::parameters::MaxParameterBuilder::before(name, idx, threshold),
                ParameterPhase::After => pywr_core::parameters::MaxParameterBuilder::after(name, idx, threshold),
                ParameterPhase::Both => pywr_core::parameters::MaxParameterBuilder::both(name, idx, threshold),
            };

            network.parameters().f64(Box::new(p));

            Ok(())
        }
    }
}

fn main() {
    println!("Hello, world!");
}

Adding the schema definition to pywr-schema

The schema definition for the new parameter should be added to the pywr-schema crate. Again, it is a good idea to follow the existing structure of the schema by making a new module for the new parameter. Developers can also follow the existing parameters as examples. As with the pywr-core implementation, the meta field is used to store the metadata for the parameter and can use the ParameterMeta struct (NB this is from pywr-schema crate). The rest of the struct looks very similar to the pywr-core implementation, but uses pywr-schema types for the fields. The struct should also derive serde::Deserialize, serde::Serialize, Debug, Clone, JsonSchema, and PywrVisitAll to be compatible with the rest of Pywr.

Note: The PywrVisitAll derive is not shown in the listing as it can not currently be used outside the pywr-schema crate.

#![allow(dead_code)]
use pywr_core::metric::{MetricConsumerPhase, MetricF64, UnresolvedMetricF64};
use pywr_core::network::ResolutionMaps;
use pywr_core::parameters::{
    BuiltParameter, GeneralAfterParameter, GeneralBeforeParameter, GeneralCalculationError, GeneralParameter,
    GeneralParameterContext, GeneralParameterEntry, MaybeBuiltParameter, Parameter, ParameterBuildError,
    ParameterBuilder, ParameterMeta, ParameterName, ParameterState,
};
use pywr_core::resolve_metric_f64;

#[derive(Debug)]
pub struct MaxParameter {
    meta: ParameterMeta,
    metric: MetricF64,
    threshold: f64,
}

impl Parameter for MaxParameter {
    fn meta(&self) -> &ParameterMeta {
        &self.meta
    }
}

impl GeneralParameter for MaxParameter {
    fn as_parameter(&self) -> &dyn Parameter
    where
        Self: Sized,
    {
        self
    }
}

impl GeneralBeforeParameter<f64> for MaxParameter {
    fn before(
        &self,
        ctx: GeneralParameterContext<'_>,
        _internal_state: &mut Option<Box<dyn ParameterState>>,
    ) -> Result<f64, GeneralCalculationError> {
        // Current value
        let x = self.metric.get_value(ctx.network, ctx.state)?;
        Ok(x.max(self.threshold))
    }
}

impl GeneralAfterParameter<f64> for MaxParameter {
    fn after(
        &self,
        ctx: GeneralParameterContext<'_>,
        _internal_state: &mut Option<Box<dyn ParameterState>>,
    ) -> Result<f64, GeneralCalculationError> {
        // Current value
        let x = self.metric.get_value(ctx.network, ctx.state)?;
        Ok(x.max(self.threshold))
    }
}
#[derive(Debug)]
pub struct MaxParameterBuilder {
    meta: ParameterMeta,
    metric: UnresolvedMetricF64,
    threshold: f64,
    phase: MetricConsumerPhase,
}

impl MaxParameterBuilder {
    /// Create a new builder for [`MaxParameter`] that is evaluated in the "before" phase.
    pub fn before(name: ParameterName, metric: UnresolvedMetricF64, threshold: f64) -> Self {
        Self {
            meta: ParameterMeta::new(name),
            metric,
            threshold,
            phase: MetricConsumerPhase::Before,
        }
    }

    /// Create a new builder for [`MaxParameter`] that is evaluated in the "after" phase.
    pub fn after(name: ParameterName, metric: UnresolvedMetricF64, threshold: f64) -> Self {
        Self {
            meta: ParameterMeta::new(name),
            metric,
            threshold,
            phase: MetricConsumerPhase::After,
        }
    }

    /// Create a new builder for [`MaxParameter`] that is evaluated in both "before" and "after" phases.
    pub fn both(name: ParameterName, metric: UnresolvedMetricF64, threshold: f64) -> Self {
        Self {
            meta: ParameterMeta::new(name),
            metric,
            threshold,
            phase: MetricConsumerPhase::Both,
        }
    }
}

impl ParameterBuilder<f64> for MaxParameterBuilder {
    fn name(&self) -> &ParameterName {
        &self.meta.name
    }

    fn build(
        self: Box<Self>,
        resolution_maps: &ResolutionMaps,
    ) -> Result<MaybeBuiltParameter<f64>, ParameterBuildError> {
        let metric = resolve_metric_f64!(self, self.metric, resolution_maps, self.phase, "metric");

        let p = MaxParameter {
            meta: self.meta,
            metric,
            threshold: self.threshold,
        };

        let built = match self.phase {
            MetricConsumerPhase::Before => BuiltParameter::General(GeneralParameterEntry::before(p)),
            MetricConsumerPhase::After => BuiltParameter::General(GeneralParameterEntry::after(p)),
            MetricConsumerPhase::Both => BuiltParameter::General(GeneralParameterEntry::both(p)),
        };

        Ok(built.into())
    }
}

mod schema {
    #[cfg(feature = "core")]
    use pywr_core::parameters::ParameterName;
    use pywr_schema::meta::NamedMeta;
    use pywr_schema::metric::Metric;
    use pywr_schema::parameters::ParameterPhase;
    #[cfg(feature = "core")]
    use pywr_schema::{LoadArgs, SchemaError};
    use schemars::JsonSchema;

    #[derive(serde::Deserialize, serde::Serialize, Debug, Clone, JsonSchema)]
    pub struct MaxParameter {
        pub meta: NamedMeta,
        pub phase: ParameterPhase,
        pub parameter: Metric,
        pub threshold: Option<f64>,
    }

    #[cfg(feature = "core")]
    impl MaxParameter {
        pub fn add_to_network(
            &self,
            network: &mut pywr_core::network::NetworkBuilder,
            args: &LoadArgs,
            parent: Option<&str>,
        ) -> Result<(), SchemaError> {
            let idx = self.parameter.load(network, args, None)?;
            let threshold = self.threshold.unwrap_or(0.0);
            let name = ParameterName::new(&self.meta.name, parent);

            let p = match self.phase {
                ParameterPhase::Before => pywr_core::parameters::MaxParameterBuilder::before(name, idx, threshold),
                ParameterPhase::After => pywr_core::parameters::MaxParameterBuilder::after(name, idx, threshold),
                ParameterPhase::Both => pywr_core::parameters::MaxParameterBuilder::both(name, idx, threshold),
            };

            network.parameters().f64(Box::new(p));

            Ok(())
        }
    }
}

fn main() {
    println!("Hello, world!");
}

Next, the parameter needs a method to add itself to a network. This is typically done by implementing a add_to_model method for the parameter. This method should be feature-gated with the core feature to ensure it is only available when the core feature is enabled. The method should take a mutable reference to the network and a reference to the LoadArgs struct. The method should load the metric from the model using the load method, and then create a new MaxParameter by matching to the given phase and using one of the before, after or both methods implemented above. Finally, the method should add the parameter to the network using the add_parameter method.

#![allow(dead_code)]
use pywr_core::metric::{MetricConsumerPhase, MetricF64, UnresolvedMetricF64};
use pywr_core::network::ResolutionMaps;
use pywr_core::parameters::{
    BuiltParameter, GeneralAfterParameter, GeneralBeforeParameter, GeneralCalculationError, GeneralParameter,
    GeneralParameterContext, GeneralParameterEntry, MaybeBuiltParameter, Parameter, ParameterBuildError,
    ParameterBuilder, ParameterMeta, ParameterName, ParameterState,
};
use pywr_core::resolve_metric_f64;

#[derive(Debug)]
pub struct MaxParameter {
    meta: ParameterMeta,
    metric: MetricF64,
    threshold: f64,
}

impl Parameter for MaxParameter {
    fn meta(&self) -> &ParameterMeta {
        &self.meta
    }
}

impl GeneralParameter for MaxParameter {
    fn as_parameter(&self) -> &dyn Parameter
    where
        Self: Sized,
    {
        self
    }
}

impl GeneralBeforeParameter<f64> for MaxParameter {
    fn before(
        &self,
        ctx: GeneralParameterContext<'_>,
        _internal_state: &mut Option<Box<dyn ParameterState>>,
    ) -> Result<f64, GeneralCalculationError> {
        // Current value
        let x = self.metric.get_value(ctx.network, ctx.state)?;
        Ok(x.max(self.threshold))
    }
}

impl GeneralAfterParameter<f64> for MaxParameter {
    fn after(
        &self,
        ctx: GeneralParameterContext<'_>,
        _internal_state: &mut Option<Box<dyn ParameterState>>,
    ) -> Result<f64, GeneralCalculationError> {
        // Current value
        let x = self.metric.get_value(ctx.network, ctx.state)?;
        Ok(x.max(self.threshold))
    }
}
#[derive(Debug)]
pub struct MaxParameterBuilder {
    meta: ParameterMeta,
    metric: UnresolvedMetricF64,
    threshold: f64,
    phase: MetricConsumerPhase,
}

impl MaxParameterBuilder {
    /// Create a new builder for [`MaxParameter`] that is evaluated in the "before" phase.
    pub fn before(name: ParameterName, metric: UnresolvedMetricF64, threshold: f64) -> Self {
        Self {
            meta: ParameterMeta::new(name),
            metric,
            threshold,
            phase: MetricConsumerPhase::Before,
        }
    }

    /// Create a new builder for [`MaxParameter`] that is evaluated in the "after" phase.
    pub fn after(name: ParameterName, metric: UnresolvedMetricF64, threshold: f64) -> Self {
        Self {
            meta: ParameterMeta::new(name),
            metric,
            threshold,
            phase: MetricConsumerPhase::After,
        }
    }

    /// Create a new builder for [`MaxParameter`] that is evaluated in both "before" and "after" phases.
    pub fn both(name: ParameterName, metric: UnresolvedMetricF64, threshold: f64) -> Self {
        Self {
            meta: ParameterMeta::new(name),
            metric,
            threshold,
            phase: MetricConsumerPhase::Both,
        }
    }
}

impl ParameterBuilder<f64> for MaxParameterBuilder {
    fn name(&self) -> &ParameterName {
        &self.meta.name
    }

    fn build(
        self: Box<Self>,
        resolution_maps: &ResolutionMaps,
    ) -> Result<MaybeBuiltParameter<f64>, ParameterBuildError> {
        let metric = resolve_metric_f64!(self, self.metric, resolution_maps, self.phase, "metric");

        let p = MaxParameter {
            meta: self.meta,
            metric,
            threshold: self.threshold,
        };

        let built = match self.phase {
            MetricConsumerPhase::Before => BuiltParameter::General(GeneralParameterEntry::before(p)),
            MetricConsumerPhase::After => BuiltParameter::General(GeneralParameterEntry::after(p)),
            MetricConsumerPhase::Both => BuiltParameter::General(GeneralParameterEntry::both(p)),
        };

        Ok(built.into())
    }
}

mod schema {
    #[cfg(feature = "core")]
    use pywr_core::parameters::ParameterName;
    use pywr_schema::meta::NamedMeta;
    use pywr_schema::metric::Metric;
    use pywr_schema::parameters::ParameterPhase;
    #[cfg(feature = "core")]
    use pywr_schema::{LoadArgs, SchemaError};
    use schemars::JsonSchema;

    #[derive(serde::Deserialize, serde::Serialize, Debug, Clone, JsonSchema)]
    pub struct MaxParameter {
        pub meta: NamedMeta,
        pub phase: ParameterPhase,
        pub parameter: Metric,
        pub threshold: Option<f64>,
    }

    #[cfg(feature = "core")]
    impl MaxParameter {
        pub fn add_to_network(
            &self,
            network: &mut pywr_core::network::NetworkBuilder,
            args: &LoadArgs,
            parent: Option<&str>,
        ) -> Result<(), SchemaError> {
            let idx = self.parameter.load(network, args, None)?;
            let threshold = self.threshold.unwrap_or(0.0);
            let name = ParameterName::new(&self.meta.name, parent);

            let p = match self.phase {
                ParameterPhase::Before => pywr_core::parameters::MaxParameterBuilder::before(name, idx, threshold),
                ParameterPhase::After => pywr_core::parameters::MaxParameterBuilder::after(name, idx, threshold),
                ParameterPhase::Both => pywr_core::parameters::MaxParameterBuilder::both(name, idx, threshold),
            };

            network.parameters().f64(Box::new(p));

            Ok(())
        }
    }
}

fn main() {
    println!("Hello, world!");
}

Finally, the schema definition should be added to the Parameter enum in the parameters module. This will require ensuring the new variant is added to all places where that enum is used. The borrow checker can be helpful in ensuring all places are updated.

Contributing to Documentation

The documentation for Pywr V2 is located in the pywr-next repository, here in the pywr-book subfolder.

The documentation is written using 'markdown', a format which enables easy formatting for the web.

This website can help get started: www.markdownguide.org

To contribute documentation for Pywr V2, we recommend following the steps below to ensure we can review and integrate any changes as easily as possible.

Steps to create documentation

  1. Fork the pywr-next repository

Fork the repository

  1. Clone the fork
    git clone https://github.com/MYUSER/pywr-next
  1. Create a branch
    git checkout -b my-awesome-docs
  1. Open the book documentation in your favourite editor
    vi pywr-next/pywr-book/introduction.md

Which should look something like this:

An example docs file

  1. Having modified the documentation, add and commit the changes using the commit format
git add introduction.md"
git commit -m "docs: Add an example documentation"
  1. Create a pull request from your branch
    1. In your fork, click on the 'Pull Requests' tab Pull request

    2. Click on 'New Pull Request' Pull request

    3. Choose your branch from the drop-down on the right-hand-side Pull request

    4. Click 'Create Pull Request' when the button appears Pull request

    5. Add a note if you want, and click 'Create Pull Request' Pull request

Placeholder for API documentation

The API documentation is auto-generated by pdoc and should replace this file!