use std::ops::Range;
use cobre_solver::StageTemplate;
#[derive(Debug, Clone, Copy)]
pub struct EvaporationIndices {
pub q_ev_col: usize,
pub f_evap_plus_col: usize,
pub f_evap_minus_col: usize,
pub evap_row: usize,
}
#[derive(Debug, Clone, Copy)]
pub struct FphaRowRange {
pub start: usize,
pub planes_per_block: usize,
}
fn build_inflow_slack_range(
has_inflow_penalty: bool,
hydro_count: usize,
excess_end: usize,
) -> (Range<usize>, bool) {
if has_inflow_penalty && hydro_count > 0 {
(excess_end..excess_end + hydro_count, true)
} else {
(0..0, false)
}
}
struct OperViolationRanges {
outflow_below_slack: Range<usize>,
outflow_above_slack: Range<usize>,
turbine_below_slack: Range<usize>,
generation_below_slack: Range<usize>,
min_outflow_rows: Range<usize>,
max_outflow_rows: Range<usize>,
min_turbine_rows: Range<usize>,
min_generation_rows: Range<usize>,
has_operational_violations: bool,
}
fn build_oper_violation_ranges(
hydro_count: usize,
n_blks: usize,
ws_end: usize,
evap_rows_end: usize,
) -> OperViolationRanges {
if hydro_count == 0 {
return OperViolationRanges {
outflow_below_slack: 0..0,
outflow_above_slack: 0..0,
turbine_below_slack: 0..0,
generation_below_slack: 0..0,
min_outflow_rows: 0..0,
max_outflow_rows: 0..0,
min_turbine_rows: 0..0,
min_generation_rows: 0..0,
has_operational_violations: false,
};
}
let n_op = hydro_count * n_blks;
let ob = ws_end..ws_end + n_op;
let oa = ob.end..ob.end + n_op;
let tb = oa.end..oa.end + n_op;
let gb = tb.end..tb.end + n_op;
let r_min_out = evap_rows_end..evap_rows_end + n_op;
let r_max_out = r_min_out.end..r_min_out.end + n_op;
let r_min_turb = r_max_out.end..r_max_out.end + n_op;
let r_min_gen = r_min_turb.end..r_min_turb.end + n_op;
OperViolationRanges {
outflow_below_slack: ob,
outflow_above_slack: oa,
turbine_below_slack: tb,
generation_below_slack: gb,
min_outflow_rows: r_min_out,
max_outflow_rows: r_max_out,
min_turbine_rows: r_min_turb,
min_generation_rows: r_min_gen,
has_operational_violations: true,
}
}
#[derive(Debug, Clone)]
pub struct StageIndexer {
pub storage: Range<usize>,
pub inflow_lags: Range<usize>,
pub storage_in: Range<usize>,
pub theta: usize,
pub n_state: usize,
pub storage_fixing: Range<usize>,
pub lag_fixing: Range<usize>,
pub hydro_count: usize,
pub max_par_order: usize,
pub turbine: Range<usize>,
pub spillage: Range<usize>,
pub diversion: Range<usize>,
pub thermal: Range<usize>,
pub line_fwd: Range<usize>,
pub line_rev: Range<usize>,
pub deficit: Range<usize>,
pub max_deficit_segments: usize,
pub excess: Range<usize>,
pub n_blks: usize,
pub n_thermals: usize,
pub n_lines: usize,
pub n_buses: usize,
pub water_balance: Range<usize>,
pub load_balance: Range<usize>,
pub inflow_slack: Range<usize>,
pub inflow_slack_rows: Range<usize>,
pub has_inflow_penalty: bool,
pub generation: Range<usize>,
pub n_fpha_hydros: usize,
pub fpha_hydro_indices: Vec<usize>,
pub fpha_rows: Vec<FphaRowRange>,
pub n_evap_hydros: usize,
pub evap_hydro_indices: Vec<usize>,
pub evap_indices: Vec<EvaporationIndices>,
pub withdrawal_slack_neg: Range<usize>,
pub withdrawal_slack_pos: Range<usize>,
pub has_withdrawal: bool,
pub outflow_below_slack: Range<usize>,
pub outflow_above_slack: Range<usize>,
pub turbine_below_slack: Range<usize>,
pub generation_below_slack: Range<usize>,
pub min_outflow_rows: Range<usize>,
pub max_outflow_rows: Range<usize>,
pub min_turbine_rows: Range<usize>,
pub min_generation_rows: Range<usize>,
pub has_operational_violations: bool,
pub generic_constraint_rows: Range<usize>,
pub generic_constraint_slack: Range<usize>,
pub n_generic_constraints_active: usize,
pub ncs_generation: Range<usize>,
pub z_inflow: Range<usize>,
pub z_inflow_rows: Range<usize>,
pub z_inflow_row_start: usize,
pub nonzero_state_indices: Vec<usize>,
}
pub struct EquipmentCounts {
pub hydro_count: usize,
pub max_par_order: usize,
pub n_thermals: usize,
pub n_lines: usize,
pub n_buses: usize,
pub n_blks: usize,
pub has_inflow_penalty: bool,
pub max_deficit_segments: usize,
}
pub struct FphaColumnLayout {
pub hydro_indices: Vec<usize>,
pub planes_per_hydro: Vec<usize>,
}
pub struct EvapConfig {
pub hydro_indices: Vec<usize>,
}
impl StageIndexer {
#[must_use]
pub fn new(hydro_count: usize, max_par_order: usize) -> Self {
let n = hydro_count;
let l = max_par_order;
let storage = 0..n;
let inflow_lags = n..n * (1 + l);
let z_inflow_start = n * (1 + l);
let z_inflow = z_inflow_start..z_inflow_start + n;
let storage_in = n * (2 + l)..n * (3 + l);
let theta = n * (3 + l);
let n_state = n * (1 + l);
let storage_fixing = 0..n;
let lag_fixing = n..n * (1 + l);
let z_inflow_rows = z_inflow_start..z_inflow_start + n;
let z_inflow_row_start = z_inflow_start;
Self {
storage,
inflow_lags,
storage_in,
theta,
n_state,
storage_fixing,
lag_fixing,
hydro_count,
max_par_order,
turbine: 0..0,
spillage: 0..0,
diversion: 0..0,
thermal: 0..0,
line_fwd: 0..0,
line_rev: 0..0,
deficit: 0..0,
max_deficit_segments: 0,
excess: 0..0,
n_blks: 0,
n_thermals: 0,
n_lines: 0,
n_buses: 0,
water_balance: 0..0,
load_balance: 0..0,
inflow_slack: 0..0,
inflow_slack_rows: 0..0,
has_inflow_penalty: false,
generation: 0..0,
n_fpha_hydros: 0,
fpha_hydro_indices: Vec::new(),
fpha_rows: Vec::new(),
n_evap_hydros: 0,
evap_hydro_indices: Vec::new(),
evap_indices: Vec::new(),
withdrawal_slack_neg: 0..0,
withdrawal_slack_pos: 0..0,
has_withdrawal: false,
outflow_below_slack: 0..0,
outflow_above_slack: 0..0,
turbine_below_slack: 0..0,
generation_below_slack: 0..0,
min_outflow_rows: 0..0,
max_outflow_rows: 0..0,
min_turbine_rows: 0..0,
min_generation_rows: 0..0,
has_operational_violations: false,
generic_constraint_rows: 0..0,
generic_constraint_slack: 0..0,
n_generic_constraints_active: 0,
ncs_generation: 0..0,
z_inflow,
z_inflow_rows,
z_inflow_row_start,
nonzero_state_indices: Vec::new(),
}
}
#[must_use]
pub fn with_equipment(counts: &EquipmentCounts, fpha: &FphaColumnLayout) -> Self {
Self::with_equipment_and_evaporation(
counts,
fpha,
&EvapConfig {
hydro_indices: vec![],
},
)
}
#[must_use]
pub fn with_equipment_and_evaporation(
counts: &EquipmentCounts,
fpha: &FphaColumnLayout,
evap: &EvapConfig,
) -> Self {
let hydro_count = counts.hydro_count;
let max_par_order = counts.max_par_order;
let n_thermals = counts.n_thermals;
let n_lines = counts.n_lines;
let n_buses = counts.n_buses;
let n_blks = counts.n_blks;
let has_inflow_penalty = counts.has_inflow_penalty;
let fpha_hydro_indices = fpha.hydro_indices.clone();
let evap_hydro_indices = evap.hydro_indices.clone();
debug_assert_eq!(
fpha_hydro_indices.len(),
fpha.planes_per_hydro.len(),
"fpha_hydro_indices and fpha_planes_per_hydro must have equal length"
);
let base = Self::new(hydro_count, max_par_order);
let decision_start = base.theta + 1;
let turbine_start = decision_start;
let spillage_start = turbine_start + hydro_count * n_blks;
let diversion_start = spillage_start + hydro_count * n_blks;
let thermal_start = diversion_start + hydro_count * n_blks;
let line_fwd_start = thermal_start + n_thermals * n_blks;
let line_rev_start = line_fwd_start + n_lines * n_blks;
let deficit_start = line_rev_start + n_lines * n_blks;
let max_deficit_segments = counts.max_deficit_segments; let excess_start = deficit_start + n_buses * max_deficit_segments * n_blks;
let excess_end = excess_start + n_buses * n_blks;
let (inflow_slack, active_penalty) =
build_inflow_slack_range(has_inflow_penalty, hydro_count, excess_end);
let n_fpha_hydros = fpha_hydro_indices.len();
let generation_start = if active_penalty {
inflow_slack.end
} else {
excess_end
};
let generation_end = generation_start + n_fpha_hydros * n_blks;
let generation = if n_fpha_hydros > 0 {
generation_start..generation_end
} else {
0..0
};
let n_evap_hydros = evap_hydro_indices.len();
let evap_col_start = generation_end;
let water_balance_start = base.n_state + hydro_count;
let load_balance_start = water_balance_start + hydro_count;
let load_balance_end = load_balance_start + n_buses * n_blks;
let (fpha_rows, fpha_row_cursor) =
Self::build_fpha_rows(&fpha.planes_per_hydro, n_blks, load_balance_end);
let evap_indices_vec =
Self::build_evap_indices(n_evap_hydros, evap_col_start, fpha_row_cursor);
let evap_col_end = evap_col_start + n_evap_hydros * 3;
let (withdrawal_slack_neg, withdrawal_slack_pos, has_withdrawal) = if hydro_count > 0 {
let neg = evap_col_end..evap_col_end + hydro_count;
let pos = neg.end..neg.end + hydro_count;
(neg, pos, true)
} else {
(0..0, 0..0, false)
};
let evap_rows_end = fpha_row_cursor + n_evap_hydros;
let ws_end = withdrawal_slack_pos.end;
let op = build_oper_violation_ranges(hydro_count, n_blks, ws_end, evap_rows_end);
Self {
turbine: turbine_start..spillage_start,
spillage: spillage_start..diversion_start,
diversion: diversion_start..thermal_start,
thermal: thermal_start..line_fwd_start,
line_fwd: line_fwd_start..line_rev_start,
line_rev: line_rev_start..deficit_start,
deficit: deficit_start..excess_start,
max_deficit_segments,
excess: excess_start..excess_end,
n_blks,
n_thermals,
n_lines,
n_buses,
water_balance: water_balance_start..water_balance_start + hydro_count,
load_balance: load_balance_start..load_balance_end,
inflow_slack,
inflow_slack_rows: 0..0,
has_inflow_penalty: active_penalty,
generation,
n_fpha_hydros,
fpha_hydro_indices,
fpha_rows,
n_evap_hydros,
evap_hydro_indices,
evap_indices: evap_indices_vec,
withdrawal_slack_neg,
withdrawal_slack_pos,
has_withdrawal,
outflow_below_slack: op.outflow_below_slack,
outflow_above_slack: op.outflow_above_slack,
turbine_below_slack: op.turbine_below_slack,
generation_below_slack: op.generation_below_slack,
min_outflow_rows: op.min_outflow_rows,
max_outflow_rows: op.max_outflow_rows,
min_turbine_rows: op.min_turbine_rows,
min_generation_rows: op.min_generation_rows,
has_operational_violations: op.has_operational_violations,
..base
}
}
#[must_use]
pub fn evap_indices(&self, local_idx: usize) -> &EvaporationIndices {
debug_assert!(
local_idx < self.n_evap_hydros,
"evap local index {local_idx} out of bounds (n_evap_hydros = {})",
self.n_evap_hydros
);
&self.evap_indices[local_idx]
}
fn build_fpha_rows(
planes_per_hydro: &[usize],
n_blks: usize,
start_row: usize,
) -> (Vec<FphaRowRange>, usize) {
let mut rows = Vec::with_capacity(planes_per_hydro.len());
let mut cursor = start_row;
for &planes in planes_per_hydro {
rows.push(FphaRowRange {
start: cursor,
planes_per_block: planes,
});
cursor += planes * n_blks;
}
(rows, cursor)
}
fn build_evap_indices(
n_evap_hydros: usize,
col_start: usize,
row_start: usize,
) -> Vec<EvaporationIndices> {
(0..n_evap_hydros)
.map(|i| EvaporationIndices {
q_ev_col: col_start + i * 3,
f_evap_plus_col: col_start + i * 3 + 1,
f_evap_minus_col: col_start + i * 3 + 2,
evap_row: row_start + i,
})
.collect()
}
#[must_use]
pub fn from_stage_template(template: &StageTemplate) -> Self {
Self::new(template.n_hydro, template.max_par_order)
}
#[inline]
#[must_use]
pub fn state_to_lp_column(&self, j: usize) -> usize {
let n = self.hydro_count;
if j < n || self.max_par_order == 0 {
return j;
}
let offset = j - n;
let h = offset % n;
let lag = offset / n;
if lag == 0 {
self.z_inflow.start + h
} else {
n + (lag - 1) * n + h
}
}
pub fn set_nonzero_mask(&mut self, lag_counts: &[usize]) {
debug_assert_eq!(
lag_counts.len(),
self.hydro_count,
"lag_counts length {} != hydro_count {}",
lag_counts.len(),
self.hydro_count
);
let mut mask = Vec::with_capacity(self.n_state);
for h in 0..self.hydro_count {
mask.push(h);
}
for lag in 0..self.max_par_order {
for (h, &lag_count) in lag_counts.iter().enumerate() {
debug_assert!(
lag_count <= self.max_par_order,
"lag_counts[{h}] = {lag_count} exceeds max_par_order {}",
self.max_par_order
);
if lag < lag_count {
mask.push(self.inflow_lags.start + lag * self.hydro_count + h);
}
}
}
debug_assert!(
mask.windows(2).all(|w| w[0] < w[1]),
"nonzero_state_indices must be sorted and unique"
);
self.nonzero_state_indices = mask;
}
}
const _: () = {
fn assert_send_sync<T: Send + Sync>() {}
fn check() {
assert_send_sync::<StageIndexer>();
}
let _ = check;
};
#[cfg(test)]
mod tests {
use cobre_solver::StageTemplate;
use super::{EquipmentCounts, EvapConfig, FphaColumnLayout, FphaRowRange, StageIndexer};
fn eq(
hydro_count: usize,
max_par_order: usize,
n_thermals: usize,
n_lines: usize,
n_buses: usize,
n_blks: usize,
has_inflow_penalty: bool,
) -> EquipmentCounts {
EquipmentCounts {
hydro_count,
max_par_order,
n_thermals,
n_lines,
n_buses,
n_blks,
has_inflow_penalty,
max_deficit_segments: 1,
}
}
fn fpha(hydro_indices: Vec<usize>, planes_per_hydro: Vec<usize>) -> FphaColumnLayout {
FphaColumnLayout {
hydro_indices,
planes_per_hydro,
}
}
fn evap(hydro_indices: Vec<usize>) -> EvapConfig {
EvapConfig { hydro_indices }
}
fn indexer_3_2() -> StageIndexer {
StageIndexer::new(3, 2)
}
#[test]
fn storage_range_3_2() {
assert_eq!(indexer_3_2().storage, 0..3);
}
#[test]
fn inflow_lags_range_3_2() {
assert_eq!(indexer_3_2().inflow_lags, 3..9);
}
#[test]
fn z_inflow_range_3_2() {
assert_eq!(indexer_3_2().z_inflow, 9..12);
}
#[test]
fn storage_in_range_3_2() {
assert_eq!(indexer_3_2().storage_in, 12..15);
}
#[test]
fn theta_index_3_2() {
assert_eq!(indexer_3_2().theta, 15);
}
#[test]
fn n_state_3_2() {
assert_eq!(indexer_3_2().n_state, 9);
}
#[test]
fn storage_fixing_range_3_2() {
assert_eq!(indexer_3_2().storage_fixing, 0..3);
}
#[test]
fn lag_fixing_range_3_2() {
assert_eq!(indexer_3_2().lag_fixing, 3..9);
}
#[test]
fn row_column_symmetry_3_2() {
let idx = indexer_3_2();
assert_eq!(idx.storage_fixing, idx.storage);
assert_eq!(idx.lag_fixing, idx.inflow_lags);
}
fn indexer_160_12() -> StageIndexer {
StageIndexer::new(160, 12)
}
#[test]
fn n_state_production_scale() {
assert_eq!(indexer_160_12().n_state, 2080);
}
#[test]
fn theta_production_scale() {
assert_eq!(indexer_160_12().theta, 2400);
}
#[test]
fn row_column_symmetry_production_scale() {
let idx = indexer_160_12();
assert_eq!(idx.storage_fixing, idx.storage);
assert_eq!(idx.lag_fixing, idx.inflow_lags);
}
#[test]
fn single_hydro_no_lags() {
let idx = StageIndexer::new(1, 0);
assert_eq!(idx.storage, 0..1);
assert_eq!(idx.inflow_lags, 1..1);
assert_eq!(idx.z_inflow, 1..2);
assert_eq!(idx.storage_in, 2..3);
assert_eq!(idx.theta, 3);
assert_eq!(idx.n_state, 1);
assert_eq!(idx.storage_fixing, 0..1);
assert_eq!(idx.lag_fixing, 1..1);
assert_eq!(idx.storage_fixing, idx.storage);
assert_eq!(idx.lag_fixing, idx.inflow_lags);
}
#[test]
fn degenerate_zero_hydros() {
let idx = StageIndexer::new(0, 0);
assert_eq!(idx.storage, 0..0);
assert_eq!(idx.inflow_lags, 0..0);
assert_eq!(idx.z_inflow, 0..0);
assert_eq!(idx.storage_in, 0..0);
assert_eq!(idx.theta, 0);
assert_eq!(idx.n_state, 0);
assert_eq!(idx.storage_fixing, 0..0);
assert_eq!(idx.lag_fixing, 0..0);
assert_eq!(idx.storage_fixing, idx.storage);
assert_eq!(idx.lag_fixing, idx.inflow_lags);
}
fn make_template(n_hydro: usize, max_par_order: usize) -> StageTemplate {
let n_state = n_hydro * (1 + max_par_order);
let n_transfer = n_hydro * max_par_order;
StageTemplate {
num_cols: 0,
num_rows: 0,
num_nz: 0,
col_starts: vec![0_i32],
row_indices: vec![],
values: vec![],
col_lower: vec![],
col_upper: vec![],
objective: vec![],
row_lower: vec![],
row_upper: vec![],
n_state,
n_transfer,
n_dual_relevant: n_state,
n_hydro,
max_par_order,
col_scale: Vec::new(),
row_scale: Vec::new(),
}
}
#[test]
fn from_stage_template_matches_new_3_2() {
let tmpl = make_template(3, 2);
let from_tmpl = StageIndexer::from_stage_template(&tmpl);
let from_new = StageIndexer::new(3, 2);
assert_eq!(from_tmpl.storage, from_new.storage);
assert_eq!(from_tmpl.inflow_lags, from_new.inflow_lags);
assert_eq!(from_tmpl.storage_in, from_new.storage_in);
assert_eq!(from_tmpl.theta, from_new.theta);
assert_eq!(from_tmpl.n_state, from_new.n_state);
assert_eq!(from_tmpl.storage_fixing, from_new.storage_fixing);
assert_eq!(from_tmpl.lag_fixing, from_new.lag_fixing);
assert_eq!(from_tmpl.hydro_count, from_new.hydro_count);
assert_eq!(from_tmpl.max_par_order, from_new.max_par_order);
}
#[test]
fn from_stage_template_matches_new_160_12() {
let tmpl = make_template(160, 12);
let from_tmpl = StageIndexer::from_stage_template(&tmpl);
let from_new = StageIndexer::new(160, 12);
assert_eq!(from_tmpl.n_state, from_new.n_state);
assert_eq!(from_tmpl.theta, from_new.theta);
assert_eq!(from_tmpl.hydro_count, from_new.hydro_count);
assert_eq!(from_tmpl.max_par_order, from_new.max_par_order);
}
#[test]
fn from_stage_template_matches_new_edge_cases() {
for (n, l) in [(0, 0), (1, 0), (1, 1)] {
let tmpl = make_template(n, l);
let from_tmpl = StageIndexer::from_stage_template(&tmpl);
let from_new = StageIndexer::new(n, l);
assert_eq!(from_tmpl.storage, from_new.storage, "N={n} L={l}");
assert_eq!(from_tmpl.inflow_lags, from_new.inflow_lags, "N={n} L={l}");
assert_eq!(from_tmpl.theta, from_new.theta, "N={n} L={l}");
assert_eq!(from_tmpl.n_state, from_new.n_state, "N={n} L={l}");
}
}
#[test]
fn clone_and_debug() {
let idx = indexer_3_2();
let cloned = idx.clone();
assert_eq!(cloned.theta, idx.theta);
assert_eq!(cloned.n_state, idx.n_state);
let debug_str = format!("{idx:?}");
assert!(debug_str.contains("StageIndexer"));
}
#[test]
fn new_equipment_ranges_are_empty() {
let idx = StageIndexer::new(3, 2);
assert!(idx.turbine.is_empty());
assert!(idx.spillage.is_empty());
assert!(idx.diversion.is_empty());
assert!(idx.thermal.is_empty());
assert!(idx.line_fwd.is_empty());
assert!(idx.line_rev.is_empty());
assert!(idx.deficit.is_empty());
assert!(idx.excess.is_empty());
assert_eq!(idx.n_blks, 0);
assert_eq!(idx.n_thermals, 0);
assert_eq!(idx.n_lines, 0);
assert_eq!(idx.n_buses, 0);
}
#[test]
fn with_equipment_doctest_n1_l0_t2_l1_b2_k1() {
let idx = StageIndexer::with_equipment(&eq(1, 0, 2, 1, 2, 1, false), &fpha(vec![], vec![]));
assert_eq!(idx.storage, 0..1);
assert_eq!(idx.inflow_lags, 1..1);
assert_eq!(idx.z_inflow, 1..2);
assert_eq!(idx.storage_in, 2..3);
assert_eq!(idx.theta, 3);
assert_eq!(idx.n_state, 1);
assert_eq!(idx.turbine, 4..5);
assert_eq!(idx.spillage, 5..6);
assert_eq!(idx.diversion, 6..7);
assert_eq!(idx.thermal, 7..9);
assert_eq!(idx.line_fwd, 9..10);
assert_eq!(idx.line_rev, 10..11);
assert_eq!(idx.deficit, 11..13);
assert_eq!(idx.excess, 13..15);
assert_eq!(idx.n_blks, 1);
assert_eq!(idx.n_thermals, 2);
assert_eq!(idx.n_lines, 1);
assert_eq!(idx.n_buses, 2);
}
#[test]
fn with_equipment_n2_l1_t3_l2_b4_k2() {
let idx = StageIndexer::with_equipment(&eq(2, 1, 3, 2, 4, 2, false), &fpha(vec![], vec![]));
assert_eq!(idx.theta, 8);
assert_eq!(idx.n_state, 4);
assert_eq!(idx.turbine, 9..13);
assert_eq!(idx.spillage, 13..17);
assert_eq!(idx.diversion, 17..21);
assert_eq!(idx.thermal, 21..27);
assert_eq!(idx.line_fwd, 27..31);
assert_eq!(idx.line_rev, 31..35);
assert_eq!(idx.deficit, 35..43);
assert_eq!(idx.excess, 43..51);
}
#[test]
fn with_equipment_all_counts_zero_matches_new() {
let with_eq =
StageIndexer::with_equipment(&eq(3, 2, 0, 0, 0, 0, false), &fpha(vec![], vec![]));
let base = StageIndexer::new(3, 2);
assert_eq!(with_eq.storage, base.storage);
assert_eq!(with_eq.inflow_lags, base.inflow_lags);
assert_eq!(with_eq.storage_in, base.storage_in);
assert_eq!(with_eq.theta, base.theta);
assert_eq!(with_eq.n_state, base.n_state);
assert!(with_eq.turbine.is_empty());
assert!(with_eq.spillage.is_empty());
assert!(with_eq.diversion.is_empty());
assert!(with_eq.thermal.is_empty());
assert!(with_eq.line_fwd.is_empty());
assert!(with_eq.line_rev.is_empty());
assert!(with_eq.deficit.is_empty());
assert!(with_eq.excess.is_empty());
}
#[test]
fn with_equipment_ranges_are_contiguous() {
let idx = StageIndexer::with_equipment(&eq(2, 1, 3, 2, 4, 2, false), &fpha(vec![], vec![]));
assert_eq!(idx.turbine.start, idx.theta + 1);
assert_eq!(idx.spillage.start, idx.turbine.end);
assert_eq!(idx.diversion.start, idx.spillage.end);
assert_eq!(idx.thermal.start, idx.diversion.end);
assert_eq!(idx.line_fwd.start, idx.thermal.end);
assert_eq!(idx.line_rev.start, idx.line_fwd.end);
assert_eq!(idx.deficit.start, idx.line_rev.end);
assert_eq!(idx.excess.start, idx.deficit.end);
}
#[test]
fn with_equipment_column_index_formulas() {
let n_blks = 3_usize;
let idx =
StageIndexer::with_equipment(&eq(2, 1, 1, 1, 1, n_blks, false), &fpha(vec![], vec![]));
assert_eq!(idx.turbine.start, idx.turbine.start);
assert_eq!(idx.turbine.start + n_blks + 2, idx.turbine.start + 5);
assert_eq!(idx.deficit.start + 1, idx.deficit.start + 1);
assert_eq!(idx.turbine.start + n_blks, idx.turbine.start + 3);
}
#[test]
fn with_equipment_inflow_penalty_appends_slack() {
let idx = StageIndexer::with_equipment(&eq(2, 1, 1, 1, 1, 1, true), &fpha(vec![], vec![]));
assert!(idx.has_inflow_penalty, "has_inflow_penalty must be true");
assert_eq!(
idx.inflow_slack.start, idx.excess.end,
"inflow_slack.start must equal excess.end (contiguous)"
);
assert_eq!(
idx.inflow_slack.len(),
idx.hydro_count,
"inflow_slack must contain exactly hydro_count columns"
);
assert_eq!(idx.inflow_slack, 20..22);
assert!(
idx.inflow_slack_rows.is_empty(),
"inflow_slack_rows must remain empty"
);
let no_penalty =
StageIndexer::with_equipment(&eq(2, 1, 1, 1, 1, 1, false), &fpha(vec![], vec![]));
assert!(!no_penalty.has_inflow_penalty);
assert!(no_penalty.inflow_slack.is_empty());
}
#[test]
fn fpha_no_hydros_generation_is_empty() {
let idx = StageIndexer::with_equipment(&eq(4, 0, 0, 0, 1, 1, false), &fpha(vec![], vec![]));
assert!(
idx.generation.is_empty(),
"generation must be empty with no FPHA hydros"
);
assert_eq!(idx.n_fpha_hydros, 0);
assert!(idx.fpha_hydro_indices.is_empty());
assert!(idx.fpha_rows.is_empty());
}
#[test]
fn fpha_one_hydro_one_block_three_planes() {
let idx =
StageIndexer::with_equipment(&eq(2, 0, 1, 0, 1, 1, false), &fpha(vec![0], vec![3]));
assert_eq!(idx.generation.len(), 1, "generation must span 1 column");
assert_eq!(idx.generation, 16..17);
assert_eq!(idx.n_fpha_hydros, 1);
assert_eq!(idx.fpha_hydro_indices, vec![0]);
assert_eq!(idx.fpha_rows.len(), 1);
assert_eq!(
idx.fpha_rows[0].start, idx.load_balance.end,
"fpha_rows[0].start must equal load_balance.end"
);
assert_eq!(idx.fpha_rows[0].planes_per_block, 3);
}
#[test]
fn fpha_two_hydros_two_blocks_different_planes() {
let idx = StageIndexer::with_equipment(
&eq(4, 0, 0, 0, 1, 2, false),
&fpha(vec![1, 3], vec![5, 4]),
);
assert_eq!(idx.generation.len(), 4, "generation must span 4 columns");
assert_eq!(idx.n_fpha_hydros, 2);
assert_eq!(idx.fpha_hydro_indices, vec![1, 3]);
assert_eq!(idx.fpha_rows.len(), 2);
assert_eq!(
idx.fpha_rows[0].start, idx.load_balance.end,
"fpha_rows[0].start must equal load_balance.end"
);
assert_eq!(idx.fpha_rows[0].planes_per_block, 5);
assert_eq!(
idx.fpha_rows[1].start,
idx.fpha_rows[0].start + 5 * 2,
"fpha_rows[1].start must follow fpha_rows[0]'s 10-row region"
);
assert_eq!(idx.fpha_rows[1].planes_per_block, 4);
}
#[test]
fn fpha_generation_contiguous_with_prior_region() {
let no_penalty =
StageIndexer::with_equipment(&eq(2, 0, 0, 0, 1, 1, false), &fpha(vec![0], vec![2]));
assert_eq!(
no_penalty.generation.start, no_penalty.excess.end,
"generation.start must equal excess.end when no penalty"
);
let with_penalty =
StageIndexer::with_equipment(&eq(2, 0, 0, 0, 1, 1, true), &fpha(vec![0], vec![2]));
assert_eq!(
with_penalty.generation.start, with_penalty.inflow_slack.end,
"generation.start must equal inflow_slack.end when penalty active"
);
}
#[test]
fn fpha_rows_contiguous_with_load_balance() {
let idx = StageIndexer::with_equipment(
&eq(3, 1, 2, 0, 2, 3, false),
&fpha(vec![0, 2], vec![4, 6]),
);
assert_eq!(
idx.fpha_rows[0].start, idx.load_balance.end,
"fpha_rows[0] must start at load_balance.end"
);
assert_eq!(
idx.fpha_rows[1].start,
idx.fpha_rows[0].start + 4 * 3,
"fpha_rows[1] must start after fpha_rows[0]'s rows"
);
assert_eq!(idx.fpha_rows[1].planes_per_block, 6);
}
#[test]
fn evap_no_hydros_indices_empty() {
let idx = StageIndexer::with_equipment(&eq(3, 0, 1, 0, 1, 1, false), &fpha(vec![], vec![]));
assert_eq!(idx.n_evap_hydros, 0);
assert!(idx.evap_hydro_indices.is_empty());
assert!(idx.evap_indices.is_empty());
}
#[test]
fn evap_one_hydro_column_row_positions() {
let idx = StageIndexer::with_equipment_and_evaporation(
&eq(2, 0, 0, 0, 1, 1, false),
&fpha(vec![], vec![]),
&evap(vec![0]),
);
assert_eq!(idx.n_evap_hydros, 1);
assert_eq!(idx.evap_hydro_indices, vec![0]);
assert_eq!(idx.evap_indices.len(), 1);
let ei = idx.evap_indices(0);
assert_eq!(ei.q_ev_col, 15);
assert_eq!(ei.f_evap_plus_col, 16);
assert_eq!(ei.f_evap_minus_col, 17);
assert_eq!(ei.evap_row, 7);
}
#[test]
fn evap_two_hydros_with_fpha_contiguous() {
let idx = StageIndexer::with_equipment_and_evaporation(
&eq(4, 0, 0, 0, 1, 1, false),
&fpha(vec![0], vec![3]),
&evap(vec![1, 2]),
);
assert_eq!(idx.n_evap_hydros, 2);
assert_eq!(idx.evap_hydro_indices, vec![1, 2]);
let ei0 = idx.evap_indices(0);
let ei1 = idx.evap_indices(1);
assert_eq!(ei0.q_ev_col, 28);
assert_eq!(ei0.f_evap_plus_col, 29);
assert_eq!(ei0.f_evap_minus_col, 30);
assert_eq!(ei1.q_ev_col, 31);
assert_eq!(ei1.f_evap_plus_col, 32);
assert_eq!(ei1.f_evap_minus_col, 33);
assert_eq!(ei0.evap_row, 16);
assert_eq!(ei1.evap_row, 17);
assert!(ei0.evap_row > idx.fpha_rows[0].start);
}
#[test]
fn new_evap_ranges_are_empty() {
let idx = StageIndexer::new(3, 2);
assert_eq!(idx.n_evap_hydros, 0);
assert!(idx.evap_hydro_indices.is_empty());
assert!(idx.evap_indices.is_empty());
}
#[test]
fn withdrawal_slack_with_equipment_and_evaporation_n3_evap1() {
let idx = StageIndexer::with_equipment_and_evaporation(
&eq(3, 0, 0, 0, 1, 1, false),
&fpha(vec![], vec![]),
&evap(vec![0]),
);
assert!(idx.has_withdrawal);
let evap_col_end = idx.evap_indices(0).f_evap_minus_col + 1;
assert_eq!(
idx.withdrawal_slack_neg.start, evap_col_end,
"withdrawal_slack_neg.start must equal evap_col_end"
);
assert_eq!(idx.withdrawal_slack_neg.len(), 3);
assert_eq!(idx.withdrawal_slack_neg, 24..27);
assert_eq!(idx.withdrawal_slack_pos.start, idx.withdrawal_slack_neg.end);
assert_eq!(idx.withdrawal_slack_pos.len(), 3);
assert_eq!(idx.withdrawal_slack_pos, 27..30);
}
#[test]
fn withdrawal_slack_zero_hydros_is_empty() {
let idx = StageIndexer::with_equipment_and_evaporation(
&eq(0, 0, 0, 0, 1, 1, false),
&fpha(vec![], vec![]),
&evap(vec![]),
);
assert!(!idx.has_withdrawal);
assert_eq!(idx.withdrawal_slack_neg, 0..0);
assert_eq!(idx.withdrawal_slack_pos, 0..0);
}
#[test]
fn withdrawal_slack_from_new_is_empty() {
let idx = StageIndexer::new(3, 2);
assert!(!idx.has_withdrawal);
assert_eq!(idx.withdrawal_slack_neg, 0..0);
assert_eq!(idx.withdrawal_slack_pos, 0..0);
}
#[test]
fn withdrawal_slack_length_equals_hydro_count() {
for n in [1_usize, 5] {
let idx = StageIndexer::with_equipment_and_evaporation(
&EquipmentCounts {
hydro_count: n,
max_par_order: 0,
n_thermals: 0,
n_lines: 0,
n_buses: 1,
n_blks: 1,
has_inflow_penalty: false,
max_deficit_segments: 1,
},
&fpha(vec![], vec![]),
&evap(vec![]),
);
assert!(idx.has_withdrawal, "has_withdrawal must be true for n={n}");
assert_eq!(
idx.withdrawal_slack_neg.len(),
n,
"withdrawal_slack_neg length must equal hydro_count for n={n}"
);
assert_eq!(
idx.withdrawal_slack_pos.len(),
n,
"withdrawal_slack_pos length must equal hydro_count for n={n}"
);
}
}
#[test]
fn withdrawal_slack_immediately_after_evap_columns() {
let idx = StageIndexer::with_equipment_and_evaporation(
&eq(2, 0, 0, 0, 1, 1, false),
&fpha(vec![], vec![]),
&evap(vec![0]),
);
assert_eq!(
idx.withdrawal_slack_neg.start, 18,
"withdrawal_slack_neg must start at evap_col_end=18"
);
assert_eq!(idx.withdrawal_slack_neg.len(), 2);
assert_eq!(idx.withdrawal_slack_neg, 18..20);
assert_eq!(idx.withdrawal_slack_pos, 20..22);
assert_eq!(
idx.outflow_below_slack.start, idx.withdrawal_slack_pos.end,
"outflow_below_slack must start at withdrawal_slack_pos.end"
);
}
#[test]
fn evap_indices_debug_clone_copy() {
use super::EvaporationIndices;
let ei = EvaporationIndices {
q_ev_col: 10,
f_evap_plus_col: 11,
f_evap_minus_col: 12,
evap_row: 5,
};
let cloned = ei;
assert_eq!(cloned.q_ev_col, 10);
assert_eq!(cloned.evap_row, 5);
let debug_str = format!("{ei:?}");
assert!(debug_str.contains("EvaporationIndices"));
}
#[test]
fn fpha_row_range_debug_clone_copy() {
let r = FphaRowRange {
start: 42,
planes_per_block: 5,
};
let cloned = r;
assert_eq!(cloned.start, 42);
assert_eq!(cloned.planes_per_block, 5);
let debug_str = format!("{r:?}");
assert!(debug_str.contains("FphaRowRange"));
}
#[test]
fn new_fpha_ranges_are_empty() {
let idx = StageIndexer::new(3, 2);
assert!(idx.generation.is_empty());
assert_eq!(idx.n_fpha_hydros, 0);
assert!(idx.fpha_hydro_indices.is_empty());
assert!(idx.fpha_rows.is_empty());
}
#[test]
fn extended_adjacency_invariant_with_fpha() {
let idx =
StageIndexer::with_equipment(&eq(2, 1, 1, 1, 1, 1, false), &fpha(vec![0], vec![3]));
assert_eq!(idx.turbine.start, idx.theta + 1);
assert_eq!(idx.spillage.start, idx.turbine.end);
assert_eq!(idx.diversion.start, idx.spillage.end);
assert_eq!(idx.thermal.start, idx.diversion.end);
assert_eq!(idx.line_fwd.start, idx.thermal.end);
assert_eq!(idx.line_rev.start, idx.line_fwd.end);
assert_eq!(idx.deficit.start, idx.line_rev.end);
assert_eq!(idx.excess.start, idx.deficit.end);
assert_eq!(idx.generation.start, idx.excess.end);
assert_eq!(idx.generation.len(), 1);
}
#[test]
fn test_diversion_range_n3_l0_k2() {
let idx = StageIndexer::with_equipment(&eq(3, 0, 0, 0, 1, 2, false), &fpha(vec![], vec![]));
assert_eq!(idx.diversion.start, idx.spillage.end);
assert_eq!(idx.diversion.len(), 3 * 2);
assert_eq!(idx.thermal.start, idx.diversion.end);
}
#[test]
fn test_diversion_zero_hydros() {
let idx = StageIndexer::with_equipment(&eq(0, 0, 1, 0, 1, 1, false), &fpha(vec![], vec![]));
assert!(idx.diversion.is_empty());
}
#[test]
fn z_inflow_range_new_constructor() {
let idx = StageIndexer::new(3, 2);
assert_eq!(idx.z_inflow, 9..12);
assert_eq!(idx.z_inflow.len(), idx.hydro_count);
}
#[test]
fn z_inflow_range_zero_hydros() {
let idx = StageIndexer::new(0, 0);
assert!(idx.z_inflow.is_empty());
assert!(idx.z_inflow_rows.is_empty());
assert_eq!(idx.z_inflow_row_start, 0);
}
#[test]
fn z_inflow_row_fields() {
let idx = StageIndexer::new(5, 1);
assert_eq!(idx.z_inflow_rows, 10..15);
assert_eq!(idx.z_inflow_row_start, 10);
assert_eq!(idx.z_inflow.len(), 5);
}
#[test]
fn z_inflow_range_with_equipment() {
let idx = StageIndexer::with_equipment(&eq(2, 1, 1, 1, 1, 1, false), &fpha(vec![], vec![]));
assert_eq!(idx.z_inflow, 4..6);
assert_eq!(idx.z_inflow.len(), 2);
assert_eq!(idx.z_inflow_rows, 4..6);
assert_eq!(idx.z_inflow_row_start, 4);
}
#[test]
fn z_inflow_single_hydro_no_lags() {
let idx = StageIndexer::new(1, 0);
assert_eq!(idx.z_inflow, 1..2);
assert_eq!(idx.z_inflow.len(), 1);
}
#[test]
fn nonzero_mask_default_is_empty() {
let idx = StageIndexer::new(4, 6);
assert!(
idx.nonzero_state_indices.is_empty(),
"default mask must be empty (dense path)"
);
}
#[test]
fn nonzero_mask_mixed_ar_orders() {
let mut idx = StageIndexer::new(4, 6);
idx.set_nonzero_mask(&[0, 1, 3, 6]);
assert_eq!(
idx.nonzero_state_indices.len(),
14,
"mask length: 4 storage + 0 + 1 + 3 + 6 = 14"
);
assert_eq!(&idx.nonzero_state_indices[..4], &[0, 1, 2, 3]);
assert_eq!(
&idx.nonzero_state_indices[4..],
&[5, 6, 7, 10, 11, 14, 15, 19, 23, 27]
);
assert!(idx.nonzero_state_indices.windows(2).all(|w| w[0] < w[1]));
}
#[test]
fn nonzero_mask_zero_par_order() {
let mut idx = StageIndexer::new(3, 0);
idx.set_nonzero_mask(&[0, 0, 0]);
assert_eq!(idx.nonzero_state_indices.len(), 3);
assert_eq!(&idx.nonzero_state_indices, &[0, 1, 2]);
}
#[test]
fn nonzero_mask_all_full_order() {
let mut idx = StageIndexer::new(2, 3);
idx.set_nonzero_mask(&[3, 3]);
assert_eq!(idx.nonzero_state_indices.len(), 8);
assert_eq!(idx.nonzero_state_indices.len(), idx.n_state);
}
#[test]
fn nonzero_mask_par_a_includes_full_psi_stride() {
let mut idx = StageIndexer::new(2, 12);
idx.set_nonzero_mask(&[4, 12]);
assert_eq!(
idx.nonzero_state_indices.len(),
18,
"PAR-A hydro must contribute all 12 lag slots to the cut mask; \
omitting slots 4..12 (where ψ̂/12 lives) shifts the cut hyperplane \
above the LP value at the visited state (over-estimating cuts)."
);
assert_eq!(&idx.nonzero_state_indices[..2], &[0, 1]);
assert!(
idx.nonzero_state_indices.contains(&25),
"lag-11 slot for hydro 1 (the trailing PAR-A annual slot) must be in the mask"
);
assert!(
!idx.nonzero_state_indices.contains(&10),
"lag-4 slot for hydro 0 (classical AR(4)) must NOT be in the mask"
);
assert!(idx.nonzero_state_indices.windows(2).all(|w| w[0] < w[1]));
}
}