use thiserror::Error;
#[derive(Debug, Error)]
pub enum FlowError {
#[error("invalid configuration: {0}")]
InvalidConfig(String),
#[error("index out of range: {0}")]
IndexOutOfRange(String),
#[error("numerical error: {0}")]
NumericalError(String),
}
#[derive(Debug, Clone)]
pub enum EnergyCarrierFlow {
Electricity {
base_mva: f64,
},
NaturalGas {
pressure_bar: f64,
},
Heat {
temperature_c: f64,
},
Hydrogen {
pressure_bar: f64,
},
CoolingWater {
flow_rate_m3_per_h: f64,
},
}
impl EnergyCarrierFlow {
pub fn name(&self) -> &'static str {
match self {
EnergyCarrierFlow::Electricity { .. } => "Electricity",
EnergyCarrierFlow::NaturalGas { .. } => "NaturalGas",
EnergyCarrierFlow::Heat { .. } => "Heat",
EnergyCarrierFlow::Hydrogen { .. } => "Hydrogen",
EnergyCarrierFlow::CoolingWater { .. } => "CoolingWater",
}
}
}
#[derive(Debug, Clone)]
pub struct CouplingConstraint {
pub source_carrier: usize,
pub dest_carrier: usize,
pub node: usize,
pub conversion_efficiency: f64,
pub max_conversion_rate: f64,
pub bidirectional: bool,
}
#[derive(Debug, Clone)]
pub enum FlowObjective {
MinimizeCost,
MinimizeLosses,
MaximizeRenewable,
MultiObjective {
weights: Vec<f64>,
},
}
#[derive(Debug, Clone)]
pub struct EnergyFlowConfig {
pub n_nodes: usize,
pub commodities: Vec<EnergyCarrierFlow>,
pub coupling_constraints: Vec<CouplingConstraint>,
pub optimization_objective: FlowObjective,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NodeType {
Source,
Sink,
Hub,
Transit,
}
#[derive(Debug, Clone)]
pub struct NetworkNode {
pub id: usize,
pub name: String,
pub node_type: NodeType,
pub supply: Vec<f64>,
pub demand: Vec<f64>,
pub storage: Vec<f64>,
}
#[derive(Debug, Clone)]
pub struct NetworkArc {
pub from_node: usize,
pub to_node: usize,
pub carrier: usize,
pub capacity: f64,
pub cost_per_unit: f64,
pub loss_factor: f64,
}
#[derive(Debug, Clone)]
pub struct FlowResult {
pub arc_flows: Vec<(usize, usize, usize, f64)>,
pub node_balance: Vec<Vec<f64>>,
pub total_cost: f64,
pub total_losses: f64,
pub unmet_demand: Vec<f64>,
pub coupling_utilization: Vec<f64>,
}
pub struct MultiCommodityFlowSolver {
config: EnergyFlowConfig,
nodes: Vec<NetworkNode>,
arcs: Vec<NetworkArc>,
}
impl MultiCommodityFlowSolver {
pub fn new(config: EnergyFlowConfig) -> Self {
Self {
config,
nodes: Vec::new(),
arcs: Vec::new(),
}
}
pub fn add_node(&mut self, node: NetworkNode) {
self.nodes.push(node);
}
pub fn add_arc(&mut self, arc: NetworkArc) {
self.arcs.push(arc);
}
pub fn solve(&self) -> Result<FlowResult, FlowError> {
let n_nodes = self.nodes.len();
let n_commodities = self.config.commodities.len();
if n_nodes == 0 {
return Err(FlowError::InvalidConfig("No nodes in network".to_string()));
}
if n_commodities == 0 {
return Err(FlowError::InvalidConfig(
"No commodities defined".to_string(),
));
}
for (i, node) in self.nodes.iter().enumerate() {
if node.id != i {
}
if node.supply.len() != n_commodities {
return Err(FlowError::IndexOutOfRange(format!(
"Node {} supply vector has {} entries, expected {}",
node.id,
node.supply.len(),
n_commodities
)));
}
}
let mut arc_remaining: Vec<f64> = self.arcs.iter().map(|a| a.capacity).collect();
let mut arc_flow_values: Vec<f64> = vec![0.0; self.arcs.len()];
let mut node_balance: Vec<Vec<f64>> = self
.nodes
.iter()
.map(|n| {
(0..n_commodities)
.map(|c| {
n.supply.get(c).copied().unwrap_or(0.0)
- n.demand.get(c).copied().unwrap_or(0.0)
})
.collect()
})
.collect();
for carrier in 0..n_commodities {
self.route_commodity(
carrier,
&mut node_balance,
&mut arc_remaining,
&mut arc_flow_values,
)?;
}
let mut coupling_utilization = vec![0.0_f64; self.config.coupling_constraints.len()];
for (ci, cc) in self.config.coupling_constraints.iter().enumerate() {
let src = cc.source_carrier;
let dst = cc.dest_carrier;
if src >= n_commodities || dst >= n_commodities {
continue;
}
let node_pos = self
.nodes
.iter()
.position(|n| n.id == cc.node)
.unwrap_or(cc.node);
if node_pos >= n_nodes {
continue;
}
let surplus = node_balance[node_pos][src].max(0.0);
let deficit = (-node_balance[node_pos][dst]).max(0.0);
let convertible = surplus.min(cc.max_conversion_rate);
let converted_out = convertible * cc.conversion_efficiency;
let actual_convert = convertible.min(deficit / cc.conversion_efficiency.max(1e-12));
let actual_out = actual_convert * cc.conversion_efficiency;
node_balance[node_pos][src] -= actual_convert;
node_balance[node_pos][dst] += actual_out;
coupling_utilization[ci] = if cc.max_conversion_rate > 1e-12 {
actual_convert / cc.max_conversion_rate
} else {
0.0
};
let _ = converted_out; }
let unmet_demand: Vec<f64> = (0..n_commodities)
.map(|c| {
self.nodes
.iter()
.enumerate()
.map(|(ni, node)| {
let demand = node.demand.get(c).copied().unwrap_or(0.0);
let supply_net = node_balance[ni][c];
if supply_net < 0.0 {
supply_net.abs().min(demand)
} else {
0.0
}
})
.sum()
})
.collect();
let mut arc_flows: Vec<(usize, usize, usize, f64)> = Vec::new();
let mut total_cost = 0.0_f64;
let mut total_losses = 0.0_f64;
for (i, arc) in self.arcs.iter().enumerate() {
let flow = arc_flow_values[i];
if flow > 1e-12 {
arc_flows.push((arc.from_node, arc.to_node, arc.carrier, flow));
total_cost += flow * arc.cost_per_unit;
total_losses += flow * arc.loss_factor;
}
}
Ok(FlowResult {
arc_flows,
node_balance,
total_cost,
total_losses,
unmet_demand,
coupling_utilization,
})
}
fn route_commodity(
&self,
carrier: usize,
node_balance: &mut [Vec<f64>],
arc_remaining: &mut [f64],
arc_flow_values: &mut [f64],
) -> Result<(), FlowError> {
let n_nodes = self.nodes.len();
let supply_nodes: Vec<usize> = (0..n_nodes)
.filter(|&i| node_balance[i][carrier] > 1e-9)
.collect();
let demand_nodes: Vec<usize> = (0..n_nodes)
.filter(|&i| node_balance[i][carrier] < -1e-9)
.collect();
if supply_nodes.is_empty() || demand_nodes.is_empty() {
return Ok(()); }
for &src in &supply_nodes {
if node_balance[src][carrier] < 1e-9 {
continue;
}
let predecessors = self.dijkstra(src, carrier, arc_remaining);
for &dst in &demand_nodes {
if node_balance[dst][carrier] > -1e-9 {
continue; }
if node_balance[src][carrier] < 1e-9 {
break; }
let path = reconstruct_path(src, dst, &predecessors);
if path.is_empty() {
continue; }
let avail_supply = node_balance[src][carrier];
let demand_deficit = (-node_balance[dst][carrier]).max(0.0);
let bottleneck = self.path_bottleneck(&path, carrier, arc_remaining);
let flow = avail_supply.min(demand_deficit).min(bottleneck);
if flow < 1e-12 {
continue;
}
let mut remaining_flow = flow;
for (&u, &v) in path.iter().zip(path.iter().skip(1)) {
if let Some(arc_idx) = self.find_arc(u, v, carrier) {
let arc = &self.arcs[arc_idx];
let actual = remaining_flow.min(arc_remaining[arc_idx]);
arc_remaining[arc_idx] -= actual;
arc_flow_values[arc_idx] += actual;
remaining_flow = actual * (1.0 - arc.loss_factor);
}
}
node_balance[src][carrier] -= flow;
node_balance[dst][carrier] += flow; }
}
Ok(())
}
pub fn shortest_path(&self, source: usize, carrier: usize) -> Vec<usize> {
let arc_remaining = vec![f64::MAX; self.arcs.len()]; self.dijkstra(source, carrier, &arc_remaining)
}
fn dijkstra(&self, source: usize, carrier: usize, arc_remaining: &[f64]) -> Vec<usize> {
let n = self.nodes.len();
let mut dist = vec![f64::MAX; n];
let mut pred = vec![usize::MAX; n];
let mut visited = vec![false; n];
dist[source] = 0.0;
for _ in 0..n {
let u = match (0..n)
.filter(|&i| !visited[i] && dist[i] < f64::MAX)
.min_by(|&a, &b| {
dist[a]
.partial_cmp(&dist[b])
.unwrap_or(std::cmp::Ordering::Equal)
}) {
Some(v) => v,
None => break,
};
visited[u] = true;
for (arc_idx, arc) in self.arcs.iter().enumerate() {
if arc.carrier != carrier || arc.from_node != u {
continue;
}
if arc_remaining[arc_idx] < 1e-12 {
continue; }
let v = arc.to_node;
if v >= n || visited[v] {
continue;
}
let new_dist = dist[u] + arc.cost_per_unit;
if new_dist < dist[v] {
dist[v] = new_dist;
pred[v] = u;
}
}
}
pred
}
fn find_arc(&self, u: usize, v: usize, carrier: usize) -> Option<usize> {
self.arcs
.iter()
.position(|a| a.from_node == u && a.to_node == v && a.carrier == carrier)
}
fn path_bottleneck(&self, path: &[usize], carrier: usize, arc_remaining: &[f64]) -> f64 {
let mut min_cap = f64::MAX;
for (&u, &v) in path.iter().zip(path.iter().skip(1)) {
if let Some(arc_idx) = self.find_arc(u, v, carrier) {
min_cap = min_cap.min(arc_remaining[arc_idx]);
} else {
return 0.0; }
}
if min_cap == f64::MAX {
0.0
} else {
min_cap
}
}
}
fn reconstruct_path(src: usize, dst: usize, pred: &[usize]) -> Vec<usize> {
if pred[dst] == usize::MAX && dst != src {
return Vec::new(); }
let mut path = Vec::new();
let mut cur = dst;
let mut steps = 0usize;
let max_steps = pred.len() + 1;
while cur != src && steps < max_steps {
path.push(cur);
let p = pred[cur];
if p == usize::MAX {
return Vec::new(); }
cur = p;
steps += 1;
}
path.push(src);
path.reverse();
path
}
#[cfg(test)]
mod tests {
use super::*;
fn elec_config() -> EnergyFlowConfig {
EnergyFlowConfig {
n_nodes: 3,
commodities: vec![EnergyCarrierFlow::Electricity { base_mva: 100.0 }],
coupling_constraints: vec![],
optimization_objective: FlowObjective::MinimizeCost,
}
}
fn make_node(id: usize, supply: f64, demand: f64, n_carriers: usize) -> NetworkNode {
NetworkNode {
id,
name: format!("N{id}"),
node_type: if supply > 0.0 {
NodeType::Source
} else {
NodeType::Sink
},
supply: (0..n_carriers)
.map(|c| if c == 0 { supply } else { 0.0 })
.collect(),
demand: (0..n_carriers)
.map(|c| if c == 0 { demand } else { 0.0 })
.collect(),
storage: vec![0.0; n_carriers],
}
}
#[test]
fn test_single_commodity_balanced_flow() {
let config = elec_config();
let mut solver = MultiCommodityFlowSolver::new(config);
solver.add_node(make_node(0, 100.0, 0.0, 1));
solver.add_node(make_node(1, 0.0, 100.0, 1));
solver.add_arc(NetworkArc {
from_node: 0,
to_node: 1,
carrier: 0,
capacity: 150.0,
cost_per_unit: 1.0,
loss_factor: 0.0,
});
let result = solver.solve().unwrap();
assert!(
result.unmet_demand[0] < 1e-6,
"Demand should be fully met: {:.4}",
result.unmet_demand[0]
);
let flow: f64 = result.arc_flows.iter().map(|&(_, _, _, f)| f).sum();
assert!(
(flow - 100.0).abs() < 1e-6,
"Total flow should be 100: {flow:.4}"
);
}
#[test]
fn test_multi_commodity_separate_carriers() {
let config = EnergyFlowConfig {
n_nodes: 4,
commodities: vec![
EnergyCarrierFlow::Electricity { base_mva: 100.0 },
EnergyCarrierFlow::NaturalGas { pressure_bar: 50.0 },
],
coupling_constraints: vec![],
optimization_objective: FlowObjective::MinimizeCost,
};
let mut solver = MultiCommodityFlowSolver::new(config);
solver.add_node(NetworkNode {
id: 0,
name: "ElecSource".to_string(),
node_type: NodeType::Source,
supply: vec![50.0, 0.0],
demand: vec![0.0, 0.0],
storage: vec![0.0, 0.0],
});
solver.add_node(NetworkNode {
id: 1,
name: "ElecSink".to_string(),
node_type: NodeType::Sink,
supply: vec![0.0, 0.0],
demand: vec![50.0, 0.0],
storage: vec![0.0, 0.0],
});
solver.add_node(NetworkNode {
id: 2,
name: "GasSource".to_string(),
node_type: NodeType::Source,
supply: vec![0.0, 80.0],
demand: vec![0.0, 0.0],
storage: vec![0.0, 0.0],
});
solver.add_node(NetworkNode {
id: 3,
name: "GasSink".to_string(),
node_type: NodeType::Sink,
supply: vec![0.0, 0.0],
demand: vec![0.0, 80.0],
storage: vec![0.0, 0.0],
});
solver.add_arc(NetworkArc {
from_node: 0,
to_node: 1,
carrier: 0,
capacity: 100.0,
cost_per_unit: 1.0,
loss_factor: 0.0,
});
solver.add_arc(NetworkArc {
from_node: 2,
to_node: 3,
carrier: 1,
capacity: 200.0,
cost_per_unit: 0.5,
loss_factor: 0.0,
});
let result = solver.solve().unwrap();
assert!(
result.unmet_demand[0] < 1e-6,
"Electricity unmet: {:.4}",
result.unmet_demand[0]
);
assert!(
result.unmet_demand[1] < 1e-6,
"Gas unmet: {:.4}",
result.unmet_demand[1]
);
assert_eq!(result.arc_flows.len(), 2, "Should have 2 arc flows");
}
#[test]
fn test_coupling_electricity_to_heat() {
let config = EnergyFlowConfig {
n_nodes: 2,
commodities: vec![
EnergyCarrierFlow::Electricity { base_mva: 100.0 },
EnergyCarrierFlow::Heat {
temperature_c: 90.0,
},
],
coupling_constraints: vec![CouplingConstraint {
source_carrier: 0, dest_carrier: 1, node: 0,
conversion_efficiency: 0.9,
max_conversion_rate: 50.0,
bidirectional: false,
}],
optimization_objective: FlowObjective::MinimizeCost,
};
let mut solver = MultiCommodityFlowSolver::new(config);
solver.add_node(NetworkNode {
id: 0,
name: "Hub".to_string(),
node_type: NodeType::Hub,
supply: vec![100.0, 0.0],
demand: vec![0.0, 40.0],
storage: vec![0.0, 0.0],
});
solver.add_node(NetworkNode {
id: 1,
name: "Transit".to_string(),
node_type: NodeType::Transit,
supply: vec![0.0, 0.0],
demand: vec![0.0, 0.0],
storage: vec![0.0, 0.0],
});
let result = solver.solve().unwrap();
assert!(
result.unmet_demand[1] < 1e-6,
"Heat demand should be met via coupling: {:.4}",
result.unmet_demand[1]
);
assert!(
result.coupling_utilization[0] > 0.0,
"Coupling should be utilised"
);
}
#[test]
fn test_capacity_limit_bounds_flow() {
let config = elec_config();
let mut solver = MultiCommodityFlowSolver::new(config);
solver.add_node(make_node(0, 200.0, 0.0, 1));
solver.add_node(make_node(1, 0.0, 200.0, 1));
solver.add_arc(NetworkArc {
from_node: 0,
to_node: 1,
carrier: 0,
capacity: 80.0,
cost_per_unit: 1.0,
loss_factor: 0.0,
});
let result = solver.solve().unwrap();
let total_flow: f64 = result.arc_flows.iter().map(|&(_, _, _, f)| f).sum();
assert!(
total_flow <= 80.0 + 1e-6,
"Flow should be bounded by arc capacity: {total_flow:.4}"
);
assert!(
result.unmet_demand[0] > 1e-6,
"There should be unmet demand when capacity is insufficient"
);
}
#[test]
fn test_unmet_demand_when_no_supply() {
let config = elec_config();
let mut solver = MultiCommodityFlowSolver::new(config);
solver.add_node(make_node(0, 0.0, 100.0, 1));
solver.add_node(make_node(1, 0.0, 50.0, 1));
let result = solver.solve().unwrap();
assert!(
result.unmet_demand[0] > 1.0,
"All demand should be unmet when there is no supply"
);
}
#[test]
fn test_no_nodes_returns_error() {
let config = elec_config();
let solver = MultiCommodityFlowSolver::new(config);
let result = solver.solve();
assert!(result.is_err(), "Empty node set should return error");
}
#[test]
fn test_losses_accumulated() {
let config = elec_config();
let mut solver = MultiCommodityFlowSolver::new(config);
solver.add_node(make_node(0, 100.0, 0.0, 1));
solver.add_node(make_node(1, 0.0, 100.0, 1));
solver.add_arc(NetworkArc {
from_node: 0,
to_node: 1,
carrier: 0,
capacity: 200.0,
cost_per_unit: 1.0,
loss_factor: 0.05, });
let result = solver.solve().unwrap();
assert!(
result.total_losses > 0.0,
"Losses should be positive with loss_factor > 0"
);
}
#[test]
fn test_multi_hop_routing() {
let config = elec_config();
let mut solver = MultiCommodityFlowSolver::new(config);
solver.add_node(make_node(0, 60.0, 0.0, 1));
solver.add_node(NetworkNode {
id: 1,
name: "Hub".to_string(),
node_type: NodeType::Transit,
supply: vec![0.0],
demand: vec![0.0],
storage: vec![0.0],
});
solver.add_node(make_node(2, 0.0, 60.0, 1));
solver.add_arc(NetworkArc {
from_node: 0,
to_node: 1,
carrier: 0,
capacity: 100.0,
cost_per_unit: 1.0,
loss_factor: 0.0,
});
solver.add_arc(NetworkArc {
from_node: 1,
to_node: 2,
carrier: 0,
capacity: 100.0,
cost_per_unit: 1.0,
loss_factor: 0.0,
});
let result = solver.solve().unwrap();
assert!(
result.unmet_demand[0] < 1e-6,
"Multi-hop: demand should be met: {:.4}",
result.unmet_demand[0]
);
}
}