use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::Path;
use chrono::NaiveDate;
use cobre_core::{EntityId, System};
use cobre_io::{
Config, FileManifest, LoadError, ValidationContext,
config::OrderSelectionMethod,
parse_inflow_history,
scenarios::{InflowArCoefficientRow, InflowSeasonalStatsRow, assemble_inflow_models},
validate_structure,
};
use cobre_stochastic::{
StochasticError,
par::contribution::{
check_negative_contributions, compute_contributions, find_max_valid_order,
has_negative_phi1,
},
par::fitting::{
ArCoefficientEstimate, SeasonalStats, estimate_ar_coefficients_with_season_map,
estimate_correlation_with_season_map, estimate_periodic_ar_coefficients,
estimate_seasonal_stats_with_season_map, find_season_for_date, periodic_pacf,
select_order_pacf,
},
};
#[derive(Debug, thiserror::Error)]
pub enum EstimationError {
#[error("load error: {0}")]
Load(#[from] LoadError),
#[error("estimation failed: {0}")]
Stochastic(#[from] StochasticError),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReductionReason {
MagnitudeBound,
Phi1Negative,
NegativeContribution,
}
impl ReductionReason {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::MagnitudeBound => "magnitude_bound",
Self::Phi1Negative => "phi1_negative",
Self::NegativeContribution => "negative_contribution",
}
}
}
#[derive(Debug, Clone)]
pub struct ContributionReduction {
pub season_id: usize,
pub original_order: usize,
pub reduced_order: usize,
pub contributions: Vec<f64>,
pub reason: ReductionReason,
}
#[derive(Debug, Clone)]
pub struct HydroEstimationEntry {
pub selected_order: u32,
pub coefficients: Vec<Vec<f64>>,
pub contribution_reductions: Vec<ContributionReduction>,
}
#[must_use]
#[derive(Debug, Clone)]
pub struct EstimationReport {
pub entries: BTreeMap<EntityId, HydroEstimationEntry>,
pub method: String,
}
#[derive(Debug, Clone)]
pub struct ContributionValidationResult {
pub valid: bool,
pub max_valid_order: usize,
pub contributions: Vec<f64>,
}
fn validate_order_contributions(
season_id: usize,
n_seasons: usize,
current_order: usize,
all_season_coefficients: &[Vec<f64>],
std_by_season: &[f64],
) -> ContributionValidationResult {
if current_order == 0 {
return ContributionValidationResult {
valid: true,
max_valid_order: 0,
contributions: Vec::new(),
};
}
let coeff_refs: Vec<&[f64]> = all_season_coefficients.iter().map(Vec::as_slice).collect();
let contributions = compute_contributions(
season_id,
n_seasons,
current_order,
&coeff_refs,
std_by_season,
);
let valid = !check_negative_contributions(&contributions);
let max_valid_order = if valid {
current_order
} else {
find_max_valid_order(&contributions)
};
ContributionValidationResult {
valid,
max_valid_order,
contributions,
}
}
fn residual_std_ratio_from_sigma2(sigma2: f64) -> f64 {
if sigma2 <= 0.0 {
1.0
} else {
sigma2.sqrt().clamp(f64::EPSILON, 1.0)
}
}
pub fn estimate_from_history(
system: System,
case_dir: &Path,
config: &Config,
) -> Result<(System, Option<EstimationReport>), EstimationError> {
let mut ctx = ValidationContext::new();
let manifest = validate_structure(case_dir, &mut ctx);
if ctx.into_result().is_err() {
return Ok((system, None));
}
if !manifest.scenarios_inflow_history_parquet {
return Ok((system, None));
}
if manifest.scenarios_inflow_seasonal_stats_parquet
&& manifest.scenarios_inflow_ar_coefficients_parquet
{
return Ok((system, None));
}
let (system, report) = run_estimation(system, case_dir, config, &manifest)?;
Ok((system, Some(report)))
}
fn run_estimation(
system: System,
case_dir: &Path,
config: &Config,
manifest: &FileManifest,
) -> Result<(System, EstimationReport), EstimationError> {
let history_path = case_dir.join("scenarios/inflow_history.parquet");
let history = parse_inflow_history(&history_path)?;
let observations: Vec<(EntityId, NaiveDate, f64)> = history
.iter()
.map(|row| (row.hydro_id, row.date, row.value_m3s))
.collect();
let hydro_ids: Vec<EntityId> = system.hydros().iter().map(|h| h.id).collect();
let stages = system.stages();
let season_map = system.policy_graph().season_map.as_ref();
let seasonal_stats =
estimate_seasonal_stats_with_season_map(&observations, stages, &hydro_ids, season_map)?;
let max_order = config.estimation.max_order as usize;
let (ar_estimates, estimation_report) = estimate_ar_coefficients_with_selection(
&observations,
&seasonal_stats,
stages,
&hydro_ids,
&ArEstimationConfig {
max_order,
max_coeff_magnitude: config.estimation.max_coefficient_magnitude,
method: &config.estimation.order_selection,
season_map,
},
)?;
let correlation = if manifest.scenarios_correlation_json {
system.correlation().clone()
} else {
estimate_correlation_with_season_map(
&observations,
&ar_estimates,
&seasonal_stats,
stages,
&hydro_ids,
season_map,
)?
};
let stats_rows = seasonal_stats_to_rows(&seasonal_stats, stages);
let coeff_rows = ar_estimates_to_rows(&ar_estimates, stages);
let inflow_models = assemble_inflow_models(stats_rows, coeff_rows)?;
Ok((
system.with_scenario_models(inflow_models, correlation),
estimation_report,
))
}
struct ArEstimationConfig<'a> {
max_order: usize,
max_coeff_magnitude: Option<f64>,
method: &'a OrderSelectionMethod,
season_map: Option<&'a cobre_core::temporal::SeasonMap>,
}
fn estimate_ar_coefficients_with_selection(
observations: &[(EntityId, NaiveDate, f64)],
seasonal_stats: &[SeasonalStats],
stages: &[cobre_core::temporal::Stage],
hydro_ids: &[EntityId],
cfg: &ArEstimationConfig<'_>,
) -> Result<(Vec<ArCoefficientEstimate>, EstimationReport), StochasticError> {
match cfg.method {
OrderSelectionMethod::Fixed => {
let mut estimates = estimate_ar_coefficients_with_season_map(
observations,
seasonal_stats,
stages,
hydro_ids,
cfg.max_order,
cfg.season_map,
)?;
let stage_index = stages
.iter()
.filter_map(|s| s.season_id.map(|sid| (s.id, sid)))
.collect::<Vec<_>>();
let stage_id_to_season: HashMap<i32, usize> = stage_index.iter().copied().collect();
let n_seasons = stage_index
.iter()
.map(|&(_, sid)| sid + 1)
.max()
.unwrap_or(0);
let stats_map: HashMap<(EntityId, usize), &SeasonalStats> = seasonal_stats
.iter()
.filter_map(|s| {
let season_id = stage_id_to_season.get(&s.stage_id).copied()?;
Some(((s.entity_id, season_id), s))
})
.collect();
let sigma2_map: HashMap<(EntityId, usize), Vec<f64>> = HashMap::new();
let reductions = apply_contribution_validation(
&mut estimates,
n_seasons,
&stats_map,
&sigma2_map,
cfg.max_coeff_magnitude,
);
let report = build_estimation_report(&estimates, n_seasons, &reductions, "fixed");
Ok((estimates, report))
}
OrderSelectionMethod::Pacf => estimate_ar_with_pacf(
observations,
seasonal_stats,
stages,
hydro_ids,
cfg.max_order,
cfg.season_map,
cfg.max_coeff_magnitude,
),
}
}
#[allow(clippy::too_many_lines)]
fn estimate_ar_with_pacf(
observations: &[(EntityId, NaiveDate, f64)],
seasonal_stats: &[SeasonalStats],
stages: &[cobre_core::temporal::Stage],
hydro_ids: &[EntityId],
max_order: usize,
season_map: Option<&cobre_core::temporal::SeasonMap>,
max_coeff_magnitude: Option<f64>,
) -> Result<(Vec<ArCoefficientEstimate>, EstimationReport), StochasticError> {
if max_order == 0 {
let estimates = estimate_ar_coefficients_with_season_map(
observations,
seasonal_stats,
stages,
hydro_ids,
0,
season_map,
)?;
let report = EstimationReport {
entries: BTreeMap::new(),
method: "PACF".to_string(),
};
return Ok((estimates, report));
}
let mut stage_index = stages
.iter()
.filter_map(|s| s.season_id.map(|sid| (s.start_date, s.end_date, s.id, sid)))
.collect::<Vec<_>>();
stage_index.sort_unstable_by_key(|(start, _, _, _)| *start);
let stage_id_to_season: HashMap<i32, usize> = stage_index
.iter()
.map(|&(_, _, stage_id, season_id)| (stage_id, season_id))
.collect();
let stats_map: HashMap<(EntityId, usize), &SeasonalStats> = seasonal_stats
.iter()
.filter_map(|s| {
let season_id = stage_id_to_season.get(&s.stage_id).copied()?;
Some(((s.entity_id, season_id), s))
})
.collect();
let n_seasons: usize = {
let mut max_season = 0usize;
for &(_, _, _, season_id) in &stage_index {
if season_id >= max_season {
max_season = season_id + 1;
}
}
max_season
};
let entity_set: HashSet<EntityId> = hydro_ids.iter().copied().collect();
let mut group_obs: HashMap<(EntityId, usize), Vec<f64>> = HashMap::new();
for &(entity_id, date, value) in observations {
if !entity_set.contains(&entity_id) {
continue;
}
let Some(season_id) = find_season_for_date(&stage_index, date)
.or_else(|| season_map.and_then(|sm| sm.season_for_date(date)))
else {
continue;
};
group_obs
.entry((entity_id, season_id))
.or_default()
.push(value);
}
let z_alpha = 1.96_f64;
let mut estimates: Vec<ArCoefficientEstimate> = Vec::new();
for &hydro_id in hydro_ids {
let mut obs_by_season: Vec<Vec<f64>> = vec![Vec::new(); n_seasons];
let mut stats_by_season: Vec<(f64, f64)> = vec![(0.0, 0.0); n_seasons];
for season in 0..n_seasons {
if let Some(obs) = group_obs.get(&(hydro_id, season)) {
obs_by_season[season].clone_from(obs);
}
if let Some(stats) = stats_map.get(&(hydro_id, season)) {
stats_by_season[season] = (stats.mean, stats.std);
}
}
let obs_refs: Vec<&[f64]> = obs_by_season.iter().map(Vec::as_slice).collect();
for season in 0..n_seasons {
let stats_s = stats_by_season[season];
if stats_s.1 == 0.0 || obs_by_season[season].len() < 2 {
estimates.push(ArCoefficientEstimate {
hydro_id,
season_id: season,
coefficients: Vec::new(),
residual_std_ratio: 1.0,
});
continue;
}
let n_obs = obs_by_season[season].len();
let pacf_values =
periodic_pacf(season, max_order, n_seasons, &obs_refs, &stats_by_season);
let pacf_result = select_order_pacf(&pacf_values, n_obs, z_alpha);
let selected_order = pacf_result.selected_order;
let yw_result = estimate_periodic_ar_coefficients(
season,
selected_order,
n_seasons,
&obs_refs,
&stats_by_season,
);
estimates.push(ArCoefficientEstimate {
hydro_id,
season_id: season,
coefficients: yw_result.coefficients,
residual_std_ratio: yw_result.residual_std_ratio,
});
}
}
let reductions = iterative_pacf_reduction(
&mut estimates,
n_seasons,
hydro_ids,
&group_obs,
&stats_map,
max_order,
z_alpha,
max_coeff_magnitude,
);
let report = build_estimation_report(&estimates, n_seasons, &reductions, "PACF");
Ok((estimates, report))
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn iterative_pacf_reduction(
estimates: &mut [ArCoefficientEstimate],
n_seasons: usize,
hydro_ids: &[EntityId],
group_obs: &HashMap<(EntityId, usize), Vec<f64>>,
stats_map: &HashMap<(EntityId, usize), &SeasonalStats>,
initial_max_order: usize,
z_alpha: f64,
max_coeff_magnitude: Option<f64>,
) -> HashMap<EntityId, Vec<ContributionReduction>> {
let mut all_reductions: HashMap<EntityId, Vec<ContributionReduction>> = HashMap::new();
if let Some(threshold) = max_coeff_magnitude {
for est in estimates.iter_mut() {
let has_explosive = est.coefficients.iter().any(|c| c.abs() > threshold);
if has_explosive {
let original_order = est.coefficients.len();
all_reductions
.entry(est.hydro_id)
.or_default()
.push(ContributionReduction {
season_id: est.season_id,
original_order,
reduced_order: 0,
contributions: Vec::new(),
reason: ReductionReason::MagnitudeBound,
});
est.coefficients.clear();
est.residual_std_ratio = 1.0;
}
}
}
for est in estimates.iter_mut() {
if has_negative_phi1(&est.coefficients) {
let original_order = est.coefficients.len();
all_reductions
.entry(est.hydro_id)
.or_default()
.push(ContributionReduction {
season_id: est.season_id,
original_order,
reduced_order: 0,
contributions: Vec::new(),
reason: ReductionReason::Phi1Negative,
});
est.coefficients.clear();
est.residual_std_ratio = 1.0;
}
}
let mut hydro_indices: BTreeMap<EntityId, Vec<usize>> = BTreeMap::new();
for (idx, est) in estimates.iter().enumerate() {
hydro_indices.entry(est.hydro_id).or_default().push(idx);
}
for &hydro_id in hydro_ids {
let Some(indices) = hydro_indices.get(&hydro_id) else {
continue;
};
let mut obs_by_season: Vec<Vec<f64>> = vec![Vec::new(); n_seasons];
let mut stats_by_season: Vec<(f64, f64)> = vec![(0.0, 0.0); n_seasons];
for season in 0..n_seasons {
if let Some(obs) = group_obs.get(&(hydro_id, season)) {
obs_by_season[season].clone_from(obs);
}
if let Some(stats) = stats_map.get(&(hydro_id, season)) {
stats_by_season[season] = (stats.mean, stats.std);
}
}
let std_by_season: Vec<f64> = (0..n_seasons)
.map(|sid| stats_map.get(&(hydro_id, sid)).map_or(0.0, |s| s.std))
.collect();
let mut max_orders: Vec<usize> = vec![initial_max_order; n_seasons];
let mut all_coeffs: Vec<Vec<f64>> = vec![Vec::new(); n_seasons];
for &idx in indices {
let est = &estimates[idx];
if est.season_id < n_seasons {
all_coeffs[est.season_id].clone_from(&est.coefficients);
}
}
let mut frozen: Vec<bool> = vec![false; n_seasons];
for &idx in indices {
let sid = estimates[idx].season_id;
if estimates[idx].coefficients.is_empty() {
frozen[sid] = true;
}
}
let obs_refs: Vec<&[f64]> = obs_by_season.iter().map(Vec::as_slice).collect();
loop {
let mut failing_seasons: Vec<usize> = Vec::new();
for &idx in indices {
let season_id = estimates[idx].season_id;
if frozen[season_id] {
continue;
}
let current_order = estimates[idx].coefficients.len();
if current_order == 0 {
continue;
}
let result = validate_order_contributions(
season_id,
n_seasons,
current_order,
&all_coeffs,
&std_by_season,
);
if !result.valid {
all_reductions
.entry(hydro_id)
.or_default()
.push(ContributionReduction {
season_id,
original_order: current_order,
reduced_order: result.max_valid_order,
contributions: result.contributions,
reason: ReductionReason::NegativeContribution,
});
failing_seasons.push(season_id);
}
}
if failing_seasons.is_empty() {
break; }
let mut any_reselected = false;
for &season_id in &failing_seasons {
if max_orders[season_id] == 0 {
continue;
}
max_orders[season_id] -= 1;
if max_orders[season_id] == 0 {
for &idx in indices {
if estimates[idx].season_id == season_id {
estimates[idx].coefficients.clear();
estimates[idx].residual_std_ratio = 1.0;
all_coeffs[season_id].clear();
frozen[season_id] = true;
}
}
continue;
}
let stats_s = stats_by_season[season_id];
if stats_s.1 == 0.0 || obs_by_season[season_id].len() < 2 {
frozen[season_id] = true;
continue;
}
let n_obs = obs_by_season[season_id].len();
let pacf_values = periodic_pacf(
season_id,
max_orders[season_id],
n_seasons,
&obs_refs,
&stats_by_season,
);
let pacf_result = select_order_pacf(&pacf_values, n_obs, z_alpha);
let selected_order = pacf_result.selected_order;
let yw_result = estimate_periodic_ar_coefficients(
season_id,
selected_order,
n_seasons,
&obs_refs,
&stats_by_season,
);
for &idx in indices {
if estimates[idx].season_id == season_id {
estimates[idx]
.coefficients
.clone_from(&yw_result.coefficients);
estimates[idx].residual_std_ratio = yw_result.residual_std_ratio;
all_coeffs[season_id].clone_from(&yw_result.coefficients);
}
}
if has_negative_phi1(&all_coeffs[season_id]) {
let original_order = all_coeffs[season_id].len();
all_reductions
.entry(hydro_id)
.or_default()
.push(ContributionReduction {
season_id,
original_order,
reduced_order: 0,
contributions: Vec::new(),
reason: ReductionReason::Phi1Negative,
});
for &idx in indices {
if estimates[idx].season_id == season_id {
estimates[idx].coefficients.clear();
estimates[idx].residual_std_ratio = 1.0;
all_coeffs[season_id].clear();
frozen[season_id] = true;
}
}
} else {
any_reselected = true;
}
}
if !any_reselected {
break; }
}
}
all_reductions
}
fn apply_contribution_validation(
estimates: &mut [ArCoefficientEstimate],
n_seasons: usize,
stats_map: &HashMap<(EntityId, usize), &SeasonalStats>,
sigma2_map: &HashMap<(EntityId, usize), Vec<f64>>,
max_coeff_magnitude: Option<f64>,
) -> HashMap<EntityId, Vec<ContributionReduction>> {
let mut all_reductions: HashMap<EntityId, Vec<ContributionReduction>> = HashMap::new();
if let Some(threshold) = max_coeff_magnitude {
for est in estimates.iter_mut() {
let has_explosive = est.coefficients.iter().any(|c| c.abs() > threshold);
if has_explosive {
let original_order = est.coefficients.len();
all_reductions
.entry(est.hydro_id)
.or_default()
.push(ContributionReduction {
season_id: est.season_id,
original_order,
reduced_order: 0,
contributions: Vec::new(), reason: ReductionReason::MagnitudeBound,
});
est.coefficients.clear();
est.residual_std_ratio = 1.0;
}
}
}
for est in estimates.iter_mut() {
if has_negative_phi1(&est.coefficients) {
let original_order = est.coefficients.len();
all_reductions
.entry(est.hydro_id)
.or_default()
.push(ContributionReduction {
season_id: est.season_id,
original_order,
reduced_order: 0,
contributions: Vec::new(),
reason: ReductionReason::Phi1Negative,
});
est.coefficients.clear();
est.residual_std_ratio = 1.0;
}
}
let mut hydro_indices: BTreeMap<EntityId, Vec<usize>> = BTreeMap::new();
for (idx, est) in estimates.iter().enumerate() {
hydro_indices.entry(est.hydro_id).or_default().push(idx);
}
for (&hydro_id, indices) in &hydro_indices {
let std_by_season: Vec<f64> = (0..n_seasons)
.map(|sid| stats_map.get(&(hydro_id, sid)).map_or(0.0, |s| s.std))
.collect();
let mut all_coeffs: Vec<Vec<f64>> = vec![Vec::new(); n_seasons];
for &idx in indices {
let est = &estimates[idx];
if est.season_id < n_seasons {
all_coeffs[est.season_id].clone_from(&est.coefficients);
}
}
for &idx in indices {
let season_id = estimates[idx].season_id;
let mut current_order = estimates[idx].coefficients.len();
loop {
let result = validate_order_contributions(
season_id,
n_seasons,
current_order,
&all_coeffs,
&std_by_season,
);
if result.valid || current_order == 0 {
break;
}
let original_order = current_order;
let reduced_order = result.max_valid_order;
all_reductions
.entry(hydro_id)
.or_default()
.push(ContributionReduction {
season_id,
original_order,
reduced_order,
contributions: result.contributions,
reason: ReductionReason::NegativeContribution,
});
estimates[idx].coefficients.truncate(reduced_order);
if reduced_order == 0 {
estimates[idx].residual_std_ratio = 1.0;
} else if let Some(sigma2_vec) = sigma2_map.get(&(hydro_id, season_id)) {
if reduced_order <= sigma2_vec.len() {
let sigma2 = sigma2_vec[reduced_order - 1];
estimates[idx].residual_std_ratio = residual_std_ratio_from_sigma2(sigma2);
}
}
all_coeffs[season_id].clone_from(&estimates[idx].coefficients);
current_order = reduced_order;
}
}
}
all_reductions
}
pub(crate) fn build_estimation_report(
estimates: &[ArCoefficientEstimate],
n_seasons: usize,
contribution_reductions: &HashMap<EntityId, Vec<ContributionReduction>>,
method: &str,
) -> EstimationReport {
let mut hydro_coeffs: BTreeMap<EntityId, Vec<(usize, Vec<f64>)>> = BTreeMap::new();
for est in estimates {
hydro_coeffs
.entry(est.hydro_id)
.or_default()
.push((est.season_id, est.coefficients.clone()));
}
let mut entries: BTreeMap<EntityId, HydroEstimationEntry> = BTreeMap::new();
for (hydro_id, mut season_coeffs) in hydro_coeffs {
season_coeffs.sort_by_key(|(season_id, _)| *season_id);
let selected_order = season_coeffs
.iter()
.map(|(_, coeffs)| coeffs.len())
.max()
.unwrap_or(0);
let season_map: HashMap<usize, Vec<f64>> = season_coeffs.into_iter().collect();
let coefficients: Vec<Vec<f64>> = (0..n_seasons)
.map(|sid| season_map.get(&sid).cloned().unwrap_or_default())
.collect();
let reductions = contribution_reductions
.get(&hydro_id)
.cloned()
.unwrap_or_default();
#[allow(clippy::cast_possible_truncation)]
entries.insert(
hydro_id,
HydroEstimationEntry {
selected_order: selected_order as u32,
coefficients,
contribution_reductions: reductions,
},
);
}
EstimationReport {
entries,
method: method.to_string(),
}
}
fn seasonal_stats_to_rows(
stats: &[SeasonalStats],
stages: &[cobre_core::temporal::Stage],
) -> Vec<InflowSeasonalStatsRow> {
let stage_to_season: HashMap<i32, usize> = stages
.iter()
.filter_map(|s| s.season_id.map(|sid| (s.id, sid)))
.collect();
let mut season_to_stages: HashMap<usize, Vec<i32>> = HashMap::new();
for stage in stages {
if let Some(sid) = stage.season_id {
season_to_stages.entry(sid).or_default().push(stage.id);
}
}
let mut rows = Vec::with_capacity(stats.len() * 10);
for s in stats {
if let Some(&season_id) = stage_to_season.get(&s.stage_id) {
if let Some(stage_ids) = season_to_stages.get(&season_id) {
for &stage_id in stage_ids {
rows.push(InflowSeasonalStatsRow {
hydro_id: s.entity_id,
stage_id,
mean_m3s: s.mean,
std_m3s: s.std,
});
}
continue;
}
}
rows.push(InflowSeasonalStatsRow {
hydro_id: s.entity_id,
stage_id: s.stage_id,
mean_m3s: s.mean,
std_m3s: s.std,
});
}
rows.sort_by_key(|r| (r.hydro_id.0, r.stage_id));
rows
}
fn ar_estimates_to_rows(
ar_estimates: &[ArCoefficientEstimate],
stages: &[cobre_core::temporal::Stage],
) -> Vec<InflowArCoefficientRow> {
let mut season_to_stages: HashMap<usize, Vec<i32>> = HashMap::new();
for stage in stages {
if let Some(sid) = stage.season_id {
season_to_stages.entry(sid).or_default().push(stage.id);
}
}
let mut rows: Vec<InflowArCoefficientRow> = Vec::new();
for est in ar_estimates {
let Some(stage_ids) = season_to_stages.get(&est.season_id) else {
continue;
};
for &stage_id in stage_ids {
for (lag_idx, &coeff) in est.coefficients.iter().enumerate() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let lag = (lag_idx + 1) as i32;
rows.push(InflowArCoefficientRow {
hydro_id: est.hydro_id,
stage_id,
lag,
coefficient: coeff,
residual_std_ratio: est.residual_std_ratio,
});
}
}
}
rows.sort_by_key(|r| (r.hydro_id.0, r.stage_id, r.lag));
rows
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::too_many_lines,
clippy::float_cmp,
clippy::doc_markdown,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::useless_vec
)]
mod tests {
use super::*;
use cobre_core::scenario::{CorrelationModel, InflowModel};
use cobre_core::{EntityId, SystemBuilder};
fn minimal_system_with_inflow_models(models: Vec<InflowModel>) -> System {
SystemBuilder::new()
.inflow_models(models)
.build()
.expect("valid system")
}
#[test]
fn test_with_scenario_models_replaces_fields() {
use cobre_core::{
Bus, DeficitSegment,
scenario::{CorrelationModel, InflowModel},
};
let bus = Bus {
id: EntityId(1),
name: "B1".to_string(),
deficit_segments: vec![DeficitSegment {
depth_mw: Some(f64::INFINITY),
cost_per_mwh: 1000.0,
}],
excess_cost: 0.0,
};
let old_model = InflowModel {
hydro_id: EntityId(1),
stage_id: 0,
mean_m3s: 10.0,
std_m3s: 1.0,
ar_coefficients: vec![],
residual_std_ratio: 1.0,
};
let system = SystemBuilder::new()
.buses(vec![bus])
.inflow_models(vec![old_model.clone(), {
let mut m = old_model.clone();
m.stage_id = 1;
m
}])
.build()
.expect("valid system");
assert_eq!(system.inflow_models().len(), 2);
assert_eq!(system.n_buses(), 1);
let new_models: Vec<InflowModel> = (0..4)
.map(|i| InflowModel {
hydro_id: EntityId(1),
stage_id: i,
mean_m3s: 50.0,
std_m3s: 5.0,
ar_coefficients: vec![0.4],
residual_std_ratio: 0.9,
})
.collect();
let new_corr = CorrelationModel::default();
let updated = system.with_scenario_models(new_models.clone(), new_corr.clone());
assert_eq!(updated.inflow_models().len(), 4, "expected 4 inflow models");
assert_eq!(
*updated.correlation(),
new_corr,
"correlation should equal new_corr"
);
assert_eq!(updated.n_buses(), 1, "buses must be preserved");
assert!(
updated.hydros().is_empty(),
"hydros must be preserved (empty)"
);
assert!(
updated.stages().is_empty(),
"stages must be preserved (empty)"
);
}
#[test]
fn test_with_scenario_models_clears_when_empty() {
let model = InflowModel {
hydro_id: EntityId(1),
stage_id: 0,
mean_m3s: 100.0,
std_m3s: 10.0,
ar_coefficients: vec![],
residual_std_ratio: 1.0,
};
let system = minimal_system_with_inflow_models(vec![model]);
assert_eq!(system.inflow_models().len(), 1);
let updated = system.with_scenario_models(vec![], CorrelationModel::default());
assert!(updated.inflow_models().is_empty());
}
#[test]
fn test_estimate_explicit_stats_returns_unchanged() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let case_dir = dir.path();
create_required_files(case_dir);
let scenarios = case_dir.join("scenarios");
std::fs::create_dir_all(&scenarios).unwrap();
std::fs::write(scenarios.join("inflow_history.parquet"), b"").unwrap();
std::fs::write(scenarios.join("inflow_seasonal_stats.parquet"), b"").unwrap();
std::fs::write(scenarios.join("inflow_ar_coefficients.parquet"), b"").unwrap();
let model = InflowModel {
hydro_id: EntityId(1),
stage_id: 0,
mean_m3s: 100.0,
std_m3s: 10.0,
ar_coefficients: vec![0.5],
residual_std_ratio: 0.87,
};
let system = minimal_system_with_inflow_models(vec![model]);
let original_len = system.inflow_models().len();
let config = default_config();
let (result, report) = estimate_from_history(system, case_dir, &config).unwrap();
assert_eq!(
result.inflow_models().len(),
original_len,
"explicit stats: system must be unchanged"
);
assert!(
report.is_none(),
"explicit stats path must return None report"
);
}
#[test]
fn test_estimate_no_history_returns_unchanged() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let case_dir = dir.path();
create_required_files(case_dir);
let model = InflowModel {
hydro_id: EntityId(1),
stage_id: 0,
mean_m3s: 100.0,
std_m3s: 10.0,
ar_coefficients: vec![],
residual_std_ratio: 1.0,
};
let system = minimal_system_with_inflow_models(vec![model]);
let original_len = system.inflow_models().len();
let config = default_config();
let (result, report) = estimate_from_history(system, case_dir, &config).unwrap();
assert_eq!(
result.inflow_models().len(),
original_len,
"no history: system must be unchanged"
);
assert!(report.is_none(), "no history path must return None report");
}
fn default_config() -> Config {
use cobre_io::config::{EstimationConfig, OrderSelectionMethod};
let mut cfg: Config = serde_json::from_str(MINIMAL_CONFIG_JSON).unwrap();
cfg.estimation = EstimationConfig {
max_order: 2,
order_selection: OrderSelectionMethod::Fixed,
min_observations_per_season: 2,
max_coefficient_magnitude: None,
};
cfg
}
const MINIMAL_CONFIG_JSON: &str = r#"{
"training": { "seed": 42 },
"simulation": { "enabled": false, "num_scenarios": 0, "io_channel_capacity": 16 },
"modeling": {},
"policy": {},
"exports": {},
"output": {}
}"#;
fn create_required_files(case_dir: &std::path::Path) {
let _ = std::fs::create_dir_all(case_dir.join("system"));
let _ = std::fs::create_dir_all(case_dir.join("scenarios"));
let write = |name: &str| {
let _ = std::fs::write(case_dir.join(name), b"{}");
};
write("config.json");
write("penalties.json");
write("stages.json");
write("initial_conditions.json");
write("system/buses.json");
write("system/lines.json");
write("system/hydros.json");
write("system/thermals.json");
}
#[test]
fn test_estimation_report_structure() {
let h1 = EntityId(1);
let h2 = EntityId(2);
let n_seasons = 3_usize;
let mut estimates = Vec::new();
for &hydro_id in &[h1, h2] {
for season_id in 0..n_seasons {
estimates.push(ArCoefficientEstimate {
hydro_id,
season_id,
coefficients: vec![0.5, 0.3],
residual_std_ratio: 0.9,
});
}
}
let contribution_reductions: HashMap<EntityId, Vec<ContributionReduction>> = HashMap::new();
let report =
build_estimation_report(&estimates, n_seasons, &contribution_reductions, "PACF");
assert_eq!(report.entries.len(), 2, "expected 2 hydro entries");
for &hydro_id in &[h1, h2] {
let entry = report.entries.get(&hydro_id).expect("entry must exist");
assert_eq!(entry.selected_order, 2, "selected_order must be 2");
assert_eq!(
entry.coefficients.len(),
n_seasons,
"one coefficient vec per season"
);
}
}
#[test]
fn test_estimation_report_empty_for_fixed() {
use cobre_core::temporal::Stage;
use cobre_io::config::OrderSelectionMethod;
let method = OrderSelectionMethod::Fixed;
let observations: Vec<(EntityId, chrono::NaiveDate, f64)> = vec![];
let seasonal_stats: Vec<SeasonalStats> = vec![];
let stages: Vec<Stage> = vec![];
let hydro_ids: Vec<EntityId> = vec![];
let max_order = 2;
let (_, report) = estimate_ar_coefficients_with_selection(
&observations,
&seasonal_stats,
&stages,
&hydro_ids,
&ArEstimationConfig {
max_order,
max_coeff_magnitude: None,
method: &method,
season_map: None,
},
)
.unwrap();
assert!(
report.entries.is_empty(),
"Fixed method must produce empty EstimationReport"
);
}
#[test]
fn test_contribution_order_zero_fallback() {
let result = validate_order_contributions(
0, 1, 1, &[vec![-1.5]], &[10.0], );
assert!(!result.valid);
assert_eq!(result.max_valid_order, 0);
}
#[test]
fn test_contribution_order_zero_input_passes() {
let result = validate_order_contributions(0, 1, 0, &[Vec::new()], &[10.0]);
assert!(result.valid);
assert_eq!(result.max_valid_order, 0);
assert!(result.contributions.is_empty());
}
#[test]
fn test_contribution_stable_model_passes() {
let result = validate_order_contributions(
0, 1, 2, &[vec![0.4, 0.2]], &[10.0], );
assert!(result.valid);
assert_eq!(result.max_valid_order, 2);
assert_eq!(result.contributions.len(), 2);
}
#[test]
fn test_apply_contribution_validation_reduces_explosive() {
let hydro_id = EntityId(1);
let n_seasons = 1;
let mut estimates = vec![ArCoefficientEstimate {
hydro_id,
season_id: 0,
coefficients: vec![0.3, -0.8],
residual_std_ratio: 0.9,
}];
let stats = vec![SeasonalStats {
entity_id: hydro_id,
stage_id: 0,
mean: 100.0,
std: 10.0,
}];
let stats_map: HashMap<(EntityId, usize), &SeasonalStats> =
stats.iter().map(|s| ((s.entity_id, 0_usize), s)).collect();
let mut sigma2_map: HashMap<(EntityId, usize), Vec<f64>> = HashMap::new();
sigma2_map.insert((hydro_id, 0), vec![0.81, 0.75]);
let reductions = apply_contribution_validation(
&mut estimates,
n_seasons,
&stats_map,
&sigma2_map,
None, );
assert_eq!(
estimates[0].coefficients.len(),
1,
"explosive AR(2) should be reduced to AR(1)"
);
assert!((estimates[0].coefficients[0] - 0.3).abs() < 1e-10);
assert!(
(estimates[0].residual_std_ratio - 0.81_f64.sqrt()).abs() < 1e-10,
"residual_std_ratio should be recomputed from sigma2_per_order[0]"
);
let entity_reductions = reductions.get(&hydro_id).expect("should have reductions");
assert_eq!(entity_reductions.len(), 1);
assert_eq!(entity_reductions[0].original_order, 2);
assert_eq!(entity_reductions[0].reduced_order, 1);
assert_eq!(entity_reductions[0].season_id, 0);
}
#[test]
fn test_pimental_like_multi_season_reduction() {
let hydro_id = EntityId(156);
let n_seasons = 12;
let mut estimates: Vec<ArCoefficientEstimate> = (0..n_seasons)
.map(|s| ArCoefficientEstimate {
hydro_id,
season_id: s,
coefficients: if s == 7 {
vec![0.5, 48.9]
} else {
vec![0.1] },
residual_std_ratio: 0.95,
})
.collect();
let stds: Vec<f64> = (0..n_seasons)
.map(|s| if s == 7 { 5.0 } else { 200.0 })
.collect();
let stats: Vec<SeasonalStats> = (0..n_seasons)
.map(|s| SeasonalStats {
entity_id: hydro_id,
stage_id: s as i32,
mean: 100.0,
std: stds[s],
})
.collect();
let stats_map: HashMap<(EntityId, usize), &SeasonalStats> = stats
.iter()
.enumerate()
.map(|(s, st)| ((hydro_id, s), st))
.collect();
let sigma2_map: HashMap<(EntityId, usize), Vec<f64>> = HashMap::new();
let reductions = apply_contribution_validation(
&mut estimates,
n_seasons,
&stats_map,
&sigma2_map,
None, );
let august_order = estimates[7].coefficients.len();
for (s, est) in estimates.iter().enumerate() {
if s != 7 {
assert_eq!(est.coefficients.len(), 1, "season {s} should remain AR(1)");
}
}
if august_order < 2 {
let entity_reductions = reductions.get(&hydro_id).expect("should have reductions");
assert!(
entity_reductions.iter().any(|r| r.season_id == 7),
"August reduction should be recorded"
);
}
}
#[test]
fn test_all_negative_fallback_to_white_noise() {
let hydro_id = EntityId(1);
let n_seasons = 1;
let mut estimates = vec![ArCoefficientEstimate {
hydro_id,
season_id: 0,
coefficients: vec![-2.0],
residual_std_ratio: 0.8,
}];
let stats = vec![SeasonalStats {
entity_id: hydro_id,
stage_id: 0,
mean: 50.0,
std: 10.0,
}];
let stats_map: HashMap<(EntityId, usize), &SeasonalStats> =
stats.iter().map(|s| ((s.entity_id, 0_usize), s)).collect();
let sigma2_map: HashMap<(EntityId, usize), Vec<f64>> = HashMap::new();
let _reductions = apply_contribution_validation(
&mut estimates,
n_seasons,
&stats_map,
&sigma2_map,
None, );
assert!(
estimates[0].coefficients.is_empty(),
"should fall back to order 0"
);
assert!(
(estimates[0].residual_std_ratio - 1.0).abs() < 1e-10,
"white-noise residual ratio should be 1.0"
);
}
#[test]
fn phi1_rejection_sets_order_to_zero() {
let hydro_id = EntityId(1);
let n_seasons = 2;
let mut estimates = vec![
ArCoefficientEstimate {
hydro_id,
season_id: 0,
coefficients: vec![-0.3, 0.5],
residual_std_ratio: 0.8,
},
ArCoefficientEstimate {
hydro_id,
season_id: 1,
coefficients: vec![0.4, 0.2],
residual_std_ratio: 0.7,
},
];
let stats = vec![
SeasonalStats {
entity_id: hydro_id,
stage_id: 0,
mean: 50.0,
std: 10.0,
},
SeasonalStats {
entity_id: hydro_id,
stage_id: 1,
mean: 60.0,
std: 12.0,
},
];
let stats_map: HashMap<(EntityId, usize), &SeasonalStats> = stats
.iter()
.enumerate()
.map(|(i, s)| ((s.entity_id, i), s))
.collect();
let sigma2_map: HashMap<(EntityId, usize), Vec<f64>> = HashMap::new();
let reductions =
apply_contribution_validation(&mut estimates, n_seasons, &stats_map, &sigma2_map, None);
assert!(
estimates[0].coefficients.is_empty(),
"season 0 should be cleared to order 0"
);
assert!(
(estimates[0].residual_std_ratio - 1.0).abs() < 1e-10,
"season 0 residual_std_ratio should be 1.0"
);
assert_eq!(
estimates[1].coefficients,
vec![0.4, 0.2],
"season 1 should be unchanged"
);
let hydro_reductions = reductions.get(&hydro_id).expect("should have reductions");
let r = hydro_reductions
.iter()
.find(|r| r.season_id == 0)
.expect("should have reduction for season 0");
assert_eq!(r.original_order, 2);
assert_eq!(r.reduced_order, 0);
assert!(r.contributions.is_empty());
}
#[test]
fn phi1_rejection_before_contribution_analysis() {
let hydro_id = EntityId(1);
let n_seasons = 1;
let mut estimates = vec![ArCoefficientEstimate {
hydro_id,
season_id: 0,
coefficients: vec![-0.01, 0.5],
residual_std_ratio: 0.8,
}];
let stats = vec![SeasonalStats {
entity_id: hydro_id,
stage_id: 0,
mean: 50.0,
std: 10.0,
}];
let stats_map: HashMap<(EntityId, usize), &SeasonalStats> =
stats.iter().map(|s| ((s.entity_id, 0_usize), s)).collect();
let sigma2_map: HashMap<(EntityId, usize), Vec<f64>> = HashMap::new();
let reductions =
apply_contribution_validation(&mut estimates, n_seasons, &stats_map, &sigma2_map, None);
assert!(
estimates[0].coefficients.is_empty(),
"phi_1 = -0.01 should trigger rejection"
);
assert!(
reductions.contains_key(&hydro_id),
"should have a reduction entry"
);
}
#[test]
fn phi1_zero_is_not_rejected() {
let hydro_id = EntityId(1);
let n_seasons = 1;
let mut estimates = vec![ArCoefficientEstimate {
hydro_id,
season_id: 0,
coefficients: vec![0.0, 0.3],
residual_std_ratio: 0.8,
}];
let stats = vec![SeasonalStats {
entity_id: hydro_id,
stage_id: 0,
mean: 50.0,
std: 10.0,
}];
let stats_map: HashMap<(EntityId, usize), &SeasonalStats> =
stats.iter().map(|s| ((s.entity_id, 0_usize), s)).collect();
let sigma2_map: HashMap<(EntityId, usize), Vec<f64>> = HashMap::new();
let _reductions =
apply_contribution_validation(&mut estimates, n_seasons, &stats_map, &sigma2_map, None);
assert_eq!(
estimates[0].coefficients.len(),
2,
"phi_1 = 0.0 should not be rejected"
);
}
#[test]
fn phi1_rejection_interacts_with_magnitude_bound() {
let hydro_id = EntityId(1);
let n_seasons = 1;
let mut estimates = vec![ArCoefficientEstimate {
hydro_id,
season_id: 0,
coefficients: vec![-50.0, 0.3],
residual_std_ratio: 0.8,
}];
let stats = vec![SeasonalStats {
entity_id: hydro_id,
stage_id: 0,
mean: 50.0,
std: 10.0,
}];
let stats_map: HashMap<(EntityId, usize), &SeasonalStats> =
stats.iter().map(|s| ((s.entity_id, 0_usize), s)).collect();
let sigma2_map: HashMap<(EntityId, usize), Vec<f64>> = HashMap::new();
let reductions = apply_contribution_validation(
&mut estimates,
n_seasons,
&stats_map,
&sigma2_map,
Some(10.0), );
assert!(
estimates[0].coefficients.is_empty(),
"should be cleared to order 0"
);
let hydro_reductions = reductions.get(&hydro_id).expect("should have reductions");
assert_eq!(
hydro_reductions.len(),
1,
"should have exactly 1 reduction entry (magnitude bound only)"
);
}
#[allow(clippy::cast_precision_loss, clippy::cast_lossless)] fn generate_ar_observations(coefficients: &[f64], n: usize) -> Vec<f64> {
let p = coefficients.len();
let mut values = vec![0.0_f64; n + p];
let mut seed: u64 = 42;
for i in p..(n + p) {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
let noise = ((seed >> 33) as f64 / (u32::MAX as f64) - 0.5) * 2.0;
let mut val = noise;
for (j, c) in coefficients.iter().enumerate() {
val += c * values[i - j - 1];
}
values[i] = val;
}
values[p..].to_vec()
}
#[test]
fn iterative_reduction_terminates_at_zero() {
let hydro_id = EntityId(1);
let n_seasons = 1;
let mut estimates = vec![ArCoefficientEstimate {
hydro_id,
season_id: 0,
coefficients: vec![0.3, -0.8],
residual_std_ratio: 0.8,
}];
let stats = vec![SeasonalStats {
entity_id: hydro_id,
stage_id: 0,
mean: 50.0,
std: 10.0,
}];
let stats_map: HashMap<(EntityId, usize), &SeasonalStats> =
stats.iter().map(|s| ((s.entity_id, 0_usize), s)).collect();
let sigma2_map: HashMap<(EntityId, usize), Vec<f64>> = HashMap::new();
let reductions =
apply_contribution_validation(&mut estimates, n_seasons, &stats_map, &sigma2_map, None);
assert!(
estimates[0].coefficients.len() < 2,
"order should be reduced from 2; got {}",
estimates[0].coefficients.len()
);
assert!(
reductions.contains_key(&hydro_id),
"should have a reduction entry"
);
}
#[test]
fn iterative_reduction_only_affects_failing_seasons() {
let hydro_id = EntityId(1);
let n_seasons = 2;
let mut estimates = vec![
ArCoefficientEstimate {
hydro_id,
season_id: 0,
coefficients: vec![0.3, -0.8],
residual_std_ratio: 0.8,
},
ArCoefficientEstimate {
hydro_id,
season_id: 1,
coefficients: vec![0.4, 0.2],
residual_std_ratio: 0.7,
},
];
let stats = vec![
SeasonalStats {
entity_id: hydro_id,
stage_id: 0,
mean: 50.0,
std: 10.0,
},
SeasonalStats {
entity_id: hydro_id,
stage_id: 1,
mean: 60.0,
std: 10.0,
},
];
let stats_map: HashMap<(EntityId, usize), &SeasonalStats> = stats
.iter()
.enumerate()
.map(|(i, s)| ((s.entity_id, i), s))
.collect();
let sigma2_map: HashMap<(EntityId, usize), Vec<f64>> = HashMap::new();
let _reductions =
apply_contribution_validation(&mut estimates, n_seasons, &stats_map, &sigma2_map, None);
assert!(
estimates[0].coefficients.len() < 2,
"season 0 order should be reduced from 2; got {}",
estimates[0].coefficients.len()
);
assert_eq!(
estimates[1].coefficients,
vec![0.4, 0.2],
"season 1 should be unchanged"
);
}
#[test]
fn iterative_pacf_reduction_with_synthetic_observations() {
let hydro_id = EntityId(1);
let n_seasons = 1;
let obs = generate_ar_observations(&[0.5, 0.2], 100);
let mut group_obs: HashMap<(EntityId, usize), Vec<f64>> = HashMap::new();
group_obs.insert((hydro_id, 0), obs);
let stats = vec![SeasonalStats {
entity_id: hydro_id,
stage_id: 0,
mean: 0.0,
std: 1.0,
}];
let stats_map: HashMap<(EntityId, usize), &SeasonalStats> =
stats.iter().map(|s| ((s.entity_id, 0_usize), s)).collect();
let mut estimates = vec![ArCoefficientEstimate {
hydro_id,
season_id: 0,
coefficients: vec![0.5, 0.2, -5.0], residual_std_ratio: 0.8,
}];
let reductions = iterative_pacf_reduction(
&mut estimates,
n_seasons,
&[hydro_id],
&group_obs,
&stats_map,
3, 1.96,
None,
);
assert!(
estimates[0].coefficients.len() < 3,
"order should be reduced from 3; got {}",
estimates[0].coefficients.len()
);
assert!(
reductions.contains_key(&hydro_id),
"should have a reduction entry"
);
}
#[test]
fn fixed_path_uses_truncation_not_reselection() {
let hydro_id = EntityId(1);
let n_seasons = 1;
let mut estimates = vec![ArCoefficientEstimate {
hydro_id,
season_id: 0,
coefficients: vec![0.5, 0.2, -0.8],
residual_std_ratio: 0.8,
}];
let stats = vec![SeasonalStats {
entity_id: hydro_id,
stage_id: 0,
mean: 50.0,
std: 10.0,
}];
let stats_map: HashMap<(EntityId, usize), &SeasonalStats> =
stats.iter().map(|s| ((s.entity_id, 0_usize), s)).collect();
let sigma2_map: HashMap<(EntityId, usize), Vec<f64>> = HashMap::new();
let reductions =
apply_contribution_validation(&mut estimates, n_seasons, &stats_map, &sigma2_map, None);
let final_order = estimates[0].coefficients.len();
assert!(
final_order <= 2,
"Fixed path should truncate; got order {final_order}"
);
assert!(
reductions.contains_key(&hydro_id),
"should have a reduction entry"
);
let r = &reductions[&hydro_id][0];
assert_eq!(r.original_order, 3);
assert!(r.reduced_order <= 2, "truncation should produce order <= 2");
}
#[test]
fn combined_strategies_produce_correct_reduction_reasons() {
let h1 = EntityId(1);
let h2 = EntityId(2);
let n_seasons = 2;
let mut estimates = vec![
ArCoefficientEstimate {
hydro_id: h1,
season_id: 0,
coefficients: vec![-0.3, 0.5],
residual_std_ratio: 0.8,
},
ArCoefficientEstimate {
hydro_id: h1,
season_id: 1,
coefficients: vec![0.5, 0.2, -0.8],
residual_std_ratio: 0.7,
},
ArCoefficientEstimate {
hydro_id: h2,
season_id: 0,
coefficients: vec![50.0],
residual_std_ratio: 0.9,
},
ArCoefficientEstimate {
hydro_id: h2,
season_id: 1,
coefficients: vec![0.4, 0.2],
residual_std_ratio: 0.7,
},
];
let stats = vec![
SeasonalStats {
entity_id: h1,
stage_id: 0,
mean: 50.0,
std: 10.0,
},
SeasonalStats {
entity_id: h1,
stage_id: 1,
mean: 60.0,
std: 10.0,
},
SeasonalStats {
entity_id: h2,
stage_id: 0,
mean: 70.0,
std: 15.0,
},
SeasonalStats {
entity_id: h2,
stage_id: 1,
mean: 80.0,
std: 12.0,
},
];
let stats_map: HashMap<(EntityId, usize), &SeasonalStats> = stats
.iter()
.enumerate()
.map(|(i, s)| ((s.entity_id, i % 2), s))
.collect();
let sigma2_map: HashMap<(EntityId, usize), Vec<f64>> = HashMap::new();
let reductions = apply_contribution_validation(
&mut estimates,
n_seasons,
&stats_map,
&sigma2_map,
Some(10.0),
);
let h1_reductions = &reductions[&h1];
let h1_s0 = h1_reductions
.iter()
.find(|r| r.season_id == 0)
.expect("should have reduction for H1 S0");
assert_eq!(h1_s0.reason, ReductionReason::Phi1Negative);
assert_eq!(h1_s0.reduced_order, 0);
let h1_s1 = h1_reductions
.iter()
.find(|r| r.season_id == 1)
.expect("should have reduction for H1 S1");
assert_eq!(h1_s1.reason, ReductionReason::NegativeContribution);
let h2_reductions = &reductions[&h2];
let h2_s0 = h2_reductions
.iter()
.find(|r| r.season_id == 0)
.expect("should have reduction for H2 S0");
assert_eq!(h2_s0.reason, ReductionReason::MagnitudeBound);
assert_eq!(h2_s0.reduced_order, 0);
assert!(
!h2_reductions.iter().any(|r| r.season_id == 1),
"H2 S1 should have no reduction"
);
}
fn make_expansion_stage(
index: usize,
id: i32,
season_id: Option<usize>,
) -> cobre_core::temporal::Stage {
use chrono::NaiveDate;
use cobre_core::temporal::{
Block, BlockMode, NoiseMethod, ScenarioSourceConfig, StageRiskConfig, StageStateConfig,
};
cobre_core::temporal::Stage {
index,
id,
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
season_id,
blocks: vec![Block {
index: 0,
name: "SINGLE".to_string(),
duration_hours: 744.0,
}],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 10,
noise_method: NoiseMethod::Saa,
},
}
}
#[test]
fn seasonal_stats_to_rows_includes_prestudy_stages() {
let stages = vec![
make_expansion_stage(0, -2, Some(1)),
make_expansion_stage(1, -1, Some(2)),
make_expansion_stage(2, 0, Some(0)),
make_expansion_stage(3, 1, Some(1)),
make_expansion_stage(4, 2, Some(2)),
];
let h1 = EntityId(1);
let stats = vec![
SeasonalStats {
entity_id: h1,
stage_id: 0,
mean: 100.0,
std: 20.0,
},
SeasonalStats {
entity_id: h1,
stage_id: 1,
mean: 110.0,
std: 22.0,
},
SeasonalStats {
entity_id: h1,
stage_id: 2,
mean: 120.0,
std: 24.0,
},
];
let rows = seasonal_stats_to_rows(&stats, &stages);
assert_eq!(rows.len(), 5, "expected 5 rows (3 study + 2 pre-study)");
let prestudy_rows: Vec<_> = rows.iter().filter(|r| r.stage_id < 0).collect();
assert_eq!(
prestudy_rows.len(),
2,
"expected 2 pre-study rows, got {}",
prestudy_rows.len()
);
let neg2 = rows.iter().find(|r| r.stage_id == -2).expect("row for -2");
assert!((neg2.mean_m3s - 110.0).abs() < f64::EPSILON);
assert!((neg2.std_m3s - 22.0).abs() < f64::EPSILON);
let neg1 = rows.iter().find(|r| r.stage_id == -1).expect("row for -1");
assert!((neg1.mean_m3s - 120.0).abs() < f64::EPSILON);
assert!((neg1.std_m3s - 24.0).abs() < f64::EPSILON);
for w in rows.windows(2) {
assert!(
(w[0].hydro_id.0, w[0].stage_id) <= (w[1].hydro_id.0, w[1].stage_id),
"rows not sorted"
);
}
}
#[test]
fn ar_estimates_to_rows_includes_prestudy_stages() {
let stages = vec![
make_expansion_stage(0, -2, Some(1)),
make_expansion_stage(1, -1, Some(2)),
make_expansion_stage(2, 0, Some(0)),
make_expansion_stage(3, 1, Some(1)),
make_expansion_stage(4, 2, Some(2)),
];
let h1 = EntityId(1);
let ar_estimates = vec![
ArCoefficientEstimate {
hydro_id: h1,
season_id: 0,
coefficients: vec![0.3],
residual_std_ratio: 0.9,
},
ArCoefficientEstimate {
hydro_id: h1,
season_id: 1,
coefficients: vec![0.4],
residual_std_ratio: 0.85,
},
ArCoefficientEstimate {
hydro_id: h1,
season_id: 2,
coefficients: vec![0.5],
residual_std_ratio: 0.8,
},
];
let rows = ar_estimates_to_rows(&ar_estimates, &stages);
assert_eq!(rows.len(), 5, "expected 5 rows");
let prestudy_rows: Vec<_> = rows.iter().filter(|r| r.stage_id < 0).collect();
assert_eq!(prestudy_rows.len(), 2);
let neg2 = rows.iter().find(|r| r.stage_id == -2).expect("row for -2");
assert!((neg2.coefficient - 0.4).abs() < f64::EPSILON);
assert!((neg2.residual_std_ratio - 0.85).abs() < f64::EPSILON);
let neg1 = rows.iter().find(|r| r.stage_id == -1).expect("row for -1");
assert!((neg1.coefficient - 0.5).abs() < f64::EPSILON);
assert!((neg1.residual_std_ratio - 0.8).abs() < f64::EPSILON);
}
#[test]
fn full_estimation_produces_prestudy_inflow_models() {
use cobre_io::scenarios::assemble_inflow_models;
let stages = vec![
make_expansion_stage(0, -2, Some(1)),
make_expansion_stage(1, -1, Some(2)),
make_expansion_stage(2, 0, Some(0)),
make_expansion_stage(3, 1, Some(1)),
make_expansion_stage(4, 2, Some(2)),
];
let h1 = EntityId(1);
let stats = vec![
SeasonalStats {
entity_id: h1,
stage_id: 0,
mean: 100.0,
std: 20.0,
},
SeasonalStats {
entity_id: h1,
stage_id: 1,
mean: 110.0,
std: 22.0,
},
SeasonalStats {
entity_id: h1,
stage_id: 2,
mean: 120.0,
std: 24.0,
},
];
let stats_rows = seasonal_stats_to_rows(&stats, &stages);
let ar_ests = vec![
ArCoefficientEstimate {
hydro_id: h1,
season_id: 0,
coefficients: vec![0.3],
residual_std_ratio: 0.9,
},
ArCoefficientEstimate {
hydro_id: h1,
season_id: 1,
coefficients: vec![0.4],
residual_std_ratio: 0.85,
},
ArCoefficientEstimate {
hydro_id: h1,
season_id: 2,
coefficients: vec![0.5],
residual_std_ratio: 0.8,
},
];
let coeff_rows = ar_estimates_to_rows(&ar_ests, &stages);
let inflow_models =
assemble_inflow_models(stats_rows, coeff_rows).expect("assembly should succeed");
assert!(
inflow_models.iter().any(|m| m.stage_id < 0),
"expected pre-study InflowModel entries (negative stage_id)"
);
let prestudy_neg2 = inflow_models
.iter()
.find(|m| m.stage_id == -2)
.expect("InflowModel for stage -2");
assert!((prestudy_neg2.mean_m3s - 110.0).abs() < f64::EPSILON);
assert!((prestudy_neg2.std_m3s - 22.0).abs() < f64::EPSILON);
}
}