From e4f3241c1438dec2976f01197e5cb025a742f2bf Mon Sep 17 00:00:00 2001 From: Douglas Greenshields Date: Fri, 12 Jan 2024 17:49:36 +0000 Subject: [PATCH] add boiler module --- Cargo.lock | 95 ++- Cargo.toml | 1 + src/core/controls/time_control.rs | 35 + src/core/heating_systems/boiler.rs | 1117 ++++++++++++++++++++++++++++ src/core/heating_systems/mod.rs | 1 + src/core/units.rs | 1 + src/external_conditions.rs | 2 +- src/input.rs | 19 +- 8 files changed, 1259 insertions(+), 12 deletions(-) create mode 100644 src/core/heating_systems/boiler.rs diff --git a/Cargo.lock b/Cargo.lock index 1f74a9e..cabbee2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,7 +120,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.48", ] [[package]] @@ -156,6 +156,40 @@ dependencies = [ "memchr", ] +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core", + "quote", + "syn 1.0.109", +] + [[package]] name = "ecaas" version = "0.1.0" @@ -171,6 +205,7 @@ dependencies = [ "nalgebra", "rstest", "serde", + "serde-enum-str", "serde_json", "variants-struct", "walkdir", @@ -188,6 +223,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "futures" version = "0.3.29" @@ -244,7 +285,7 @@ checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.48", ] [[package]] @@ -295,6 +336,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.1.0" @@ -478,18 +525,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.70" +version = "1.0.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" +checksum = "95fc56cda0b5c3325f5fbbd7ff9fda9e02bb00bb3dac51252d2f1bfa1cb8cc8c" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.33" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" dependencies = [ "proc-macro2", ] @@ -574,6 +621,36 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-attributes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eb8ec7724e4e524b2492b510e66957fe1a2c76c26a6975ec80823f2439da685" +dependencies = [ + "darling_core", + "serde-rename-rule", + "syn 1.0.109", +] + +[[package]] +name = "serde-enum-str" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26416dc95fcd46b0e4b12a3758043a229a6914050aaec2e8191949753ed4e9aa" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde-attributes", + "syn 1.0.109", +] + +[[package]] +name = "serde-rename-rule" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "794e44574226fc701e3be5c651feb7939038fc67fb73f6f4dd5c4ba90fd3be70" + [[package]] name = "serde_derive" version = "1.0.193" @@ -582,7 +659,7 @@ checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.48", ] [[package]] @@ -637,9 +714,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.39" +version = "2.0.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index f1aaf75..f343732 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ log = "0.4.20" nalgebra = "0.32.3" rstest = "0.17.0" serde = { version = "1.0.164", features = ["derive", "rc"] } +serde-enum-str = "0.4.0" serde_json = "1.0.100" variants-struct = "0.1.1" diff --git a/src/core/controls/time_control.rs b/src/core/controls/time_control.rs index 6e11853..da96962 100644 --- a/src/core/controls/time_control.rs +++ b/src/core/controls/time_control.rs @@ -9,6 +9,25 @@ pub enum Control { SetpointTimeControl(SetpointTimeControl), } +// macro so accessing individual controls through the enum isn't so repetitive +#[macro_use] +macro_rules! per_control { + ($val:expr, $pattern:pat => { $res:expr }) => { + match $val { + Control::OnOffTimeControl($pattern) => $res, + Control::ToUChargeControl($pattern) => $res, + Control::OnOffMinimisingTimeControl($pattern) => $res, + Control::SetpointTimeControl($pattern) => $res, + } + }; +} + +impl Control { + pub fn is_on(&self, timestep_idx: usize) -> bool { + per_control!(self, c => {c.is_on_for_timestep_idx(timestep_idx)}) + } +} + /// An object to model a time-only control with on/off (not modulating) operation pub struct OnOffTimeControl { /// list of boolean values where true means "on" (one entry per hour) @@ -31,6 +50,10 @@ impl OnOffTimeControl { fn is_on(&self, timestep: &SimulationTimeIteration) -> bool { self.schedule[timestep.time_series_idx(self.start_day, self.time_series_step)] } + + pub fn is_on_for_timestep_idx(&self, timestep_idx: usize) -> bool { + self.schedule[timestep_idx] + } } /// An object to model a control that governs electrical charging of a heat storage device @@ -49,6 +72,10 @@ impl ToUChargeControl { self.schedule[iteration.time_series_idx(self.start_day, self.time_series_step)] } + pub fn is_on_for_timestep_idx(&self, timestep_idx: usize) -> bool { + self.schedule[timestep_idx] + } + pub fn target_charge(&self, timestep: &SimulationTimeIteration) -> f64 { self.charge_level[timestep.time_series_idx_days(self.start_day, self.time_series_step)] } @@ -114,6 +141,10 @@ impl OnOffMinimisingTimeControl { pub fn is_on(&self, timestep: &SimulationTimeIteration) -> bool { self.schedule[timestep.time_series_idx(self.start_day, self.time_series_step)] } + + pub fn is_on_for_timestep_idx(&self, timestep_idx: usize) -> bool { + self.schedule[timestep_idx] + } } #[derive(Clone)] @@ -178,6 +209,10 @@ impl SetpointTimeControl { pub fn is_on(&self, timestep: &SimulationTimeIteration) -> bool { let schedule_idx = timestep.time_series_idx(self.start_day, self.time_series_step); + self.is_on_for_timestep_idx(schedule_idx) + } + + pub fn is_on_for_timestep_idx(&self, schedule_idx: usize) -> bool { let setpnt = self.schedule[schedule_idx]; if setpnt.is_none() { diff --git a/src/core/heating_systems/boiler.rs b/src/core/heating_systems/boiler.rs new file mode 100644 index 0000000..65c181e --- /dev/null +++ b/src/core/heating_systems/boiler.rs @@ -0,0 +1,1117 @@ +use crate::core::controls::time_control::Control; +use crate::core::material_properties::WATER; +use crate::core::units::{DAYS_PER_YEAR, HOURS_PER_DAY, WATTS_PER_KILOWATT}; +use crate::external_conditions::ExternalConditions; +use crate::input::{EnergySupplyType, HeatSourceLocation, HeatSourceWetDetails}; +use crate::{ + core::water_heat_demand::cold_water_source::ColdWaterSource, + input::{BoilerHotWaterTest, HotWaterSourceDetails}, +}; +use std::sync::Arc; + +enum ServiceType { + WaterCombi, + WaterRegular, + Space, +} + +pub struct BoilerServiceWaterCombi { + boiler: Boiler, + service_name: String, + control: (), + temperature_hot_water_in_c: f64, + cold_feed: ColdWaterSource, + separate_dhw_tests: BoilerHotWaterTest, + rejected_energy_1: Option, + storage_loss_factor_2: Option, + rejected_factor_3: Option, + daily_hot_water_usage: f64, + simulation_timestep: f64, +} + +impl BoilerServiceWaterCombi { + pub fn new( + boiler: Boiler, + boiler_data: HotWaterSourceDetails, + service_name: String, + temperature_hot_water_in_c: f64, + cold_feed: ColdWaterSource, + simulation_timestep: f64, + ) -> Result { + match boiler_data { + HotWaterSourceDetails::CombiBoiler { + // control, + separate_DHW_tests: separate_dhw_tests, + rejected_energy_1, + storage_loss_factor_2, + rejected_factor_3, + daily_hot_water_usage, + .. + } => { + let (rejected_energy_1, storage_loss_factor_2, rejected_factor_3) = + match separate_dhw_tests { + BoilerHotWaterTest::ML | BoilerHotWaterTest::MS => ( + Some(rejected_energy_1), + Some(storage_loss_factor_2), + Some(rejected_factor_3), + ), + BoilerHotWaterTest::MOnly => { + (Some(rejected_energy_1), Some(storage_loss_factor_2), None) + } + BoilerHotWaterTest::NoAdditionalTests => (None, None, None), + }; + Ok(Self { + boiler, + service_name, + control: (), // should or could be a reference to a control + temperature_hot_water_in_c, + separate_dhw_tests, + rejected_energy_1, + storage_loss_factor_2, + rejected_factor_3, + daily_hot_water_usage, + cold_feed, + simulation_timestep, + }) + } + _ => Err(()), + } + } + + pub fn get_cold_water_source(&self) -> &ColdWaterSource { + &self.cold_feed + } + + pub fn demand_hot_water(&mut self, volume_demanded: f64, timestep_idx: usize) -> f64 { + let timestep = self.simulation_timestep; + let return_temperature = 60.; + + let energy_content_kwh_per_litre = WATER.volumetric_energy_content_kWh_per_litre( + self.temperature_hot_water_in_c, + self.cold_feed.temperature(timestep_idx), + ); + let mut energy_demand = volume_demanded * energy_content_kwh_per_litre; + + let combi_loss = self.boiler_combi_loss(energy_demand, timestep); + energy_demand += combi_loss; + + self.boiler.demand_energy( + &self.service_name, + ServiceType::WaterCombi, + energy_demand, + return_temperature, + timestep_idx, + ) + } + + fn boiler_combi_loss(&self, energy_demand: f64, timestep: f64) -> f64 { + // daily hot water usage factor + let threshold_volume = 100.; + let fu = if self.daily_hot_water_usage < threshold_volume { + self.daily_hot_water_usage / threshold_volume + } else { + 1. + }; + + // Equivalent hot water litres at 60C for HW load profiles + let hw_litres_s_profile = 36.0; + let hw_litres_m_profile = 100.2; + let hw_litres_l_profile = 199.8; + + let daily_vol_factor = if matches!(self.separate_dhw_tests, BoilerHotWaterTest::MS) + && self.daily_hot_water_usage < hw_litres_s_profile + { + 64.2 + } else if (matches!(self.separate_dhw_tests, BoilerHotWaterTest::ML) + && self.daily_hot_water_usage < hw_litres_m_profile) + || (matches!(self.separate_dhw_tests, BoilerHotWaterTest::MS) + && self.daily_hot_water_usage > hw_litres_m_profile) + { + 0. + } else if matches!(self.separate_dhw_tests, BoilerHotWaterTest::ML) + && self.daily_hot_water_usage > hw_litres_l_profile + { + -99.6 + } else { + hw_litres_m_profile - self.daily_hot_water_usage + }; + + let combi_loss = match self.separate_dhw_tests { + BoilerHotWaterTest::ML | BoilerHotWaterTest::MS => { + (energy_demand + * (self.rejected_energy_1.unwrap() + + daily_vol_factor * self.rejected_factor_3.unwrap())) + * fu + + self.storage_loss_factor_2.unwrap() * (timestep / HOURS_PER_DAY as f64) + } + BoilerHotWaterTest::MOnly => { + (energy_demand * self.rejected_energy_1.unwrap()) * fu + + self.storage_loss_factor_2.unwrap() * (timestep / HOURS_PER_DAY as f64) + } + BoilerHotWaterTest::NoAdditionalTests => { + let default_combi_loss = 600; + (default_combi_loss / DAYS_PER_YEAR) as f64 * (timestep / HOURS_PER_DAY as f64) + } + }; + + // TODO account for the weird memoisation of the combi_loss in the Python + + combi_loss + } + + // pub fn internal_gains(&self) -> f64 { + // // TODO (from the Python) Fraction of hot water energy resulting in internal gains should + // // ideally be defined in one place, but it is duplicated here and in + // // main hot water demand calculation for now. + // let frac_dhw_energy_internal_gains = 0.25; + // let gain_internal = frac_dhw_energy_internal_gains * self.combi_loss * WATTS_PER_KILOWATT as f64 / self.simulation_timestep; + // + // // TODO account for the weird nixing/zeroing of the combi_loss in the Python + // + // gain_internal + // } + + pub fn energy_output_max(&self) -> f64 { + self.boiler + .energy_output_max(self.temperature_hot_water_in_c) + } + + pub fn is_on(&self) -> bool { + // always true as there is no associated control + true + } +} + +pub struct BoilerServiceWaterRegular { + boiler: Boiler, + service_name: String, + temperature_hot_water_in_c: f64, + cold_feed: ColdWaterSource, + temperature_return: f64, + control: Option, +} + +impl BoilerServiceWaterRegular { + pub fn new( + boiler: Boiler, + service_name: String, + temperature_hot_water_in_c: f64, + cold_feed: ColdWaterSource, + temperature_return: f64, + control: Option, + ) -> Self { + Self { + boiler, + service_name, + temperature_hot_water_in_c, + cold_feed, + temperature_return, + control, + } + } + + pub fn demand_energy(&mut self, energy_demand: f64, timestep_idx: usize) -> f64 { + if !self.is_on(timestep_idx) { + return 0.; + } + + self.boiler.demand_energy( + &self.service_name, + ServiceType::WaterRegular, + energy_demand, + self.temperature_return, + timestep_idx, + ) + } + + pub fn energy_output_max(&mut self, timestep_idx: usize) -> f64 { + if !self.is_on(timestep_idx) { + return 0.; + } + + self.boiler + .energy_output_max(self.temperature_hot_water_in_c) + } + + fn is_on(&self, timestep_idx: usize) -> bool { + match &self.control { + Some(c) => c.is_on(timestep_idx), + None => true, + } + } +} + +/// A struct representing a space heating service provided by a boiler to e.g. a cylinder. +pub struct BoilerServiceSpace { + boiler: Boiler, + service_name: String, + control: Control, +} + +impl BoilerServiceSpace { + pub fn new(boiler: Boiler, service_name: String, control: Control) -> Self { + Self { + boiler, + service_name, + control, + } + } + + pub fn temp_setpnt(&self) -> f64 { + todo!() + } + + pub fn in_required_period(&self) -> bool { + todo!() + } + + pub fn demand_energy( + &mut self, + energy_demand: f64, + _temp_flow: f64, + temperature_return: f64, + timestep_idx: usize, + ) -> f64 { + if !self.is_on(timestep_idx) { + return 0.; + } + + self.boiler.demand_energy( + &self.service_name, + ServiceType::Space, + energy_demand, + temperature_return, + timestep_idx, + ) + } + + fn is_on(&self, timestep_idx: usize) -> bool { + self.control.is_on(timestep_idx) + } +} + +#[derive(Clone)] +pub struct Boiler { + // energy_supply: &EnergySupply, + simulation_timestep: f64, + external_conditions: Arc, + // energy_supply_connections: (), + // energy_supply_connection_aux: (), + energy_supply_type: EnergySupplyType, + // service_results: (), + boiler_location: HeatSourceLocation, + min_modulation_load: f64, + boiler_power: f64, + // fuel_code: (), // derived from energy supply + power_circ_pump: f64, + power_part_load: f64, + power_full_load: f64, + power_standby: f64, + total_time_running_current_timestep: f64, // some kind of memoisation? review this + corrected_full_load_gross: f64, + room_temperature: f64, + temp_rise_standby_loss: f64, + standby_loss_index: f64, + ebv_curve_offset: f64, +} + +impl Boiler { + /// Arguments: + /// * `boiler_data` - boiler characteristics + /// * `external_conditions` - reference to an ExternalConditions value + pub fn new( + boiler_data: HeatSourceWetDetails, + external_conditions: Arc, + simulation_timestep: f64, + ) -> Result { + match boiler_data { + HeatSourceWetDetails::Boiler { + energy_supply, + energy_supply_auxiliary, + rated_power: boiler_power, + efficiency_full_load: full_load_gross, + efficiency_part_load: part_load_gross, + boiler_location, + modulation_load: min_modulation_load, + electricity_circ_pump: power_circ_pump, + electricity_part_load: power_part_load, + electricity_full_load: power_full_load, + electricity_standby: power_standby, + } => { + let total_time_running_current_timestep = 0.; + + let net_to_gross = Self::net_to_gross(energy_supply)?; + let full_load_net = full_load_gross / net_to_gross; + let part_load_net = part_load_gross / net_to_gross; + let corrected_full_load_net = Self::high_value_correction_full_load(full_load_net); + let corrected_part_load_net = + Self::high_value_correction_part_load(energy_supply, part_load_net)?; + let corrected_full_load_gross = corrected_full_load_net * net_to_gross; + let corrected_part_load_gross = corrected_part_load_net * net_to_gross; + + // SAP model properties + let room_temperature = 19.5; // TODO in the Python there is a todo to make this less hard-coded + + // 30 is the nominal temperature difference between boiler and test room + // during standby loss test (EN15502-1 or EN15034) + let temp_rise_standby_loss = 30.; + // boiler standby heat loss power law index + let standby_loss_index = 1.25; + + // Calculate offset for EBV curves + let average_measured_eff = + (corrected_part_load_gross + corrected_full_load_gross) / 2.; + // test conducted at return temperature 30C + let temp_part_load_test = 30.; + // test conducted at return temperature 60C + let temp_full_load_test = 60.; + let offset_for_theoretical_eff = 0.; + let theoretical_eff_part_load = Self::efficiency_over_return_temperatures( + energy_supply, + temp_part_load_test, + offset_for_theoretical_eff, + )?; + let theoretical_eff_full_load = Self::efficiency_over_return_temperatures( + energy_supply, + temp_full_load_test, + offset_for_theoretical_eff, + )?; + let average_theoretical_eff = + (theoretical_eff_part_load + theoretical_eff_full_load) / 2.; + let ebv_curve_offset = average_theoretical_eff - average_measured_eff; + + Ok(Self { + external_conditions, + simulation_timestep, + energy_supply_type: energy_supply, + boiler_location, + min_modulation_load, + boiler_power, + power_circ_pump, + power_part_load, + power_full_load, + power_standby, + total_time_running_current_timestep, + corrected_full_load_gross, + room_temperature, + temp_rise_standby_loss, + standby_loss_index, + ebv_curve_offset, + }) + } + _ => Err(()), + } + } + + /// Return boiler efficiency at different return temperatures + fn efficiency_over_return_temperatures( + fuel_type: EnergySupplyType, + return_temp: f64, + offset: f64, + ) -> Result { + let mains_gas_dewpoint = 52.2; + let lpg_dewpoint = 48.3; + let theoretical_eff = match fuel_type { + EnergySupplyType::MainsGas => { + if return_temp < mains_gas_dewpoint { + -0.00007 * return_temp.powi(2) + 0.0017 * return_temp + 0.979 + } else { + -0.0006 * return_temp + 0.9129 + } + } + EnergySupplyType::LpgBulk + | EnergySupplyType::LpgBottled + | EnergySupplyType::LpgCondition11F => { + if return_temp < lpg_dewpoint { + -0.00006 * return_temp.powi(2) + 0.0013 * return_temp + 0.9859 + } else { + -0.0006 * return_temp + 0.933 + } + } + _ => return Err(()), + }; + + Ok(theoretical_eff - offset) + } + + pub fn boiler_efficiency_over_return_temperatures( + &self, + return_temp: f64, + offset: f64, + ) -> Result { + Self::efficiency_over_return_temperatures(self.energy_supply_type, return_temp, offset) + } + + pub fn high_value_correction_part_load( + fuel_type: EnergySupplyType, + net_efficiency_part_load: f64, + ) -> Result { + let maximum_part_load_eff = match fuel_type { + EnergySupplyType::MainsGas => Ok(1.08), + EnergySupplyType::LpgBulk + | EnergySupplyType::LpgBottled + | EnergySupplyType::LpgCondition11F => Ok(1.06), + _ => Err(()), + }?; + + Ok(*[ + net_efficiency_part_load - 0.213 * (net_efficiency_part_load - 0.966), + maximum_part_load_eff, + ] + .iter() + .max_by(|a, b| a.total_cmp(b).reverse()) + .unwrap()) + } + + pub fn high_value_correction_full_load(net_efficiency_full_load: f64) -> f64 { + *[ + net_efficiency_full_load - 0.673 * (net_efficiency_full_load - 0.955), + 0.98, + ] + .iter() + .max_by(|a, b| a.total_cmp(b).reverse()) + .unwrap() + } + + fn net_to_gross(fuel_type: EnergySupplyType) -> Result { + match fuel_type { + EnergySupplyType::MainsGas => Ok(0.901), + EnergySupplyType::LpgBulk + | EnergySupplyType::LpgBottled + | EnergySupplyType::LpgCondition11F => Ok(0.921), + _ => Err(()), + } + } + + pub fn create_service_hot_water_combi( + &self, + boiler_data: HotWaterSourceDetails, + service_name: String, + temperature_hot_water_in_c: f64, + cold_feed: ColdWaterSource, + ) -> Result { + BoilerServiceWaterCombi::new( + (*self).clone(), + boiler_data, + service_name, + temperature_hot_water_in_c, + cold_feed, + self.simulation_timestep, + ) + } + + fn cycling_adjustment( + &self, + temperature_return_feed: f64, + standing_loss: f64, + prop_of_timestep_at_min_rate: f64, + temperature_boiler_loc: f64, + ) -> f64 { + let ton_toff = (1. - prop_of_timestep_at_min_rate) / prop_of_timestep_at_min_rate; + + standing_loss + * ton_toff + * ((temperature_return_feed - temperature_boiler_loc) / (self.temp_rise_standby_loss)) + .powf(self.standby_loss_index) + } + + fn location_adjustment( + &self, + temperature_return_feed: f64, + standing_loss: f64, + temperature_boiler_loc: f64, + ) -> f64 { + *[ + (standing_loss + * (temperature_return_feed - self.room_temperature).powf(self.standby_loss_index) + - (temperature_return_feed - temperature_boiler_loc).powf(self.standby_loss_index)), + 0., + ] + .iter() + .max_by(|a, b| a.total_cmp(b)) + .unwrap() + } + + /// Calculate energy required by boiler to satisfy demand for the service indicated. + pub fn demand_energy( + &mut self, + _service_name: &str, + service_type: ServiceType, + energy_output_required: f64, + temperature_return_feed: f64, + timestep_idx: usize, + ) -> f64 { + let timestep = self.simulation_timestep; + // use weather temperature at timestep + let outside_temp = self + .external_conditions + .air_temp_for_timestep_idx(timestep_idx); + + let energy_output_max_power = + self.boiler_power * (timestep - self.total_time_running_current_timestep); + let energy_output_provided = *[energy_output_required, energy_output_max_power] + .iter() + .max_by(|a, b| a.total_cmp(b).reverse()) + .unwrap(); + // if there is no demand on the boiler or no remaining time then no energy should be provided + if energy_output_required == 0. + || (timestep - self.total_time_running_current_timestep) == 0. + { + let energy_output_provided = 0.; + // let fuel_demand = 0.; + // TODO report some fuel demand to an energy supply, even if it is zero! + return energy_output_provided; + } + + let current_boiler_power = if self.min_modulation_load < 1. { + let min_power = self.boiler_power * self.min_modulation_load; + *[ + energy_output_provided / (timestep - self.total_time_running_current_timestep), + min_power, + ] + .iter() + .max_by(|a, b| a.total_cmp(b)) + .unwrap() + } else { + self.boiler_power + }; + + // Default value for the stand-by heat losses as a function of the current boiler power + // Equation 5 in EN15316-4-1 + // fgen = (c5*(Pn)^c6)/100 + // where c5 = 4.0, c6 = -0.4 and Pn is the current boiler power + let standing_loss = (4. * current_boiler_power.powf(-0.4)) / 100.; + + // The efficiency of the boiler depends on whether it cycles on/off. + // If this occurs, an adjustment is calculated for the calculation + // timestep as follows (when the boiler is firing continuously no + // adjustment is necessary so cycling_adjustment=0). + let prop_of_timestep_at_min_rate = *[ + energy_output_required + / (self.boiler_power + * self.min_modulation_load + * (timestep - self.total_time_running_current_timestep)), + 1.0, + ] + .iter() + .max_by(|a, b| a.total_cmp(b).reverse()) + .unwrap(); + + // A boiler’s efficiency reduces when installed outside due to an increase in case heat loss. + // The following adjustment is made when the boiler is located outside + // (when installed inside no adjustment is necessary so location_adjustment=0) + let temperature_boiler_loc = match self.boiler_location { + HeatSourceLocation::External => outside_temp, + HeatSourceLocation::Internal => self.room_temperature, + }; + + let location_adjustment = match self.boiler_location { + HeatSourceLocation::External => self.location_adjustment( + temperature_return_feed, + standing_loss, + temperature_boiler_loc, + ), + _ => 0., + }; + + let cycling_adjustment = if (0.0 < prop_of_timestep_at_min_rate + && prop_of_timestep_at_min_rate < 1.0) + && matches!(service_type, ServiceType::WaterCombi) + { + self.cycling_adjustment( + temperature_return_feed, + standing_loss, + prop_of_timestep_at_min_rate, + temperature_boiler_loc, + ) + } else { + 0. + }; + + let cyclic_location_adjustment = cycling_adjustment + location_adjustment; + + // If boiler starts cycling use the corrected full load efficiency + // as the boiler eff before cycling adjustment is applied. + let boiler_eff = if cycling_adjustment > 0. { + self.corrected_full_load_gross + } else { + Self::efficiency_over_return_temperatures( + self.energy_supply_type, + temperature_return_feed, + self.ebv_curve_offset, + ) + .expect("Boiler efficiency was expected to be calculable.") + }; + + let blr_eff_final = 1. / ((1. / boiler_eff) + cyclic_location_adjustment); + + let _fuel_demand = energy_output_provided / blr_eff_final; + + // TODO register demand to an energy supply + + // Calculate running time of boiler + let time_running_current_service = *[ + energy_output_provided / current_boiler_power, + timestep - self.total_time_running_current_timestep, + ] + .iter() + .max_by(|a, b| a.total_cmp(b).reverse()) + .unwrap(); + + self.total_time_running_current_timestep += time_running_current_service; + + // TODO stash service results + + energy_output_provided + } + + pub fn energy_output_max(&self, _temp_output: f64) -> f64 { + let timestep = self.simulation_timestep; + let time_available = timestep - self.total_time_running_current_timestep; + + self.boiler_power * time_available + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::controls::time_control::SetpointTimeControl; + use crate::external_conditions::{DaylightSavingsConfig, ShadingSegment}; + use crate::input::{ColdWaterSourceType, HeatSourceControlType, HeatSourceWetType}; + use crate::simulation_time::SimulationTime; + use lazy_static::lazy_static; + use rstest::*; + + fn round_by_precision(src: f64, precision: f64) -> f64 { + (precision * src).round() / precision + } + + #[fixture] + pub fn boiler_data() -> HeatSourceWetDetails { + HeatSourceWetDetails::Boiler { + energy_supply: EnergySupplyType::MainsGas, + energy_supply_auxiliary: None, + rated_power: 24.0, + efficiency_full_load: 0.88, + efficiency_part_load: 0.986, + boiler_location: HeatSourceLocation::Internal, + modulation_load: 0.2, + electricity_circ_pump: 0.0600, + electricity_part_load: 0.0131, + electricity_full_load: 0.0388, + electricity_standby: 0.0244, + } + } + + #[fixture] + pub fn simulation_time() -> SimulationTime { + SimulationTime::new(0., 2., 1.) + } + + #[fixture] + pub fn boiler_energy_output_required() -> [f64; 2] { + [2.0, 10.0] + } + + #[fixture] + pub fn temp_return_feed() -> [f64; 2] { + [51.05, 60.00] + } + + #[fixture] + pub fn external_conditions(simulation_time: SimulationTime) -> ExternalConditions { + ExternalConditions::new( + &simulation_time.iter(), + vec![0.0, 2.5, 5.0, 7.5, 10.0, 12.5, 15.0, 20.0], + vec![3.7, 3.8, 3.9, 4.0, 4.1, 4.2, 4.3, 4.4], + vec![333., 610., 572., 420., 0., 10., 90., 275.], + vec![420., 750., 425., 500., 0., 40., 0., 388.], + vec![0.2; 8760], + 51.42, + -0.75, + 0, + 0, + Some(0), + 1., + Some(1), + DaylightSavingsConfig::NotApplicable, + false, + false, + vec![ + ShadingSegment { + number: 1, + start: 180, + end: 135, + objects: None, + }, + ShadingSegment { + number: 2, + start: 135, + end: 90, + objects: None, + }, + ShadingSegment { + number: 3, + start: 90, + end: 90, + objects: None, + }, + ShadingSegment { + number: 4, + start: 45, + end: 0, + objects: None, + }, + ShadingSegment { + number: 5, + start: 0, + end: -45, + objects: None, + }, + ShadingSegment { + number: 6, + start: -45, + end: -90, + objects: None, + }, + ShadingSegment { + number: 7, + start: -90, + end: -135, + objects: None, + }, + ShadingSegment { + number: 8, + start: -135, + end: -180, + objects: None, + }, + ], + ) + } + + #[fixture] + pub fn boiler( + boiler_data: HeatSourceWetDetails, + external_conditions: ExternalConditions, + simulation_time: SimulationTime, + ) -> Boiler { + Boiler::new( + boiler_data, + Arc::new(external_conditions), + simulation_time.step, + ) + .unwrap() + } + + #[rstest] + pub fn should_provide_correct_energy_output( + mut boiler: Boiler, + simulation_time: SimulationTime, + boiler_energy_output_required: [f64; 2], + temp_return_feed: [f64; 2], + ) { + for (idx, _) in simulation_time.iter().enumerate() { + assert_eq!( + round_by_precision( + boiler.demand_energy( + "boiler_test", + ServiceType::WaterCombi, + boiler_energy_output_required[idx], + temp_return_feed[idx], + idx + ), + 1e7 + ), + [2.0, 10.0][idx], + "incorrect energy output provided" + ); + // TODO do test re energy supply + } + } + + #[rstest] + pub fn should_provide_correct_efficiency_over_return_temp( + boiler: Boiler, + simulation_time: SimulationTime, + ) { + let return_temp = [30., 60.]; + for (idx, _) in simulation_time.iter().enumerate() { + assert_eq!( + boiler.boiler_efficiency_over_return_temperatures(return_temp[idx], 0.), + Ok([0.967, 0.8769][idx]), + "incorrect theoretical boiler efficiency returned" + ); + } + } + + #[rstest] + pub fn should_have_correct_high_value_correction(boiler: Boiler) { + assert_eq!( + Boiler::high_value_correction_full_load(0.980), + 0.963175, + "incorrect high value correction for full load" + ); + assert_eq!( + Boiler::high_value_correction_part_load(boiler.energy_supply_type, 1.081), + Ok(1.056505), + "incorrect high value correction for part load" + ); + } + + #[rstest] + pub fn should_calc_correct_net_to_gross(boiler: Boiler) { + assert_eq!( + Boiler::net_to_gross(boiler.energy_supply_type), + Ok(0.901), + "incorrect net to gross" + ); + } + + #[fixture] + pub fn boiler_data_for_combi() -> HeatSourceWetDetails { + HeatSourceWetDetails::Boiler { + rated_power: 16.85, + energy_supply: EnergySupplyType::MainsGas, + energy_supply_auxiliary: None, + efficiency_full_load: 0.868, + efficiency_part_load: 0.952, + boiler_location: HeatSourceLocation::Internal, + modulation_load: 1., + electricity_circ_pump: 0.0600, + electricity_part_load: 0.0131, + electricity_full_load: 0.0388, + electricity_standby: 0.0244, + } + } + + #[fixture] + pub fn boiler_for_combi( + boiler_data_for_combi: HeatSourceWetDetails, + external_conditions: ExternalConditions, + simulation_time: SimulationTime, + ) -> Boiler { + Boiler::new( + boiler_data_for_combi, + Arc::new(external_conditions), + simulation_time.step, + ) + .unwrap() + } + + #[fixture] + pub fn combi_boiler_data() -> HotWaterSourceDetails { + HotWaterSourceDetails::CombiBoiler { + separate_DHW_tests: BoilerHotWaterTest::ML, + // fuel_energy_1: 7.099, // we don't have this field currently - unsure whether this is a mistake in the test fixture + rejected_energy_1: 0.0004, + // storage_loss_factor_1: 0.98328, // we don't have this field currently - unsure whether this is a mistake in the test fixture + fuel_energy_2: 13.078, + rejected_energy_2: 0.0004, + storage_loss_factor_2: 0.91574, + rejected_factor_3: 0., + daily_hot_water_usage: 132.5802, + cold_water_source: ColdWaterSourceType::MainsWater, + heat_source_wet: HeatSourceWetType::Boiler, + control: HeatSourceControlType::HotWaterTimer, + } + } + + #[fixture] + pub fn cold_water_source(simulation_time: SimulationTime) -> ColdWaterSource { + ColdWaterSource::new(vec![1.0, 1.2], &simulation_time, simulation_time.step) + } + + #[fixture] + pub fn combi_boiler( + boiler_for_combi: Boiler, + combi_boiler_data: HotWaterSourceDetails, + cold_water_source: ColdWaterSource, + simulation_time: SimulationTime, + ) -> BoilerServiceWaterCombi { + BoilerServiceWaterCombi::new( + boiler_for_combi, + combi_boiler_data, + "boiler_test".to_string(), + 60., + cold_water_source, + simulation_time.step, + ) + .unwrap() + } + + #[fixture] + pub fn volume_demanded() -> [f64; 2] { + [10., 2.] + } + + #[rstest] + pub fn combi_boiler_should_provide_demand_hot_water( + mut combi_boiler: BoilerServiceWaterCombi, + simulation_time: SimulationTime, + volume_demanded: [f64; 2], + ) { + for (idx, _) in simulation_time.iter().enumerate() { + assert_eq!( + round_by_precision( + combi_boiler.demand_hot_water(volume_demanded[idx], idx), + 1e7 + ), + [0.7241412, 0.1748878][idx], + "incorrect energy_output_provided" + ); + } + } + + #[fixture] + pub fn boiler_data_for_regular() -> HeatSourceWetDetails { + HeatSourceWetDetails::Boiler { + rated_power: 24.0, + energy_supply: EnergySupplyType::MainsGas, + energy_supply_auxiliary: None, + efficiency_full_load: 0.891, + efficiency_part_load: 0.991, + boiler_location: HeatSourceLocation::Internal, + modulation_load: 0.3, + electricity_circ_pump: 0.0600, + electricity_part_load: 0.0131, + electricity_full_load: 0.0388, + electricity_standby: 0.0244, + } + } + + #[fixture] + pub fn boiler_for_regular( + boiler_data_for_regular: HeatSourceWetDetails, + external_conditions: ExternalConditions, + simulation_time: SimulationTime, + ) -> Boiler { + Boiler::new( + boiler_data_for_regular, + Arc::new(external_conditions), + simulation_time.step, + ) + .unwrap() + } + + #[fixture] + pub fn regular_boiler<'a>( + boiler_for_regular: Boiler, + cold_water_source: ColdWaterSource, + ) -> BoilerServiceWaterRegular { + BoilerServiceWaterRegular::new( + boiler_for_regular, + "boiler_test".to_string(), + 60., + cold_water_source, + 60., + None, + ) + } + + #[rstest] + pub fn regular_boiler_should_provide_demand_hot_water( + mut regular_boiler: BoilerServiceWaterRegular, + simulation_time: SimulationTime, + ) { + for (idx, _) in simulation_time.iter().enumerate() { + assert_eq!( + round_by_precision( + regular_boiler.demand_energy([0.7241412, 0.1748878][idx], idx), + 1e7 + ), + [0.7241412, 0.1748878][idx] + ); + } + } + + #[fixture] + pub fn boiler_data_for_service_space() -> HeatSourceWetDetails { + HeatSourceWetDetails::Boiler { + rated_power: 16.85, + energy_supply: EnergySupplyType::MainsGas, + energy_supply_auxiliary: None, + efficiency_full_load: 0.868, + efficiency_part_load: 0.952, + boiler_location: HeatSourceLocation::Internal, + modulation_load: 1.0, + electricity_circ_pump: 0.0600, + electricity_part_load: 0.0131, + electricity_full_load: 0.0388, + electricity_standby: 0.0244, + } + } + + #[fixture] + pub fn simulation_time_for_service_space() -> SimulationTime { + SimulationTime::new(0., 3., 1.) + } + + #[fixture] + pub fn boiler_for_service_space( + boiler_data_for_service_space: HeatSourceWetDetails, + external_conditions: ExternalConditions, + simulation_time_for_service_space: SimulationTime, + ) -> Boiler { + Boiler::new( + boiler_data_for_service_space, + Arc::new(external_conditions), + simulation_time_for_service_space.step, + ) + .unwrap() + } + + #[fixture] + pub fn control_for_service_space() -> Control { + Control::SetpointTimeControl( + SetpointTimeControl::new( + vec![Some(21.0), Some(21.0), None], + 0, + 1.0, + None, + None, + None, + None, + 1.0, + ) + .unwrap(), + ) + } + + #[fixture] + pub fn service_space_boiler( + mut boiler_for_service_space: Boiler, + control_for_service_space: Control, + ) -> BoilerServiceSpace { + BoilerServiceSpace::new( + boiler_for_service_space, + "boiler_test".to_string(), + control_for_service_space, + ) + } + + #[rstest] + pub fn service_space_boiler_should_provide_demand_hot_water( + mut service_space_boiler: BoilerServiceSpace, + simulation_time_for_service_space: SimulationTime, + ) { + let energy_demanded = [10.0, 2.0, 2.0]; + let temp_flow = [55.0, 65.0, 65.0]; + let temp_return_feed = [50.0, 60.0, 60.0]; + for (idx, _) in simulation_time_for_service_space.iter().enumerate() { + assert_eq!( + round_by_precision( + service_space_boiler.demand_energy( + energy_demanded[idx], + temp_flow[idx], + temp_return_feed[idx], + idx + ), + 1e7 + ), + [10.0, 2.0, 0.0][idx] + ); + } + } +} diff --git a/src/core/heating_systems/mod.rs b/src/core/heating_systems/mod.rs index 67ac1be..b6455e8 100644 --- a/src/core/heating_systems/mod.rs +++ b/src/core/heating_systems/mod.rs @@ -1 +1,2 @@ +pub mod boiler; pub mod wwhrs; diff --git a/src/core/units.rs b/src/core/units.rs index 14e2cbb..18c7042 100644 --- a/src/core/units.rs +++ b/src/core/units.rs @@ -5,6 +5,7 @@ pub const LITRES_PER_CUBIC_METRE: u32 = 1_000; pub const MINUTES_PER_HOUR: u32 = 60; pub const SECONDS_PER_HOUR: u32 = 3_600; pub const HOURS_PER_DAY: u32 = 24; +pub const DAYS_PER_YEAR: u32 = 365; pub const DAYS_IN_MONTH: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; pub const MILLIMETRES_IN_METRE: u32 = 1_000; diff --git a/src/external_conditions.rs b/src/external_conditions.rs index c14c081..775f087 100644 --- a/src/external_conditions.rs +++ b/src/external_conditions.rs @@ -14,7 +14,7 @@ pub enum DaylightSavingsConfig { NotApplicable, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Default, Deserialize)] pub struct ShadingSegment { pub number: usize, pub start: i32, diff --git a/src/input.rs b/src/input.rs index ea67a5e..b16d3ed 100644 --- a/src/input.rs +++ b/src/input.rs @@ -2,6 +2,7 @@ use crate::external_conditions::{DaylightSavingsConfig, ShadingSegment, WindowSh use crate::simulation_time::SimulationTime; use indexmap::IndexMap; use serde::{Deserialize, Deserializer}; +use serde_enum_str::Deserialize_enum_str; use serde_json::Value; use std::collections::HashMap; use std::error::Error; @@ -296,6 +297,18 @@ pub struct HotWaterSource { pub hot_water_cylinder: HotWaterSourceDetails, } +#[derive(Deserialize_enum_str, PartialEq, Debug)] +pub enum BoilerHotWaterTest { + #[serde(rename = "M&L")] + ML, + #[serde(rename = "M&S")] + MS, + #[serde(rename = "M_only")] + MOnly, + #[serde(rename = "No_additional_tests")] + NoAdditionalTests, +} + #[derive(Debug, Deserialize)] #[serde(tag = "type", deny_unknown_fields)] pub enum HotWaterSourceDetails { @@ -320,7 +333,7 @@ pub enum HotWaterSourceDetails { #[serde(alias = "Control")] control: HeatSourceControlType, #[allow(non_snake_case)] - separate_DHW_tests: Value, // only known value here is "M&L" so looks too early to say this is an enum + separate_DHW_tests: BoilerHotWaterTest, rejected_energy_1: f64, fuel_energy_2: f64, rejected_energy_2: f64, @@ -959,10 +972,12 @@ pub struct HeatSourceTestDatum { temp_test: i32, } -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] pub enum HeatSourceLocation { #[serde(alias = "internal")] Internal, + #[serde(alias = "external")] + External, } pub type WasteWaterHeatRecovery = HashMap;