use std::collections::HashMap;
use cobre_core::entities::hydro::HydroGenerationModel;
use cobre_core::{
Bus, CascadeTopology, EntityId, Hydro, Line, LoadModel, ResolvedBounds, ResolvedPenalties,
Stage, System, Thermal,
};
use cobre_solver::StageTemplate;
use cobre_stochastic::normal::precompute::PrecomputedNormalLp;
use cobre_stochastic::par::precompute::PrecomputedParLp;
use crate::error::SddpError;
use crate::indexer::StageIndexer;
use crate::inflow_method::InflowNonNegativityMethod;
#[derive(Debug, Clone)]
pub struct PatchBuffer {
pub indices: Vec<usize>,
pub lower: Vec<f64>,
pub upper: Vec<f64>,
hydro_count: usize,
max_par_order: usize,
load_bus_count: usize,
max_blocks: usize,
active_load_patches: usize,
}
impl PatchBuffer {
#[must_use]
pub fn new(
hydro_count: usize,
max_par_order: usize,
n_load_buses: usize,
max_blocks: usize,
) -> Self {
let capacity = hydro_count * (2 + max_par_order) + n_load_buses * max_blocks;
Self {
indices: vec![0; capacity],
lower: vec![0.0; capacity],
upper: vec![0.0; capacity],
hydro_count,
max_par_order,
load_bus_count: n_load_buses,
max_blocks,
active_load_patches: 0,
}
}
pub fn fill_forward_patches(
&mut self,
indexer: &StageIndexer,
state: &[f64],
noise: &[f64],
base_row: usize,
) {
debug_assert_eq!(
state.len(),
indexer.n_state,
"state slice length {got} != n_state {expected}",
got = state.len(),
expected = indexer.n_state,
);
debug_assert!(
noise.len() == indexer.hydro_count || noise.is_empty(),
"noise slice length {got} must equal hydro_count {expected} or be empty",
got = noise.len(),
expected = indexer.hydro_count,
);
let n = self.hydro_count;
let l = self.max_par_order;
for (h, &sv) in state[..n].iter().enumerate() {
self.indices[h] = h;
self.lower[h] = sv;
self.upper[h] = sv;
}
for lag in 0..l {
for h in 0..n {
let slot = n + lag * n + h;
self.indices[slot] = slot;
self.lower[slot] = state[slot];
self.upper[slot] = state[slot];
}
}
let cat3_start = n * (1 + l);
for (h, &nv) in noise.iter().enumerate() {
let slot = cat3_start + h;
self.indices[slot] = ar_dynamics_row_offset(base_row, h);
self.lower[slot] = nv;
self.upper[slot] = nv;
}
}
pub fn fill_state_patches(&mut self, indexer: &StageIndexer, state: &[f64]) {
debug_assert_eq!(
state.len(),
indexer.n_state,
"state slice length {got} != n_state {expected}",
got = state.len(),
expected = indexer.n_state,
);
let n = self.hydro_count;
let l = self.max_par_order;
for (h, &sv) in state[..n].iter().enumerate() {
self.indices[h] = h;
self.lower[h] = sv;
self.upper[h] = sv;
}
for lag in 0..l {
for h in 0..n {
let slot = n + lag * n + h;
self.indices[slot] = slot;
self.lower[slot] = state[slot];
self.upper[slot] = state[slot];
}
}
}
pub fn fill_load_patches(
&mut self,
load_row_start: usize,
n_blocks: usize,
load_rhs: &[f64],
bus_positions: &[usize],
) {
debug_assert_eq!(
load_rhs.len(),
self.load_bus_count * n_blocks,
"load_rhs length {got} != load_bus_count*n_blocks {expected}",
got = load_rhs.len(),
expected = self.load_bus_count * n_blocks,
);
debug_assert_eq!(
bus_positions.len(),
self.load_bus_count,
"bus_positions length {got} != load_bus_count {expected}",
got = bus_positions.len(),
expected = self.load_bus_count,
);
debug_assert!(
n_blocks <= self.max_blocks,
"n_blocks {n_blocks} exceeds max_blocks {mb}",
mb = self.max_blocks,
);
let cat4_start = self.hydro_count * (2 + self.max_par_order);
let mut slot = cat4_start;
for (i, &bus_pos) in bus_positions.iter().enumerate() {
for blk in 0..n_blocks {
let row = load_row_start + bus_pos * n_blocks + blk;
let rhs = load_rhs[i * n_blocks + blk];
self.indices[slot] = row;
self.lower[slot] = rhs;
self.upper[slot] = rhs;
slot += 1;
}
}
self.active_load_patches = self.load_bus_count * n_blocks;
}
#[must_use]
#[inline]
pub fn forward_patch_count(&self) -> usize {
self.hydro_count * (2 + self.max_par_order) + self.active_load_patches
}
#[must_use]
#[inline]
pub fn state_patch_count(&self) -> usize {
self.hydro_count * (1 + self.max_par_order)
}
}
#[must_use]
#[inline]
pub fn ar_dynamics_row_offset(base_row: usize, hydro_index: usize) -> usize {
base_row + hydro_index
}
#[derive(Debug, Clone)]
pub struct StageTemplates {
pub templates: Vec<StageTemplate>,
pub base_rows: Vec<usize>,
pub noise_scale: Vec<f64>,
pub zeta_per_stage: Vec<f64>,
pub block_hours_per_stage: Vec<Vec<f64>>,
pub n_hydros: usize,
pub load_balance_row_starts: Vec<usize>,
pub n_load_buses: usize,
pub load_bus_indices: Vec<usize>,
}
const M3S_TO_HM3: f64 = 3_600.0 / 1_000_000.0;
struct TemplateBuildCtx<'a> {
hydros: &'a [Hydro],
thermals: &'a [Thermal],
lines: &'a [Line],
buses: &'a [Bus],
load_models: &'a [LoadModel],
cascade: &'a CascadeTopology,
bounds: &'a ResolvedBounds,
penalties: &'a ResolvedPenalties,
hydro_pos: HashMap<EntityId, usize>,
bus_pos: HashMap<EntityId, usize>,
inflow_method: &'a InflowNonNegativityMethod,
par_lp: &'a PrecomputedParLp,
n_hydros: usize,
n_thermals: usize,
n_lines: usize,
n_buses: usize,
max_par_order: usize,
has_penalty: bool,
}
struct StageLayout {
n_blks: usize,
n_h: usize,
n_b: usize,
lag_order: usize,
col_turbine_start: usize,
col_spillage_start: usize,
col_thermal_start: usize,
col_line_fwd_start: usize,
col_line_rev_start: usize,
col_deficit_start: usize,
col_excess_start: usize,
col_inflow_slack_start: usize,
num_cols: usize,
row_water_balance_start: usize,
row_load_balance_start: usize,
num_rows: usize,
n_state: usize,
n_dual_relevant: usize,
zeta: f64,
}
impl StageLayout {
fn new(ctx: &TemplateBuildCtx<'_>, stage: &Stage) -> Self {
let n_blks = stage.blocks.len();
let idx = StageIndexer::new(ctx.n_hydros, ctx.max_par_order);
let decision_start = idx.theta + 1;
let col_turbine_start = decision_start;
let col_spillage_start = col_turbine_start + ctx.n_hydros * n_blks;
let col_thermal_start = col_spillage_start + ctx.n_hydros * n_blks;
let col_line_fwd_start = col_thermal_start + ctx.n_thermals * n_blks;
let col_line_rev_start = col_line_fwd_start + ctx.n_lines * n_blks;
let col_deficit_start = col_line_rev_start + ctx.n_lines * n_blks;
let col_excess_start = col_deficit_start + ctx.n_buses * n_blks;
let col_excess_end = col_excess_start + ctx.n_buses * n_blks;
let col_inflow_slack_start = col_excess_end;
let n_slack_cols = if ctx.has_penalty { ctx.n_hydros } else { 0 };
let num_cols = col_excess_end + n_slack_cols;
let n_state = idx.n_state;
let n_dual_relevant = n_state;
let row_water_balance_start = n_dual_relevant;
let row_load_balance_start = row_water_balance_start + ctx.n_hydros;
let num_rows = row_load_balance_start + ctx.n_buses * n_blks;
let total_stage_hours: f64 = stage.blocks.iter().map(|b| b.duration_hours).sum();
let zeta = total_stage_hours * M3S_TO_HM3;
Self {
n_blks,
n_h: ctx.n_hydros,
n_b: ctx.n_buses,
lag_order: ctx.max_par_order,
col_turbine_start,
col_spillage_start,
col_thermal_start,
col_line_fwd_start,
col_line_rev_start,
col_deficit_start,
col_excess_start,
col_inflow_slack_start,
num_cols,
row_water_balance_start,
row_load_balance_start,
num_rows,
n_state,
n_dual_relevant,
zeta,
}
}
}
fn fill_stage_columns(
ctx: &TemplateBuildCtx<'_>,
stage: &Stage,
stage_idx: usize,
layout: &StageLayout,
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let idx = StageIndexer::new(ctx.n_hydros, ctx.max_par_order);
let mut col_lower = vec![0.0_f64; layout.num_cols];
let mut col_upper = vec![f64::INFINITY; layout.num_cols];
let mut objective = vec![0.0_f64; layout.num_cols];
for (h_idx, _hydro) in ctx.hydros.iter().enumerate() {
let hb = ctx.bounds.hydro_bounds(h_idx, stage_idx);
col_lower[h_idx] = hb.min_storage_hm3;
col_upper[h_idx] = hb.max_storage_hm3;
col_lower[idx.storage_in.start + h_idx] = f64::NEG_INFINITY;
col_upper[idx.storage_in.start + h_idx] = f64::INFINITY;
}
for lag_col in idx.inflow_lags.clone() {
col_lower[lag_col] = f64::NEG_INFINITY;
col_upper[lag_col] = f64::INFINITY;
}
col_lower[idx.theta] = 0.0;
col_upper[idx.theta] = f64::INFINITY;
objective[idx.theta] = 1.0;
for (h_idx, _hydro) in ctx.hydros.iter().enumerate() {
let hb = ctx.bounds.hydro_bounds(h_idx, stage_idx);
for blk in 0..layout.n_blks {
let col = layout.col_turbine_start + h_idx * layout.n_blks + blk;
col_lower[col] = hb.min_turbined_m3s;
col_upper[col] = hb.max_turbined_m3s;
}
}
for (h_idx, _hydro) in ctx.hydros.iter().enumerate() {
let hp = ctx.penalties.hydro_penalties(h_idx, stage_idx);
for blk in 0..layout.n_blks {
let col = layout.col_spillage_start + h_idx * layout.n_blks + blk;
col_upper[col] = f64::INFINITY;
let block_hours = stage.blocks[blk].duration_hours;
objective[col] = hp.spillage_cost * block_hours;
}
}
for (t_idx, thermal) in ctx.thermals.iter().enumerate() {
let tb = ctx.bounds.thermal_bounds(t_idx, stage_idx);
let marginal_cost_per_mwh = thermal
.cost_segments
.first()
.map_or(0.0, |seg| seg.cost_per_mwh);
for blk in 0..layout.n_blks {
let col = layout.col_thermal_start + t_idx * layout.n_blks + blk;
col_lower[col] = tb.min_generation_mw;
col_upper[col] = tb.max_generation_mw;
let block_hours = stage.blocks[blk].duration_hours;
objective[col] = marginal_cost_per_mwh * block_hours;
}
}
for (l_idx, line) in ctx.lines.iter().enumerate() {
let lb = ctx.bounds.line_bounds(l_idx, stage_idx);
let lp = ctx.penalties.line_penalties(l_idx, stage_idx);
for blk in 0..layout.n_blks {
let col_fwd = layout.col_line_fwd_start + l_idx * layout.n_blks + blk;
let col_rev = layout.col_line_rev_start + l_idx * layout.n_blks + blk;
col_upper[col_fwd] = lb.direct_mw;
col_upper[col_rev] = lb.reverse_mw;
let block_hours = stage.blocks[blk].duration_hours;
objective[col_fwd] = lp.exchange_cost * block_hours;
objective[col_rev] = lp.exchange_cost * block_hours;
let _ = line;
}
}
for (b_idx, bus) in ctx.buses.iter().enumerate() {
let bp = ctx.penalties.bus_penalties(b_idx, stage_idx);
let deficit_cost = bus
.deficit_segments
.last()
.map_or(0.0, |seg| seg.cost_per_mwh);
for blk in 0..layout.n_blks {
let col_def = layout.col_deficit_start + b_idx * layout.n_blks + blk;
let col_exc = layout.col_excess_start + b_idx * layout.n_blks + blk;
col_upper[col_def] = f64::INFINITY;
col_upper[col_exc] = f64::INFINITY;
let block_hours = stage.blocks[blk].duration_hours;
objective[col_def] = deficit_cost * block_hours;
objective[col_exc] = bp.excess_cost * block_hours;
}
}
if ctx.has_penalty {
let total_stage_hours: f64 = stage.blocks.iter().map(|b| b.duration_hours).sum();
let penalty_cost = ctx.inflow_method.penalty_cost().unwrap_or(0.0);
let obj_coeff = penalty_cost * total_stage_hours;
for h_idx in 0..layout.n_h {
let col = layout.col_inflow_slack_start + h_idx;
objective[col] = obj_coeff;
}
}
(col_lower, col_upper, objective)
}
fn fill_stage_rows(
ctx: &TemplateBuildCtx<'_>,
stage: &Stage,
stage_idx: usize,
layout: &StageLayout,
) -> (Vec<f64>, Vec<f64>) {
let mut row_lower = vec![0.0_f64; layout.num_rows];
let mut row_upper = vec![0.0_f64; layout.num_rows];
for h_idx in 0..layout.n_h {
let row = layout.row_water_balance_start + h_idx;
let base = if ctx.par_lp.n_stages() > 0 && ctx.par_lp.n_hydros() == layout.n_h {
ctx.par_lp.deterministic_base(stage_idx, h_idx)
} else {
0.0
};
row_lower[row] = layout.zeta * base;
row_upper[row] = layout.zeta * base;
}
for (b_idx, bus) in ctx.buses.iter().enumerate() {
let mean_mw = ctx
.load_models
.iter()
.find(|lm| lm.bus_id == bus.id && lm.stage_id == stage.id)
.map_or(0.0, |lm| lm.mean_mw);
for blk in 0..layout.n_blks {
let row = layout.row_load_balance_start + b_idx * layout.n_blks + blk;
row_lower[row] = mean_mw;
row_upper[row] = mean_mw;
}
}
(row_lower, row_upper)
}
fn fill_state_and_water_entries(
ctx: &TemplateBuildCtx<'_>,
stage: &Stage,
stage_idx: usize,
layout: &StageLayout,
col_entries: &mut [Vec<(usize, f64)>],
) {
let idx = StageIndexer::new(ctx.n_hydros, ctx.max_par_order);
let n_h = layout.n_h;
let n_blks = layout.n_blks;
let lag_order = layout.lag_order;
let zeta = layout.zeta;
let row_water = layout.row_water_balance_start;
for h in 0..n_h {
let col = idx.storage_in.start + h;
col_entries[col].push((h, 1.0));
}
for lag in 0..lag_order {
for h in 0..n_h {
let col = idx.inflow_lags.start + lag * n_h + h;
let row = n_h + lag * n_h + h;
col_entries[col].push((row, 1.0));
}
}
for h_idx in 0..n_h {
let hydro = &ctx.hydros[h_idx];
let row = row_water + h_idx;
col_entries[h_idx].push((row, 1.0));
col_entries[idx.storage_in.start + h_idx].push((row, -1.0));
for blk in 0..n_blks {
let tau_h = stage.blocks[blk].duration_hours * M3S_TO_HM3;
let col_turbine = layout.col_turbine_start + h_idx * n_blks + blk;
col_entries[col_turbine].push((row, tau_h));
let col_spillage = layout.col_spillage_start + h_idx * n_blks + blk;
col_entries[col_spillage].push((row, tau_h));
for &up_id in ctx.cascade.upstream(hydro.id) {
if let Some(&u_idx) = ctx.hydro_pos.get(&up_id) {
col_entries[layout.col_turbine_start + u_idx * n_blks + blk]
.push((row, -tau_h));
col_entries[layout.col_spillage_start + u_idx * n_blks + blk]
.push((row, -tau_h));
}
}
}
if ctx.par_lp.n_stages() > 0 && ctx.par_lp.n_hydros() == n_h {
let psi = ctx.par_lp.psi_slice(stage_idx, h_idx);
for (lag, &psi_val) in psi.iter().enumerate() {
if psi_val != 0.0 && lag < lag_order {
let col = idx.inflow_lags.start + lag * n_h + h_idx;
col_entries[col].push((row, -zeta * psi_val));
}
}
}
}
if ctx.has_penalty {
for h_idx in 0..n_h {
let col = layout.col_inflow_slack_start + h_idx;
let row = row_water + h_idx;
col_entries[col].push((row, -zeta));
}
}
}
fn fill_load_balance_entries(
ctx: &TemplateBuildCtx<'_>,
layout: &StageLayout,
col_entries: &mut [Vec<(usize, f64)>],
) {
let n_blks = layout.n_blks;
let row_load = layout.row_load_balance_start;
for (h_idx, hydro) in ctx.hydros.iter().enumerate() {
let rho = match &hydro.generation_model {
HydroGenerationModel::ConstantProductivity {
productivity_mw_per_m3s,
}
| HydroGenerationModel::LinearizedHead {
productivity_mw_per_m3s,
} => *productivity_mw_per_m3s,
HydroGenerationModel::Fpha => unreachable!(
"FPHA validation guard at the top of build_stage_templates prevents reaching this arm"
),
};
if let Some(&b_idx) = ctx.bus_pos.get(&hydro.bus_id) {
for blk in 0..n_blks {
let row = row_load + b_idx * n_blks + blk;
let col = layout.col_turbine_start + h_idx * n_blks + blk;
col_entries[col].push((row, rho));
}
}
}
for (t_idx, thermal) in ctx.thermals.iter().enumerate() {
if let Some(&b_idx) = ctx.bus_pos.get(&thermal.bus_id) {
for blk in 0..n_blks {
let row = row_load + b_idx * n_blks + blk;
let col = layout.col_thermal_start + t_idx * n_blks + blk;
col_entries[col].push((row, 1.0));
}
}
}
for (l_idx, line) in ctx.lines.iter().enumerate() {
let src_idx = ctx.bus_pos.get(&line.source_bus_id).copied();
let tgt_idx = ctx.bus_pos.get(&line.target_bus_id).copied();
for blk in 0..n_blks {
let col_fwd = layout.col_line_fwd_start + l_idx * n_blks + blk;
let col_rev = layout.col_line_rev_start + l_idx * n_blks + blk;
if let Some(tgt) = tgt_idx {
let row = row_load + tgt * n_blks + blk;
col_entries[col_fwd].push((row, 1.0));
col_entries[col_rev].push((row, -1.0));
}
if let Some(src) = src_idx {
let row = row_load + src * n_blks + blk;
col_entries[col_fwd].push((row, -1.0));
col_entries[col_rev].push((row, 1.0));
}
}
}
for b_idx in 0..layout.n_b {
for blk in 0..n_blks {
let row = row_load + b_idx * n_blks + blk;
let col_def = layout.col_deficit_start + b_idx * n_blks + blk;
let col_exc = layout.col_excess_start + b_idx * n_blks + blk;
col_entries[col_def].push((row, 1.0));
col_entries[col_exc].push((row, -1.0));
}
}
}
fn build_stage_matrix_entries(
ctx: &TemplateBuildCtx<'_>,
stage: &Stage,
stage_idx: usize,
layout: &StageLayout,
) -> Vec<Vec<(usize, f64)>> {
let mut col_entries: Vec<Vec<(usize, f64)>> = vec![Vec::new(); layout.num_cols];
fill_state_and_water_entries(ctx, stage, stage_idx, layout, &mut col_entries);
fill_load_balance_entries(ctx, layout, &mut col_entries);
for entries in &mut col_entries {
entries.sort_unstable_by_key(|&(row, _)| row);
}
col_entries
}
fn assemble_csc(col_entries: &[Vec<(usize, f64)>]) -> (Vec<i32>, Vec<i32>, Vec<f64>) {
let num_cols = col_entries.len();
let total_nz: usize = col_entries.iter().map(Vec::len).sum();
let mut col_starts = Vec::with_capacity(num_cols + 1);
let mut row_indices = Vec::with_capacity(total_nz);
let mut values = Vec::with_capacity(total_nz);
let mut offset: i32 = 0;
for entries in col_entries {
col_starts.push(offset);
for &(row, val) in entries {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
row_indices.push(row as i32);
values.push(val);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
{
offset += entries.len() as i32;
}
}
col_starts.push(offset);
(col_starts, row_indices, values)
}
fn build_single_stage_template(
ctx: &TemplateBuildCtx<'_>,
stage: &Stage,
stage_idx: usize,
) -> (StageTemplate, usize, usize) {
let layout = StageLayout::new(ctx, stage);
let stage_base_row = layout.row_water_balance_start;
let load_balance_row_start = layout.row_load_balance_start;
let (col_lower, col_upper, objective) = fill_stage_columns(ctx, stage, stage_idx, &layout);
let (row_lower, row_upper) = fill_stage_rows(ctx, stage, stage_idx, &layout);
let col_entries = build_stage_matrix_entries(ctx, stage, stage_idx, &layout);
let (col_starts, row_indices, values) = assemble_csc(&col_entries);
let n_transfer = ctx.n_hydros * ctx.max_par_order;
let total_nz = col_entries.iter().map(Vec::len).sum();
let template = StageTemplate {
num_cols: layout.num_cols,
num_rows: layout.num_rows,
num_nz: total_nz,
col_starts,
row_indices,
values,
col_lower,
col_upper,
objective,
row_lower,
row_upper,
n_state: layout.n_state,
n_transfer,
n_dual_relevant: layout.n_dual_relevant,
n_hydro: layout.n_h,
max_par_order: layout.lag_order,
};
(template, stage_base_row, load_balance_row_start)
}
fn compute_noise_scale(
study_stages: &[&Stage],
n_hydros: usize,
par_lp: &PrecomputedParLp,
) -> (Vec<f64>, Vec<f64>, Vec<Vec<f64>>) {
let n = study_stages.len();
let mut noise_scale = vec![0.0_f64; n * n_hydros];
let mut zeta_per_stage = Vec::with_capacity(n);
let mut block_hours_per_stage = Vec::with_capacity(n);
for (s_idx, stage) in study_stages.iter().enumerate() {
let total_hours: f64 = stage.blocks.iter().map(|b| b.duration_hours).sum();
let zeta_s = total_hours * M3S_TO_HM3;
zeta_per_stage.push(zeta_s);
block_hours_per_stage.push(stage.blocks.iter().map(|b| b.duration_hours).collect());
for h_idx in 0..n_hydros {
let sigma = if par_lp.n_stages() > 0 && par_lp.n_hydros() == n_hydros {
par_lp.sigma(s_idx, h_idx)
} else {
0.0
};
noise_scale[s_idx * n_hydros + h_idx] = zeta_s * sigma;
}
}
(noise_scale, zeta_per_stage, block_hours_per_stage)
}
fn collect_load_bus_indices(system: &System, bus_pos: &HashMap<EntityId, usize>) -> Vec<usize> {
let mut ids: Vec<EntityId> = system
.load_models()
.iter()
.filter(|m| m.std_mw > 0.0)
.map(|m| m.bus_id)
.collect();
ids.sort_unstable_by_key(|id| id.0);
ids.dedup();
ids.iter()
.filter_map(|id| bus_pos.get(id).copied())
.collect()
}
pub fn build_stage_templates(
system: &System,
inflow_method: &InflowNonNegativityMethod,
par_lp: &PrecomputedParLp,
normal_lp: &PrecomputedNormalLp,
) -> Result<StageTemplates, SddpError> {
for hydro in system.hydros() {
if matches!(hydro.generation_model, HydroGenerationModel::Fpha) {
return Err(SddpError::Validation(format!(
"hydro plant '{}' (id={}) uses FPHA generation model which is not yet implemented",
hydro.name, hydro.id
)));
}
}
let study_stages: Vec<_> = system.stages().iter().filter(|s| s.id >= 0).collect();
let hydros = system.hydros();
let n_hydros = hydros.len();
if study_stages.is_empty() {
return Ok(StageTemplates {
templates: Vec::new(),
base_rows: Vec::new(),
noise_scale: Vec::new(),
zeta_per_stage: Vec::new(),
block_hours_per_stage: Vec::new(),
n_hydros,
load_balance_row_starts: Vec::new(),
n_load_buses: 0,
load_bus_indices: Vec::new(),
});
}
let buses = system.buses();
let hydro_pos: HashMap<EntityId, usize> =
hydros.iter().enumerate().map(|(i, h)| (h.id, i)).collect();
let bus_pos: HashMap<EntityId, usize> =
buses.iter().enumerate().map(|(i, b)| (b.id, i)).collect();
let load_bus_indices = collect_load_bus_indices(system, &bus_pos);
let n_load_buses = load_bus_indices.len();
debug_assert!(
normal_lp.n_entities() == 0 || normal_lp.n_entities() == n_load_buses,
"PrecomputedNormalLp has {} entities but system has {} stochastic load buses",
normal_lp.n_entities(),
n_load_buses
);
let max_par_order: usize = system
.inflow_models()
.iter()
.filter(|m| m.stage_id >= 0)
.map(|m| m.ar_coefficients.len())
.max()
.unwrap_or(0);
let ctx = TemplateBuildCtx {
hydros,
thermals: system.thermals(),
lines: system.lines(),
buses,
load_models: system.load_models(),
cascade: system.cascade(),
bounds: system.bounds(),
penalties: system.penalties(),
hydro_pos,
bus_pos,
inflow_method,
par_lp,
n_hydros,
n_thermals: system.thermals().len(),
n_lines: system.lines().len(),
n_buses: buses.len(),
max_par_order,
has_penalty: n_hydros > 0 && inflow_method.has_slack_columns(),
};
let n_study = study_stages.len();
let mut templates = Vec::with_capacity(n_study);
let mut base_rows = Vec::with_capacity(n_study);
let mut load_balance_row_starts = Vec::with_capacity(n_study);
for (stage_idx, stage) in study_stages.iter().enumerate() {
let (template, stage_base_row, load_balance_row_start) =
build_single_stage_template(&ctx, stage, stage_idx);
templates.push(template);
base_rows.push(stage_base_row);
load_balance_row_starts.push(load_balance_row_start);
}
let (noise_scale, zeta_per_stage, block_hours_per_stage) =
compute_noise_scale(&study_stages, n_hydros, par_lp);
Ok(StageTemplates {
templates,
base_rows,
noise_scale,
zeta_per_stage,
block_hours_per_stage,
n_hydros,
load_balance_row_starts,
n_load_buses,
load_bus_indices,
})
}
#[cfg(test)]
mod tests {
use super::{PatchBuffer, ar_dynamics_row_offset};
use crate::indexer::StageIndexer;
fn idx(n: usize, l: usize) -> StageIndexer {
StageIndexer::new(n, l)
}
#[test]
fn new_3_2_sizes_to_12() {
let buf = PatchBuffer::new(3, 2, 0, 0);
assert_eq!(buf.indices.len(), 12);
assert_eq!(buf.lower.len(), 12);
assert_eq!(buf.upper.len(), 12);
}
#[test]
fn new_160_12_sizes_to_2240() {
let buf = PatchBuffer::new(160, 12, 0, 0);
assert_eq!(buf.indices.len(), 2240);
assert_eq!(buf.lower.len(), 2240);
assert_eq!(buf.upper.len(), 2240);
}
#[test]
fn new_zero_lags_sizes_to_2n() {
let buf = PatchBuffer::new(5, 0, 0, 0);
assert_eq!(buf.indices.len(), 10); }
#[test]
fn new_zero_hydros_sizes_to_zero() {
let buf = PatchBuffer::new(0, 0, 0, 0);
assert_eq!(buf.indices.len(), 0);
}
#[test]
fn forward_patch_count_matches_buffer_len() {
let buf = PatchBuffer::new(3, 2, 0, 0);
assert_eq!(buf.forward_patch_count(), buf.indices.len());
assert_eq!(buf.forward_patch_count(), 12);
}
#[test]
fn state_patch_count_is_n_times_one_plus_l() {
let buf = PatchBuffer::new(3, 2, 0, 0);
assert_eq!(buf.state_patch_count(), 9);
}
#[test]
fn state_patch_count_zero_lags() {
let buf = PatchBuffer::new(4, 0, 0, 0);
assert_eq!(buf.state_patch_count(), 4);
}
#[test]
fn ar_dynamics_row_offset_adds_base_plus_hydro() {
assert_eq!(ar_dynamics_row_offset(100, 0), 100);
assert_eq!(ar_dynamics_row_offset(100, 1), 101);
assert_eq!(ar_dynamics_row_offset(100, 2), 102);
}
#[test]
fn ar_dynamics_row_offset_zero_base() {
assert_eq!(ar_dynamics_row_offset(0, 7), 7);
}
#[test]
fn fill_forward_patches_category1_indices() {
let mut buf = PatchBuffer::new(3, 2, 0, 0);
let state = [10.0, 20.0, 30.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let noise = [0.1, 0.2, 0.3];
buf.fill_forward_patches(&idx(3, 2), &state, &noise, 50);
assert_eq!(buf.indices[0], 0);
assert_eq!(buf.indices[1], 1);
assert_eq!(buf.indices[2], 2);
}
#[test]
fn fill_forward_patches_category2_indices() {
let mut buf = PatchBuffer::new(3, 2, 0, 0);
let state = [10.0, 20.0, 30.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let noise = [0.1, 0.2, 0.3];
buf.fill_forward_patches(&idx(3, 2), &state, &noise, 50);
assert_eq!(buf.indices[3], 3); assert_eq!(buf.indices[4], 4); assert_eq!(buf.indices[5], 5); assert_eq!(buf.indices[6], 6); assert_eq!(buf.indices[7], 7); assert_eq!(buf.indices[8], 8); }
#[test]
fn fill_forward_patches_category3_indices() {
let mut buf = PatchBuffer::new(3, 2, 0, 0);
let state = [10.0, 20.0, 30.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let noise = [0.1, 0.2, 0.3];
buf.fill_forward_patches(&idx(3, 2), &state, &noise, 50);
assert_eq!(buf.indices[9], 50); assert_eq!(buf.indices[10], 51); assert_eq!(buf.indices[11], 52); }
#[test]
fn fill_forward_patches_category1_values() {
let mut buf = PatchBuffer::new(3, 2, 0, 0);
let state = [10.0, 20.0, 30.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let noise = [0.1, 0.2, 0.3];
buf.fill_forward_patches(&idx(3, 2), &state, &noise, 50);
assert_eq!(buf.lower[0], 10.0);
assert_eq!(buf.upper[0], 10.0);
assert_eq!(buf.lower[1], 20.0);
assert_eq!(buf.upper[1], 20.0);
assert_eq!(buf.lower[2], 30.0);
assert_eq!(buf.upper[2], 30.0);
}
#[test]
fn fill_forward_patches_category2_values() {
let mut buf = PatchBuffer::new(3, 2, 0, 0);
let state = [10.0, 20.0, 30.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let noise = [0.1, 0.2, 0.3];
buf.fill_forward_patches(&idx(3, 2), &state, &noise, 50);
assert_eq!(buf.lower[3], 1.0);
assert_eq!(buf.upper[3], 1.0);
assert_eq!(buf.lower[4], 2.0);
assert_eq!(buf.upper[4], 2.0);
assert_eq!(buf.lower[5], 3.0);
assert_eq!(buf.upper[5], 3.0);
assert_eq!(buf.lower[6], 4.0);
assert_eq!(buf.upper[6], 4.0);
assert_eq!(buf.lower[7], 5.0);
assert_eq!(buf.upper[7], 5.0);
assert_eq!(buf.lower[8], 6.0);
assert_eq!(buf.upper[8], 6.0);
}
#[test]
fn fill_forward_patches_category3_values() {
let mut buf = PatchBuffer::new(3, 2, 0, 0);
let state = [10.0, 20.0, 30.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let noise = [0.1, 0.2, 0.3];
buf.fill_forward_patches(&idx(3, 2), &state, &noise, 50);
assert_eq!(buf.lower[9], 0.1);
assert_eq!(buf.upper[9], 0.1);
assert_eq!(buf.lower[10], 0.2);
assert_eq!(buf.upper[10], 0.2);
assert_eq!(buf.lower[11], 0.3);
assert_eq!(buf.upper[11], 0.3);
}
#[test]
fn fill_forward_patches_all_equality_constraints() {
let mut buf = PatchBuffer::new(3, 2, 0, 0);
let state = [10.0, 20.0, 30.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let noise = [0.1, 0.2, 0.3];
buf.fill_forward_patches(&idx(3, 2), &state, &noise, 50);
for i in 0..buf.forward_patch_count() {
assert_eq!(
buf.lower[i],
buf.upper[i],
"patch {i}: lower {lo} != upper {up}",
lo = buf.lower[i],
up = buf.upper[i],
);
}
}
#[test]
fn fill_state_patches_count_is_n_times_one_plus_l() {
let mut buf = PatchBuffer::new(3, 2, 0, 0);
let state = [10.0, 20.0, 30.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
buf.fill_state_patches(&idx(3, 2), &state);
assert_eq!(buf.state_patch_count(), 9);
}
#[test]
fn fill_state_patches_category1_correct() {
let mut buf = PatchBuffer::new(3, 2, 0, 0);
let state = [10.0, 20.0, 30.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
buf.fill_state_patches(&idx(3, 2), &state);
assert_eq!(buf.indices[0], 0);
assert_eq!(buf.lower[0], 10.0);
assert_eq!(buf.upper[0], 10.0);
assert_eq!(buf.indices[1], 1);
assert_eq!(buf.lower[1], 20.0);
assert_eq!(buf.upper[1], 20.0);
assert_eq!(buf.indices[2], 2);
assert_eq!(buf.lower[2], 30.0);
assert_eq!(buf.upper[2], 30.0);
}
#[test]
fn fill_state_patches_category2_correct() {
let mut buf = PatchBuffer::new(3, 2, 0, 0);
let state = [10.0, 20.0, 30.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
buf.fill_state_patches(&idx(3, 2), &state);
assert_eq!(buf.indices[3], 3);
assert_eq!(buf.lower[3], 1.0);
assert_eq!(buf.upper[3], 1.0);
assert_eq!(buf.indices[8], 8);
assert_eq!(buf.lower[8], 6.0);
assert_eq!(buf.upper[8], 6.0);
}
#[test]
fn fill_state_patches_equality_constraints_in_active_range() {
let mut buf = PatchBuffer::new(3, 2, 0, 0);
let state = [10.0, 20.0, 30.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
buf.fill_state_patches(&idx(3, 2), &state);
let active = buf.state_patch_count();
for i in 0..active {
assert_eq!(
buf.lower[i],
buf.upper[i],
"state patch {i}: lower {lo} != upper {up}",
lo = buf.lower[i],
up = buf.upper[i],
);
}
}
#[test]
fn forward_patches_zero_lags_only_storage_and_noise() {
let n = 2;
let mut buf = PatchBuffer::new(n, 0, 0, 0);
let state = [5.0, 7.0]; let noise = [0.5, 0.6];
buf.fill_forward_patches(&idx(n, 0), &state, &noise, 10);
assert_eq!(buf.forward_patch_count(), 4);
assert_eq!(buf.indices[0], 0);
assert_eq!(buf.lower[0], 5.0);
assert_eq!(buf.indices[1], 1);
assert_eq!(buf.lower[1], 7.0);
assert_eq!(buf.indices[2], 10); assert_eq!(buf.lower[2], 0.5);
assert_eq!(buf.indices[3], 11); assert_eq!(buf.lower[3], 0.6);
}
#[test]
fn state_patches_zero_lags_only_storage() {
let n = 3;
let mut buf = PatchBuffer::new(n, 0, 0, 0);
let state = [1.0, 2.0, 3.0]; buf.fill_state_patches(&idx(n, 0), &state);
assert_eq!(buf.state_patch_count(), 3);
assert_eq!(buf.indices[0], 0);
assert_eq!(buf.lower[0], 1.0);
assert_eq!(buf.upper[0], 1.0);
assert_eq!(buf.indices[1], 1);
assert_eq!(buf.lower[1], 2.0);
assert_eq!(buf.indices[2], 2);
assert_eq!(buf.lower[2], 3.0);
}
#[test]
fn production_scale_forward_patch_count() {
let buf = PatchBuffer::new(160, 12, 0, 0);
assert_eq!(buf.forward_patch_count(), 2240);
assert_eq!(buf.indices.len(), 2240);
}
#[test]
#[allow(clippy::cast_precision_loss)] fn production_scale_fill_forward_patches_smoke() {
let n = 160;
let l = 12;
let mut buf = PatchBuffer::new(n, l, 0, 0);
let n_state = n * (1 + l);
let state: Vec<f64> = (0..n_state).map(|i| i as f64).collect();
let noise: Vec<f64> = (0..n).map(|h| h as f64 * 0.01).collect();
buf.fill_forward_patches(&StageIndexer::new(n, l), &state, &noise, 500);
assert_eq!(buf.indices[0], 0);
assert_eq!(buf.lower[0], 0.0);
assert_eq!(buf.indices[2080], 500); assert_eq!(buf.lower[2080], 0.0); assert_eq!(buf.indices[2239], 659); assert_eq!(buf.lower[2239], 159.0 * 0.01);
for i in 0..buf.forward_patch_count() {
assert_eq!(buf.lower[i], buf.upper[i], "patch {i} not equality");
}
}
#[test]
fn clone_and_debug() {
let buf = PatchBuffer::new(3, 2, 0, 0);
let cloned = buf.clone();
assert_eq!(cloned.indices.len(), buf.indices.len());
let s = format!("{buf:?}");
assert!(s.contains("PatchBuffer"));
}
#[test]
fn new_with_load_allocates_correct_capacity() {
let buf = PatchBuffer::new(2, 1, 1, 3);
assert_eq!(buf.indices.len(), 9); assert_eq!(buf.lower.len(), 9);
assert_eq!(buf.upper.len(), 9);
}
#[test]
fn fill_load_patches_correct_indices() {
let mut buf = PatchBuffer::new(0, 0, 2, 2);
let load_rhs = [300.0_f64, 280.0, 500.0, 450.0];
let bus_positions = [0_usize, 1];
buf.fill_load_patches(100, 2, &load_rhs, &bus_positions);
assert_eq!(buf.indices[0], 100); assert_eq!(buf.indices[1], 101); assert_eq!(buf.indices[2], 102); assert_eq!(buf.indices[3], 103); }
#[test]
fn fill_load_patches_correct_values() {
let mut buf = PatchBuffer::new(0, 0, 2, 2);
let load_rhs = [300.0_f64, 280.0, 500.0, 450.0];
let bus_positions = [0_usize, 1];
buf.fill_load_patches(100, 2, &load_rhs, &bus_positions);
assert_eq!(buf.lower[0], 300.0);
assert_eq!(buf.upper[0], 300.0);
assert_eq!(buf.lower[1], 280.0);
assert_eq!(buf.upper[1], 280.0);
assert_eq!(buf.lower[2], 500.0);
assert_eq!(buf.upper[2], 500.0);
assert_eq!(buf.lower[3], 450.0);
assert_eq!(buf.upper[3], 450.0);
}
#[test]
fn fill_load_patches_equality_constraints() {
let mut buf = PatchBuffer::new(3, 2, 2, 3);
let state = [10.0, 20.0, 30.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let noise = [0.1, 0.2, 0.3];
buf.fill_forward_patches(&idx(3, 2), &state, &noise, 50);
let load_rhs = [100.0_f64, 90.0, 80.0, 200.0, 190.0, 180.0];
let bus_positions = [0_usize, 1];
buf.fill_load_patches(20, 3, &load_rhs, &bus_positions);
let count = buf.forward_patch_count();
for i in 0..count {
assert_eq!(
buf.lower[i],
buf.upper[i],
"patch {i}: lower {lo} != upper {up}",
lo = buf.lower[i],
up = buf.upper[i],
);
}
}
#[test]
fn forward_patch_count_includes_load() {
let mut buf = PatchBuffer::new(3, 2, 2, 3);
let state = [10.0, 20.0, 30.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let noise = [0.1, 0.2, 0.3];
buf.fill_forward_patches(&idx(3, 2), &state, &noise, 50);
let load_rhs = [100.0_f64, 90.0, 80.0, 200.0, 190.0, 180.0];
let bus_positions = [0_usize, 1];
buf.fill_load_patches(20, 3, &load_rhs, &bus_positions);
assert_eq!(buf.forward_patch_count(), 18); }
#[test]
fn state_patch_count_excludes_load() {
let mut buf = PatchBuffer::new(3, 2, 2, 3);
let state = [10.0, 20.0, 30.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
buf.fill_state_patches(&idx(3, 2), &state);
let load_rhs = [100.0_f64, 90.0, 80.0, 200.0, 190.0, 180.0];
let bus_positions = [0_usize, 1];
buf.fill_load_patches(20, 3, &load_rhs, &bus_positions);
assert_eq!(buf.state_patch_count(), 9);
}
#[test]
fn zero_load_buses_no_category4() {
let mut buf = PatchBuffer::new(3, 2, 0, 0);
let state = [10.0, 20.0, 30.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let noise = [0.1, 0.2, 0.3];
buf.fill_forward_patches(&idx(3, 2), &state, &noise, 50);
assert_eq!(buf.forward_patch_count(), 12); }
use super::build_stage_templates;
use crate::inflow_method::InflowNonNegativityMethod;
use cobre_core::{
Bus, BusStagePenalties, ContractStageBounds, DeficitSegment, EntityId, HydroStageBounds,
HydroStagePenalties, LineStageBounds, LineStagePenalties, NcsStagePenalties,
PumpingStageBounds, ResolvedBounds, ResolvedPenalties, SystemBuilder, ThermalStageBounds,
};
use cobre_stochastic::normal::precompute::PrecomputedNormalLp;
use cobre_stochastic::par::precompute::PrecomputedParLp;
fn no_penalty_config() -> InflowNonNegativityMethod {
InflowNonNegativityMethod::None
}
fn penalty_config(cost: f64) -> InflowNonNegativityMethod {
InflowNonNegativityMethod::Penalty { cost }
}
fn default_hydro_bounds() -> HydroStageBounds {
HydroStageBounds {
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
max_diversion_m3s: None,
filling_inflow_m3s: 0.0,
water_withdrawal_m3s: 0.0,
}
}
fn default_hydro_penalties() -> HydroStagePenalties {
HydroStagePenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
fpha_turbined_cost: 0.0,
storage_violation_below_cost: 0.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn one_bus_system(n_stages: usize) -> cobre_core::System {
use chrono::NaiveDate;
use cobre_core::scenario::LoadModel;
use cobre_core::temporal::{
Block, BlockMode, NoiseMethod, ScenarioSourceConfig, Stage, StageRiskConfig,
StageStateConfig,
};
let bus = Bus {
id: EntityId(1),
name: "B1".to_string(),
deficit_segments: vec![DeficitSegment {
depth_mw: None,
cost_per_mwh: 500.0,
}],
excess_cost: 0.0,
};
let stages: Vec<Stage> = (0..n_stages)
.map(|i| Stage {
index: i,
id: i as i32,
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
season_id: None,
blocks: vec![Block {
index: 0,
name: "S".to_string(),
duration_hours: 744.0,
}],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: false,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
})
.collect();
let load_models: Vec<LoadModel> = (0..n_stages)
.map(|i| LoadModel {
bus_id: EntityId(1),
stage_id: i as i32,
mean_mw: 100.0,
std_mw: 0.0,
})
.collect();
let n_st = n_stages.max(1);
let bounds = ResolvedBounds::new(
0,
0,
0,
0,
0,
n_st,
default_hydro_bounds(),
ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 0.0,
},
LineStageBounds {
direct_mw: 0.0,
reverse_mw: 0.0,
},
PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 0.0,
},
ContractStageBounds {
min_mw: 0.0,
max_mw: 0.0,
price_per_mwh: 0.0,
},
);
let penalties = ResolvedPenalties::new(
0,
1,
0,
0,
n_st,
default_hydro_penalties(),
BusStagePenalties { excess_cost: 0.0 },
LineStagePenalties { exchange_cost: 0.0 },
NcsStagePenalties {
curtailment_cost: 0.0,
},
);
SystemBuilder::new()
.buses(vec![bus])
.stages(stages)
.load_models(load_models)
.bounds(bounds)
.penalties(penalties)
.build()
.expect("one_bus_system: valid")
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::too_many_lines
)]
fn one_hydro_system(n_stages: usize, lag_order: usize) -> cobre_core::System {
use chrono::NaiveDate;
use cobre_core::entities::hydro::{Hydro, HydroGenerationModel, HydroPenalties};
use cobre_core::scenario::{InflowModel, LoadModel};
use cobre_core::temporal::{
Block, BlockMode, NoiseMethod, ScenarioSourceConfig, Stage, StageRiskConfig,
StageStateConfig,
};
let bus = Bus {
id: EntityId(1),
name: "B1".to_string(),
deficit_segments: vec![DeficitSegment {
depth_mw: None,
cost_per_mwh: 500.0,
}],
excess_cost: 0.0,
};
let hydro = Hydro {
id: EntityId(2),
name: "H1".to_string(),
bus_id: EntityId(1),
downstream_id: None,
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: HydroGenerationModel::ConstantProductivity {
productivity_mw_per_m3s: 2.5,
},
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
diversion: None,
filling: None,
penalties: HydroPenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
fpha_turbined_cost: 0.0,
storage_violation_below_cost: 0.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
},
};
let stages: Vec<Stage> = (0..n_stages)
.map(|i| Stage {
index: i,
id: i as i32,
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
season_id: None,
blocks: vec![Block {
index: 0,
name: "S".to_string(),
duration_hours: 744.0,
}],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: lag_order > 0,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
})
.collect();
let ar_coefficients: Vec<f64> = (0..lag_order).map(|_| 0.5).collect();
let inflow_models: Vec<InflowModel> = (0..n_stages)
.map(|i| InflowModel {
hydro_id: EntityId(2),
stage_id: i as i32,
mean_m3s: 80.0,
std_m3s: 20.0,
ar_coefficients: ar_coefficients.clone(),
residual_std_ratio: 1.0,
})
.collect();
let load_models: Vec<LoadModel> = (0..n_stages)
.map(|i| LoadModel {
bus_id: EntityId(1),
stage_id: i as i32,
mean_mw: 100.0,
std_mw: 0.0,
})
.collect();
let n_st = n_stages.max(1);
let bounds = ResolvedBounds::new(
1,
0,
0,
0,
0,
n_st,
default_hydro_bounds(),
ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 0.0,
},
LineStageBounds {
direct_mw: 0.0,
reverse_mw: 0.0,
},
PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 0.0,
},
ContractStageBounds {
min_mw: 0.0,
max_mw: 0.0,
price_per_mwh: 0.0,
},
);
let penalties = ResolvedPenalties::new(
1,
1,
0,
0,
n_st,
default_hydro_penalties(),
BusStagePenalties { excess_cost: 0.0 },
LineStagePenalties { exchange_cost: 0.0 },
NcsStagePenalties {
curtailment_cost: 0.0,
},
);
SystemBuilder::new()
.buses(vec![bus])
.hydros(vec![hydro])
.stages(stages)
.inflow_models(inflow_models)
.load_models(load_models)
.bounds(bounds)
.penalties(penalties)
.build()
.expect("one_hydro_system: valid")
}
#[test]
fn empty_stages_returns_empty() {
let system = one_bus_system(0);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
assert!(result.templates.is_empty());
assert!(result.base_rows.is_empty());
}
#[test]
fn one_stage_one_template() {
let system = one_bus_system(1);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
assert_eq!(result.templates.len(), 1);
assert_eq!(result.base_rows.len(), 1);
}
#[test]
fn num_cols_formula_no_hydro_no_thermal_no_line() {
let system = one_bus_system(1);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
assert_eq!(t.num_cols, 3, "num_cols mismatch for no-entity system");
}
#[test]
fn num_cols_formula_one_hydro_lag_zero() {
let system = one_hydro_system(1, 0);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
assert_eq!(t.num_cols, 7, "num_cols mismatch for N=1 L=0");
}
#[test]
fn num_cols_formula_one_hydro_lag_two() {
let system = one_hydro_system(1, 2);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
assert_eq!(t.num_cols, 9, "num_cols mismatch for N=1 L=2");
}
#[test]
fn num_rows_formula_no_hydro() {
let system = one_bus_system(1);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
assert_eq!(t.num_rows, 1, "num_rows mismatch for no-hydro system");
}
#[test]
fn num_rows_formula_one_hydro_lag_zero() {
let system = one_hydro_system(1, 0);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
assert_eq!(t.num_rows, 3, "num_rows mismatch for N=1 L=0");
}
#[test]
fn num_rows_formula_one_hydro_lag_two() {
let system = one_hydro_system(1, 2);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
assert_eq!(t.num_rows, 5, "num_rows mismatch for N=1 L=2");
}
#[test]
fn n_state_matches_indexer() {
let system = one_hydro_system(1, 2);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
let expected = StageIndexer::new(1, 2).n_state;
assert_eq!(t.n_state, expected, "n_state must match StageIndexer");
}
#[test]
fn n_transfer_is_n_times_lag_order() {
let system = one_hydro_system(1, 2);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
assert_eq!(t.n_transfer, 2, "n_transfer = N*L");
}
#[test]
fn n_dual_relevant_equals_n_state_for_constant_productivity() {
let system = one_hydro_system(1, 2);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
assert_eq!(
t.n_dual_relevant, t.n_state,
"n_dual_relevant must equal n_state for constant-productivity hydros"
);
}
#[test]
fn base_row_is_n_dual_relevant() {
let system = one_hydro_system(2, 2);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
for (s, (&br, t)) in result.base_rows.iter().zip(&result.templates).enumerate() {
assert_eq!(
br, t.n_dual_relevant,
"base_rows[{s}] must equal n_dual_relevant"
);
}
}
#[test]
fn csc_col_starts_monotone_nondecreasing() {
let system = one_hydro_system(1, 1);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
for w in t.col_starts.windows(2) {
assert!(w[0] <= w[1], "col_starts not monotone: {} > {}", w[0], w[1]);
}
assert_eq!(t.col_starts.len(), t.num_cols + 1);
}
#[test]
#[allow(clippy::cast_sign_loss)]
fn csc_row_indices_in_range() {
let system = one_hydro_system(1, 1);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
for &r in &t.row_indices {
assert!(
r >= 0 && (r as usize) < t.num_rows,
"row index {r} out of range [0, {})",
t.num_rows
);
}
}
#[test]
#[allow(clippy::cast_sign_loss)]
fn csc_nz_count_matches_col_starts() {
let system = one_hydro_system(1, 1);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
assert_eq!(
t.num_nz,
*t.col_starts.last().unwrap() as usize,
"num_nz must equal col_starts[num_cols]"
);
assert_eq!(
t.row_indices.len(),
t.num_nz,
"row_indices.len() must equal num_nz"
);
assert_eq!(t.values.len(), t.num_nz, "values.len() must equal num_nz");
}
#[test]
fn theta_column_has_unit_objective() {
let lag_order = 2;
let system = one_hydro_system(1, lag_order);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
let theta_col = StageIndexer::new(1, lag_order).theta;
assert_eq!(
t.objective[theta_col], 1.0,
"theta column objective must be 1.0"
);
}
#[test]
fn spillage_objective_nonzero_for_nonzero_penalty() {
let system = one_hydro_system(1, 0);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
let spill_col = 4;
assert!(
t.objective[spill_col] > 0.0,
"spillage objective must be > 0 when spillage_cost > 0"
);
}
#[test]
fn load_balance_rhs_matches_load_model_mean_mw() {
let system = one_bus_system(1);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
let load_row = 0;
assert_eq!(
t.row_lower[load_row], 100.0,
"row_lower for load balance must be mean_mw"
);
assert_eq!(
t.row_upper[load_row], 100.0,
"row_upper for load balance must be mean_mw"
);
}
#[test]
fn multiple_stages_produce_same_count_templates_and_base_rows() {
let system = one_hydro_system(3, 1);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
assert_eq!(result.templates.len(), 3);
assert_eq!(result.base_rows.len(), 3);
}
#[test]
fn stage_templates_clone_and_debug() {
let system = one_hydro_system(1, 0);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let cloned = result.clone();
assert_eq!(cloned.templates.len(), result.templates.len());
let s = format!("{result:?}");
assert!(s.contains("StageTemplates"));
}
#[test]
#[allow(clippy::too_many_lines)]
fn test_fpha_model_rejected() {
use chrono::NaiveDate;
use cobre_core::entities::hydro::{Hydro, HydroGenerationModel, HydroPenalties};
use cobre_core::scenario::{InflowModel, LoadModel};
use cobre_core::temporal::{
Block, BlockMode, NoiseMethod, ScenarioSourceConfig, Stage, StageRiskConfig,
StageStateConfig,
};
let bus = Bus {
id: EntityId(1),
name: "B1".to_string(),
deficit_segments: vec![DeficitSegment {
depth_mw: None,
cost_per_mwh: 500.0,
}],
excess_cost: 0.0,
};
let hydro = Hydro {
id: EntityId(5),
name: "Tucurui".to_string(),
bus_id: EntityId(1),
downstream_id: None,
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: HydroGenerationModel::Fpha,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
diversion: None,
filling: None,
penalties: HydroPenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
fpha_turbined_cost: 0.0,
storage_violation_below_cost: 0.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
},
};
let stages: Vec<Stage> = vec![Stage {
index: 0,
id: 0,
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).expect("valid date"),
end_date: NaiveDate::from_ymd_opt(2024, 2, 1).expect("valid date"),
season_id: None,
blocks: vec![Block {
index: 0,
name: "S".to_string(),
duration_hours: 744.0,
}],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
}];
let inflow_models: Vec<InflowModel> = vec![InflowModel {
hydro_id: EntityId(5),
stage_id: 0,
mean_m3s: 80.0,
std_m3s: 20.0,
ar_coefficients: vec![],
residual_std_ratio: 1.0,
}];
let load_models: Vec<LoadModel> = vec![LoadModel {
bus_id: EntityId(1),
stage_id: 0,
mean_mw: 100.0,
std_mw: 0.0,
}];
let bounds = ResolvedBounds::new(
1,
0,
0,
0,
0,
1,
default_hydro_bounds(),
ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 0.0,
},
LineStageBounds {
direct_mw: 0.0,
reverse_mw: 0.0,
},
PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 0.0,
},
ContractStageBounds {
min_mw: 0.0,
max_mw: 0.0,
price_per_mwh: 0.0,
},
);
let penalties = ResolvedPenalties::new(
1,
1,
0,
0,
1,
default_hydro_penalties(),
BusStagePenalties { excess_cost: 0.0 },
LineStagePenalties { exchange_cost: 0.0 },
NcsStagePenalties {
curtailment_cost: 0.0,
},
);
let system = cobre_core::SystemBuilder::new()
.buses(vec![bus])
.hydros(vec![hydro])
.stages(stages)
.inflow_models(inflow_models)
.load_models(load_models)
.bounds(bounds)
.penalties(penalties)
.build()
.expect("test_fpha_model_rejected: valid system");
let err = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect_err("FPHA plant must cause an error");
let msg = err.to_string();
assert!(
msg.contains("Tucurui"),
"error message must contain the hydro plant name 'Tucurui', got: {msg}"
);
assert!(
msg.to_lowercase().contains("fpha"),
"error message must mention 'FPHA', got: {msg}"
);
}
#[test]
fn test_constant_productivity_accepted() {
let system = one_hydro_system(1, 0);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
);
assert!(
result.is_ok(),
"ConstantProductivity system must return Ok, got: {result:?}"
);
assert_eq!(
result.expect("accepted").templates.len(),
1,
"one study stage → one template"
);
}
#[test]
fn test_penalty_columns_added() {
let system = one_hydro_system(1, 0);
let without = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let with_p = build_stage_templates(
&system,
&penalty_config(1000.0),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
assert_eq!(
with_p.templates[0].num_cols,
without.templates[0].num_cols + 1,
"penalty method must add exactly n_hydros extra columns"
);
}
#[test]
fn test_penalty_columns_added_3_hydros() {
let system = one_bus_system(1);
let with_p = build_stage_templates(
&system,
&penalty_config(1000.0),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let without = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
assert_eq!(
with_p.templates[0].num_cols, without.templates[0].num_cols,
"no slack columns when n_hydros == 0, even with penalty config"
);
}
#[test]
fn test_penalty_objective_coefficient() {
let system = one_hydro_system(1, 0);
let config = penalty_config(1000.0);
let result = build_stage_templates(
&system,
&config,
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
let slack_col = t.num_cols - 1; let expected_obj = 1000.0 * 744.0;
assert!(
(t.objective[slack_col] - expected_obj).abs() < 1e-9,
"expected objective {expected_obj}, got {}",
t.objective[slack_col]
);
}
#[test]
fn test_no_penalty_columns_when_none() {
let system = one_hydro_system(1, 2);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
assert_eq!(t.num_cols, 9, "method=none must not add extra columns");
assert_eq!(t.num_rows, 5, "method=none must not add extra rows");
}
#[test]
#[allow(clippy::cast_sign_loss)]
fn test_penalty_slack_in_water_balance() {
let system = one_hydro_system(1, 0);
let config = penalty_config(1000.0);
let result = build_stage_templates(
&system,
&config,
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
let slack_col = t.num_cols - 1;
let water_balance_row = 1_usize;
let col_start = t.col_starts[slack_col] as usize;
let col_end = t.col_starts[slack_col + 1] as usize;
let found = t.row_indices[col_start..col_end]
.iter()
.zip(&t.values[col_start..col_end])
.any(|(&r, &v)| r as usize == water_balance_row && v.abs() > 1e-12);
assert!(
found,
"slack column must have a non-zero entry in the water balance row"
);
}
#[test]
fn test_penalty_slack_bounds() {
let system = one_hydro_system(1, 0);
let config = penalty_config(1000.0);
let result = build_stage_templates(
&system,
&config,
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
let slack_col = t.num_cols - 1;
assert_eq!(t.col_lower[slack_col], 0.0, "slack lower bound must be 0.0");
assert!(
t.col_upper[slack_col].is_infinite() && t.col_upper[slack_col] > 0.0,
"slack upper bound must be +infinity"
);
}
#[test]
#[allow(clippy::cast_sign_loss)]
fn test_penalty_water_balance_coefficient_value() {
let system = one_hydro_system(1, 0);
let config = penalty_config(1000.0);
let result = build_stage_templates(
&system,
&config,
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let t = &result.templates[0];
let slack_col = t.num_cols - 1;
let water_balance_row = 1_usize; let zeta = 744.0 * (3_600.0 / 1_000_000.0);
let expected_coeff = -zeta;
let col_start = t.col_starts[slack_col] as usize;
let col_end = t.col_starts[slack_col + 1] as usize;
let coeff = t.row_indices[col_start..col_end]
.iter()
.zip(&t.values[col_start..col_end])
.find(|&(&r, _)| r as usize == water_balance_row)
.map(|(_, &v)| v);
assert!(
coeff.is_some(),
"slack column must have an entry in the water balance row"
);
let coeff = coeff.unwrap();
assert!(
(coeff - expected_coeff).abs() < 1e-9,
"expected coefficient {expected_coeff:.9}, got {coeff:.9}"
);
}
#[test]
fn test_penalty_multi_stage_consistent() {
let system = one_hydro_system(3, 1);
let config = penalty_config(2000.0);
let result = build_stage_templates(
&system,
&config,
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
assert_eq!(result.templates.len(), 3);
let base_cols = result.templates[0].num_cols;
for t in &result.templates {
assert_eq!(
t.num_cols, base_cols,
"all stages must have the same column count"
);
}
}
#[test]
fn test_penalty_slack_absorbs_negative_inflow() {
use cobre_solver::{HighsSolver, RowBatch, SolverInterface};
let system = one_hydro_system(1, 0);
let config = penalty_config(1000.0);
let result = build_stage_templates(
&system,
&config,
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
let template = &result.templates[0];
let col_inflow_slack_start = template.num_cols - 1;
let base_row = result.base_rows[0];
let water_balance_row = base_row;
let mut solver = HighsSolver::new().expect("HighsSolver::new must succeed");
solver.load_model(template);
let empty_cuts = RowBatch {
num_rows: 0,
row_starts: vec![0_i32],
col_indices: vec![],
values: vec![],
row_lower: vec![],
row_upper: vec![],
};
solver.add_rows(&empty_cuts);
let initial_storage = 100.0_f64;
let negative_noise = -5.0_f64;
solver.set_row_bounds(
&[0, water_balance_row],
&[initial_storage, negative_noise],
&[initial_storage, negative_noise],
);
let view = solver
.solve()
.expect("LP must be feasible with inflow slack active");
let primal = view.primal;
assert!(
primal[col_inflow_slack_start] > 0.0,
"inflow slack must be positive when noise is negative, got {}",
primal[col_inflow_slack_start]
);
assert!(
view.objective > 0.0,
"objective must include a positive penalty contribution, got {}",
view.objective
);
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::too_many_lines
)]
fn two_bus_system_with_stochastic_load(
n_stages: usize,
n_hydros_in_system: usize,
n_blocks: usize,
) -> cobre_core::System {
use chrono::NaiveDate;
use cobre_core::entities::hydro::{Hydro, HydroGenerationModel, HydroPenalties};
use cobre_core::scenario::{InflowModel, LoadModel};
use cobre_core::temporal::{
Block, BlockMode, NoiseMethod, ScenarioSourceConfig, Stage, StageRiskConfig,
StageStateConfig,
};
let bus1 = Bus {
id: EntityId(10),
name: "B1".to_string(),
deficit_segments: vec![DeficitSegment {
depth_mw: None,
cost_per_mwh: 500.0,
}],
excess_cost: 0.0,
};
let bus2 = Bus {
id: EntityId(20),
name: "B2".to_string(),
deficit_segments: vec![DeficitSegment {
depth_mw: None,
cost_per_mwh: 500.0,
}],
excess_cost: 0.0,
};
let blocks: Vec<_> = (0..n_blocks)
.map(|b| Block {
index: b,
name: format!("B{b}"),
duration_hours: 240.0,
})
.collect();
let stages: Vec<Stage> = (0..n_stages)
.map(|i| Stage {
index: i,
id: i as i32,
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
season_id: None,
blocks: blocks.clone(),
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: false,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
})
.collect();
let hydros: Vec<Hydro> = (0..n_hydros_in_system)
.map(|h| Hydro {
id: EntityId((h + 100) as i32),
name: format!("H{h}"),
bus_id: EntityId(10),
downstream_id: None,
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: HydroGenerationModel::ConstantProductivity {
productivity_mw_per_m3s: 1.0,
},
min_turbined_m3s: 0.0,
max_turbined_m3s: 50.0,
min_generation_mw: 0.0,
max_generation_mw: 50.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
diversion: None,
filling: None,
penalties: HydroPenalties {
spillage_cost: 0.0,
diversion_cost: 0.0,
fpha_turbined_cost: 0.0,
storage_violation_below_cost: 0.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
},
})
.collect();
let inflow_models: Vec<InflowModel> = hydros
.iter()
.flat_map(|h| {
(0..n_stages).map(move |s| InflowModel {
hydro_id: h.id,
stage_id: s as i32,
mean_m3s: 50.0,
std_m3s: 10.0,
ar_coefficients: vec![],
residual_std_ratio: 1.0,
})
})
.collect();
let load_models: Vec<LoadModel> = (0..n_stages)
.flat_map(|s| {
[
LoadModel {
bus_id: EntityId(10),
stage_id: s as i32,
mean_mw: 80.0,
std_mw: 0.0, },
LoadModel {
bus_id: EntityId(20),
stage_id: s as i32,
mean_mw: 120.0,
std_mw: 15.0, },
]
})
.collect();
let n_st = n_stages.max(1);
let bounds = ResolvedBounds::new(
n_hydros_in_system,
0,
0,
0,
0,
n_st,
default_hydro_bounds(),
ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 0.0,
},
LineStageBounds {
direct_mw: 0.0,
reverse_mw: 0.0,
},
PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 0.0,
},
ContractStageBounds {
min_mw: 0.0,
max_mw: 0.0,
price_per_mwh: 0.0,
},
);
let penalties = ResolvedPenalties::new(
n_hydros_in_system,
2,
0,
0,
n_st,
default_hydro_penalties(),
BusStagePenalties { excess_cost: 0.0 },
LineStagePenalties { exchange_cost: 0.0 },
NcsStagePenalties {
curtailment_cost: 0.0,
},
);
let mut builder = SystemBuilder::new()
.buses(vec![bus1, bus2])
.stages(stages)
.load_models(load_models)
.bounds(bounds)
.penalties(penalties);
if !hydros.is_empty() {
builder = builder.hydros(hydros).inflow_models(inflow_models);
}
builder.build().expect("two_bus_system: valid")
}
#[test]
fn stage_templates_load_balance_row_starts_correct() {
let system = two_bus_system_with_stochastic_load(2, 2, 3);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
assert_eq!(
result.load_balance_row_starts.len(),
result.templates.len(),
"load_balance_row_starts length must match templates length"
);
let expected_row_start = result.base_rows[0] + 2; assert_eq!(
result.load_balance_row_starts[0], expected_row_start,
"load_balance_row_starts[0] must equal row_water_balance_start + n_hydros"
);
assert_eq!(
result.load_balance_row_starts[0], result.load_balance_row_starts[1],
"identical stages share the same load balance row start"
);
}
#[test]
fn stage_templates_n_load_buses_matches_stochastic_buses() {
let system = two_bus_system_with_stochastic_load(1, 0, 1);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
assert_eq!(
result.n_load_buses, 1,
"only B2 has std_mw > 0 → n_load_buses must be 1"
);
assert_eq!(
result.load_bus_indices.len(),
1,
"load_bus_indices must have exactly one entry"
);
assert_eq!(
result.load_bus_indices[0], 1,
"B2 is at buses slice index 1 (buses are [B1(10), B2(20)])"
);
}
#[test]
fn stage_templates_no_load_buses_gives_zero() {
let system = one_bus_system(2);
let result = build_stage_templates(
&system,
&no_penalty_config(),
&PrecomputedParLp::default(),
&PrecomputedNormalLp::default(),
)
.expect("no FPHA plants");
assert_eq!(
result.n_load_buses, 0,
"system with std_mw = 0 everywhere must give n_load_buses = 0"
);
assert!(
result.load_bus_indices.is_empty(),
"load_bus_indices must be empty when n_load_buses = 0"
);
assert_eq!(
result.load_balance_row_starts.len(),
result.templates.len(),
"load_balance_row_starts length must always match templates length"
);
}
}