use super::{ResolvedBlockBounds, ResolvedHydroUnitGroupBounds};
#[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 filling_min_rate_m3s: f64,
pub water_withdrawal_m3s: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct HydroBlockBounds {
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>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
struct HydroCell {
stage: HydroStageBounds,
block: HydroBlockBounds,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ThermalBlockBounds {
pub min_generation_mw: f64,
pub max_generation_mw: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ThermalStageBounds {
pub cost_per_mwh: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
struct ThermalCell {
stage: ThermalStageBounds,
block: ThermalBlockBounds,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct LineBlockBounds {
pub direct_mw: f64,
pub reverse_mw: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PumpingBlockBounds {
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 ContractBlockBounds {
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<HydroCell>,
thermal: Vec<ThermalCell>,
line: Vec<LineBlockBounds>,
pumping: Vec<PumpingBlockBounds>,
contract: Vec<ContractBlockBounds>,
block: ResolvedBlockBounds,
group: ResolvedHydroUnitGroupBounds,
}
#[cfg(feature = "serde")]
#[derive(serde::Deserialize)]
struct ResolvedBoundsWire {
n_stages: usize,
thermal_stage_axis_len: usize,
hydro: Vec<HydroCell>,
thermal: Vec<ThermalCell>,
line: Vec<LineBlockBounds>,
pumping: Vec<PumpingBlockBounds>,
contract: Vec<ContractBlockBounds>,
#[serde(default)]
block: ResolvedBlockBounds,
#[serde(default)]
group: ResolvedHydroUnitGroupBounds,
}
#[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,
block: wire.block,
group: wire.group,
})
}
}
#[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 hydro_block: HydroBlockBounds,
pub thermal: ThermalStageBounds,
pub thermal_block: ThermalBlockBounds,
pub line_block: LineBlockBounds,
pub pumping_block: PumpingBlockBounds,
pub contract_block: ContractBlockBounds,
}
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(),
block: ResolvedBlockBounds::empty(),
group: ResolvedHydroUnitGroupBounds::empty(),
}
}
#[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;
let thermal_cell = ThermalCell {
stage: defaults.thermal,
block: defaults.thermal_block,
};
let hydro_cell = HydroCell {
stage: defaults.hydro,
block: defaults.hydro_block,
};
Self {
n_stages: counts.n_stages,
thermal_stage_axis_len: thermal_axis,
hydro: vec![hydro_cell; counts.n_hydros * counts.n_stages],
thermal: vec![thermal_cell; counts.n_thermals * thermal_axis],
line: vec![defaults.line_block; counts.n_lines * counts.n_stages],
pumping: vec![defaults.pumping_block; counts.n_pumping * counts.n_stages],
contract: vec![defaults.contract_block; counts.n_contracts * counts.n_stages],
block: ResolvedBlockBounds::empty(),
group: ResolvedHydroUnitGroupBounds::empty(),
}
}
#[inline]
#[must_use]
pub fn hydro_bounds(&self, hydro_index: usize, stage_index: usize) -> &HydroStageBounds {
&self.hydro[hydro_index * self.n_stages + stage_index].stage
}
#[inline]
fn thermal_cell_index(&self, thermal_index: usize, stage_index: usize) -> usize {
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"
);
thermal_index * self.thermal_stage_axis_len + stage_index
}
#[inline]
#[must_use]
pub fn thermal_bounds(&self, thermal_index: usize, stage_index: usize) -> ThermalStageBounds {
self.thermal[self.thermal_cell_index(thermal_index, stage_index)].stage
}
#[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].stage
}
#[inline]
pub fn hydro_block_base_mut(
&mut self,
hydro_index: usize,
stage_index: usize,
) -> &mut HydroBlockBounds {
&mut self.hydro[hydro_index * self.n_stages + stage_index].block
}
#[inline]
pub fn thermal_bounds_mut(
&mut self,
thermal_index: usize,
stage_index: usize,
) -> &mut ThermalStageBounds {
let idx = self.thermal_cell_index(thermal_index, stage_index);
&mut self.thermal[idx].stage
}
#[inline]
pub fn thermal_block_base_mut(
&mut self,
thermal_index: usize,
stage_index: usize,
) -> &mut ThermalBlockBounds {
let idx = self.thermal_cell_index(thermal_index, stage_index);
&mut self.thermal[idx].block
}
#[inline]
pub fn line_bounds_mut(
&mut self,
line_index: usize,
stage_index: usize,
) -> &mut LineBlockBounds {
&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 PumpingBlockBounds {
&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 ContractBlockBounds {
&mut self.contract[contract_index * self.n_stages + stage_index]
}
pub fn set_block_overlay(&mut self, block: ResolvedBlockBounds) {
self.block = block;
}
#[inline]
#[must_use]
pub fn block_overlay(&self) -> &ResolvedBlockBounds {
&self.block
}
#[inline]
pub fn block_overlay_mut(&mut self) -> &mut ResolvedBlockBounds {
&mut self.block
}
pub fn set_group_overlay(&mut self, group: ResolvedHydroUnitGroupBounds) {
self.group = group;
}
#[inline]
#[must_use]
pub fn group_overlay(&self) -> &ResolvedHydroUnitGroupBounds {
&self.group
}
#[inline]
pub fn group_overlay_mut(&mut self) -> &mut ResolvedHydroUnitGroupBounds {
&mut self.group
}
#[inline]
#[must_use]
pub fn hydro_bounds_at_block(
&self,
hydro_index: usize,
stage_index: usize,
block_index: usize,
) -> HydroBlockBounds {
let cell = self.hydro[hydro_index * self.n_stages + stage_index].block;
let over = self
.block
.hydro_override(hydro_index, stage_index, block_index);
HydroBlockBounds {
min_turbined_m3s: over.min_turbined_m3s.unwrap_or(cell.min_turbined_m3s),
max_turbined_m3s: over.max_turbined_m3s.unwrap_or(cell.max_turbined_m3s),
min_outflow_m3s: over.min_outflow_m3s.unwrap_or(cell.min_outflow_m3s),
max_outflow_m3s: over.max_outflow_m3s.or(cell.max_outflow_m3s),
min_generation_mw: over.min_generation_mw.unwrap_or(cell.min_generation_mw),
max_generation_mw: over.max_generation_mw.unwrap_or(cell.max_generation_mw),
max_diversion_m3s: over.max_diversion_m3s.or(cell.max_diversion_m3s),
}
}
#[inline]
#[must_use]
pub fn thermal_bounds_at_block(
&self,
thermal_index: usize,
stage_index: usize,
block_index: usize,
) -> ThermalBlockBounds {
let cell = self.thermal[self.thermal_cell_index(thermal_index, stage_index)].block;
let over = self
.block
.thermal_override(thermal_index, stage_index, block_index);
ThermalBlockBounds {
min_generation_mw: over.min_generation_mw.unwrap_or(cell.min_generation_mw),
max_generation_mw: over.max_generation_mw.unwrap_or(cell.max_generation_mw),
}
}
#[inline]
#[must_use]
pub fn line_bounds_at_block(
&self,
line_index: usize,
stage_index: usize,
block_index: usize,
) -> LineBlockBounds {
let cell = self.line[line_index * self.n_stages + stage_index];
let over = self
.block
.line_override(line_index, stage_index, block_index);
LineBlockBounds {
direct_mw: over.direct_mw.unwrap_or(cell.direct_mw),
reverse_mw: over.reverse_mw.unwrap_or(cell.reverse_mw),
}
}
#[inline]
#[must_use]
pub fn pumping_bounds_at_block(
&self,
pumping_index: usize,
stage_index: usize,
block_index: usize,
) -> PumpingBlockBounds {
let cell = self.pumping[pumping_index * self.n_stages + stage_index];
let over = self
.block
.pumping_override(pumping_index, stage_index, block_index);
PumpingBlockBounds {
min_flow_m3s: over.min_flow_m3s.unwrap_or(cell.min_flow_m3s),
max_flow_m3s: over.max_flow_m3s.unwrap_or(cell.max_flow_m3s),
}
}
#[inline]
#[must_use]
pub fn contract_bounds_at_block(
&self,
contract_index: usize,
stage_index: usize,
block_index: usize,
) -> ContractBlockBounds {
let cell = self.contract[contract_index * self.n_stages + stage_index];
let over = self
.block
.contract_override(contract_index, stage_index, block_index);
ContractBlockBounds {
min_mw: over.min_mw.unwrap_or(cell.min_mw),
max_mw: over.max_mw.unwrap_or(cell.max_mw),
price_per_mwh: over.price_per_mwh.unwrap_or(cell.price_per_mwh),
}
}
#[inline]
#[must_use]
pub fn hydro_block_base(&self, hydro_index: usize, stage_index: usize) -> HydroBlockBounds {
self.hydro[hydro_index * self.n_stages + stage_index].block
}
#[inline]
#[must_use]
pub fn thermal_block_base(
&self,
thermal_index: usize,
stage_index: usize,
) -> ThermalBlockBounds {
self.thermal[self.thermal_cell_index(thermal_index, stage_index)].block
}
#[inline]
#[must_use]
pub fn line_block_base(&self, line_index: usize, stage_index: usize) -> LineBlockBounds {
self.line[line_index * self.n_stages + stage_index]
}
#[inline]
#[must_use]
pub fn pumping_block_base(
&self,
pumping_index: usize,
stage_index: usize,
) -> PumpingBlockBounds {
self.pumping[pumping_index * self.n_stages + stage_index]
}
#[inline]
#[must_use]
pub fn contract_block_base(
&self,
contract_index: usize,
stage_index: usize,
) -> ContractBlockBounds {
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 n_pumping(&self) -> usize {
if self.n_stages == 0 {
0
} else {
self.pumping.len() / self.n_stages
}
}
#[inline]
#[must_use]
pub fn n_contracts(&self) -> usize {
if self.n_stages == 0 {
0
} else {
debug_assert_eq!(
self.contract.len() % self.n_stages,
0,
"contract Vec length must be a multiple of n_stages"
);
self.contract.len() / 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::super::{BlockBoundsCountsSpec, HydroUnitGroupBoundsCountsSpec};
use super::{
BoundsCountsSpec, BoundsDefaults, ContractBlockBounds, HydroBlockBounds, HydroStageBounds,
LineBlockBounds, PumpingBlockBounds, ResolvedBlockBounds, ResolvedBounds,
ResolvedHydroUnitGroupBounds, ThermalBlockBounds, ThermalStageBounds,
};
fn make_hydro_bounds() -> HydroStageBounds {
HydroStageBounds {
min_storage_hm3: 10.0,
max_storage_hm3: 200.0,
filling_min_rate_m3s: 0.0,
water_withdrawal_m3s: 0.0,
}
}
fn make_hydro_block_bounds() -> HydroBlockBounds {
HydroBlockBounds {
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,
}
}
#[test]
fn test_all_bound_structs_are_copy() {
let hb = make_hydro_bounds();
let hydro_block = make_hydro_block_bounds();
let cost_bounds = ThermalStageBounds { cost_per_mwh: 50.0 };
let gen_bounds = ThermalBlockBounds {
min_generation_mw: 0.0,
max_generation_mw: 100.0,
};
let lb = LineBlockBounds {
direct_mw: 500.0,
reverse_mw: 500.0,
};
let pb = PumpingBlockBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 20.0,
};
let cb = ContractBlockBounds {
min_mw: 0.0,
max_mw: 50.0,
price_per_mwh: 80.0,
};
let hb2 = hb;
let hydro_block2 = hydro_block;
let cost_bounds_copy = cost_bounds;
let gen_bounds_copy = gen_bounds;
let lb2 = lb;
let pb2 = pb;
let cb2 = cb;
assert_eq!(hb, hb2);
assert_eq!(hydro_block, hydro_block2);
assert_eq!(cost_bounds, cost_bounds_copy);
assert_eq!(gen_bounds, gen_bounds_copy);
assert_eq!(lb, lb2);
assert_eq!(pb, pb2);
assert_eq!(cb, cb2);
}
#[test]
fn test_resolved_bounds_construction() {
let hb = make_hydro_bounds();
let hbl = make_hydro_block_bounds();
let tb = ThermalStageBounds { cost_per_mwh: 0.0 };
let tbb = ThermalBlockBounds {
min_generation_mw: 50.0,
max_generation_mw: 400.0,
};
let lb = LineBlockBounds {
direct_mw: 1000.0,
reverse_mw: 800.0,
};
let pb = PumpingBlockBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 20.0,
};
let cb = ContractBlockBounds {
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,
hydro_block: hbl,
thermal: tb,
thermal_block: tbb,
line_block: lb,
pumping_block: pb,
contract_block: 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);
let block_base = table.hydro_block_base(0, 2);
assert!(block_base.max_outflow_m3s.is_none());
assert!(block_base.max_diversion_m3s.is_none());
let t0 = table.thermal_block_base(0, 0);
let t1 = table.thermal_block_base(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_block_base(0, 1).direct_mw - 1000.0).abs() < f64::EPSILON);
assert!((table.pumping_block_base(0, 0).max_flow_m3s - 20.0).abs() < f64::EPSILON);
assert!((table.contract_block_base(0, 2).price_per_mwh - 80.0).abs() < f64::EPSILON);
}
#[test]
fn test_resolved_bounds_mutable_update() {
let hb = make_hydro_bounds();
let hbl = make_hydro_block_bounds();
let tb = ThermalStageBounds { cost_per_mwh: 0.0 };
let tbb = ThermalBlockBounds {
min_generation_mw: 0.0,
max_generation_mw: 200.0,
};
let lb = LineBlockBounds {
direct_mw: 500.0,
reverse_mw: 500.0,
};
let pb = PumpingBlockBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 30.0,
};
let cb = ContractBlockBounds {
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,
hydro_block: hbl,
thermal: tb,
thermal_block: tbb,
line_block: lb,
pumping_block: pb,
contract_block: cb,
},
);
let cell = table.hydro_bounds_mut(1, 0);
cell.min_storage_hm3 = 25.0;
table.hydro_block_base_mut(1, 0).max_outflow_m3s = Some(1000.0);
assert!((table.hydro_bounds(1, 0).min_storage_hm3 - 25.0).abs() < f64::EPSILON);
assert_eq!(table.hydro_block_base(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_block_base(1, 1).max_outflow_m3s.is_none());
table.thermal_block_base_mut(0, 2).max_generation_mw = 150.0;
assert!((table.thermal_block_base(0, 2).max_generation_mw - 150.0).abs() < f64::EPSILON);
assert!((table.thermal_block_base(0, 0).max_generation_mw - 200.0).abs() < f64::EPSILON);
}
#[test]
fn test_thermal_stage_axis_extends_with_k_max() {
let tbb = ThermalBlockBounds {
min_generation_mw: 0.0,
max_generation_mw: 100.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 {
thermal_block: tbb,
..zero_defaults()
},
);
assert_eq!(table.thermal_stage_axis_len(), 5);
let padded = table.thermal_block_base(1, 4);
assert!((padded.max_generation_mw - 100.0).abs() < f64::EPSILON);
}
#[test]
fn test_thermal_stage_axis_zero_k_max_unchanged() {
let tbb = ThermalBlockBounds {
min_generation_mw: 0.0,
max_generation_mw: 50.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 {
thermal_block: tbb,
..zero_defaults()
},
);
assert_eq!(table.thermal_stage_axis_len(), table.n_stages());
let last = table.thermal_block_base(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);
}
#[test]
fn test_n_pumping_recovers_station_count() {
let table = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 0,
n_thermals: 0,
n_lines: 0,
n_pumping: 2,
n_contracts: 0,
n_stages: 3,
k_max: 0,
},
&zero_defaults(),
);
assert_eq!(table.n_pumping(), 2);
}
#[test]
fn test_n_pumping_zero_when_no_stations() {
let table = make_bounds_for_boundary_tests(4, 0);
assert_eq!(table.n_pumping(), 0);
}
#[test]
fn test_n_pumping_empty_table_is_zero() {
assert_eq!(ResolvedBounds::empty().n_pumping(), 0);
}
const T_DEFAULT: ThermalStageBounds = ThermalStageBounds { cost_per_mwh: 7.7 };
const T_BLOCK_DEFAULT: ThermalBlockBounds = ThermalBlockBounds {
min_generation_mw: 7.0,
max_generation_mw: 77.0,
};
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 {
thermal: T_DEFAULT,
thermal_block: T_BLOCK_DEFAULT,
..zero_defaults()
},
)
}
#[test]
fn test_thermal_bounds_at_last_study_stage() {
let mut table = make_bounds_for_boundary_tests(5, 3);
let written_stage = ThermalStageBounds { cost_per_mwh: 1.1 };
let written_block = ThermalBlockBounds {
min_generation_mw: 11.0,
max_generation_mw: 111.0,
};
*table.thermal_bounds_mut(0, 4) = written_stage;
*table.thermal_block_base_mut(0, 4) = written_block;
let read = table.thermal_bounds(0, 4);
let read_block = table.thermal_block_base(0, 4);
assert!((read_block.min_generation_mw - 11.0).abs() < f64::EPSILON);
assert!((read_block.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_block_base(0, 5);
assert!(
(padded.min_generation_mw - T_BLOCK_DEFAULT.min_generation_mw).abs() < f64::EPSILON
);
assert!(
(padded.max_generation_mw - T_BLOCK_DEFAULT.max_generation_mw).abs() < f64::EPSILON
);
assert!(
(table.thermal_bounds(0, 5).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_block_base(0, 7);
assert!(
(padded.min_generation_mw - T_BLOCK_DEFAULT.min_generation_mw).abs() < f64::EPSILON
);
assert!(
(padded.max_generation_mw - T_BLOCK_DEFAULT.max_generation_mw).abs() < f64::EPSILON
);
assert!(
(table.thermal_bounds(0, 7).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, ResolvedBounds, T_BLOCK_DEFAULT, T_DEFAULT,
zero_defaults,
};
#[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 {
thermal: T_DEFAULT,
thermal_block: T_BLOCK_DEFAULT,
..zero_defaults()
},
);
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_defaults() -> BoundsDefaults {
BoundsDefaults {
hydro: HydroStageBounds {
min_storage_hm3: 0.0,
max_storage_hm3: 0.0,
filling_min_rate_m3s: 0.0,
water_withdrawal_m3s: 0.0,
},
hydro_block: HydroBlockBounds {
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,
},
thermal: ThermalStageBounds { cost_per_mwh: 0.0 },
thermal_block: ThermalBlockBounds {
min_generation_mw: 0.0,
max_generation_mw: 0.0,
},
line_block: LineBlockBounds {
direct_mw: 0.0,
reverse_mw: 0.0,
},
pumping_block: PumpingBlockBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 0.0,
},
contract_block: ContractBlockBounds {
min_mw: 0.0,
max_mw: 0.0,
price_per_mwh: 0.0,
},
}
}
#[test]
fn test_hydro_stage_bounds_has_four_fields() {
let b = HydroStageBounds {
min_storage_hm3: 1.0,
max_storage_hm3: 2.0,
filling_min_rate_m3s: 10.0,
water_withdrawal_m3s: 11.0,
};
assert!((b.min_storage_hm3 - 1.0).abs() < f64::EPSILON);
assert!((b.max_storage_hm3 - 2.0).abs() < f64::EPSILON);
assert!((b.filling_min_rate_m3s - 10.0).abs() < f64::EPSILON);
assert!((b.water_withdrawal_m3s - 11.0).abs() < f64::EPSILON);
}
#[test]
fn test_hydro_block_bounds_has_seven_fields() {
let b = HydroBlockBounds {
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),
};
assert!((b.min_turbined_m3s - 3.0).abs() < f64::EPSILON);
assert!((b.max_turbined_m3s - 4.0).abs() < f64::EPSILON);
assert!((b.min_generation_mw - 7.0).abs() < f64::EPSILON);
assert!((b.max_generation_mw - 8.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 hbl = make_hydro_block_bounds();
let tb = ThermalStageBounds { cost_per_mwh: 0.0 };
let tbb = ThermalBlockBounds {
min_generation_mw: 0.0,
max_generation_mw: 100.0,
};
let lb = LineBlockBounds {
direct_mw: 500.0,
reverse_mw: 500.0,
};
let pb = PumpingBlockBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 20.0,
};
let cb = ContractBlockBounds {
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,
hydro_block: hbl,
thermal: tb,
thermal_block: tbb,
line_block: lb,
pumping_block: pb,
contract_block: 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 hbl = make_hydro_block_bounds();
let tb = ThermalStageBounds { cost_per_mwh: 60.0 };
let tbb = ThermalBlockBounds {
min_generation_mw: 0.0,
max_generation_mw: 200.0,
};
let lb = LineBlockBounds {
direct_mw: 50.0,
reverse_mw: 50.0,
};
let pb = PumpingBlockBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 20.0,
};
let cb = ContractBlockBounds {
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,
hydro_block: hbl,
thermal: tb,
thermal_block: tbb,
line_block: lb,
pumping_block: pb,
contract_block: 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": [{"stage": {"cost_per_mwh": 50.0}, "block": {"min_generation_mw": 0.0, "max_generation_mw": 100.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": [{"stage": {"cost_per_mwh": 50.0}, "block": {"min_generation_mw": 0.0, "max_generation_mw": 100.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,
},
&zero_defaults(),
)
});
assert!(
result.is_err(),
"ResolvedBounds::new(n_stages=0) must panic in debug builds"
);
}
fn opt_f64_bits_eq(a: Option<f64>, b: Option<f64>) -> bool {
match (a, b) {
(None, None) => true,
(Some(x), Some(y)) => x.to_bits() == y.to_bits(),
_ => false,
}
}
fn hydro_stage_bounds_bits_eq(a: &HydroStageBounds, b: &HydroStageBounds) -> bool {
a.min_storage_hm3.to_bits() == b.min_storage_hm3.to_bits()
&& a.max_storage_hm3.to_bits() == b.max_storage_hm3.to_bits()
&& a.filling_min_rate_m3s.to_bits() == b.filling_min_rate_m3s.to_bits()
&& a.water_withdrawal_m3s.to_bits() == b.water_withdrawal_m3s.to_bits()
}
fn hydro_block_bounds_bits_eq(a: &HydroBlockBounds, b: &HydroBlockBounds) -> bool {
a.min_turbined_m3s.to_bits() == b.min_turbined_m3s.to_bits()
&& a.max_turbined_m3s.to_bits() == b.max_turbined_m3s.to_bits()
&& a.min_outflow_m3s.to_bits() == b.min_outflow_m3s.to_bits()
&& opt_f64_bits_eq(a.max_outflow_m3s, b.max_outflow_m3s)
&& a.min_generation_mw.to_bits() == b.min_generation_mw.to_bits()
&& a.max_generation_mw.to_bits() == b.max_generation_mw.to_bits()
&& opt_f64_bits_eq(a.max_diversion_m3s, b.max_diversion_m3s)
}
fn thermal_block_bounds_bits_eq(a: &ThermalBlockBounds, b: &ThermalBlockBounds) -> bool {
a.min_generation_mw.to_bits() == b.min_generation_mw.to_bits()
&& a.max_generation_mw.to_bits() == b.max_generation_mw.to_bits()
}
fn line_bounds_bits_eq(a: &LineBlockBounds, b: &LineBlockBounds) -> bool {
a.direct_mw.to_bits() == b.direct_mw.to_bits()
&& a.reverse_mw.to_bits() == b.reverse_mw.to_bits()
}
fn pumping_bounds_bits_eq(a: &PumpingBlockBounds, b: &PumpingBlockBounds) -> bool {
a.min_flow_m3s.to_bits() == b.min_flow_m3s.to_bits()
&& a.max_flow_m3s.to_bits() == b.max_flow_m3s.to_bits()
}
fn contract_bounds_bits_eq(a: &ContractBlockBounds, b: &ContractBlockBounds) -> bool {
a.min_mw.to_bits() == b.min_mw.to_bits()
&& a.max_mw.to_bits() == b.max_mw.to_bits()
&& a.price_per_mwh.to_bits() == b.price_per_mwh.to_bits()
}
#[allow(clippy::cast_precision_loss)] fn make_distinct_bounds_table(n_entities: usize, n_stages: usize) -> ResolvedBounds {
let mut table = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: n_entities,
n_thermals: n_entities,
n_lines: n_entities,
n_pumping: n_entities,
n_contracts: n_entities,
n_stages,
k_max: 0,
},
&zero_defaults(),
);
for e in 0..n_entities {
for s in 0..n_stages {
let base = (e * 1000 + s) as f64;
*table.hydro_bounds_mut(e, s) = HydroStageBounds {
min_storage_hm3: base + 1.0,
max_storage_hm3: base + 2.0,
filling_min_rate_m3s: base + 10.0,
water_withdrawal_m3s: base + 11.0,
};
*table.hydro_block_base_mut(e, s) = HydroBlockBounds {
min_turbined_m3s: base + 3.0,
max_turbined_m3s: base + 4.0,
min_outflow_m3s: base + 5.0,
max_outflow_m3s: if (e + s) % 2 == 0 {
Some(base + 6.0)
} else {
None
},
min_generation_mw: base + 7.0,
max_generation_mw: base + 8.0,
max_diversion_m3s: if (e + s) % 2 == 0 {
None
} else {
Some(base + 9.0)
},
};
*table.thermal_bounds_mut(e, s) = ThermalStageBounds {
cost_per_mwh: base + 3.0,
};
*table.thermal_block_base_mut(e, s) = ThermalBlockBounds {
min_generation_mw: base + 1.0,
max_generation_mw: base + 2.0,
};
*table.line_bounds_mut(e, s) = LineBlockBounds {
direct_mw: base + 1.0,
reverse_mw: base + 2.0,
};
*table.pumping_bounds_mut(e, s) = PumpingBlockBounds {
min_flow_m3s: base + 1.0,
max_flow_m3s: base + 2.0,
};
*table.contract_bounds_mut(e, s) = ContractBlockBounds {
min_mw: base + 1.0,
max_mw: base + 2.0,
price_per_mwh: base + 3.0,
};
}
}
table
}
#[test]
fn test_empty_overlay_block_accessor_is_bit_identical_to_stage_accessor() {
let n_entities = 2;
let n_stages = 3;
let table = make_distinct_bounds_table(n_entities, n_stages);
for e in 0..n_entities {
for s in 0..n_stages {
let hydro_expected = table.hydro_block_base(e, s);
let thermal_block_expected = table.thermal_block_base(e, s);
let line_expected = table.line_block_base(e, s);
let pumping_expected = table.pumping_block_base(e, s);
let contract_expected = table.contract_block_base(e, s);
for b in 0..5 {
assert!(
hydro_block_bounds_bits_eq(
&hydro_expected,
&table.hydro_bounds_at_block(e, s, b)
),
"hydro mismatch at (e={e}, s={s}, b={b})"
);
assert!(
thermal_block_bounds_bits_eq(
&thermal_block_expected,
&table.thermal_bounds_at_block(e, s, b)
),
"thermal mismatch at (e={e}, s={s}, b={b})"
);
assert!(
line_bounds_bits_eq(&line_expected, &table.line_bounds_at_block(e, s, b)),
"line mismatch at (e={e}, s={s}, b={b})"
);
assert!(
pumping_bounds_bits_eq(
&pumping_expected,
&table.pumping_bounds_at_block(e, s, b)
),
"pumping mismatch at (e={e}, s={s}, b={b})"
);
assert!(
contract_bounds_bits_eq(
&contract_expected,
&table.contract_bounds_at_block(e, s, b)
),
"contract mismatch at (e={e}, s={s}, b={b})"
);
}
}
}
}
#[test]
fn test_block_override_replaces_only_its_own_column_and_block() {
let tbb = ThermalBlockBounds {
min_generation_mw: 10.0,
max_generation_mw: 50.0,
};
let mut table = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 0,
n_thermals: 1,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: 2,
k_max: 0,
},
&BoundsDefaults {
thermal_block: tbb,
..zero_defaults()
},
);
let mut block = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
n_hydros: 0,
n_thermals: 1,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: 2,
max_blocks: 3,
});
block
.thermal_override_mut(0, 1, 2)
.expect("in-range override cell")
.max_generation_mw = Some(100.0);
table.set_block_overlay(block);
let overridden = table.thermal_bounds_at_block(0, 1, 2);
assert!((overridden.max_generation_mw - 100.0).abs() < f64::EPSILON);
assert!((overridden.min_generation_mw - tbb.min_generation_mw).abs() < f64::EPSILON);
let other_block = table.thermal_bounds_at_block(0, 1, 0);
let block_cell = table.thermal_block_base(0, 1);
assert!(
(other_block.min_generation_mw - block_cell.min_generation_mw).abs() < f64::EPSILON
);
assert!(
(other_block.max_generation_mw - block_cell.max_generation_mw).abs() < f64::EPSILON
);
}
#[test]
fn test_thermal_block_base_ignores_the_overlay() {
let tbb = ThermalBlockBounds {
min_generation_mw: 10.0,
max_generation_mw: 50.0,
};
let mut table = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 0,
n_thermals: 1,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: 2,
k_max: 0,
},
&BoundsDefaults {
thermal_block: tbb,
..zero_defaults()
},
);
let mut block = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
n_hydros: 0,
n_thermals: 1,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: 2,
max_blocks: 3,
});
block
.thermal_override_mut(0, 1, 0)
.expect("in-range override cell")
.max_generation_mw = Some(100.0);
table.set_block_overlay(block);
let base = table.thermal_block_base(0, 1);
let at_block_0 = table.thermal_bounds_at_block(0, 1, 0);
assert_eq!(
base.max_generation_mw.to_bits(),
tbb.max_generation_mw.to_bits(),
"base must ignore the overlay and return the unoverridden stage cell"
);
assert_eq!(
at_block_0.max_generation_mw.to_bits(),
100.0_f64.to_bits(),
"at-block must return the overridden value"
);
assert_ne!(
base.max_generation_mw.to_bits(),
at_block_0.max_generation_mw.to_bits(),
"base and at-block must diverge once a block override is installed"
);
}
#[test]
fn test_line_block_base_ignores_the_overlay() {
let lb = LineBlockBounds {
direct_mw: 1000.0,
reverse_mw: 800.0,
};
let mut table = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 0,
n_thermals: 0,
n_lines: 1,
n_pumping: 0,
n_contracts: 0,
n_stages: 2,
k_max: 0,
},
&BoundsDefaults {
line_block: lb,
..zero_defaults()
},
);
let mut block = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
n_hydros: 0,
n_thermals: 0,
n_lines: 1,
n_pumping: 0,
n_contracts: 0,
n_stages: 2,
max_blocks: 3,
});
block
.line_override_mut(0, 1, 0)
.expect("in-range override cell")
.direct_mw = Some(400.0);
table.set_block_overlay(block);
let base = table.line_block_base(0, 1);
let at_block_0 = table.line_bounds_at_block(0, 1, 0);
assert_eq!(
base.direct_mw.to_bits(),
lb.direct_mw.to_bits(),
"base must ignore the overlay and return the unoverridden stage cell"
);
assert_eq!(
at_block_0.direct_mw.to_bits(),
400.0_f64.to_bits(),
"at-block must return the overridden value"
);
assert_ne!(
base.direct_mw.to_bits(),
at_block_0.direct_mw.to_bits(),
"base and at-block must diverge once a block override is installed"
);
}
#[test]
fn test_pumping_block_base_ignores_the_overlay() {
let pb = PumpingBlockBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 50.0,
};
let mut table = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 0,
n_thermals: 0,
n_lines: 0,
n_pumping: 1,
n_contracts: 0,
n_stages: 2,
k_max: 0,
},
&BoundsDefaults {
pumping_block: pb,
..zero_defaults()
},
);
let mut block = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
n_hydros: 0,
n_thermals: 0,
n_lines: 0,
n_pumping: 1,
n_contracts: 0,
n_stages: 2,
max_blocks: 3,
});
block
.pumping_override_mut(0, 1, 0)
.expect("in-range override cell")
.max_flow_m3s = Some(20.0);
table.set_block_overlay(block);
let base = table.pumping_block_base(0, 1);
let at_block_0 = table.pumping_bounds_at_block(0, 1, 0);
assert_eq!(
base.max_flow_m3s.to_bits(),
pb.max_flow_m3s.to_bits(),
"base must ignore the overlay and return the unoverridden stage cell"
);
assert_eq!(
at_block_0.max_flow_m3s.to_bits(),
20.0_f64.to_bits(),
"at-block must return the overridden value"
);
assert_ne!(
base.max_flow_m3s.to_bits(),
at_block_0.max_flow_m3s.to_bits(),
"base and at-block must diverge once a block override is installed"
);
}
#[test]
fn test_contract_block_base_ignores_the_overlay() {
let cb = ContractBlockBounds {
min_mw: 0.0,
max_mw: 200.0,
price_per_mwh: 80.0,
};
let mut table = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 0,
n_thermals: 0,
n_lines: 0,
n_pumping: 0,
n_contracts: 1,
n_stages: 2,
k_max: 0,
},
&BoundsDefaults {
contract_block: cb,
..zero_defaults()
},
);
let mut block = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
n_hydros: 0,
n_thermals: 0,
n_lines: 0,
n_pumping: 0,
n_contracts: 1,
n_stages: 2,
max_blocks: 3,
});
block
.contract_override_mut(0, 1, 0)
.expect("in-range override cell")
.price_per_mwh = Some(120.0);
table.set_block_overlay(block);
let base = table.contract_block_base(0, 1);
let at_block_0 = table.contract_bounds_at_block(0, 1, 0);
assert_eq!(
base.price_per_mwh.to_bits(),
cb.price_per_mwh.to_bits(),
"base must ignore the overlay and return the unoverridden stage cell"
);
assert_eq!(
at_block_0.price_per_mwh.to_bits(),
120.0_f64.to_bits(),
"at-block must return the overridden value"
);
assert_ne!(
base.price_per_mwh.to_bits(),
at_block_0.price_per_mwh.to_bits(),
"base and at-block must diverge once a block override is installed"
);
}
#[test]
fn test_hydro_block_base_ignores_the_overlay() {
let hbl = HydroBlockBounds {
min_turbined_m3s: 0.0,
max_turbined_m3s: 500.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
min_generation_mw: 0.0,
max_generation_mw: 100.0,
max_diversion_m3s: None,
};
let mut table = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 1,
n_thermals: 0,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: 2,
k_max: 0,
},
&BoundsDefaults {
hydro: make_hydro_bounds(),
hydro_block: hbl,
..zero_defaults()
},
);
let stage_before = *table.hydro_bounds(0, 1);
let mut block = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
n_hydros: 1,
n_thermals: 0,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: 2,
max_blocks: 3,
});
block
.hydro_override_mut(0, 1, 0)
.expect("in-range override cell")
.max_turbined_m3s = Some(999.0);
table.set_block_overlay(block);
let base = table.hydro_block_base(0, 1);
let at_block_0 = table.hydro_bounds_at_block(0, 1, 0);
let at_block_1 = table.hydro_bounds_at_block(0, 1, 1);
assert_eq!(
base.max_turbined_m3s.to_bits(),
hbl.max_turbined_m3s.to_bits(),
"base must ignore the overlay and return the unoverridden block-base cell"
);
assert_eq!(
at_block_0.max_turbined_m3s.to_bits(),
999.0_f64.to_bits(),
"at-block must return the overridden value"
);
assert_ne!(
base.max_turbined_m3s.to_bits(),
at_block_0.max_turbined_m3s.to_bits(),
"base and at-block must diverge once a block override is installed"
);
assert_eq!(
at_block_1.max_turbined_m3s.to_bits(),
base.max_turbined_m3s.to_bits(),
"a block with no override falls through to the block-base cell"
);
let stage_after = *table.hydro_bounds(0, 1);
assert!(
hydro_stage_bounds_bits_eq(&stage_before, &stage_after),
"installing a block overlay must not perturb the stage cell"
);
}
#[test]
fn test_thermal_block_base_reads_padded_delivery_stage() {
let mut table = make_bounds_for_boundary_tests(5, 3);
let written_stage = ThermalStageBounds { cost_per_mwh: 2.1 };
let written_block = ThermalBlockBounds {
min_generation_mw: 21.0,
max_generation_mw: 210.0,
};
*table.thermal_bounds_mut(0, 6) = written_stage;
*table.thermal_block_base_mut(0, 6) = written_block;
let base = table.thermal_block_base(0, 6);
assert_eq!(
base.min_generation_mw.to_bits(),
written_block.min_generation_mw.to_bits()
);
assert_eq!(
base.max_generation_mw.to_bits(),
written_block.max_generation_mw.to_bits()
);
assert_eq!(
table.thermal_bounds(0, 6).cost_per_mwh.to_bits(),
written_stage.cost_per_mwh.to_bits()
);
let neighbor = table.thermal_block_base(0, 5);
assert_eq!(
neighbor.min_generation_mw.to_bits(),
T_BLOCK_DEFAULT.min_generation_mw.to_bits(),
"an untouched padded cell must keep the uniform default, distinguishing \
a correct stage_index-aware read from one that ignores stage_index"
);
}
#[test]
fn test_optional_column_override_replaces_and_falls_through() {
let mut hydro_block_default = make_hydro_block_bounds();
hydro_block_default.max_outflow_m3s = None;
let mut table = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 1,
n_thermals: 0,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: 2,
k_max: 0,
},
&BoundsDefaults {
hydro_block: hydro_block_default,
..zero_defaults()
},
);
let mut block = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
n_hydros: 1,
n_thermals: 0,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: 2,
max_blocks: 2,
});
block
.hydro_override_mut(0, 0, 0)
.expect("in-range override cell")
.max_outflow_m3s = Some(250.0);
table.set_block_overlay(block);
assert_eq!(
table.hydro_bounds_at_block(0, 0, 0).max_outflow_m3s,
Some(250.0)
);
assert_eq!(table.hydro_bounds_at_block(0, 0, 1).max_outflow_m3s, None);
table.hydro_block_base_mut(0, 1).max_outflow_m3s = Some(400.0);
table.hydro_block_base_mut(0, 1).max_diversion_m3s = Some(40.0);
table
.block_overlay_mut()
.hydro_override_mut(0, 1, 0)
.expect("in-range override cell")
.max_outflow_m3s = Some(650.0);
table
.block_overlay_mut()
.hydro_override_mut(0, 1, 1)
.expect("in-range override cell")
.max_diversion_m3s = Some(80.0);
assert_eq!(
table.hydro_bounds_at_block(0, 1, 0).max_outflow_m3s,
Some(650.0)
);
assert_eq!(
table.hydro_bounds_at_block(0, 1, 1).max_diversion_m3s,
Some(80.0)
);
}
#[cfg(feature = "serde")]
#[test]
fn test_resolved_bounds_wire_round_trip_with_overlay() {
let hb = make_hydro_bounds();
let hbl = make_hydro_block_bounds();
let tb = ThermalStageBounds { cost_per_mwh: 20.0 };
let tbb = ThermalBlockBounds {
min_generation_mw: 0.0,
max_generation_mw: 100.0,
};
let lb = LineBlockBounds {
direct_mw: 500.0,
reverse_mw: 500.0,
};
let pb = PumpingBlockBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 20.0,
};
let cb = ContractBlockBounds {
min_mw: 0.0,
max_mw: 50.0,
price_per_mwh: 80.0,
};
let mut original = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 1,
n_thermals: 1,
n_lines: 1,
n_pumping: 1,
n_contracts: 1,
n_stages: 2,
k_max: 0,
},
&BoundsDefaults {
hydro: hb,
hydro_block: hbl,
thermal: tb,
thermal_block: tbb,
line_block: lb,
pumping_block: pb,
contract_block: cb,
},
);
let mut block = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
n_hydros: 1,
n_thermals: 1,
n_lines: 1,
n_pumping: 1,
n_contracts: 1,
n_stages: 2,
max_blocks: 2,
});
block
.thermal_override_mut(0, 0, 0)
.expect("in-range override cell")
.max_generation_mw = Some(75.0);
original.set_block_overlay(block);
let json = serde_json::to_string(&original).expect("serialize json");
let restored_json: ResolvedBounds = serde_json::from_str(&json).expect("deserialize json");
assert_eq!(original, restored_json);
let bytes = postcard::to_allocvec(&original).expect("serialize postcard");
let restored_postcard: ResolvedBounds =
postcard::from_bytes(&bytes).expect("deserialize postcard");
assert_eq!(original, restored_postcard);
}
#[cfg(feature = "serde")]
#[test]
fn test_resolved_bounds_wire_absent_overlay_defaults_to_empty() {
let json = r#"{
"n_stages": 1,
"thermal_stage_axis_len": 1,
"hydro": [],
"thermal": [],
"line": [],
"pumping": [],
"contract": []
}"#;
let restored: ResolvedBounds = serde_json::from_str(json).expect("deserialize");
assert!(restored.block_overlay().is_empty());
}
#[cfg(feature = "serde")]
#[test]
fn resolved_bounds_group_overlay_round_trips_and_defaults() {
let mut original = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 1,
n_thermals: 0,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: 2,
k_max: 0,
},
&BoundsDefaults {
hydro: make_hydro_bounds(),
..zero_defaults()
},
);
let mut group = ResolvedHydroUnitGroupBounds::new(&HydroUnitGroupBoundsCountsSpec {
groups_per_plant: &[2],
n_stages: 2,
max_blocks: 2,
});
group
.stage_override_mut(0, 1, 1)
.expect("in-range stage cell")
.max_turbined_m3s = Some(40.0);
group
.block_override_mut(0, 1, 1, 0)
.expect("in-range block cell")
.max_turbined_m3s = Some(12.0);
original.set_group_overlay(group);
let json = serde_json::to_string(&original).expect("serialize");
let restored: ResolvedBounds = serde_json::from_str(&json).expect("deserialize");
for (hydro_idx, group_pos, stage_idx, block_idx) in
[(0, 0, 0, 0), (0, 1, 1, 0), (0, 1, 1, 1), (0, 1, 0, 0)]
{
let original_over = original
.group_overlay()
.override_at_block(hydro_idx, group_pos, stage_idx, block_idx);
let restored_over = restored
.group_overlay()
.override_at_block(hydro_idx, group_pos, stage_idx, block_idx);
assert_eq!(
original_over.max_turbined_m3s.map(f64::to_bits),
restored_over.max_turbined_m3s.map(f64::to_bits),
"mismatch at (h={hydro_idx}, g={group_pos}, t={stage_idx}, b={block_idx})"
);
}
let absent_group_json = r#"{
"n_stages": 1,
"thermal_stage_axis_len": 1,
"hydro": [],
"thermal": [],
"line": [],
"pumping": [],
"contract": []
}"#;
let restored_without_group: ResolvedBounds =
serde_json::from_str(absent_group_json).expect("deserialize without group field");
assert!(restored_without_group.group_overlay().is_empty());
}
}