use std::ops::Range;
use cobre_comm::Communicator;
use cobre_solver::{RowBatch, SolverError, SolverInterface};
use cobre_stochastic::{OpeningTree, StochasticContext, evaluate_par_batch, solve_par_noise_batch};
use crate::{
cut::FutureCostFunction,
error::SddpError,
forward::build_cut_row_batch_into,
indexer::StageIndexer,
inflow_method::InflowNonNegativityMethod,
lp_builder::COST_SCALE_FACTOR,
lp_builder::PatchBuffer,
noise::{NcsNoiseOffsets, compute_effective_eta, transform_ncs_noise},
risk_measure::RiskMeasure,
};
use cobre_solver::StageTemplate;
pub struct LbEvalSpec<'a> {
pub template: &'a StageTemplate,
pub base_row: usize,
pub noise_scale: &'a [f64],
pub n_hydros: usize,
pub opening_tree: &'a OpeningTree,
pub risk_measure: &'a RiskMeasure,
pub stochastic: Option<&'a StochasticContext>,
pub n_load_buses: usize,
pub ncs_max_gen: &'a [f64],
pub ncs_allow_curtailment: &'a [bool],
pub block_count: usize,
pub ncs_generation: Range<usize>,
pub inflow_method: &'a InflowNonNegativityMethod,
}
#[allow(clippy::struct_field_names)]
pub struct LbEvalScratch {
pub noise_buf: Vec<f64>,
pub z_inflow_rhs_buf: Vec<f64>,
pub ncs_col_upper_buf: Vec<f64>,
pub ncs_col_indices_buf: Vec<usize>,
pub ncs_col_lower_buf: Vec<f64>,
pub lag_matrix_buf: Vec<f64>,
pub eta_floor_buf: Vec<f64>,
pub par_inflow_buf: Vec<f64>,
pub effective_eta_buf: Vec<f64>,
pub zero_targets_buf: Vec<f64>,
pub uniform_prob_buf: Vec<f64>,
}
impl LbEvalScratch {
#[must_use]
pub fn new() -> Self {
Self {
noise_buf: Vec::new(),
z_inflow_rhs_buf: Vec::new(),
ncs_col_upper_buf: Vec::new(),
ncs_col_indices_buf: Vec::new(),
ncs_col_lower_buf: Vec::new(),
lag_matrix_buf: Vec::new(),
eta_floor_buf: Vec::new(),
par_inflow_buf: Vec::new(),
effective_eta_buf: Vec::new(),
zero_targets_buf: Vec::new(),
uniform_prob_buf: Vec::new(),
}
}
}
impl Default for LbEvalScratch {
fn default() -> Self {
Self::new()
}
}
pub struct LbEvalScratchBundle<'a> {
pub patch_buf: &'a mut PatchBuffer,
pub lb_cut_batch: &'a mut cobre_solver::RowBatch,
pub lb_cut_row_map: Option<&'a mut crate::cut::CutRowMap>,
pub lb_scratch: &'a mut LbEvalScratch,
}
impl<'a> LbEvalScratchBundle<'a> {
pub fn from_scratch_fields(
patch_buf: &'a mut PatchBuffer,
lb_cut_batch: &'a mut cobre_solver::RowBatch,
lb_cut_row_map: Option<&'a mut crate::cut::CutRowMap>,
lb_scratch: &'a mut LbEvalScratch,
) -> Self {
Self {
patch_buf,
lb_cut_batch,
lb_cut_row_map,
lb_scratch,
}
}
}
fn lb_init_rank0<S: SolverInterface>(
solver: &mut S,
fcf: &FutureCostFunction,
spec: &LbEvalSpec<'_>,
indexer: &StageIndexer,
lb_cut_batch: &mut RowBatch,
lb_cut_row_map: Option<&mut crate::cut::CutRowMap>,
scratch: &mut LbEvalScratch,
) {
scratch.ncs_col_upper_buf.clear();
scratch.ncs_col_indices_buf.clear();
scratch.ncs_col_lower_buf.clear();
if let Some(stoch) = spec.stochastic {
let n_stochastic_ncs = stoch.n_stochastic_ncs();
if n_stochastic_ncs > 0 && !spec.ncs_generation.is_empty() {
for ncs_idx in 0..n_stochastic_ncs {
for blk in 0..spec.block_count {
scratch
.ncs_col_indices_buf
.push(spec.ncs_generation.start + ncs_idx * spec.block_count + blk);
}
}
}
}
scratch.par_inflow_buf.resize(spec.n_hydros, 0.0);
if let Some(row_map) = lb_cut_row_map {
if row_map.total_cut_rows() == 0 {
solver.load_model(spec.template);
}
crate::forward::append_new_cuts_to_lp(
solver,
fcf,
0,
indexer,
&spec.template.col_scale,
row_map,
lb_cut_batch,
);
} else {
build_cut_row_batch_into(lb_cut_batch, fcf, 0, indexer, &spec.template.col_scale);
solver.load_model(spec.template);
if lb_cut_batch.num_rows > 0 {
solver.add_rows(lb_cut_batch);
}
}
}
fn lb_evaluate_stage_0<S: SolverInterface>(
solver: &mut S,
spec: &LbEvalSpec<'_>,
patch_buf: &mut PatchBuffer,
initial_state: &[f64],
indexer: &StageIndexer,
scratch: &mut LbEvalScratch,
) -> Result<Vec<f64>, SddpError> {
let n_openings = spec.opening_tree.n_openings(0);
let n_hydros = spec.n_hydros;
let base_row = spec.base_row;
let needs_truncation = matches!(
spec.inflow_method,
InflowNonNegativityMethod::Truncation | InflowNonNegativityMethod::TruncationWithPenalty
);
let par_lp_opt = spec.stochastic.map(StochasticContext::par);
let truncation_par = if needs_truncation {
par_lp_opt.filter(|p| p.n_stages() > 0 && p.n_hydros() == n_hydros)
} else {
None
};
if let Some(par_lp) = truncation_par {
let max_order = indexer.max_par_order;
let lag_len = max_order * n_hydros;
scratch.lag_matrix_buf.resize(lag_len, 0.0);
for h in 0..n_hydros {
for l in 0..max_order {
scratch.lag_matrix_buf[l * n_hydros + h] =
initial_state[indexer.inflow_lags.start + l * n_hydros + h];
}
}
scratch.eta_floor_buf.resize(n_hydros, f64::NEG_INFINITY);
scratch.zero_targets_buf.clear();
scratch.zero_targets_buf.resize(n_hydros, 0.0);
solve_par_noise_batch(
par_lp,
0,
&scratch.lag_matrix_buf,
&scratch.zero_targets_buf,
&mut scratch.eta_floor_buf,
);
}
let mut objectives = Vec::with_capacity(n_openings);
for opening_idx in 0..n_openings {
let raw_noise = spec.opening_tree.opening(0, opening_idx);
scratch.noise_buf.clear();
scratch.z_inflow_rhs_buf.clear();
if let Some(par_lp) = truncation_par {
evaluate_par_batch(
par_lp,
0,
&scratch.lag_matrix_buf,
raw_noise,
&mut scratch.par_inflow_buf,
);
}
compute_effective_eta(
raw_noise,
n_hydros,
*spec.inflow_method,
&scratch.par_inflow_buf,
&scratch.eta_floor_buf,
&mut scratch.effective_eta_buf,
);
for (h, &eta_eff) in scratch.effective_eta_buf.iter().enumerate() {
scratch
.noise_buf
.push(spec.template.row_lower[base_row + h] + spec.noise_scale[h] * eta_eff);
if let Some(stoch) = spec.stochastic {
let par_lp = stoch.par();
if par_lp.n_stages() > 0 && par_lp.n_hydros() == n_hydros {
let base = par_lp.deterministic_base(0, h);
let sigma = par_lp.sigma(0, h);
scratch.z_inflow_rhs_buf.push(base + sigma * eta_eff);
} else {
scratch.z_inflow_rhs_buf.push(0.0);
}
} else {
scratch.z_inflow_rhs_buf.push(0.0);
}
}
patch_buf.fill_forward_patches(
indexer,
initial_state,
&scratch.noise_buf,
base_row,
&spec.template.row_scale,
);
patch_buf.fill_z_inflow_patches(
indexer.z_inflow_row_start,
&scratch.z_inflow_rhs_buf,
&spec.template.row_scale,
);
let n_patches = patch_buf.forward_patch_count();
solver.set_row_bounds(
&patch_buf.indices[..n_patches],
&patch_buf.lower[..n_patches],
&patch_buf.upper[..n_patches],
);
if let Some(stoch) = spec.stochastic {
let n_stochastic_ncs = stoch.n_stochastic_ncs();
if n_stochastic_ncs > 0 && !spec.ncs_generation.is_empty() {
transform_ncs_noise(
raw_noise,
&NcsNoiseOffsets {
n_hydros,
n_load_buses: spec.n_load_buses,
},
stoch,
0,
spec.block_count,
spec.ncs_max_gen,
spec.ncs_allow_curtailment,
&mut scratch.ncs_col_lower_buf,
&mut scratch.ncs_col_upper_buf,
);
solver.set_col_bounds(
&scratch.ncs_col_indices_buf,
&scratch.ncs_col_lower_buf,
&scratch.ncs_col_upper_buf,
);
}
}
let view = solver.solve(None).map_err(|e| match e {
SolverError::Infeasible => SddpError::Infeasible {
stage: 0,
iteration: 0,
scenario: opening_idx,
},
other => SddpError::Solver(other),
})?;
objectives.push(view.objective);
}
Ok(objectives)
}
fn lb_aggregate_and_broadcast<C: Communicator>(
objectives: &[f64],
risk_measure: &RiskMeasure,
scratch: &mut LbEvalScratch,
comm: &C,
) -> Result<f64, SddpError> {
#[allow(clippy::cast_precision_loss)]
let uniform_prob = 1.0_f64 / objectives.len() as f64;
scratch.uniform_prob_buf.clear();
scratch
.uniform_prob_buf
.resize(objectives.len(), uniform_prob);
let mut lb =
risk_measure.evaluate_risk(objectives, &scratch.uniform_prob_buf) * COST_SCALE_FACTOR;
comm.broadcast(std::slice::from_mut(&mut lb), 0)
.map_err(SddpError::from)?;
Ok(lb)
}
pub fn evaluate_lower_bound<S: SolverInterface, C: Communicator>(
solver: &mut S,
fcf: &FutureCostFunction,
initial_state: &[f64],
indexer: &StageIndexer,
scratch: &mut LbEvalScratchBundle<'_>,
spec: &LbEvalSpec<'_>,
comm: &C,
) -> Result<f64, SddpError> {
let mut lb = 0.0_f64;
if comm.rank() == 0 {
assert!(
spec.opening_tree.n_openings(0) > 0,
"evaluate_lower_bound: stage 0 must have at least one opening"
);
lb_init_rank0(
solver,
fcf,
spec,
indexer,
scratch.lb_cut_batch,
scratch.lb_cut_row_map.as_deref_mut(),
scratch.lb_scratch,
);
let objectives = lb_evaluate_stage_0(
solver,
spec,
scratch.patch_buf,
initial_state,
indexer,
scratch.lb_scratch,
)?;
lb = lb_aggregate_and_broadcast(&objectives, spec.risk_measure, scratch.lb_scratch, comm)?;
return Ok(lb);
}
comm.broadcast(std::slice::from_mut(&mut lb), 0)
.map_err(SddpError::from)?;
Ok(lb)
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::float_cmp,
clippy::cast_precision_loss
)]
mod tests {
use super::{
LbEvalScratch, LbEvalScratchBundle, LbEvalSpec, evaluate_lower_bound, lb_evaluate_stage_0,
};
use crate::{
cut::FutureCostFunction, error::SddpError, indexer::StageIndexer,
inflow_method::InflowNonNegativityMethod, lp_builder::PatchBuffer,
risk_measure::RiskMeasure,
};
use cobre_comm::{CommData, CommError, Communicator, ReduceOp};
use cobre_solver::{
Basis, RowBatch, SolverError, SolverInterface, SolverStatistics, StageTemplate,
};
use cobre_stochastic::OpeningTree;
fn empty_row_batch() -> RowBatch {
RowBatch {
num_rows: 0,
row_starts: Vec::new(),
col_indices: Vec::new(),
values: Vec::new(),
row_lower: Vec::new(),
row_upper: Vec::new(),
}
}
fn make_lb_locals() -> (RowBatch, LbEvalScratch) {
(empty_row_batch(), LbEvalScratch::new())
}
fn minimal_template() -> StageTemplate {
StageTemplate {
num_cols: 3,
num_rows: 1,
num_nz: 1,
col_starts: vec![0_i32, 0, 1, 1], row_indices: vec![0_i32],
values: vec![1.0],
col_lower: vec![0.0, 0.0, 0.0],
col_upper: vec![f64::INFINITY, f64::INFINITY, f64::INFINITY],
objective: vec![0.0, 0.0, 1.0], row_lower: vec![0.0],
row_upper: vec![0.0],
n_state: 1,
n_transfer: 0,
n_dual_relevant: 1,
n_hydro: 1,
max_par_order: 0,
col_scale: Vec::new(),
row_scale: Vec::new(),
}
}
fn simple_opening_tree(n_openings: usize) -> OpeningTree {
use chrono::NaiveDate;
use cobre_core::{
EntityId,
scenario::{CorrelationEntity, CorrelationGroup, CorrelationModel, CorrelationProfile},
temporal::{
Block, BlockMode, NoiseMethod, ScenarioSourceConfig, Stage, StageRiskConfig,
StageStateConfig,
},
};
use cobre_stochastic::correlation::resolve::DecomposedCorrelation;
use std::collections::BTreeMap;
let stage = Stage {
index: 0,
id: 0,
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
season_id: Some(0),
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: n_openings,
noise_method: NoiseMethod::Saa,
},
};
let entity_id = EntityId(1);
let mut profiles = BTreeMap::new();
profiles.insert(
"default".to_string(),
CorrelationProfile {
groups: vec![CorrelationGroup {
name: "g1".to_string(),
entities: vec![CorrelationEntity {
entity_type: "inflow".to_string(),
id: entity_id,
}],
matrix: vec![vec![1.0]],
}],
},
);
let corr_model = CorrelationModel {
method: "spectral".to_string(),
profiles,
schedule: vec![],
};
let decomposed = DecomposedCorrelation::build(&corr_model).unwrap();
let entity_order = vec![entity_id];
cobre_stochastic::tree::generate::generate_opening_tree(
42,
&[stage],
1, &decomposed,
&entity_order,
cobre_stochastic::ClassDimensions {
n_hydros: 1,
n_load_buses: 0,
n_ncs: 0,
},
&cobre_stochastic::tree::generate::OpeningTreeGenerationInputs::default(),
)
.unwrap()
}
struct LocalComm;
impl Communicator for LocalComm {
fn allgatherv<T: CommData>(
&self,
_send: &[T],
_recv: &mut [T],
_counts: &[usize],
_displs: &[usize],
) -> Result<(), CommError> {
unreachable!("LocalComm allgatherv not used in lower_bound tests")
}
fn allreduce<T: CommData>(
&self,
_send: &[T],
_recv: &mut [T],
_op: ReduceOp,
) -> Result<(), CommError> {
unreachable!("LocalComm allreduce not used in lower_bound tests")
}
fn broadcast<T: CommData>(&self, _buf: &mut [T], _root: usize) -> Result<(), CommError> {
Ok(())
}
fn barrier(&self) -> Result<(), CommError> {
Ok(())
}
fn rank(&self) -> usize {
0
}
fn size(&self) -> usize {
1
}
fn abort(&self, error_code: i32) -> ! {
std::process::exit(error_code)
}
}
struct FailingBcastComm;
impl Communicator for FailingBcastComm {
fn allgatherv<T: CommData>(
&self,
_send: &[T],
_recv: &mut [T],
_counts: &[usize],
_displs: &[usize],
) -> Result<(), CommError> {
unreachable!()
}
fn allreduce<T: CommData>(
&self,
_send: &[T],
_recv: &mut [T],
_op: ReduceOp,
) -> Result<(), CommError> {
unreachable!()
}
fn broadcast<T: CommData>(&self, _buf: &mut [T], _root: usize) -> Result<(), CommError> {
Err(CommError::CollectiveFailed {
operation: "broadcast",
mpi_error_code: -1,
message: "test-induced broadcast failure".to_string(),
})
}
fn barrier(&self) -> Result<(), CommError> {
Ok(())
}
fn rank(&self) -> usize {
0
}
fn size(&self) -> usize {
1
}
fn abort(&self, error_code: i32) -> ! {
std::process::exit(error_code)
}
}
struct MockSolver {
objectives: Vec<f64>,
call_count: usize,
infeasible_on_call: Option<usize>,
set_col_bounds_calls: usize,
}
impl MockSolver {
fn with_objectives(objectives: Vec<f64>) -> Self {
Self {
objectives,
call_count: 0,
infeasible_on_call: None,
set_col_bounds_calls: 0,
}
}
fn infeasible_on_first() -> Self {
Self {
objectives: vec![0.0],
call_count: 0,
infeasible_on_call: Some(0),
set_col_bounds_calls: 0,
}
}
}
impl SolverInterface for MockSolver {
fn solver_name_version(&self) -> String {
"MockSolver 0.0.0".to_string()
}
fn load_model(&mut self, _template: &StageTemplate) {}
fn add_rows(&mut self, _cuts: &RowBatch) {}
fn set_row_bounds(&mut self, _indices: &[usize], _lower: &[f64], _upper: &[f64]) {}
fn set_col_bounds(&mut self, _indices: &[usize], _lower: &[f64], _upper: &[f64]) {
self.set_col_bounds_calls += 1;
}
fn solve(
&mut self,
_basis: Option<&Basis>,
) -> Result<cobre_solver::SolutionView<'_>, SolverError> {
let call = self.call_count;
self.call_count += 1;
if self.infeasible_on_call == Some(call) {
return Err(SolverError::Infeasible);
}
let obj = self.objectives[call % self.objectives.len()];
Ok(cobre_solver::SolutionView {
objective: obj,
primal: &[],
dual: &[],
reduced_costs: &[],
iterations: 0,
solve_time_seconds: 0.0,
})
}
fn get_basis(&mut self, _out: &mut Basis) {}
fn statistics(&self) -> SolverStatistics {
SolverStatistics::default()
}
fn name(&self) -> &'static str {
"Mock"
}
}
fn make_fcf(n_stages: usize, n_state: usize) -> FutureCostFunction {
FutureCostFunction::new(n_stages, n_state, 2, 100, &vec![0; n_stages])
}
#[test]
fn one_opening_expectation_lb_equals_single_objective() {
let indexer = StageIndexer::new(1, 0); let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64; indexer.n_state];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(1);
let rm = RiskMeasure::Expectation;
let comm = LocalComm;
let mut solver = MockSolver::with_objectives(vec![100.0]);
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
ncs_allow_curtailment: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::None,
};
let (mut row_batch, mut lb_scratch) = make_lb_locals();
let mut bundle = LbEvalScratchBundle::from_scratch_fields(
&mut patch_buf,
&mut row_batch,
None,
&mut lb_scratch,
);
let lb = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut bundle,
&spec,
&comm,
)
.unwrap();
assert!(
(lb - 100_000.0).abs() < 1e-7,
"single opening expectation LB must equal objective 100.0 * COST_SCALE_FACTOR = 100_000.0, got {lb}"
);
}
#[test]
fn three_openings_expectation_lb_equals_mean() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64; indexer.n_state];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(3);
let rm = RiskMeasure::Expectation;
let comm = LocalComm;
let mut solver = MockSolver::with_objectives(vec![60.0, 80.0, 100.0]);
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
ncs_allow_curtailment: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::None,
};
let (mut row_batch_lb, mut lb_scratch_lb) = make_lb_locals();
let mut bundle_lb = LbEvalScratchBundle::from_scratch_fields(
&mut patch_buf,
&mut row_batch_lb,
None,
&mut lb_scratch_lb,
);
let lb = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut bundle_lb,
&spec,
&comm,
)
.unwrap();
assert!(
(lb - 80_000.0).abs() < 1e-7,
"three openings expectation LB must equal 80_000.0, got {lb}"
);
}
#[test]
fn two_openings_pure_cvar_alpha_half_lb_equals_worst() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64; indexer.n_state];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(2);
let rm = RiskMeasure::CVaR {
alpha: 0.5,
lambda: 1.0,
};
let comm = LocalComm;
let mut solver = MockSolver::with_objectives(vec![50.0, 150.0]);
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
ncs_allow_curtailment: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::None,
};
let (mut row_batch_lb, mut lb_scratch_lb) = make_lb_locals();
let mut bundle_lb = LbEvalScratchBundle::from_scratch_fields(
&mut patch_buf,
&mut row_batch_lb,
None,
&mut lb_scratch_lb,
);
let lb = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut bundle_lb,
&spec,
&comm,
)
.unwrap();
assert!(
(lb - 150_000.0).abs() < 1e-7,
"pure CVaR(0.5, 1.0) with 2 openings must equal 150_000.0, got {lb}"
);
}
#[test]
fn two_openings_cvar_alpha_one_equals_expectation() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64; indexer.n_state];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(2);
let rm = RiskMeasure::CVaR {
alpha: 1.0,
lambda: 1.0,
};
let comm = LocalComm;
let mut solver = MockSolver::with_objectives(vec![50.0, 150.0]);
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
ncs_allow_curtailment: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::None,
};
let (mut row_batch_lb, mut lb_scratch_lb) = make_lb_locals();
let mut bundle_lb = LbEvalScratchBundle::from_scratch_fields(
&mut patch_buf,
&mut row_batch_lb,
None,
&mut lb_scratch_lb,
);
let lb = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut bundle_lb,
&spec,
&comm,
)
.unwrap();
assert!(
(lb - 100_000.0).abs() < 1e-7,
"CVaR(alpha=1, lambda=1) must equal expectation 100_000.0, got {lb}"
);
}
#[test]
fn infeasible_solve_maps_to_sddp_infeasible() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64; indexer.n_state];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(1);
let rm = RiskMeasure::Expectation;
let comm = LocalComm;
let mut solver = MockSolver::infeasible_on_first();
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
ncs_allow_curtailment: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::None,
};
let (mut row_batch_result, mut lb_scratch_result) = make_lb_locals();
let mut bundle_result = LbEvalScratchBundle::from_scratch_fields(
&mut patch_buf,
&mut row_batch_result,
None,
&mut lb_scratch_result,
);
let result = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut bundle_result,
&spec,
&comm,
);
assert!(
matches!(result, Err(SddpError::Infeasible { stage: 0, .. })),
"infeasible solver must produce SddpError::Infeasible at stage 0, got {result:?}"
);
}
#[test]
fn broadcast_failure_maps_to_communication_error() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64; indexer.n_state];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(1);
let rm = RiskMeasure::Expectation;
let comm = FailingBcastComm;
let mut solver = MockSolver::with_objectives(vec![100.0]);
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
ncs_allow_curtailment: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::None,
};
let (mut row_batch_result, mut lb_scratch_result) = make_lb_locals();
let mut bundle_result = LbEvalScratchBundle::from_scratch_fields(
&mut patch_buf,
&mut row_batch_result,
None,
&mut lb_scratch_result,
);
let result = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut bundle_result,
&spec,
&comm,
);
assert!(
matches!(result, Err(SddpError::Communication(_))),
"broadcast failure must produce SddpError::Communication, got {result:?}"
);
}
#[test]
fn integration_two_openings_local_backend_expectation() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![50.0_f64]; let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(2);
let rm = RiskMeasure::Expectation;
let comm = LocalComm;
let mut solver = MockSolver::with_objectives(vec![200.0, 300.0]);
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
ncs_allow_curtailment: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::None,
};
let (mut row_batch_lb, mut lb_scratch_lb) = make_lb_locals();
let mut bundle_lb = LbEvalScratchBundle::from_scratch_fields(
&mut patch_buf,
&mut row_batch_lb,
None,
&mut lb_scratch_lb,
);
let lb = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut bundle_lb,
&spec,
&comm,
)
.unwrap();
assert!(
(lb - 250_000.0).abs() < 1e-7,
"integration round-trip must produce 250_000.0, got {lb}"
);
}
#[test]
fn integration_monotonicity_more_cuts_yields_higher_or_equal_lb() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(2);
let rm = RiskMeasure::Expectation;
let comm = LocalComm;
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
ncs_allow_curtailment: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::None,
};
let mut solver1 = MockSolver::with_objectives(vec![50.0, 100.0]);
let (mut row_batch_lb1, mut lb_scratch_lb1) = make_lb_locals();
let mut bundle_lb1 = LbEvalScratchBundle::from_scratch_fields(
&mut patch_buf,
&mut row_batch_lb1,
None,
&mut lb_scratch_lb1,
);
let lb1 = evaluate_lower_bound(
&mut solver1,
&fcf,
&initial_state,
&indexer,
&mut bundle_lb1,
&spec,
&comm,
)
.unwrap();
let mut solver2 = MockSolver::with_objectives(vec![80.0, 120.0]);
let (mut row_batch_lb2, mut lb_scratch_lb2) = make_lb_locals();
let mut bundle_lb2 = LbEvalScratchBundle::from_scratch_fields(
&mut patch_buf,
&mut row_batch_lb2,
None,
&mut lb_scratch_lb2,
);
let lb2 = evaluate_lower_bound(
&mut solver2,
&fcf,
&initial_state,
&indexer,
&mut bundle_lb2,
&spec,
&comm,
)
.unwrap();
assert!(
lb2 >= lb1,
"second LB ({lb2}) must be >= first LB ({lb1}) when cuts are tighter"
);
}
#[test]
fn test_lb_none_method_unchanged() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64; indexer.n_state];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(2);
let rm = RiskMeasure::Expectation;
let comm = LocalComm;
let mut solver = MockSolver::with_objectives(vec![60.0, 80.0]);
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
ncs_allow_curtailment: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::None,
};
let (mut row_batch_lb, mut lb_scratch_lb) = make_lb_locals();
let mut bundle_lb = LbEvalScratchBundle::from_scratch_fields(
&mut patch_buf,
&mut row_batch_lb,
None,
&mut lb_scratch_lb,
);
let lb = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut bundle_lb,
&spec,
&comm,
)
.unwrap();
assert!(
(lb - 70_000.0).abs() < 1e-7,
"None method must produce correct LB, got {lb}"
);
}
#[test]
fn test_lb_truncation_no_crash() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64; indexer.n_state];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(1);
let rm = RiskMeasure::Expectation;
let comm = LocalComm;
let mut solver = MockSolver::with_objectives(vec![100.0]);
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
ncs_allow_curtailment: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::Truncation,
};
let (mut row_batch_result, mut lb_scratch_result) = make_lb_locals();
let mut bundle_result = LbEvalScratchBundle::from_scratch_fields(
&mut patch_buf,
&mut row_batch_result,
None,
&mut lb_scratch_result,
);
let result = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut bundle_result,
&spec,
&comm,
);
assert!(
result.is_ok(),
"Truncation method must not panic or fail, got {result:?}"
);
}
#[test]
fn test_lb_truncation_with_penalty_no_crash() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64; indexer.n_state];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(1);
let rm = RiskMeasure::Expectation;
let comm = LocalComm;
let mut solver = MockSolver::with_objectives(vec![100.0]);
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
ncs_allow_curtailment: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::TruncationWithPenalty,
};
let (mut row_batch_result, mut lb_scratch_result) = make_lb_locals();
let mut bundle_result = LbEvalScratchBundle::from_scratch_fields(
&mut patch_buf,
&mut row_batch_result,
None,
&mut lb_scratch_result,
);
let result = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut bundle_result,
&spec,
&comm,
);
assert!(
result.is_ok(),
"TruncationWithPenalty method must not panic or fail, got {result:?}"
);
}
#[allow(clippy::too_many_lines)]
#[test]
fn lb_evaluate_stage_0_patches_ncs_bounds_per_opening() {
use cobre_core::{
Bus, DeficitSegment, EntityId, SystemBuilder,
entities::non_controllable::NonControllableSource,
scenario::{
CorrelationEntity, CorrelationGroup, CorrelationModel, CorrelationProfile,
NcsModel, SamplingScheme,
},
temporal::{
Block, BlockMode, NoiseMethod, ScenarioSourceConfig, Stage, StageRiskConfig,
StageStateConfig,
},
};
use cobre_stochastic::context::{
ClassSchemes, OpeningTreeInputs, build_stochastic_context,
};
use std::collections::BTreeMap;
let n_openings = 3_usize;
let n_ncs = 1_usize;
let block_count = 1_usize;
let ncs_entity_id = EntityId(10);
let bus = Bus {
id: EntityId(0),
name: "B0".to_string(),
deficit_segments: vec![DeficitSegment {
depth_mw: None,
cost_per_mwh: 1000.0,
}],
excess_cost: 0.0,
};
let ncs_source = NonControllableSource {
id: ncs_entity_id,
name: "W1".to_string(),
bus_id: EntityId(0),
entry_stage_id: None,
exit_stage_id: None,
max_generation_mw: 100.0,
allow_curtailment: true,
curtailment_cost: 0.0,
};
let stage = Stage {
index: 0,
id: 0,
start_date: chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: chrono::NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
season_id: Some(0),
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: n_openings,
noise_method: NoiseMethod::Saa,
},
};
let ncs_model = NcsModel {
ncs_id: ncs_entity_id,
stage_id: 0,
mean: 0.5,
std: 0.1,
};
let mut profiles = BTreeMap::new();
profiles.insert(
"default".to_string(),
CorrelationProfile {
groups: vec![CorrelationGroup {
name: "ncs_group".to_string(),
entities: vec![CorrelationEntity {
entity_type: "ncs".to_string(),
id: ncs_entity_id,
}],
matrix: vec![vec![1.0]],
}],
},
);
let correlation = CorrelationModel {
method: "spectral".to_string(),
profiles,
schedule: vec![],
};
let system = SystemBuilder::new()
.buses(vec![bus])
.non_controllable_sources(vec![ncs_source])
.stages(vec![stage])
.ncs_models(vec![ncs_model])
.correlation(correlation)
.build()
.unwrap();
let stoch = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: None,
load: None,
ncs: Some(SamplingScheme::InSample),
},
)
.unwrap();
assert_eq!(
stoch.n_stochastic_ncs(),
n_ncs,
"StochasticContext must report {n_ncs} stochastic NCS entity"
);
let opening_tree = stoch.opening_tree();
let template = StageTemplate {
num_cols: 1,
num_rows: 0,
num_nz: 0,
col_starts: vec![0_i32, 0],
row_indices: vec![],
values: vec![],
col_lower: vec![0.0],
col_upper: vec![100.0],
objective: vec![0.0],
row_lower: vec![],
row_upper: vec![],
n_state: 0,
n_transfer: 0,
n_dual_relevant: 0,
n_hydro: 0,
max_par_order: 0,
col_scale: Vec::new(),
row_scale: Vec::new(),
};
let indexer = StageIndexer::new(0, 0);
let ncs_max_gen = vec![100.0_f64; n_ncs];
let ncs_allow_curtailment = vec![true; n_ncs];
let spec = LbEvalSpec {
template: &template,
base_row: 0,
noise_scale: &[],
n_hydros: 0,
opening_tree,
risk_measure: &RiskMeasure::Expectation,
stochastic: Some(&stoch),
n_load_buses: 0,
ncs_max_gen: &ncs_max_gen,
ncs_allow_curtailment: &ncs_allow_curtailment,
block_count,
ncs_generation: 0..block_count,
inflow_method: &InflowNonNegativityMethod::None,
};
let mut lb_scratch = LbEvalScratch::new();
for ncs_idx in 0..n_ncs {
for blk in 0..block_count {
lb_scratch
.ncs_col_indices_buf
.push(spec.ncs_generation.start + ncs_idx * block_count + blk);
lb_scratch.ncs_col_lower_buf.push(0.0);
}
}
let mut patch_buf = PatchBuffer::new(0, 0, 0, 0);
let initial_state: Vec<f64> = Vec::new();
let actual_n_openings = opening_tree.n_openings(0);
let mut solver =
MockSolver::with_objectives(vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0]);
lb_evaluate_stage_0(
&mut solver,
&spec,
&mut patch_buf,
&initial_state,
&indexer,
&mut lb_scratch,
)
.unwrap();
assert_eq!(
solver.set_col_bounds_calls, actual_n_openings,
"set_col_bounds must be called once per opening ({actual_n_openings} openings), \
got {} calls — NCS bounds are not being patched per opening",
solver.set_col_bounds_calls
);
assert!(
actual_n_openings > 0,
"opening tree must have at least one opening at stage 0"
);
}
#[test]
fn lb_eval_scratch_reuses_buffers_across_calls() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64; indexer.n_state];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(1);
let rm = RiskMeasure::Expectation;
let comm = LocalComm;
let spec = LbEvalSpec {
template: &template,
base_row: 0,
noise_scale: &[1.0],
n_hydros: 1,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
ncs_allow_curtailment: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::None,
};
let mut row_batch = empty_row_batch();
let mut lb_scratch = LbEvalScratch::new();
let mut solver1 = MockSolver::with_objectives(vec![10.0]);
{
let mut bundle = LbEvalScratchBundle::from_scratch_fields(
&mut patch_buf,
&mut row_batch,
None,
&mut lb_scratch,
);
evaluate_lower_bound(
&mut solver1,
&fcf,
&initial_state,
&indexer,
&mut bundle,
&spec,
&comm,
)
.unwrap();
}
let cap_after_first = lb_scratch.noise_buf.capacity();
assert!(
cap_after_first > 0,
"noise_buf must have nonzero capacity after first call (n_hydros = 1)"
);
let mut solver2 = MockSolver::with_objectives(vec![20.0]);
{
let mut bundle = LbEvalScratchBundle::from_scratch_fields(
&mut patch_buf,
&mut row_batch,
None,
&mut lb_scratch,
);
evaluate_lower_bound(
&mut solver2,
&fcf,
&initial_state,
&indexer,
&mut bundle,
&spec,
&comm,
)
.unwrap();
}
let cap_after_second = lb_scratch.noise_buf.capacity();
assert_eq!(
cap_after_second, cap_after_first,
"noise_buf capacity must be stable across calls (first={cap_after_first}, second={cap_after_second}); \
a decrease indicates reallocation on the lower-bound hot path"
);
}
}