From 030a7e9e5642e0da7b5d39db262e0ee032ca39ed Mon Sep 17 00:00:00 2001 From: James Tomlinson Date: Thu, 3 Oct 2024 20:34:46 +0100 Subject: [PATCH] docs: Add developer guide on adding a new parameter. (#197) --- .github/workflows/linux.yml | 2 +- .github/workflows/windows.yml | 2 +- Cargo.toml | 2 + .../listings/adding-a-parameter/Cargo.toml | 15 +++ .../listings/adding-a-parameter/src/main.rs | 97 +++++++++++++++++++ pywr-book/src/SUMMARY.md | 32 +++--- pywr-book/src/developers-guide/README.md | 6 ++ .../developers-guide/adding-a-parameter.md | 97 +++++++++++++++++++ .../src/developers-guide/parameter-traits.md | 53 ++++++++++ pywr-core/src/parameters/mod.rs | 2 +- 10 files changed, 293 insertions(+), 15 deletions(-) create mode 100644 pywr-book/listings/adding-a-parameter/Cargo.toml create mode 100644 pywr-book/listings/adding-a-parameter/src/main.rs create mode 100644 pywr-book/src/developers-guide/README.md create mode 100644 pywr-book/src/developers-guide/adding-a-parameter.md create mode 100644 pywr-book/src/developers-guide/parameter-traits.md diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 4812c506..a2fe2d0b 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -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 diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 1b881ce6..ca6568ae 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -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 diff --git a/Cargo.toml b/Cargo.toml index 7c6358aa..c3ac88c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/pywr-book/listings/adding-a-parameter/Cargo.toml b/pywr-book/listings/adding-a-parameter/Cargo.toml new file mode 100644 index 00000000..f4623dfa --- /dev/null +++ b/pywr-book/listings/adding-a-parameter/Cargo.toml @@ -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"] diff --git a/pywr-book/listings/adding-a-parameter/src/main.rs b/pywr-book/listings/adding-a-parameter/src/main.rs new file mode 100644 index 00000000..d51edfdd --- /dev/null +++ b/pywr-book/listings/adding-a-parameter/src/main.rs @@ -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 for MaxParameter { + fn compute( + &self, + _timestep: &Timestep, + _scenario_index: &ScenarioIndex, + model: &Network, + state: &State, + _internal_state: &mut Option>, + ) -> Result { + // 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, + } + + // 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, 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!"); +} diff --git a/pywr-book/src/SUMMARY.md b/pywr-book/src/SUMMARY.md index 3ba6792b..2cef92db 100644 --- a/pywr-book/src/SUMMARY.md +++ b/pywr-book/src/SUMMARY.md @@ -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 diff --git a/pywr-book/src/developers-guide/README.md b/pywr-book/src/developers-guide/README.md new file mode 100644 index 00000000..af31698f --- /dev/null +++ b/pywr-book/src/developers-guide/README.md @@ -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 diff --git a/pywr-book/src/developers-guide/adding-a-parameter.md b/pywr-book/src/developers-guide/adding-a-parameter.md new file mode 100644 index 00000000..c8bcc3c3 --- /dev/null +++ b/pywr-book/src/developers-guide/adding-a-parameter.md @@ -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. diff --git a/pywr-book/src/developers-guide/parameter-traits.md b/pywr-book/src/developers-guide/parameter-traits.md new file mode 100644 index 00000000..51812c4b --- /dev/null +++ b/pywr-book/src/developers-guide/parameter-traits.md @@ -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`, `SimpleParameter`, or `ConstParameter`. + +## 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` trait + +The `GeneralParameter` 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` trait + +The `SimpleParameter` 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` can vary with time. This method is resolved in order with +other `SimpleParameter` before `GeneralParameter` and other model components such as nodes. + +## The `ConstParameter` trait + +The `ConstParameter` 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`. + +## Implementing multiple traits + +A parameter should implement the "lowest" trait in the hierarchy. For example, if a parameter depends on +a `SimpleParameter` and a `ConstParameter` value, it should implement the `SimpleParameter` trait. +If a parameter depends on a `GeneralParameter` and a `ConstParameter` value, it should implement the +`GeneralParameter` 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. diff --git a/pywr-core/src/parameters/mod.rs b/pywr-core/src/parameters/mod.rs index 5c0b4e0c..427db3e6 100644 --- a/pywr-core/src/parameters/mod.rs +++ b/pywr-core/src/parameters/mod.rs @@ -309,7 +309,7 @@ pub struct ParameterMeta { } impl ParameterMeta { - fn new(name: ParameterName) -> Self { + pub fn new(name: ParameterName) -> Self { Self { name } } }