From 8c8e57310945c5fd55e10ca01b5cbabb78f810bf Mon Sep 17 00:00:00 2001 From: James Tomlinson Date: Tue, 28 Nov 2023 23:57:48 +0000 Subject: [PATCH 1/3] chore: Make all Pywr crates to version 2.0.0-dev (#75) --- pywr-cli/Cargo.toml | 2 +- pywr-python/Cargo.toml | 2 +- pywr-schema/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pywr-cli/Cargo.toml b/pywr-cli/Cargo.toml index 7aad221e..48fb7812 100644 --- a/pywr-cli/Cargo.toml +++ b/pywr-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pywr-cli" -version = "0.1.0" +version = "2.0.0-dev" edition = "2021" rust-version = "1.60" description = "A generalised water resource allocation model." diff --git a/pywr-python/Cargo.toml b/pywr-python/Cargo.toml index c0206067..2f650844 100644 --- a/pywr-python/Cargo.toml +++ b/pywr-python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pywr-python" -version = "0.1.0" +version = "2.0.0-dev" edition = "2021" rust-version = "1.60" description = "A generalised water resource allocation model." diff --git a/pywr-schema/Cargo.toml b/pywr-schema/Cargo.toml index f5112477..b65a0737 100644 --- a/pywr-schema/Cargo.toml +++ b/pywr-schema/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pywr-schema" -version = "0.1.0" +version = "2.0.0-dev" authors = ["James Tomlinson "] edition = "2021" rust-version = "1.60" From 12e2328015c27e498227a983aea6226584460587 Mon Sep 17 00:00:00 2001 From: James Tomlinson Date: Tue, 12 Dec 2023 10:27:05 +0000 Subject: [PATCH 2/3] feat: Add RbfProfileParameter (#55) An initial implementation of RbfProfile parameter. It uses a basic RBF interpolation procedure. It also implements `VariableParameter` trait. This has required refactoring the `VariableParameter` trait into a generic and then providing an implementation for f64 and u32. This mimics the float and integer APIs from Pywr v1.x. `TryFromV1Parameter` is implemented for RbfProfileParameter following an update to v1 schema v0.9.0. --- Cargo.toml | 4 +- pywr-core/Cargo.toml | 1 + pywr-core/src/model.rs | 40 +- pywr-core/src/parameters/constant.rs | 23 +- pywr-core/src/parameters/mod.rs | 60 ++- pywr-core/src/parameters/offset.rs | 6 +- pywr-core/src/parameters/profiles/mod.rs | 2 + pywr-core/src/parameters/profiles/rbf.rs | 440 ++++++++++++++++++ pywr-schema/src/error.rs | 9 + .../src/parameters/doc_examples/rbf_1.json | 10 + .../src/parameters/doc_examples/rbf_2.json | 16 + pywr-schema/src/parameters/mod.rs | 11 +- pywr-schema/src/parameters/profiles.rs | 257 +++++++++- 13 files changed, 842 insertions(+), 37 deletions(-) create mode 100644 pywr-core/src/parameters/profiles/rbf.rs create mode 100644 pywr-schema/src/parameters/doc_examples/rbf_1.json create mode 100644 pywr-schema/src/parameters/doc_examples/rbf_2.json diff --git a/Cargo.toml b/Cargo.toml index ac4fd74b..853a9e71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,3 @@ - - [workspace] resolver = "2" members = [ @@ -47,4 +45,4 @@ tracing = "0.1" csv = "1.1" hdf5 = { version="0.8.1" } hdf5-sys = { version="0.8.1", features=["static"] } -pywr-v1-schema = { git = "https://github.com/pywr/pywr-schema/", tag="v0.8.0", package = "pywr-schema" } +pywr-v1-schema = { git = "https://github.com/pywr/pywr-schema/", tag="v0.9.0", package = "pywr-schema" } diff --git a/pywr-core/Cargo.toml b/pywr-core/Cargo.toml index 00b15761..589f0162 100644 --- a/pywr-core/Cargo.toml +++ b/pywr-core/Cargo.toml @@ -31,6 +31,7 @@ tracing = { workspace = true } tracing-subscriber = { version ="0.3.17", features=["env-filter"] } highs-sys = { git = "https://github.com/jetuk/highs-sys", branch="fix-build-libz-linking", optional = true } # highs-sys = { path = "../../highs-sys" } +nalgebra = "0.32.3" pyo3 = { workspace = true } diff --git a/pywr-core/src/model.rs b/pywr-core/src/model.rs index fc7dd932..1dcfc1d1 100644 --- a/pywr-core/src/model.rs +++ b/pywr-core/src/model.rs @@ -1518,9 +1518,9 @@ impl Model { } /// Set the variable values on the parameter a index `['idx']. - pub fn set_parameter_variable_values(&mut self, idx: ParameterIndex, values: &[f64]) -> Result<(), PywrError> { + pub fn set_f64_parameter_variable_values(&mut self, idx: ParameterIndex, values: &[f64]) -> Result<(), PywrError> { match self.parameters.get_mut(*idx.deref()) { - Some(parameter) => match parameter.as_variable_mut() { + Some(parameter) => match parameter.as_f64_variable_mut() { Some(variable) => variable.set_variables(values), None => Err(PywrError::ParameterTypeNotVariable), }, @@ -1529,10 +1529,30 @@ impl Model { } /// Return a vector of the current values of active variable parameters. - pub fn get_parameter_variable_values(&self) -> Vec { + pub fn get_f64_parameter_variable_values(&self) -> Vec { self.parameters .iter() - .filter_map(|p| p.as_variable().filter(|v| v.is_active()).map(|v| v.get_variables())) + .filter_map(|p| p.as_f64_variable().filter(|v| v.is_active()).map(|v| v.get_variables())) + .flatten() + .collect() + } + + /// Set the variable values on the parameter a index `['idx']. + pub fn set_u32_parameter_variable_values(&mut self, idx: ParameterIndex, values: &[u32]) -> Result<(), PywrError> { + match self.parameters.get_mut(*idx.deref()) { + Some(parameter) => match parameter.as_u32_variable_mut() { + Some(variable) => variable.set_variables(values), + None => Err(PywrError::ParameterTypeNotVariable), + }, + None => Err(PywrError::ParameterIndexNotFound(idx)), + } + } + + /// Return a vector of the current values of active variable parameters. + pub fn get_u32_parameter_variable_values(&self) -> Vec { + self.parameters + .iter() + .filter_map(|p| p.as_u32_variable().filter(|v| v.is_active()).map(|v| v.get_variables())) .flatten() .collect() } @@ -1892,8 +1912,8 @@ mod tests { let variable = ActivationFunction::Unit { min: 0.0, max: 10.0 }; let input_max_flow = parameters::ConstantParameter::new("my-constant", 10.0, Some(variable)); - assert!(input_max_flow.can_be_variable()); - assert!(input_max_flow.is_variable_active()); + assert!(input_max_flow.can_be_f64_variable()); + assert!(input_max_flow.is_f64_variable_active()); assert!(input_max_flow.is_active()); let input_max_flow_idx = model.add_parameter(Box::new(input_max_flow)).unwrap(); @@ -1906,13 +1926,15 @@ mod tests { ) .unwrap(); - let variable_values = model.get_parameter_variable_values(); + let variable_values = model.get_f64_parameter_variable_values(); assert_eq!(variable_values, vec![10.0]); // Update the variable values - model.set_parameter_variable_values(input_max_flow_idx, &[5.0]).unwrap(); + model + .set_f64_parameter_variable_values(input_max_flow_idx, &[5.0]) + .unwrap(); - let variable_values = model.get_parameter_variable_values(); + let variable_values = model.get_f64_parameter_variable_values(); assert_eq!(variable_values, vec![5.0]); } } diff --git a/pywr-core/src/parameters/constant.rs b/pywr-core/src/parameters/constant.rs index 3d5f77e1..5111992d 100644 --- a/pywr-core/src/parameters/constant.rs +++ b/pywr-core/src/parameters/constant.rs @@ -1,5 +1,5 @@ use crate::model::Model; -use crate::parameters::{ActivationFunction, Parameter, ParameterMeta, VariableParameter}; +use crate::parameters::{downcast_internal_state, ActivationFunction, Parameter, ParameterMeta, VariableParameter}; use crate::scenario::ScenarioIndex; use crate::state::State; use crate::timestep::Timestep; @@ -30,27 +30,38 @@ impl Parameter for ConstantParameter { fn meta(&self) -> &ParameterMeta { &self.meta } + + fn setup( + &self, + timesteps: &[Timestep], + scenario_index: &ScenarioIndex, + ) -> Result>, PywrError> { + Ok(Some(Box::new(self.value))) + } + fn compute( &self, _timestep: &Timestep, _scenario_index: &ScenarioIndex, _model: &Model, _state: &State, - _internal_state: &mut Option>, + internal_state: &mut Option>, ) -> Result { - Ok(self.value) + let value = downcast_internal_state::(internal_state); + + Ok(*value) } - fn as_variable(&self) -> Option<&dyn VariableParameter> { + fn as_f64_variable(&self) -> Option<&dyn VariableParameter> { Some(self) } - fn as_variable_mut(&mut self) -> Option<&mut dyn VariableParameter> { + fn as_f64_variable_mut(&mut self) -> Option<&mut dyn VariableParameter> { Some(self) } } -impl VariableParameter for ConstantParameter { +impl VariableParameter for ConstantParameter { fn is_active(&self) -> bool { self.variable.is_some() } diff --git a/pywr-core/src/parameters/mod.rs b/pywr-core/src/parameters/mod.rs index 99978519..1c724963 100644 --- a/pywr-core/src/parameters/mod.rs +++ b/pywr-core/src/parameters/mod.rs @@ -52,7 +52,10 @@ pub use min::MinParameter; pub use negative::NegativeParameter; pub use offset::OffsetParameter; pub use polynomial::Polynomial1DParameter; -pub use profiles::{DailyProfileParameter, MonthlyInterpDay, MonthlyProfileParameter, UniformDrawdownProfileParameter}; +pub use profiles::{ + DailyProfileParameter, MonthlyInterpDay, MonthlyProfileParameter, RadialBasisFunction, RbfProfileParameter, + RbfProfileVariableConfig, UniformDrawdownProfileParameter, +}; pub use py::PyParameter; use std::fmt; use std::fmt::{Display, Formatter}; @@ -194,24 +197,47 @@ pub trait Parameter: Send + Sync { Ok(()) } - /// Return the parameter as a [`VariableParameter'] if it supports being a variable. - fn as_variable(&self) -> Option<&dyn VariableParameter> { + /// Return the parameter as a [`VariableParameter'] if it supports being a variable. + fn as_f64_variable(&self) -> Option<&dyn VariableParameter> { + None + } + + /// Return the parameter as a [`VariableParameter'] if it supports being a variable. + fn as_f64_variable_mut(&mut self) -> Option<&mut dyn VariableParameter> { + None + } + + /// Can this parameter be a variable + fn can_be_f64_variable(&self) -> bool { + self.as_f64_variable().is_some() + } + + /// Is this parameter an active variable + fn is_f64_variable_active(&self) -> bool { + match self.as_f64_variable() { + Some(var) => var.is_active(), + None => false, + } + } + + /// Return the parameter as a [`VariableParameter'] if it supports being a variable. + fn as_u32_variable(&self) -> Option<&dyn VariableParameter> { None } - /// Return the parameter as a [`VariableParameter'] if it supports being a variable. - fn as_variable_mut(&mut self) -> Option<&mut dyn VariableParameter> { + /// Return the parameter as a [`VariableParameter'] if it supports being a variable. + fn as_u32_variable_mut(&mut self) -> Option<&mut dyn VariableParameter> { None } /// Can this parameter be a variable - fn can_be_variable(&self) -> bool { - self.as_variable().is_some() + fn can_be_u32_variable(&self) -> bool { + self.as_u32_variable().is_some() } /// Is this parameter an active variable - fn is_variable_active(&self) -> bool { - match self.as_variable() { + fn is_u32_variable_active(&self) -> bool { + match self.as_u32_variable() { Some(var) => var.is_active(), None => false, } @@ -308,19 +334,25 @@ pub enum ParameterType { Multi(MultiValueParameterIndex), } -pub trait VariableParameter { +/// A parameter that can be optimised. +/// +/// This trait is used to allow parameter's internal values to be accessed and altered by +/// external algorithms. It is primarily designed to be used by the optimisation algorithms +/// such as multi-objective evolutionary algorithms. The trait is generic to the type of +/// the variable values being optimised but these will typically by `f64` and `u32`. +pub trait VariableParameter { /// Is this variable activated (i.e. should be used in optimisation) fn is_active(&self) -> bool; /// Return the number of variables required fn size(&self) -> usize; /// Apply new variable values to the parameter - fn set_variables(&mut self, values: &[f64]) -> Result<(), PywrError>; + fn set_variables(&mut self, values: &[T]) -> Result<(), PywrError>; /// Get the current variable values - fn get_variables(&self) -> Vec; + fn get_variables(&self) -> Vec; /// Get variable lower bounds - fn get_lower_bounds(&self) -> Result, PywrError>; + fn get_lower_bounds(&self) -> Result, PywrError>; /// Get variable upper bounds - fn get_upper_bounds(&self) -> Result, PywrError>; + fn get_upper_bounds(&self) -> Result, PywrError>; } #[cfg(test)] diff --git a/pywr-core/src/parameters/offset.rs b/pywr-core/src/parameters/offset.rs index 8b18517a..c0d13d86 100644 --- a/pywr-core/src/parameters/offset.rs +++ b/pywr-core/src/parameters/offset.rs @@ -45,16 +45,16 @@ impl Parameter for OffsetParameter { let x = self.metric.get_value(model, state)?; Ok(x + self.offset) } - fn as_variable(&self) -> Option<&dyn VariableParameter> { + fn as_f64_variable(&self) -> Option<&dyn VariableParameter> { Some(self) } - fn as_variable_mut(&mut self) -> Option<&mut dyn VariableParameter> { + fn as_f64_variable_mut(&mut self) -> Option<&mut dyn VariableParameter> { Some(self) } } -impl VariableParameter for OffsetParameter { +impl VariableParameter for OffsetParameter { fn is_active(&self) -> bool { self.variable.is_some() } diff --git a/pywr-core/src/parameters/profiles/mod.rs b/pywr-core/src/parameters/profiles/mod.rs index 8b32b134..f138cbb7 100644 --- a/pywr-core/src/parameters/profiles/mod.rs +++ b/pywr-core/src/parameters/profiles/mod.rs @@ -1,7 +1,9 @@ mod daily; mod monthly; +mod rbf; mod uniform_drawdown; pub use daily::DailyProfileParameter; pub use monthly::{MonthlyInterpDay, MonthlyProfileParameter}; +pub use rbf::{RadialBasisFunction, RbfProfileParameter, RbfProfileVariableConfig}; pub use uniform_drawdown::UniformDrawdownProfileParameter; diff --git a/pywr-core/src/parameters/profiles/rbf.rs b/pywr-core/src/parameters/profiles/rbf.rs new file mode 100644 index 00000000..fa334cf2 --- /dev/null +++ b/pywr-core/src/parameters/profiles/rbf.rs @@ -0,0 +1,440 @@ +use crate::model::Model; +use crate::parameters::{downcast_internal_state, Parameter, ParameterMeta, VariableParameter}; +use crate::scenario::ScenarioIndex; +use crate::state::State; +use crate::timestep::Timestep; +use crate::PywrError; +use nalgebra::DMatrix; +use std::any::Any; + +pub struct RbfProfileVariableConfig { + days_of_year_range: Option, + value_upper_bounds: f64, + value_lower_bounds: f64, +} + +impl RbfProfileVariableConfig { + pub fn new(days_of_year_range: Option, value_upper_bounds: f64, value_lower_bounds: f64) -> Self { + Self { + days_of_year_range, + value_upper_bounds, + value_lower_bounds, + } + } +} + +/// A parameter that interpolates between a set of points using a radial basis function to +/// create a daily profile. +pub struct RbfProfileParameter { + meta: ParameterMeta, + points: Vec<(u32, f64)>, + function: RadialBasisFunction, + variable: Option, +} + +impl RbfProfileParameter { + pub fn new( + name: &str, + points: Vec<(u32, f64)>, + function: RadialBasisFunction, + variable: Option, + ) -> Self { + Self { + meta: ParameterMeta::new(name), + points, + function, + variable, + } + } +} + +impl Parameter for RbfProfileParameter { + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn meta(&self) -> &ParameterMeta { + &self.meta + } + + fn setup( + &self, + _timesteps: &[Timestep], + _scenario_index: &ScenarioIndex, + ) -> Result>, PywrError> { + let profile = interpolate_rbf_profile(&self.points, &self.function); + Ok(Some(Box::new(profile))) + } + + fn compute( + &self, + timestep: &Timestep, + _scenario_index: &ScenarioIndex, + _model: &Model, + _state: &State, + internal_state: &mut Option>, + ) -> Result { + // Get the profile from the internal state + let profile = downcast_internal_state::<[f64; 366]>(internal_state); + // Return today's value from the profile + Ok(profile[timestep.date.ordinal() as usize - 1]) + } + + fn as_f64_variable(&self) -> Option<&dyn VariableParameter> { + Some(self) + } + + fn as_f64_variable_mut(&mut self) -> Option<&mut dyn VariableParameter> { + Some(self) + } + + fn as_u32_variable(&self) -> Option<&dyn VariableParameter> { + Some(self) + } + + fn as_u32_variable_mut(&mut self) -> Option<&mut dyn VariableParameter> { + Some(self) + } +} + +impl VariableParameter for RbfProfileParameter { + fn is_active(&self) -> bool { + self.variable.is_some() + } + + /// The size is the number of points that define the profile. + fn size(&self) -> usize { + self.points.len() + } + + /// The f64 values update the profile value of each point. + fn set_variables(&mut self, values: &[f64]) -> Result<(), PywrError> { + if values.len() == self.points.len() { + for (point, v) in self.points.iter_mut().zip(values) { + point.1 = *v; + } + Ok(()) + } else { + Err(PywrError::ParameterVariableValuesIncorrectLength) + } + } + + /// The f64 values are the profile values of each point. + fn get_variables(&self) -> Vec { + self.points.iter().map(|p| p.1).collect() + } + + fn get_lower_bounds(&self) -> Result, PywrError> { + if let Some(variable) = &self.variable { + let lb = (0..self.points.len()).map(|_| variable.value_lower_bounds).collect(); + Ok(lb) + } else { + Err(PywrError::ParameterVariableNotActive) + } + } + + fn get_upper_bounds(&self) -> Result, PywrError> { + if let Some(variable) = &self.variable { + let ub = (0..self.points.len()).map(|_| variable.value_upper_bounds).collect(); + Ok(ub) + } else { + Err(PywrError::ParameterVariableNotActive) + } + } +} + +impl VariableParameter for RbfProfileParameter { + fn is_active(&self) -> bool { + self.variable.as_ref().is_some_and(|v| v.days_of_year_range.is_some()) + } + + /// The size is the number of points that define the profile. + fn size(&self) -> usize { + self.points.len() + } + + /// Sets the day of year for each point. + fn set_variables(&mut self, values: &[u32]) -> Result<(), PywrError> { + if values.len() == self.points.len() { + for (point, v) in self.points.iter_mut().zip(values) { + point.0 = *v; + } + Ok(()) + } else { + Err(PywrError::ParameterVariableValuesIncorrectLength) + } + } + + /// Returns the day of year for each point. + fn get_variables(&self) -> Vec { + self.points.iter().map(|p| p.0).collect() + } + + fn get_lower_bounds(&self) -> Result, PywrError> { + if let Some(variable) = &self.variable { + if let Some(days_of_year_range) = &variable.days_of_year_range { + // Make sure the lower bound is not less than 1 and handle integer underflow + let lb = self + .points + .iter() + .map(|p| p.0.checked_sub(*days_of_year_range).unwrap_or(1).max(1)) + .collect(); + + Ok(lb) + } else { + Err(PywrError::ParameterVariableNotActive) + } + } else { + Err(PywrError::ParameterVariableNotActive) + } + } + + fn get_upper_bounds(&self) -> Result, PywrError> { + if let Some(variable) = &self.variable { + if let Some(days_of_year_range) = &variable.days_of_year_range { + // Make sure the upper bound is not greater than 365 and handle integer overflow + let lb = self + .points + .iter() + .map(|p| p.0.checked_add(*days_of_year_range).unwrap_or(365).min(365)) + .collect(); + + Ok(lb) + } else { + Err(PywrError::ParameterVariableNotActive) + } + } else { + Err(PywrError::ParameterVariableNotActive) + } + } +} + +/// Radial basis functions for interpolation. +pub enum RadialBasisFunction { + Linear, + Cubic, + Quintic, + ThinPlateSpline, + Gaussian { epsilon: f64 }, + MultiQuadric { epsilon: f64 }, + InverseMultiQuadric { epsilon: f64 }, +} + +impl RadialBasisFunction { + fn compute(&self, r: f64) -> f64 { + match self { + RadialBasisFunction::Linear => r, + RadialBasisFunction::Cubic => r.powi(3), + RadialBasisFunction::Quintic => r.powi(5), + RadialBasisFunction::ThinPlateSpline => r.powi(2) * r.ln(), + RadialBasisFunction::Gaussian { epsilon } => (-(epsilon * r).powi(2)).exp(), + RadialBasisFunction::MultiQuadric { epsilon } => (1.0 + (epsilon * r).powi(2)).sqrt(), + RadialBasisFunction::InverseMultiQuadric { epsilon } => (1.0 + (epsilon * r).powi(2)).powf(-0.5), + } + } +} + +/// Perform radial-basis function interpolation from the given points. +/// +/// The provided points are a tuple of observed (x, y) values. +fn interpolate_rbf(points: &[(f64, f64)], function: &RadialBasisFunction, x: &[f64; N]) -> [f64; N] { + let n = points.len(); + + let matrix = DMatrix::from_fn(n, n, |r, c| { + let r = (points[c].0 - points[r].0).abs(); + function.compute(r) + }); + + let b = DMatrix::from_fn(n, 1, |r, _| points[r].1); + + let weights = matrix + .lu() + .solve(&b) + .expect("Failed to solve RBF system for interpolation weights."); + + let mut profile = [f64::default(); N]; + + for (profile, &doy) in profile.iter_mut().zip(x) { + *profile = points + .iter() + .enumerate() + .map(|(i, p)| { + let r = (doy - p.0).abs(); + let distance = function.compute(r); + distance * weights[(i, 0)] + }) + .sum(); + } + + profile +} + +/// Calculate the interpolation weights for the given points. +/// +/// This method repeats the point 365 days before and after the user provided points. This +/// helps create a cyclic interpolation suitable for a annual profile. It then repeats the +/// value for the 58th day to create a daily profile 366 days long. +fn interpolate_rbf_profile(points: &[(u32, f64)], function: &RadialBasisFunction) -> [f64; 366] { + // Replicate the points in the year before and after. + let year_before = points.iter().map(|p| (p.0 as f64 - 365.0, p.1)); + let year_after = points.iter().map(|p| (p.0 as f64 + 365.0, p.1)); + let points: Vec<_> = year_before + .chain(points.iter().map(|p| (p.0 as f64, p.1))) + .chain(year_after) + .collect(); + + let mut x_out = [f64::default(); 365]; + for (i, v) in x_out.iter_mut().enumerate() { + *v = i as f64; + } + let short_profile = interpolate_rbf(&points, function, &x_out); + + let (start, end) = short_profile.split_at(58); + + let profile = [start, &[end[0]], end].concat(); + + profile.try_into().unwrap() +} + +#[cfg(test)] +mod tests { + use crate::parameters::profiles::rbf::{interpolate_rbf, interpolate_rbf_profile, RadialBasisFunction}; + use float_cmp::{assert_approx_eq, F64Margin}; + use std::f64::consts::PI; + + /// Test example from Wikipedia on Rbf interpolation + /// + /// This test compares values to those produced by Scipy's Rbf interpolation. + /// + /// For future reference, the Scipy code used to produce the expected values is as follows: + /// ```python + /// from scipy.interpolate import Rbf + /// import numpy as np + /// x = np.array([k / 14.0 for k in range(15)]) + /// f = np.exp(x * np.cos(3.0 * x * np.pi)) + /// + /// rbf = Rbf(x, f, function='gaussian', epsilon=1/3.0) + /// + /// x_out = np.array([k / 149.0 for k in range(150)]) + /// f_interp = rbf(x_out) + /// print(f_interp) + /// ``` + #[test] + fn test_rbf_interpolation() { + let points: Vec<(f64, f64)> = (0..15) + .map(|k| { + let x = k as f64 / 14.0; + let f = (x * (3.0 * x * PI).cos()).exp(); + (x, f) + }) + .collect(); + + let mut x_out = [f64::default(); 150]; + for (i, v) in x_out.iter_mut().enumerate() { + *v = i as f64 / 149.0; + } + + let rbf = RadialBasisFunction::Gaussian { epsilon: 3.0 }; + let f_interp = interpolate_rbf(&points, &rbf, &x_out); + + // Values computed from the Scipy RBF interpolation function for the same problem. + let f_expected = [ + 0.99999999, 1.02215444, 1.03704224, 1.04658357, 1.05232959, 1.0555025, 1.05703598, 1.05761412, 1.05770977, + 1.05762023, 1.0575012, 1.05739784, 1.05727216, 1.0570282, 1.0565335, 1.05563715, 1.05418473, 1.05203042, + 1.04904584, 1.04512659, 1.04019611, 1.03420771, 1.02714462, 1.0190189, 1.00986897, 0.99975608, 0.98876095, + 0.97697989, 0.96451978, 0.9514951, 0.93802364, 0.92422356, 0.91021058, 0.89609542, 0.88198282, 0.86796961, + 0.85414519, 0.8405903, 0.82737825, 0.81457486, 0.80224023, 0.79042854, 0.77919009, 0.76857191, 0.75861923, + 0.74937591, 0.74088519, 0.73319047, 0.72633599, 0.72036607, 0.71532606, 0.71126198, 0.70821968, 0.7062455, + 0.70538494, 0.70568346, 0.7071849, 0.70993231, 0.71396743, 0.71933052, 0.72606058, 0.73419586, 0.74377345, + 0.75483021, 0.76740264, 0.78152758, 0.79724185, 0.81458285, 0.83358751, 0.85429299, 0.87673482, 0.90094656, + 0.926958, 0.95479321, 0.98446917, 1.01599247, 1.04935705, 1.08454095, 1.12150386, 1.16018313, 1.20049191, + 1.24231544, 1.28550918, 1.32989614, 1.37526651, 1.42137569, 1.46794495, 1.51466233, 1.56118419, 1.6071376, + 1.65212512, 1.69572785, 1.7375121, 1.77703531, 1.81385273, 1.84752542, 1.87762766, 1.90375533, 1.92553407, + 1.94262687, 1.95474147, 1.96163779, 1.96313291, 1.95910686, 1.94950578, 1.93434466, 1.91370844, 1.88775047, + 1.85669197, 1.82081727, 1.78046916, 1.73604268, 1.68797763, 1.63674943, 1.58286071, 1.52683076, 1.46918569, + 1.41044858, 1.35112887, 1.29171453, 1.23266261, 1.17439264, 1.11728046, 1.06165402, 1.00779065, 0.95591582, + 0.90620394, 0.8587805, 0.81372578, 0.77108031, 0.73085073, 0.69301704, 0.6575401, 0.62436898, 0.59344848, + 0.56472532, 0.53815332, 0.51369657, 0.49133094, 0.47104256, 0.45282388, 0.43666555, 0.42254569, 0.4104155, + 0.40018055, 0.39167888, 0.38465535, 0.37873281, 0.3733805, 0.36787943, + ]; + + for (i, e) in f_interp.iter().zip(f_expected) { + assert_approx_eq!(f64, *i, e, F64Margin { ulps: 2, epsilon: 1e-6 }); + } + } + + /// Test cyclical daily profile interpolation + /// + /// This test compares values to those produced by Scipy's Rbf interpolation. + /// + /// For future reference, the Scipy code used to produce the expected values is as follows: + /// ```python + /// from scipy.interpolate import Rbf + /// import numpy as np + /// x = np.array([90, 180, 270]) + /// f = np.array([0.5, 0.3, 0.7]) + /// + /// x = np.concatenate([x - 365, x, x + 365]) + /// f = np.concatenate([f, f, f]) + /// + /// rbf = Rbf(x, f, function='multiquadric', epsilon=50.0) + /// x_out = np.array([k for k in range(365)]) + /// f_interp = rbf(x_out) + /// print(f_interp) + /// ``` + #[test] + fn test_rbf_interpolation_profile() { + let points: Vec<(u32, f64)> = vec![(90, 0.5), (180, 0.3), (270, 0.7)]; + + let rbf = RadialBasisFunction::MultiQuadric { epsilon: 1.0 / 50.0 }; + let f_interp = interpolate_rbf_profile(&points, &rbf); + + let f_expected = [ + 0.69464463, 0.69308183, 0.69150736, 0.68992139, 0.68832406, 0.68671551, 0.68509589, 0.68346531, 0.68182389, + 0.68017171, 0.67850888, 0.67683548, 0.67515156, 0.6734572, 0.67175245, 0.67003733, 0.66831189, 0.66657615, + 0.66483011, 0.66307377, 0.66130712, 0.65953014, 0.65774281, 0.65594508, 0.6541369, 0.65231821, 0.65048893, + 0.64864899, 0.64679829, 0.64493672, 0.64306417, 0.64118051, 0.63928561, 0.63737931, 0.63546146, 0.63353187, + 0.63159038, 0.62963677, 0.62767084, 0.62569237, 0.62370112, 0.62169685, 0.61967931, 0.61764821, 0.61560328, + 0.61354422, 0.61147072, 0.60938246, 0.60727911, 0.60516031, 0.60302571, 0.60087495, 0.59870763, 0.59652337, + 0.59432175, 0.59210238, 0.58986482, 0.58760865, 0.58533341, 0.58533341, 0.58303867, 0.58072398, 0.57838887, + 0.57603288, 0.57365555, 0.57125641, 0.568835, 0.56639087, 0.56392355, 0.5614326, 0.55891758, 0.55637805, + 0.55381361, 0.55122386, 0.54860842, 0.54596693, 0.54329907, 0.54060452, 0.53788302, 0.53513433, 0.53235824, + 0.5295546, 0.52672327, 0.52386419, 0.52097732, 0.51806269, 0.51512038, 0.5121505, 0.50915325, 0.50612887, + 0.50307767, 0.5, 0.4968963, 0.49376705, 0.4906128, 0.48743418, 0.48423185, 0.48100655, 0.47775909, + 0.47449034, 0.4712012, 0.46789267, 0.46456578, 0.46122162, 0.45786134, 0.45448613, 0.45109726, 0.44769602, + 0.44428374, 0.44086183, 0.43743171, 0.43399486, 0.4305528, 0.42710707, 0.42365927, 0.42021102, 0.416764, + 0.41331988, 0.4098804, 0.40644733, 0.40302245, 0.3996076, 0.39620462, 0.3928154, 0.38944187, 0.38608597, + 0.38274969, 0.37943505, 0.37614408, 0.37287886, 0.36964152, 0.3664342, 0.36325908, 0.36011837, 0.35701434, + 0.35394927, 0.35092549, 0.34794536, 0.34501129, 0.34212571, 0.33929111, 0.33650999, 0.33378492, 0.33111848, + 0.32851331, 0.32597206, 0.32349743, 0.32109215, 0.31875898, 0.31650072, 0.31432016, 0.31222016, 0.31020357, + 0.30827325, 0.30643209, 0.30468296, 0.30302876, 0.30147235, 0.30001661, 0.29866436, 0.29741843, 0.2962816, + 0.29525658, 0.29434606, 0.29355265, 0.29287889, 0.29232723, 0.29190003, 0.29159955, 0.29142793, 0.29138718, + 0.2914792, 0.29170571, 0.29206829, 0.29256837, 0.29320718, 0.29398581, 0.29490512, 0.29596581, 0.29716836, + 0.29851306, 0.3, 0.30162905, 0.30339988, 0.30531196, 0.30736453, 0.30955665, 0.31188717, 0.31435474, + 0.31695784, 0.31969475, 0.32256357, 0.32556225, 0.32868857, 0.33194015, 0.33531448, 0.33880892, 0.34242071, + 0.34614696, 0.34998469, 0.35393082, 0.35798222, 0.36213562, 0.36638776, 0.37073525, 0.37517472, 0.3797027, + 0.38431572, 0.38901027, 0.39378283, 0.39862985, 0.40354777, 0.40853303, 0.41358206, 0.4186913, 0.42385719, + 0.42907617, 0.43434469, 0.43965922, 0.44501624, 0.45041222, 0.45584367, 0.4613071, 0.46679904, 0.47231604, + 0.47785464, 0.48341142, 0.48898296, 0.49456585, 0.5001567, 0.50575214, 0.51134878, 0.51694328, 0.52253228, + 0.52811245, 0.53368045, 0.53923298, 0.54476672, 0.55027838, 0.55576468, 0.56122234, 0.56664811, 0.57203875, + 0.57739104, 0.58270178, 0.58796779, 0.59318592, 0.59835305, 0.60346609, 0.60852201, 0.61351779, 0.61845048, + 0.62331718, 0.62811505, 0.63284131, 0.63749327, 0.64206831, 0.64656388, 0.65097754, 0.65530696, 0.65954989, + 0.66370421, 0.66776792, 0.67173914, 0.67561613, 0.67939727, 0.68308111, 0.68666633, 0.69015176, 0.6935364, + 0.69681937, 0.7, 0.70307774, 0.7060522, 0.70892317, 0.71169059, 0.71435453, 0.71691524, 0.7193731, + 0.72172864, 0.7239825, 0.72613549, 0.72818851, 0.73014259, 0.73199887, 0.73375858, 0.73542305, 0.7369937, + 0.73847202, 0.73985957, 0.74115796, 0.74236887, 0.74349402, 0.74453517, 0.7454941, 0.74637263, 0.74717258, + 0.7478958, 0.74854413, 0.74911943, 0.74962353, 0.75005827, 0.75042547, 0.75072693, 0.75096445, 0.75113978, + 0.75125466, 0.75131079, 0.75130986, 0.75125351, 0.75114335, 0.75098096, 0.75076789, 0.75050563, 0.75019565, + 0.74983939, 0.74943824, 0.74899356, 0.74850665, 0.74797881, 0.74741128, 0.74680526, 0.74616191, 0.74548238, + 0.74476776, 0.74401911, 0.74323746, 0.74242382, 0.74157913, 0.74070433, 0.73980031, 0.73886796, 0.7379081, + 0.73692155, 0.73590908, 0.73487145, 0.73380939, 0.7327236, 0.73161476, 0.73048351, 0.72933049, 0.7281563, + 0.72696153, 0.72574673, 0.72451244, 0.7232592, 0.72198749, 0.72069779, 0.71939058, 0.71806629, 0.71672535, + 0.71536817, 0.71399514, 0.71260665, 0.71120305, 0.70978469, 0.7083519, 0.706905, 0.7054443, 0.70397008, + 0.70248262, 0.70098218, 0.69946903, 0.69794338, 0.69640548, 0.69485553, + ]; + + for (i, e) in f_interp.iter().zip(f_expected) { + assert_approx_eq!(f64, *i, e, F64Margin { ulps: 2, epsilon: 1e-6 }); + } + } +} diff --git a/pywr-schema/src/error.rs b/pywr-schema/src/error.rs index 37ba4a60..5c99e541 100644 --- a/pywr-schema/src/error.rs +++ b/pywr-schema/src/error.rs @@ -41,6 +41,8 @@ pub enum SchemaError { UnexpectedParameterType(String), #[error("mismatch in the length of data provided. expected: {expected}, found: {found}")] DataLengthMismatch { expected: usize, found: usize }, + #[error("Failed to estimate epsilon for use in the radial basis function.")] + RbfEpsilonEstimation, } impl From for PyErr { @@ -79,4 +81,11 @@ pub enum ConversionError { UnsupportedFeature { feature: String, name: String }, #[error("Parameter {name:?} of type `{ty:?}` are not supported in Pywr v2. {instead:?}")] DeprecatedParameter { ty: String, name: String, instead: String }, + #[error("Unexpected type for attribute {attr} on parameter {name}. Expected `{expected}`, found `{actual}`")] + UnexpectedType { + attr: String, + name: String, + expected: String, + actual: String, + }, } diff --git a/pywr-schema/src/parameters/doc_examples/rbf_1.json b/pywr-schema/src/parameters/doc_examples/rbf_1.json new file mode 100644 index 00000000..add053de --- /dev/null +++ b/pywr-schema/src/parameters/doc_examples/rbf_1.json @@ -0,0 +1,10 @@ +{ + "name": "my-interpolated-profile", + "type": "RbfProfile", + "points": [ + [90, 0.5], + [180, 0.3], + [270, 0.7] + ], + "function": {"Gaussian": { "epsilon": 3.0 }} +} diff --git a/pywr-schema/src/parameters/doc_examples/rbf_2.json b/pywr-schema/src/parameters/doc_examples/rbf_2.json new file mode 100644 index 00000000..0f90a146 --- /dev/null +++ b/pywr-schema/src/parameters/doc_examples/rbf_2.json @@ -0,0 +1,16 @@ +{ + "name": "my-interpolated-profile", + "type": "RbfProfile", + "points": [ + [90, 0.5], + [180, 0.3], + [270, 0.7] + ], + "function": {"Gaussian": { "epsilon": 3.0 }}, + "variable": { + "is_active": true, + "days_of_year_range": 30, + "value_upper_bounds": 1.0, + "value_lower_bounds": 0.0 + } +} diff --git a/pywr-schema/src/parameters/mod.rs b/pywr-schema/src/parameters/mod.rs index 5c1eed0e..0795f8a8 100644 --- a/pywr-schema/src/parameters/mod.rs +++ b/pywr-schema/src/parameters/mod.rs @@ -38,7 +38,8 @@ pub use super::parameters::discount_factor::DiscountFactorParameter; pub use super::parameters::indexed_array::IndexedArrayParameter; pub use super::parameters::polynomial::Polynomial1DParameter; pub use super::parameters::profiles::{ - DailyProfileParameter, MonthlyProfileParameter, UniformDrawdownProfileParameter, + DailyProfileParameter, MonthlyProfileParameter, RadialBasisFunction, RbfProfileParameter, + RbfProfileVariableSettings, UniformDrawdownProfileParameter, }; pub use super::parameters::python::PythonParameter; pub use super::parameters::tables::TablesArrayParameter; @@ -166,6 +167,7 @@ pub enum Parameter { Offset(OffsetParameter), DiscountFactor(DiscountFactorParameter), Interpolated(InterpolatedParameter), + RbfProfile(RbfProfileParameter), } impl Parameter { @@ -196,6 +198,7 @@ impl Parameter { Self::Offset(p) => p.meta.name.as_str(), Self::DiscountFactor(p) => p.meta.name.as_str(), Self::Interpolated(p) => p.meta.name.as_str(), + Self::RbfProfile(p) => p.meta.name.as_str(), } } @@ -228,6 +231,7 @@ impl Parameter { Self::Offset(p) => p.node_references(), Self::DiscountFactor(p) => p.node_references(), Self::Interpolated(p) => p.node_references(), + Self::RbfProfile(p) => p.node_references(), } } @@ -277,6 +281,7 @@ impl Parameter { Self::Offset(_) => "Offset", Self::DiscountFactor(_) => "DiscountFactor", Self::Interpolated(_) => "Interpolated", + Self::RbfProfile(_) => "RbfProfile", } } @@ -314,6 +319,7 @@ impl Parameter { Self::Offset(p) => ParameterType::Parameter(p.add_to_model(model, tables, data_path)?), Self::DiscountFactor(p) => ParameterType::Parameter(p.add_to_model(model, tables, data_path)?), Self::Interpolated(p) => ParameterType::Parameter(p.add_to_model(model, tables, data_path)?), + Self::RbfProfile(p) => ParameterType::Parameter(p.add_to_model(model)?), }; Ok(ty) @@ -414,6 +420,9 @@ impl TryFromV1Parameter for Parameter { instead: "Use a derived metric instead.".to_string(), }) } + CoreParameter::RbfProfile(p) => { + Parameter::RbfProfile(p.try_into_v2_parameter(parent_node, unnamed_count)?) + } }, ParameterV1::Custom(p) => { println!("Custom parameter: {:?} ({})", p.meta.name, p.ty); diff --git a/pywr-schema/src/parameters/profiles.rs b/pywr-schema/src/parameters/profiles.rs index 7294d1db..6ece1d48 100644 --- a/pywr-schema/src/parameters/profiles.rs +++ b/pywr-schema/src/parameters/profiles.rs @@ -6,7 +6,7 @@ use crate::parameters::{ use pywr_core::parameters::ParameterIndex; use pywr_v1_schema::parameters::{ DailyProfileParameter as DailyProfileParameterV1, MonthInterpDay as MonthInterpDayV1, - MonthlyProfileParameter as MonthlyProfileParameterV1, + MonthlyProfileParameter as MonthlyProfileParameterV1, RbfProfileParameter as RbfProfileParameterV1, UniformDrawdownProfileParameter as UniformDrawdownProfileParameterV1, }; use std::collections::HashMap; @@ -218,3 +218,258 @@ impl TryFromV1Parameter for UniformDrawdownPr Ok(p) } } + +/// Distance functions for radial basis function interpolation. +#[derive(serde::Deserialize, serde::Serialize, Debug, Copy, Clone)] +pub enum RadialBasisFunction { + Linear, + Cubic, + Quintic, + ThinPlateSpline, + Gaussian { epsilon: Option }, + MultiQuadric { epsilon: Option }, + InverseMultiQuadric { epsilon: Option }, +} + +impl RadialBasisFunction { + /// Convert the schema representation of the RBF into `pywr_core` type. + /// + /// If required this will estimate values of from the provided points. + fn into_core_rbf(self, points: &[(u32, f64)]) -> Result { + let rbf = match self { + Self::Linear => pywr_core::parameters::RadialBasisFunction::Linear, + Self::Cubic => pywr_core::parameters::RadialBasisFunction::Cubic, + Self::Quintic => pywr_core::parameters::RadialBasisFunction::Cubic, + Self::ThinPlateSpline => pywr_core::parameters::RadialBasisFunction::ThinPlateSpline, + Self::Gaussian { epsilon } => { + let epsilon = match epsilon { + Some(e) => e, + None => estimate_epsilon(points).ok_or(SchemaError::RbfEpsilonEstimation)?, + }; + + pywr_core::parameters::RadialBasisFunction::Gaussian { epsilon } + } + Self::MultiQuadric { epsilon } => { + let epsilon = match epsilon { + Some(e) => e, + None => estimate_epsilon(points).ok_or(SchemaError::RbfEpsilonEstimation)?, + }; + + pywr_core::parameters::RadialBasisFunction::MultiQuadric { epsilon } + } + Self::InverseMultiQuadric { epsilon } => { + let epsilon = match epsilon { + Some(e) => e, + None => estimate_epsilon(points).ok_or(SchemaError::RbfEpsilonEstimation)?, + }; + + pywr_core::parameters::RadialBasisFunction::InverseMultiQuadric { epsilon } + } + }; + + Ok(rbf) + } +} + +/// Compute an estimate for epsilon. +/// +/// If there `points` is empty then `None` is returned. +fn estimate_epsilon(points: &[(u32, f64)]) -> Option { + if points.is_empty() { + return None; + } + + // SAFETY: Above check that points is non-empty should make these unwraps safe. + let x_min = points.iter().map(|(x, _)| *x).min().unwrap(); + let x_max = points.iter().map(|(x, _)| *x).max().unwrap(); + let y_min = points.iter().map(|(_, y)| *y).reduce(f64::min).unwrap(); + let y_max = points.iter().map(|(_, y)| *y).reduce(f64::max).unwrap(); + + let mut x_range = x_max - x_min; + if x_range == 0 { + x_range = 1; + } + let mut y_range = y_max - y_min; + if y_range == 0.0 { + y_range = 1.0; + } + + Some((x_range as f64 * y_range).powf(1.0 / points.len() as f64)) +} + +/// Settings for a variable RBF profile. +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone, Copy)] +pub struct RbfProfileVariableSettings { + /// Is this parameter an active variable? + pub is_active: bool, + /// Optional maximum number of days that the interpolation points can be moved from their + /// original position. If this is `None` then the points can not be moved from their + /// original day of the year. + pub days_of_year_range: Option, + /// Optional upper bound for the value of each interpolation point. If this is `None` then + /// there is no upper bound. + pub value_upper_bounds: Option, + /// Optional lower bound for the value of each interpolation point. If this is `None` then + /// the lower bound is zero. + pub value_lower_bounds: Option, +} + +impl Into for RbfProfileVariableSettings { + fn into(self) -> pywr_core::parameters::RbfProfileVariableConfig { + pywr_core::parameters::RbfProfileVariableConfig::new( + self.days_of_year_range, + self.value_upper_bounds.unwrap_or(f64::INFINITY), + self.value_lower_bounds.unwrap_or(0.0), + ) + } +} + +/// A parameter that interpolates between a set of points using a radial basis function to +/// create a daily profile. +/// +/// # JSON Examples +/// +/// The example below shows the definition of a [`RbfProfileParameter`] in JSON. +/// +/// ```json +#[doc = include_str!("doc_examples/rbf_1.json")] +/// ``` +/// +/// The example below shows the definition of a [`RbfProfileParameter`] in JSON with variable +/// settings defined. This settings determine how the interpolation points be modified by +/// external algorithms. See [`RbfProfileVariableSettings`] for more information. +/// +/// ```json +#[doc = include_str!("doc_examples/rbf_2.json")] +/// ``` +/// +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +pub struct RbfProfileParameter { + #[serde(flatten)] + pub meta: ParameterMeta, + /// The points are the profile positions defined by an ordinal day of the year and a value. + /// Radial basis function interpolation is used to create a daily profile from these points. + pub points: Vec<(u32, f64)>, + /// The distance function used for interpolation. + pub function: RadialBasisFunction, + /// Definition of optional variable settings. + pub variable: Option, +} + +impl RbfProfileParameter { + pub fn node_references(&self) -> HashMap<&str, &str> { + HashMap::new() + } + pub fn parameters(&self) -> HashMap<&str, DynamicFloatValueType> { + HashMap::new() + } + + pub fn add_to_model(&self, model: &mut pywr_core::model::Model) -> Result { + let variable = match self.variable { + None => None, + Some(v) => { + // Only set the variable data if the user has indicated the variable is active. + if v.is_active { + Some(v.into()) + } else { + None + } + } + }; + + let function = self.function.into_core_rbf(&self.points)?; + + let p = + pywr_core::parameters::RbfProfileParameter::new(&self.meta.name, self.points.clone(), function, variable); + Ok(model.add_parameter(Box::new(p))?) + } +} + +impl TryFromV1Parameter for RbfProfileParameter { + type Error = ConversionError; + + fn try_from_v1_parameter( + v1: RbfProfileParameterV1, + parent_node: Option<&str>, + unnamed_count: &mut usize, + ) -> Result { + let meta: ParameterMeta = v1.meta.into_v2_parameter(parent_node, unnamed_count); + + let points = v1 + .days_of_year + .into_iter() + .zip(v1.values.into_iter()) + .map(|(doy, v)| (doy, v)) + .collect(); + + if v1.rbf_kwargs.contains_key("smooth") { + return Err(ConversionError::UnsupportedFeature { + feature: "The RBF `smooth` keyword argument is not supported.".to_string(), + name: meta.name, + }); + } + + if v1.rbf_kwargs.contains_key("norm") { + return Err(ConversionError::UnsupportedFeature { + feature: "The RBF `norm` keyword argument is not supported.".to_string(), + name: meta.name, + }); + } + + // Parse any epsilon value; we expect a float here. + let epsilon = if let Some(epsilon_value) = v1.rbf_kwargs.get("epsilon") { + if let Some(epsilon_f64) = epsilon_value.as_f64() { + Some(epsilon_f64) + } else { + return Err(ConversionError::UnexpectedType { + attr: "epsilon".to_string(), + name: meta.name, + expected: "float".to_string(), + actual: format!("{}", epsilon_value), + }); + } + } else { + None + }; + + let function = if let Some(function_value) = v1.rbf_kwargs.get("function") { + if let Some(function_str) = function_value.as_str() { + // Function kwarg is a string! + let f = match function_str { + "multiquadric" => RadialBasisFunction::MultiQuadric { epsilon }, + "inverse" => RadialBasisFunction::InverseMultiQuadric { epsilon }, + "gaussian" => RadialBasisFunction::Gaussian { epsilon }, + "linear" => RadialBasisFunction::Linear, + "cubic" => RadialBasisFunction::Cubic, + "thin_plate" => RadialBasisFunction::ThinPlateSpline, + _ => { + return Err(ConversionError::UnsupportedFeature { + feature: format!("Radial basis function `{}` not supported.", function_str), + name: meta.name.clone(), + }) + } + }; + f + } else { + return Err(ConversionError::UnexpectedType { + attr: "function".to_string(), + name: meta.name, + expected: "string".to_string(), + actual: format!("{}", function_value), + }); + } + } else { + // Default to multi-quadratic + RadialBasisFunction::MultiQuadric { epsilon } + }; + + let p = Self { + meta, + points, + function, + variable: None, + }; + + Ok(p) + } +} From 1f4c756da8318f2583a62aa24639b50bde72efbf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Dec 2023 10:27:29 +0000 Subject: [PATCH 3/3] chore(deps): Bump actions/setup-python from 4 to 5 (#78) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/python.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index a884b747..be714375 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -21,7 +21,7 @@ jobs: sudo apt-get update sudo apt-get install libhdf5-dev ocl-icd-opencl-dev zlib1g-dev - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.x" - name: Install Dependencies @@ -47,7 +47,7 @@ jobs: sudo apt-get update sudo apt-get install libhdf5-dev ocl-icd-opencl-dev liblzma-dev zlib1g-dev - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Build and test