From 878e8bdb091fcc33a24056ae2d0e3b2046a9d530 Mon Sep 17 00:00:00 2001 From: James Tomlinson Date: Mon, 22 Jul 2024 13:49:08 +0100 Subject: [PATCH] fix: Fix LossLink and WTW losses. - Add missing aggregated nodes to LossLinkNode and WaterTreatmentWorks. This means that actually apply losses. - Adds a shared struct for defining Gross or Net losses. - Add new tests for both nodes. --- pywr-core/src/aggregated_node.rs | 2 +- pywr-core/src/metric.rs | 24 +++ pywr-core/src/network.rs | 6 +- pywr-core/src/recorders/csv.rs | 19 +- pywr-core/src/recorders/mod.rs | 2 +- pywr-core/src/test_utils.rs | 39 +++- pywr-core/src/virtual_storage.rs | 6 +- pywr-schema/src/model.rs | 2 +- pywr-schema/src/nodes/core.rs | 2 +- pywr-schema/src/nodes/delay.rs | 2 +- pywr-schema/src/nodes/loss_link.rs | 123 ++++++++++- pywr-schema/src/nodes/piecewise_link.rs | 2 +- pywr-schema/src/nodes/piecewise_storage.rs | 4 +- pywr-schema/src/nodes/river_gauge.rs | 2 +- .../src/nodes/river_split_with_gauge.rs | 2 +- .../src/nodes/rolling_virtual_storage.rs | 2 +- .../src/nodes/water_treatment_works.rs | 203 +++--------------- pywr-schema/src/outputs/csv.rs | 9 +- .../src/test_models/loss_link1-expected.csv | 19 ++ pywr-schema/src/test_models/loss_link1.json | 133 ++++++++++++ pywr-schema/src/test_models/wtw1-expected.csv | 10 + pywr-schema/src/test_models/wtw1.json | 95 ++++++++ pywr-schema/src/timeseries/mod.rs | 2 +- 23 files changed, 501 insertions(+), 209 deletions(-) create mode 100644 pywr-schema/src/test_models/loss_link1-expected.csv create mode 100644 pywr-schema/src/test_models/loss_link1.json create mode 100644 pywr-schema/src/test_models/wtw1-expected.csv create mode 100644 pywr-schema/src/test_models/wtw1.json diff --git a/pywr-core/src/aggregated_node.rs b/pywr-core/src/aggregated_node.rs index ddcc3c1e..e1c64207 100644 --- a/pywr-core/src/aggregated_node.rs +++ b/pywr-core/src/aggregated_node.rs @@ -344,6 +344,6 @@ mod tests { let model = Model::new(default_time_domain().into(), network); - run_all_solvers(&model, &["cbc", "highs"]); + run_all_solvers(&model, &["cbc", "highs"], &[]); } } diff --git a/pywr-core/src/metric.rs b/pywr-core/src/metric.rs index 8f1df5bb..2f72b65d 100644 --- a/pywr-core/src/metric.rs +++ b/pywr-core/src/metric.rs @@ -27,6 +27,14 @@ impl ConstantMetricF64 { ConstantMetricF64::Constant(v) => Ok(*v), } } + + /// Returns true if the constant value is a [`ConstantMetricF64::Constant`] with a value of zero. + pub fn is_constant_zero(&self) -> bool { + match self { + ConstantMetricF64::Constant(v) => *v == 0.0, + _ => false, + } + } } #[derive(Clone, Debug, PartialEq)] pub enum SimpleMetricF64 { @@ -45,6 +53,14 @@ impl SimpleMetricF64 { SimpleMetricF64::Constant(m) => m.get_value(values.get_constant_values()), } } + + /// Returns true if the constant value is a [`ConstantMetricF64::Constant`] with a value of zero. + pub fn is_constant_zero(&self) -> bool { + match self { + SimpleMetricF64::Constant(c) => c.is_constant_zero(), + _ => false, + } + } } #[derive(Clone, Debug, PartialEq)] @@ -124,6 +140,14 @@ impl MetricF64 { MetricF64::Simple(s) => s.get_value(&state.get_simple_parameter_values()), } } + + /// Returns true if the constant value is a [`ConstantMetricF64::Constant`] with a value of zero. + pub fn is_constant_zero(&self) -> bool { + match self { + MetricF64::Simple(s) => s.is_constant_zero(), + _ => false, + } + } } impl TryFrom for SimpleMetricF64 { diff --git a/pywr-core/src/network.rs b/pywr-core/src/network.rs index c9627270..10705deb 100644 --- a/pywr-core/src/network.rs +++ b/pywr-core/src/network.rs @@ -1785,7 +1785,7 @@ mod tests { model.network_mut().add_recorder(Box::new(recorder)).unwrap(); // Test all solvers - run_all_solvers(&model, &[]); + run_all_solvers(&model, &[], &[]); } #[test] @@ -1809,7 +1809,7 @@ mod tests { network.add_recorder(Box::new(recorder)).unwrap(); // Test all solvers - run_all_solvers(&model, &[]); + run_all_solvers(&model, &[], &[]); } /// Test proportional storage derived metric. @@ -1849,7 +1849,7 @@ mod tests { network.add_recorder(Box::new(recorder)).unwrap(); // Test all solvers - run_all_solvers(&model, &[]); + run_all_solvers(&model, &[], &[]); } #[test] diff --git a/pywr-core/src/recorders/csv.rs b/pywr-core/src/recorders/csv.rs index aea1144e..7d91088d 100644 --- a/pywr-core/src/recorders/csv.rs +++ b/pywr-core/src/recorders/csv.rs @@ -8,6 +8,7 @@ use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; use std::any::Any; use std::fs::File; +use std::num::NonZeroU32; use std::ops::Deref; use std::path::PathBuf; @@ -205,14 +206,21 @@ pub struct CsvLongFmtOutput { meta: RecorderMeta, filename: PathBuf, metric_set_indices: Vec, + decimal_places: Option, } impl CsvLongFmtOutput { - pub fn new>(name: &str, filename: P, metric_set_indices: &[MetricSetIndex]) -> Self { + pub fn new>( + name: &str, + filename: P, + metric_set_indices: &[MetricSetIndex], + decimal_places: Option, + ) -> Self { Self { meta: RecorderMeta::new(name), filename: filename.into(), metric_set_indices: metric_set_indices.to_vec(), + decimal_places, } } @@ -236,6 +244,13 @@ impl CsvLongFmtOutput { let name = metric.name().to_string(); let attribute = metric.attribute().to_string(); + let value_scaled = if let Some(decimal_places) = self.decimal_places { + let scale = 10.0_f64.powi(decimal_places.get() as i32); + (value.value * scale).round() / scale + } else { + value.value + }; + let record = CsvLongFmtRecord { time_start: value.start, time_end: value.end(), @@ -243,7 +258,7 @@ impl CsvLongFmtOutput { metric_set: metric_set.name().to_string(), name, attribute, - value: value.value, + value: value_scaled, }; internal diff --git a/pywr-core/src/recorders/mod.rs b/pywr-core/src/recorders/mod.rs index 025dd4c2..f3c26616 100644 --- a/pywr-core/src/recorders/mod.rs +++ b/pywr-core/src/recorders/mod.rs @@ -362,7 +362,7 @@ mod tests { let _idx = model.network_mut().add_recorder(Box::new(rec)).unwrap(); // Test all solvers - run_all_solvers(&model, &[]); + run_all_solvers(&model, &[], &[]); // TODO fix this with respect to the trait. // let array = rec.data_view2().unwrap(); diff --git a/pywr-core/src/test_utils.rs b/pywr-core/src/test_utils.rs index 752c1a4a..bc2f2560 100644 --- a/pywr-core/src/test_utils.rs +++ b/pywr-core/src/test_utils.rs @@ -23,6 +23,7 @@ use float_cmp::{approx_eq, F64Margin}; use ndarray::{Array, Array2}; use rand::Rng; use rand_distr::{Distribution, Normal}; +use std::path::PathBuf; pub fn default_timestepper() -> Timestepper { let start = NaiveDate::from_ymd_opt(2020, 1, 1) @@ -163,21 +164,42 @@ pub fn run_and_assert_parameter( let rec = AssertionRecorder::new("assert", p_idx.into(), expected_values, ulps, epsilon); model.network_mut().add_recorder(Box::new(rec)).unwrap(); - run_all_solvers(model, &[]) + run_all_solvers(model, &[], &[]) +} + +/// A struct to hold the expected outputs for a test. +pub struct ExpectedOutputs { + actual_path: PathBuf, + expected_str: &'static str, +} + +impl ExpectedOutputs { + pub fn new(actual_path: PathBuf, expected_str: &'static str) -> Self { + Self { + actual_path, + expected_str, + } + } + + fn verify(&self) { + assert!(self.actual_path.exists()); + let actual_str = std::fs::read_to_string(&self.actual_path).unwrap(); + assert_eq!(actual_str, self.expected_str); + } } /// Run a model using each of the in-built solvers. /// /// The model will only be run if the solver has the required solver features (and /// is also enabled as a Cargo feature). -pub fn run_all_solvers(model: &Model, solvers_without_features: &[&str]) { - check_features_and_run::(model, !solvers_without_features.contains(&"clp")); +pub fn run_all_solvers(model: &Model, solvers_without_features: &[&str], expected_outputs: &[ExpectedOutputs]) { + check_features_and_run::(model, !solvers_without_features.contains(&"clp"), expected_outputs); #[cfg(feature = "cbc")] - check_features_and_run::(model, !solvers_without_features.contains(&"cbc")); + check_features_and_run::(model, !solvers_without_features.contains(&"cbc"), expected_outputs); #[cfg(feature = "highs")] - check_features_and_run::(model, !solvers_without_features.contains(&"highs")); + check_features_and_run::(model, !solvers_without_features.contains(&"highs"), expected_outputs); #[cfg(feature = "ipm-simd")] { @@ -199,7 +221,7 @@ pub fn run_all_solvers(model: &Model, solvers_without_features: &[&str]) { } /// Check features and -fn check_features_and_run(model: &Model, expect_features: bool) +fn check_features_and_run(model: &Model, expect_features: bool, expected_outputs: &[ExpectedOutputs]) where S: Solver, ::Settings: SolverSettings + Default, @@ -214,6 +236,11 @@ where model .run::(&Default::default()) .unwrap_or_else(|_| panic!("Failed to solve with: {}", S::name())); + + // Verify any expected outputs + for expected_output in expected_outputs { + expected_output.verify(); + } } else { assert!( !has_features, diff --git a/pywr-core/src/virtual_storage.rs b/pywr-core/src/virtual_storage.rs index 1eefc664..4e853ad3 100644 --- a/pywr-core/src/virtual_storage.rs +++ b/pywr-core/src/virtual_storage.rs @@ -422,7 +422,7 @@ mod tests { let domain = default_timestepper().try_into().unwrap(); let model = Model::new(domain, network); // Test all solvers - run_all_solvers(&model, &["highs"]); + run_all_solvers(&model, &["highs"], &[]); } #[test] @@ -449,7 +449,7 @@ mod tests { network.add_recorder(Box::new(recorder)).unwrap(); // Test all solvers - run_all_solvers(&model, &["highs"]); + run_all_solvers(&model, &["highs"], &[]); } #[test] @@ -489,6 +489,6 @@ mod tests { network.add_recorder(Box::new(recorder)).unwrap(); // Test all solvers - run_all_solvers(&model, &["highs"]); + run_all_solvers(&model, &["highs"], &[]); } } diff --git a/pywr-schema/src/model.rs b/pywr-schema/src/model.rs index 84fa6b0c..aa532c06 100644 --- a/pywr-schema/src/model.rs +++ b/pywr-schema/src/model.rs @@ -1066,7 +1066,7 @@ mod core_tests { network.add_recorder(Box::new(rec)).unwrap(); // Test all solvers - run_all_solvers(&model, &[]); + run_all_solvers(&model, &[], &[]); } /// Test that a cycle in parameter dependencies does not load. diff --git a/pywr-schema/src/nodes/core.rs b/pywr-schema/src/nodes/core.rs index 79925706..94719fa5 100644 --- a/pywr-schema/src/nodes/core.rs +++ b/pywr-schema/src/nodes/core.rs @@ -982,6 +982,6 @@ mod tests { let schema = PywrModel::from_str(data).unwrap(); let model: pywr_core::models::Model = schema.build_model(None, None).unwrap(); // Test all solvers - run_all_solvers(&model, &[]); + run_all_solvers(&model, &[], &[]); } } diff --git a/pywr-schema/src/nodes/delay.rs b/pywr-schema/src/nodes/delay.rs index 0e062416..60ac6000 100644 --- a/pywr-schema/src/nodes/delay.rs +++ b/pywr-schema/src/nodes/delay.rs @@ -197,6 +197,6 @@ mod tests { network.add_recorder(Box::new(recorder)).unwrap(); // Test all solvers - run_all_solvers(&model, &[]); + run_all_solvers(&model, &[], &[]); } } diff --git a/pywr-schema/src/nodes/loss_link.rs b/pywr-schema/src/nodes/loss_link.rs index dfe1453c..32147f7f 100644 --- a/pywr-schema/src/nodes/loss_link.rs +++ b/pywr-schema/src/nodes/loss_link.rs @@ -6,14 +6,62 @@ use crate::metric::Metric; use crate::model::LoadArgs; use crate::nodes::{NodeAttribute, NodeMeta}; use crate::parameters::TryIntoV2Parameter; +use pywr_core::aggregated_node::Factors; #[cfg(feature = "core")] use pywr_core::metric::MetricF64; use pywr_schema_macros::PywrVisitAll; use pywr_v1_schema::nodes::LossLinkNode as LossLinkNodeV1; use schemars::JsonSchema; +/// The type of loss factor applied. +/// +/// Gross losses are typically applied as a proportion of the total flow into a node, whereas +/// net losses are applied as a proportion of the net flow. Please see the documentation for +/// specific nodes (e.g. [`LossLinkNode`]) to understand how the loss factor is applied. +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, JsonSchema, PywrVisitAll)] +#[serde(tag = "type")] +pub enum LossFactor { + Gross { factor: Metric }, + Net { factor: Metric }, +} + +impl LossFactor { + pub fn load( + &self, + network: &mut pywr_core::network::Network, + args: &LoadArgs, + ) -> Result, SchemaError> { + match self { + LossFactor::Gross { factor } => { + let lf = factor.load(network, args)?; + // Handle the case where we a given a zero loss factor + // The aggregated node does not support zero loss factors so filter them here. + if lf.is_constant_zero() { + return Ok(None); + } + // Gross losses are configured as a proportion of the net flow + Ok(Some(Factors::Proportion(vec![lf]))) + } + LossFactor::Net { factor } => { + let lf = factor.load(network, args)?; + // Handle the case where we a given a zero loss factor + // The aggregated node does not support zero loss factors so filter them here. + if lf.is_constant_zero() { + return Ok(None); + } + // Net losses are configured as a ratio of the net flow + Ok(Some(Factors::Ratio(vec![1.0.into(), lf]))) + } + } + } +} + #[doc = svgbobdoc::transform!( -/// This is used to represent link with losses. +/// This is used to represent a link with losses. +/// +/// The loss is applied using a loss factor, [`LossFactor`], which can be applied to either the +/// gross or net flow. If no loss factor is defined the output node "O" and the associated +/// aggregated node are not created. /// /// The default output metric for this node is the net flow. /// @@ -33,7 +81,7 @@ use schemars::JsonSchema; pub struct LossLinkNode { #[serde(flatten)] pub meta: NodeMeta, - pub loss_factor: Option, + pub loss_factor: Option, pub min_net_flow: Option, pub max_net_flow: Option, pub net_cost: Option, @@ -70,13 +118,24 @@ impl LossLinkNode { #[cfg(feature = "core")] impl LossLinkNode { + fn agg_sub_name() -> Option<&'static str> { + Some("agg") + } pub fn add_to_model(&self, network: &mut pywr_core::network::Network) -> Result<(), SchemaError> { - network.add_link_node(self.meta.name.as_str(), Self::net_sub_name())?; + let idx_net = network.add_link_node(self.meta.name.as_str(), Self::net_sub_name())?; // TODO make the loss node configurable (i.e. it could be a link if a network wanted to use the loss) // The above would need to support slots in the connections. - network.add_output_node(self.meta.name.as_str(), Self::loss_sub_name())?; - // TODO add the aggregated node that actually does the losses! + if self.loss_factor.is_some() { + let idx_loss = network.add_output_node(self.meta.name.as_str(), Self::loss_sub_name())?; + // This aggregated node will contain the factors to enforce the loss + network.add_aggregated_node( + self.meta.name.as_str(), + Self::agg_sub_name(), + &[idx_net, idx_loss], + None, + )?; + } Ok(()) } @@ -100,6 +159,11 @@ impl LossLinkNode { network.set_node_min_flow(self.meta.name.as_str(), Self::net_sub_name(), value.into())?; } + if let Some(loss_factor) = &self.loss_factor { + let factors = loss_factor.load(network, args)?; + network.set_aggregated_node_factors(self.meta.name.as_str(), Self::agg_sub_name(), factors)?; + } + Ok(()) } @@ -154,7 +218,10 @@ impl TryFrom for LossLinkNode { let loss_factor = v1 .loss_factor - .map(|v| v.try_into_v2_parameter(Some(&meta.name), &mut unnamed_count)) + .map(|v| { + let factor = v.try_into_v2_parameter(Some(&meta.name), &mut unnamed_count)?; + Ok::<_, Self::Error>(LossFactor::Net { factor }) + }) .transpose()?; let min_net_flow = v1 @@ -182,3 +249,47 @@ impl TryFrom for LossLinkNode { Ok(n) } } + +#[cfg(test)] +mod tests { + use crate::model::PywrModel; + #[cfg(feature = "core")] + use pywr_core::test_utils::run_all_solvers; + use pywr_core::test_utils::ExpectedOutputs; + use tempfile::TempDir; + + fn loss_link1_str() -> &'static str { + include_str!("../test_models/loss_link1.json") + } + + fn loss_link1_outputs_str() -> &'static str { + include_str!("../test_models/loss_link1-expected.csv") + } + + #[test] + fn test_model_schema() { + let data = loss_link1_str(); + let schema: PywrModel = serde_json::from_str(data).unwrap(); + + assert_eq!(schema.network.nodes.len(), 5); + assert_eq!(schema.network.edges.len(), 4); + } + + #[test] + #[cfg(feature = "core")] + fn test_model_run() { + let data = loss_link1_str(); + let schema: PywrModel = serde_json::from_str(data).unwrap(); + let temp_dir = TempDir::new().unwrap(); + + let model = schema.build_model(None, Some(temp_dir.path())).unwrap(); + // After model run there should be an output file. + let expected_outputs = [ExpectedOutputs::new( + temp_dir.path().join("loss_link1.csv"), + loss_link1_outputs_str(), + )]; + + // Test all solvers + run_all_solvers(&model, &["cbc", "highs"], &expected_outputs); + } +} diff --git a/pywr-schema/src/nodes/piecewise_link.rs b/pywr-schema/src/nodes/piecewise_link.rs index 631ab3c5..556a3464 100644 --- a/pywr-schema/src/nodes/piecewise_link.rs +++ b/pywr-schema/src/nodes/piecewise_link.rs @@ -223,6 +223,6 @@ mod tests { network.add_recorder(Box::new(recorder)).unwrap(); // Test all solvers - run_all_solvers(&model, &[]); + run_all_solvers(&model, &[], &[]); } } diff --git a/pywr-schema/src/nodes/piecewise_storage.rs b/pywr-schema/src/nodes/piecewise_storage.rs index 5b774a15..9676aeb2 100644 --- a/pywr-schema/src/nodes/piecewise_storage.rs +++ b/pywr-schema/src/nodes/piecewise_storage.rs @@ -286,7 +286,7 @@ mod tests { network.add_recorder(Box::new(recorder)).unwrap(); // Test all solvers - run_all_solvers(&model, &[]); + run_all_solvers(&model, &[], &[]); } /// Test running `piecewise_storage2.json` @@ -357,6 +357,6 @@ mod tests { network.add_recorder(Box::new(recorder)).unwrap(); // Test all solvers - run_all_solvers(&model, &[]); + run_all_solvers(&model, &[], &[]); } } diff --git a/pywr-schema/src/nodes/river_gauge.rs b/pywr-schema/src/nodes/river_gauge.rs index ec4ba87c..1b3d8a44 100644 --- a/pywr-schema/src/nodes/river_gauge.rs +++ b/pywr-schema/src/nodes/river_gauge.rs @@ -179,7 +179,7 @@ mod tests { assert_eq!(network.edges().len(), 6); // Test all solvers - run_all_solvers(&model, &[]); + run_all_solvers(&model, &[], &[]); // TODO assert the results! } diff --git a/pywr-schema/src/nodes/river_split_with_gauge.rs b/pywr-schema/src/nodes/river_split_with_gauge.rs index a1f43e0d..b8f3e428 100644 --- a/pywr-schema/src/nodes/river_split_with_gauge.rs +++ b/pywr-schema/src/nodes/river_split_with_gauge.rs @@ -271,7 +271,7 @@ mod tests { assert_eq!(network.edges().len(), 6); // Test all solvers - run_all_solvers(&model, &[]); + run_all_solvers(&model, &[], &[]); // TODO assert the results! } diff --git a/pywr-schema/src/nodes/rolling_virtual_storage.rs b/pywr-schema/src/nodes/rolling_virtual_storage.rs index 95eda32d..1a4cbd83 100644 --- a/pywr-schema/src/nodes/rolling_virtual_storage.rs +++ b/pywr-schema/src/nodes/rolling_virtual_storage.rs @@ -275,6 +275,6 @@ mod tests { network.add_recorder(Box::new(recorder)).unwrap(); // Test all solvers - run_all_solvers(&model, &["highs"]); + run_all_solvers(&model, &["highs"], &[]); } } diff --git a/pywr-schema/src/nodes/water_treatment_works.rs b/pywr-schema/src/nodes/water_treatment_works.rs index 3caebc0b..1ac648cb 100644 --- a/pywr-schema/src/nodes/water_treatment_works.rs +++ b/pywr-schema/src/nodes/water_treatment_works.rs @@ -3,29 +3,23 @@ use crate::error::SchemaError; use crate::metric::Metric; #[cfg(feature = "core")] use crate::model::LoadArgs; +use crate::nodes::loss_link::LossFactor; use crate::nodes::{NodeAttribute, NodeMeta}; #[cfg(feature = "core")] -use num::Zero; -#[cfg(feature = "core")] -use pywr_core::{ - aggregated_node::Factors, - metric::{ConstantMetricF64, MetricF64, SimpleMetricF64}, -}; +use pywr_core::metric::MetricF64; use pywr_schema_macros::PywrVisitAll; use schemars::JsonSchema; #[doc = svgbobdoc::transform!( /// A node used to represent a water treatment works (WTW) with optional losses. /// -/// This nodes comprises an internal structure that allows specifying a minimum and -/// maximum total net flow, an optional loss factor applied as a proportion of *net* -/// flow, and an optional "soft" minimum flow. +/// This node comprises an internal structure that allows specifying a minimum and +/// maximum total net flow, an optional loss factor applied as a proportion of either net +/// 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 `Output` and `Aggregated` nodes are created. The aggregated node -/// is given factors that require the flow through the output node to be equal to -/// loss factor mulitplied by the net flow. I.e. total gross flow will become -/// (1 + loss factor) * net flow. +/// factor is provided [`pywr_core::nodes::Output`] and [`pywr_core::nodes::Aggregated`] nodes +/// are created. /// /// /// ```svgbob @@ -47,7 +41,7 @@ pub struct WaterTreatmentWorks { #[serde(flatten)] pub meta: NodeMeta, /// The proportion of net flow that is lost to the loss node. - pub loss_factor: Option, + pub loss_factor: Option, /// The minimum flow through the `net` flow node. pub min_flow: Option, /// The maximum flow through the `net` flow node. @@ -178,28 +172,8 @@ impl WaterTreatmentWorks { } if let Some(loss_factor) = &self.loss_factor { - // Handle the case where we a given a zero loss factor - // The aggregated node does not support zero loss factors so filter them here. - let lf = match loss_factor.load(network, args)? { - MetricF64::Simple(s) => match s { - SimpleMetricF64::Constant(ConstantMetricF64::Constant(f)) => { - if f.is_zero() { - None - } else { - Some(f.into()) - } - } - _ => None, - }, - m => Some(m), - }; - - if let Some(lf) = lf { - // Set the factors for the loss - // TODO allow for configuring as proportion of gross. - let factors = Factors::Ratio(vec![1.0.into(), lf]); - network.set_aggregated_node_factors(self.meta.name.as_str(), Self::agg_sub_name(), Some(factors))?; - } + let factors = loss_factor.load(network, args)?; + network.set_aggregated_node_factors(self.meta.name.as_str(), Self::agg_sub_name(), factors)?; } Ok(()) @@ -249,138 +223,22 @@ impl WaterTreatmentWorks { #[cfg(test)] mod tests { use crate::model::PywrModel; - use crate::nodes::WaterTreatmentWorks; - #[cfg(feature = "core")] - use ndarray::Array2; #[cfg(feature = "core")] - use pywr_core::{metric::MetricF64, recorders::AssertionRecorder, test_utils::run_all_solvers}; - - #[test] - fn test_wtw_schema_load() { - let data = r#" - { - "type": "WaterTreatmentWorks", - "name": "My WTW", - "comment": null, - "position": null, - "loss_factor": { - "type": "Table", - "index": "My WTW", - "table": "loss_factors" - }, - "soft_min_flow": { - "type": "Constant", - "value": 105.0 - }, - "cost": { - "type": "Constant", - "value": 2.29 - }, - "max_flow": { - "type": "InlineParameter", - "definition": { - "type": "ControlCurve", - "name": "My WTW max flow", - "control_curves": [ - { - "type": "Parameter", - "name": "A control curve" - } - ], - "values": [ - { - "type": "Parameter", - "name": "a max flow" - }, - { - "type": "Constant", - "value": 0.0 - } - ], - "storage_node": { - "name": "My reservoir", - "attribute": "ProportionalVolume" - } - } - }, - "soft_min_flow_cost": { - "type": "Parameter", - "name": "my_min_flow_cost" - } - } - "#; - - let node: WaterTreatmentWorks = serde_json::from_str(data).unwrap(); + use pywr_core::test_utils::run_all_solvers; + use pywr_core::test_utils::ExpectedOutputs; + use tempfile::TempDir; - assert_eq!(node.meta.name, "My WTW"); + fn wtw1_str() -> &'static str { + include_str!("../test_models/wtw1.json") } - fn model_str() -> &'static str { - r#" - { - "metadata": { - "title": "WTW Test 1", - "description": "Test WTW work", - "minimum_version": "0.1" - }, - "timestepper": { - "start": "2015-01-01", - "end": "2015-12-31", - "timestep": 1 - }, - "network": { - "nodes": [ - { - "name": "input1", - "type": "Input", - "flow": { - "type": "Constant", - "value": 15.0 - } - }, - { - "name": "wtw1", - "type": "WaterTreatmentWorks", - "max_flow": { - "type": "Constant", - "value": 10.0 - }, - "loss_factor": { - "type": "Constant", - "value": 0.1 - } - }, - { - "name": "demand1", - "type": "Output", - "max_flow": { - "type": "Constant", - "value": 15.0 - }, - "cost": { - "type": "Constant", - "value": -10 - } - } - ], - "edges": [ - { - "from_node": "input1", - "to_node": "wtw1" - }, - { - "from_node": "wtw1", - "to_node": "demand1" - } - ] - } - } - "# + fn wtw1_outputs_str() -> &'static str { + include_str!("../test_models/wtw1-expected.csv") } #[test] fn test_model_schema() { - let data = model_str(); + let data = wtw1_str(); let schema: PywrModel = serde_json::from_str(data).unwrap(); assert_eq!(schema.network.nodes.len(), 3); @@ -390,30 +248,23 @@ mod tests { #[test] #[cfg(feature = "core")] fn test_model_run() { - let data = model_str(); + let data = wtw1_str(); let schema: PywrModel = serde_json::from_str(data).unwrap(); - let mut model = schema.build_model(None, None).unwrap(); + let temp_dir = TempDir::new().unwrap(); - let shape = model.domain().shape(); + let mut model = schema.build_model(None, Some(temp_dir.path())).unwrap(); let network = model.network_mut(); assert_eq!(network.nodes().len(), 6); assert_eq!(network.edges().len(), 6); - // Setup expected results - // Set-up assertion for "input" node - // TODO write some helper functions for adding these assertion recorders - let idx = network.get_node_by_name("input1", None).unwrap().index(); - let expected = Array2::from_elem(shape, 11.0); - let recorder = AssertionRecorder::new("input-flow", MetricF64::NodeOutFlow(idx), expected, None, None); - network.add_recorder(Box::new(recorder)).unwrap(); - - let idx = network.get_node_by_name("demand1", None).unwrap().index(); - let expected = Array2::from_elem(shape, 10.0); - let recorder = AssertionRecorder::new("demand-flow", MetricF64::NodeInFlow(idx), expected, None, None); - network.add_recorder(Box::new(recorder)).unwrap(); + // After model run there should be an output file. + let expected_outputs = [ExpectedOutputs::new( + temp_dir.path().join("wtw1.csv"), + wtw1_outputs_str(), + )]; // Test all solvers - run_all_solvers(&model, &["cbc", "highs"]); + run_all_solvers(&model, &["cbc", "highs"], &expected_outputs); } } diff --git a/pywr-schema/src/outputs/csv.rs b/pywr-schema/src/outputs/csv.rs index 52a40f01..18950bbd 100644 --- a/pywr-schema/src/outputs/csv.rs +++ b/pywr-schema/src/outputs/csv.rs @@ -4,6 +4,7 @@ use crate::error::SchemaError; use pywr_core::recorders::{CsvLongFmtOutput, CsvWideFmtOutput, Recorder}; use pywr_schema_macros::PywrVisitPaths; use schemars::JsonSchema; +use std::num::NonZeroU32; #[cfg(feature = "core")] use std::path::Path; use std::path::PathBuf; @@ -40,6 +41,7 @@ pub struct CsvOutput { pub filename: PathBuf, pub format: CsvFormat, pub metric_set: CsvMetricSet, + pub decimal_places: Option, } #[cfg(feature = "core")] @@ -75,7 +77,12 @@ impl CsvOutput { .collect::, _>>()?, }; - Box::new(CsvLongFmtOutput::new(&self.name, filename, &metric_set_indices)) + Box::new(CsvLongFmtOutput::new( + &self.name, + filename, + &metric_set_indices, + self.decimal_places.and_then(NonZeroU32::new), + )) } }; diff --git a/pywr-schema/src/test_models/loss_link1-expected.csv b/pywr-schema/src/test_models/loss_link1-expected.csv new file mode 100644 index 00000000..2f6584ec --- /dev/null +++ b/pywr-schema/src/test_models/loss_link1-expected.csv @@ -0,0 +1,19 @@ +time_start,time_end,scenario_index,metric_set,name,attribute,value +2015-01-01T00:00:00,2015-01-02T00:00:00,0,nodes,loss1,Inflow,11.0 +2015-01-01T00:00:00,2015-01-02T00:00:00,0,nodes,loss1,Outflow,10.0 +2015-01-01T00:00:00,2015-01-02T00:00:00,0,nodes,loss1,Loss,1.0 +2015-01-01T00:00:00,2015-01-02T00:00:00,0,nodes,loss2,Inflow,11.1 +2015-01-01T00:00:00,2015-01-02T00:00:00,0,nodes,loss2,Outflow,10.0 +2015-01-01T00:00:00,2015-01-02T00:00:00,0,nodes,loss2,Loss,1.1 +2015-01-02T00:00:00,2015-01-03T00:00:00,0,nodes,loss1,Inflow,11.0 +2015-01-02T00:00:00,2015-01-03T00:00:00,0,nodes,loss1,Outflow,10.0 +2015-01-02T00:00:00,2015-01-03T00:00:00,0,nodes,loss1,Loss,1.0 +2015-01-02T00:00:00,2015-01-03T00:00:00,0,nodes,loss2,Inflow,11.1 +2015-01-02T00:00:00,2015-01-03T00:00:00,0,nodes,loss2,Outflow,10.0 +2015-01-02T00:00:00,2015-01-03T00:00:00,0,nodes,loss2,Loss,1.1 +2015-01-03T00:00:00,2015-01-04T00:00:00,0,nodes,loss1,Inflow,11.0 +2015-01-03T00:00:00,2015-01-04T00:00:00,0,nodes,loss1,Outflow,10.0 +2015-01-03T00:00:00,2015-01-04T00:00:00,0,nodes,loss1,Loss,1.0 +2015-01-03T00:00:00,2015-01-04T00:00:00,0,nodes,loss2,Inflow,11.1 +2015-01-03T00:00:00,2015-01-04T00:00:00,0,nodes,loss2,Outflow,10.0 +2015-01-03T00:00:00,2015-01-04T00:00:00,0,nodes,loss2,Loss,1.1 diff --git a/pywr-schema/src/test_models/loss_link1.json b/pywr-schema/src/test_models/loss_link1.json new file mode 100644 index 00000000..449aca62 --- /dev/null +++ b/pywr-schema/src/test_models/loss_link1.json @@ -0,0 +1,133 @@ +{ + "metadata": { + "title": "Loss Link Test 1", + "description": "Test LossLink nodes", + "minimum_version": "0.1" + }, + "timestepper": { + "start": "2015-01-01", + "end": "2015-01-03", + "timestep": 1 + }, + "network": { + "nodes": [ + { + "name": "input1", + "type": "Input" + }, + { + "name": "loss1", + "type": "LossLink", + "loss_factor": { + "type": "Net", + "factor": { + "type": "Constant", + "value": 0.1 + } + } + }, + { + "name": "demand1", + "type": "Output", + "max_flow": { + "type": "Constant", + "value": 10.0 + }, + "cost": { + "type": "Constant", + "value": -10 + } + }, + { + "name": "loss2", + "type": "LossLink", + "loss_factor": { + "type": "Gross", + "factor": { + "type": "Constant", + "value": 0.1 + } + } + }, + { + "name": "demand2", + "type": "Output", + "max_flow": { + "type": "Constant", + "value": 10.0 + }, + "cost": { + "type": "Constant", + "value": -10 + } + } + ], + "edges": [ + { + "from_node": "input1", + "to_node": "loss1" + }, + { + "from_node": "loss1", + "to_node": "demand1" + }, + { + "from_node": "input1", + "to_node": "loss2" + }, + { + "from_node": "loss2", + "to_node": "demand2" + } + ], + "metric_sets": [ + { + "name": "nodes", + "metrics": [ + { + "type": "Node", + "name": "loss1", + "attribute": "Inflow" + }, + { + "type": "Node", + "name": "loss1", + "attribute": "Outflow" + }, + { + "type": "Node", + "name": "loss1", + "attribute": "Loss" + }, + { + "type": "Node", + "name": "loss2", + "attribute": "Inflow" + }, + { + "type": "Node", + "name": "loss2", + "attribute": "Outflow" + }, + { + "type": "Node", + "name": "loss2", + "attribute": "Loss" + } + ] + } + ], + "outputs": [ + { + "name": "node-outputs", + "type": "CSV", + "format": "long", + "filename": "loss_link1.csv", + "metric_set": [ + "nodes" + ], + "decimal_places": 1 + } + ] + } +} diff --git a/pywr-schema/src/test_models/wtw1-expected.csv b/pywr-schema/src/test_models/wtw1-expected.csv new file mode 100644 index 00000000..9e28f4e4 --- /dev/null +++ b/pywr-schema/src/test_models/wtw1-expected.csv @@ -0,0 +1,10 @@ +time_start,time_end,scenario_index,metric_set,name,attribute,value +2015-01-01T00:00:00,2015-01-02T00:00:00,0,nodes,wtw1,Inflow,11.0 +2015-01-01T00:00:00,2015-01-02T00:00:00,0,nodes,wtw1,Outflow,10.0 +2015-01-01T00:00:00,2015-01-02T00:00:00,0,nodes,wtw1,Loss,1.0 +2015-01-02T00:00:00,2015-01-03T00:00:00,0,nodes,wtw1,Inflow,11.0 +2015-01-02T00:00:00,2015-01-03T00:00:00,0,nodes,wtw1,Outflow,10.0 +2015-01-02T00:00:00,2015-01-03T00:00:00,0,nodes,wtw1,Loss,1.0 +2015-01-03T00:00:00,2015-01-04T00:00:00,0,nodes,wtw1,Inflow,11.0 +2015-01-03T00:00:00,2015-01-04T00:00:00,0,nodes,wtw1,Outflow,10.0 +2015-01-03T00:00:00,2015-01-04T00:00:00,0,nodes,wtw1,Loss,1.0 diff --git a/pywr-schema/src/test_models/wtw1.json b/pywr-schema/src/test_models/wtw1.json new file mode 100644 index 00000000..f94edee3 --- /dev/null +++ b/pywr-schema/src/test_models/wtw1.json @@ -0,0 +1,95 @@ +{ + "metadata": { + "title": "WTW Test 1", + "description": "Test WTW work", + "minimum_version": "0.1" + }, + "timestepper": { + "start": "2015-01-01", + "end": "2015-01-03", + "timestep": 1 + }, + "network": { + "nodes": [ + { + "name": "input1", + "type": "Input", + "flow": { + "type": "Constant", + "value": 15.0 + } + }, + { + "name": "wtw1", + "type": "WaterTreatmentWorks", + "max_flow": { + "type": "Constant", + "value": 10.0 + }, + "loss_factor": { + "type": "Net", + "factor": { + "type": "Constant", + "value": 0.1 + } + } + }, + { + "name": "demand1", + "type": "Output", + "max_flow": { + "type": "Constant", + "value": 15.0 + }, + "cost": { + "type": "Constant", + "value": -10 + } + } + ], + "edges": [ + { + "from_node": "input1", + "to_node": "wtw1" + }, + { + "from_node": "wtw1", + "to_node": "demand1" + } + ], + "metric_sets": [ + { + "name": "nodes", + "metrics": [ + { + "type": "Node", + "name": "wtw1", + "attribute": "Inflow" + }, + { + "type": "Node", + "name": "wtw1", + "attribute": "Outflow" + }, + { + "type": "Node", + "name": "wtw1", + "attribute": "Loss" + } + ] + } + ], + "outputs": [ + { + "name": "node-outputs", + "type": "CSV", + "format": "long", + "filename": "wtw1.csv", + "metric_set": [ + "nodes" + ], + "decimal_places": 1 + } + ] + } +} diff --git a/pywr-schema/src/timeseries/mod.rs b/pywr-schema/src/timeseries/mod.rs index e57cf936..0a63238d 100644 --- a/pywr-schema/src/timeseries/mod.rs +++ b/pywr-schema/src/timeseries/mod.rs @@ -311,6 +311,6 @@ mod tests { let recorder = AssertionRecorder::new("output-flow", MetricF64::NodeInFlow(idx), expected.clone(), None, None); model.network_mut().add_recorder(Box::new(recorder)).unwrap(); - run_all_solvers(&model, &[]) + run_all_solvers(&model, &[], &[]) } }