Skip to content

Commit

Permalink
fix: Clamp storage volumes to be within min & max volume ranges.
Browse files Browse the repository at this point in the history
This accounts for small floating point rounding errors and
tolerances from LP results. In some cases the resulting
volume will be a rounding error above or below the bounds.

This also replicates behaviour from v1.
  • Loading branch information
jetuk committed Oct 11, 2024
1 parent 029c1c8 commit d737cbb
Show file tree
Hide file tree
Showing 8 changed files with 82 additions and 14 deletions.
4 changes: 2 additions & 2 deletions pywr-core/src/derived_metric.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ impl DerivedMetric {
pub fn compute(&self, network: &Network, state: &State) -> Result<f64, PywrError> {
match self {
Self::NodeProportionalVolume(idx) => {
let max_volume = network.get_node(idx)?.get_current_max_volume(state)?;
let max_volume = network.get_node(idx)?.get_max_volume(state)?;
Ok(state
.get_network_state()
.get_node_proportional_volume(idx, max_volume)?)
Expand All @@ -100,7 +100,7 @@ impl DerivedMetric {
let max_volume: f64 = node
.nodes
.iter()
.map(|idx| network.get_node(idx)?.get_current_max_volume(state))
.map(|idx| network.get_node(idx)?.get_max_volume(state))
.sum::<Result<_, _>>()?;
// TODO handle divide by zero
Ok(volume / max_volume)
Expand Down
11 changes: 6 additions & 5 deletions pywr-core/src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ impl Node {
}
}

pub fn get_current_min_volume(&self, state: &State) -> Result<f64, PywrError> {
pub fn get_min_volume(&self, state: &State) -> Result<f64, PywrError> {
match self {
Self::Input(_) => Err(PywrError::StorageConstraintsUndefined),
Self::Link(_) => Err(PywrError::StorageConstraintsUndefined),
Expand All @@ -439,7 +439,7 @@ impl Node {
}
}

pub fn get_current_max_volume(&self, state: &State) -> Result<f64, PywrError> {
pub fn get_max_volume(&self, state: &State) -> Result<f64, PywrError> {
match self {
Self::Input(_) => Err(PywrError::StorageConstraintsUndefined),
Self::Link(_) => Err(PywrError::StorageConstraintsUndefined),
Expand All @@ -448,10 +448,11 @@ impl Node {
}
}

pub fn get_current_volume_bounds(&self, state: &State) -> Result<(f64, f64), PywrError> {
match (self.get_current_min_volume(state), self.get_current_max_volume(state)) {
/// Return the current min and max volumes as a tuple.
pub fn get_volume_bounds(&self, state: &State) -> Result<(f64, f64), PywrError> {
match (self.get_min_volume(state), self.get_max_volume(state)) {
(Ok(min_vol), Ok(max_vol)) => Ok((min_vol, max_vol)),
_ => Err(PywrError::FlowConstraintsUndefined),
_ => Err(PywrError::StorageConstraintsUndefined),
}
}

Expand Down
2 changes: 1 addition & 1 deletion pywr-core/src/solvers/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ where
.iter()
.zip(network.virtual_storage_nodes().deref())
{
let (avail, missing) = match node.get_current_available_volume_bounds(state) {
let (avail, missing) = match node.get_available_volume_bounds(state) {
Ok(bnds) => bnds,
Err(e) => return Err(e),
};
Expand Down
2 changes: 1 addition & 1 deletion pywr-core/src/solvers/cbc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ impl Solver for CbcSolver {
let flow = if flow.abs() < 1e-10 { 0.0 } else { flow };
network_state.add_flow(edge, timestep, flow)?;
}
network_state.complete(model, timestep)?;
state.complete(model, timestep)?;
timings.save_solution += start_save_solution.elapsed();

Ok(timings)
Expand Down
2 changes: 1 addition & 1 deletion pywr-core/src/solvers/clp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ impl Solver for ClpSolver {
let flow = solution[col];
network_state.add_flow(edge, timestep, flow)?;
}
network_state.complete(model, timestep)?;
state.complete(model, timestep)?;
timings.save_solution += start_save_solution.elapsed();

Ok(timings)
Expand Down
2 changes: 1 addition & 1 deletion pywr-core/src/solvers/highs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ impl Solver for HighsSolver {
let flow = solution[col];
network_state.add_flow(edge, timestep, flow)?;
}
network_state.complete(network, timestep)?;
state.complete(network, timestep)?;
timings.save_solution += start_save_solution.elapsed();

Ok(timings)
Expand Down
71 changes: 69 additions & 2 deletions pywr-core/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ impl NodeState {
Self::Storage(s) => s.add_out_flow(flow, timestep),
};
}

fn clamp_volume(&mut self, min_volume: f64, max_volume: f64) {
if let Self::Storage(s) = self {
s.clamp(min_volume, max_volume);
}
}
}

#[derive(Clone, Copy, Debug, Default)]
Expand Down Expand Up @@ -124,6 +130,11 @@ impl StorageState {
// TODO handle divide by zero (is it full or empty?)
self.volume / max_volume
}

/// Ensure the volume is within the min and max volume range (inclusive).
fn clamp(&mut self, min_volume: f64, max_volume: f64) {
self.volume = self.volume.clamp(min_volume, max_volume);
}
}

/// Stores the history of virtual storage flows.
Expand Down Expand Up @@ -215,6 +226,10 @@ impl VirtualStorageState {
fn proportional_volume(&self, max_volume: f64) -> f64 {
self.storage.proportional_volume(max_volume)
}

fn clamp_volume(&mut self, min_volume: f64, max_volume: f64) {
self.storage.clamp(min_volume, max_volume);
}
}

#[derive(Clone, Copy, Debug, Default)]
Expand Down Expand Up @@ -530,8 +545,8 @@ impl NetworkState {
/// Complete a timestep after all the flow has been added.
///
/// This final step ensures that derived states (e.g. virtual storage volume) are updated
/// once all of the flows have been updated.
pub fn complete(&mut self, model: &Network, timestep: &Timestep) -> Result<(), PywrError> {
/// once all the flows have been updated.
fn update_derived_states(&mut self, model: &Network, timestep: &Timestep) -> Result<(), PywrError> {
// Update virtual storage node states
for (state, node) in self
.virtual_storage_states
Expand Down Expand Up @@ -562,6 +577,32 @@ impl NetworkState {
Ok(())
}

/// Clamp the volume of `node_index` to be within the bounds provided.
fn clamp_node_volume(&mut self, node_index: &NodeIndex, min_volume: f64, max_volume: f64) -> Result<(), PywrError> {
match self.node_states.get_mut(*node_index.deref()) {
Some(s) => {
s.clamp_volume(min_volume, max_volume);
Ok(())
}
None => Err(PywrError::NodeIndexNotFound),
}
}

/// Clamp the volume of `node_index` to be within the bounds provided.
fn clamp_virtual_storage_node_volume(
&mut self,
node_index: &VirtualStorageIndex,
min_volume: f64,
max_volume: f64,
) -> Result<(), PywrError> {
match self.virtual_storage_states.get_mut(*node_index.deref()) {
Some(s) => {
s.clamp_volume(min_volume, max_volume);
Ok(())
}
None => Err(PywrError::VirtualStorageIndexNotFound(*node_index)),
}
}
pub fn get_node_in_flow(&self, node_index: &NodeIndex) -> Result<f64, PywrError> {
match self.node_states.get(*node_index.deref()) {
Some(s) => Ok(s.get_in_flow()),
Expand Down Expand Up @@ -913,6 +954,32 @@ impl State {
None => Err(PywrError::MultiNetworkTransferIndexNotFound(idx)),
}
}

/// Complete a timestep after all the flow has been added.
///
/// This final step ensures, once all the flows have been updated, that:
/// - Derived states (e.g. virtual storage volume) are updated
/// - Volumes are within bounds
pub fn complete(&mut self, model: &Network, timestep: &Timestep) -> Result<(), PywrError> {
for node in model.nodes().iter() {
if let Node::Storage(_) = node {
let node_index = node.index();
let min_volume = node.get_min_volume(self)?;
let max_volume = node.get_max_volume(self)?;
self.network.clamp_node_volume(&node_index, min_volume, max_volume)?;
}
}

for node in model.virtual_storage_nodes().iter() {
let node_index = node.index();
let min_volume = node.get_min_volume(self)?;
let max_volume = node.get_max_volume(self)?;
self.network
.clamp_virtual_storage_node_volume(&node_index, min_volume, max_volume)?;
}

self.network.update_derived_states(model, timestep)
}
}

/// Builder for the [`State`] struct.
Expand Down
2 changes: 1 addition & 1 deletion pywr-core/src/virtual_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ impl VirtualStorage {
.get_max_volume(&state.get_simple_parameter_values())
}

pub fn get_current_available_volume_bounds(&self, state: &State) -> Result<(f64, f64), PywrError> {
pub fn get_available_volume_bounds(&self, state: &State) -> Result<(f64, f64), PywrError> {
let min_vol = self.get_min_volume(state)?;
let max_vol = self.get_max_volume(state)?;

Expand Down

0 comments on commit d737cbb

Please sign in to comment.