From 376b01d606811febaa63c32f0dde24dc861bd36b Mon Sep 17 00:00:00 2001 From: James Tomlinson Date: Thu, 21 Sep 2023 17:53:21 +0100 Subject: [PATCH] feat: Initial commit of variable API. (#32) - New VariableParameter trait. - New methods on Parameter trait to return access variable properties. - Basic implementation for ConstantParameter. - Improved ConstantParameter documentation. - Integrated activation functions into constant parameter. - Add OffsetParameter. --- src/lib.rs | 6 + src/model.rs | 57 ++++++- src/parameters/activation_function.rs | 109 +++++++++++++ src/parameters/constant.rs | 89 +++++++++++ src/parameters/mod.rs | 112 ++++++------- src/parameters/offset.rs | 93 +++++++++++ src/parameters/vector.rs | 43 +++++ src/schema/mod.rs | 6 + src/schema/model.rs | 3 + src/schema/parameters/aggregated.rs | 40 +++-- src/schema/parameters/core.rs | 147 +++++++++++++++++- .../parameters/doc_examples/aggregated_1.json | 28 ++++ .../doc_examples/constant_simple.json | 5 + .../doc_examples/constant_variable.json | 13 ++ .../doc_examples/offset_simple.json | 9 ++ .../doc_examples/offset_variable.json | 17 ++ src/schema/parameters/mod.rs | 45 +++++- src/schema/parameters/offset.rs | 74 +++++++++ src/test_utils.rs | 8 +- 19 files changed, 810 insertions(+), 94 deletions(-) create mode 100644 src/parameters/activation_function.rs create mode 100644 src/parameters/constant.rs create mode 100644 src/parameters/offset.rs create mode 100644 src/parameters/vector.rs create mode 100644 src/schema/parameters/doc_examples/aggregated_1.json create mode 100644 src/schema/parameters/doc_examples/constant_simple.json create mode 100644 src/schema/parameters/doc_examples/constant_variable.json create mode 100644 src/schema/parameters/doc_examples/offset_simple.json create mode 100644 src/schema/parameters/doc_examples/offset_variable.json create mode 100644 src/schema/parameters/offset.rs diff --git a/src/lib.rs b/src/lib.rs index 68e04f2d..84e5adda 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -135,4 +135,10 @@ pub enum PywrError { DataTable(#[from] schema::data_tables::TableError), #[error("unsupported file format")] UnsupportedFileFormat, + #[error("parameter type does is not a valid variable")] + ParameterTypeNotVariable, + #[error("parameter variable is not active")] + ParameterVariableNotActive, + #[error("incorrect number of values for parameter variable")] + ParameterVariableValuesIncorrectLength, } diff --git a/src/model.rs b/src/model.rs index 69363fbf..127b439a 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1337,6 +1337,26 @@ impl Model { Ok(edge_index) } + + /// Set the variable values on the parameter a index `['idx']. + pub fn set_parameter_variable_values(&mut self, idx: ParameterIndex, values: &[f64]) -> Result<(), PywrError> { + match self.parameters.get_mut(*idx.deref()) { + Some(parameter) => match parameter.as_variable_mut() { + Some(variable) => variable.set_variables(values), + None => Err(PywrError::ParameterTypeNotVariable), + }, + None => Err(PywrError::ParameterIndexNotFound(idx)), + } + } + + /// Return a vector of the current values of active variable parameters. + pub fn get_parameter_variable_values(&self) -> Vec { + self.parameters + .iter() + .filter_map(|p| p.as_variable().filter(|v| v.is_active()).map(|v| v.get_variables())) + .flatten() + .collect() + } } #[cfg(test)] @@ -1345,9 +1365,9 @@ mod tests { use crate::metric::Metric; use crate::model::Model; use crate::node::{Constraint, ConstraintValue}; + use crate::parameters::{ActivationFunction, Parameter, VariableParameter}; use crate::recorders::AssertionRecorder; use crate::scenario::{ScenarioGroupCollection, ScenarioIndex}; - #[cfg(feature = "clipm")] use crate::solvers::ClIpmF64Solver; use crate::solvers::ClpSolver; @@ -1432,7 +1452,7 @@ mod tests { let mut model = Model::default(); let _node_index = model.add_input_node("input", None).unwrap(); - let input_max_flow = parameters::ConstantParameter::new("my-constant", 10.0); + let input_max_flow = parameters::ConstantParameter::new("my-constant", 10.0, None); let parameter = model.add_parameter(Box::new(input_max_flow)).unwrap(); // assign the new parameter to one of the nodes. @@ -1678,4 +1698,37 @@ mod tests { }) ); } + + #[test] + /// Test the variable API + fn test_variable_api() { + let mut model = Model::default(); + let _node_index = model.add_input_node("input", None).unwrap(); + + let variable = ActivationFunction::Unit { min: 0.0, max: 10.0 }; + let input_max_flow = parameters::ConstantParameter::new("my-constant", 10.0, Some(variable)); + + assert!(input_max_flow.can_be_variable()); + assert!(input_max_flow.is_variable_active()); + assert!(input_max_flow.is_active()); + + let input_max_flow_idx = model.add_parameter(Box::new(input_max_flow)).unwrap(); + + // assign the new parameter to one of the nodes. + let node = model.get_mut_node_by_name("input", None).unwrap(); + node.set_constraint( + ConstraintValue::Metric(Metric::ParameterValue(input_max_flow_idx)), + Constraint::MaxFlow, + ) + .unwrap(); + + let variable_values = model.get_parameter_variable_values(); + assert_eq!(variable_values, vec![10.0]); + + // Update the variable values + model.set_parameter_variable_values(input_max_flow_idx, &[5.0]).unwrap(); + + let variable_values = model.get_parameter_variable_values(); + assert_eq!(variable_values, vec![5.0]); + } } diff --git a/src/parameters/activation_function.rs b/src/parameters/activation_function.rs new file mode 100644 index 00000000..a05e4a3b --- /dev/null +++ b/src/parameters/activation_function.rs @@ -0,0 +1,109 @@ +#[derive(Copy, Clone)] +pub enum ActivationFunction { + Unit { min: f64, max: f64 }, + Rectifier { min: f64, max: f64, neg_value: f64 }, + BinaryStep { pos_value: f64, neg_value: f64 }, + Logistic { growth_rate: f64, max: f64 }, +} + +impl ActivationFunction { + /// Apply the activation function to a given value. + /// + /// The function applied depends on the current variant. In all cases the value + /// is clamped to the lower and upper bounds before application in the function. + pub fn apply(&self, value: f64) -> f64 { + let value = value.clamp(self.lower_bound(), self.upper_bound()); + match self { + Self::Unit { .. } => value, + Self::Rectifier { max, min, neg_value } => { + if value <= 0.0 { + *neg_value + } else { + min + value * (max - min) + } + } + Self::BinaryStep { pos_value, neg_value } => { + if value <= 0.0 { + *neg_value + } else { + *pos_value + } + } + Self::Logistic { growth_rate, max } => max / (1.0 + (-growth_rate * value).exp()), + } + } + + pub fn lower_bound(&self) -> f64 { + match self { + Self::Unit { min, .. } => *min, + Self::Rectifier { .. } => -1.0, + Self::BinaryStep { .. } => -1.0, + Self::Logistic { .. } => -6.0, + } + } + pub fn upper_bound(&self) -> f64 { + match self { + Self::Unit { max, .. } => *max, + Self::Rectifier { .. } => 1.0, + Self::BinaryStep { .. } => 1.0, + Self::Logistic { .. } => 6.0, + } + } +} + +#[cfg(test)] +mod tests { + use crate::parameters::ActivationFunction; + use float_cmp::assert_approx_eq; + + #[test] + fn test_unit() { + let af = ActivationFunction::Unit { min: -10.0, max: 10.0 }; + + assert_approx_eq!(f64, af.lower_bound(), -10.0); + assert_approx_eq!(f64, af.upper_bound(), 10.0); + assert_approx_eq!(f64, af.apply(0.0), 0.0); + // Out of range value is clamped + assert_approx_eq!(f64, af.apply(-20.0), -10.0); + assert_approx_eq!(f64, af.apply(20.0), 10.0); + } + + #[test] + fn test_rectifier() { + let af = ActivationFunction::Rectifier { + min: -10.0, + max: 10.0, + neg_value: 3.0, + }; + + assert_approx_eq!(f64, af.lower_bound(), -1.0); + assert_approx_eq!(f64, af.upper_bound(), 1.0); + assert_approx_eq!(f64, af.apply(0.0), 3.0); + assert_approx_eq!(f64, af.apply(-0.01), 3.0); + assert_approx_eq!(f64, af.apply(0.01), -10.0 + 0.01 * 20.0); + assert_approx_eq!(f64, af.apply(1.0), 10.0); + assert_approx_eq!(f64, af.apply(0.5), 0.0); + // Out of range value is clamped + assert_approx_eq!(f64, af.apply(-20.0), 3.0); + assert_approx_eq!(f64, af.apply(2.0), 10.0); + } + + #[test] + fn test_binary_step() { + let af = ActivationFunction::BinaryStep { + neg_value: -10.0, + pos_value: 10.0, + }; + + assert_approx_eq!(f64, af.lower_bound(), -1.0); + assert_approx_eq!(f64, af.upper_bound(), 1.0); + assert_approx_eq!(f64, af.apply(0.0), -10.0); + assert_approx_eq!(f64, af.apply(-0.01), -10.0); + assert_approx_eq!(f64, af.apply(0.01), 10.0); + assert_approx_eq!(f64, af.apply(1.0), 10.0); + assert_approx_eq!(f64, af.apply(0.5), 10.0); + // Out of range value is clamped + assert_approx_eq!(f64, af.apply(-20.0), -10.0); + assert_approx_eq!(f64, af.apply(2.0), 10.0); + } +} diff --git a/src/parameters/constant.rs b/src/parameters/constant.rs new file mode 100644 index 00000000..3d5f77e1 --- /dev/null +++ b/src/parameters/constant.rs @@ -0,0 +1,89 @@ +use crate::model::Model; +use crate::parameters::{ActivationFunction, Parameter, ParameterMeta, VariableParameter}; +use crate::scenario::ScenarioIndex; +use crate::state::State; +use crate::timestep::Timestep; +use crate::PywrError; +use std::any::Any; + +pub struct ConstantParameter { + meta: ParameterMeta, + value: f64, + variable: Option, +} + +impl ConstantParameter { + pub fn new(name: &str, value: f64, variable: Option) -> Self { + Self { + meta: ParameterMeta::new(name), + value, + variable, + } + } +} + +impl Parameter for ConstantParameter { + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn meta(&self) -> &ParameterMeta { + &self.meta + } + fn compute( + &self, + _timestep: &Timestep, + _scenario_index: &ScenarioIndex, + _model: &Model, + _state: &State, + _internal_state: &mut Option>, + ) -> Result { + Ok(self.value) + } + + fn as_variable(&self) -> Option<&dyn VariableParameter> { + Some(self) + } + + fn as_variable_mut(&mut self) -> Option<&mut dyn VariableParameter> { + Some(self) + } +} + +impl VariableParameter for ConstantParameter { + fn is_active(&self) -> bool { + self.variable.is_some() + } + + fn size(&self) -> usize { + 1 + } + + fn set_variables(&mut self, values: &[f64]) -> Result<(), PywrError> { + if values.len() == 1 { + let variable = self.variable.ok_or(PywrError::ParameterVariableNotActive)?; + self.value = variable.apply(values[0]); + Ok(()) + } else { + Err(PywrError::ParameterVariableValuesIncorrectLength) + } + } + + fn get_variables(&self) -> Vec { + vec![self.value] + } + + fn get_lower_bounds(&self) -> Result, PywrError> { + match self.variable { + Some(variable) => Ok(vec![variable.lower_bound()]), + None => Err(PywrError::ParameterVariableNotActive), + } + } + + fn get_upper_bounds(&self) -> Result, PywrError> { + match self.variable { + Some(variable) => Ok(vec![variable.upper_bound()]), + None => Err(PywrError::ParameterVariableNotActive), + } + } +} diff --git a/src/parameters/mod.rs b/src/parameters/mod.rs index d6cdd38e..8c0a2ef5 100644 --- a/src/parameters/mod.rs +++ b/src/parameters/mod.rs @@ -1,7 +1,9 @@ +mod activation_function; mod aggregated; mod aggregated_index; mod array; mod asymmetric; +mod constant; mod control_curves; mod delay; mod division; @@ -9,12 +11,14 @@ mod indexed_array; mod max; mod min; mod negative; +mod offset; mod polynomial; mod profiles; mod py; mod rhai; pub mod simple_wasm; mod threshold; +mod vector; use std::any::Any; // Re-imports @@ -24,10 +28,12 @@ use crate::model::Model; use crate::scenario::ScenarioIndex; use crate::state::{MultiValue, State}; use crate::timestep::Timestep; +pub use activation_function::ActivationFunction; pub use aggregated::{AggFunc, AggregatedParameter}; pub use aggregated_index::{AggIndexFunc, AggregatedIndexParameter}; pub use array::{Array1Parameter, Array2Parameter}; pub use asymmetric::AsymmetricSwitchIndexParameter; +pub use constant::ConstantParameter; pub use control_curves::{ ApportionParameter, ControlCurveIndexParameter, ControlCurveParameter, InterpolatedParameter, PiecewiseInterpolatedParameter, @@ -38,6 +44,7 @@ pub use indexed_array::IndexedArrayParameter; pub use max::MaxParameter; pub use min::MinParameter; pub use negative::NegativeParameter; +pub use offset::OffsetParameter; pub use polynomial::Polynomial1DParameter; pub use profiles::{DailyProfileParameter, MonthlyInterpDay, MonthlyProfileParameter, UniformDrawdownProfileParameter}; pub use py::PyParameter; @@ -45,6 +52,7 @@ use std::fmt; use std::fmt::{Display, Formatter}; use std::ops::Deref; pub use threshold::{Predicate, ThresholdParameter}; +pub use vector::VectorParameter; #[derive(Debug, Copy, Clone, Eq, PartialEq)] pub struct ParameterIndex(usize); @@ -179,6 +187,29 @@ pub trait Parameter: Send + Sync { ) -> Result<(), PywrError> { Ok(()) } + + /// Return the parameter as a [`VariableParameter'] if it supports being a variable. + fn as_variable(&self) -> Option<&dyn VariableParameter> { + None + } + + /// Return the parameter as a [`VariableParameter'] if it supports being a variable. + fn as_variable_mut(&mut self) -> Option<&mut dyn VariableParameter> { + None + } + + /// Can this parameter be a variable + fn can_be_variable(&self) -> bool { + self.as_variable().is_some() + } + + /// Is this parameter an active variable + fn is_variable_active(&self) -> bool { + match self.as_variable() { + Some(var) => var.is_active(), + None => false, + } + } } pub trait IndexParameter: Send + Sync { @@ -271,74 +302,19 @@ pub enum ParameterType { Multi(MultiValueParameterIndex), } -pub struct ConstantParameter { - meta: ParameterMeta, - value: f64, -} - -impl ConstantParameter { - pub fn new(name: &str, value: f64) -> Self { - Self { - meta: ParameterMeta::new(name), - value, - } - } -} - -impl Parameter for ConstantParameter { - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn meta(&self) -> &ParameterMeta { - &self.meta - } - fn compute( - &self, - _timestep: &Timestep, - _scenario_index: &ScenarioIndex, - _model: &Model, - _state: &State, - _internal_state: &mut Option>, - ) -> Result { - Ok(self.value) - } -} - -pub struct VectorParameter { - meta: ParameterMeta, - values: Vec, -} - -impl VectorParameter { - pub fn new(name: &str, values: Vec) -> Self { - Self { - meta: ParameterMeta::new(name), - values, - } - } -} - -impl Parameter for VectorParameter { - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - fn meta(&self) -> &ParameterMeta { - &self.meta - } - fn compute( - &self, - timestep: &Timestep, - _scenario_index: &ScenarioIndex, - _model: &Model, - _state: &State, - _internal_state: &mut Option>, - ) -> Result { - match self.values.get(timestep.index) { - Some(v) => Ok(*v), - None => Err(PywrError::TimestepIndexOutOfRange), - } - } +pub trait VariableParameter { + /// Is this variable activated (i.e. should be used in optimisation) + fn is_active(&self) -> bool; + /// Return the number of variables required + fn size(&self) -> usize; + /// Apply new variable values to the parameter + fn set_variables(&mut self, values: &[f64]) -> Result<(), PywrError>; + /// Get the current variable values + fn get_variables(&self) -> Vec; + /// Get variable lower bounds + fn get_lower_bounds(&self) -> Result, PywrError>; + /// Get variable upper bounds + fn get_upper_bounds(&self) -> Result, PywrError>; } #[cfg(test)] diff --git a/src/parameters/offset.rs b/src/parameters/offset.rs new file mode 100644 index 00000000..8b18517a --- /dev/null +++ b/src/parameters/offset.rs @@ -0,0 +1,93 @@ +use crate::metric::Metric; +use crate::model::Model; +use crate::parameters::{ActivationFunction, Parameter, ParameterMeta, VariableParameter}; +use crate::scenario::ScenarioIndex; +use std::any::Any; + +use crate::state::State; +use crate::timestep::Timestep; +use crate::PywrError; + +pub struct OffsetParameter { + meta: ParameterMeta, + metric: Metric, + offset: f64, + variable: Option, +} + +impl OffsetParameter { + pub fn new(name: &str, metric: Metric, offset: f64, variable: Option) -> Self { + Self { + meta: ParameterMeta::new(name), + metric, + offset, + variable, + } + } +} + +impl Parameter for OffsetParameter { + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + fn meta(&self) -> &ParameterMeta { + &self.meta + } + fn compute( + &self, + _timestep: &Timestep, + _scenario_index: &ScenarioIndex, + model: &Model, + state: &State, + _internal_state: &mut Option>, + ) -> Result { + // Current value + let x = self.metric.get_value(model, state)?; + Ok(x + self.offset) + } + fn as_variable(&self) -> Option<&dyn VariableParameter> { + Some(self) + } + + fn as_variable_mut(&mut self) -> Option<&mut dyn VariableParameter> { + Some(self) + } +} + +impl VariableParameter for OffsetParameter { + fn is_active(&self) -> bool { + self.variable.is_some() + } + + fn size(&self) -> usize { + 1 + } + + fn set_variables(&mut self, values: &[f64]) -> Result<(), PywrError> { + if values.len() == 1 { + let variable = self.variable.ok_or(PywrError::ParameterVariableNotActive)?; + self.offset = variable.apply(values[0]); + Ok(()) + } else { + Err(PywrError::ParameterVariableValuesIncorrectLength) + } + } + + fn get_variables(&self) -> Vec { + vec![self.offset] + } + + fn get_lower_bounds(&self) -> Result, PywrError> { + match self.variable { + Some(variable) => Ok(vec![variable.lower_bound()]), + None => Err(PywrError::ParameterVariableNotActive), + } + } + + fn get_upper_bounds(&self) -> Result, PywrError> { + match self.variable { + Some(variable) => Ok(vec![variable.upper_bound()]), + None => Err(PywrError::ParameterVariableNotActive), + } + } +} diff --git a/src/parameters/vector.rs b/src/parameters/vector.rs new file mode 100644 index 00000000..44f2a319 --- /dev/null +++ b/src/parameters/vector.rs @@ -0,0 +1,43 @@ +use crate::model::Model; +use crate::parameters::{Parameter, ParameterMeta}; +use crate::scenario::ScenarioIndex; +use crate::state::State; +use crate::timestep::Timestep; +use crate::PywrError; +use std::any::Any; + +pub struct VectorParameter { + meta: ParameterMeta, + values: Vec, +} + +impl VectorParameter { + pub fn new(name: &str, values: Vec) -> Self { + Self { + meta: ParameterMeta::new(name), + values, + } + } +} + +impl Parameter for VectorParameter { + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + fn meta(&self) -> &ParameterMeta { + &self.meta + } + fn compute( + &self, + timestep: &Timestep, + _scenario_index: &ScenarioIndex, + _model: &Model, + _state: &State, + _internal_state: &mut Option>, + ) -> Result { + match self.values.get(timestep.index) { + Some(v) => Ok(*v), + None => Err(PywrError::TimestepIndexOutOfRange), + } + } +} diff --git a/src/schema/mod.rs b/src/schema/mod.rs index 4dabecc9..ddb766f2 100644 --- a/src/schema/mod.rs +++ b/src/schema/mod.rs @@ -1,3 +1,9 @@ +//! Schema for a Pywr model. +//! +//! Schema definition for a Pywr model. +//! +//! Serializing and deserializing is accomplished using [`serde`]. +//! pub mod data_tables; pub mod edge; mod error; diff --git a/src/schema/model.rs b/src/schema/model.rs index 32f1ad4b..78e5abba 100644 --- a/src/schema/model.rs +++ b/src/schema/model.rs @@ -373,6 +373,7 @@ mod tests { comment: None, }, value: ConstantValue::Literal(10.0), + variable: None, }), Parameter::Aggregated(AggregatedParameter { meta: ParameterMeta { @@ -429,6 +430,7 @@ mod tests { comment: None, }, value: ConstantValue::Literal(10.0), + variable: None, }), Parameter::Constant(ConstantParameter { meta: ParameterMeta { @@ -436,6 +438,7 @@ mod tests { comment: None, }, value: ConstantValue::Literal(10.0), + variable: None, }), ]); } diff --git a/src/schema/parameters/aggregated.rs b/src/schema/parameters/aggregated.rs index ef61e991..2a9b48ac 100644 --- a/src/schema/parameters/aggregated.rs +++ b/src/schema/parameters/aggregated.rs @@ -44,23 +44,29 @@ impl From for AggFunc { } } -/// TODO finish this documentation -/// { -/// "type": "Aggregated", -/// "agg_func": "sum", -/// "parameters": [ -/// 3.1415, -/// { -/// "table": "demands", -/// "index": "my-node", -/// }, -/// "my-other-parameter", -/// { -/// "type": "MonthlyProfile", -/// "values": [] -/// } -/// ] -/// } +/// Schema for a parameter that aggregates metrics using a user specified function. +/// +/// Each time-step the aggregation is updated using the current values of the referenced metrics. +/// The available aggregation functions are defined by the [`AggFunc`] enum. +/// +/// This parameter definition is applied to a model using [`crate::parameters::AggregatedParameter`]. +/// +/// See also [`AggregatedIndexParameter`] for aggregation of integer values. +/// +/// # JSON Examples +/// +/// The example below shows the definition of an [`AggregatedParameter`] that sums the values +/// from a variety of sources: +/// - a literal constant: 3.1415, +/// - a constant value from the table "demands" with reference "my-node", +/// - the current value of the parameter "my-other-parameter", +/// - the current volume of the node "my-reservoir", and +/// - the current value of the inline monthly profile, named "my-monthly-profile". +/// +/// ```json +#[doc = include_str!("doc_examples/aggregated_1.json")] +/// ``` + #[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] pub struct AggregatedParameter { #[serde(flatten)] diff --git a/src/schema/parameters/core.rs b/src/schema/parameters/core.rs index d93bf8e4..61b7650e 100644 --- a/src/schema/parameters/core.rs +++ b/src/schema/parameters/core.rs @@ -12,11 +12,143 @@ use pywr_schema::parameters::{ use std::collections::HashMap; use std::path::Path; +/// Activation function or transformation to apply to variable value. +/// +/// These different functions are used to specify how a variable value is transformed +/// before being used in a model. These transformations can be useful for optimisation +/// algorithms to represent a, for example, binary-like variable in a continuous domain. Each +/// activation function requires different data to parameterize the function's behaviour. +/// +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone, Copy)] +#[serde(tag = "type")] +pub enum ActivationFunction { + /// A unit or null transformation. + /// + /// ```rust + /// # use pywr::schema::parameters::ActivationFunction; + /// let data = r#" + /// { + /// "type": "Unit", + /// "min": 0.0, + /// "max": 10.0 + /// }"#; + /// let a: ActivationFunction = serde_json::from_str(data)?; + /// # Ok::<(), serde_json::Error>(()) + /// ``` + Unit { min: f64, max: f64 }, + /// A linear rectifier function, or ramp function. + /// + /// ```rust + /// # use pywr::schema::parameters::ActivationFunction; + /// let data = r#" + /// { + /// "type": "Rectifier", + /// "min": 0.0, + /// "max": 10.0 + /// }"#; + /// let a: ActivationFunction = serde_json::from_str(data)?; + /// # Ok::<(), serde_json::Error>(()) + /// ``` + Rectifier { + /// Minimum output of the function (i.e. when x is 0.0) + min: f64, + /// Maximum output of the function (i.e. when x is 1.0). + max: f64, + /// Value to return in the negative part of the function (defaults to zero). + off_value: Option, + }, + /// A binary-step function. + /// + /// ```rust + /// # use pywr::schema::parameters::ActivationFunction; + /// let data = r#" + /// { + /// "type": "BinaryStep", + /// "on_value": 0.0, + /// "off_value": 10.0 + /// }"#; + /// let a: ActivationFunction = serde_json::from_str(data)?; + /// # Ok::<(), serde_json::Error>(()) + /// ``` + BinaryStep { + /// Value to return in the positive part of the function. + on_value: f64, + /// Value to return in the negative part of the function (defaults to zero). + off_value: Option, + }, + /// A logistic, or S, function. + /// + /// ```rust + /// # use pywr::schema::parameters::ActivationFunction; + /// let data = r#" + /// { + /// "type": "Logistic", + /// "growth_rate": 1.0, + /// "max": 10.0 + /// }"#; + /// let a: ActivationFunction = serde_json::from_str(data)?; + /// # Ok::<(), serde_json::Error>(()) + /// ``` + Logistic { growth_rate: f64, max: f64 }, +} + +impl Into for ActivationFunction { + fn into(self) -> crate::parameters::ActivationFunction { + match self { + Self::Unit { min, max } => crate::parameters::ActivationFunction::Unit { min, max }, + Self::Rectifier { min, max, off_value } => crate::parameters::ActivationFunction::Rectifier { + min, + max, + neg_value: off_value.unwrap_or(0.0), + }, + Self::BinaryStep { on_value, off_value } => crate::parameters::ActivationFunction::BinaryStep { + pos_value: on_value, + neg_value: off_value.unwrap_or(0.0), + }, + Self::Logistic { growth_rate, max } => crate::parameters::ActivationFunction::Logistic { growth_rate, max }, + } + } +} + +/// Settings for a variable value. +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +pub struct VariableSettings { + /// Is this parameter an active variable? + pub is_active: bool, + /// The activation function to use for the variable. + pub activation: ActivationFunction, +} + +/// A constant parameter. +/// +/// This is the most basic type of parameter which represents a single constant value. +/// +/// # JSON Examples +/// +/// A simple example: +/// ```json +#[doc = include_str!("doc_examples/constant_simple.json")] +/// ``` +/// +/// An example specifying the parameter as a variable and defining the activation function: +/// ```json +#[doc = include_str!("doc_examples/constant_variable.json")] +/// ``` +/// #[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] pub struct ConstantParameter { + /// Meta-data. + /// + /// This field is flattened in the serialised format. #[serde(flatten)] pub meta: ParameterMeta, + /// The value the parameter should return. + /// + /// In the simple case this will be the value used by the model. However, if an activation + /// function is specified this value will be the `x` value for that activation function. pub value: ConstantValue, + /// Definition of optional variable settings. + pub variable: Option, } impl ConstantParameter { @@ -33,7 +165,19 @@ impl ConstantParameter { model: &mut crate::model::Model, tables: &LoadedTableCollection, ) -> Result { - let p = crate::parameters::ConstantParameter::new(&self.meta.name, self.value.load(tables)?); + let variable = match &self.variable { + None => None, + Some(v) => { + // Only set the variable data if the user has indicated the variable is active. + if v.is_active { + Some(v.activation.into()) + } else { + None + } + } + }; + + let p = crate::parameters::ConstantParameter::new(&self.meta.name, self.value.load(tables)?, variable); model.add_parameter(Box::new(p)) } } @@ -57,6 +201,7 @@ impl TryFromV1Parameter for ConstantParameter { let p = Self { meta: v1.meta.into_v2_parameter(parent_node, unnamed_count), value, + variable: None, // TODO implement conversion of v1 variable definition }; Ok(p) } diff --git a/src/schema/parameters/doc_examples/aggregated_1.json b/src/schema/parameters/doc_examples/aggregated_1.json new file mode 100644 index 00000000..0ec79d39 --- /dev/null +++ b/src/schema/parameters/doc_examples/aggregated_1.json @@ -0,0 +1,28 @@ +{ + "name": "my-aggregated-value", + "type": "Aggregated", + "agg_func": "sum", + "metrics": [ + 3.1415, + { + "table": "demands", + "index": "my-node" + }, + { + "type": "Parameter", + "name": "my-other-parameter" + }, + { + "type": "NodeVolume", + "name": "my-reservoir" + }, + { + "type": "InlineParameter", + "definition": { + "name": "my-monthly-profile", + "type": "MonthlyProfile", + "values": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0] + } + } + ] +} diff --git a/src/schema/parameters/doc_examples/constant_simple.json b/src/schema/parameters/doc_examples/constant_simple.json new file mode 100644 index 00000000..1ff5fe3d --- /dev/null +++ b/src/schema/parameters/doc_examples/constant_simple.json @@ -0,0 +1,5 @@ +{ + "type": "Constant", + "name": "my-constant", + "value": 10.0 +} diff --git a/src/schema/parameters/doc_examples/constant_variable.json b/src/schema/parameters/doc_examples/constant_variable.json new file mode 100644 index 00000000..8c2810a0 --- /dev/null +++ b/src/schema/parameters/doc_examples/constant_variable.json @@ -0,0 +1,13 @@ +{ + "type": "Constant", + "name": "my-variable", + "value": 5.0, + "variable": { + "is_active": true, + "activation": { + "type": "Unit", + "min": 0.0, + "max": 10.0 + } + } +} diff --git a/src/schema/parameters/doc_examples/offset_simple.json b/src/schema/parameters/doc_examples/offset_simple.json new file mode 100644 index 00000000..2479be7b --- /dev/null +++ b/src/schema/parameters/doc_examples/offset_simple.json @@ -0,0 +1,9 @@ +{ + "type": "Offset", + "name": "my-offset", + "offset": 3.14, + "metric": { + "type": "Parameter", + "name": "my-other-parameter" + } +} diff --git a/src/schema/parameters/doc_examples/offset_variable.json b/src/schema/parameters/doc_examples/offset_variable.json new file mode 100644 index 00000000..e6041ea4 --- /dev/null +++ b/src/schema/parameters/doc_examples/offset_variable.json @@ -0,0 +1,17 @@ +{ + "type": "Offset", + "name": "my-variable-offset", + "offset": 5.0, + "metric": { + "type": "Parameter", + "name": "my-other-parameter" + }, + "variable": { + "is_active": true, + "activation": { + "type": "Unit", + "min": 0.0, + "max": 10.0 + } + } +} diff --git a/src/schema/parameters/mod.rs b/src/schema/parameters/mod.rs index ef779491..12179ee4 100644 --- a/src/schema/parameters/mod.rs +++ b/src/schema/parameters/mod.rs @@ -1,3 +1,12 @@ +//! Parameter schema definitions. +//! +//! The enum [`Parameter`] contains all of the valid Pywr parameter schemas. The parameter +//! variants define separate schemas for different parameter types. When a model is generated +//! from a schema the parameter schemas are added to the model using [`Parameter::add_to_model`]. +//! This typically adds a struct from [`crate::parameters`] to the model using the data +//! defined in the schema. +//! +//! Serializing and deserializing is accomplished using [`serde`]. mod aggregated; mod asymmetric_switch; mod control_curves; @@ -5,6 +14,7 @@ mod core; mod data_frame; mod delay; mod indexed_array; +mod offset; mod polynomial; mod profiles; mod python; @@ -18,7 +28,9 @@ pub use super::parameters::control_curves::{ ControlCurveIndexParameter, ControlCurveInterpolatedParameter, ControlCurveParameter, ControlCurvePiecewiseInterpolatedParameter, }; -pub use super::parameters::core::{ConstantParameter, MaxParameter, MinParameter, NegativeParameter}; +pub use super::parameters::core::{ + ActivationFunction, ConstantParameter, MaxParameter, MinParameter, NegativeParameter, VariableSettings, +}; pub use super::parameters::delay::DelayParameter; pub use super::parameters::indexed_array::IndexedArrayParameter; pub use super::parameters::polynomial::Polynomial1DParameter; @@ -34,6 +46,7 @@ use crate::schema::error::ConversionError; use crate::schema::parameters::core::DivisionParameter; pub use crate::schema::parameters::data_frame::DataFrameParameter; use crate::{IndexParameterIndex, NodeIndex, PywrError}; +pub use offset::OffsetParameter; use pywr_schema::parameters::{ CoreParameter, ExternalDataRef as ExternalDataRefV1, Parameter as ParameterV1, ParameterMeta as ParameterMetaV1, ParameterValue as ParameterValueV1, TableIndex as TableIndexV1, @@ -145,6 +158,7 @@ pub enum Parameter { DataFrame(DataFrameParameter), Delay(DelayParameter), Division(DivisionParameter), + Offset(OffsetParameter), } impl Parameter { @@ -171,7 +185,8 @@ impl Parameter { Self::Python(p) => p.meta.name.as_str(), Self::DataFrame(p) => p.meta.name.as_str(), Self::Division(p) => p.meta.name.as_str(), - Parameter::Delay(p) => p.meta.name.as_str(), + Self::Delay(p) => p.meta.name.as_str(), + Self::Offset(p) => p.meta.name.as_str(), } } @@ -201,6 +216,7 @@ impl Parameter { Self::DataFrame(p) => p.node_references(), Self::Delay(p) => p.node_references(), Self::Division(p) => p.node_references(), + Self::Offset(p) => p.node_references(), } } @@ -247,6 +263,7 @@ impl Parameter { Self::DataFrame(_) => "DataFrame", Self::Delay(_) => "Delay", Self::Division(_) => "Division", + Self::Offset(_) => "Offset", } } @@ -281,6 +298,7 @@ impl Parameter { Self::DataFrame(p) => ParameterType::Parameter(p.add_to_model(model, data_path)?), Self::Delay(p) => ParameterType::Parameter(p.add_to_model(model, tables, data_path)?), Self::Division(p) => ParameterType::Parameter(p.add_to_model(model, tables, data_path)?), + Self::Offset(p) => ParameterType::Parameter(p.add_to_model(model, tables, data_path)?), }; Ok(ty) @@ -362,6 +380,7 @@ impl TryFromV1Parameter for Parameter { comment: Some(comment), }, value: ConstantValue::Literal(0.0), + variable: None, }) } }; @@ -722,3 +741,25 @@ impl<'a> From<&'a Vec> for DynamicFloatValueType<'a> { Self::List(v) } } + +#[cfg(test)] +mod tests { + use crate::schema::parameters::Parameter; + use std::fs; + use std::path::PathBuf; + + /// Test all of the documentation examples successfully deserialize. + #[test] + fn test_doc_examples() { + let mut doc_examples = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + doc_examples.push("src/schema/parameters/doc_examples"); + + for entry in fs::read_dir(doc_examples).unwrap() { + let p = entry.unwrap().path(); + if p.is_file() { + let data = fs::read_to_string(p).unwrap(); + let _: Parameter = serde_json::from_str(&data).unwrap(); + } + } + } +} diff --git a/src/schema/parameters/offset.rs b/src/schema/parameters/offset.rs new file mode 100644 index 00000000..08537261 --- /dev/null +++ b/src/schema/parameters/offset.rs @@ -0,0 +1,74 @@ +use crate::schema::data_tables::LoadedTableCollection; +use crate::schema::parameters::{ + ConstantValue, DynamicFloatValue, DynamicFloatValueType, ParameterMeta, VariableSettings, +}; +use crate::{ParameterIndex, PywrError}; + +use std::collections::HashMap; +use std::path::Path; + +/// A parameter that returns a fixed delta from another metric. +/// +/// # JSON Examples +/// +/// A simple example that returns 3.14 plus the value of the Parameter "my-other-parameter". +/// ```json +#[doc = include_str!("doc_examples/offset_simple.json")] +/// ``` +/// +/// An example specifying the parameter as a variable and defining the activation function: +/// ```json +#[doc = include_str!("doc_examples/offset_variable.json")] +/// ``` +/// +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +pub struct OffsetParameter { + /// Meta-data. + /// + /// This field is flattened in the serialised format. + #[serde(flatten)] + pub meta: ParameterMeta, + /// The offset value applied to the metric. + /// + /// In the simple case this will be the value used by the model. However, if an activation + /// function is specified this value will be the `x` value for that activation function. + pub offset: ConstantValue, + /// The metric from which to apply the offset. + pub metric: DynamicFloatValue, + /// Definition of optional variable settings. + pub variable: Option, +} + +impl OffsetParameter { + pub fn node_references(&self) -> HashMap<&str, &str> { + HashMap::new() + } + + pub fn parameters(&self) -> HashMap<&str, DynamicFloatValueType> { + HashMap::new() + } + + pub fn add_to_model( + &self, + model: &mut crate::model::Model, + tables: &LoadedTableCollection, + data_path: Option<&Path>, + ) -> Result { + let variable = match &self.variable { + None => None, + Some(v) => { + // Only set the variable data if the user has indicated the variable is active. + if v.is_active { + Some(v.activation.into()) + } else { + None + } + } + }; + + let idx = self.metric.load(model, tables, data_path)?; + + let p = crate::parameters::OffsetParameter::new(&self.meta.name, idx, self.offset.load(tables)?, variable); + model.add_parameter(Box::new(p)) + } +} diff --git a/src/test_utils.rs b/src/test_utils.rs index 99fc26bd..0e61b397 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -40,7 +40,7 @@ pub fn simple_model(num_scenarios: usize) -> Model { let base_demand = 10.0; - let demand_factor = ConstantParameter::new("demand-factor", 1.2); + let demand_factor = ConstantParameter::new("demand-factor", 1.2, None); let demand_factor = model.add_parameter(Box::new(demand_factor)).unwrap(); let total_demand = AggregatedParameter::new( @@ -50,7 +50,7 @@ pub fn simple_model(num_scenarios: usize) -> Model { ); let total_demand = model.add_parameter(Box::new(total_demand)).unwrap(); - let demand_cost = ConstantParameter::new("demand-cost", -10.0); + let demand_cost = ConstantParameter::new("demand-cost", -10.0, None); let demand_cost = model.add_parameter(Box::new(demand_cost)).unwrap(); let output_node = model.get_mut_node_by_name("output", None).unwrap(); @@ -84,10 +84,10 @@ pub fn simple_storage_model() -> Model { // Apply demand to the model // TODO convenience function for adding a constant constraint. - let demand = ConstantParameter::new("demand", 10.0); + let demand = ConstantParameter::new("demand", 10.0, None); let demand = model.add_parameter(Box::new(demand)).unwrap(); - let demand_cost = ConstantParameter::new("demand-cost", -10.0); + let demand_cost = ConstantParameter::new("demand-cost", -10.0, None); let demand_cost = model.add_parameter(Box::new(demand_cost)).unwrap(); let output_node = model.get_mut_node_by_name("output", None).unwrap();