Skip to content

Commit

Permalink
docs: Add developer guide on adding a new parameter. (#197)
Browse files Browse the repository at this point in the history
  • Loading branch information
jetuk authored Oct 3, 2024
1 parent ccc087a commit 030a7e9
Show file tree
Hide file tree
Showing 10 changed files with 293 additions and 15 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
echo "$(pwd)/bin" >> $GITHUB_PATH
- name: Build
run: cargo build --verbose --features highs,cbc
run: cargo build --verbose --features highs,cbc --workspace --exclude ipm-simd --exclude pywr-python
- name: Run tests
run: cargo test --features highs,cbc
- name: Run mdbook tests
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
- name: Run Clippy
run: cargo clippy --all-targets --features highs,cbc
- name: Build
run: cargo build --verbose --features highs,cbc
run: cargo build --verbose --features highs,cbc --workspace --exclude ipm-simd --exclude pywr-python
- name: Run tests
# Only test the library and binaries, not the docs
# There were some issues with the docs tests timing out on Windows CI
Expand Down
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ members = [
"pywr-cli",
"pywr-python",
"pywr-schema-macros",
# These are the listings for the book
"pywr-book/listings/*",
]
exclude = [
"tests/models/simple-wasm/simple-wasm-parameter"
Expand Down
15 changes: 15 additions & 0 deletions pywr-book/listings/adding-a-parameter/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "adding-a-parameter"
version = "0.1.0"
edition = "2021"

[dependencies]
pywr-core = { path = "../../../pywr-core" }
pywr-schema = { path = "../../../pywr-schema" }
pywr-schema-macros = { path = "../../../pywr-schema-macros" }
serde = { workspace = true }
schemars = { workspace = true }

[features]
core = ["pywr-schema/core"]
default = ["core"]
97 changes: 97 additions & 0 deletions pywr-book/listings/adding-a-parameter/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#![allow(dead_code)]
use pywr_core::metric::MetricF64;
use pywr_core::network::Network;
use pywr_core::parameters::{GeneralParameter, Parameter, ParameterMeta, ParameterName, ParameterState};
use pywr_core::scenario::ScenarioIndex;
use pywr_core::state::State;
use pywr_core::timestep::Timestep;
use pywr_core::PywrError;

// ANCHOR: parameter
pub struct MaxParameter {
meta: ParameterMeta,
metric: MetricF64,
threshold: f64,
}
// ANCHOR_END: parameter
// ANCHOR: impl-new
impl MaxParameter {
pub fn new(name: ParameterName, metric: MetricF64, threshold: f64) -> Self {
Self {
meta: ParameterMeta::new(name),
metric,
threshold,
}
}
}
// ANCHOR_END: impl-new
// ANCHOR: impl-parameter
impl Parameter for MaxParameter {
fn meta(&self) -> &ParameterMeta {
&self.meta
}
}

impl GeneralParameter<f64> for MaxParameter {
fn compute(
&self,
_timestep: &Timestep,
_scenario_index: &ScenarioIndex,
model: &Network,
state: &State,
_internal_state: &mut Option<Box<dyn ParameterState>>,
) -> Result<f64, PywrError> {
// Current value
let x = self.metric.get_value(model, state)?;
Ok(x.max(self.threshold))
}

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

// ANCHOR_END: impl-parameter
mod schema {
#[cfg(feature = "core")]
use pywr_core::parameters::ParameterIndex;
use pywr_schema::metric::Metric;
use pywr_schema::parameters::ParameterMeta;
#[cfg(feature = "core")]
use pywr_schema::{model::LoadArgs, SchemaError};
use schemars::JsonSchema;

// ANCHOR: schema
#[derive(serde::Deserialize, serde::Serialize, Debug, Clone, JsonSchema)]
pub struct MaxParameter {
#[serde(flatten)]
pub meta: ParameterMeta,
pub parameter: Metric,
pub threshold: Option<f64>,
}

// ANCHOR_END: schema
// ANCHOR: schema-impl
#[cfg(feature = "core")]
impl MaxParameter {
pub fn add_to_model(
&self,
network: &mut pywr_core::network::Network,
args: &LoadArgs,
) -> Result<ParameterIndex<f64>, SchemaError> {
let idx = self.parameter.load(network, args)?;
let threshold = self.threshold.unwrap_or(0.0);

let p = pywr_core::parameters::MaxParameter::new(self.meta.name.as_str().into(), idx, threshold);
Ok(network.add_parameter(Box::new(p))?)
}
}
// ANCHOR_END: schema-impl
}

fn main() {
println!("Hello, world!");
}
32 changes: 20 additions & 12 deletions pywr-book/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,28 @@

- [Introduction](./introduction.md)
- [Getting started](./getting_started.md)
- [Related projects](./related_projects.md)
- [Related projects](./related_projects.md)
- [Core concepts](./concepts/README.md)
- [The network](./concepts/the-network.md)
- [Parameters](./concepts/parameters.md)
- [Penalty costs](./concepts/penalty-costs.md)
- [Reservoirs](./concepts/reservoirs.md)
- [Abstraction licences](./concepts/abstraction-licences.md)
- [Scenarios](./concepts/scenarios.md)
- [Input data](./concepts/input-data.md)
- [The network](./concepts/the-network.md)
- [Parameters](./concepts/parameters.md)
- [Penalty costs](./concepts/penalty-costs.md)
- [Reservoirs](./concepts/reservoirs.md)
- [Abstraction licences](./concepts/abstraction-licences.md)
- [Scenarios](./concepts/scenarios.md)
- [Input data](./concepts/input-data.md)
- [Extending Pywr](./extending_pywr.md)
- [Multi-models](./multi_models.md)
- [Migration guide](./migration_guide.md)
- [Model management](./model-management/README.md)
- [Version control](./model-management/version-control.md)
- [Packaging](./model-management/packaging.md)
- [Deployment & Docker](./model-management/deployment.md)
- [Documentation](./building_documentation.md)
- [Version control](./model-management/version-control.md)
- [Packaging](./model-management/packaging.md)
- [Deployment & Docker](./model-management/deployment.md)
- [Developers Guide](./developers-guide/README.md)
- [Parameter traits and return types](./developers-guide/parameter-traits.md)
- [Adding a new parameter](./developers-guide/adding-a-parameter.md)
- [Version control](./model-management/version-control.md)
- [Packaging](./model-management/packaging.md)
- [Deployment & Docker](./model-management/deployment.md)
- [Documentation](./building_documentation.md)

> > > > > > > main
6 changes: 6 additions & 0 deletions pywr-book/src/developers-guide/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# 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
97 changes: 97 additions & 0 deletions pywr-book/src/developers-guide/adding-a-parameter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# 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.

```rust,ignore
{{#rustdoc_include ../../listings/adding-a-parameter/src/main.rs:parameter}}
```

To allow the parameter to be used in the model it is helpful to add a `new` function that creates a new instance of the
parameter. This will be used by the schema to create the parameter when it is loaded from a model file.

```rust,ignore
{{#rustdoc_include ../../listings/adding-a-parameter/src/main.rs:impl-new}}
```

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](parameter-traits.md) about parameter traits and return types for more information.

```rust,ignore
{{#rustdoc_include ../../listings/adding-a-parameter/src/main.rs:impl-parameter}}
```

### 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.
```rust,ignore
{{#rustdoc_include ../../listings/adding-a-parameter/src/main.rs:schema}}
```

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` using
the `new` method implemented above.
Finally, the method should add the parameter to the network using the `add_parameter` method.

```rust,ignore
{{#rustdoc_include ../../listings/adding-a-parameter/src/main.rs:schema-impl}}
```

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.
53 changes: 53 additions & 0 deletions pywr-book/src/developers-guide/parameter-traits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# 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.
2 changes: 1 addition & 1 deletion pywr-core/src/parameters/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ pub struct ParameterMeta {
}

impl ParameterMeta {
fn new(name: ParameterName) -> Self {
pub fn new(name: ParameterName) -> Self {
Self { name }
}
}
Expand Down

0 comments on commit 030a7e9

Please sign in to comment.