use cobre_core::temporal::StageLagTransition;
use cobre_stochastic::{StochasticContext, evaluate_par_batch, solve_par_noise_batch};
use crate::{
InflowNonNegativityMethod,
context::{StageContext, TrainingContext},
workspace::ScratchBuffers,
};
pub(crate) fn compute_effective_eta(
raw_noise: &[f64],
n_hydros: usize,
inflow_method: InflowNonNegativityMethod,
par_inflows: &[f64],
eta_floor: &[f64],
effective_eta: &mut Vec<f64>,
) {
effective_eta.clear();
match inflow_method {
InflowNonNegativityMethod::Truncation
| InflowNonNegativityMethod::TruncationWithPenalty => {
let has_negative = par_inflows.iter().take(n_hydros).any(|&a| a < 0.0);
for h in 0..n_hydros {
let eta = raw_noise[h];
let clamped = if has_negative && par_inflows[h] < 0.0 {
eta.max(eta_floor[h])
} else {
eta
};
effective_eta.push(clamped);
}
}
InflowNonNegativityMethod::None | InflowNonNegativityMethod::Penalty => {
effective_eta.extend_from_slice(&raw_noise[..n_hydros]);
}
}
}
pub(crate) fn transform_inflow_noise(
raw_noise: &[f64],
stage: usize,
current_state: &[f64],
ctx: &StageContext<'_>,
training_ctx: &TrainingContext<'_>,
scratch: &mut ScratchBuffers,
) {
let n_hydros = ctx.n_hydros;
let stage_offset = stage * n_hydros;
let base_row = ctx.base_rows[stage];
let template_row_lower = &ctx.templates[stage].row_lower;
let noise_scale = ctx.noise_scale;
let inflow_method = training_ctx.inflow_method;
let stochastic = training_ctx.stochastic;
let indexer = training_ctx.indexer;
scratch.noise_buf.clear();
scratch.z_inflow_rhs_buf.clear();
let par_lp = stochastic.par();
let has_par = par_lp.n_stages() > 0 && par_lp.n_hydros() == n_hydros;
match inflow_method {
InflowNonNegativityMethod::Truncation
| InflowNonNegativityMethod::TruncationWithPenalty => {
let max_order = indexer.max_par_order;
let lag_len = max_order * n_hydros;
scratch.lag_matrix_buf.clear();
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] =
current_state[indexer.inflow_lags.start + l * n_hydros + h];
}
}
scratch.par_inflow_buf.clear();
scratch.par_inflow_buf.resize(n_hydros, 0.0);
evaluate_par_batch(
par_lp,
stage,
&scratch.lag_matrix_buf,
&raw_noise[..n_hydros],
&mut scratch.par_inflow_buf,
);
let has_negative = scratch.par_inflow_buf.iter().any(|&a| a < 0.0);
if has_negative {
scratch.eta_floor_buf.clear();
scratch.eta_floor_buf.resize(n_hydros, f64::NEG_INFINITY);
let zero_targets = &scratch.zero_targets_buf[..n_hydros];
solve_par_noise_batch(
par_lp,
stage,
&scratch.lag_matrix_buf,
zero_targets,
&mut scratch.eta_floor_buf,
);
}
}
InflowNonNegativityMethod::None | InflowNonNegativityMethod::Penalty => {}
}
compute_effective_eta(
raw_noise,
n_hydros,
*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() {
let base_rhs = template_row_lower[base_row + h];
scratch
.noise_buf
.push(base_rhs + noise_scale[stage_offset + h] * eta_eff);
if has_par {
let base = par_lp.deterministic_base(stage, h);
let sigma = par_lp.sigma(stage, h);
scratch.z_inflow_rhs_buf.push(base + sigma * eta_eff);
} else {
scratch.z_inflow_rhs_buf.push(0.0);
}
}
}
#[cfg(test)]
pub(crate) fn shift_lag_state(
state: &mut [f64],
incoming_lags: &[f64],
unscaled_primal: &[f64],
indexer: &crate::indexer::StageIndexer,
) {
let n_h = indexer.hydro_count;
let l_max = indexer.max_par_order;
if l_max == 0 || n_h == 0 {
return; }
let lag_start = indexer.inflow_lags.start;
for h in 0..n_h {
let z_t_h = unscaled_primal[indexer.z_inflow.start + h];
for lag in (1..l_max).rev() {
state[lag_start + lag * n_h + h] = incoming_lags[(lag - 1) * n_h + h];
}
state[lag_start + h] = z_t_h;
}
}
fn shift_lag_state_from_inflows(
state: &mut [f64],
incoming_lags: &[f64],
monthly_inflows: &[f64],
indexer: &crate::indexer::StageIndexer,
) {
let n_h = indexer.hydro_count;
let l_max = indexer.max_par_order;
let lag_start = indexer.inflow_lags.start;
for h in 0..n_h {
for lag in (1..l_max).rev() {
state[lag_start + lag * n_h + h] = incoming_lags[(lag - 1) * n_h + h];
}
state[lag_start + h] = monthly_inflows[h];
}
}
pub(crate) fn shift_anticipated_state(
state: &mut [f64],
incoming_anticipated: &[f64],
unscaled_primal: &[f64],
indexer: &crate::indexer::StageIndexer,
) {
let n_ant = indexer.n_anticipated;
let k_max = indexer.k_max;
if n_ant == 0 || k_max == 0 {
return;
}
debug_assert_eq!(
incoming_anticipated.len(),
n_ant * k_max,
"incoming_anticipated length mismatch: expected {}, got {}",
n_ant * k_max,
incoming_anticipated.len(),
);
debug_assert!(
unscaled_primal.len() >= indexer.anticipated_decision.end,
"unscaled_primal too short for anticipated_decision range",
);
let state_start = indexer.anticipated_state.start;
let decision_start = indexer.anticipated_decision.start;
for (plant, &k_i) in indexer.anticipated_lead_stages.iter().enumerate() {
debug_assert!(
k_i >= 1,
"K_i must be >= 1 (enforced by anticipation validation)"
);
for slot in 0..(k_i - 1) {
let dst = state_start + slot * n_ant + plant;
let src = (slot + 1) * n_ant + plant;
state[dst] = incoming_anticipated[src];
}
let newest = state_start + (k_i - 1) * n_ant + plant;
state[newest] = unscaled_primal[decision_start + plant];
for slot in k_i..k_max {
let dst = state_start + slot * n_ant + plant;
state[dst] = 0.0;
}
}
}
pub(crate) struct LagAccumState<'a> {
pub accumulator: &'a mut [f64],
pub weight_accum: &'a mut f64,
}
pub(crate) struct DownstreamAccumState<'a> {
pub accumulator: &'a mut [f64],
pub weight_accum: &'a mut f64,
pub completed_lags: &'a mut [f64],
pub n_completed: &'a mut usize,
pub par_order: usize,
}
pub(crate) fn accumulate_and_shift_lag_state(
state: &mut [f64],
incoming_lags: &[f64],
unscaled_primal: &[f64],
indexer: &crate::indexer::StageIndexer,
stage_lag: &StageLagTransition,
lag: &mut LagAccumState<'_>,
ds: &mut DownstreamAccumState<'_>,
) {
let n_h = indexer.hydro_count;
let l_max = indexer.max_par_order;
if l_max == 0 || n_h == 0 {
return; }
debug_assert!(
lag.accumulator.len() >= n_h,
"lag_accumulator too short: {} < {n_h}",
lag.accumulator.len()
);
let z_start = indexer.z_inflow.start;
let w = stage_lag.accumulate_weight;
for h in 0..n_h {
lag.accumulator[h] += unscaled_primal[z_start + h] * w;
}
*lag.weight_accum += w;
if !ds.accumulator.is_empty() && stage_lag.accumulate_downstream {
debug_assert!(
ds.accumulator.len() >= n_h,
"downstream_accumulator too short: {} < {n_h}",
ds.accumulator.len()
);
debug_assert!(
ds.par_order == 0 || ds.completed_lags.len() >= n_h * ds.par_order,
"downstream_completed_lags too short: {} < {}",
ds.completed_lags.len(),
n_h * ds.par_order
);
let dw = stage_lag.downstream_accumulate_weight;
for h in 0..n_h {
ds.accumulator[h] += unscaled_primal[z_start + h] * dw;
}
*ds.weight_accum += dw;
if stage_lag.downstream_finalize && *ds.weight_accum > 0.0 {
let inv = 1.0 / *ds.weight_accum;
for v in &mut ds.accumulator[..n_h] {
*v *= inv;
}
let slot = (*ds.n_completed).min(ds.par_order.saturating_sub(1));
let offset = slot * n_h;
ds.completed_lags[offset..offset + n_h].copy_from_slice(&ds.accumulator[..n_h]);
*ds.n_completed = (*ds.n_completed + 1).min(ds.par_order);
if stage_lag.downstream_spillover_weight > 0.0 {
let dsw = stage_lag.downstream_spillover_weight;
for h in 0..n_h {
ds.accumulator[h] = unscaled_primal[z_start + h] * dsw;
}
*ds.weight_accum = dsw;
} else {
ds.accumulator[..n_h].fill(0.0);
*ds.weight_accum = 0.0;
}
}
}
if stage_lag.rebuild_from_downstream && *ds.n_completed > 0 {
let n_fill = (*ds.n_completed).min(l_max);
for lag_idx in 0..n_fill {
let src_slot = *ds.n_completed - 1 - lag_idx;
let src_offset = src_slot * n_h;
let dst_offset = indexer.inflow_lags.start + lag_idx * n_h;
state[dst_offset..dst_offset + n_h]
.copy_from_slice(&ds.completed_lags[src_offset..src_offset + n_h]);
}
*ds.n_completed = 0;
ds.completed_lags.fill(0.0);
if !ds.accumulator.is_empty() {
ds.accumulator[..n_h].fill(0.0);
}
*ds.weight_accum = 0.0;
return; }
if stage_lag.finalize_period && *lag.weight_accum > 0.0 {
let inv = 1.0 / *lag.weight_accum;
for v in &mut lag.accumulator[..n_h] {
*v *= inv;
}
shift_lag_state_from_inflows(state, incoming_lags, lag.accumulator, indexer);
if stage_lag.spillover_weight > 0.0 {
let sw = stage_lag.spillover_weight;
for h in 0..n_h {
lag.accumulator[h] = unscaled_primal[z_start + h] * sw;
}
*lag.weight_accum = sw;
} else {
lag.accumulator[..n_h].fill(0.0);
*lag.weight_accum = 0.0;
}
}
}
pub(crate) fn transform_load_noise(
raw_noise: &[f64],
n_hydros: usize,
n_load_buses: usize,
stochastic: &StochasticContext,
stage: usize,
block_count: usize,
load_rhs_buf: &mut Vec<f64>,
) {
load_rhs_buf.clear();
if n_load_buses == 0 {
return;
}
let load_lp = stochastic.normal();
for lb_idx in 0..n_load_buses {
let eta = raw_noise[n_hydros + lb_idx];
let mean = load_lp.mean(stage, lb_idx);
let std = load_lp.std(stage, lb_idx);
let realization = (mean + std * eta).max(0.0);
for blk in 0..block_count {
let factor = load_lp.block_factor(stage, lb_idx, blk);
load_rhs_buf.push(realization * factor);
}
}
}
pub(crate) struct NcsNoiseOffsets {
pub n_hydros: usize,
pub n_load_buses: usize,
}
pub(crate) fn transform_ncs_noise(
raw_noise: &[f64],
offsets: &NcsNoiseOffsets,
stochastic: &StochasticContext,
stage: usize,
block_count: usize,
ncs_max_gen: &[f64],
ncs_allow_curtailment: &[bool],
ncs_col_lower_buf: &mut Vec<f64>,
ncs_col_upper_buf: &mut Vec<f64>,
) {
let n_stochastic_ncs = stochastic.n_stochastic_ncs();
ncs_col_upper_buf.clear();
ncs_col_lower_buf.clear();
if n_stochastic_ncs == 0 {
return;
}
debug_assert_eq!(
ncs_allow_curtailment.len(),
ncs_max_gen.len(),
"ncs_allow_curtailment and ncs_max_gen must have matching length",
);
let ncs_lp = stochastic.ncs_normal();
let ncs_noise_start = offsets.n_hydros + offsets.n_load_buses;
for ncs_idx in 0..n_stochastic_ncs {
let eta = raw_noise[ncs_noise_start + ncs_idx];
let mean = ncs_lp.mean(stage, ncs_idx);
let std = ncs_lp.std(stage, ncs_idx);
let max_gen = ncs_max_gen[ncs_idx];
let availability_ratio = (mean + std * eta).clamp(0.0, 1.0);
let realization = max_gen * availability_ratio;
let allow_curtailment = ncs_allow_curtailment[ncs_idx];
for blk in 0..block_count {
let factor = ncs_lp.block_factor(stage, ncs_idx, blk);
let upper = realization * factor;
ncs_col_upper_buf.push(upper);
ncs_col_lower_buf.push(if allow_curtailment { 0.0 } else { upper });
}
}
}
#[cfg(test)]
#[allow(
clippy::erasing_op,
clippy::identity_op,
clippy::doc_markdown,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap
)]
mod tests {
use chrono::NaiveDate;
use cobre_core::entities::hydro::{Hydro, HydroGenerationModel, HydroPenalties};
use cobre_core::scenario::{
CorrelationEntity, CorrelationGroup, CorrelationModel, CorrelationProfile, InflowModel,
LoadModel, SamplingScheme,
};
use cobre_core::temporal::{
Block, BlockMode, NoiseMethod, ScenarioSourceConfig, Stage, StageRiskConfig,
StageStateConfig,
};
use cobre_core::{Bus, DeficitSegment, EntityId, SystemBuilder};
use cobre_solver::StageTemplate;
use cobre_stochastic::StochasticContext;
use cobre_stochastic::context::{ClassSchemes, OpeningTreeInputs, build_stochastic_context};
use std::collections::BTreeMap;
use crate::{
context::{StageContext, TrainingContext},
horizon_mode::HorizonMode,
indexer::StageIndexer,
inflow_method::InflowNonNegativityMethod,
noise::{
compute_effective_eta, shift_anticipated_state, shift_lag_state,
transform_inflow_noise, transform_load_noise,
},
workspace::ScratchBuffers,
};
fn make_minimal_template(row_lower: Vec<f64>) -> StageTemplate {
let n = row_lower.len();
StageTemplate {
num_cols: 0,
num_rows: n,
num_nz: 0,
col_starts: vec![0_i32],
row_indices: vec![],
values: vec![],
col_lower: vec![],
col_upper: vec![],
objective: vec![],
row_lower,
row_upper: vec![0.0; n],
n_transfer: 0,
n_dual_relevant: 0,
n_hydro: 0,
max_par_order: 0,
col_scale: Vec::new(),
row_scale: Vec::new(),
n_state: 0,
}
}
fn make_scratch(n_hydros: usize) -> ScratchBuffers {
ScratchBuffers {
noise_buf: Vec::with_capacity(n_hydros),
inflow_m3s_buf: Vec::new(),
lag_matrix_buf: Vec::new(),
par_inflow_buf: Vec::new(),
eta_floor_buf: Vec::new(),
zero_targets_buf: vec![0.0_f64; n_hydros],
ncs_col_upper_buf: Vec::new(),
ncs_col_lower_buf: Vec::new(),
ncs_col_indices_buf: Vec::new(),
load_rhs_buf: Vec::new(),
row_lower_buf: Vec::new(),
z_inflow_rhs_buf: Vec::new(),
effective_eta_buf: Vec::with_capacity(n_hydros),
unscaled_primal: Vec::new(),
unscaled_dual: Vec::new(),
lag_accumulator: Vec::new(),
lag_weight_accum: 0.0,
downstream_accumulator: Vec::new(),
downstream_weight_accum: 0.0,
downstream_completed_lags: Vec::new(),
downstream_n_completed: 0,
recon_slot_lookup: Vec::new(),
trajectory_costs_buf: Vec::new(),
raw_noise_buf: Vec::new(),
perm_scratch: Vec::new(),
anticipated_state_buf: Vec::new(),
}
}
#[allow(clippy::too_many_lines)]
fn make_one_hydro_stochastic(n_stages: usize) -> StochasticContext {
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 hydro = Hydro {
id: EntityId(1),
name: "H1".to_string(),
bus_id: EntityId(0),
downstream_id: None,
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3: 0.0,
max_storage_hm3: 100.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: HydroGenerationModel::ConstantProductivity,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
specific_productivity_mw_per_m3s_per_m: None,
min_generation_mw: 0.0,
max_generation_mw: 100.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
evaporation_reference_volumes_hm3: None,
diversion: None,
filling: None,
penalties: HydroPenalties {
spillage_cost: 0.0,
diversion_cost: 0.0,
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,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
},
};
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let make_stage = |idx: usize| Stage {
index: idx,
id: idx as i32,
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: 1,
noise_method: NoiseMethod::Saa,
},
};
let stages: Vec<Stage> = (0..n_stages).map(make_stage).collect();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let inflow_models: Vec<InflowModel> = (0..n_stages)
.map(|idx| InflowModel {
hydro_id: EntityId(1),
stage_id: idx as i32,
mean_m3s: 0.0,
std_m3s: 1.0,
ar_coefficients: vec![],
residual_std_ratio: 1.0,
annual: None,
})
.collect();
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: EntityId(1),
}],
matrix: vec![vec![1.0]],
}],
},
);
let correlation = CorrelationModel {
method: "spectral".to_string(),
profiles,
schedule: vec![],
};
let system = SystemBuilder::new()
.buses(vec![bus])
.hydros(vec![hydro])
.stages(stages)
.inflow_models(inflow_models)
.correlation(correlation)
.build()
.unwrap();
build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.unwrap()
}
#[allow(clippy::too_many_lines)]
fn make_stochastic_with_load(n_stages: usize, mean_mw: f64, std_mw: f64) -> StochasticContext {
let bus0 = 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 bus1 = Bus {
id: EntityId(1),
name: "B1".to_string(),
deficit_segments: vec![DeficitSegment {
depth_mw: None,
cost_per_mwh: 1000.0,
}],
excess_cost: 0.0,
};
let hydro = Hydro {
id: EntityId(10),
name: "H10".to_string(),
bus_id: EntityId(0),
downstream_id: None,
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3: 0.0,
max_storage_hm3: 100.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: HydroGenerationModel::ConstantProductivity,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
specific_productivity_mw_per_m3s_per_m: None,
min_generation_mw: 0.0,
max_generation_mw: 100.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
evaporation_reference_volumes_hm3: None,
diversion: None,
filling: None,
penalties: HydroPenalties {
spillage_cost: 0.0,
diversion_cost: 0.0,
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,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
},
};
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let make_stage = |idx: usize| Stage {
index: idx,
id: idx as i32,
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: 1,
noise_method: NoiseMethod::Saa,
},
};
let stages: Vec<Stage> = (0..n_stages).map(make_stage).collect();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let inflow_models: Vec<InflowModel> = (0..n_stages)
.map(|idx| InflowModel {
hydro_id: EntityId(10),
stage_id: idx as i32,
mean_m3s: 0.0,
std_m3s: 1.0,
ar_coefficients: vec![],
residual_std_ratio: 1.0,
annual: None,
})
.collect();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let load_models: Vec<LoadModel> = (0..n_stages)
.map(|idx| LoadModel {
bus_id: EntityId(1),
stage_id: idx as i32,
mean_mw,
std_mw,
})
.collect();
let correlation = CorrelationModel {
method: "spectral".to_string(),
profiles: BTreeMap::new(),
schedule: vec![],
};
let system = SystemBuilder::new()
.buses(vec![bus0, bus1])
.hydros(vec![hydro])
.stages(stages)
.inflow_models(inflow_models)
.load_models(load_models)
.correlation(correlation)
.build()
.unwrap();
build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.unwrap()
}
#[test]
fn test_transform_inflow_noise_none_method() {
let stochastic = make_one_hydro_stochastic(1);
let indexer = StageIndexer::new(1, 0);
let current_state = vec![0.0; indexer.n_state];
let raw_noise = vec![-3.0_f64];
let noise_scale = vec![1.0_f64];
let template = make_minimal_template(vec![0.0, 5.0]);
let templates = vec![template];
let base_rows = vec![1_usize];
let inflow_method = InflowNonNegativityMethod::None;
let horizon = HorizonMode::Finite { num_stages: 1 };
let ctx = StageContext {
templates: &templates,
base_rows: &base_rows,
noise_scale: &noise_scale,
n_hydros: 1,
n_load_buses: 0,
load_balance_row_starts: &[],
load_bus_indices: &[],
block_counts_per_stage: &[1],
ncs_max_gen: &[],
ncs_allow_curtailment: &[],
discount_factors: &[],
cumulative_discount_factors: &[],
stage_lag_transitions: &[],
noise_group_ids: &[],
downstream_par_order: 0,
};
let training_ctx = TrainingContext {
horizon: &horizon,
indexer: &indexer,
inflow_method: &inflow_method,
stochastic: &stochastic,
initial_state: ¤t_state,
inflow_scheme: SamplingScheme::InSample,
load_scheme: SamplingScheme::InSample,
ncs_scheme: SamplingScheme::InSample,
stages: &[],
historical_library: None,
external_inflow_library: None,
external_load_library: None,
external_ncs_library: None,
recent_accum_seed: &[],
recent_weight_seed: 0.0,
dcs: None,
noise_key_diag: None,
};
let mut scratch = make_scratch(1);
transform_inflow_noise(
&raw_noise,
0,
¤t_state,
&ctx,
&training_ctx,
&mut scratch,
);
assert_eq!(scratch.noise_buf.len(), 1);
assert!((scratch.noise_buf[0] - 2.0).abs() < 1e-12);
}
#[test]
fn test_transform_inflow_noise_truncation_clamps() {
let stochastic = make_one_hydro_stochastic(1);
let indexer = StageIndexer::new(1, 0);
let current_state = vec![0.0; indexer.n_state];
let raw_noise = vec![-5.0_f64];
let noise_scale = vec![1.0_f64];
let template = make_minimal_template(vec![0.0]);
let templates = vec![template];
let base_rows = vec![0_usize];
let inflow_method = InflowNonNegativityMethod::Truncation;
let horizon = HorizonMode::Finite { num_stages: 1 };
let ctx = StageContext {
templates: &templates,
base_rows: &base_rows,
noise_scale: &noise_scale,
n_hydros: 1,
n_load_buses: 0,
load_balance_row_starts: &[],
load_bus_indices: &[],
block_counts_per_stage: &[1],
ncs_max_gen: &[],
ncs_allow_curtailment: &[],
discount_factors: &[],
cumulative_discount_factors: &[],
stage_lag_transitions: &[],
noise_group_ids: &[],
downstream_par_order: 0,
};
let training_ctx = TrainingContext {
horizon: &horizon,
indexer: &indexer,
inflow_method: &inflow_method,
stochastic: &stochastic,
initial_state: ¤t_state,
inflow_scheme: SamplingScheme::InSample,
load_scheme: SamplingScheme::InSample,
ncs_scheme: SamplingScheme::InSample,
stages: &[],
historical_library: None,
external_inflow_library: None,
external_load_library: None,
external_ncs_library: None,
recent_accum_seed: &[],
recent_weight_seed: 0.0,
dcs: None,
noise_key_diag: None,
};
let mut scratch = make_scratch(1);
transform_inflow_noise(
&raw_noise,
0,
¤t_state,
&ctx,
&training_ctx,
&mut scratch,
);
assert_eq!(scratch.noise_buf.len(), 1);
assert!(
scratch.noise_buf[0] >= -1e-10,
"truncation must yield non-negative RHS, got {}",
scratch.noise_buf[0]
);
}
#[test]
fn test_transform_inflow_noise_truncation_passthrough() {
let stochastic = make_one_hydro_stochastic(1);
let indexer = StageIndexer::new(1, 0);
let current_state = vec![0.0; indexer.n_state];
let raw_noise = vec![3.0_f64];
let noise_scale = vec![2.0_f64];
let template = make_minimal_template(vec![5.0]);
let templates = vec![template];
let base_rows = vec![0_usize];
let inflow_method = InflowNonNegativityMethod::Truncation;
let horizon = HorizonMode::Finite { num_stages: 1 };
let ctx = StageContext {
templates: &templates,
base_rows: &base_rows,
noise_scale: &noise_scale,
n_hydros: 1,
n_load_buses: 0,
load_balance_row_starts: &[],
load_bus_indices: &[],
block_counts_per_stage: &[1],
ncs_max_gen: &[],
ncs_allow_curtailment: &[],
discount_factors: &[],
cumulative_discount_factors: &[],
stage_lag_transitions: &[],
noise_group_ids: &[],
downstream_par_order: 0,
};
let training_ctx = TrainingContext {
horizon: &horizon,
indexer: &indexer,
inflow_method: &inflow_method,
stochastic: &stochastic,
initial_state: ¤t_state,
inflow_scheme: SamplingScheme::InSample,
load_scheme: SamplingScheme::InSample,
ncs_scheme: SamplingScheme::InSample,
stages: &[],
historical_library: None,
external_inflow_library: None,
external_load_library: None,
external_ncs_library: None,
recent_accum_seed: &[],
recent_weight_seed: 0.0,
dcs: None,
noise_key_diag: None,
};
let mut scratch = make_scratch(1);
transform_inflow_noise(
&raw_noise,
0,
¤t_state,
&ctx,
&training_ctx,
&mut scratch,
);
assert_eq!(scratch.noise_buf.len(), 1);
assert!(
(scratch.noise_buf[0] - 11.0).abs() < 1e-12,
"expected 11.0, got {}",
scratch.noise_buf[0]
);
}
#[test]
fn test_transform_load_noise_basic() {
let mean_mw = 5.0_f64;
let std_mw = 1.0_f64;
let stochastic = make_stochastic_with_load(1, mean_mw, std_mw);
let raw_noise = vec![0.0_f64, 0.0_f64]; let mut load_rhs_buf = Vec::new();
transform_load_noise(&raw_noise, 1, 1, &stochastic, 0, 1, &mut load_rhs_buf);
assert_eq!(load_rhs_buf.len(), 1);
assert!(
(load_rhs_buf[0] - 5.0).abs() < 1e-10,
"expected 5.0, got {}",
load_rhs_buf[0]
);
}
#[test]
fn test_transform_load_noise_clamped_non_negative() {
let mean_mw = 2.0_f64;
let std_mw = 1.0_f64;
let stochastic = make_stochastic_with_load(1, mean_mw, std_mw);
let raw_noise = vec![0.0_f64, -10.0_f64];
let mut load_rhs_buf = Vec::new();
transform_load_noise(&raw_noise, 1, 1, &stochastic, 0, 1, &mut load_rhs_buf);
assert_eq!(load_rhs_buf.len(), 1);
assert!(
load_rhs_buf[0].abs() < 1e-12,
"expected 0.0, got {}",
load_rhs_buf[0]
);
}
#[test]
fn shift_lag_state_par0_is_noop() {
let indexer = StageIndexer::new(2, 0);
let mut state = vec![100.0, 200.0]; let incoming_lags: Vec<f64> = vec![];
let primal = vec![0.0; 10];
shift_lag_state(&mut state, &incoming_lags, &primal, &indexer);
assert_eq!(
state,
vec![100.0, 200.0],
"state must be unchanged for PAR(0)"
);
}
#[test]
fn shift_lag_state_par1_single_hydro() {
let indexer = StageIndexer::new(1, 1);
let mut state = vec![500.0, 99.0]; let incoming_lags = vec![42.0]; let mut primal = vec![0.0; 10];
primal[indexer.z_inflow.start] = 77.0; shift_lag_state(&mut state, &incoming_lags, &primal, &indexer);
assert_eq!(state[1], 77.0, "lag[0] must be Z_t = 77.0");
}
#[test]
fn shift_lag_state_par3_single_hydro() {
let indexer = StageIndexer::new(1, 3);
let mut state = vec![500.0, 0.0, 0.0, 0.0];
let incoming_lags = vec![10.0, 20.0, 30.0];
let mut primal = vec![0.0; 20];
primal[indexer.z_inflow.start] = 55.0;
shift_lag_state(&mut state, &incoming_lags, &primal, &indexer);
assert_eq!(state[1], 55.0, "lag[0] must be Z_t");
assert_eq!(state[2], 10.0, "lag[1] must be incoming lag[0]");
assert_eq!(state[3], 20.0, "lag[2] must be incoming lag[1]");
}
#[test]
fn shift_lag_state_par1_two_hydros() {
let indexer = StageIndexer::new(2, 1);
let mut state = vec![100.0, 200.0, 0.0, 0.0];
let incoming_lags = vec![10.0, 20.0]; let mut primal = vec![0.0; 20];
primal[indexer.z_inflow.start] = 33.0; primal[indexer.z_inflow.start + 1] = 44.0; shift_lag_state(&mut state, &incoming_lags, &primal, &indexer);
assert_eq!(state[2], 33.0, "lag[0] for h0 must be Z_t_h0");
assert_eq!(state[3], 44.0, "lag[0] for h1 must be Z_t_h1");
}
#[test]
fn shift_lag_state_preserves_storage() {
let indexer = StageIndexer::new(2, 2);
let mut state = vec![100.0, 200.0, 0.0, 0.0, 0.0, 0.0];
let incoming_lags = vec![1.0, 2.0, 3.0, 4.0];
let mut primal = vec![0.0; 20];
primal[indexer.z_inflow.start] = 50.0;
primal[indexer.z_inflow.start + 1] = 60.0;
shift_lag_state(&mut state, &incoming_lags, &primal, &indexer);
assert_eq!(state[0], 100.0, "storage[0] must be preserved");
assert_eq!(state[1], 200.0, "storage[1] must be preserved");
}
fn make_anticipated_indexer(
n_anticipated: usize,
k_max: usize,
anticipated_lead_stages: Vec<usize>,
) -> StageIndexer {
use crate::indexer::{EquipmentCounts, EvapConfig, FphaColumnLayout};
StageIndexer::with_equipment_and_evaporation(
&EquipmentCounts {
hydro_count: 0,
max_par_order: 0,
n_thermals: 0,
n_lines: 0,
n_buses: 1,
n_blks: 1,
has_inflow_penalty: false,
max_deficit_segments: 1,
n_anticipated,
k_max,
anticipated_lead_stages,
anticipated_thermal_indices: (0..n_anticipated).collect(),
},
&FphaColumnLayout {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
&EvapConfig {
hydro_indices: vec![],
},
)
}
#[test]
fn shift_anticipated_state_no_anticipated_is_noop() {
let indexer = StageIndexer::new(1, 0);
let mut state = vec![42.0_f64; indexer.n_state.max(1)];
let incoming = vec![];
let primal = vec![0.0_f64; 10];
let before = state.clone();
shift_anticipated_state(&mut state, &incoming, &primal, &indexer);
assert_eq!(
state, before,
"state must be unchanged when n_anticipated == 0"
);
}
#[test]
fn shift_anticipated_state_single_plant_k2() {
let indexer = make_anticipated_indexer(1, 2, vec![2]);
let ant_start = indexer.anticipated_state.start;
let dec_start = indexer.anticipated_decision.start;
let mut state = vec![0.0_f64; indexer.n_state];
let incoming = vec![10.0_f64, 20.0_f64]; let mut primal = vec![0.0_f64; indexer.anticipated_decision.end + 1];
primal[dec_start] = 99.0;
shift_anticipated_state(&mut state, &incoming, &primal, &indexer);
assert_eq!(
state[ant_start], 20.0,
"slot 0 must be shifted from incoming slot 1"
);
assert_eq!(
state[ant_start + 1],
99.0,
"slot K_i-1 must receive the new decision primal"
);
}
#[test]
fn shift_anticipated_state_single_plant_k1() {
let indexer = make_anticipated_indexer(1, 1, vec![1]);
let ant_start = indexer.anticipated_state.start;
let dec_start = indexer.anticipated_decision.start;
let mut state = vec![0.0_f64; indexer.n_state];
let incoming = vec![100.0_f64]; let mut primal = vec![0.0_f64; indexer.anticipated_decision.end + 1];
primal[dec_start] = 42.0;
shift_anticipated_state(&mut state, &incoming, &primal, &indexer);
assert_eq!(
state[ant_start], 42.0,
"K_i=1 single slot must receive the new decision",
);
}
#[test]
fn shift_anticipated_state_two_plants_mixed_k() {
let indexer = make_anticipated_indexer(2, 3, vec![2, 3]);
let ant_start = indexer.anticipated_state.start;
let dec_start = indexer.anticipated_decision.start;
let mut state = vec![0.0_f64; indexer.n_state];
let incoming = vec![1.0_f64, 2.0, 3.0, 4.0, 5.0, 6.0];
let mut primal = vec![0.0_f64; indexer.anticipated_decision.end + 1];
primal[dec_start] = 10.0; primal[dec_start + 1] = 20.0;
shift_anticipated_state(&mut state, &incoming, &primal, &indexer);
assert_eq!(state[ant_start + 0 * 2 + 0], 3.0, "plant0 slot0");
assert_eq!(
state[ant_start + 1 * 2 + 0],
10.0,
"plant0 slot1 (decision)"
);
assert_eq!(state[ant_start + 2 * 2 + 0], 0.0, "plant0 slot2 (padding)");
assert_eq!(state[ant_start + 0 * 2 + 1], 4.0, "plant1 slot0");
assert_eq!(state[ant_start + 1 * 2 + 1], 6.0, "plant1 slot1");
assert_eq!(
state[ant_start + 2 * 2 + 1],
20.0,
"plant1 slot2 (decision)"
);
}
#[test]
fn shift_anticipated_state_preserves_storage_and_lag() {
use crate::indexer::{EquipmentCounts, EvapConfig, FphaColumnLayout};
let indexer = StageIndexer::with_equipment_and_evaporation(
&EquipmentCounts {
hydro_count: 2,
max_par_order: 1,
n_thermals: 0,
n_lines: 0,
n_buses: 1,
n_blks: 1,
has_inflow_penalty: false,
max_deficit_segments: 1,
n_anticipated: 1,
k_max: 2,
anticipated_lead_stages: vec![2],
anticipated_thermal_indices: vec![0],
},
&FphaColumnLayout {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
&EvapConfig {
hydro_indices: vec![],
},
);
let ant_start = indexer.anticipated_state.start;
let dec_start = indexer.anticipated_decision.start;
let mut state = vec![100.0, 200.0, 10.0, 20.0, 0.0, 0.0];
let incoming = vec![7.0_f64, 11.0]; let mut primal = vec![0.0_f64; dec_start + 2];
primal[dec_start] = 55.0;
shift_anticipated_state(&mut state, &incoming, &primal, &indexer);
assert_eq!(state[0], 100.0, "storage[0] must be preserved");
assert_eq!(state[1], 200.0, "storage[1] must be preserved");
assert_eq!(state[2], 10.0, "lag[0] must be preserved");
assert_eq!(state[3], 20.0, "lag[1] must be preserved");
assert_eq!(state[ant_start], 11.0, "slot 0 = incoming slot 1");
assert_eq!(state[ant_start + 1], 55.0, "slot 1 = decision");
}
#[test]
fn shift_anticipated_state_zero_k_max_is_noop() {
let indexer = StageIndexer::new(1, 0);
let mut state = vec![5.0_f64; 3];
let incoming = vec![];
let primal = vec![0.0_f64; 5];
let before = state.clone();
shift_anticipated_state(&mut state, &incoming, &primal, &indexer);
assert_eq!(state, before, "state must be unchanged when k_max == 0");
}
#[test]
fn shift_anticipated_state_invariant_under_stage_lag_transition() {
let indexer = make_anticipated_indexer(1, 2, vec![2]);
let incoming_anticipated = vec![5.0_f64, 15.0];
let dec_start = indexer.anticipated_decision.start;
let mut primal = vec![0.0_f64; dec_start + 2];
primal[dec_start] = 42.0;
let mut state_a = vec![0.0_f64; indexer.n_state];
let mut state_b = state_a.clone();
shift_anticipated_state(&mut state_a, &incoming_anticipated, &primal, &indexer);
shift_anticipated_state(&mut state_b, &incoming_anticipated, &primal, &indexer);
let s = indexer.anticipated_state.start;
let n_ant_state = indexer.n_anticipated * indexer.k_max;
assert_eq!(
&state_a[s..s + n_ant_state],
&state_b[s..s + n_ant_state],
"anticipated_state shift must be invariant under StageLagTransition"
);
}
#[test]
fn test_compute_effective_eta_none_passes_through() {
let raw_noise = [0.5, -1.0];
let par_inflows = []; let eta_floor = []; let mut effective = Vec::new();
compute_effective_eta(
&raw_noise,
2,
InflowNonNegativityMethod::None,
&par_inflows,
&eta_floor,
&mut effective,
);
assert_eq!(effective, vec![0.5, -1.0]);
}
#[test]
fn test_compute_effective_eta_penalty_passes_through() {
let raw_noise = [0.5, -1.0];
let par_inflows = [];
let eta_floor = [];
let mut effective = Vec::new();
compute_effective_eta(
&raw_noise,
2,
InflowNonNegativityMethod::Penalty,
&par_inflows,
&eta_floor,
&mut effective,
);
assert_eq!(effective, vec![0.5, -1.0]);
}
#[test]
fn test_compute_effective_eta_truncation_clamps_negative() {
let raw_noise = [-2.0, 1.0];
let par_inflows = [-5.0, 3.0];
let eta_floor = [-1.0, -0.5]; let mut effective = Vec::new();
compute_effective_eta(
&raw_noise,
2,
InflowNonNegativityMethod::Truncation,
&par_inflows,
&eta_floor,
&mut effective,
);
assert_eq!(effective, vec![-1.0, 1.0]);
}
#[test]
fn test_compute_effective_eta_truncation_passes_positive() {
let raw_noise = [-2.0, 1.0];
let par_inflows = [3.0, 5.0];
let eta_floor = [-1.0, -0.5]; let mut effective = Vec::new();
compute_effective_eta(
&raw_noise,
2,
InflowNonNegativityMethod::Truncation,
&par_inflows,
&eta_floor,
&mut effective,
);
assert_eq!(effective, vec![-2.0, 1.0]);
}
#[test]
fn test_compute_effective_eta_truncation_with_penalty_clamps() {
let raw_noise = [-2.0, 1.0];
let par_inflows = [-5.0, 3.0];
let eta_floor = [-1.0, -0.5];
let mut effective = Vec::new();
compute_effective_eta(
&raw_noise,
2,
InflowNonNegativityMethod::TruncationWithPenalty,
&par_inflows,
&eta_floor,
&mut effective,
);
assert_eq!(effective, vec![-1.0, 1.0]);
}
use cobre_core::temporal::StageLagTransition;
use crate::noise::{DownstreamAccumState, LagAccumState, accumulate_and_shift_lag_state};
fn noop_ds<'a>(
accumulator: &'a mut Vec<f64>,
weight_accum: &'a mut f64,
completed_lags: &'a mut Vec<f64>,
n_completed: &'a mut usize,
) -> DownstreamAccumState<'a> {
DownstreamAccumState {
accumulator: accumulator.as_mut_slice(),
weight_accum,
completed_lags: completed_lags.as_mut_slice(),
n_completed,
par_order: 0,
}
}
#[test]
fn test_accumulate_monthly_identity() {
let indexer = StageIndexer::new(1, 1);
let mut state_ref = vec![500.0, 99.0];
let incoming_lags = vec![42.0];
let mut primal = vec![0.0; 10];
primal[indexer.z_inflow.start] = 77.0;
shift_lag_state(&mut state_ref, &incoming_lags, &primal, &indexer);
let mut state_acc = vec![500.0, 99.0];
let mut lag_accumulator = vec![0.0_f64; 1];
let mut lag_weight_accum = 0.0_f64;
let stage_lag = StageLagTransition {
accumulate_weight: 1.0,
spillover_weight: 0.0,
finalize_period: true,
accumulate_downstream: false,
downstream_accumulate_weight: 0.0,
downstream_spillover_weight: 0.0,
downstream_finalize: false,
rebuild_from_downstream: false,
};
let mut ds_accum: Vec<f64> = vec![];
let mut ds_weight = 0.0_f64;
let mut ds_completed: Vec<f64> = vec![];
let mut ds_n_completed = 0_usize;
accumulate_and_shift_lag_state(
&mut state_acc,
&incoming_lags,
&primal,
&indexer,
&stage_lag,
&mut LagAccumState {
accumulator: &mut lag_accumulator,
weight_accum: &mut lag_weight_accum,
},
&mut noop_ds(
&mut ds_accum,
&mut ds_weight,
&mut ds_completed,
&mut ds_n_completed,
),
);
assert_eq!(
state_acc, state_ref,
"monthly identity must produce identical result to shift_lag_state"
);
assert_eq!(lag_accumulator[0], 0.0);
assert_eq!(lag_weight_accum, 0.0);
}
#[test]
fn test_accumulate_four_weeks_then_finalize() {
let indexer = StageIndexer::new(1, 1);
let mut state = vec![500.0, 0.0]; let incoming_lags = vec![0.0]; let mut lag_accumulator = vec![0.0_f64; 1];
let mut lag_weight_accum = 0.0_f64;
let z_inflows = [500.0_f64, 480.0, 520.0, 510.0];
let mut ds_accum: Vec<f64> = vec![];
let mut ds_weight = 0.0_f64;
let mut ds_completed: Vec<f64> = vec![];
let mut ds_n_completed = 0_usize;
for (week, &z) in z_inflows.iter().enumerate() {
let finalize = week == 3;
let stage_lag = StageLagTransition {
accumulate_weight: 0.25,
spillover_weight: 0.0,
finalize_period: finalize,
accumulate_downstream: false,
downstream_accumulate_weight: 0.0,
downstream_spillover_weight: 0.0,
downstream_finalize: false,
rebuild_from_downstream: false,
};
let mut primal = vec![0.0; 10];
primal[indexer.z_inflow.start] = z;
accumulate_and_shift_lag_state(
&mut state,
&incoming_lags,
&primal,
&indexer,
&stage_lag,
&mut LagAccumState {
accumulator: &mut lag_accumulator,
weight_accum: &mut lag_weight_accum,
},
&mut noop_ds(
&mut ds_accum,
&mut ds_weight,
&mut ds_completed,
&mut ds_n_completed,
),
);
}
let expected = (500.0 + 480.0 + 520.0 + 510.0) / 4.0;
assert!(
(state[indexer.inflow_lags.start] - expected).abs() < 1e-12,
"lag[0] must equal weighted average {expected}, got {}",
state[indexer.inflow_lags.start]
);
assert_eq!(lag_accumulator[0], 0.0);
assert_eq!(lag_weight_accum, 0.0);
}
#[test]
fn test_accumulate_spillover_seeds_next_period() {
let indexer = StageIndexer::new(1, 1);
let mut state = vec![0.0, 0.0];
let incoming_lags = vec![0.0];
let mut lag_accumulator = vec![0.0_f64; 1];
let mut lag_weight_accum = 0.0_f64;
let mut primal = vec![0.0; 10];
primal[indexer.z_inflow.start] = 200.0;
let stage_lag = StageLagTransition {
accumulate_weight: 0.968, spillover_weight: 0.032,
finalize_period: true,
accumulate_downstream: false,
downstream_accumulate_weight: 0.0,
downstream_spillover_weight: 0.0,
downstream_finalize: false,
rebuild_from_downstream: false,
};
let mut ds_accum: Vec<f64> = vec![];
let mut ds_weight = 0.0_f64;
let mut ds_completed: Vec<f64> = vec![];
let mut ds_n_completed = 0_usize;
accumulate_and_shift_lag_state(
&mut state,
&incoming_lags,
&primal,
&indexer,
&stage_lag,
&mut LagAccumState {
accumulator: &mut lag_accumulator,
weight_accum: &mut lag_weight_accum,
},
&mut noop_ds(
&mut ds_accum,
&mut ds_weight,
&mut ds_completed,
&mut ds_n_completed,
),
);
let expected_seed = 200.0 * 0.032;
assert!(
(lag_accumulator[0] - expected_seed).abs() < 1e-12,
"accumulator must be seeded with z_inflow * spillover_weight = {expected_seed}, got {}",
lag_accumulator[0]
);
assert!(
(lag_weight_accum - 0.032).abs() < 1e-12,
"lag_weight_accum must equal spillover_weight = 0.032, got {lag_weight_accum}"
);
}
#[test]
fn test_accumulate_noop_for_par0() {
let indexer = StageIndexer::new(2, 0); let mut state = vec![100.0, 200.0];
let incoming_lags: Vec<f64> = vec![];
let primal = vec![0.0; 10];
let mut lag_accumulator: Vec<f64> = vec![]; let mut lag_weight_accum = 0.0_f64;
let stage_lag = StageLagTransition {
accumulate_weight: 1.0,
spillover_weight: 0.0,
finalize_period: true,
accumulate_downstream: false,
downstream_accumulate_weight: 0.0,
downstream_spillover_weight: 0.0,
downstream_finalize: false,
rebuild_from_downstream: false,
};
let mut ds_accum: Vec<f64> = vec![];
let mut ds_weight = 0.0_f64;
let mut ds_completed: Vec<f64> = vec![];
let mut ds_n_completed = 0_usize;
accumulate_and_shift_lag_state(
&mut state,
&incoming_lags,
&primal,
&indexer,
&stage_lag,
&mut LagAccumState {
accumulator: &mut lag_accumulator,
weight_accum: &mut lag_weight_accum,
},
&mut noop_ds(
&mut ds_accum,
&mut ds_weight,
&mut ds_completed,
&mut ds_n_completed,
),
);
assert_eq!(
state,
vec![100.0, 200.0],
"state must be unchanged for PAR(0)"
);
assert_eq!(lag_weight_accum, 0.0, "weight must be unchanged for PAR(0)");
}
#[test]
fn test_accumulate_preserves_storage() {
let indexer = StageIndexer::new(2, 2);
let mut state = vec![100.0, 200.0, 0.0, 0.0, 0.0, 0.0];
let incoming_lags = vec![1.0, 2.0, 3.0, 4.0]; let mut primal = vec![0.0; 20];
primal[indexer.z_inflow.start] = 50.0;
primal[indexer.z_inflow.start + 1] = 60.0;
let mut lag_accumulator = vec![0.0_f64; 2];
let mut lag_weight_accum = 0.0_f64;
let stage_lag = StageLagTransition {
accumulate_weight: 1.0,
spillover_weight: 0.0,
finalize_period: true,
accumulate_downstream: false,
downstream_accumulate_weight: 0.0,
downstream_spillover_weight: 0.0,
downstream_finalize: false,
rebuild_from_downstream: false,
};
let mut ds_accum: Vec<f64> = vec![];
let mut ds_weight = 0.0_f64;
let mut ds_completed: Vec<f64> = vec![];
let mut ds_n_completed = 0_usize;
accumulate_and_shift_lag_state(
&mut state,
&incoming_lags,
&primal,
&indexer,
&stage_lag,
&mut LagAccumState {
accumulator: &mut lag_accumulator,
weight_accum: &mut lag_weight_accum,
},
&mut noop_ds(
&mut ds_accum,
&mut ds_weight,
&mut ds_completed,
&mut ds_n_completed,
),
);
assert_eq!(state[0], 100.0, "storage[0] must be preserved");
assert_eq!(state[1], 200.0, "storage[1] must be preserved");
}
fn monthly_with_downstream(
finalize_primary: bool,
downstream_finalize: bool,
) -> StageLagTransition {
StageLagTransition {
accumulate_weight: 1.0 / 3.0,
spillover_weight: 0.0,
finalize_period: finalize_primary,
accumulate_downstream: true,
downstream_accumulate_weight: 1.0 / 3.0,
downstream_spillover_weight: 0.0,
downstream_finalize,
rebuild_from_downstream: false,
}
}
#[allow(clippy::too_many_arguments)]
fn run_stage(
state: &mut [f64],
incoming_lags: &[f64],
z_inflow: f64,
indexer: &crate::indexer::StageIndexer,
stage_lag: &StageLagTransition,
lag: &mut LagAccumState<'_>,
ds: &mut DownstreamAccumState<'_>,
) {
let mut primal = vec![0.0; indexer.z_inflow.start + indexer.hydro_count + 4];
primal[indexer.z_inflow.start] = z_inflow;
accumulate_and_shift_lag_state(state, incoming_lags, &primal, indexer, stage_lag, lag, ds);
}
fn run_stage_2h(
state: &mut [f64],
incoming_lags: &[f64],
z_inflows: [f64; 2],
indexer: &crate::indexer::StageIndexer,
stage_lag: &StageLagTransition,
lag: &mut LagAccumState<'_>,
ds: &mut DownstreamAccumState<'_>,
) {
let n = indexer.z_inflow.start + indexer.hydro_count + 4;
let mut primal = vec![0.0; n];
primal[indexer.z_inflow.start] = z_inflows[0];
primal[indexer.z_inflow.start + 1] = z_inflows[1];
accumulate_and_shift_lag_state(state, incoming_lags, &primal, indexer, stage_lag, lag, ds);
}
#[test]
fn test_downstream_par1_accumulation_and_rebuild() {
let indexer = StageIndexer::new(1, 1);
let lag_start = indexer.inflow_lags.start;
let mut state = vec![500.0, 42.0];
let incoming_lags = vec![0.0];
let mut lag_acc = vec![0.0_f64; 1];
let mut lag_w = 0.0_f64;
let mut ds_acc = vec![0.0_f64; 1];
let mut ds_w = 0.0_f64;
let mut ds_completed = vec![0.0_f64; 1];
let mut ds_n = 0_usize;
let z_vals = [90.0_f64, 100.0, 110.0];
for (i, &z) in z_vals.iter().enumerate() {
let ds_finalize = i == 2; let stage_lag = monthly_with_downstream(false, ds_finalize);
run_stage(
&mut state,
&incoming_lags,
z,
&indexer,
&stage_lag,
&mut LagAccumState {
accumulator: &mut lag_acc,
weight_accum: &mut lag_w,
},
&mut DownstreamAccumState {
accumulator: &mut ds_acc,
weight_accum: &mut ds_w,
completed_lags: &mut ds_completed,
n_completed: &mut ds_n,
par_order: 1,
},
);
}
let expected_avg = (90.0 + 100.0 + 110.0) / 3.0;
assert!(
(ds_completed[0] - expected_avg).abs() < 1e-12,
"ring buf slot 0 should be {expected_avg}, got {}",
ds_completed[0]
);
assert_eq!(ds_n, 1, "n_completed must be 1 after one quarter");
let rebuild_lag = StageLagTransition {
accumulate_weight: 1.0,
spillover_weight: 0.0,
finalize_period: false,
accumulate_downstream: false,
downstream_accumulate_weight: 0.0,
downstream_spillover_weight: 0.0,
downstream_finalize: false,
rebuild_from_downstream: true,
};
run_stage(
&mut state,
&incoming_lags,
999.0, &indexer,
&rebuild_lag,
&mut LagAccumState {
accumulator: &mut lag_acc,
weight_accum: &mut lag_w,
},
&mut DownstreamAccumState {
accumulator: &mut ds_acc,
weight_accum: &mut ds_w,
completed_lags: &mut ds_completed,
n_completed: &mut ds_n,
par_order: 1,
},
);
assert!(
(state[lag_start] - expected_avg).abs() < 1e-12,
"state[lag_start] must be rebuilt to {expected_avg}, got {}",
state[lag_start]
);
assert_eq!(state[0], 500.0, "storage must be untouched during rebuild");
assert_eq!(ds_n, 0, "n_completed must reset to 0 after rebuild");
assert_eq!(
ds_completed[0], 0.0,
"completed_lags must be zeroed after rebuild"
);
assert_eq!(ds_w, 0.0, "downstream weight must reset after rebuild");
}
#[test]
#[allow(clippy::too_many_lines)]
fn test_downstream_par2_two_quarters() {
let indexer = StageIndexer::new(1, 2); let lag_start = indexer.inflow_lags.start;
let mut state = vec![0.0; 1 + 2]; let incoming_lags = vec![0.0, 0.0]; let mut lag_acc = vec![0.0_f64; 1];
let mut lag_w = 0.0_f64;
let mut ds_acc = vec![0.0_f64; 1];
let mut ds_w = 0.0_f64;
let mut ds_completed = vec![0.0_f64; 2];
let mut ds_n = 0_usize;
let q3_vals = [60.0_f64, 70.0, 80.0];
for (i, &z) in q3_vals.iter().enumerate() {
let stage_lag = monthly_with_downstream(false, i == 2);
run_stage(
&mut state,
&incoming_lags,
z,
&indexer,
&stage_lag,
&mut LagAccumState {
accumulator: &mut lag_acc,
weight_accum: &mut lag_w,
},
&mut DownstreamAccumState {
accumulator: &mut ds_acc,
weight_accum: &mut ds_w,
completed_lags: &mut ds_completed,
n_completed: &mut ds_n,
par_order: 2,
},
);
}
let q3_avg = (60.0 + 70.0 + 80.0) / 3.0;
assert!(
(ds_completed[0] - q3_avg).abs() < 1e-12,
"slot 0 should be Q3 avg {q3_avg}, got {}",
ds_completed[0]
);
assert_eq!(ds_n, 1);
let q4_vals = [90.0_f64, 100.0, 110.0];
for (i, &z) in q4_vals.iter().enumerate() {
let stage_lag = monthly_with_downstream(false, i == 2);
run_stage(
&mut state,
&incoming_lags,
z,
&indexer,
&stage_lag,
&mut LagAccumState {
accumulator: &mut lag_acc,
weight_accum: &mut lag_w,
},
&mut DownstreamAccumState {
accumulator: &mut ds_acc,
weight_accum: &mut ds_w,
completed_lags: &mut ds_completed,
n_completed: &mut ds_n,
par_order: 2,
},
);
}
let q4_avg = (90.0 + 100.0 + 110.0) / 3.0;
assert!(
(ds_completed[1] - q4_avg).abs() < 1e-12,
"slot 1 should be Q4 avg {q4_avg}, got {}",
ds_completed[1]
);
assert_eq!(ds_n, 2);
let rebuild_lag = StageLagTransition {
accumulate_weight: 1.0,
spillover_weight: 0.0,
finalize_period: false,
accumulate_downstream: false,
downstream_accumulate_weight: 0.0,
downstream_spillover_weight: 0.0,
downstream_finalize: false,
rebuild_from_downstream: true,
};
run_stage(
&mut state,
&incoming_lags,
999.0,
&indexer,
&rebuild_lag,
&mut LagAccumState {
accumulator: &mut lag_acc,
weight_accum: &mut lag_w,
},
&mut DownstreamAccumState {
accumulator: &mut ds_acc,
weight_accum: &mut ds_w,
completed_lags: &mut ds_completed,
n_completed: &mut ds_n,
par_order: 2,
},
);
assert!(
(state[lag_start] - q4_avg).abs() < 1e-12,
"lag[0] should be newest Q4 avg {q4_avg}, got {}",
state[lag_start]
);
assert!(
(state[lag_start + 1] - q3_avg).abs() < 1e-12,
"lag[1] should be Q3 avg {q3_avg}, got {}",
state[lag_start + 1]
);
}
#[test]
fn test_no_downstream_for_uniform_monthly() {
let indexer = StageIndexer::new(1, 1);
let mut state_ds = vec![500.0, 0.0]; let mut state_ref = vec![500.0, 0.0]; let incoming_lags = vec![0.0];
let z_inflows = [100.0_f64, 110.0, 120.0];
let mut lag_acc_ref = vec![0.0_f64; 1];
let mut lag_w_ref = 0.0_f64;
let mut lag_acc_ds = vec![0.0_f64; 1];
let mut lag_w_ds = 0.0_f64;
for (i, &z) in z_inflows.iter().enumerate() {
let finalize = i == 2;
let stage_lag = StageLagTransition {
accumulate_weight: 1.0 / 3.0,
spillover_weight: 0.0,
finalize_period: finalize,
accumulate_downstream: false,
downstream_accumulate_weight: 0.0,
downstream_spillover_weight: 0.0,
downstream_finalize: false,
rebuild_from_downstream: false,
};
let mut ds_accum_ref: Vec<f64> = vec![];
let mut ds_weight_ref = 0.0_f64;
let mut ds_completed_ref: Vec<f64> = vec![];
let mut ds_n_completed_ref = 0_usize;
run_stage(
&mut state_ref,
&incoming_lags,
z,
&indexer,
&stage_lag,
&mut LagAccumState {
accumulator: &mut lag_acc_ref,
weight_accum: &mut lag_w_ref,
},
&mut noop_ds(
&mut ds_accum_ref,
&mut ds_weight_ref,
&mut ds_completed_ref,
&mut ds_n_completed_ref,
),
);
let mut ds_accum_ds: Vec<f64> = vec![];
let mut ds_weight_ds = 0.0_f64;
let mut ds_completed_ds: Vec<f64> = vec![];
let mut ds_n_completed_ds = 0_usize;
run_stage(
&mut state_ds,
&incoming_lags,
z,
&indexer,
&stage_lag,
&mut LagAccumState {
accumulator: &mut lag_acc_ds,
weight_accum: &mut lag_w_ds,
},
&mut DownstreamAccumState {
accumulator: &mut ds_accum_ds,
weight_accum: &mut ds_weight_ds,
completed_lags: &mut ds_completed_ds,
n_completed: &mut ds_n_completed_ds,
par_order: 0,
},
);
}
assert_eq!(
state_ds, state_ref,
"uniform monthly study must be identical with or without downstream buffers"
);
}
#[test]
fn test_rebuild_resets_downstream_state() {
let indexer = StageIndexer::new(1, 1);
let mut state = vec![0.0, 0.0];
let incoming_lags = vec![0.0];
let mut lag_acc = vec![0.0_f64; 1];
let mut lag_w = 0.0_f64;
let mut ds_acc = vec![0.0_f64; 1];
let mut ds_w = 0.5_f64; let mut ds_completed = vec![77.0_f64; 1]; let mut ds_n = 1_usize;
let rebuild_lag = StageLagTransition {
accumulate_weight: 1.0,
spillover_weight: 0.0,
finalize_period: false,
accumulate_downstream: false,
downstream_accumulate_weight: 0.0,
downstream_spillover_weight: 0.0,
downstream_finalize: false,
rebuild_from_downstream: true,
};
run_stage(
&mut state,
&incoming_lags,
0.0,
&indexer,
&rebuild_lag,
&mut LagAccumState {
accumulator: &mut lag_acc,
weight_accum: &mut lag_w,
},
&mut DownstreamAccumState {
accumulator: &mut ds_acc,
weight_accum: &mut ds_w,
completed_lags: &mut ds_completed,
n_completed: &mut ds_n,
par_order: 1,
},
);
assert_eq!(ds_n, 0, "n_completed must reset to 0 after rebuild");
assert_eq!(
ds_completed[0], 0.0,
"completed_lags must be zeroed after rebuild"
);
assert_eq!(
ds_w, 0.0,
"downstream weight_accum must reset after rebuild"
);
assert_eq!(
ds_acc[0], 0.0,
"downstream accumulator must be zeroed after rebuild"
);
}
#[test]
fn test_downstream_spillover_seeds_next_quarter() {
let indexer = StageIndexer::new(1, 1);
let mut state = vec![0.0, 0.0];
let incoming_lags = vec![0.0];
let mut lag_acc = vec![0.0_f64; 1];
let mut lag_w = 0.0_f64;
let mut ds_acc = vec![0.0_f64; 1];
let mut ds_completed = vec![0.0_f64; 1];
let mut ds_n = 0_usize;
ds_acc[0] = 200.0; let mut ds_w = 2.0 / 3.0_f64;
let spillover_weight = 0.1;
let stage_lag = StageLagTransition {
accumulate_weight: 1.0 / 3.0,
spillover_weight: 0.0,
finalize_period: false,
accumulate_downstream: true,
downstream_accumulate_weight: 1.0 / 3.0,
downstream_spillover_weight: spillover_weight,
downstream_finalize: true,
rebuild_from_downstream: false,
};
let z = 120.0_f64;
run_stage(
&mut state,
&incoming_lags,
z,
&indexer,
&stage_lag,
&mut LagAccumState {
accumulator: &mut lag_acc,
weight_accum: &mut lag_w,
},
&mut DownstreamAccumState {
accumulator: &mut ds_acc,
weight_accum: &mut ds_w,
completed_lags: &mut ds_completed,
n_completed: &mut ds_n,
par_order: 1,
},
);
assert_eq!(ds_n, 1, "one quarter must be finalized");
let expected_seed = z * spillover_weight;
assert!(
(ds_acc[0] - expected_seed).abs() < 1e-12,
"accumulator should be seeded with {expected_seed}, got {}",
ds_acc[0]
);
assert!(
(ds_w - spillover_weight).abs() < 1e-12,
"weight_accum should be {spillover_weight}, got {ds_w}"
);
}
#[test]
fn test_downstream_multi_hydro() {
let indexer = StageIndexer::new(2, 1);
let lag_start = indexer.inflow_lags.start;
let mut state = vec![0.0; 2 + 2]; let incoming_lags = vec![0.0, 0.0]; let mut lag_acc = vec![0.0_f64; 2];
let mut lag_w = 0.0_f64;
let mut ds_acc = vec![0.0_f64; 2];
let mut ds_w = 0.0_f64;
let mut ds_completed = vec![0.0_f64; 2];
let mut ds_n = 0_usize;
let h0_vals = [10.0_f64, 20.0, 30.0];
let h1_vals = [40.0_f64, 50.0, 60.0];
for (i, (&z0, &z1)) in h0_vals.iter().zip(h1_vals.iter()).enumerate() {
let stage_lag = monthly_with_downstream(false, i == 2);
run_stage_2h(
&mut state,
&incoming_lags,
[z0, z1],
&indexer,
&stage_lag,
&mut LagAccumState {
accumulator: &mut lag_acc,
weight_accum: &mut lag_w,
},
&mut DownstreamAccumState {
accumulator: &mut ds_acc,
weight_accum: &mut ds_w,
completed_lags: &mut ds_completed,
n_completed: &mut ds_n,
par_order: 1,
},
);
}
let expected_h0 = (10.0 + 20.0 + 30.0) / 3.0;
let expected_h1 = (40.0 + 50.0 + 60.0) / 3.0;
assert!(
(ds_completed[0] - expected_h0).abs() < 1e-12,
"hydro 0 quarterly avg should be {expected_h0}, got {}",
ds_completed[0]
);
assert!(
(ds_completed[1] - expected_h1).abs() < 1e-12,
"hydro 1 quarterly avg should be {expected_h1}, got {}",
ds_completed[1]
);
assert_eq!(ds_n, 1);
let rebuild_lag = StageLagTransition {
accumulate_weight: 1.0,
spillover_weight: 0.0,
finalize_period: false,
accumulate_downstream: false,
downstream_accumulate_weight: 0.0,
downstream_spillover_weight: 0.0,
downstream_finalize: false,
rebuild_from_downstream: true,
};
run_stage_2h(
&mut state,
&incoming_lags,
[999.0, 888.0],
&indexer,
&rebuild_lag,
&mut LagAccumState {
accumulator: &mut lag_acc,
weight_accum: &mut lag_w,
},
&mut DownstreamAccumState {
accumulator: &mut ds_acc,
weight_accum: &mut ds_w,
completed_lags: &mut ds_completed,
n_completed: &mut ds_n,
par_order: 1,
},
);
assert!(
(state[lag_start] - expected_h0).abs() < 1e-12,
"rebuilt lag[0] hydro 0 should be {expected_h0}, got {}",
state[lag_start]
);
assert!(
(state[lag_start + 1] - expected_h1).abs() < 1e-12,
"rebuilt lag[0] hydro 1 should be {expected_h1}, got {}",
state[lag_start + 1]
);
}
#[test]
fn shift_anticipated_state_pre_horizon_seed_three_stage_evolution() {
let indexer = make_anticipated_indexer(1, 2, vec![2]);
let ant_start = indexer.anticipated_state.start;
let dec_start = indexer.anticipated_decision.start;
let mut state = vec![0.0_f64; indexer.n_state];
state[ant_start] = 10.0;
state[ant_start + 1] = 20.0;
let mut incoming = vec![0.0_f64; 2];
let mut primal = vec![0.0_f64; dec_start + 2];
incoming.copy_from_slice(&state[ant_start..ant_start + 2]);
primal[dec_start] = 30.0;
shift_anticipated_state(&mut state, &incoming, &primal, &indexer);
assert_eq!(state[ant_start], 20.0);
assert_eq!(state[ant_start + 1], 30.0);
incoming.copy_from_slice(&state[ant_start..ant_start + 2]);
primal[dec_start] = 40.0;
shift_anticipated_state(&mut state, &incoming, &primal, &indexer);
assert_eq!(state[ant_start], 30.0);
assert_eq!(state[ant_start + 1], 40.0);
incoming.copy_from_slice(&state[ant_start..ant_start + 2]);
primal[dec_start] = 50.0;
shift_anticipated_state(&mut state, &incoming, &primal, &indexer);
assert_eq!(state[ant_start], 40.0);
assert_eq!(state[ant_start + 1], 50.0);
}
}