use crate::CoefficientRef;
use crate::EntityId;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum VariableRef {
HydroStorage {
hydro_id: EntityId,
},
HydroTurbined {
hydro_id: EntityId,
block_id: Option<usize>,
bus_id: Option<EntityId>,
},
HydroSpillage {
hydro_id: EntityId,
block_id: Option<usize>,
},
HydroDiversion {
hydro_id: EntityId,
block_id: Option<usize>,
},
HydroOutflow {
hydro_id: EntityId,
block_id: Option<usize>,
},
HydroGeneration {
hydro_id: EntityId,
block_id: Option<usize>,
bus_id: Option<EntityId>,
},
HydroEvaporation {
hydro_id: EntityId,
block_id: Option<usize>,
},
HydroWithdrawal {
hydro_id: EntityId,
},
ThermalGeneration {
thermal_id: EntityId,
block_id: Option<usize>,
},
LineDirect {
line_id: EntityId,
block_id: Option<usize>,
},
LineReverse {
line_id: EntityId,
block_id: Option<usize>,
},
LineExchange {
line_id: EntityId,
block_id: Option<usize>,
},
BusDeficit {
bus_id: EntityId,
block_id: Option<usize>,
},
BusExcess {
bus_id: EntityId,
block_id: Option<usize>,
},
PumpingFlow {
station_id: EntityId,
block_id: Option<usize>,
},
PumpingPower {
station_id: EntityId,
block_id: Option<usize>,
},
ContractImport {
contract_id: EntityId,
block_id: Option<usize>,
},
ContractExport {
contract_id: EntityId,
block_id: Option<usize>,
},
NonControllableGeneration {
source_id: EntityId,
block_id: Option<usize>,
},
NonControllableCurtailment {
source_id: EntityId,
block_id: Option<usize>,
},
AnticipatedDecision {
thermal_id: EntityId,
},
HydroInflow {
hydro_id: EntityId,
block_id: Option<usize>,
},
HydroStorageInitial {
hydro_id: EntityId,
block_id: Option<usize>,
},
HydroStorageFinal {
hydro_id: EntityId,
block_id: Option<usize>,
},
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct LinearTerm {
pub coefficient: CoefficientRef,
pub scale: f64,
pub variable: VariableRef,
}
impl LinearTerm {
#[must_use]
pub fn literal(coef: f64, variable: VariableRef) -> Self {
Self {
coefficient: CoefficientRef::Literal(coef),
scale: 1.0,
variable,
}
}
#[must_use]
pub fn parameter(id: EntityId, scale: f64, variable: VariableRef) -> Self {
Self {
coefficient: CoefficientRef::Parameter(id),
scale,
variable,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ConstraintExpression {
pub terms: Vec<LinearTerm>,
}
impl ConstraintExpression {
pub fn canonicalize(&mut self) {
self.terms.sort_by_key(canonical_term_key);
}
}
fn canonical_variable_key(v: &VariableRef) -> (u8, i32, i64, i64) {
match *v {
VariableRef::HydroStorage { hydro_id } => (0, hydro_id.0, -1, -1),
VariableRef::HydroTurbined {
hydro_id,
block_id,
bus_id,
} => (
1,
hydro_id.0,
block_sentinel(block_id),
bus_sentinel(bus_id),
),
VariableRef::HydroSpillage { hydro_id, block_id } => {
(2, hydro_id.0, block_sentinel(block_id), -1)
}
VariableRef::HydroDiversion { hydro_id, block_id } => {
(3, hydro_id.0, block_sentinel(block_id), -1)
}
VariableRef::HydroOutflow { hydro_id, block_id } => {
(4, hydro_id.0, block_sentinel(block_id), -1)
}
VariableRef::HydroGeneration {
hydro_id,
block_id,
bus_id,
} => (
5,
hydro_id.0,
block_sentinel(block_id),
bus_sentinel(bus_id),
),
VariableRef::HydroEvaporation { hydro_id, block_id } => {
(6, hydro_id.0, block_sentinel(block_id), -1)
}
VariableRef::HydroWithdrawal { hydro_id } => (7, hydro_id.0, -1, -1),
VariableRef::ThermalGeneration {
thermal_id,
block_id,
} => (8, thermal_id.0, block_sentinel(block_id), -1),
VariableRef::LineDirect { line_id, block_id } => {
(9, line_id.0, block_sentinel(block_id), -1)
}
VariableRef::LineReverse { line_id, block_id } => {
(10, line_id.0, block_sentinel(block_id), -1)
}
VariableRef::LineExchange { line_id, block_id } => {
(11, line_id.0, block_sentinel(block_id), -1)
}
VariableRef::BusDeficit { bus_id, block_id } => {
(12, bus_id.0, block_sentinel(block_id), -1)
}
VariableRef::BusExcess { bus_id, block_id } => (13, bus_id.0, block_sentinel(block_id), -1),
VariableRef::PumpingFlow {
station_id,
block_id,
} => (14, station_id.0, block_sentinel(block_id), -1),
VariableRef::PumpingPower {
station_id,
block_id,
} => (15, station_id.0, block_sentinel(block_id), -1),
VariableRef::ContractImport {
contract_id,
block_id,
} => (16, contract_id.0, block_sentinel(block_id), -1),
VariableRef::ContractExport {
contract_id,
block_id,
} => (17, contract_id.0, block_sentinel(block_id), -1),
VariableRef::NonControllableGeneration {
source_id,
block_id,
} => (18, source_id.0, block_sentinel(block_id), -1),
VariableRef::NonControllableCurtailment {
source_id,
block_id,
} => (19, source_id.0, block_sentinel(block_id), -1),
VariableRef::AnticipatedDecision { thermal_id } => (20, thermal_id.0, -1, -1),
VariableRef::HydroInflow { hydro_id, block_id } => {
(21, hydro_id.0, block_sentinel(block_id), -1)
}
VariableRef::HydroStorageInitial { hydro_id, block_id } => {
(22, hydro_id.0, block_sentinel(block_id), -1)
}
VariableRef::HydroStorageFinal { hydro_id, block_id } => {
(23, hydro_id.0, block_sentinel(block_id), -1)
}
}
}
#[allow(clippy::cast_possible_wrap)]
fn block_sentinel(block_id: Option<usize>) -> i64 {
block_id.map_or(-1, |b| b as i64)
}
fn bus_sentinel(bus_id: Option<EntityId>) -> i64 {
bus_id.map_or(-1, |b| i64::from(b.0))
}
fn canonical_term_key(term: &LinearTerm) -> ((u8, i32, i64, i64), u8, u64, u64) {
let (coef_kind, coef_payload) = match term.coefficient {
CoefficientRef::Literal(v) => (0, v.to_bits()),
CoefficientRef::Parameter(id) => (1, u64::from(id.0.cast_unsigned())),
};
(
canonical_variable_key(&term.variable),
coef_kind,
coef_payload,
term.scale.to_bits(),
)
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AffineBound {
pub constant: f64,
pub terms: Vec<(f64, EntityId)>,
}
impl AffineBound {
#[must_use]
pub fn single(id: EntityId) -> Self {
Self {
constant: 0.0,
terms: vec![(1.0, id)],
}
}
pub fn params(&self) -> impl Iterator<Item = EntityId> + '_ {
self.terms.iter().map(|&(_, id)| id)
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SlackConfig {
pub enabled: bool,
pub penalty: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct GenericConstraint {
pub id: EntityId,
pub name: String,
pub description: Option<String>,
pub expression: ConstraintExpression,
pub slack: SlackConfig,
pub bound_lower_affine: Option<AffineBound>,
pub bound_upper_affine: Option<AffineBound>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_variable_ref_variants() {
let variants: &[(&str, VariableRef)] = &[
(
"HydroStorage",
VariableRef::HydroStorage {
hydro_id: EntityId(0),
},
),
(
"HydroTurbined",
VariableRef::HydroTurbined {
hydro_id: EntityId(0),
block_id: None,
bus_id: None,
},
),
(
"HydroSpillage",
VariableRef::HydroSpillage {
hydro_id: EntityId(0),
block_id: Some(1),
},
),
(
"HydroDiversion",
VariableRef::HydroDiversion {
hydro_id: EntityId(0),
block_id: None,
},
),
(
"HydroOutflow",
VariableRef::HydroOutflow {
hydro_id: EntityId(0),
block_id: None,
},
),
(
"HydroGeneration",
VariableRef::HydroGeneration {
hydro_id: EntityId(0),
block_id: Some(0),
bus_id: None,
},
),
(
"HydroEvaporation",
VariableRef::HydroEvaporation {
hydro_id: EntityId(0),
block_id: None,
},
),
(
"HydroWithdrawal",
VariableRef::HydroWithdrawal {
hydro_id: EntityId(0),
},
),
(
"ThermalGeneration",
VariableRef::ThermalGeneration {
thermal_id: EntityId(0),
block_id: None,
},
),
(
"LineDirect",
VariableRef::LineDirect {
line_id: EntityId(0),
block_id: None,
},
),
(
"LineReverse",
VariableRef::LineReverse {
line_id: EntityId(0),
block_id: None,
},
),
(
"LineExchange",
VariableRef::LineExchange {
line_id: EntityId(0),
block_id: None,
},
),
(
"BusDeficit",
VariableRef::BusDeficit {
bus_id: EntityId(0),
block_id: None,
},
),
(
"BusExcess",
VariableRef::BusExcess {
bus_id: EntityId(0),
block_id: None,
},
),
(
"PumpingFlow",
VariableRef::PumpingFlow {
station_id: EntityId(0),
block_id: None,
},
),
(
"PumpingPower",
VariableRef::PumpingPower {
station_id: EntityId(0),
block_id: None,
},
),
(
"ContractImport",
VariableRef::ContractImport {
contract_id: EntityId(0),
block_id: None,
},
),
(
"ContractExport",
VariableRef::ContractExport {
contract_id: EntityId(0),
block_id: None,
},
),
(
"NonControllableGeneration",
VariableRef::NonControllableGeneration {
source_id: EntityId(0),
block_id: None,
},
),
(
"NonControllableCurtailment",
VariableRef::NonControllableCurtailment {
source_id: EntityId(0),
block_id: None,
},
),
(
"AnticipatedDecision",
VariableRef::AnticipatedDecision {
thermal_id: EntityId(0),
},
),
(
"HydroInflow",
VariableRef::HydroInflow {
hydro_id: EntityId(0),
block_id: None,
},
),
(
"HydroStorageInitial",
VariableRef::HydroStorageInitial {
hydro_id: EntityId(0),
block_id: Some(0),
},
),
(
"HydroStorageFinal",
VariableRef::HydroStorageFinal {
hydro_id: EntityId(0),
block_id: None,
},
),
];
assert_eq!(
variants.len(),
24,
"VariableRef must have exactly 24 variants"
);
for (name, variant) in variants {
let debug_str = format!("{variant:?}");
assert!(
debug_str.contains(name),
"Debug output for {name} does not contain the variant name: {debug_str}"
);
}
}
#[cfg(feature = "serde")]
#[test]
fn test_variable_ref_postcard_discriminant_pin() {
let hydro_inflow = postcard::to_allocvec(&VariableRef::HydroInflow {
hydro_id: EntityId(0),
block_id: None,
})
.expect("HydroInflow serializes");
assert_eq!(
hydro_inflow[0], 0x15,
"HydroInflow must serialize to postcard discriminant 0x15"
);
let anticipated = postcard::to_allocvec(&VariableRef::AnticipatedDecision {
thermal_id: EntityId(0),
})
.expect("AnticipatedDecision serializes");
assert_eq!(
anticipated[0], 0x14,
"AnticipatedDecision must keep postcard discriminant 0x14"
);
let storage_initial = postcard::to_allocvec(&VariableRef::HydroStorageInitial {
hydro_id: EntityId(0),
block_id: None,
})
.expect("HydroStorageInitial serializes");
assert_eq!(
storage_initial[0], 0x16,
"HydroStorageInitial must serialize to postcard discriminant 0x16"
);
let storage_final = postcard::to_allocvec(&VariableRef::HydroStorageFinal {
hydro_id: EntityId(0),
block_id: None,
})
.expect("HydroStorageFinal serializes");
assert_eq!(
storage_final[0], 0x17,
"HydroStorageFinal must serialize to postcard discriminant 0x17"
);
}
#[test]
fn test_generic_constraint_construction() {
let expr = ConstraintExpression {
terms: vec![
LinearTerm::literal(
1.0,
VariableRef::HydroGeneration {
hydro_id: EntityId(10),
block_id: None,
bus_id: None,
},
),
LinearTerm::literal(
1.0,
VariableRef::HydroGeneration {
hydro_id: EntityId(11),
block_id: None,
bus_id: None,
},
),
],
};
let gc = GenericConstraint {
id: EntityId(0),
name: "min_southeast_hydro".to_string(),
description: Some("Minimum hydro generation in Southeast region".to_string()),
expression: expr,
slack: SlackConfig {
enabled: true,
penalty: Some(5_000.0),
},
bound_lower_affine: None,
bound_upper_affine: None,
};
assert_eq!(gc.expression.terms.len(), 2);
assert_eq!(gc.id, EntityId(0));
assert_eq!(gc.name, "min_southeast_hydro");
assert!(gc.description.is_some());
assert!(gc.slack.enabled);
assert_eq!(gc.slack.penalty, Some(5_000.0));
}
#[test]
fn canonicalize_orders_by_content_not_authoring_order() {
let a = LinearTerm::literal(
1.0,
VariableRef::HydroGeneration {
hydro_id: EntityId(1),
block_id: None,
bus_id: None,
},
);
let b = LinearTerm::literal(
1.0,
VariableRef::HydroGeneration {
hydro_id: EntityId(0),
block_id: None,
bus_id: None,
},
);
let mut forward = ConstraintExpression {
terms: vec![a.clone(), b.clone()],
};
let mut reversed = ConstraintExpression { terms: vec![b, a] };
forward.canonicalize();
reversed.canonicalize();
assert_eq!(forward.terms, reversed.terms);
assert_eq!(
forward.terms[0].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(0),
block_id: None,
bus_id: None,
}
);
assert_eq!(
forward.terms[1].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(1),
block_id: None,
bus_id: None,
}
);
}
#[test]
fn canonicalize_is_idempotent() {
let mut expr = ConstraintExpression {
terms: vec![
LinearTerm::literal(
2.0,
VariableRef::ThermalGeneration {
thermal_id: EntityId(5),
block_id: None,
},
),
LinearTerm::literal(
1.0,
VariableRef::HydroGeneration {
hydro_id: EntityId(3),
block_id: None,
bus_id: None,
},
),
LinearTerm::parameter(
EntityId(7),
-1.0,
VariableRef::HydroStorage {
hydro_id: EntityId(2),
},
),
],
};
expr.canonicalize();
let once = expr.terms.clone();
expr.canonicalize();
assert_eq!(expr.terms, once);
}
#[test]
fn canonical_key_orders_literal_before_parameter() {
let var = VariableRef::HydroGeneration {
hydro_id: EntityId(4),
block_id: None,
bus_id: None,
};
let mut expr = ConstraintExpression {
terms: vec![
LinearTerm::parameter(EntityId(7), 1.0, var),
LinearTerm::literal(1.0, var),
],
};
expr.canonicalize();
assert_eq!(expr.terms[0].coefficient, CoefficientRef::Literal(1.0));
assert!(matches!(
expr.terms[1].coefficient,
CoefficientRef::Parameter(_)
));
}
#[test]
fn canonical_variable_key_is_injective_over_distinct_variants() {
let vars = [
VariableRef::HydroStorage {
hydro_id: EntityId(0),
},
VariableRef::HydroGeneration {
hydro_id: EntityId(0),
block_id: None,
bus_id: None,
},
VariableRef::HydroGeneration {
hydro_id: EntityId(0),
block_id: Some(0),
bus_id: None,
},
VariableRef::HydroGeneration {
hydro_id: EntityId(0),
block_id: None,
bus_id: Some(EntityId(0)),
},
VariableRef::ThermalGeneration {
thermal_id: EntityId(0),
block_id: None,
},
VariableRef::AnticipatedDecision {
thermal_id: EntityId(0),
},
VariableRef::HydroStorageFinal {
hydro_id: EntityId(0),
block_id: None,
},
];
let mut keys: Vec<_> = vars.iter().map(canonical_variable_key).collect();
let n = keys.len();
keys.sort_unstable();
keys.dedup();
assert_eq!(
keys.len(),
n,
"distinct variables must map to distinct canonical keys"
);
}
#[test]
fn test_slack_config_disabled_has_no_penalty() {
let slack = SlackConfig {
enabled: false,
penalty: None,
};
assert!(!slack.enabled);
assert!(slack.penalty.is_none());
}
fn lit(term: &LinearTerm) -> f64 {
match term.coefficient {
CoefficientRef::Literal(v) => v,
CoefficientRef::Parameter(_) => panic!("expected literal"),
}
}
#[test]
fn test_linear_term_with_coefficient() {
let term = LinearTerm::literal(
2.5,
VariableRef::ThermalGeneration {
thermal_id: EntityId(5),
block_id: None,
},
);
assert!((lit(&term) - 2.5).abs() < f64::EPSILON);
let debug = format!("{:?}", term.variable);
assert!(debug.contains("ThermalGeneration"));
}
#[test]
fn linear_term_literal_constructor() {
let term = LinearTerm::literal(
3.0,
VariableRef::ThermalGeneration {
thermal_id: EntityId(1),
block_id: None,
},
);
assert_eq!(term.coefficient, CoefficientRef::Literal(3.0));
assert!((term.scale - 1.0).abs() < f64::EPSILON);
assert_eq!(
term.variable,
VariableRef::ThermalGeneration {
thermal_id: EntityId(1),
block_id: None,
}
);
}
#[test]
fn linear_term_explicit_scale() {
let term = LinearTerm {
coefficient: CoefficientRef::Literal(2.0),
scale: 0.5,
variable: VariableRef::HydroStorage {
hydro_id: EntityId(1),
},
};
assert!((term.scale - 0.5).abs() < f64::EPSILON);
}
#[test]
fn test_variable_ref_block_none_vs_some() {
let all_blocks = VariableRef::HydroTurbined {
hydro_id: EntityId(3),
block_id: None,
bus_id: None,
};
let specific_block = VariableRef::HydroTurbined {
hydro_id: EntityId(3),
block_id: Some(0),
bus_id: None,
};
assert_ne!(all_blocks, specific_block);
}
#[test]
fn anticipated_decision_constructs() {
let v = VariableRef::AnticipatedDecision {
thermal_id: EntityId(5),
};
let debug_str = format!("{v:?}");
assert!(
debug_str.contains("AnticipatedDecision"),
"Debug output should contain variant name: {debug_str}"
);
assert!(
debug_str.contains("thermal_id"),
"Debug output should contain field name: {debug_str}"
);
}
#[test]
fn anticipated_decision_copy_and_eq() {
let v1 = VariableRef::AnticipatedDecision {
thermal_id: EntityId(5),
};
let v2 = v1;
assert_eq!(v1, v2);
let v3 = VariableRef::AnticipatedDecision {
thermal_id: EntityId(9),
};
assert_ne!(v1, v3);
}
#[cfg(feature = "serde")]
#[test]
fn anticipated_decision_postcard_roundtrip() {
let original = VariableRef::AnticipatedDecision {
thermal_id: EntityId(5),
};
let bytes = postcard::to_allocvec(&original).expect("serialize");
let recovered: VariableRef = postcard::from_bytes(&bytes).expect("deserialize");
assert_eq!(
original, recovered,
"postcard round-trip must preserve the variant"
);
assert_eq!(
bytes[0], 20,
"AnticipatedDecision must be discriminant 20 (end-of-enum); \
got {}. Did you insert a variant before it?",
bytes[0]
);
}
#[cfg(feature = "serde")]
#[test]
fn non_controllable_curtailment_discriminant_is_19() {
let v = VariableRef::NonControllableCurtailment {
source_id: EntityId(0),
block_id: None,
};
let bytes = postcard::to_allocvec(&v).expect("serialize");
assert_eq!(
bytes[0], 19,
"NonControllableCurtailment must remain discriminant 19; \
got {}. A variant was inserted before it.",
bytes[0]
);
}
#[cfg(feature = "serde")]
#[test]
fn variable_ref_bus_selector_postcard_roundtrip() {
let turbined = VariableRef::HydroTurbined {
hydro_id: EntityId(7),
block_id: Some(1),
bus_id: Some(EntityId(3)),
};
let bytes = postcard::to_allocvec(&turbined).expect("serialize");
let recovered: VariableRef = postcard::from_bytes(&bytes).expect("deserialize");
assert_eq!(turbined, recovered);
let generation = VariableRef::HydroGeneration {
hydro_id: EntityId(7),
block_id: None,
bus_id: Some(EntityId(3)),
};
let bytes = postcard::to_allocvec(&generation).expect("serialize");
let recovered: VariableRef = postcard::from_bytes(&bytes).expect("deserialize");
assert_eq!(generation, recovered);
}
#[cfg(feature = "serde")]
#[test]
fn test_generic_constraint_serde_roundtrip() {
let gc = GenericConstraint {
id: EntityId(0),
name: "test".to_string(),
description: None,
expression: ConstraintExpression {
terms: vec![
LinearTerm::literal(
1.0,
VariableRef::HydroGeneration {
hydro_id: EntityId(10),
block_id: None,
bus_id: None,
},
),
LinearTerm::literal(
1.0,
VariableRef::HydroGeneration {
hydro_id: EntityId(11),
block_id: None,
bus_id: None,
},
),
],
},
slack: SlackConfig {
enabled: true,
penalty: Some(5_000.0),
},
bound_lower_affine: None,
bound_upper_affine: Some(AffineBound::single(EntityId(42))),
};
let json = serde_json::to_string(&gc).unwrap();
let deserialized: GenericConstraint = serde_json::from_str(&json).unwrap();
assert_eq!(gc, deserialized);
assert_eq!(deserialized.expression.terms.len(), 2);
assert_eq!(
deserialized.bound_upper_affine,
Some(AffineBound::single(EntityId(42)))
);
}
#[test]
fn affine_bound_single_is_zero_constant_plus_one_term() {
let bound = AffineBound::single(EntityId(7));
assert_eq!(bound.constant, 0.0);
assert_eq!(bound.terms, vec![(1.0, EntityId(7))]);
}
#[test]
fn affine_bound_params_iterates_term_ids() {
let bound = AffineBound {
constant: 12.0,
terms: vec![(0.5, EntityId(7)), (-2.0, EntityId(9))],
};
assert_eq!(
bound.params().collect::<Vec<_>>(),
vec![EntityId(7), EntityId(9)]
);
}
#[test]
fn affine_bound_single_resolves_to_the_bare_value() {
for x in [0.0_f64, 1.0, -3.5, 42.75, f64::MIN_POSITIVE, 1e300] {
let bound = AffineBound::single(EntityId(1));
let (coef, _) = bound.terms[0];
assert_eq!(bound.constant + coef * x, x);
}
}
}