use std::collections::HashMap;
use std::ops::Range;
use cobre_core::ConstraintSense;
use cobre_core::EntityId;
use crate::energy_conversion::EnergyConversionSet;
use crate::indexer::StageIndexer;
use crate::lp_builder::{COST_SCALE_FACTOR, GenericConstraintRowEntry};
use crate::simulation::types::{
ScenarioCategoryCosts, SimulationBusResult, SimulationContractResult, SimulationCostResult,
SimulationExchangeResult, SimulationGenericViolationResult, SimulationHydroResult,
SimulationInflowLagResult, SimulationNonControllableResult, SimulationPumpingResult,
SimulationStageResult, SimulationThermalResult,
};
pub(crate) struct HydroReverseLookup {
pub(crate) fpha: Vec<Option<usize>>,
pub(crate) evap: Vec<Option<usize>>,
}
impl HydroReverseLookup {
pub(crate) fn build(indexer: &StageIndexer, n_hydros: usize) -> Self {
let mut fpha = vec![None; n_hydros];
for (local, &sys) in indexer.fpha_hydro_indices.iter().enumerate() {
fpha[sys] = Some(local);
}
let mut evap = vec![None; n_hydros];
for (local, &sys) in indexer.evap_hydro_indices.iter().enumerate() {
evap[sys] = Some(local);
}
Self { fpha, evap }
}
}
pub(crate) struct ThermalReverseLookup {
pub(crate) thermal_is_anticipated: Vec<Option<usize>>,
}
impl ThermalReverseLookup {
pub(crate) fn build(indexer: &StageIndexer, n_thermals: usize) -> Self {
let mut thermal_is_anticipated = vec![None; n_thermals];
for (local, &sys) in indexer.anticipated_thermal_indices.iter().enumerate() {
debug_assert!(
sys < n_thermals,
"anticipated_thermal_indices entry {sys} >= n_thermals {n_thermals}"
);
thermal_is_anticipated[sys] = Some(local);
}
Self {
thermal_is_anticipated,
}
}
}
#[inline]
fn compute_anticipated_decision_mw(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
lookup: &ThermalReverseLookup,
thermal_local: usize,
) -> Option<f64> {
let local_idx = lookup.thermal_is_anticipated[thermal_local]?;
let k_i = spec.indexer.anticipated_lead_stages[local_idx];
if spec.stage_index.saturating_add(k_i) >= spec.n_stages {
return None;
}
let col = spec.indexer.anticipated_decision.start + local_idx;
debug_assert!(
col < view.primal.len(),
"anticipated_decision col {col} out of primal bounds {}",
view.primal.len(),
);
Some(view.primal[col])
}
#[inline]
fn compute_anticipated_committed_mw(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
lookup: &ThermalReverseLookup,
thermal_local: usize,
) -> Option<f64> {
let local_idx = lookup.thermal_is_anticipated[thermal_local]?;
if !spec
.indexer
.is_anticipated_fishing_active(local_idx, spec.stage_index, spec.n_stages)
{
return None;
}
let col = spec.indexer.anticipated_state.start + local_idx;
debug_assert!(
col < view.primal.len(),
"anticipated_state slot-0 col {col} out of primal bounds {}",
view.primal.len(),
);
Some(view.primal[col])
}
#[derive(Debug, Clone)]
pub struct EntityCounts {
pub hydro_ids: Vec<i32>,
pub thermal_ids: Vec<i32>,
pub line_ids: Vec<i32>,
pub bus_ids: Vec<i32>,
pub hydro_productivities: Vec<f64>,
pub pumping_station_ids: Vec<i32>,
pub contract_ids: Vec<i32>,
pub non_controllable_ids: Vec<i32>,
}
#[must_use]
pub fn assign_scenarios(n_scenarios: u32, rank: usize, world_size: usize) -> Range<u32> {
debug_assert!(world_size > 0, "world_size must be > 0");
let n = n_scenarios as usize;
let r = world_size;
let fat_count = n % r;
let fat_size = n / r + 1; let lean_size = n / r;
let start: usize = if rank < fat_count {
rank * fat_size
} else {
fat_count * fat_size + (rank - fat_count) * lean_size
};
let size = if rank < fat_count {
fat_size
} else {
lean_size
};
let end = start + size;
#[allow(clippy::cast_possible_truncation)]
{
(start as u32)..(end as u32)
}
}
pub struct SolutionView<'a> {
pub primal: &'a [f64],
pub dual: &'a [f64],
pub objective: f64,
pub objective_coeffs: &'a [f64],
pub row_lower: &'a [f64],
}
pub const ENERGY_FACTOR_MWH_PER_HM3_PER_MW_PER_M3S: f64 = 1.0e6 / 3600.0;
pub struct StageExtractionSpec<'a> {
pub indexer: &'a StageIndexer,
pub entity_counts: &'a EntityCounts,
pub inflow_m3s_per_hydro: &'a [f64],
pub block_hours: &'a [f64],
pub generic_constraint_entries: &'a [GenericConstraintRowEntry],
pub ncs_col_start: usize,
pub n_ncs: usize,
pub ncs_entity_ids: &'a [i32],
pub ncs_col_upper: &'a [f64],
pub diversion_upstream: &'a HashMap<EntityId, Vec<usize>>,
pub hydro_productivities: &'a [f64],
pub col_scale: &'a [f64],
pub row_scale: &'a [f64],
pub cumulative_discount_factor: f64,
pub energy_conversion: &'a EnergyConversionSet,
pub hydro_min_storage_hm3: &'a [f64],
pub stage_index: usize,
pub n_stages: usize,
}
impl StageExtractionSpec<'_> {
#[inline]
fn col_scale_factor(&self, col: usize) -> f64 {
if col < self.col_scale.len() {
let d = self.col_scale[col];
if d == 0.0 { 1.0 } else { d }
} else {
1.0
}
}
}
fn extract_hydro_no_turbine(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
lookup: &HydroReverseLookup,
h: usize,
hydro_id: i32,
stage_id: u32,
) -> SimulationHydroResult {
let indexer = spec.indexer;
let incremental_inflow = if h < spec.inflow_m3s_per_hydro.len() {
spec.inflow_m3s_per_hydro[h]
} else if indexer.max_par_order > 0 {
view.primal[indexer.inflow_lags.start + h]
} else {
0.0
};
let inflow_slack = if indexer.has_inflow_penalty {
view.primal[indexer.inflow_slack.start + h]
} else {
0.0
};
let withdrawal_neg = if indexer.has_withdrawal {
view.primal[indexer.withdrawal_slack_neg.start + h]
} else {
0.0
};
let withdrawal_pos = if indexer.has_withdrawal {
view.primal[indexer.withdrawal_slack_pos.start + h]
} else {
0.0
};
let water_value = view
.dual
.get(indexer.water_balance.start + h)
.copied()
.unwrap_or(0.0)
* COST_SCALE_FACTOR;
let (turbined_slack, outflow_slack_below, outflow_slack_above, generation_slack) =
if indexer.has_operational_violations {
let n_blks = indexer.n_blks;
let base_turbine_below = indexer.turbine_below_slack.start + h * n_blks;
let base_outflow_below = indexer.outflow_below_slack.start + h * n_blks;
let base_outflow_above = indexer.outflow_above_slack.start + h * n_blks;
let base_gen_below = indexer.generation_below_slack.start + h * n_blks;
let mut tb = 0.0_f64;
let mut ob = 0.0_f64;
let mut oa = 0.0_f64;
let mut gb = 0.0_f64;
let total_hours: f64 = spec.block_hours.iter().sum();
for blk in 0..n_blks {
let w = spec.block_hours[blk] / total_hours;
tb += view.primal[base_turbine_below + blk] * w;
ob += view.primal[base_outflow_below + blk] * w;
oa += view.primal[base_outflow_above + blk] * w;
gb += view.primal[base_gen_below + blk] * w;
}
(tb, ob, oa, gb)
} else {
(0.0, 0.0, 0.0, 0.0)
};
let (evaporation_m3s, evaporation_violation_neg_m3s, evaporation_violation_pos_m3s) =
if let Some(local_evap_idx) = lookup.evap[h] {
let ei = &indexer.evap_indices[local_evap_idx];
let evaporation_flow = view.primal[ei.evaporation_flow_col];
let neg = view.primal[ei.f_evap_plus_col]; let pos = view.primal[ei.f_evap_minus_col]; (Some(evaporation_flow), neg, pos)
} else {
(Some(0.0), 0.0, 0.0)
};
let conv = spec.energy_conversion.conversion(h, spec.stage_index);
let rho_acum = spec
.energy_conversion
.accumulated_productivity(h, spec.stage_index);
let v_min = spec.hydro_min_storage_hm3.get(h).copied().unwrap_or(0.0);
let storage_initial = view.primal[indexer.storage_in.start + h];
let storage_final = view.primal[indexer.storage.start + h];
SimulationHydroResult {
stage_id,
block_id: None,
hydro_id,
turbined_m3s: 0.0,
spillage_m3s: 0.0,
evaporation_m3s,
diverted_inflow_m3s: Some(0.0),
diverted_outflow_m3s: Some(0.0),
incremental_inflow_m3s: incremental_inflow,
inflow_m3s: incremental_inflow,
storage_initial_hm3: storage_initial,
storage_final_hm3: storage_final,
generation_mw: 0.0,
equivalent_productivity_mw_per_m3s: conv.equivalent_productivity_mw_per_m3s,
accumulated_productivity_mw_per_m3s: rho_acum,
incremental_inflow_energy_mw: rho_acum * incremental_inflow,
stored_energy_initial_mwh: (storage_initial - v_min)
* rho_acum
* ENERGY_FACTOR_MWH_PER_HM3_PER_MW_PER_M3S,
stored_energy_final_mwh: (storage_final - v_min)
* rho_acum
* ENERGY_FACTOR_MWH_PER_HM3_PER_MW_PER_M3S,
spillage_cost: 0.0,
water_value_per_hm3: water_value,
storage_binding_code: 0,
operative_state_code: 1,
turbined_slack_m3s: turbined_slack,
outflow_slack_below_m3s: outflow_slack_below,
outflow_slack_above_m3s: outflow_slack_above,
generation_slack_mw: generation_slack,
storage_violation_below_hm3: 0.0,
filling_target_violation_hm3: 0.0,
evaporation_violation_pos_m3s,
evaporation_violation_neg_m3s,
inflow_nonnegativity_slack_m3s: inflow_slack,
water_withdrawal_violation_pos_m3s: withdrawal_pos,
water_withdrawal_violation_neg_m3s: withdrawal_neg,
}
}
struct HydroStageContext {
storage_final: f64,
storage_initial: f64,
incremental_inflow: f64,
inflow_slack: f64,
withdrawal_neg: f64,
withdrawal_pos: f64,
water_value: f64,
fpha_local: Option<usize>,
equivalent_productivity_mw_per_m3s: f64,
accumulated_productivity_mw_per_m3s: f64,
incremental_inflow_energy_mw: f64,
stored_energy_initial_mwh: f64,
stored_energy_final_mwh: f64,
evaporation_m3s: Option<f64>,
evaporation_violation_neg_m3s: f64,
evaporation_violation_pos_m3s: f64,
}
impl HydroStageContext {
fn new(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
lookup: &HydroReverseLookup,
h: usize,
) -> Self {
let indexer = spec.indexer;
let storage_final = view.primal[indexer.storage.start + h];
let storage_initial = view.primal[indexer.storage_in.start + h];
let incremental_inflow = if h < spec.inflow_m3s_per_hydro.len() {
spec.inflow_m3s_per_hydro[h]
} else if indexer.max_par_order > 0 {
view.primal[indexer.inflow_lags.start + h]
} else {
0.0
};
let inflow_slack = if indexer.has_inflow_penalty {
view.primal[indexer.inflow_slack.start + h]
} else {
0.0
};
let withdrawal_neg = if indexer.has_withdrawal {
view.primal[indexer.withdrawal_slack_neg.start + h]
} else {
0.0
};
let withdrawal_pos = if indexer.has_withdrawal {
view.primal[indexer.withdrawal_slack_pos.start + h]
} else {
0.0
};
let water_value = view
.dual
.get(indexer.water_balance.start + h)
.copied()
.unwrap_or(0.0)
* COST_SCALE_FACTOR;
let fpha_local = lookup.fpha[h];
let local_evap = lookup.evap[h];
let (evaporation_m3s, evaporation_violation_neg_m3s, evaporation_violation_pos_m3s) =
if let Some(lei) = local_evap {
let ei = &indexer.evap_indices[lei];
let evaporation_flow = view.primal[ei.evaporation_flow_col];
let neg = view.primal[ei.f_evap_plus_col];
let pos = view.primal[ei.f_evap_minus_col];
(Some(evaporation_flow), neg, pos)
} else {
(Some(0.0), 0.0, 0.0)
};
let conv = spec.energy_conversion.conversion(h, spec.stage_index);
let rho_acum = spec
.energy_conversion
.accumulated_productivity(h, spec.stage_index);
let v_min = spec.hydro_min_storage_hm3.get(h).copied().unwrap_or(0.0);
Self {
storage_final,
storage_initial,
incremental_inflow,
inflow_slack,
withdrawal_neg,
withdrawal_pos,
water_value,
fpha_local,
equivalent_productivity_mw_per_m3s: conv.equivalent_productivity_mw_per_m3s,
accumulated_productivity_mw_per_m3s: rho_acum,
incremental_inflow_energy_mw: rho_acum * incremental_inflow,
stored_energy_initial_mwh: (storage_initial - v_min)
* rho_acum
* ENERGY_FACTOR_MWH_PER_HM3_PER_MW_PER_M3S,
stored_energy_final_mwh: (storage_final - v_min)
* rho_acum
* ENERGY_FACTOR_MWH_PER_HM3_PER_MW_PER_M3S,
evaporation_m3s,
evaporation_violation_neg_m3s,
evaporation_violation_pos_m3s,
}
}
}
fn extract_hydro_per_block<'a>(
view: &'a SolutionView<'a>,
spec: &'a StageExtractionSpec<'a>,
lookup: &'a HydroReverseLookup,
h: usize,
hydro_id: i32,
stage_id: u32,
) -> impl Iterator<Item = SimulationHydroResult> + 'a {
let indexer = spec.indexer;
let n_blks = indexer.n_blks;
let ctx = HydroStageContext::new(view, spec, lookup, h);
let hydro_entity_id = EntityId(hydro_id);
let div_sources = spec.diversion_upstream.get(&hydro_entity_id);
(0..n_blks).map(move |b| {
let t_col = indexer.turbine.start + h * n_blks + b;
let s_col = indexer.spillage.start + h * n_blks + b;
let turbined = view.primal[t_col];
let spillage = view.primal[s_col];
let diverted_outflow = if indexer.diversion.is_empty() {
0.0
} else {
view.primal[indexer.diversion.start + h * n_blks + b]
};
let diverted_inflow = if let Some(sources) = div_sources {
let mut total = 0.0;
for &d_idx in sources {
total += view.primal[indexer.diversion.start + d_idx * n_blks + b];
}
total
} else {
0.0
};
let generation_mw = if let Some(local_fpha_idx) = ctx.fpha_local {
view.primal[indexer.generation.start + local_fpha_idx * n_blks + b]
} else {
turbined * spec.hydro_productivities[h]
};
let (turbined_slack, outflow_slack_below, outflow_slack_above, generation_slack) =
if indexer.has_operational_violations {
let n = indexer.n_blks;
(
view.primal[indexer.turbine_below_slack.start + h * n + b],
view.primal[indexer.outflow_below_slack.start + h * n + b],
view.primal[indexer.outflow_above_slack.start + h * n + b],
view.primal[indexer.generation_below_slack.start + h * n + b],
)
} else {
(0.0, 0.0, 0.0, 0.0)
};
#[allow(clippy::cast_possible_truncation)]
SimulationHydroResult {
stage_id,
block_id: Some(b as u32),
hydro_id,
turbined_m3s: turbined,
spillage_m3s: spillage,
evaporation_m3s: ctx.evaporation_m3s,
diverted_inflow_m3s: Some(diverted_inflow),
diverted_outflow_m3s: Some(diverted_outflow),
incremental_inflow_m3s: ctx.incremental_inflow,
inflow_m3s: ctx.incremental_inflow,
storage_initial_hm3: ctx.storage_initial,
storage_final_hm3: ctx.storage_final,
generation_mw,
equivalent_productivity_mw_per_m3s: ctx.equivalent_productivity_mw_per_m3s,
accumulated_productivity_mw_per_m3s: ctx.accumulated_productivity_mw_per_m3s,
incremental_inflow_energy_mw: ctx.incremental_inflow_energy_mw,
stored_energy_initial_mwh: ctx.stored_energy_initial_mwh,
stored_energy_final_mwh: ctx.stored_energy_final_mwh,
spillage_cost: spillage * view.objective_coeffs[s_col] / spec.col_scale_factor(s_col)
* COST_SCALE_FACTOR,
water_value_per_hm3: ctx.water_value,
storage_binding_code: 0,
operative_state_code: 1,
turbined_slack_m3s: turbined_slack,
outflow_slack_below_m3s: outflow_slack_below,
outflow_slack_above_m3s: outflow_slack_above,
generation_slack_mw: generation_slack,
storage_violation_below_hm3: 0.0,
filling_target_violation_hm3: 0.0,
evaporation_violation_pos_m3s: ctx.evaporation_violation_pos_m3s,
evaporation_violation_neg_m3s: ctx.evaporation_violation_neg_m3s,
inflow_nonnegativity_slack_m3s: ctx.inflow_slack,
water_withdrawal_violation_pos_m3s: ctx.withdrawal_pos,
water_withdrawal_violation_neg_m3s: ctx.withdrawal_neg,
}
})
}
fn extract_hydros(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
stage_id: u32,
lookup: &HydroReverseLookup,
) -> Vec<SimulationHydroResult> {
let indexer = spec.indexer;
if indexer.turbine.is_empty() || indexer.n_blks == 0 {
spec.entity_counts
.hydro_ids
.iter()
.enumerate()
.map(|(h, &hydro_id)| {
extract_hydro_no_turbine(view, spec, lookup, h, hydro_id, stage_id)
})
.collect()
} else {
spec.entity_counts
.hydro_ids
.iter()
.enumerate()
.flat_map(|(h, &hydro_id)| {
extract_hydro_per_block(view, spec, lookup, h, hydro_id, stage_id)
})
.collect()
}
}
fn extract_thermals(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
stage_id: u32,
lookup: &ThermalReverseLookup,
) -> Vec<SimulationThermalResult> {
let indexer = spec.indexer;
let n_blks = indexer.n_blks;
if indexer.thermal.is_empty() || n_blks == 0 {
spec.entity_counts
.thermal_ids
.iter()
.enumerate()
.map(|(t, &thermal_id)| SimulationThermalResult {
stage_id,
block_id: None,
thermal_id,
generation_mw: 0.0,
generation_cost: 0.0,
is_anticipated: lookup.thermal_is_anticipated[t].is_some(),
anticipated_committed_mw: compute_anticipated_committed_mw(view, spec, lookup, t),
anticipated_decision_mw: compute_anticipated_decision_mw(view, spec, lookup, t),
operative_state_code: 1,
})
.collect()
} else {
let mut results = Vec::with_capacity(spec.entity_counts.thermal_ids.len() * n_blks);
for (t, &thermal_id) in spec.entity_counts.thermal_ids.iter().enumerate() {
let is_anticipated = lookup.thermal_is_anticipated[t].is_some();
let anticipated_decision_mw = compute_anticipated_decision_mw(view, spec, lookup, t);
let anticipated_committed_mw = compute_anticipated_committed_mw(view, spec, lookup, t);
for b in 0..n_blks {
let col = indexer.thermal.start + t * n_blks + b;
let gen_mw = view.primal[col];
#[allow(clippy::cast_possible_truncation)]
results.push(SimulationThermalResult {
stage_id,
block_id: Some(b as u32),
thermal_id,
generation_mw: gen_mw,
generation_cost: gen_mw * view.objective_coeffs[col]
/ spec.col_scale_factor(col)
* COST_SCALE_FACTOR,
is_anticipated,
anticipated_committed_mw,
anticipated_decision_mw,
operative_state_code: 1,
});
}
}
results
}
}
fn extract_exchanges(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
stage_id: u32,
) -> Vec<SimulationExchangeResult> {
let indexer = spec.indexer;
let n_blks = indexer.n_blks;
if indexer.line_fwd.is_empty() || n_blks == 0 {
spec.entity_counts
.line_ids
.iter()
.map(|&line_id| SimulationExchangeResult {
stage_id,
block_id: None,
line_id,
direct_flow_mw: 0.0,
reverse_flow_mw: 0.0,
exchange_cost: 0.0,
operative_state_code: 1,
})
.collect()
} else {
spec.entity_counts
.line_ids
.iter()
.enumerate()
.flat_map(|(l, &line_id)| {
(0..n_blks).map(move |b| {
let fwd_col = indexer.line_fwd.start + l * n_blks + b;
let rev_col = indexer.line_rev.start + l * n_blks + b;
let fwd = view.primal[fwd_col];
let rev = view.primal[rev_col];
#[allow(clippy::cast_possible_truncation)]
SimulationExchangeResult {
stage_id,
block_id: Some(b as u32),
line_id,
direct_flow_mw: fwd,
reverse_flow_mw: rev,
exchange_cost: (fwd * view.objective_coeffs[fwd_col]
/ spec.col_scale_factor(fwd_col)
+ rev * view.objective_coeffs[rev_col]
/ spec.col_scale_factor(rev_col))
* COST_SCALE_FACTOR,
operative_state_code: 2,
}
})
})
.collect()
}
}
fn extract_buses(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
stage_id: u32,
) -> Vec<SimulationBusResult> {
let indexer = spec.indexer;
let n_blks = indexer.n_blks;
if indexer.deficit.is_empty() || n_blks == 0 {
spec.entity_counts
.bus_ids
.iter()
.map(|&bus_id| SimulationBusResult {
stage_id,
block_id: None,
bus_id,
load_mw: 0.0,
deficit_mw: 0.0,
excess_mw: 0.0,
spot_price: 0.0,
})
.collect()
} else {
let max_segs = indexer.max_deficit_segments;
spec.entity_counts
.bus_ids
.iter()
.enumerate()
.flat_map(move |(bus_idx, &bus_id)| {
(0..n_blks).map(move |b| {
let deficit_mw: f64 = (0..max_segs)
.map(|s| {
let col = indexer.deficit.start
+ bus_idx * max_segs * n_blks
+ s * n_blks
+ b;
view.primal[col]
})
.sum();
let excess_col = indexer.excess.start + bus_idx * n_blks + b;
let load_row = indexer.load_balance.start + bus_idx * n_blks + b;
let raw_dual = view.dual.get(load_row).copied().unwrap_or(0.0);
let hrs = spec.block_hours.get(b).copied().unwrap_or(0.0);
#[allow(clippy::cast_possible_truncation)]
SimulationBusResult {
stage_id,
block_id: Some(b as u32),
bus_id,
load_mw: view.row_lower[load_row],
deficit_mw,
excess_mw: view.primal[excess_col],
spot_price: if hrs > 0.0 {
raw_dual * COST_SCALE_FACTOR / hrs
} else {
0.0
},
}
})
})
.collect()
}
}
#[must_use]
pub fn extract_stage_result(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
stage_id: u32,
) -> SimulationStageResult {
let n_hydros = spec.entity_counts.hydro_ids.len();
let n_thermals = spec.entity_counts.thermal_ids.len();
let hydro_lookup = HydroReverseLookup::build(spec.indexer, n_hydros);
let thermal_lookup = ThermalReverseLookup::build(spec.indexer, n_thermals);
extract_stage_result_with_lookups(view, spec, stage_id, &hydro_lookup, &thermal_lookup)
}
pub(crate) fn extract_stage_result_with_lookups(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
stage_id: u32,
hydro_lookup: &HydroReverseLookup,
thermal_lookup: &ThermalReverseLookup,
) -> SimulationStageResult {
let indexer = spec.indexer;
debug_assert!(
view.primal.len() > indexer.theta,
"primal vector too short: len={}, need > theta={}",
view.primal.len(),
indexer.theta
);
debug_assert!(
spec.entity_counts.hydro_ids.len() == indexer.hydro_count,
"hydro_ids length {} does not match indexer.hydro_count {}",
spec.entity_counts.hydro_ids.len(),
indexer.hydro_count
);
debug_assert!(
indexer.excess.is_empty() || view.objective_coeffs.len() >= indexer.excess.end,
"objective_coeffs too short: len={}, need >= excess.end={}",
view.objective_coeffs.len(),
indexer.excess.end
);
debug_assert!(
spec.entity_counts.hydro_productivities.len() == indexer.hydro_count,
"hydro_productivities length {} does not match indexer.hydro_count {}",
spec.entity_counts.hydro_productivities.len(),
indexer.hydro_count
);
debug_assert!(
indexer.load_balance.is_empty() || view.row_lower.len() >= indexer.load_balance.end,
"row_lower too short: len={}, need >= load_balance.end={}",
view.row_lower.len(),
indexer.load_balance.end
);
let (generic_violations, generic_violation_cost) =
extract_generic_violations(view, spec, stage_id);
let (non_controllables, ncs_curtailment_cost) = extract_non_controllables(view, spec, stage_id);
let costs = vec![compute_cost_result(
view,
spec.indexer,
spec.col_scale,
generic_violation_cost,
spec.cumulative_discount_factor,
ncs_curtailment_cost,
stage_id,
)];
let (inflow_lags, pumping_stations, contracts) = extract_stub_collections(view, spec, stage_id);
SimulationStageResult {
stage_id,
costs,
hydros: extract_hydros(view, spec, stage_id, hydro_lookup),
thermals: extract_thermals(view, spec, stage_id, thermal_lookup),
exchanges: extract_exchanges(view, spec, stage_id),
buses: extract_buses(view, spec, stage_id),
pumping_stations,
contracts,
non_controllables,
inflow_lags,
generic_violations,
}
}
struct HydroViolationCosts {
evaporation: f64,
withdrawal: f64,
outflow_below: f64,
outflow_above: f64,
turbined: f64,
generation: f64,
}
impl HydroViolationCosts {
fn total(&self) -> f64 {
self.evaporation
+ self.withdrawal
+ self.outflow_below
+ self.outflow_above
+ self.turbined
+ self.generation
}
}
fn compute_hydro_violation_costs(
indexer: &StageIndexer,
col_cost: impl Fn(usize) -> f64,
range_sum: impl Fn(std::ops::Range<usize>) -> f64,
) -> HydroViolationCosts {
let evaporation = indexer
.evap_indices
.iter()
.map(|ei| col_cost(ei.f_evap_plus_col) + col_cost(ei.f_evap_minus_col))
.sum::<f64>()
* COST_SCALE_FACTOR;
let withdrawal = if indexer.withdrawal_slack_neg.is_empty() {
0.0
} else {
(range_sum(indexer.withdrawal_slack_neg.clone())
+ range_sum(indexer.withdrawal_slack_pos.clone()))
* COST_SCALE_FACTOR
};
let (outflow_below, outflow_above, turbined, generation) = if indexer.has_operational_violations
{
(
range_sum(indexer.outflow_below_slack.clone()) * COST_SCALE_FACTOR,
range_sum(indexer.outflow_above_slack.clone()) * COST_SCALE_FACTOR,
range_sum(indexer.turbine_below_slack.clone()) * COST_SCALE_FACTOR,
range_sum(indexer.generation_below_slack.clone()) * COST_SCALE_FACTOR,
)
} else {
(0.0, 0.0, 0.0, 0.0)
};
HydroViolationCosts {
evaporation,
withdrawal,
outflow_below,
outflow_above,
turbined,
generation,
}
}
fn compute_cost_result(
view: &SolutionView<'_>,
indexer: &StageIndexer,
col_scale: &[f64],
generic_violation_cost: f64,
cumulative_discount_factor: f64,
ncs_curtailment_cost: f64,
stage_id: u32,
) -> SimulationCostResult {
let scale_factor = |col: usize| -> f64 {
if col < col_scale.len() {
let d = col_scale[col];
if d == 0.0 { 1.0 } else { d }
} else {
1.0
}
};
let col_cost = |col: usize| view.primal[col] * view.objective_coeffs[col] / scale_factor(col);
let range_sum = |r: std::ops::Range<usize>| -> f64 { r.map(col_cost).sum() };
let theta_obj_coeff = view
.objective_coeffs
.get(indexer.theta)
.copied()
.unwrap_or(1.0);
let theta_contribution = view.primal[indexer.theta] * theta_obj_coeff;
let future_cost = theta_contribution * COST_SCALE_FACTOR;
let immediate_cost = (view.objective - theta_contribution) * COST_SCALE_FACTOR;
let thermal_cost = if indexer.thermal.is_empty() {
0.0
} else {
range_sum(indexer.thermal.clone()) * COST_SCALE_FACTOR
};
let anticipated_thermal_cost = if indexer.anticipated_decision.is_empty() {
0.0
} else {
range_sum(indexer.anticipated_decision.clone()) * COST_SCALE_FACTOR
};
let spillage_cost = if indexer.spillage.is_empty() {
0.0
} else {
range_sum(indexer.spillage.clone()) * COST_SCALE_FACTOR
};
let exchange_cost = if indexer.line_fwd.is_empty() {
0.0
} else {
indexer
.line_fwd
.clone()
.chain(indexer.line_rev.clone())
.map(col_cost)
.sum::<f64>()
* COST_SCALE_FACTOR
};
let deficit_cost = if indexer.deficit.is_empty() {
0.0
} else {
range_sum(indexer.deficit.clone()) * COST_SCALE_FACTOR
};
let excess_cost = if indexer.excess.is_empty() {
0.0
} else {
range_sum(indexer.excess.clone()) * COST_SCALE_FACTOR
};
let turbined_cost = if indexer.turbine.is_empty() {
0.0
} else {
range_sum(indexer.turbine.clone()) * COST_SCALE_FACTOR
};
let inflow_penalty_cost = if indexer.inflow_slack.is_empty() {
0.0
} else {
range_sum(indexer.inflow_slack.clone()) * COST_SCALE_FACTOR
};
let diversion_cost = if indexer.diversion.is_empty() {
0.0
} else {
range_sum(indexer.diversion.clone()) * COST_SCALE_FACTOR
};
let hv = compute_hydro_violation_costs(indexer, col_cost, range_sum);
SimulationCostResult {
stage_id,
block_id: None,
total_cost: view.objective * COST_SCALE_FACTOR,
immediate_cost,
future_cost,
discount_factor: cumulative_discount_factor,
thermal_cost,
anticipated_thermal_cost,
contract_cost: 0.0,
deficit_cost,
excess_cost,
storage_violation_cost: 0.0,
filling_target_cost: 0.0,
hydro_violation_cost: hv.total(),
outflow_violation_below_cost: hv.outflow_below,
outflow_violation_above_cost: hv.outflow_above,
turbined_violation_cost: hv.turbined,
generation_violation_cost: hv.generation,
evaporation_violation_cost: hv.evaporation,
withdrawal_violation_cost: hv.withdrawal,
inflow_penalty_cost,
generic_violation_cost,
spillage_cost: spillage_cost + diversion_cost,
turbined_cost,
curtailment_cost: ncs_curtailment_cost,
exchange_cost,
pumping_cost: 0.0,
}
}
fn extract_generic_violations(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
stage_id: u32,
) -> (Vec<SimulationGenericViolationResult>, f64) {
let entries = spec.generic_constraint_entries;
if entries.is_empty() {
return (Vec::new(), 0.0);
}
let mut results = Vec::with_capacity(entries.len());
let mut total_cost = 0.0;
for entry in entries {
let block_hours = if entry.is_stage_level {
spec.block_hours.iter().sum()
} else {
spec.block_hours
.get(entry.block_idx)
.copied()
.unwrap_or(0.0)
};
let (slack_value, slack_cost) = if entry.slack_enabled {
match entry.sense {
ConstraintSense::Equal => {
let s_plus = entry.slack_plus_col.map_or(0.0, |col| view.primal[col]);
let s_minus = entry.slack_minus_col.map_or(0.0, |col| view.primal[col]);
let net = s_plus - s_minus;
let cost = (s_plus + s_minus) * entry.slack_penalty * block_hours;
(net, cost)
}
ConstraintSense::LessEqual | ConstraintSense::GreaterEqual => {
let s = entry.slack_plus_col.map_or(0.0, |col| view.primal[col]);
let cost = s * entry.slack_penalty * block_hours;
(s, cost)
}
}
} else {
(0.0, 0.0)
};
total_cost += slack_cost;
results.push(SimulationGenericViolationResult {
stage_id,
#[allow(clippy::cast_possible_truncation)]
block_id: if entry.is_stage_level {
None
} else {
Some(entry.block_idx as u32)
},
constraint_id: entry.entity_id,
slack_value,
slack_cost,
});
}
(results, total_cost)
}
fn extract_non_controllables(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
stage_id: u32,
) -> (Vec<SimulationNonControllableResult>, f64) {
let n_ncs = spec.n_ncs;
if n_ncs == 0 {
return (Vec::new(), 0.0);
}
let n_blks = spec.indexer.n_blks;
let col_start = spec.ncs_col_start;
let mut results = Vec::with_capacity(n_ncs * n_blks);
let mut total_curtailment_cost = 0.0;
for (local_idx, &ncs_id) in spec.ncs_entity_ids.iter().enumerate() {
for blk in 0..n_blks {
let col = col_start + local_idx * n_blks + blk;
let generation_mw = view.primal[col];
let col_upper_offset = local_idx * n_blks + blk;
debug_assert!(
col_upper_offset < spec.ncs_col_upper.len(),
"NCS col_upper out of bounds: offset {col_upper_offset}, len {}",
spec.ncs_col_upper.len()
);
let available_mw = spec.ncs_col_upper[col_upper_offset];
let curtailment_mw = available_mw - generation_mw;
let col_cost = -(curtailment_mw * view.objective_coeffs[col]
/ spec.col_scale_factor(col))
* COST_SCALE_FACTOR;
total_curtailment_cost += col_cost;
#[allow(clippy::cast_possible_truncation)]
results.push(SimulationNonControllableResult {
stage_id,
block_id: Some(blk as u32),
non_controllable_id: ncs_id,
generation_mw,
available_mw,
curtailment_mw,
curtailment_cost: col_cost,
operative_state_code: 1,
});
}
}
(results, total_curtailment_cost)
}
fn extract_stub_collections(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
stage_id: u32,
) -> (
Vec<SimulationInflowLagResult>,
Vec<SimulationPumpingResult>,
Vec<SimulationContractResult>,
) {
let indexer = spec.indexer;
let inflow_lags: Vec<SimulationInflowLagResult> = spec
.entity_counts
.hydro_ids
.iter()
.enumerate()
.flat_map(|(h, &hydro_id)| {
(0..indexer.max_par_order).map(move |l| {
#[allow(clippy::cast_possible_truncation)]
SimulationInflowLagResult {
stage_id,
hydro_id,
lag_index: l as u32,
inflow_m3s: view.primal
[indexer.inflow_lags.start + l * indexer.hydro_count + h],
}
})
})
.collect();
let pumping_stations: Vec<SimulationPumpingResult> = spec
.entity_counts
.pumping_station_ids
.iter()
.map(|&pumping_station_id| SimulationPumpingResult {
stage_id,
block_id: None,
pumping_station_id,
pumped_flow_m3s: 0.0,
power_consumption_mw: 0.0,
pumping_cost: 0.0,
operative_state_code: 1,
})
.collect();
let contracts: Vec<SimulationContractResult> = spec
.entity_counts
.contract_ids
.iter()
.map(|&contract_id| SimulationContractResult {
stage_id,
block_id: None,
contract_id,
power_mw: 0.0,
price_per_mwh: 0.0,
total_cost: 0.0,
operative_state_code: 1,
})
.collect();
(inflow_lags, pumping_stations, contracts)
}
pub fn accumulate_category_costs(cost: &SimulationCostResult, accum: &mut ScenarioCategoryCosts) {
accum.resource_cost += cost.thermal_cost + cost.anticipated_thermal_cost + cost.contract_cost;
accum.recourse_cost += cost.deficit_cost + cost.excess_cost;
accum.violation_cost += cost.storage_violation_cost
+ cost.filling_target_cost
+ cost.hydro_violation_cost
+ cost.inflow_penalty_cost
+ cost.generic_violation_cost;
accum.regularization_cost +=
cost.spillage_cost + cost.turbined_cost + cost.curtailment_cost + cost.exchange_cost;
accum.imputed_cost += cost.pumping_cost;
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic, clippy::too_many_lines)]
mod tests {
use std::collections::HashMap;
use super::{
EntityCounts, SolutionView, StageExtractionSpec, accumulate_category_costs,
assign_scenarios, extract_stage_result,
};
use crate::indexer::StageIndexer;
use crate::simulation::types::{ScenarioCategoryCosts, SimulationCostResult};
#[test]
fn assign_scenarios_uneven_rank0() {
assert_eq!(assign_scenarios(10, 0, 3), 0..4);
}
#[test]
fn assign_scenarios_uneven_rank2() {
assert_eq!(assign_scenarios(10, 2, 3), 7..10);
}
#[test]
fn assign_scenarios_single_rank() {
assert_eq!(assign_scenarios(7, 0, 1), 0..7);
}
#[test]
fn assign_scenarios_uneven_rank1() {
assert_eq!(assign_scenarios(10, 1, 3), 4..7);
}
#[test]
fn assign_scenarios_exact_division() {
assert_eq!(assign_scenarios(9, 0, 3), 0..3);
assert_eq!(assign_scenarios(9, 1, 3), 3..6);
assert_eq!(assign_scenarios(9, 2, 3), 6..9);
}
#[test]
fn assign_scenarios_zero_scenarios() {
assert_eq!(assign_scenarios(0, 0, 1), 0..0);
assert_eq!(assign_scenarios(0, 0, 4), 0..0);
assert_eq!(assign_scenarios(0, 3, 4), 0..0);
}
#[test]
fn assign_scenarios_more_ranks_than_scenarios() {
assert_eq!(assign_scenarios(2, 0, 5), 0..1);
assert_eq!(assign_scenarios(2, 1, 5), 1..2);
assert_eq!(assign_scenarios(2, 2, 5), 2..2);
assert_eq!(assign_scenarios(2, 3, 5), 2..2);
assert_eq!(assign_scenarios(2, 4, 5), 2..2);
}
#[test]
fn assign_scenarios_sum_equals_n_scenarios() {
for (n, world_size) in [(0_u32, 1_usize), (1, 1), (10, 3), (9, 3), (2, 5), (100, 7)] {
let total: u32 = (0..world_size)
.map(|rank| {
let r = assign_scenarios(n, rank, world_size);
r.end - r.start
})
.sum();
assert_eq!(
total, n,
"total assigned {total} != n_scenarios {n} for world_size={world_size}"
);
}
}
fn zero_energy_conversion(
n_hydros: usize,
n_stages: usize,
) -> crate::energy_conversion::EnergyConversionSet {
use crate::energy_conversion::{EnergyConversion, EnergyConversionSet};
let zero_ec = EnergyConversion {
equivalent_productivity_mw_per_m3s: 0.0,
reference_volume_hm3: 0.0,
reference_outflow_m3s: 0.0,
};
EnergyConversionSet::new(
vec![vec![zero_ec; n_stages]; n_hydros],
vec![vec![0.0_f64; n_stages]; n_hydros],
n_hydros,
n_stages,
)
}
fn make_entity_counts_2_hydros() -> EntityCounts {
EntityCounts {
hydro_ids: vec![10, 20],
hydro_productivities: vec![1.0, 1.0],
thermal_ids: vec![1],
line_ids: vec![5],
bus_ids: vec![100],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
}
}
fn make_primal_2_1(
storage: [f64; 2],
lags: [f64; 2],
storage_in: [f64; 2],
theta: f64,
) -> Vec<f64> {
vec![
storage[0],
storage[1],
lags[0],
lags[1],
0.0, 0.0, storage_in[0],
storage_in[1],
theta,
]
}
#[test]
fn extract_costs_has_one_entry_matching_stage_id() {
let indexer = StageIndexer::new(2, 1);
let primal = make_primal_2_1([100.0, 200.0], [50.0, 60.0], [90.0, 180.0], 999.5);
let dual = vec![0.0; 4];
let ec = zero_energy_conversion(2, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 1500.0,
objective_coeffs: &[],
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &make_entity_counts_2_hydros(),
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0; 2],
stage_index: 0,
n_stages: 1,
},
3,
);
assert_eq!(result.costs.len(), 1);
assert_eq!(result.costs[0].stage_id, 3);
assert_eq!(result.costs[0].future_cost, 999_500_000.0);
}
#[test]
fn extract_cost_splits_objective_correctly() {
let indexer = StageIndexer::new(2, 1);
let theta_val = 300.0;
let objective = 800.0;
let primal = make_primal_2_1([0.0; 2], [0.0; 2], [0.0; 2], theta_val);
let dual = vec![0.0; 4];
let ec = zero_energy_conversion(2, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective,
objective_coeffs: &[],
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &make_entity_counts_2_hydros(),
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0; 2],
stage_index: 0,
n_stages: 1,
},
0,
);
let cost = &result.costs[0];
assert_eq!(cost.future_cost, theta_val * 1_000_000.0);
assert_eq!(cost.immediate_cost, (objective - theta_val) * 1_000_000.0);
assert_eq!(cost.total_cost, objective * 1_000_000.0);
}
#[test]
fn extract_hydro_storage_values_from_primal() {
let indexer = StageIndexer::new(2, 1);
let primal = make_primal_2_1([100.0, 200.0], [50.0, 60.0], [90.0, 180.0], 999.5);
let dual = vec![0.0; 4];
let ec = zero_energy_conversion(2, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 1500.0,
objective_coeffs: &[],
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &make_entity_counts_2_hydros(),
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0; 2],
stage_index: 0,
n_stages: 1,
},
0,
);
assert_eq!(result.hydros.len(), 2);
assert_eq!(result.hydros[0].hydro_id, 10);
assert_eq!(result.hydros[0].storage_initial_hm3, 90.0);
assert_eq!(result.hydros[0].storage_final_hm3, 100.0);
assert_eq!(result.hydros[1].hydro_id, 20);
assert_eq!(result.hydros[1].storage_initial_hm3, 180.0);
assert_eq!(result.hydros[1].storage_final_hm3, 200.0);
}
#[test]
fn extract_inflow_lag_values_from_primal() {
let indexer = StageIndexer::new(2, 1);
let primal = make_primal_2_1([100.0, 200.0], [50.0, 60.0], [90.0, 180.0], 999.5);
let dual = vec![0.0; 4];
let ec = zero_energy_conversion(2, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 1500.0,
objective_coeffs: &[],
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &make_entity_counts_2_hydros(),
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0; 2],
stage_index: 0,
n_stages: 1,
},
0,
);
assert_eq!(result.inflow_lags.len(), 2); assert_eq!(result.inflow_lags[0].hydro_id, 10);
assert_eq!(result.inflow_lags[0].lag_index, 0);
assert_eq!(result.inflow_lags[0].inflow_m3s, 50.0);
assert_eq!(result.inflow_lags[1].hydro_id, 20);
assert_eq!(result.inflow_lags[1].lag_index, 0);
assert_eq!(result.inflow_lags[1].inflow_m3s, 60.0);
}
#[test]
fn extract_no_lags_when_max_par_order_zero() {
let indexer = StageIndexer::new(2, 0);
let primal = vec![100.0, 200.0, 0.0, 0.0, 90.0, 180.0, 500.0];
let dual = vec![];
let counts = EntityCounts {
hydro_ids: vec![10, 20],
hydro_productivities: vec![1.0, 1.0],
thermal_ids: vec![],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(2, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 600.0,
objective_coeffs: &[],
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0; 2],
stage_index: 0,
n_stages: 1,
},
2,
);
assert!(result.inflow_lags.is_empty());
assert_eq!(result.hydros[0].incremental_inflow_m3s, 0.0);
}
#[test]
fn extract_stage_id_propagates_to_all_results() {
let indexer = StageIndexer::new(2, 1);
let primal = make_primal_2_1([100.0, 200.0], [50.0, 60.0], [90.0, 180.0], 10.0);
let dual = vec![0.0; 4];
let stage_id = 7_u32;
let ec = zero_energy_conversion(2, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 110.0,
objective_coeffs: &[],
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &make_entity_counts_2_hydros(),
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0; 2],
stage_index: 0,
n_stages: 1,
},
stage_id,
);
assert_eq!(result.stage_id, stage_id);
assert_eq!(result.costs[0].stage_id, stage_id);
assert!(result.hydros.iter().all(|h| h.stage_id == stage_id));
assert!(result.thermals.iter().all(|t| t.stage_id == stage_id));
assert!(result.exchanges.iter().all(|e| e.stage_id == stage_id));
assert!(result.buses.iter().all(|b| b.stage_id == stage_id));
assert!(result.inflow_lags.iter().all(|l| l.stage_id == stage_id));
}
#[test]
fn extract_equipment_zero_when_indexer_has_no_equipment_ranges() {
let indexer = StageIndexer::new(2, 1);
let primal = make_primal_2_1([0.0; 2], [0.0; 2], [0.0; 2], 0.0);
let dual = vec![0.0; 4];
let ec = zero_energy_conversion(2, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 0.0,
objective_coeffs: &[],
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &make_entity_counts_2_hydros(),
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0; 2],
stage_index: 0,
n_stages: 1,
},
0,
);
assert_eq!(result.thermals.len(), 1);
assert_eq!(result.thermals[0].generation_mw, 0.0);
assert_eq!(result.thermals[0].generation_cost, 0.0);
assert_eq!(result.thermals[0].block_id, None);
assert_eq!(result.exchanges.len(), 1);
assert_eq!(result.exchanges[0].direct_flow_mw, 0.0);
assert_eq!(result.exchanges[0].block_id, None);
assert_eq!(result.buses.len(), 1);
assert_eq!(result.buses[0].deficit_mw, 0.0);
assert_eq!(result.buses[0].spot_price, 0.0);
assert_eq!(result.buses[0].block_id, None);
}
#[test]
fn extract_equipment_reads_primal_when_with_equipment() {
let indexer = StageIndexer::with_equipment(
&crate::indexer::EquipmentCounts {
hydro_count: 2,
max_par_order: 1,
n_thermals: 1,
n_lines: 1,
n_buses: 1,
n_blks: 1,
has_inflow_penalty: false,
max_deficit_segments: 1,
n_anticipated: 0,
k_max: 0,
anticipated_lead_stages: vec![],
anticipated_thermal_indices: vec![],
},
&crate::indexer::FphaColumnLayout {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
);
assert_eq!(indexer.theta, 8);
assert_eq!(indexer.turbine, 9..11);
assert_eq!(indexer.spillage, 11..13);
assert_eq!(indexer.diversion, 13..15);
assert_eq!(indexer.thermal, 15..16);
assert_eq!(indexer.line_fwd, 16..17);
assert_eq!(indexer.line_rev, 17..18);
assert_eq!(indexer.deficit, 18..19);
assert_eq!(indexer.excess, 19..20);
let n_cols = indexer.generation_below_slack.end;
let mut primal = vec![0.0_f64; n_cols];
primal[0] = 100.0; primal[1] = 200.0; primal[2] = 50.0; primal[3] = 60.0; primal[6] = 90.0; primal[7] = 180.0; primal[8] = 500.0; primal[9] = 30.0; primal[10] = 40.0; primal[11] = 5.0; primal[12] = 0.0; primal[15] = 80.0; primal[16] = 15.0; primal[17] = 0.0; primal[18] = 10.0; primal[19] = 2.0;
let mut obj = vec![0.0_f64; n_cols];
obj[8] = 1.0; obj[11] = 0.1; obj[15] = 50.0; obj[16] = 5.0; obj[18] = 1000.0; obj[19] = 50.0;
let counts = EntityCounts {
hydro_ids: vec![10, 20],
hydro_productivities: vec![1.0, 1.0],
thermal_ids: vec![1],
line_ids: vec![5],
bus_ids: vec![100],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let mut dual = vec![0.0_f64; 5];
dual[2] = -120.0; dual[3] = -95.0; dual[4] = 108_000.0;
let mut row_lower = vec![0.0_f64; 5]; row_lower[4] = 75.0; let block_hours = [720.0_f64]; let ec = zero_energy_conversion(2, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 600.0,
objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &block_hours,
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0; 2],
stage_index: 0,
n_stages: 1,
},
0,
);
assert_eq!(result.hydros.len(), 2); assert_eq!(result.hydros[0].block_id, Some(0));
assert_eq!(result.hydros[0].turbined_m3s, 30.0);
assert_eq!(result.hydros[0].spillage_m3s, 5.0);
assert!((result.hydros[0].spillage_cost - 500_000.0).abs() < 1e-12);
assert_eq!(result.hydros[0].generation_mw, 30.0); assert_eq!(result.hydros[1].generation_mw, 40.0);
assert_eq!(result.hydros[1].block_id, Some(0));
assert_eq!(result.hydros[1].turbined_m3s, 40.0);
assert_eq!(result.hydros[1].spillage_m3s, 0.0);
assert_eq!(result.thermals.len(), 1);
assert_eq!(result.thermals[0].generation_mw, 80.0);
assert!((result.thermals[0].generation_cost - 4_000_000_000.0).abs() < 1e-3); assert_eq!(result.thermals[0].block_id, Some(0));
assert_eq!(result.exchanges.len(), 1);
assert_eq!(result.exchanges[0].direct_flow_mw, 15.0);
assert_eq!(result.exchanges[0].reverse_flow_mw, 0.0);
assert!((result.exchanges[0].exchange_cost - 75_000_000.0).abs() < 1e-3); assert_eq!(result.exchanges[0].block_id, Some(0));
assert_eq!(result.buses.len(), 1);
assert_eq!(result.buses[0].load_mw, 75.0); assert_eq!(result.buses[0].deficit_mw, 10.0);
assert_eq!(result.buses[0].excess_mw, 2.0);
assert_eq!(result.buses[0].block_id, Some(0));
assert!((result.buses[0].spot_price - 150_000_000.0).abs() < 1e-3);
assert!((result.hydros[0].water_value_per_hm3 - (-120_000_000.0)).abs() < 1e-3);
assert!((result.hydros[1].water_value_per_hm3 - (-95_000_000.0)).abs() < 1e-3);
let cost = &result.costs[0];
assert!((cost.thermal_cost - 4_000_000_000.0).abs() < 1e-3); assert!((cost.spillage_cost - 500_000.0).abs() < 1e-6); assert!((cost.deficit_cost - 10_000_000_000.0).abs() < 1e-3); assert!((cost.excess_cost - 100_000_000.0).abs() < 1e-3); assert!((cost.exchange_cost - 75_000_000.0).abs() < 1e-3); }
#[test]
fn extract_thermals_marks_anticipated_thermals_when_indices_nonempty() {
let indexer = StageIndexer::with_equipment(
&crate::indexer::EquipmentCounts {
hydro_count: 0,
max_par_order: 0,
n_thermals: 2,
n_lines: 0,
n_buses: 0,
n_blks: 1,
has_inflow_penalty: false,
max_deficit_segments: 1,
n_anticipated: 1,
k_max: 1,
anticipated_lead_stages: vec![1],
anticipated_thermal_indices: vec![1],
},
&crate::indexer::FphaColumnLayout {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
);
assert_eq!(indexer.theta, 1);
assert_eq!(indexer.thermal, 2..4);
let n_cols = indexer.anticipated_decision.end.max(4);
let mut primal = vec![0.0_f64; n_cols];
primal[1] = 0.0; primal[2] = 50.0; primal[3] = 30.0;
let mut obj = vec![0.0_f64; n_cols];
obj[2] = 10.0; obj[3] = 20.0;
let counts = EntityCounts {
hydro_ids: vec![],
hydro_productivities: vec![],
thermal_ids: vec![10, 20],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(0, 2);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &[],
objective: 0.0,
objective_coeffs: &obj,
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[1.0],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[],
stage_index: 0,
n_stages: 2,
},
0,
);
assert_eq!(result.thermals.len(), 2);
assert_eq!(result.thermals[0].thermal_id, 10);
assert!(
!result.thermals[0].is_anticipated,
"thermal 10 should not be anticipated"
);
assert_eq!(result.thermals[0].anticipated_committed_mw, None);
assert_eq!(result.thermals[0].anticipated_decision_mw, None);
assert_eq!(result.thermals[1].thermal_id, 20);
assert!(
result.thermals[1].is_anticipated,
"thermal 20 should be anticipated"
);
assert_eq!(result.thermals[1].anticipated_committed_mw, Some(0.0));
assert_eq!(result.thermals[1].anticipated_decision_mw, Some(0.0));
}
#[test]
fn extract_thermals_marks_no_thermals_anticipated_when_indices_empty() {
let indexer = StageIndexer::with_equipment(
&crate::indexer::EquipmentCounts {
hydro_count: 0,
max_par_order: 0,
n_thermals: 2,
n_lines: 0,
n_buses: 0,
n_blks: 1,
has_inflow_penalty: false,
max_deficit_segments: 1,
n_anticipated: 0,
k_max: 0,
anticipated_lead_stages: vec![],
anticipated_thermal_indices: vec![],
},
&crate::indexer::FphaColumnLayout {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
);
let n_cols = indexer.generation_below_slack.end.max(3);
let primal = vec![0.0_f64; n_cols];
let obj = vec![0.0_f64; n_cols];
let counts = EntityCounts {
hydro_ids: vec![],
hydro_productivities: vec![],
thermal_ids: vec![10, 20],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(0, 2);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &[],
objective: 0.0,
objective_coeffs: &obj,
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[1.0],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[],
stage_index: 0,
n_stages: 2,
},
0,
);
assert_eq!(result.thermals.len(), 2);
assert!(
!result.thermals[0].is_anticipated,
"thermal 10 should not be anticipated"
);
assert!(
!result.thermals[1].is_anticipated,
"thermal 20 should not be anticipated"
);
}
fn make_anticipated_decision_indexer_k2() -> crate::indexer::StageIndexer {
StageIndexer::with_equipment(
&crate::indexer::EquipmentCounts {
hydro_count: 0,
max_par_order: 0,
n_thermals: 2,
n_lines: 0,
n_buses: 0,
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![1],
},
&crate::indexer::FphaColumnLayout {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
)
}
fn make_primal_with_decision_sentinel(
indexer: &crate::indexer::StageIndexer,
sentinel: f64,
) -> Vec<f64> {
let n_cols = indexer.anticipated_decision.end.max(indexer.thermal.end);
let mut primal = vec![0.0_f64; n_cols];
primal[indexer.anticipated_decision.start] = sentinel;
primal
}
#[test]
fn extract_thermals_reads_anticipated_decision_when_in_horizon() {
let indexer = make_anticipated_decision_indexer_k2();
let primal = make_primal_with_decision_sentinel(&indexer, 123.5);
let obj = vec![0.0_f64; primal.len()];
let counts = EntityCounts {
hydro_ids: vec![],
hydro_productivities: vec![],
thermal_ids: vec![10, 20],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(0, 2);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &[],
objective: 0.0,
objective_coeffs: &obj,
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[1.0],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[],
stage_index: 0,
n_stages: 3,
},
0,
);
assert_eq!(result.thermals.len(), 2);
assert_eq!(result.thermals[1].thermal_id, 20);
assert_eq!(
result.thermals[1].anticipated_decision_mw,
Some(123.5),
"expected Some(123.5) for thermal 20 at stage 0 with K_i=2, n_stages=3"
);
}
#[test]
fn extract_thermals_emits_none_at_horizon_boundary() {
let indexer = make_anticipated_decision_indexer_k2();
let primal = make_primal_with_decision_sentinel(&indexer, 123.5);
let obj = vec![0.0_f64; primal.len()];
let counts = EntityCounts {
hydro_ids: vec![],
hydro_productivities: vec![],
thermal_ids: vec![10, 20],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(0, 2);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &[],
objective: 0.0,
objective_coeffs: &obj,
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[1.0],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[],
stage_index: 1,
n_stages: 3,
},
0,
);
assert_eq!(result.thermals.len(), 2);
assert_eq!(result.thermals[1].thermal_id, 20);
assert_eq!(
result.thermals[1].anticipated_decision_mw, None,
"expected None for thermal 20 at boundary stage 1 (K_i=2, n_stages=3): \
t+K_i = 3 >= n_stages = 3 under the strict predicate"
);
}
#[test]
fn extract_thermals_emits_none_one_past_horizon_boundary() {
let indexer = make_anticipated_decision_indexer_k2();
let primal = make_primal_with_decision_sentinel(&indexer, 123.5);
let obj = vec![0.0_f64; primal.len()];
let counts = EntityCounts {
hydro_ids: vec![],
hydro_productivities: vec![],
thermal_ids: vec![10, 20],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(0, 2);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &[],
objective: 0.0,
objective_coeffs: &obj,
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[1.0],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[],
stage_index: 2,
n_stages: 3,
},
0,
);
assert_eq!(result.thermals.len(), 2);
assert_eq!(result.thermals[1].thermal_id, 20);
assert_eq!(
result.thermals[1].anticipated_decision_mw, None,
"expected None for thermal 20 at stage 2 with K_i=2, n_stages=3 (one past boundary)"
);
}
#[test]
fn extract_thermals_emits_none_for_non_anticipated_thermals() {
let indexer = StageIndexer::with_equipment(
&crate::indexer::EquipmentCounts {
hydro_count: 0,
max_par_order: 0,
n_thermals: 2,
n_lines: 0,
n_buses: 0,
n_blks: 1,
has_inflow_penalty: false,
max_deficit_segments: 1,
n_anticipated: 1,
k_max: 1,
anticipated_lead_stages: vec![1],
anticipated_thermal_indices: vec![1],
},
&crate::indexer::FphaColumnLayout {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
);
let n_cols = indexer.anticipated_decision.end.max(indexer.thermal.end);
let mut primal = vec![0.0_f64; n_cols];
primal[indexer.anticipated_decision.start] = 123.5;
let obj = vec![0.0_f64; n_cols];
let counts = EntityCounts {
hydro_ids: vec![],
hydro_productivities: vec![],
thermal_ids: vec![10, 20],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(0, 2);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &[],
objective: 0.0,
objective_coeffs: &obj,
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[1.0],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[],
stage_index: 0,
n_stages: 2,
},
0,
);
assert_eq!(result.thermals.len(), 2);
assert_eq!(result.thermals[0].thermal_id, 10);
assert_eq!(
result.thermals[0].anticipated_decision_mw, None,
"thermal 10 is not anticipated; expected None"
);
}
#[test]
fn extract_thermals_anticipated_decision_is_per_block_invariant() {
let indexer = StageIndexer::with_equipment(
&crate::indexer::EquipmentCounts {
hydro_count: 0,
max_par_order: 0,
n_thermals: 1,
n_lines: 0,
n_buses: 0,
n_blks: 4,
has_inflow_penalty: false,
max_deficit_segments: 1,
n_anticipated: 1,
k_max: 1,
anticipated_lead_stages: vec![1],
anticipated_thermal_indices: vec![0],
},
&crate::indexer::FphaColumnLayout {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
);
let n_cols = indexer.anticipated_decision.end.max(indexer.thermal.end);
let mut primal = vec![0.0_f64; n_cols];
primal[indexer.anticipated_decision.start] = 123.5;
let obj = vec![0.0_f64; n_cols];
let counts = EntityCounts {
hydro_ids: vec![],
hydro_productivities: vec![],
thermal_ids: vec![30],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(0, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &[],
objective: 0.0,
objective_coeffs: &obj,
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[1.0, 1.0, 1.0, 1.0],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[],
stage_index: 0,
n_stages: 2,
},
0,
);
assert_eq!(
result.thermals.len(),
4,
"expected 4 block records (1 thermal × 4 blocks)"
);
for (blk, rec) in result.thermals.iter().enumerate() {
assert_eq!(rec.thermal_id, 30);
assert_eq!(
rec.anticipated_decision_mw,
Some(123.5),
"block {blk}: expected Some(123.5) for per-block invariance"
);
}
}
fn make_anticipated_committed_indexer_k2_3blks() -> crate::indexer::StageIndexer {
StageIndexer::with_equipment(
&crate::indexer::EquipmentCounts {
hydro_count: 0,
max_par_order: 0,
n_thermals: 1,
n_lines: 0,
n_buses: 0,
n_blks: 3,
has_inflow_penalty: false,
max_deficit_segments: 1,
n_anticipated: 1,
k_max: 2,
anticipated_lead_stages: vec![2],
anticipated_thermal_indices: vec![0],
},
&crate::indexer::FphaColumnLayout {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
)
}
#[test]
fn extract_thermals_per_block_committed_at_delivery_stage() {
let indexer = make_anticipated_committed_indexer_k2_3blks();
assert_eq!(indexer.thermal.start, 3);
assert_eq!(indexer.anticipated_state.start, 0);
let n_cols = indexer.anticipated_decision.end.max(indexer.thermal.end);
let mut primal = vec![0.0_f64; n_cols];
primal[0] = 42.0; primal[1] = 99.0; primal[2] = 0.0; primal[3] = 50.0; primal[4] = 60.0; primal[5] = 70.0; let obj = vec![0.0_f64; n_cols];
let lookup = super::ThermalReverseLookup::build(&indexer, 1);
let spec = StageExtractionSpec {
indexer: &indexer,
entity_counts: &EntityCounts {
hydro_ids: vec![],
hydro_productivities: vec![],
thermal_ids: vec![10],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
},
inflow_m3s_per_hydro: &[],
block_hours: &[1.0, 1.0, 1.0],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &zero_energy_conversion(0, 3),
hydro_min_storage_hm3: &[],
stage_index: 2,
n_stages: 3,
};
let view = SolutionView {
primal: &primal,
dual: &[],
objective: 0.0,
objective_coeffs: &obj,
row_lower: &[],
};
assert_eq!(
super::compute_anticipated_committed_mw(&view, &spec, &lookup, 0),
Some(42.0),
"helper: expected slot-0 value 42.0, NOT a per-block thermal value"
);
let counts = EntityCounts {
hydro_ids: vec![],
hydro_productivities: vec![],
thermal_ids: vec![10],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(0, 3);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &[],
objective: 0.0,
objective_coeffs: &obj,
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[1.0, 1.0, 1.0],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[],
stage_index: 2,
n_stages: 3,
},
0,
);
assert_eq!(result.thermals.len(), 3);
for (blk, rec) in result.thermals.iter().enumerate() {
assert_eq!(
rec.anticipated_committed_mw,
Some(42.0),
"block {blk}: must read slot-0 ant_state (42.0), not per-block gen"
);
assert_ne!(
rec.anticipated_committed_mw,
Some(rec.generation_mw),
"block {blk}: committed_mw must NOT alias generation_mw"
);
}
}
#[test]
fn extract_thermals_per_block_committed_slot0_when_seed_zero() {
let indexer = make_anticipated_committed_indexer_k2_3blks();
let n_cols = indexer.anticipated_decision.end.max(indexer.thermal.end);
let mut primal = vec![0.0_f64; n_cols];
primal[3] = 50.0; primal[4] = 60.0; primal[5] = 70.0; let obj = vec![0.0_f64; n_cols];
let counts = EntityCounts {
hydro_ids: vec![],
hydro_productivities: vec![],
thermal_ids: vec![10],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(0, 3);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &[],
objective: 0.0,
objective_coeffs: &obj,
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[1.0, 1.0, 1.0],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[],
stage_index: 1,
n_stages: 3,
},
0,
);
assert_eq!(result.thermals.len(), 3);
for (blk, rec) in result.thermals.iter().enumerate() {
assert_eq!(
rec.anticipated_committed_mw,
Some(0.0),
"block {blk}: always-active reads slot 0 = 0.0 regardless of stage"
);
}
}
#[test]
fn extract_thermals_per_block_committed_at_first_delivery_boundary() {
let indexer = make_anticipated_committed_indexer_k2_3blks();
let n_cols = indexer.anticipated_decision.end.max(indexer.thermal.end);
let mut primal = vec![0.0_f64; n_cols];
primal[3] = 50.0;
primal[4] = 60.0;
primal[5] = 70.0;
let obj = vec![0.0_f64; n_cols];
let counts = EntityCounts {
hydro_ids: vec![],
hydro_productivities: vec![],
thermal_ids: vec![10],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(0, 3);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &[],
objective: 0.0,
objective_coeffs: &obj,
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[1.0, 1.0, 1.0],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[],
stage_index: 2, n_stages: 3,
},
0,
);
assert_eq!(result.thermals.len(), 3);
for (blk, rec) in result.thermals.iter().enumerate() {
assert!(
rec.anticipated_committed_mw.is_some(),
"block {blk}: expected Some(_) at first delivery boundary (k_i == stage_index)"
);
}
}
#[test]
fn extract_thermals_per_block_committed_none_for_non_anticipated() {
let indexer = StageIndexer::with_equipment(
&crate::indexer::EquipmentCounts {
hydro_count: 0,
max_par_order: 0,
n_thermals: 2,
n_lines: 0,
n_buses: 0,
n_blks: 3,
has_inflow_penalty: false,
max_deficit_segments: 1,
n_anticipated: 1,
k_max: 2,
anticipated_lead_stages: vec![2],
anticipated_thermal_indices: vec![1],
},
&crate::indexer::FphaColumnLayout {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
);
let n_cols = indexer.anticipated_decision.end.max(indexer.thermal.end);
let mut primal = vec![0.0_f64; n_cols];
primal[indexer.thermal.start] = 10.0;
primal[indexer.thermal.start + 1] = 20.0;
primal[indexer.thermal.start + 2] = 30.0;
primal[indexer.thermal.start + 3] = 40.0;
primal[indexer.thermal.start + 4] = 50.0;
primal[indexer.thermal.start + 5] = 60.0;
let obj = vec![0.0_f64; n_cols];
let counts = EntityCounts {
hydro_ids: vec![],
hydro_productivities: vec![],
thermal_ids: vec![10, 20],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(0, 3);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &[],
objective: 0.0,
objective_coeffs: &obj,
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[1.0, 1.0, 1.0],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[],
stage_index: 2, n_stages: 3,
},
0,
);
assert_eq!(result.thermals.len(), 6);
for blk in 0..3 {
assert_eq!(result.thermals[blk].thermal_id, 10);
assert_eq!(
result.thermals[blk].anticipated_committed_mw, None,
"block {blk}: non-anticipated thermal 10 must have None"
);
}
for blk in 0..3 {
assert!(
result.thermals[3 + blk].anticipated_committed_mw.is_some(),
"block {blk}: anticipated thermal 20 must have Some(_) at delivery"
);
}
}
#[test]
fn extract_thermals_no_block_committed_at_delivery_is_zero() {
let indexer = StageIndexer::with_equipment(
&crate::indexer::EquipmentCounts {
hydro_count: 0,
max_par_order: 0,
n_thermals: 1,
n_lines: 0,
n_buses: 0,
n_blks: 0,
has_inflow_penalty: false,
max_deficit_segments: 1,
n_anticipated: 1,
k_max: 1,
anticipated_lead_stages: vec![1],
anticipated_thermal_indices: vec![0],
},
&crate::indexer::FphaColumnLayout {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
);
assert!(
indexer.thermal.is_empty(),
"n_blks=0 must yield empty thermal range"
);
let lookup = super::ThermalReverseLookup::build(&indexer, 1);
let spec_delivery = StageExtractionSpec {
indexer: &indexer,
entity_counts: &EntityCounts {
hydro_ids: vec![],
hydro_productivities: vec![],
thermal_ids: vec![10],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
},
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &zero_energy_conversion(0, 2),
hydro_min_storage_hm3: &[],
stage_index: 1,
n_stages: 2,
};
let n_cols_helper = indexer.anticipated_decision.end.max(1);
let primal_helper = vec![0.0_f64; n_cols_helper];
let dual_helper: Vec<f64> = vec![];
let view_helper = SolutionView {
primal: &primal_helper,
dual: &dual_helper,
objective: 0.0,
objective_coeffs: &[],
row_lower: &[],
};
assert_eq!(
super::compute_anticipated_committed_mw(&view_helper, &spec_delivery, &lookup, 0),
Some(0.0),
"consolidated helper: expected Some(0.0) at delivery stage (slot-0 = incoming = 0.0)"
);
let n_cols = indexer.anticipated_decision.end.max(1);
let primal = vec![0.0_f64; n_cols];
let obj = vec![0.0_f64; n_cols];
let counts = EntityCounts {
hydro_ids: vec![],
hydro_productivities: vec![],
thermal_ids: vec![10],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(0, 2);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &[],
objective: 0.0,
objective_coeffs: &obj,
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[],
stage_index: 1, n_stages: 2,
},
0,
);
assert_eq!(result.thermals.len(), 1);
assert_eq!(
result.thermals[0].anticipated_committed_mw,
Some(0.0),
"no-block delivery: expected Some(0.0)"
);
}
#[test]
fn extract_thermals_no_block_committed_reads_slot0_when_seed_zero() {
let indexer = StageIndexer::with_equipment(
&crate::indexer::EquipmentCounts {
hydro_count: 0,
max_par_order: 0,
n_thermals: 1,
n_lines: 0,
n_buses: 0,
n_blks: 0,
has_inflow_penalty: false,
max_deficit_segments: 1,
n_anticipated: 1,
k_max: 1,
anticipated_lead_stages: vec![1],
anticipated_thermal_indices: vec![0],
},
&crate::indexer::FphaColumnLayout {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
);
let n_cols = indexer.anticipated_decision.end.max(1);
let primal = vec![0.0_f64; n_cols];
let obj = vec![0.0_f64; n_cols];
let counts = EntityCounts {
hydro_ids: vec![],
hydro_productivities: vec![],
thermal_ids: vec![10],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(0, 2);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &[],
objective: 0.0,
objective_coeffs: &obj,
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[],
stage_index: 0, n_stages: 2,
},
0,
);
assert_eq!(result.thermals.len(), 1);
assert_eq!(
result.thermals[0].anticipated_committed_mw,
Some(0.0),
"no-block always-active: reads slot 0 = 0.0 regardless of stage"
);
}
#[test]
fn extract_stage_result_prebuilt_lookup_matches_standard_path() {
use super::{HydroReverseLookup, ThermalReverseLookup, extract_stage_result_with_lookups};
let indexer = crate::indexer::StageIndexer::with_equipment(
&crate::indexer::EquipmentCounts {
hydro_count: 0,
max_par_order: 0,
n_thermals: 1,
n_lines: 0,
n_buses: 0,
n_blks: 2,
has_inflow_penalty: false,
max_deficit_segments: 1,
n_anticipated: 1,
k_max: 1,
anticipated_lead_stages: vec![1],
anticipated_thermal_indices: vec![0],
},
&crate::indexer::FphaColumnLayout {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
);
let n_cols = indexer
.anticipated_decision
.end
.max(indexer.thermal.end)
.max(indexer.anticipated_state.end)
.max(indexer.theta + 1);
let mut primal = vec![0.0_f64; n_cols];
primal[indexer.anticipated_state.start] = 37.5;
primal[indexer.anticipated_decision.start] = 80.0;
if !indexer.thermal.is_empty() {
primal[indexer.thermal.start] = 40.0;
primal[indexer.thermal.start + 1] = 50.0;
}
let obj = vec![0.01_f64; n_cols];
let ec = zero_energy_conversion(0, 3);
let counts = EntityCounts {
hydro_ids: vec![],
hydro_productivities: vec![],
thermal_ids: vec![42],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let view = SolutionView {
primal: &primal,
dual: &[],
objective: 0.0,
objective_coeffs: &obj,
row_lower: &[],
};
let spec = StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[1.0, 1.0],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[],
stage_index: 2, n_stages: 3,
};
let result_standard = extract_stage_result(&view, &spec, 2);
let thermal_lookup = ThermalReverseLookup::build(&indexer, counts.thermal_ids.len());
let hydro_lookup = HydroReverseLookup::build(&indexer, counts.hydro_ids.len());
let result_prebuilt =
extract_stage_result_with_lookups(&view, &spec, 2, &hydro_lookup, &thermal_lookup);
assert_eq!(
result_standard.thermals.len(),
result_prebuilt.thermals.len(),
"thermal result count must match"
);
for (std_t, pre_t) in result_standard
.thermals
.iter()
.zip(result_prebuilt.thermals.iter())
{
assert_eq!(
std_t.is_anticipated, pre_t.is_anticipated,
"is_anticipated must match"
);
assert_eq!(
std_t.anticipated_committed_mw.map(f64::to_bits),
pre_t.anticipated_committed_mw.map(f64::to_bits),
"anticipated_committed_mw bits must match"
);
assert_eq!(
std_t.anticipated_decision_mw.map(f64::to_bits),
pre_t.anticipated_decision_mw.map(f64::to_bits),
"anticipated_decision_mw bits must match"
);
assert_eq!(
std_t.generation_mw.to_bits(),
pre_t.generation_mw.to_bits(),
"generation_mw bits must match"
);
}
}
#[test]
fn extract_optional_entity_types_are_empty_when_absent() {
let indexer = StageIndexer::new(1, 0);
let primal = vec![50.0, 0.0, 40.0, 200.0]; let dual = vec![];
let counts = EntityCounts {
hydro_ids: vec![1],
hydro_productivities: vec![1.0],
thermal_ids: vec![],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(1, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 250.0,
objective_coeffs: &[],
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0],
stage_index: 0,
n_stages: 1,
},
0,
);
assert!(result.pumping_stations.is_empty());
assert!(result.contracts.is_empty());
assert!(result.non_controllables.is_empty());
assert!(result.generic_violations.is_empty());
}
#[allow(clippy::too_many_arguments)]
fn make_cost(
thermal: f64,
contract: f64,
deficit: f64,
excess: f64,
storage_violation: f64,
filling: f64,
hydro_violation: f64,
inflow_penalty: f64,
generic_violation: f64,
spillage: f64,
fpha: f64,
curtailment: f64,
exchange: f64,
pumping: f64,
) -> SimulationCostResult {
SimulationCostResult {
stage_id: 0,
block_id: None,
total_cost: 0.0,
immediate_cost: 0.0,
future_cost: 0.0,
discount_factor: 1.0,
thermal_cost: thermal,
anticipated_thermal_cost: 0.0,
contract_cost: contract,
deficit_cost: deficit,
excess_cost: excess,
storage_violation_cost: storage_violation,
filling_target_cost: filling,
hydro_violation_cost: hydro_violation,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
turbined_violation_cost: 0.0,
generation_violation_cost: 0.0,
evaporation_violation_cost: 0.0,
withdrawal_violation_cost: 0.0,
inflow_penalty_cost: inflow_penalty,
generic_violation_cost: generic_violation,
spillage_cost: spillage,
turbined_cost: fpha,
curtailment_cost: curtailment,
exchange_cost: exchange,
pumping_cost: pumping,
}
}
fn zero_accum() -> ScenarioCategoryCosts {
ScenarioCategoryCosts {
resource_cost: 0.0,
recourse_cost: 0.0,
violation_cost: 0.0,
regularization_cost: 0.0,
imputed_cost: 0.0,
}
}
#[test]
fn accumulate_single_stage_all_categories() {
let cost = make_cost(
400.0, 100.0, 50.0, 10.0, 20.0, 30.0, 5.0, 3.0, 2.0, 1.0, 4.0, 7.0, 8.0, 60.0, );
let mut accum = zero_accum();
accumulate_category_costs(&cost, &mut accum);
assert_eq!(accum.resource_cost, 500.0);
assert_eq!(accum.recourse_cost, 60.0);
assert_eq!(accum.violation_cost, 60.0);
assert_eq!(accum.regularization_cost, 20.0);
assert_eq!(accum.imputed_cost, 60.0);
}
#[test]
fn accumulate_two_consecutive_stages_sums_correctly() {
let cost1 = make_cost(
100.0, 0.0, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 5.0, );
let cost2 = make_cost(
200.0, 50.0, 20.0, 5.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 10.0, );
let mut accum = zero_accum();
accumulate_category_costs(&cost1, &mut accum);
accumulate_category_costs(&cost2, &mut accum);
assert_eq!(accum.resource_cost, 100.0 + 200.0 + 50.0);
assert_eq!(accum.recourse_cost, 10.0 + 20.0 + 5.0);
assert_eq!(accum.violation_cost, 0.0);
assert_eq!(accum.regularization_cost, 0.0);
assert_eq!(accum.imputed_cost, 5.0 + 10.0);
}
#[test]
fn accumulate_all_zeros_leaves_accum_unchanged() {
let cost = make_cost(
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
);
let mut accum = ScenarioCategoryCosts {
resource_cost: 1.0,
recourse_cost: 2.0,
violation_cost: 3.0,
regularization_cost: 4.0,
imputed_cost: 5.0,
};
accumulate_category_costs(&cost, &mut accum);
assert_eq!(accum.resource_cost, 1.0);
assert_eq!(accum.recourse_cost, 2.0);
assert_eq!(accum.violation_cost, 3.0);
assert_eq!(accum.regularization_cost, 4.0);
assert_eq!(accum.imputed_cost, 5.0);
}
#[test]
fn accumulate_violation_all_five_components() {
let cost = make_cost(
0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 0.0, 0.0, 0.0, 0.0, 0.0, );
let mut accum = zero_accum();
accumulate_category_costs(&cost, &mut accum);
assert_eq!(accum.violation_cost, 15.0);
}
#[test]
fn accumulate_regularization_all_four_components() {
let cost = make_cost(
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 2.0, 3.0, 4.0, 5.0, 0.0, );
let mut accum = zero_accum();
accumulate_category_costs(&cost, &mut accum);
assert_eq!(accum.regularization_cost, 14.0);
}
#[test]
fn test_slack_extraction_with_penalty_active() {
let indexer = StageIndexer::with_equipment(
&crate::indexer::EquipmentCounts {
hydro_count: 2,
max_par_order: 1,
n_thermals: 1,
n_lines: 1,
n_buses: 1,
n_blks: 1,
has_inflow_penalty: true,
max_deficit_segments: 1,
n_anticipated: 0,
k_max: 0,
anticipated_lead_stages: vec![],
anticipated_thermal_indices: vec![],
},
&crate::indexer::FphaColumnLayout {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
);
assert!(
indexer.has_inflow_penalty,
"has_inflow_penalty must be true"
);
assert!(
!indexer.inflow_slack.is_empty(),
"inflow_slack must be non-empty"
);
let n_cols = indexer.generation_below_slack.end;
let mut primal = vec![0.0_f64; n_cols];
primal[0] = 100.0; primal[1] = 200.0; primal[2] = 50.0; primal[3] = 60.0; primal[4] = 90.0; primal[5] = 180.0; primal[6] = 500.0;
primal[indexer.inflow_slack.start] = 7.5; primal[indexer.inflow_slack.start + 1] = 0.0;
let obj = vec![0.0_f64; n_cols];
let dual = vec![0.0_f64; 4];
let row_lower = vec![0.0_f64; indexer.load_balance.end.max(1)];
let counts = EntityCounts {
hydro_ids: vec![10, 20],
hydro_productivities: vec![1.0, 1.0],
thermal_ids: vec![1],
line_ids: vec![5],
bus_ids: vec![100],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(2, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 500.0,
objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0; 2],
stage_index: 0,
n_stages: 1,
},
0,
);
assert_eq!(result.hydros.len(), 2);
assert!(
(result.hydros[0].inflow_nonnegativity_slack_m3s - 7.5).abs() < 1e-12,
"hydro 0 slack should be 7.5, got {}",
result.hydros[0].inflow_nonnegativity_slack_m3s
);
assert_eq!(
result.hydros[1].inflow_nonnegativity_slack_m3s, 0.0,
"hydro 1 slack should be 0.0"
);
}
#[test]
fn test_slack_extraction_without_penalty_is_zero() {
let indexer = StageIndexer::with_equipment(
&crate::indexer::EquipmentCounts {
hydro_count: 2,
max_par_order: 1,
n_thermals: 1,
n_lines: 1,
n_buses: 1,
n_blks: 1,
has_inflow_penalty: false,
max_deficit_segments: 1,
n_anticipated: 0,
k_max: 0,
anticipated_lead_stages: vec![],
anticipated_thermal_indices: vec![],
},
&crate::indexer::FphaColumnLayout {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
);
assert!(
!indexer.has_inflow_penalty,
"has_inflow_penalty must be false"
);
let n_cols = indexer.generation_below_slack.end; let primal = vec![1.0_f64; n_cols]; let obj = vec![0.0_f64; n_cols];
let dual = vec![0.0_f64; 4];
let row_lower = vec![0.0_f64; indexer.load_balance.end.max(1)];
let counts = EntityCounts {
hydro_ids: vec![10, 20],
hydro_productivities: vec![1.0, 1.0],
thermal_ids: vec![1],
line_ids: vec![5],
bus_ids: vec![100],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(2, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 1.0,
objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0; 2],
stage_index: 0,
n_stages: 1,
},
0,
);
for (h, hr) in result.hydros.iter().enumerate() {
assert_eq!(
hr.inflow_nonnegativity_slack_m3s, 0.0,
"hydro {h} slack must be 0.0 when penalty is inactive"
);
}
}
#[test]
fn test_slack_extraction_fallback_path_with_penalty() {
let indexer = StageIndexer::with_equipment(
&crate::indexer::EquipmentCounts {
hydro_count: 2,
max_par_order: 1,
n_thermals: 0,
n_lines: 0,
n_buses: 0,
n_blks: 0,
has_inflow_penalty: true,
max_deficit_segments: 1,
n_anticipated: 0,
k_max: 0,
anticipated_lead_stages: vec![],
anticipated_thermal_indices: vec![],
},
&crate::indexer::FphaColumnLayout {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
);
assert!(
indexer.turbine.is_empty(),
"turbine must be empty to trigger fallback"
);
assert!(
indexer.has_inflow_penalty,
"has_inflow_penalty must be true"
);
let n_cols = indexer.generation_below_slack.end;
let mut primal = vec![0.0_f64; n_cols];
primal[0] = 150.0; primal[1] = 250.0; primal[2] = 55.0; primal[3] = 65.0; primal[4] = 140.0; primal[5] = 240.0; primal[6] = 0.0; primal[indexer.inflow_slack.start] = 3.0; primal[indexer.inflow_slack.start + 1] = 0.0;
let obj = vec![0.0_f64; n_cols];
let dual = vec![0.0_f64; 4];
let row_lower = vec![0.0_f64; 1];
let counts = EntityCounts {
hydro_ids: vec![10, 20],
hydro_productivities: vec![1.0, 1.0],
thermal_ids: vec![],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(2, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 0.0,
objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0; 2],
stage_index: 0,
n_stages: 1,
},
0,
);
assert_eq!(result.hydros.len(), 2);
assert!(
(result.hydros[0].inflow_nonnegativity_slack_m3s - 3.0).abs() < 1e-12,
"hydro 0 fallback slack should be 3.0, got {}",
result.hydros[0].inflow_nonnegativity_slack_m3s
);
assert_eq!(result.hydros[1].inflow_nonnegativity_slack_m3s, 0.0);
}
fn make_indexer_2h_1fpha_1blk() -> StageIndexer {
StageIndexer::with_equipment(
&crate::indexer::EquipmentCounts {
hydro_count: 2,
max_par_order: 0,
n_thermals: 0,
n_lines: 0,
n_buses: 0,
n_blks: 1,
has_inflow_penalty: false,
max_deficit_segments: 1,
n_anticipated: 0,
k_max: 0,
anticipated_lead_stages: vec![],
anticipated_thermal_indices: vec![],
},
&crate::indexer::FphaColumnLayout {
hydro_indices: vec![0],
planes_per_hydro: vec![2],
},
)
}
#[test]
fn fpha_generation_read_from_lp_column() {
let indexer = make_indexer_2h_1fpha_1blk();
assert_eq!(indexer.generation.start, 13, "generation starts at 13");
assert_eq!(indexer.fpha_hydro_indices, vec![0]);
let n_cols = indexer.generation_below_slack.end;
let mut primal = vec![0.0_f64; n_cols];
primal[0] = 50.0; primal[1] = 80.0; primal[4] = 45.0; primal[5] = 75.0; primal[6] = 0.0; primal[7] = 20.0; primal[8] = 30.0; primal[9] = 0.0; primal[10] = 0.0; primal[13] = 75.0;
let obj = vec![0.0_f64; n_cols];
let dual = vec![0.0_f64; 2];
let row_lower = vec![0.0_f64; 1];
let counts = EntityCounts {
hydro_ids: vec![1, 2],
hydro_productivities: vec![0.0, 1.5], thermal_ids: vec![],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(2, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 0.0,
objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[0.0, 1.5],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0; 2],
stage_index: 0,
n_stages: 1,
},
0,
);
assert_eq!(result.hydros.len(), 2);
assert!(
(result.hydros[0].generation_mw - 75.0).abs() < 1e-12,
"FPHA generation_mw should be 75.0, got {}",
result.hydros[0].generation_mw
);
assert!(
(result.hydros[1].generation_mw - 45.0).abs() < 1e-12,
"constant-productivity generation_mw should be 45.0, got {}",
result.hydros[1].generation_mw
);
}
#[test]
fn fpha_productivity_placeholder_zero() {
let indexer = make_indexer_2h_1fpha_1blk();
let n_cols = indexer.generation_below_slack.end;
let primal = vec![0.0_f64; n_cols];
let obj = vec![0.0_f64; n_cols];
let dual = vec![0.0_f64; 2];
let row_lower = vec![0.0_f64; 1];
let counts = EntityCounts {
hydro_ids: vec![1, 2],
hydro_productivities: vec![0.0, 1.5],
thermal_ids: vec![],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(2, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 0.0,
objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[0.0, 1.5],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0; 2],
stage_index: 0,
n_stages: 1,
},
0,
);
assert_eq!(
result.hydros[0].equivalent_productivity_mw_per_m3s, 0.0,
"FPHA hydro must carry placeholder 0.0 for equivalent_productivity_mw_per_m3s"
);
assert_eq!(
result.hydros[1].equivalent_productivity_mw_per_m3s, 0.0,
"constant-productivity hydro must carry placeholder 0.0 for equivalent_productivity_mw_per_m3s"
);
}
fn make_indexer_1h_evap_1blk() -> StageIndexer {
StageIndexer::with_equipment_and_evaporation(
&crate::indexer::EquipmentCounts {
hydro_count: 1,
max_par_order: 0,
n_thermals: 0,
n_lines: 0,
n_buses: 0,
n_blks: 1,
has_inflow_penalty: false,
max_deficit_segments: 1,
n_anticipated: 0,
k_max: 0,
anticipated_lead_stages: vec![],
anticipated_thermal_indices: vec![],
},
&crate::indexer::FphaColumnLayout {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
&crate::indexer::EvapConfig {
hydro_indices: vec![0],
},
)
}
#[test]
fn evaporation_read_from_lp_column() {
let indexer = make_indexer_1h_evap_1blk();
assert_eq!(indexer.evap_hydro_indices, vec![0]);
let ei = &indexer.evap_indices[0];
assert_eq!(ei.evaporation_flow_col, 7);
assert_eq!(ei.f_evap_plus_col, 8);
assert_eq!(ei.f_evap_minus_col, 9);
let n_cols = indexer.generation_below_slack.end;
let mut primal = vec![0.0_f64; n_cols];
primal[0] = 200.0; primal[2] = 190.0; primal[3] = 0.0; primal[4] = 10.0; primal[5] = 0.0; primal[7] = 3.5;
let obj = vec![0.0_f64; n_cols];
let dual = vec![0.0_f64; 1];
let row_lower = vec![0.0_f64; 1];
let counts = EntityCounts {
hydro_ids: vec![1],
hydro_productivities: vec![1.0],
thermal_ids: vec![],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(1, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 0.0,
objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0],
stage_index: 0,
n_stages: 1,
},
0,
);
assert_eq!(result.hydros.len(), 1);
assert_eq!(
result.hydros[0].evaporation_m3s,
Some(3.5),
"evaporation_m3s should be Some(3.5)"
);
assert!(
result.hydros[0].evaporation_violation_pos_m3s.abs() < 1e-12,
"evaporation_violation_pos_m3s should be 0.0"
);
}
#[test]
fn evaporation_violation_is_sum_of_slacks() {
let indexer = make_indexer_1h_evap_1blk();
let n_cols = indexer.generation_below_slack.end;
let mut primal = vec![0.0_f64; n_cols];
primal[0] = 200.0;
primal[2] = 190.0; primal[7] = 2.0; primal[8] = 0.5; primal[9] = 0.0;
let obj = vec![0.0_f64; n_cols];
let dual = vec![0.0_f64; 1];
let row_lower = vec![0.0_f64; 1];
let counts = EntityCounts {
hydro_ids: vec![1],
hydro_productivities: vec![1.0],
thermal_ids: vec![],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(1, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 0.0,
objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0],
stage_index: 0,
n_stages: 1,
},
0,
);
assert!(
(result.hydros[0].evaporation_violation_neg_m3s - 0.5).abs() < 1e-12,
"evaporation_violation_neg_m3s should be 0.5, got {}",
result.hydros[0].evaporation_violation_neg_m3s
);
assert!(
result.hydros[0].evaporation_violation_pos_m3s.abs() < 1e-12,
"evaporation_violation_pos_m3s should be 0.0, got {}",
result.hydros[0].evaporation_violation_pos_m3s
);
}
#[test]
fn turbined_cost_in_compute_cost_result() {
let indexer = make_indexer_2h_1fpha_1blk();
let n_cols = indexer.generation_below_slack.end;
let mut primal = vec![0.0_f64; n_cols];
primal[6] = 500.0;
primal[7] = 30.0;
let mut obj = vec![0.0_f64; n_cols];
obj[6] = 1.0; obj[7] = 0.01;
let dual = vec![0.0_f64; 2];
let row_lower = vec![0.0_f64; 1];
let counts = EntityCounts {
hydro_ids: vec![1, 2],
hydro_productivities: vec![0.0, 1.5],
thermal_ids: vec![],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(2, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 500.3, objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[0.0, 1.5],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0; 2],
stage_index: 0,
n_stages: 1,
},
0,
);
let cost = &result.costs[0];
assert!(
(cost.turbined_cost - 300_000.0).abs() < 1e-9,
"turbined_cost should be 300_000.0 (30.0 * 0.01 * COST_SCALE_FACTOR), got {}",
cost.turbined_cost
);
}
#[test]
fn cost_breakdown_sums_to_immediate_identity_scale() {
let indexer = make_indexer_2h_1fpha_1blk();
let n_cols = indexer.generation_below_slack.end;
let mut primal = vec![0.0_f64; n_cols];
primal[6] = 500.0; primal[7] = 30.0;
let mut obj = vec![0.0_f64; n_cols];
obj[6] = 1.0; obj[7] = 0.01; let objective_val = 500.3_f64;
let dual = vec![0.0_f64; 2];
let row_lower = vec![0.0_f64; 1];
let counts = EntityCounts {
hydro_ids: vec![1, 2],
hydro_productivities: vec![0.0, 1.5],
thermal_ids: vec![],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(2, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: objective_val,
objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[0.0, 1.5],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0; 2],
stage_index: 0,
n_stages: 1,
},
0,
);
let cost = &result.costs[0];
assert!(
(cost.immediate_cost - 300_000.0).abs() < 1.0,
"immediate_cost should be 300_000.0, got {}",
cost.immediate_cost
);
let component_sum = cost.thermal_cost
+ cost.deficit_cost
+ cost.excess_cost
+ cost.exchange_cost
+ cost.spillage_cost
+ cost.generic_violation_cost
+ cost.turbined_cost
+ cost.curtailment_cost;
assert!(
(component_sum - cost.immediate_cost).abs() < 1.0,
"per-component cost sum ({component_sum}) must equal immediate_cost ({})",
cost.immediate_cost
);
}
#[test]
fn cost_unscaled_by_col_scale() {
let indexer = make_indexer_2h_1fpha_1blk();
let n_cols = indexer.generation_below_slack.end;
let mut primal = vec![0.0_f64; n_cols];
primal[6] = 500.0; primal[7] = 30.0;
let mut obj = vec![0.0_f64; n_cols];
obj[6] = 1.0; obj[7] = 0.01;
let mut col_scale = vec![1.0_f64; n_cols];
col_scale[7] = 2.0;
let dual = vec![0.0_f64; 2];
let row_lower = vec![0.0_f64; 1];
let counts = EntityCounts {
hydro_ids: vec![1, 2],
hydro_productivities: vec![0.0, 1.5],
thermal_ids: vec![],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(2, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 500.3, objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[0.0, 1.5],
col_scale: &col_scale,
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0; 2],
stage_index: 0,
n_stages: 1,
},
0,
);
let cost = &result.costs[0];
assert!(
(cost.turbined_cost - 150_000.0).abs() < 1e-6,
"turbined_cost should be 150_000.0 (unscaled by col_scale=2.0), got {}",
cost.turbined_cost
);
}
#[test]
fn hydro_violation_cost_decomposition() {
let indexer = make_indexer_2h_1fpha_1blk();
assert_eq!(indexer.withdrawal_slack_neg, 14..16);
assert_eq!(indexer.withdrawal_slack_pos, 16..18);
assert_eq!(indexer.outflow_below_slack, 18..20);
assert_eq!(indexer.outflow_above_slack, 20..22);
assert_eq!(indexer.turbine_below_slack, 22..24);
assert_eq!(indexer.generation_below_slack, 24..26);
assert!(indexer.has_operational_violations);
let n_cols = indexer.generation_below_slack.end;
let mut primal = vec![0.0_f64; n_cols];
let mut obj = vec![0.0_f64; n_cols];
primal[indexer.theta] = 0.0;
primal[18] = 2.0;
obj[18] = 10.0;
primal[19] = 3.0;
obj[19] = 10.0;
primal[20] = 1.0;
obj[20] = 5.0;
primal[22] = 4.0;
obj[22] = 8.0;
primal[25] = 6.0;
obj[25] = 3.0;
primal[14] = 0.5;
obj[14] = 20.0;
primal[17] = 0.3;
obj[17] = 15.0;
let total_obj: f64 = primal.iter().zip(obj.iter()).map(|(p, o)| p * o).sum();
let dual = vec![0.0_f64; 2];
let row_lower = vec![0.0_f64; 1];
let counts = EntityCounts {
hydro_ids: vec![1, 2],
hydro_productivities: vec![0.0, 1.5],
thermal_ids: vec![],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let ec = zero_energy_conversion(2, 1);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: total_obj,
objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[0.0, 1.5],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[0.0; 2],
stage_index: 0,
n_stages: 1,
},
0,
);
let cost = &result.costs[0];
let expected_outflow_below = (2.0 * 10.0 + 3.0 * 10.0) * 1_000_000.0;
let expected_outflow_above = (1.0 * 5.0) * 1_000_000.0;
let expected_turbined = (4.0 * 8.0) * 1_000_000.0;
let expected_generation = (6.0 * 3.0) * 1_000_000.0;
let expected_withdrawal = (0.5 * 20.0 + 0.3 * 15.0) * 1_000_000.0;
let expected_evaporation = 0.0;
assert!(
(cost.outflow_violation_below_cost - expected_outflow_below).abs() < 1e-6,
"outflow_below: expected {expected_outflow_below}, got {}",
cost.outflow_violation_below_cost
);
assert!(
(cost.outflow_violation_above_cost - expected_outflow_above).abs() < 1e-6,
"outflow_above: expected {expected_outflow_above}, got {}",
cost.outflow_violation_above_cost
);
assert!(
(cost.turbined_violation_cost - expected_turbined).abs() < 1e-6,
"turbined: expected {expected_turbined}, got {}",
cost.turbined_violation_cost
);
assert!(
(cost.generation_violation_cost - expected_generation).abs() < 1e-6,
"generation: expected {expected_generation}, got {}",
cost.generation_violation_cost
);
assert!(
(cost.evaporation_violation_cost - expected_evaporation).abs() < 1e-6,
"evaporation: expected {expected_evaporation}, got {}",
cost.evaporation_violation_cost
);
assert!(
(cost.withdrawal_violation_cost - expected_withdrawal).abs() < 1e-6,
"withdrawal: expected {expected_withdrawal}, got {}",
cost.withdrawal_violation_cost
);
let component_sum = cost.outflow_violation_below_cost
+ cost.outflow_violation_above_cost
+ cost.turbined_violation_cost
+ cost.generation_violation_cost
+ cost.evaporation_violation_cost
+ cost.withdrawal_violation_cost;
assert!(
(cost.hydro_violation_cost - component_sum).abs() < 1e-6,
"hydro_violation_cost ({}) must equal sum of components ({component_sum})",
cost.hydro_violation_cost
);
}
fn one_hydro_energy_set(
rho_eq: f64,
rho_acum: f64,
) -> crate::energy_conversion::EnergyConversionSet {
use crate::energy_conversion::{EnergyConversion, EnergyConversionSet};
let cell = EnergyConversion {
equivalent_productivity_mw_per_m3s: rho_eq,
reference_volume_hm3: 0.0,
reference_outflow_m3s: 0.0,
};
EnergyConversionSet::new(vec![vec![cell; 1]; 1], vec![vec![rho_acum; 1]; 1], 1, 1)
}
fn make_entity_counts_1_hydro() -> EntityCounts {
EntityCounts {
hydro_ids: vec![10],
hydro_productivities: vec![1.0],
thermal_ids: vec![],
line_ids: vec![],
bus_ids: vec![100],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
}
}
fn make_primal_1_1(storage: f64, storage_in: f64, theta: f64) -> Vec<f64> {
vec![storage, 0.0, 0.0, storage_in, theta]
}
#[test]
fn stored_energy_initial_uses_v_min_offset() {
let indexer = StageIndexer::new(1, 1);
let primal = make_primal_1_1(120.0, 110.0, 0.0);
let dual = vec![0.0; 2];
let ec = one_hydro_energy_set(0.9, 4.0);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 0.0,
objective_coeffs: &[],
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &make_entity_counts_1_hydro(),
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[100.0],
stage_index: 0,
n_stages: 1,
},
0,
);
assert_eq!(result.hydros.len(), 1);
let h = &result.hydros[0];
assert!(
(h.equivalent_productivity_mw_per_m3s - 0.9).abs() < 1e-12,
"equivalent_productivity should be 0.9, got {}",
h.equivalent_productivity_mw_per_m3s
);
assert!(
(h.accumulated_productivity_mw_per_m3s - 4.0).abs() < 1e-12,
"accumulated_productivity should be 4.0, got {}",
h.accumulated_productivity_mw_per_m3s
);
let expected = (110.0_f64 - 100.0) * 4.0 * 1.0e6 / 3600.0;
assert!(
(h.stored_energy_initial_mwh - expected).abs() < 1e-6,
"stored_energy_initial: expected {expected}, got {}",
h.stored_energy_initial_mwh
);
}
#[test]
fn incremental_inflow_energy_uses_rho_acum() {
let indexer = StageIndexer::new(1, 1);
let primal = make_primal_1_1(120.0, 110.0, 0.0);
let dual = vec![0.0; 2];
let ec = one_hydro_energy_set(0.9, 4.0);
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 0.0,
objective_coeffs: &[],
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &make_entity_counts_1_hydro(),
inflow_m3s_per_hydro: &[50.0],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[100.0],
stage_index: 0,
n_stages: 1,
},
0,
);
assert_eq!(result.hydros.len(), 1);
assert!(
(result.hydros[0].incremental_inflow_energy_mw - 200.0).abs() < 1e-12,
"incremental_inflow_energy should be 200.0, got {}",
result.hydros[0].incremental_inflow_energy_mw
);
}
#[test]
fn stage_path_propagates_productivity_values() {
let indexer = StageIndexer::new(1, 1);
let primal = make_primal_1_1(120.0, 110.0, 0.0);
let dual = vec![0.0; 2];
let ec = one_hydro_energy_set(0.85, 3.5);
let stage_result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 0.0,
objective_coeffs: &[],
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &make_entity_counts_1_hydro(),
inflow_m3s_per_hydro: &[10.0],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0],
col_scale: &[],
row_scale: &[],
cumulative_discount_factor: 1.0,
energy_conversion: &ec,
hydro_min_storage_hm3: &[100.0],
stage_index: 0,
n_stages: 1,
},
0,
);
let rho_eq = stage_result.hydros[0].equivalent_productivity_mw_per_m3s;
let rho_acum = stage_result.hydros[0].accumulated_productivity_mw_per_m3s;
assert!((rho_eq - 0.85).abs() < 1e-12, "stage rho_eq = {rho_eq}");
assert!(
(rho_acum - 3.5).abs() < 1e-12,
"stage rho_acum = {rho_acum}"
);
}
}