diff --git a/pywr-core/src/metric.rs b/pywr-core/src/metric.rs index d7b7017c..b5b62de9 100644 --- a/pywr-core/src/metric.rs +++ b/pywr-core/src/metric.rs @@ -81,6 +81,7 @@ pub enum MetricF64 { NodeInFlow(NodeIndex), NodeOutFlow(NodeIndex), NodeVolume(NodeIndex), + NodeMaxVolume(NodeIndex), AggregatedNodeInFlow(AggregatedNodeIndex), AggregatedNodeOutFlow(AggregatedNodeIndex), AggregatedNodeVolume(AggregatedStorageNodeIndex), @@ -103,6 +104,7 @@ impl MetricF64 { MetricF64::NodeInFlow(idx) => Ok(state.get_network_state().get_node_in_flow(idx)?), MetricF64::NodeOutFlow(idx) => Ok(state.get_network_state().get_node_out_flow(idx)?), MetricF64::NodeVolume(idx) => Ok(state.get_network_state().get_node_volume(idx)?), + MetricF64::NodeMaxVolume(idx) => Ok(model.get_node(idx)?.get_current_max_volume(state)?), MetricF64::AggregatedNodeInFlow(idx) => { let node = model.get_aggregated_node(idx)?; node.iter_nodes() diff --git a/pywr-core/src/models/simple.rs b/pywr-core/src/models/simple.rs index a1b382b8..52e1ef65 100644 --- a/pywr-core/src/models/simple.rs +++ b/pywr-core/src/models/simple.rs @@ -53,7 +53,7 @@ impl Model { &mut self.network } - /// Check whether a solver [`S`] has the required features to run this model. + /// Check whether a solver `S` has the required features to run this model. pub fn check_solver_features(&self) -> bool where S: Solver, @@ -61,7 +61,7 @@ impl Model { self.network.check_solver_features::() } - /// Check whether a solver [`S`] has the required features to run this model. + /// Check whether a solver `S` has the required features to run this model. pub fn check_multi_scenario_solver_features(&self) -> bool where S: MultiStateSolver, diff --git a/pywr-core/src/network.rs b/pywr-core/src/network.rs index f13a457e..2ed09be5 100644 --- a/pywr-core/src/network.rs +++ b/pywr-core/src/network.rs @@ -213,6 +213,7 @@ impl Network { pub fn nodes(&self) -> &NodeVec { &self.nodes } + pub fn edges(&self) -> &EdgeVec { &self.edges } @@ -285,7 +286,7 @@ impl Network { Ok(recorder_internal_states) } - /// Check whether a solver [`S`] has the required features to run this network. + /// Check whether a solver `S` has the required features to run this network. pub fn check_solver_features(&self) -> bool where S: Solver, @@ -295,7 +296,7 @@ impl Network { required_features.iter().all(|f| S::features().contains(f)) } - /// Check whether a solver [`S`] has the required features to run this network. + /// Check whether a solver `S` has the required features to run this network. pub fn check_multi_scenario_solver_features(&self) -> bool where S: MultiStateSolver, @@ -1140,7 +1141,7 @@ impl Network { } } - /// Get a [`Parameter`] from its index. + /// Get a `Parameter` from its index. pub fn get_index_parameter(&self, index: ParameterIndex) -> Result<&dyn parameters::Parameter, PywrError> { match self.parameters.get_usize(index) { Some(p) => Ok(p), @@ -1462,7 +1463,7 @@ impl Network { Ok(edge_index) } - /// Set the variable values on the parameter [`parameter_index`]. + /// Set the variable values on the parameter `parameter_index`. /// /// This will update the internal state of the parameter with the new values for all scenarios. pub fn set_f64_parameter_variable_values( @@ -1492,7 +1493,7 @@ impl Network { } } - /// Set the variable values on the parameter [`parameter_index`] and scenario [`scenario_index`]. + /// Set the variable values on the parameter `parameter_index` and scenario `scenario_index`. /// /// Only the internal state of the parameter for the given scenario will be updated. pub fn set_f64_parameter_variable_values_for_scenario( @@ -1568,7 +1569,7 @@ impl Network { } } - /// Set the variable values on the parameter [`parameter_index`]. + /// Set the variable values on the parameter `parameter_index`. /// /// This will update the internal state of the parameter with the new values for scenarios. pub fn set_u32_parameter_variable_values( @@ -1598,7 +1599,7 @@ impl Network { } } - /// Set the variable values on the parameter [`parameter_index`] and scenario [`scenario_index`]. + /// Set the variable values on the parameter `parameter_index` and scenario `scenario_index`. /// /// Only the internal state of the parameter for the given scenario will be updated. pub fn set_u32_parameter_variable_values_for_scenario( diff --git a/pywr-core/src/recorders/csv.rs b/pywr-core/src/recorders/csv.rs index 7d91088d..d8642aa2 100644 --- a/pywr-core/src/recorders/csv.rs +++ b/pywr-core/src/recorders/csv.rs @@ -12,7 +12,7 @@ use std::num::NonZeroU32; use std::ops::Deref; use std::path::PathBuf; -/// Output the values from a [`MetricSet`] to a CSV file. +/// Output the values from a [`crate::recorders::MetricSet`] to a CSV file. #[derive(Clone, Debug)] pub struct CsvWideFmtOutput { meta: RecorderMeta, @@ -196,7 +196,7 @@ pub struct CsvLongFmtRecord { value: f64, } -/// Output the values from a several [`MetricSet`]s to a CSV file in long format. +/// Output the values from a several [`crate::recorders::MetricSet`]s to a CSV file in long format. /// /// The long format contains a row for each value produced by the metric set. This is useful /// for analysis in tools like R or Python which can easily read long format data. diff --git a/pywr-core/src/timestep.rs b/pywr-core/src/timestep.rs index 929db119..9f8976b5 100644 --- a/pywr-core/src/timestep.rs +++ b/pywr-core/src/timestep.rs @@ -68,7 +68,7 @@ impl PywrDuration { } /// Convert the duration to a string representation that can be parsed by polars - /// see: https://docs.rs/polars/latest/polars/prelude/struct.Duration.html#method.parse + /// see: pub fn duration_string(&self) -> String { let milliseconds = self.milliseconds(); let mut duration = String::new(); diff --git a/pywr-schema/src/error.rs b/pywr-schema/src/error.rs index d67cee32..3a2f8ac9 100644 --- a/pywr-schema/src/error.rs +++ b/pywr-schema/src/error.rs @@ -65,6 +65,8 @@ pub enum SchemaError { OutOfRange(#[from] chrono::OutOfRange), #[error("The metric set with name '{0}' contains no metrics")] EmptyMetricSet(String), + #[error("Missing the following attribute {attr:?} on node {name:?}.")] + MissingNodeAttribute { attr: String, name: String }, } #[cfg(feature = "core")] diff --git a/pywr-schema/src/model.rs b/pywr-schema/src/model.rs index 2382965c..182ae128 100644 --- a/pywr-schema/src/model.rs +++ b/pywr-schema/src/model.rs @@ -459,10 +459,11 @@ impl PywrNetwork { .ok_or_else(|| SchemaError::NodeNotFound(edge.to_node.clone()))?; let from_slot = edge.from_slot.as_deref(); + let to_slot = edge.to_slot.as_deref(); // Connect each "from" connector to each "to" connector for from_connector in from_node.output_connectors(from_slot) { - for to_connector in to_node.input_connectors() { + for to_connector in to_node.input_connectors(to_slot) { let from_node_index = network.get_node_index_by_name(from_connector.0, from_connector.1.as_deref())?; let to_node_index = network.get_node_index_by_name(to_connector.0, to_connector.1.as_deref())?; diff --git a/pywr-schema/src/nodes/core.rs b/pywr-schema/src/nodes/core.rs index 1fd3c530..685dc246 100644 --- a/pywr-schema/src/nodes/core.rs +++ b/pywr-schema/src/nodes/core.rs @@ -810,6 +810,7 @@ impl StorageNode { let metric = match attr { NodeAttribute::Volume => MetricF64::NodeVolume(idx), + NodeAttribute::MaxVolume => MetricF64::NodeMaxVolume(idx), NodeAttribute::ProportionalVolume => { let dm = DerivedMetric::NodeProportionalVolume(idx); let derived_metric_idx = network.add_derived_metric(dm); @@ -931,7 +932,7 @@ impl TryFrom for StorageNode { #[doc = svgbobdoc::transform!( /// This is used to represent a catchment inflow. /// -/// Catchment nodes create a single [`crate::node::InputNode`] node in the network, but +/// Catchment nodes create a single [`InputNode`] node in the network, but /// ensure that the maximum and minimum flow are equal to [`Self::flow`]. /// /// ```svgbob diff --git a/pywr-schema/src/nodes/delay.rs b/pywr-schema/src/nodes/delay.rs index 6be0e61d..6f0931f6 100644 --- a/pywr-schema/src/nodes/delay.rs +++ b/pywr-schema/src/nodes/delay.rs @@ -16,9 +16,9 @@ use schemars::JsonSchema; /// /// This is often useful in long river reaches as a simply way to model time-of-travel. Internally /// an `Output` node is used to terminate flows entering the node and an `Input` node is created -/// with flow constraints set by a [DelayParameter]. These constraints set the minimum and +/// with flow constraints set by a [`pywr_core::parameters::DelayParameter`]. These constraints set the minimum and /// maximum flow on the `Input` node equal to the flow reaching the `Output` node N time-steps -/// ago. The internally created [DelayParameter] is created with this node's name and the suffix +/// ago. The internally created [`pywr_core::parameters::DelayParameter`] is created with this node's name and the suffix /// "-delay". /// /// diff --git a/pywr-schema/src/nodes/mod.rs b/pywr-schema/src/nodes/mod.rs index 661673bb..9d830e96 100644 --- a/pywr-schema/src/nodes/mod.rs +++ b/pywr-schema/src/nodes/mod.rs @@ -5,6 +5,7 @@ mod loss_link; mod monthly_virtual_storage; mod piecewise_link; mod piecewise_storage; +mod reservoir; mod river; mod river_gauge; mod river_split_with_gauge; @@ -20,6 +21,10 @@ use crate::metric::Metric; #[cfg(feature = "core")] use crate::model::LoadArgs; use crate::model::PywrNetwork; +pub use crate::nodes::reservoir::{ + Bathymetry, BathymetryType, Evaporation, Leakage, Rainfall, ReservoirNode, SpillNodeType, +}; + use crate::parameters::TimeseriesV1Data; use crate::visit::{VisitMetrics, VisitPaths}; pub use annual_virtual_storage::{AnnualReset, AnnualVirtualStorageNode}; @@ -96,10 +101,17 @@ pub enum NodeAttribute { Inflow, Outflow, Volume, + MaxVolume, ProportionalVolume, Loss, Deficit, Power, + /// The compensation flow out of a [`ReservoirNode`] + Compensation, + /// The rainfall volume into a [`ReservoirNode`] + Rainfall, + /// The evaporation volume out of a [`ReservoirNode`] + Evaporation, } pub struct NodeBuilder { @@ -233,6 +245,7 @@ impl NodeBuilder { meta, ..Default::default() }), + NodeType::Reservoir => Node::Reservoir(ReservoirNode { ..Default::default() }), } } } @@ -263,6 +276,7 @@ pub enum Node { MonthlyVirtualStorage(MonthlyVirtualStorageNode), RollingVirtualStorage(RollingVirtualStorageNode), Turbine(TurbineNode), + Reservoir(ReservoirNode), } impl Node { @@ -301,10 +315,11 @@ impl Node { Node::MonthlyVirtualStorage(n) => &n.meta, Node::RollingVirtualStorage(n) => &n.meta, Node::Turbine(n) => &n.meta, + Node::Reservoir(n) => n.meta(), } } - pub fn input_connectors(&self) -> Vec<(&str, Option)> { + pub fn input_connectors(&self, slot: Option<&str>) -> Vec<(&str, Option)> { match self { Node::Input(n) => n.input_connectors(), Node::Link(n) => n.input_connectors(), @@ -327,6 +342,7 @@ impl Node { Node::Delay(n) => n.input_connectors(), Node::RollingVirtualStorage(n) => n.input_connectors(), Node::Turbine(n) => n.input_connectors(), + Node::Reservoir(n) => n.input_connectors(slot), } } @@ -353,6 +369,7 @@ impl Node { Node::Delay(n) => n.output_connectors(), Node::RollingVirtualStorage(n) => n.output_connectors(), Node::Turbine(n) => n.output_connectors(), + Node::Reservoir(n) => n.output_connectors(slot), } } @@ -378,6 +395,7 @@ impl Node { Node::Delay(n) => n.default_metric(), Node::RollingVirtualStorage(n) => n.default_metric(), Node::Turbine(n) => n.default_metric(), + Node::Reservoir(n) => n.default_metric(), } } } @@ -406,6 +424,7 @@ impl Node { Node::Turbine(n) => n.add_to_model(network, args), Node::MonthlyVirtualStorage(n) => n.add_to_model(network, args), Node::RollingVirtualStorage(n) => n.add_to_model(network, args), + Node::Reservoir(n) => n.add_to_model(network), } } @@ -436,6 +455,7 @@ impl Node { Node::Turbine(n) => n.node_indices_for_constraints(network), Node::MonthlyVirtualStorage(n) => n.node_indices_for_constraints(network, args), Node::RollingVirtualStorage(n) => n.node_indices_for_constraints(network, args), + Node::Reservoir(n) => n.node_indices_for_constraints(network), } } @@ -463,6 +483,7 @@ impl Node { Node::PiecewiseStorage(n) => n.set_constraints(network, args), Node::Delay(n) => n.set_constraints(network, args), Node::Turbine(n) => n.set_constraints(network, args), + Node::Reservoir(n) => n.set_constraints(network, args), Node::MonthlyVirtualStorage(_) => Ok(()), // TODO Node::RollingVirtualStorage(_) => Ok(()), // TODO } @@ -496,6 +517,7 @@ impl Node { Node::Delay(n) => n.create_metric(network, attribute), Node::RollingVirtualStorage(n) => n.create_metric(network, attribute), Node::Turbine(n) => n.create_metric(network, attribute, args), + Node::Reservoir(n) => n.create_metric(network, attribute), } } } @@ -573,6 +595,7 @@ impl VisitMetrics for Node { Node::MonthlyVirtualStorage(n) => n.visit_metrics(visitor), Node::RollingVirtualStorage(n) => n.visit_metrics(visitor), Node::Turbine(n) => n.visit_metrics(visitor), + Node::Reservoir(n) => n.visit_metrics(visitor), } } @@ -598,6 +621,7 @@ impl VisitMetrics for Node { Node::MonthlyVirtualStorage(n) => n.visit_metrics_mut(visitor), Node::RollingVirtualStorage(n) => n.visit_metrics_mut(visitor), Node::Turbine(n) => n.visit_metrics_mut(visitor), + Node::Reservoir(n) => n.visit_metrics_mut(visitor), } } } @@ -625,6 +649,7 @@ impl VisitPaths for Node { Node::MonthlyVirtualStorage(n) => n.visit_paths(visitor), Node::RollingVirtualStorage(n) => n.visit_paths(visitor), Node::Turbine(n) => n.visit_paths(visitor), + Node::Reservoir(n) => n.visit_paths(visitor), } } @@ -650,6 +675,7 @@ impl VisitPaths for Node { Node::MonthlyVirtualStorage(n) => n.visit_paths_mut(visitor), Node::RollingVirtualStorage(n) => n.visit_paths_mut(visitor), Node::Turbine(n) => n.visit_paths_mut(visitor), + Node::Reservoir(n) => n.visit_paths_mut(visitor), } } } diff --git a/pywr-schema/src/nodes/reservoir.rs b/pywr-schema/src/nodes/reservoir.rs new file mode 100644 index 00000000..d902c345 --- /dev/null +++ b/pywr-schema/src/nodes/reservoir.rs @@ -0,0 +1,588 @@ +use crate::metric::Metric; +#[cfg(feature = "core")] +use crate::model::LoadArgs; +use crate::nodes::{NodeAttribute, NodeMeta, StorageNode}; +#[cfg(feature = "core")] +use crate::SchemaError; +#[cfg(feature = "core")] +use pywr_core::derived_metric::DerivedMetric; +#[cfg(feature = "core")] +use pywr_core::metric::ConstantMetricF64::Constant; +#[cfg(feature = "core")] +use pywr_core::metric::MetricF64; +#[cfg(feature = "core")] +use pywr_core::metric::SimpleMetricF64; +#[cfg(feature = "core")] +use pywr_core::parameters::{AggFunc, ParameterName}; +use pywr_schema_macros::PywrVisitAll; +use schemars::JsonSchema; + +/// The type of spill node. +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, JsonSchema, PywrVisitAll)] +pub enum SpillNodeType { + /// The spill node is created as output node. + OutputNode, + /// The spill node is created as link node. + LinkNode, +} + +/// The bathymetry data type. +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, JsonSchema, PywrVisitAll)] +pub enum BathymetryType { + /// The bathymetry is calculated by interpolating the storage and area data piecewise. + Interpolated { storage: Metric, area: Metric }, + /// The bathymetry is calculated using a polynomial expressions and the provided coefficients. + Polynomial(Vec), +} + +/// The bathymetric data +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, JsonSchema, PywrVisitAll)] +pub struct Bathymetry { + /// The bathymetric data and type. + pub data: BathymetryType, + /// Whether the `storage` provided by the [`BathymetryType`] is relative (0-1). + pub is_storage_relative: bool, +} + +/// The evaporation data +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, JsonSchema, PywrVisitAll)] +pub struct Evaporation { + /// The [`Metric`] containing the evaporation height. + data: Metric, + /// The cost to assign to the [`crate::nodes::OutputNode`]. + cost: Option, + /// If `true` the maximum surface area will be used to calculate the evaporation volume. When + /// `false`, the area is calculated from the bathymetric data. This defaults to `false`. + use_max_area: Option, +} + +/// The leakage data +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, JsonSchema, PywrVisitAll)] +pub struct Leakage { + /// The [`Metric`] containing the lost volume. + loss: Metric, + /// The cost to assign to the [`crate::nodes::OutputNode`]. + cost: Option, +} + +/// The rainfall data +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, JsonSchema, PywrVisitAll)] +pub struct Rainfall { + /// The [`Metric`] containing the rainfall level. + data: Metric, + /// If `true` the maximum surface area will be used to calculate the rainfall volume. When + /// `false`, the area is calculated from the bathymetric data. This defaults to `false`. + use_max_area: Option, +} + +#[derive(serde::Deserialize, serde::Serialize, Clone, Default, Debug, JsonSchema, PywrVisitAll)] +#[serde(deny_unknown_fields)] +/// A reservoir node with compensation, leakage, direct rainfall and evaporation. +/// +/// # Implementation +#[doc = svgbobdoc::transform!( +/// ```svgbob +/// +/// .Rainfall +/// * +/// U | if OutputNode if LinkNode +/// - - *--. | D[from_spill] +/// | | .Spill -or- .Spill +/// V V D[to_spill] D[to_spill] +/// .----------------+----------------->o --------->*- - - +/// | | +/// | StorageNode | +/// | |-------------->o .Evaporation +/// +-------------+--+---------------------------. +/// | | | +/// | +--------->*- - +------>o .Leakage +/// | .Compensation +/// | D[compensation] +/// +---->*- - - +/// D[] +/// +/// ``` +)] +/// +/// This is a [`StorageNode`] connected to an upstream node `U` and downstream network node `D`. When +/// and edge to this component is created without slots, the target nodes are directly connected to +/// `D[]`. +/// +/// This component has the following internal nodes, which the modeller still needs to connect to +/// other network nodes using slots: +/// - Compensation: when the `compensation` field is provided, a `Link` node with a `min_flow` +/// constraint will be connected to the reservoir. To connect the compensation node to another +/// node, you can use the slot named `compensation` in an edge `from_slot` property. +/// - Spill: this can either be a [`pywr_core::node::OutputNode`] or a [`pywr_core::node::LinkNode`]. +/// When an output node is created, a slot called `to_spill` is added to connect any node to this +/// internal node. For example, you can connect the compensation node. When a link is created two +/// slots are available: `from_spill`to connect the link to another network node; and `to_spill` +/// to route additional water via this node. +/// Use `None` if you don't want to create the spill and to manually route the water. +/// - Rainfall: this is a [`pywr_core::node::InputNode`] with a `min_flow` and `max_flow` equal to +/// the product of the surface area and the rainfall height. +/// - Evaporation: this is am optional [`pywr_core::node::OutputNode`] with a `max_flow` equal to +/// the product of the surface area and the evaporation level. A cost can be also added to control +/// the node's behaviour. +/// - Leakage: this is an optional [`pywr_core::node::OutputNode`] with a `max_flow` equal to the +/// provided [`Metric`]'s value. +/// +/// ## Rainfall and evaporation calculation +/// The rainfall and evaporation volumes are calculated by multiplying the reservoir current +/// surface area by the provided heights. The area is calculated based on the [`BathymetryType`] +/// value: +/// - when [`BathymetryType::Interpolated`], the area is calculated using a piecewise linear +/// interpolation with the [`pywr_core::parameters::AggregatedParameter`] of the data in +/// [`Bathymetry`]'s `storage` and `area` fields. +/// - when [`BathymetryType::Polynomial`], the area is calculated from the polynomial expression +/// using the [`pywr_core::parameters::Polynomial1DParameter`]. +/// If `rainfall.use_max_area` is set to `true`, then the rainfall volume is calculated using the +/// maximum surface area only. +/// +/// # Available metrics +/// The following metrics are available: +/// - Volume: to get the reservoir volume. +/// - ProportionalVolume: to get the reservoir relative volume (0-1). +/// - Compensation: to get the minimum residual flow when the `compensation` field is provided. +/// - Rainfall: to get the rainfall volume when the `rainfall` field is provided. +/// - Evaporation: to get the evaporation volume when the `rainfall` field is provided. +/// +/// # JSON Examples +/// ## Reservoir with output spill +/// ```json +#[doc = include_str!("../test_models/reservoir_with_spill.json")] +/// ``` +/// +/// ## Reservoir with link spill +/// The compensation goes into the spill which routes water to the "River termination" node. +/// ```json +#[doc = include_str!("../test_models/reservoir_with_river.json")] +/// ``` +pub struct ReservoirNode { + #[serde(flatten)] + pub storage: StorageNode, + /// The compensation flow. Use `None` not to add any minimum residual flow to the reservoir. + pub compensation: Option, + /// Whether to create the spill node. The node can be a link or an output node. + pub spill: Option, + /// If the `compensation` and `spill` fields are set, this options when `true` will create an edge + /// from the compensation to the spill node. When `false`, the user has to connect the + /// compensation node to an existing node. Default to `true`. + pub connect_compensation_to_spill: Option, + /// The storage table with the relationship between storage and reservoir surface area. This must + /// be provided for the calculations of the precipitation and evaporation volumes. + pub bathymetry: Option, + /// The rainfall data. Use `None` not to add the rainfall node. + pub rainfall: Option, + /// The evaporation data. Use `None` not to add the evaporation node. + pub evaporation: Option, + /// The leakage to set on the node. Use `None` not to add any loss. + pub leakage: Option, +} + +impl ReservoirNode { + /// Get the node's metadata. + pub(crate) fn meta(&self) -> &NodeMeta { + &self.storage.meta + } + + /// The sub-name of the compensation link node. + fn compensation_node_sub_name() -> Option<&'static str> { + Some("compensation") + } + + /// The sub-name of the spill output node. + fn spill_node_sub_name() -> Option<&'static str> { + Some("spill") + } + + /// The name of the compensation slot. + fn compensation_slot_name() -> &'static str { + "compensation" + } + + /// The name of the spill to_slot. + fn spill_to_slot_name() -> &'static str { + "to_spill" + } + + /// The name of the spill from_slot. + fn spill_from_slot_name() -> &'static str { + "from_spill" + } + + pub fn input_connectors(&self, slot: Option<&str>) -> Vec<(&str, Option)> { + match slot { + None => vec![(self.meta().name.as_str(), None)], + Some(name) => match name { + name if name == Self::spill_to_slot_name() => vec![( + self.meta().name.as_str(), + Self::spill_node_sub_name().map(|n| n.to_string()), + )], + _ => panic!("The slot '{name}' does not exist in {}", self.meta().name), + }, + } + } + + pub fn output_connectors(&self, slot: Option<&str>) -> Vec<(&str, Option)> { + match slot { + None => vec![(self.meta().name.as_str(), None)], + Some(name) => match name { + name if name == Self::compensation_slot_name() => vec![( + self.meta().name.as_str(), + Self::compensation_node_sub_name().map(|n| n.to_string()), + )], + name if name == Self::spill_from_slot_name() => { + if let Some(SpillNodeType::OutputNode) = self.spill { + panic!( + "The slot '{name}' in {} is only supported when the spill node is a link", + self.meta().name + ) + } + vec![( + self.meta().name.as_str(), + Self::spill_node_sub_name().map(|n| n.to_string()), + )] + } + _ => panic!("The slot '{name}' does not exist in {}", self.meta().name), + }, + } + } + + pub fn default_metric(&self) -> NodeAttribute { + self.storage.default_metric() + } +} + +#[cfg(feature = "core")] +impl ReservoirNode { + /// The sub-name of the rainfall node. + fn rainfall_node_sub_name() -> Option<&'static str> { + Some("rainfall") + } + + /// The sub-name of the evaporation node. + fn evaporation_node_sub_name() -> Option<&'static str> { + Some("evaporation") + } + + /// The sub-name of the leakage node. + fn leakage_node_sub_name() -> Option<&'static str> { + Some("leakage") + } + + pub fn node_indices_for_constraints( + &self, + network: &pywr_core::network::Network, + ) -> Result, SchemaError> { + let indices = vec![ + network.get_node_index_by_name(self.meta().name.as_str(), Self::compensation_node_sub_name())?, + network.get_node_index_by_name(self.meta().name.as_str(), Self::leakage_node_sub_name())?, + network.get_node_index_by_name(self.meta().name.as_str(), Self::rainfall_node_sub_name())?, + network.get_node_index_by_name(self.meta().name.as_str(), Self::evaporation_node_sub_name())?, + ]; + Ok(indices) + } + + pub fn add_to_model(&self, network: &mut pywr_core::network::Network) -> Result<(), SchemaError> { + // add storage and spill + self.storage.add_to_model(network)?; + let storage = network.get_node_index_by_name(self.meta().name.as_str(), None)?; + + // add compensation node and edge + let comp_node = match &self.compensation { + Some(_) => { + network.add_link_node(self.meta().name.as_str(), Self::compensation_node_sub_name())?; + let comp = + network.get_node_index_by_name(self.meta().name.as_str(), Self::compensation_node_sub_name())?; + network.connect_nodes(storage, comp)?; + Some(comp) + } + None => None, + }; + + // add spill and edge + let spill_node = match &self.spill { + None => None, + Some(node_type) => { + match node_type { + SpillNodeType::OutputNode => { + network.add_output_node(self.meta().name.as_str(), Self::spill_node_sub_name())?; + } + SpillNodeType::LinkNode => { + network.add_link_node(self.meta().name.as_str(), Self::spill_node_sub_name())?; + } + } + let spill = network.get_node_index_by_name(self.meta().name.as_str(), Self::spill_node_sub_name())?; + network.connect_nodes(storage, spill)?; + Some(spill) + } + }; + + // connect compensation and spill + let connect_comp = self.connect_compensation_to_spill.unwrap_or(true); + if connect_comp { + if let (Some(spill), Some(comp)) = (spill_node, comp_node) { + network.connect_nodes(comp, spill)?; + } + } + // add rainfall node and edge + if self.rainfall.is_some() { + network.add_input_node(self.meta().name.as_str(), Self::rainfall_node_sub_name())?; + let rainfall = network.get_node_index_by_name(self.meta().name.as_str(), Self::rainfall_node_sub_name())?; + network.connect_nodes(rainfall, storage)?; + + if self.bathymetry.is_none() { + return Err(SchemaError::MissingNodeAttribute { + attr: "bathymetry".to_string(), + name: self.meta().name.clone(), + }); + } + } + + // add evaporation node and edge + if self.evaporation.is_some() { + network.add_output_node(self.meta().name.as_str(), Self::evaporation_node_sub_name())?; + let evaporation = + network.get_node_index_by_name(self.meta().name.as_str(), Self::evaporation_node_sub_name())?; + network.connect_nodes(storage, evaporation)?; + } + + // add leakage node and edge + if self.leakage.is_some() { + network.add_output_node(self.meta().name.as_str(), Self::leakage_node_sub_name())?; + let leakage = network.get_node_index_by_name(self.meta().name.as_str(), Self::leakage_node_sub_name())?; + network.connect_nodes(storage, leakage)?; + } + + Ok(()) + } + + pub fn set_constraints( + &self, + network: &mut pywr_core::network::Network, + args: &LoadArgs, + ) -> Result<(), SchemaError> { + self.storage.set_constraints(network, args)?; + + // Set compensation + if let Some(compensation) = &self.compensation { + let value = compensation.load(network, args)?; + network.set_node_min_flow( + self.meta().name.as_str(), + Self::compensation_node_sub_name(), + value.into(), + )?; + } + + // add leakage + if let Some(leakage) = &self.leakage { + let value = leakage.loss.load(network, args)?; + network.set_node_max_flow(self.meta().name.as_str(), Self::leakage_node_sub_name(), value.into())?; + if let Some(cost) = &leakage.cost { + let value = cost.load(network, args)?; + network.set_node_cost(self.meta().name.as_str(), Self::leakage_node_sub_name(), value.into())?; + } + } + + // add rainfall and evaporation + if let Some(bathymetry) = &self.bathymetry { + // add the rainfall + if let Some(rainfall) = &self.rainfall { + let use_max_area = rainfall.use_max_area.unwrap_or(false); + let rainfall_area_metric = + self.get_area_metric(network, args, "rainfall_area", bathymetry, use_max_area)?; + let rainfall_metric = rainfall.data.load(network, args)?; + + let rainfall_volume_parameter = pywr_core::parameters::AggregatedParameter::new( + ParameterName::new("rainfall", Some(self.meta().name.as_str())), + &[rainfall_metric, rainfall_area_metric], + AggFunc::Product, + ); + let rainfall_idx = network.add_parameter(Box::new(rainfall_volume_parameter))?; + let rainfall_volume_metric: MetricF64 = rainfall_idx.into(); + + network.set_node_min_flow( + self.meta().name.as_str(), + Self::rainfall_node_sub_name(), + Some(rainfall_volume_metric.clone()), + )?; + network.set_node_max_flow( + self.meta().name.as_str(), + Self::rainfall_node_sub_name(), + Some(rainfall_volume_metric), + )?; + } + + // add the evaporation + if let Some(evaporation) = &self.evaporation { + let use_max_area = evaporation.use_max_area.unwrap_or(false); + let evaporation_area_metric = + self.get_area_metric(network, args, "evaporation_area", bathymetry, use_max_area)?; + + // add volume to output node + let evaporation_metric = evaporation.data.load(network, args)?; + let evaporation_volume_parameter = pywr_core::parameters::AggregatedParameter::new( + ParameterName::new("evaporation", Some(self.meta().name.as_str())), + &[evaporation_metric, evaporation_area_metric], + AggFunc::Product, + ); + let evaporation_idx = network.add_parameter(Box::new(evaporation_volume_parameter))?; + let evaporation_volume_metric: MetricF64 = evaporation_idx.into(); + + network.set_node_max_flow( + self.meta().name.as_str(), + Self::evaporation_node_sub_name(), + Some(evaporation_volume_metric), + )?; + + // set optional cost + if let Some(cost) = &evaporation.cost { + let value = cost.load(network, args)?; + network.set_node_cost( + self.meta().name.as_str(), + Self::evaporation_node_sub_name(), + Some(value), + )?; + } + } + } + + Ok(()) + } + + /// Get the `MetricF64` for the reservoir surface area. The area is calculated either using + /// a [`pywr_core::parameters::InterpolatedParameter`] or a + /// [`pywr_core::parameters::Polynomial1DParameter`] from the storage's volume. When the + /// `use_max_area` parameter is `true`, the area is calculated using the storage's max volume + /// from the state instead of the current reservoir's volume. + /// + /// # Arguments + /// + /// * `network`: The network. + /// * `args`: The arguments. + /// * `name`: The unique name to assign to the created parameters. + /// * `bathymetry`: The bathymetric data. + /// * `use_max_area`: Whether to get the max area from the reservoir's max volume. + /// + /// returns: `Result` + fn get_area_metric( + &self, + network: &mut pywr_core::network::Network, + args: &LoadArgs, + name: &str, + bathymetry: &Bathymetry, + use_max_area: bool, + ) -> Result { + // get the current storage + let storage_node = network.get_node_index_by_name(self.meta().name.as_str(), None)?; + + // the storage (absolute or relative) can be the current or max volume + let current_storage = match (bathymetry.is_storage_relative, use_max_area) { + (false, false) => MetricF64::NodeVolume(storage_node), + (true, false) => { + let dm = DerivedMetric::NodeProportionalVolume(storage_node); + let derived_metric_idx = network.add_derived_metric(dm); + MetricF64::DerivedMetric(derived_metric_idx) + } + (false, true) => MetricF64::NodeMaxVolume(storage_node), + (true, true) => MetricF64::Simple(SimpleMetricF64::Constant(Constant(1.0))), + }; + + // get the variable area metric + let area_metric = match &bathymetry.data { + BathymetryType::Interpolated { storage, area } => { + let storage_metric = storage.load(network, args)?; + let area_metric = area.load(network, args)?; + + let interpolated_area_parameter = pywr_core::parameters::InterpolatedParameter::new( + ParameterName::new(name, Some(self.meta().name.as_str())), + current_storage, + vec![(storage_metric, area_metric.clone())], + true, + ); + let area_idx = network.add_parameter(Box::new(interpolated_area_parameter))?; + let interpolated_area_metric: MetricF64 = area_idx.into(); + interpolated_area_metric + } + BathymetryType::Polynomial(coeffs) => { + let poly_area_parameter = pywr_core::parameters::Polynomial1DParameter::new( + ParameterName::new(name, Some(self.meta().name.as_str())), + current_storage, + coeffs.clone(), + 1.0, + 0.0, + ); + let area_idx = network.add_parameter(Box::new(poly_area_parameter))?; + let poly_area_metric: MetricF64 = area_idx.into(); + poly_area_metric + } + }; + + Ok(area_metric) + } + + pub fn create_metric( + &self, + network: &mut pywr_core::network::Network, + attribute: Option, + ) -> Result { + match self.storage.create_metric(network, attribute) { + Ok(m) => Ok(m), + Err(SchemaError::NodeAttributeNotSupported { .. }) => { + let attr = attribute.unwrap(); + let metric = match attr { + NodeAttribute::Compensation => match network + .get_node_index_by_name(self.meta().name.as_str(), Self::compensation_node_sub_name()) + { + Ok(idx) => MetricF64::NodeInFlow(idx), + Err(_) => 0.0.into(), + }, + NodeAttribute::Rainfall => match network + .get_node_index_by_name(self.meta().name.as_str(), Self::rainfall_node_sub_name()) + { + Ok(idx) => MetricF64::NodeInFlow(idx), + Err(_) => 0.0.into(), + }, + NodeAttribute::Evaporation => match network + .get_node_index_by_name(self.meta().name.as_str(), Self::rainfall_node_sub_name()) + { + Ok(idx) => MetricF64::NodeInFlow(idx), + Err(_) => 0.0.into(), + }, + _ => { + return Err(SchemaError::NodeAttributeNotSupported { + ty: "ReservoirNode".to_string(), + name: self.meta().name.clone(), + attr, + }) + } + }; + + Ok(metric) + } + Err(e) => Err(e), + } + } +} + +#[cfg(test)] +#[cfg(feature = "core")] +mod tests { + use crate::model::PywrModel; + + fn reservoir_with_spill_str() -> &'static str { + include_str!("../test_models/reservoir_with_spill.json") + } + + #[test] + fn test_model_nodes_and_edges() { + let data = reservoir_with_spill_str(); + let schema: PywrModel = serde_json::from_str(data).unwrap(); + let mut model: pywr_core::models::Model = schema.build_model(None, None).unwrap(); + + let network = model.network_mut(); + assert_eq!(network.nodes().len(), 5); + assert_eq!(network.edges().len(), 5); + } +} diff --git a/pywr-schema/src/nodes/water_treatment_works.rs b/pywr-schema/src/nodes/water_treatment_works.rs index a827b981..a692f3ab 100644 --- a/pywr-schema/src/nodes/water_treatment_works.rs +++ b/pywr-schema/src/nodes/water_treatment_works.rs @@ -18,8 +18,8 @@ use schemars::JsonSchema; /// or gross flow, and an optional "soft" minimum flow. /// /// When a loss factor is not given the `loss` node is not created. When a non-zero loss -/// factor is provided [`pywr_core::nodes::Output`] and [`pywr_core::nodes::Aggregated`] nodes -/// are created. +/// factor is provided [`pywr_core::node::OutputNode`] and [`pywr_core::aggregated_node::AggregatedNode`] +/// nodes are created. /// /// /// ```svgbob diff --git a/pywr-schema/src/parameters/hydropower.rs b/pywr-schema/src/parameters/hydropower.rs index 893e8250..d16bfa7a 100644 --- a/pywr-schema/src/parameters/hydropower.rs +++ b/pywr-schema/src/parameters/hydropower.rs @@ -48,7 +48,7 @@ pub struct HydropowerTargetParameter { /// The elevation of water entering the turbine. The difference of this /// value with the `turbine_elevation` gives the working head of the turbine. This is optional /// and can be a constant, a value from a table, a parameter name or an inline parameter - /// (see [`DynamicFloatValue`]). + /// (see [`Metric`]). pub water_elevation: Option, /// The elevation of the turbine. The difference between the `water_elevation` and this value /// gives the working head of the turbine. Default to `0.0`. diff --git a/pywr-schema/src/test_models/reservoir_with_river.json b/pywr-schema/src/test_models/reservoir_with_river.json new file mode 100644 index 00000000..d183d6e4 --- /dev/null +++ b/pywr-schema/src/test_models/reservoir_with_river.json @@ -0,0 +1,83 @@ +{ + "metadata": { + "title": "Reservoir node", + "description": "A test of the ReservoirNode.", + "minimum_version": "0.1" + }, + "timestepper": { + "start": "2015-01-01", + "end": "2015-12-31", + "timestep": 1 + }, + "network": { + "nodes": [ + { + "meta": { + "name": "Catchment" + }, + "type": "Catchment", + "flow": { + "type": "Constant", + "value": 15 + } + }, + { + "meta": { + "name": "Reservoir" + }, + "type": "Reservoir", + "max_volume": { + "type": "Constant", + "value": 21000 + }, + "cost": { + "type": "Constant", + "value": -10.0 + }, + "initial_volume": { + "Proportional": 1.0 + }, + "compensation": { + "type": "Constant", + "value": 0.2 + }, + "spill": "LinkNode" + }, + { + "meta": { + "name": "River termination" + }, + "type": "Output" + }, + { + "meta": { + "name": "Demand" + }, + "type": "Output", + "max_flow": { + "type": "Constant", + "value": 20.0 + }, + "cost": { + "type": "Constant", + "value": -10.0 + } + } + ], + "edges": [ + { + "from_node": "Catchment", + "to_node": "Reservoir" + }, + { + "from_node": "Reservoir", + "from_slot": "from_spill", + "to_node": "River termination" + }, + { + "from_node": "Reservoir", + "to_node": "Demand" + } + ] + } +} diff --git a/pywr-schema/src/test_models/reservoir_with_spill.json b/pywr-schema/src/test_models/reservoir_with_spill.json new file mode 100644 index 00000000..6d689720 --- /dev/null +++ b/pywr-schema/src/test_models/reservoir_with_spill.json @@ -0,0 +1,72 @@ +{ + "metadata": { + "title": "Reservoir node", + "description": "A test of the ReservoirNode.", + "minimum_version": "0.1" + }, + "timestepper": { + "start": "2015-01-01", + "end": "2015-12-31", + "timestep": 1 + }, + "network": { + "nodes": [ + { + "meta": { + "name": "Catchment" + }, + "type": "Catchment", + "flow": { + "type": "Constant", + "value": 15 + } + }, + { + "meta": { + "name": "Reservoir" + }, + "type": "Reservoir", + "max_volume": { + "type": "Constant", + "value": 21000 + }, + "cost": { + "type": "Constant", + "value": -10.0 + }, + "initial_volume": { + "Proportional": 1.0 + }, + "compensation": { + "type": "Constant", + "value": 0.2 + }, + "spill": "OutputNode" + }, + { + "meta": { + "name": "Demand" + }, + "type": "Output", + "max_flow": { + "type": "Constant", + "value": 20.0 + }, + "cost": { + "type": "Constant", + "value": -10.0 + } + } + ], + "edges": [ + { + "from_node": "Catchment", + "to_node": "Reservoir" + }, + { + "from_node": "Reservoir", + "to_node": "Demand" + } + ] + } +}