#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct HydroStageBounds {
pub min_storage_hm3: f64,
pub max_storage_hm3: f64,
pub min_turbined_m3s: f64,
pub max_turbined_m3s: f64,
pub min_outflow_m3s: f64,
pub max_outflow_m3s: Option<f64>,
pub min_generation_mw: f64,
pub max_generation_mw: f64,
pub max_diversion_m3s: Option<f64>,
pub filling_inflow_m3s: f64,
pub water_withdrawal_m3s: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ThermalStageBounds {
pub min_generation_mw: f64,
pub max_generation_mw: f64,
pub cost_per_mwh: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct LineStageBounds {
pub direct_mw: f64,
pub reverse_mw: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PumpingStageBounds {
pub min_flow_m3s: f64,
pub max_flow_m3s: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ContractStageBounds {
pub min_mw: f64,
pub max_mw: f64,
pub price_per_mwh: f64,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(try_from = "ResolvedBoundsWire"))]
pub struct ResolvedBounds {
n_stages: usize,
thermal_stage_axis_len: usize,
hydro: Vec<HydroStageBounds>,
thermal: Vec<ThermalStageBounds>,
line: Vec<LineStageBounds>,
pumping: Vec<PumpingStageBounds>,
contract: Vec<ContractStageBounds>,
}
#[cfg(feature = "serde")]
#[derive(serde::Deserialize)]
struct ResolvedBoundsWire {
n_stages: usize,
thermal_stage_axis_len: usize,
hydro: Vec<HydroStageBounds>,
thermal: Vec<ThermalStageBounds>,
line: Vec<LineStageBounds>,
pumping: Vec<PumpingStageBounds>,
contract: Vec<ContractStageBounds>,
}
#[cfg(feature = "serde")]
impl TryFrom<ResolvedBoundsWire> for ResolvedBounds {
type Error = String;
fn try_from(wire: ResolvedBoundsWire) -> Result<Self, Self::Error> {
if !wire.thermal.is_empty() && wire.thermal_stage_axis_len == 0 {
return Err(
"thermal_stage_axis_len must be > 0 when the thermal table is non-empty; \
a zero stride aliases every thermal to thermal 0"
.to_string(),
);
}
Ok(Self {
n_stages: wire.n_stages,
thermal_stage_axis_len: wire.thermal_stage_axis_len,
hydro: wire.hydro,
thermal: wire.thermal,
line: wire.line,
pumping: wire.pumping,
contract: wire.contract,
})
}
}
#[derive(Debug, Clone)]
pub struct BoundsCountsSpec {
pub n_hydros: usize,
pub n_thermals: usize,
pub n_lines: usize,
pub n_pumping: usize,
pub n_contracts: usize,
pub n_stages: usize,
pub k_max: usize,
}
#[derive(Debug, Clone)]
pub struct BoundsDefaults {
pub hydro: HydroStageBounds,
pub thermal: ThermalStageBounds,
pub line: LineStageBounds,
pub pumping: PumpingStageBounds,
pub contract: ContractStageBounds,
}
impl ResolvedBounds {
#[must_use]
pub fn empty() -> Self {
Self {
n_stages: 0,
thermal_stage_axis_len: 0,
hydro: Vec::new(),
thermal: Vec::new(),
line: Vec::new(),
pumping: Vec::new(),
contract: Vec::new(),
}
}
#[must_use]
pub fn new(counts: &BoundsCountsSpec, defaults: &BoundsDefaults) -> Self {
debug_assert!(
counts.n_stages > 0,
"ResolvedBounds::new: n_stages must be > 0 (got 0)"
);
let thermal_axis = counts.n_stages + counts.k_max;
Self {
n_stages: counts.n_stages,
thermal_stage_axis_len: thermal_axis,
hydro: vec![defaults.hydro; counts.n_hydros * counts.n_stages],
thermal: vec![defaults.thermal; counts.n_thermals * thermal_axis],
line: vec![defaults.line; counts.n_lines * counts.n_stages],
pumping: vec![defaults.pumping; counts.n_pumping * counts.n_stages],
contract: vec![defaults.contract; counts.n_contracts * counts.n_stages],
}
}
#[inline]
#[must_use]
pub fn hydro_bounds(&self, hydro_index: usize, stage_index: usize) -> &HydroStageBounds {
&self.hydro[hydro_index * self.n_stages + stage_index]
}
#[inline]
#[must_use]
pub fn thermal_bounds(&self, thermal_index: usize, stage_index: usize) -> ThermalStageBounds {
debug_assert!(
self.thermal.is_empty() || self.thermal_stage_axis_len > 0,
"thermal_stage_axis_len must be > 0 when the thermal table is non-empty"
);
self.thermal[thermal_index * self.thermal_stage_axis_len + stage_index]
}
#[inline]
#[must_use]
pub fn line_bounds(&self, line_index: usize, stage_index: usize) -> LineStageBounds {
self.line[line_index * self.n_stages + stage_index]
}
#[inline]
#[must_use]
pub fn pumping_bounds(&self, pumping_index: usize, stage_index: usize) -> PumpingStageBounds {
self.pumping[pumping_index * self.n_stages + stage_index]
}
#[inline]
#[must_use]
pub fn contract_bounds(
&self,
contract_index: usize,
stage_index: usize,
) -> ContractStageBounds {
self.contract[contract_index * self.n_stages + stage_index]
}
#[inline]
pub fn hydro_bounds_mut(
&mut self,
hydro_index: usize,
stage_index: usize,
) -> &mut HydroStageBounds {
&mut self.hydro[hydro_index * self.n_stages + stage_index]
}
#[inline]
pub fn thermal_bounds_mut(
&mut self,
thermal_index: usize,
stage_index: usize,
) -> &mut ThermalStageBounds {
debug_assert!(
self.thermal.is_empty() || self.thermal_stage_axis_len > 0,
"thermal_stage_axis_len must be > 0 when the thermal table is non-empty"
);
&mut self.thermal[thermal_index * self.thermal_stage_axis_len + stage_index]
}
#[inline]
pub fn line_bounds_mut(
&mut self,
line_index: usize,
stage_index: usize,
) -> &mut LineStageBounds {
&mut self.line[line_index * self.n_stages + stage_index]
}
#[inline]
pub fn pumping_bounds_mut(
&mut self,
pumping_index: usize,
stage_index: usize,
) -> &mut PumpingStageBounds {
&mut self.pumping[pumping_index * self.n_stages + stage_index]
}
#[inline]
pub fn contract_bounds_mut(
&mut self,
contract_index: usize,
stage_index: usize,
) -> &mut ContractStageBounds {
&mut self.contract[contract_index * self.n_stages + stage_index]
}
#[inline]
#[must_use]
pub fn n_stages(&self) -> usize {
self.n_stages
}
#[inline]
#[must_use]
pub fn thermal_stage_axis_len(&self) -> usize {
self.thermal_stage_axis_len
}
}
#[cfg(test)]
mod tests {
use super::{
BoundsCountsSpec, BoundsDefaults, ContractStageBounds, HydroStageBounds, LineStageBounds,
PumpingStageBounds, ResolvedBounds, ThermalStageBounds,
};
fn make_hydro_bounds() -> HydroStageBounds {
HydroStageBounds {
min_storage_hm3: 10.0,
max_storage_hm3: 200.0,
min_turbined_m3s: 0.0,
max_turbined_m3s: 500.0,
min_outflow_m3s: 5.0,
max_outflow_m3s: None,
min_generation_mw: 0.0,
max_generation_mw: 100.0,
max_diversion_m3s: None,
filling_inflow_m3s: 0.0,
water_withdrawal_m3s: 0.0,
}
}
#[test]
fn test_all_bound_structs_are_copy() {
let hb = make_hydro_bounds();
let tb = ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 100.0,
cost_per_mwh: 50.0,
};
let lb = LineStageBounds {
direct_mw: 500.0,
reverse_mw: 500.0,
};
let pb = PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 20.0,
};
let cb = ContractStageBounds {
min_mw: 0.0,
max_mw: 50.0,
price_per_mwh: 80.0,
};
let hb2 = hb;
let tb2 = tb;
let lb2 = lb;
let pb2 = pb;
let cb2 = cb;
assert_eq!(hb, hb2);
assert_eq!(tb, tb2);
assert_eq!(lb, lb2);
assert_eq!(pb, pb2);
assert_eq!(cb, cb2);
}
#[test]
fn test_resolved_bounds_construction() {
let hb = make_hydro_bounds();
let tb = ThermalStageBounds {
min_generation_mw: 50.0,
max_generation_mw: 400.0,
cost_per_mwh: 0.0,
};
let lb = LineStageBounds {
direct_mw: 1000.0,
reverse_mw: 800.0,
};
let pb = PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 20.0,
};
let cb = ContractStageBounds {
min_mw: 0.0,
max_mw: 100.0,
price_per_mwh: 80.0,
};
let table = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 1,
n_thermals: 2,
n_lines: 1,
n_pumping: 1,
n_contracts: 1,
n_stages: 3,
k_max: 0,
},
&BoundsDefaults {
hydro: hb,
thermal: tb,
line: lb,
pumping: pb,
contract: cb,
},
);
let b = table.hydro_bounds(0, 2);
assert!((b.min_storage_hm3 - 10.0).abs() < f64::EPSILON);
assert!((b.max_storage_hm3 - 200.0).abs() < f64::EPSILON);
assert!(b.max_outflow_m3s.is_none());
assert!(b.max_diversion_m3s.is_none());
let t0 = table.thermal_bounds(0, 0);
let t1 = table.thermal_bounds(1, 2);
assert!((t0.max_generation_mw - 400.0).abs() < f64::EPSILON);
assert!((t1.min_generation_mw - 50.0).abs() < f64::EPSILON);
assert!((table.line_bounds(0, 1).direct_mw - 1000.0).abs() < f64::EPSILON);
assert!((table.pumping_bounds(0, 0).max_flow_m3s - 20.0).abs() < f64::EPSILON);
assert!((table.contract_bounds(0, 2).price_per_mwh - 80.0).abs() < f64::EPSILON);
}
#[test]
fn test_resolved_bounds_mutable_update() {
let hb = make_hydro_bounds();
let tb = ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 200.0,
cost_per_mwh: 0.0,
};
let lb = LineStageBounds {
direct_mw: 500.0,
reverse_mw: 500.0,
};
let pb = PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 30.0,
};
let cb = ContractStageBounds {
min_mw: 0.0,
max_mw: 50.0,
price_per_mwh: 60.0,
};
let mut table = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 2,
n_thermals: 1,
n_lines: 1,
n_pumping: 1,
n_contracts: 1,
n_stages: 3,
k_max: 0,
},
&BoundsDefaults {
hydro: hb,
thermal: tb,
line: lb,
pumping: pb,
contract: cb,
},
);
let cell = table.hydro_bounds_mut(1, 0);
cell.min_storage_hm3 = 25.0;
cell.max_outflow_m3s = Some(1000.0);
assert!((table.hydro_bounds(1, 0).min_storage_hm3 - 25.0).abs() < f64::EPSILON);
assert_eq!(table.hydro_bounds(1, 0).max_outflow_m3s, Some(1000.0));
assert!((table.hydro_bounds(0, 0).min_storage_hm3 - 10.0).abs() < f64::EPSILON);
assert!(table.hydro_bounds(1, 1).max_outflow_m3s.is_none());
table.thermal_bounds_mut(0, 2).max_generation_mw = 150.0;
assert!((table.thermal_bounds(0, 2).max_generation_mw - 150.0).abs() < f64::EPSILON);
assert!((table.thermal_bounds(0, 0).max_generation_mw - 200.0).abs() < f64::EPSILON);
}
#[test]
fn test_thermal_stage_axis_extends_with_k_max() {
let tb = ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 100.0,
cost_per_mwh: 0.0,
};
let table = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 0,
n_thermals: 2,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: 3,
k_max: 2,
},
&BoundsDefaults {
hydro: zero_hydro_default_for_tests(),
thermal: tb,
line: LineStageBounds {
direct_mw: 0.0,
reverse_mw: 0.0,
},
pumping: PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 0.0,
},
contract: ContractStageBounds {
min_mw: 0.0,
max_mw: 0.0,
price_per_mwh: 0.0,
},
},
);
assert_eq!(table.thermal_stage_axis_len(), 5);
let padded = table.thermal_bounds(1, 4);
assert!((padded.max_generation_mw - 100.0).abs() < f64::EPSILON);
}
#[test]
fn test_thermal_stage_axis_zero_k_max_unchanged() {
let tb = ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 50.0,
cost_per_mwh: 0.0,
};
let table = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 0,
n_thermals: 1,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: 4,
k_max: 0,
},
&BoundsDefaults {
hydro: zero_hydro_default_for_tests(),
thermal: tb,
line: LineStageBounds {
direct_mw: 0.0,
reverse_mw: 0.0,
},
pumping: PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 0.0,
},
contract: ContractStageBounds {
min_mw: 0.0,
max_mw: 0.0,
price_per_mwh: 0.0,
},
},
);
assert_eq!(table.thermal_stage_axis_len(), table.n_stages());
let last = table.thermal_bounds(0, 3);
assert!((last.max_generation_mw - 50.0).abs() < f64::EPSILON);
}
#[test]
fn test_empty_bounds_has_zero_thermal_axis() {
let empty = ResolvedBounds::empty();
assert_eq!(empty.thermal_stage_axis_len(), 0);
assert_eq!(empty.n_stages(), 0);
}
const T_DEFAULT: ThermalStageBounds = ThermalStageBounds {
min_generation_mw: 7.0,
max_generation_mw: 77.0,
cost_per_mwh: 7.7,
};
fn make_bounds_for_boundary_tests(n_stages: usize, k_max: usize) -> ResolvedBounds {
ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 0,
n_thermals: 1,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages,
k_max,
},
&BoundsDefaults {
hydro: zero_hydro_default_for_tests(),
thermal: T_DEFAULT,
line: LineStageBounds {
direct_mw: 0.0,
reverse_mw: 0.0,
},
pumping: PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 0.0,
},
contract: ContractStageBounds {
min_mw: 0.0,
max_mw: 0.0,
price_per_mwh: 0.0,
},
},
)
}
#[test]
fn test_thermal_bounds_at_last_study_stage() {
let mut table = make_bounds_for_boundary_tests(5, 3);
let written = ThermalStageBounds {
min_generation_mw: 11.0,
max_generation_mw: 111.0,
cost_per_mwh: 1.1,
};
*table.thermal_bounds_mut(0, 4) = written;
let read = table.thermal_bounds(0, 4);
assert!((read.min_generation_mw - 11.0).abs() < f64::EPSILON);
assert!((read.max_generation_mw - 111.0).abs() < f64::EPSILON);
assert!((read.cost_per_mwh - 1.1).abs() < f64::EPSILON);
}
#[test]
fn test_thermal_bounds_at_first_padded_stage() {
let table = make_bounds_for_boundary_tests(5, 3);
let padded = table.thermal_bounds(0, 5);
assert!((padded.min_generation_mw - T_DEFAULT.min_generation_mw).abs() < f64::EPSILON);
assert!((padded.max_generation_mw - T_DEFAULT.max_generation_mw).abs() < f64::EPSILON);
assert!((padded.cost_per_mwh - T_DEFAULT.cost_per_mwh).abs() < f64::EPSILON);
}
#[test]
fn test_thermal_bounds_at_last_padded_stage() {
let table = make_bounds_for_boundary_tests(5, 3);
let padded = table.thermal_bounds(0, 7);
assert!((padded.min_generation_mw - T_DEFAULT.min_generation_mw).abs() < f64::EPSILON);
assert!((padded.max_generation_mw - T_DEFAULT.max_generation_mw).abs() < f64::EPSILON);
assert!((padded.cost_per_mwh - T_DEFAULT.cost_per_mwh).abs() < f64::EPSILON);
}
#[test]
#[cfg(debug_assertions)]
fn test_thermal_bounds_out_of_range_panics_in_debug() {
let table = make_bounds_for_boundary_tests(5, 3);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = table.thermal_bounds(0, 8);
}));
assert!(
result.is_err(),
"thermal_bounds(0, 8) must panic in debug builds when n_stages=5, k_max=3"
);
}
#[test]
fn test_n_stages_unchanged_with_padding() {
let table = make_bounds_for_boundary_tests(5, 3);
assert_eq!(table.n_stages(), 5);
}
#[test]
fn test_thermal_stage_axis_len_equals_n_plus_k_max() {
let table = make_bounds_for_boundary_tests(5, 3);
assert_eq!(table.thermal_stage_axis_len(), 8);
}
mod bounds_padding_invariants {
use super::{
BoundsCountsSpec, BoundsDefaults, ContractStageBounds, LineStageBounds,
PumpingStageBounds, ResolvedBounds, T_DEFAULT, zero_hydro_default_for_tests,
};
#[test]
fn axis_len_matches_n_plus_k_max() {
let n_stages_grid = [1_usize, 5, 12];
let k_max_grid = [0_usize, 1, 3, 10];
let n_thermals_grid = [0_usize, 1, 5];
let mut count: usize = 0;
for &n_stages in &n_stages_grid {
for &k_max in &k_max_grid {
for &n_thermals in &n_thermals_grid {
let table = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 0,
n_thermals,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages,
k_max,
},
&BoundsDefaults {
hydro: zero_hydro_default_for_tests(),
thermal: T_DEFAULT,
line: LineStageBounds {
direct_mw: 0.0,
reverse_mw: 0.0,
},
pumping: PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 0.0,
},
contract: ContractStageBounds {
min_mw: 0.0,
max_mw: 0.0,
price_per_mwh: 0.0,
},
},
);
assert_eq!(
table.thermal_stage_axis_len(),
n_stages + k_max,
"axis_len mismatch at (n_stages={n_stages}, k_max={k_max}, n_thermals={n_thermals})"
);
assert_eq!(
table.n_stages(),
n_stages,
"n_stages mismatch at (n_stages={n_stages}, k_max={k_max}, n_thermals={n_thermals})"
);
count += 1;
}
}
}
assert!(
count >= 27,
"expected at least 27 sweep combinations, got {count}"
);
}
}
fn zero_hydro_default_for_tests() -> HydroStageBounds {
HydroStageBounds {
min_storage_hm3: 0.0,
max_storage_hm3: 0.0,
min_turbined_m3s: 0.0,
max_turbined_m3s: 0.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
min_generation_mw: 0.0,
max_generation_mw: 0.0,
max_diversion_m3s: None,
filling_inflow_m3s: 0.0,
water_withdrawal_m3s: 0.0,
}
}
#[test]
fn test_hydro_stage_bounds_has_eleven_fields() {
let b = HydroStageBounds {
min_storage_hm3: 1.0,
max_storage_hm3: 2.0,
min_turbined_m3s: 3.0,
max_turbined_m3s: 4.0,
min_outflow_m3s: 5.0,
max_outflow_m3s: Some(6.0),
min_generation_mw: 7.0,
max_generation_mw: 8.0,
max_diversion_m3s: Some(9.0),
filling_inflow_m3s: 10.0,
water_withdrawal_m3s: 11.0,
};
assert!((b.min_storage_hm3 - 1.0).abs() < f64::EPSILON);
assert!((b.water_withdrawal_m3s - 11.0).abs() < f64::EPSILON);
assert_eq!(b.max_outflow_m3s, Some(6.0));
assert_eq!(b.max_diversion_m3s, Some(9.0));
}
#[test]
#[cfg(feature = "serde")]
fn test_resolved_bounds_serde_roundtrip() {
let hb = make_hydro_bounds();
let tb = ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 100.0,
cost_per_mwh: 0.0,
};
let lb = LineStageBounds {
direct_mw: 500.0,
reverse_mw: 500.0,
};
let pb = PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 20.0,
};
let cb = ContractStageBounds {
min_mw: 0.0,
max_mw: 50.0,
price_per_mwh: 80.0,
};
let original = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 1,
n_thermals: 1,
n_lines: 1,
n_pumping: 1,
n_contracts: 1,
n_stages: 3,
k_max: 0,
},
&BoundsDefaults {
hydro: hb,
thermal: tb,
line: lb,
pumping: pb,
contract: cb,
},
);
let json = serde_json::to_string(&original).expect("serialize");
let restored: ResolvedBounds = serde_json::from_str(&json).expect("deserialize");
assert_eq!(original, restored);
}
#[cfg(feature = "serde")]
#[test]
fn test_resolved_bounds_serde_roundtrip_with_padding() {
let hb = make_hydro_bounds();
let tb = ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 200.0,
cost_per_mwh: 60.0,
};
let lb = LineStageBounds {
direct_mw: 50.0,
reverse_mw: 50.0,
};
let pb = PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 20.0,
};
let cb = ContractStageBounds {
min_mw: 0.0,
max_mw: 50.0,
price_per_mwh: 80.0,
};
let original = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 1,
n_thermals: 2,
n_lines: 1,
n_pumping: 1,
n_contracts: 1,
n_stages: 3,
k_max: 2,
},
&BoundsDefaults {
hydro: hb,
thermal: tb,
line: lb,
pumping: pb,
contract: cb,
},
);
assert_eq!(original.thermal_stage_axis_len(), 5);
let json = serde_json::to_string(&original).expect("serialize");
let restored: ResolvedBounds = serde_json::from_str(&json).expect("deserialize");
assert_eq!(
restored.thermal_stage_axis_len(),
original.thermal_stage_axis_len(),
"thermal_stage_axis_len must survive serde roundtrip"
);
assert_eq!(original, restored);
}
#[cfg(feature = "serde")]
#[test]
fn deserialize_missing_thermal_axis_len_with_thermals_is_rejected() {
let json = r#"{
"n_stages": 1,
"hydro": [],
"thermal": [{"min_generation_mw": 0.0, "max_generation_mw": 100.0, "cost_per_mwh": 50.0}],
"line": [],
"pumping": [],
"contract": []
}"#;
let result: Result<ResolvedBounds, _> = serde_json::from_str(json);
assert!(
result.is_err(),
"deserializing a non-empty thermal table without thermal_stage_axis_len \
must error, got Ok"
);
}
#[cfg(feature = "serde")]
#[test]
fn deserialize_zero_thermal_axis_len_with_thermals_is_rejected() {
let json = r#"{
"n_stages": 1,
"thermal_stage_axis_len": 0,
"hydro": [],
"thermal": [{"min_generation_mw": 0.0, "max_generation_mw": 100.0, "cost_per_mwh": 50.0}],
"line": [],
"pumping": [],
"contract": []
}"#;
let result: Result<ResolvedBounds, _> = serde_json::from_str(json);
assert!(
result.is_err(),
"deserializing a non-empty thermal table with thermal_stage_axis_len=0 \
must error, got Ok"
);
}
#[test]
#[cfg(debug_assertions)]
fn new_with_zero_n_stages_panics_in_debug() {
let result = std::panic::catch_unwind(|| {
ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 1,
n_thermals: 1,
n_lines: 1,
n_pumping: 1,
n_contracts: 1,
n_stages: 0,
k_max: 0,
},
&BoundsDefaults {
hydro: zero_hydro_default_for_tests(),
thermal: ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 0.0,
cost_per_mwh: 0.0,
},
line: LineStageBounds {
direct_mw: 0.0,
reverse_mw: 0.0,
},
pumping: PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 0.0,
},
contract: ContractStageBounds {
min_mw: 0.0,
max_mw: 0.0,
price_per_mwh: 0.0,
},
},
)
});
assert!(
result.is_err(),
"ResolvedBounds::new(n_stages=0) must panic in debug builds"
);
}
}