use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::Path;
use chrono::{Months, NaiveDate};
use cobre_core::{EntityId, SeasonMap, Stage, System};
use cobre_stochastic::{
StochasticError,
par::aggregate::aggregate_observations_to_season,
par::fitting::{
ArCoefficientEstimate, ArEstimationConfig, SeasonalStats, StdRatioDivergence,
estimate_ar_coefficients_with_selection, estimate_correlation_with_season_map,
estimate_seasonal_stats_with_season_map,
},
season_cast::{
RealizedWindow, SeasonPeriodWindow, cast, next_season_period_window, season_period_window,
},
};
use crate::LoadError::ConstraintError;
use crate::{
Config, FileManifest, LoadError, OrderSelectionMethod, ValidationContext,
parse_inflow_ar_coefficients, parse_inflow_history,
scenarios::{
InflowAnnualComponentRow, InflowArCoefficientRow, InflowHistoryRow, InflowSeasonalStatsRow,
assemble_inflow_models, populate_derived_residual_ratios, resolve_stage_seasons,
},
validate_structure,
};
pub use cobre_stochastic::par::fitting::EstimationReport;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EstimationPath {
Deterministic,
UserStatsWhiteNoise,
UserProvidedNoHistory,
FullEstimation,
UserArHistoryStats,
PartialEstimation,
UserProvidedAll,
}
impl EstimationPath {
#[must_use]
pub fn resolve(manifest: &FileManifest) -> Self {
match (
manifest.scenarios_inflow_history_parquet,
manifest.scenarios_inflow_seasonal_stats_parquet,
manifest.scenarios_inflow_ar_coefficients_parquet,
) {
(false, false, _) => Self::Deterministic,
(false, true, false) => Self::UserStatsWhiteNoise,
(false, true, true) => Self::UserProvidedNoHistory,
(true, false, false) => Self::FullEstimation,
(true, false, true) => Self::UserArHistoryStats,
(true, true, false) => Self::PartialEstimation,
(true, true, true) => Self::UserProvidedAll,
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Deterministic => "deterministic",
Self::UserStatsWhiteNoise => "user_stats_white_noise",
Self::UserProvidedNoHistory => "user_provided_no_history",
Self::FullEstimation => "full_estimation",
Self::UserArHistoryStats => "user_ar_history_stats",
Self::PartialEstimation => "partial_estimation",
Self::UserProvidedAll => "user_provided_all",
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum EstimationError {
#[error("load error: {0}")]
Load(#[from] LoadError),
#[error("estimation failed: {0}")]
Stochastic(#[from] StochasticError),
}
pub fn estimate_from_history(
system: System,
case_dir: &Path,
config: &Config,
) -> Result<(System, Option<EstimationReport>, EstimationPath), EstimationError> {
let mut ctx = ValidationContext::new();
let manifest = validate_structure(case_dir, &mut ctx);
if ctx.into_result().is_err() {
return Ok((system, None, EstimationPath::Deterministic));
}
let path = EstimationPath::resolve(&manifest);
match path {
EstimationPath::Deterministic
| EstimationPath::UserStatsWhiteNoise
| EstimationPath::UserProvidedNoHistory
| EstimationPath::UserProvidedAll => Ok((system, None, path)),
EstimationPath::PartialEstimation => {
let (system, report) = run_partial_estimation(system, case_dir, config, &manifest)?;
Ok((system, Some(report), path))
}
EstimationPath::FullEstimation => {
let (system, report) = run_estimation(system, case_dir, config, &manifest)?;
Ok((system, Some(report), path))
}
EstimationPath::UserArHistoryStats => {
let (system, report) = run_user_ar_estimation(system, case_dir, config, &manifest)?;
Ok((system, Some(report), path))
}
}
}
fn run_estimation(
system: System,
case_dir: &Path,
config: &Config,
manifest: &FileManifest,
) -> Result<(System, EstimationReport), EstimationError> {
let hydro_ids: Vec<EntityId> = system.hydros().iter().map(|h| h.id).collect();
let study_stages = system.stages();
let season_map = system.policy_graph().season_map.as_ref();
let max_order = config.estimation.max_order as usize;
let prestudy = synthesize_prestudy_stages(study_stages, max_order, season_map);
let stages: Vec<Stage> = study_stages
.iter()
.cloned()
.chain(prestudy.iter().cloned())
.collect();
let stages = stages.as_slice();
let observations = load_and_aggregate_observations(case_dir, study_stages, season_map)?;
let seasonal_stats =
estimate_seasonal_stats_with_season_map(&observations, stages, &hydro_ids, season_map)?;
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,
season_map,
use_annual_component: matches!(
config.estimation.order_selection,
OrderSelectionMethod::PacfAnnual
),
},
)?;
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 annual_rows = ar_estimates_to_annual_rows(&ar_estimates, stages);
let mut inflow_models = assemble_inflow_models(stats_rows, coeff_rows, annual_rows)?;
let (stage_to_season, n_seasons) = resolve_stage_seasons(stages, season_map);
populate_derived_residual_ratios(&mut inflow_models, &stage_to_season, n_seasons)?;
Ok((
system.with_scenario_models(inflow_models, correlation),
estimation_report,
))
}
fn run_partial_estimation(
system: System,
case_dir: &Path,
config: &Config,
manifest: &FileManifest,
) -> Result<(System, EstimationReport), EstimationError> {
let hydro_ids: Vec<EntityId> = system.hydros().iter().map(|h| h.id).collect();
let study_stages = system.stages();
let season_map = system.policy_graph().season_map.as_ref();
let max_order = config.estimation.max_order as usize;
let prestudy = synthesize_prestudy_stages(study_stages, max_order, season_map);
let stages_owned: Vec<Stage> = study_stages
.iter()
.cloned()
.chain(prestudy.iter().cloned())
.collect();
let stages = stages_owned.as_slice();
let observations = load_and_aggregate_observations(case_dir, study_stages, season_map)?;
if system.inflow_models().is_empty() {
return Err(EstimationError::Load(ConstraintError {
description: "manifest indicates inflow_seasonal_stats.parquet is present \
but system.inflow_models() is empty; \
no user stats available for partial estimation"
.to_string(),
}));
}
let fitting_stats =
estimate_seasonal_stats_with_season_map(&observations, stages, &hydro_ids, season_map)?;
let (ar_estimates, mut estimation_report) = estimate_ar_coefficients_with_selection(
&observations,
&fitting_stats,
stages,
&hydro_ids,
&ArEstimationConfig {
max_order,
max_coeff_magnitude: config.estimation.max_coefficient_magnitude,
season_map,
use_annual_component: matches!(
config.estimation.order_selection,
OrderSelectionMethod::PacfAnnual
),
},
)?;
let (white_noise_fallbacks, std_ratio_warnings) =
validate_partial_estimation_coverage(&system, &fitting_stats, study_stages)?;
let correlation = if manifest.scenarios_correlation_json {
system.correlation().clone()
} else {
estimate_correlation_with_season_map(
&observations,
&ar_estimates,
&fitting_stats,
stages,
&hydro_ids,
season_map,
)?
};
let mut stats_rows = user_stats_to_rows(&system);
stats_rows.extend(prestudy_seasonal_rows(&fitting_stats, &prestudy));
let coeff_rows = ar_estimates_to_rows(&ar_estimates, stages);
let annual_rows = ar_estimates_to_annual_rows(&ar_estimates, stages);
let mut inflow_models = assemble_inflow_models(stats_rows, coeff_rows, annual_rows)?;
let (stage_to_season, n_seasons) = resolve_stage_seasons(stages, season_map);
populate_derived_residual_ratios(&mut inflow_models, &stage_to_season, n_seasons)?;
estimation_report.white_noise_fallbacks = white_noise_fallbacks;
estimation_report.std_ratio_warnings = std_ratio_warnings;
Ok((
system.with_scenario_models(inflow_models, correlation),
estimation_report,
))
}
fn load_and_aggregate_observations(
case_dir: &Path,
stages: &[Stage],
season_map: Option<&SeasonMap>,
) -> Result<Vec<(EntityId, NaiveDate, f64)>, EstimationError> {
let history_path = case_dir.join("scenarios/inflow_history.parquet");
let history = parse_inflow_history(&history_path)?;
let (observations, skipped_partial) =
resolve_coverage_gated_observations(&history, season_map, stages.first());
log_skipped_partial_occurrences(&skipped_partial);
if let Some(sm) = season_map {
Ok(aggregate_observations_to_season(&observations, stages, sm)?)
} else {
Ok(observations)
}
}
type CoverageGatedObservations = (Vec<(EntityId, NaiveDate, f64)>, BTreeMap<EntityId, usize>);
#[allow(clippy::float_cmp)]
fn resolve_coverage_gated_observations(
history: &[InflowHistoryRow],
season_map: Option<&SeasonMap>,
stage_template: Option<&Stage>,
) -> CoverageGatedObservations {
let (Some(season_map), Some(stage_template)) = (season_map, stage_template) else {
let observations = history
.iter()
.map(|row| (row.hydro_id, row.start_date, row.value_m3s))
.collect();
return (observations, BTreeMap::new());
};
let mut windows_by_hydro: BTreeMap<EntityId, Vec<RealizedWindow>> = BTreeMap::new();
for row in history {
windows_by_hydro
.entry(row.hydro_id)
.or_default()
.push(RealizedWindow {
start_date: row.start_date,
end_date: row.end_date,
value_m3s: row.value_m3s,
});
}
let mut observations = Vec::new();
let mut skipped_partial = BTreeMap::new();
for (&hydro_id, windows) in &windows_by_hydro {
let occurrences = discover_hydro_occurrences(season_map, stage_template, windows);
let mut skip_count = 0usize;
for occurrence in &occurrences {
let overlapping: Vec<RealizedWindow> = windows
.iter()
.filter(|w| w.start_date < occurrence.end && w.end_date > occurrence.start)
.map(|w| RealizedWindow {
start_date: w.start_date,
end_date: w.end_date,
value_m3s: w.value_m3s,
})
.collect();
let projection = cast(&overlapping, occurrence);
if projection.coverage == 1.0 {
observations.push((hydro_id, occurrence.start, projection.value));
} else if projection.coverage > 0.0 {
skip_count += 1;
}
}
if skip_count > 0 {
skipped_partial.insert(hydro_id, skip_count);
}
}
observations.sort_by_key(|(id, date, _)| (id.0, *date));
(observations, skipped_partial)
}
fn discover_hydro_occurrences(
season_map: &SeasonMap,
stage_template: &Stage,
windows: &[RealizedWindow],
) -> Vec<SeasonPeriodWindow> {
let mut discovered: BTreeMap<NaiveDate, SeasonPeriodWindow> = BTreeMap::new();
for window in windows {
let Some(mut occurrence) = occurrence_containing(season_map, stage_template, window) else {
continue;
};
while occurrence.start < window.end_date {
let key = occurrence.start;
discovered.entry(key).or_insert_with(|| SeasonPeriodWindow {
start: occurrence.start,
end: occurrence.end,
hours: occurrence.hours,
});
let Some(season_id) = season_map.season_for_date(occurrence.start) else {
break;
};
let Some(season_def) = season_map.seasons.iter().find(|s| s.id == season_id) else {
break;
};
let Some(next) = next_season_period_window(season_map, season_def, &occurrence) else {
break;
};
occurrence = next;
}
}
discovered.into_values().collect()
}
fn occurrence_containing(
season_map: &SeasonMap,
stage_template: &Stage,
window: &RealizedWindow,
) -> Option<SeasonPeriodWindow> {
let season_id = season_map.season_for_date(window.start_date)?;
let season_def = season_map.seasons.iter().find(|s| s.id == season_id)?;
let mut probe = stage_template.clone();
probe.start_date = window.start_date;
probe.end_date = window.end_date;
Some(season_period_window(season_map, season_def, &probe))
}
fn log_skipped_partial_occurrences(skipped_partial: &BTreeMap<EntityId, usize>) {
if skipped_partial.is_empty() {
return;
}
let total: usize = skipped_partial.values().sum();
let per_hydro: Vec<String> = skipped_partial
.iter()
.map(|(hydro_id, count)| format!("hydro {hydro_id}: {count}"))
.collect();
tracing::info!(
"estimation observation loading skipped {total} partial-coverage season \
occurrence(s) across {} hydro(s) ({})",
skipped_partial.len(),
per_hydro.join(", ")
);
}
type CoverageCheckResult = (Vec<EntityId>, Vec<StdRatioDivergence>);
fn validate_partial_estimation_coverage(
system: &System,
fitting_stats: &[SeasonalStats],
stages: &[Stage],
) -> Result<CoverageCheckResult, EstimationError> {
let estimated_hydro_ids: HashSet<EntityId> =
fitting_stats.iter().map(|s| s.entity_id).collect();
let user_stats_hydro_ids: HashSet<EntityId> =
system.inflow_models().iter().map(|m| m.hydro_id).collect();
let mut missing_stats: Vec<EntityId> = estimated_hydro_ids
.difference(&user_stats_hydro_ids)
.copied()
.collect();
missing_stats.sort();
if !missing_stats.is_empty() {
let ids: Vec<String> = missing_stats.iter().map(|id| id.0.to_string()).collect();
return Err(EstimationError::Load(ConstraintError {
description: format!(
"partial estimation: AR coefficients were estimated for hydro(s) \
[{ids}] but inflow_seasonal_stats.parquet has no entry for them; \
all hydros with estimated AR must have user-provided stats",
ids = ids.join(", ")
),
}));
}
let mut white_noise_fallbacks: Vec<EntityId> = user_stats_hydro_ids
.difference(&estimated_hydro_ids)
.copied()
.collect();
white_noise_fallbacks.sort();
let std_ratio_warnings = check_std_ratio_divergence(system, fitting_stats, stages);
for w in &std_ratio_warnings {
tracing::warn!(
"hydro {} season {}->{} std ratio diverges {:.1}x between \
user ({:.2}) and estimated ({:.2})",
w.hydro_id.0,
w.season_a,
w.season_b,
w.divergence,
w.user_ratio,
w.estimated_ratio
);
}
Ok((white_noise_fallbacks, std_ratio_warnings))
}
fn run_user_ar_estimation(
system: System,
case_dir: &Path,
config: &Config,
manifest: &FileManifest,
) -> Result<(System, EstimationReport), EstimationError> {
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 max_order = config.estimation.max_order as usize;
let prestudy = synthesize_prestudy_stages(stages, max_order, season_map);
let extended: Vec<Stage> = stages
.iter()
.cloned()
.chain(prestudy.iter().cloned())
.collect();
let extended = extended.as_slice();
let observations = load_and_aggregate_observations(case_dir, stages, season_map)?;
let seasonal_stats =
estimate_seasonal_stats_with_season_map(&observations, extended, &hydro_ids, season_map)?;
let ar_path = case_dir.join("scenarios/inflow_ar_coefficients.parquet");
let user_ar_rows = parse_inflow_ar_coefficients(&ar_path)?;
let user_ar_estimates = ar_rows_to_estimates(&user_ar_rows, stages);
let correlation = if manifest.scenarios_correlation_json {
system.correlation().clone()
} else {
estimate_correlation_with_season_map(
&observations,
&user_ar_estimates,
&seasonal_stats,
extended,
&hydro_ids,
season_map,
)?
};
let stats_rows = seasonal_stats_to_rows(&seasonal_stats, extended);
let mut inflow_models = assemble_inflow_models(stats_rows, user_ar_rows, vec![])?;
let (stage_to_season, n_seasons) = resolve_stage_seasons(extended, season_map);
populate_derived_residual_ratios(&mut inflow_models, &stage_to_season, n_seasons)?;
let estimation_report = EstimationReport {
entries: BTreeMap::new(),
method: "user_provided".to_string(),
white_noise_fallbacks: Vec::new(),
std_ratio_warnings: Vec::new(),
};
Ok((
system.with_scenario_models(inflow_models, correlation),
estimation_report,
))
}
fn ar_rows_to_estimates(
rows: &[InflowArCoefficientRow],
stages: &[Stage],
) -> Vec<ArCoefficientEstimate> {
let stage_id_to_season: HashMap<i32, usize> = stages
.iter()
.filter_map(|s| s.season_id.map(|sid| (s.id, sid)))
.collect();
let mut first_stage: HashMap<(EntityId, usize), i32> = HashMap::new();
let mut groups: BTreeMap<(EntityId, usize), Vec<f64>> = BTreeMap::new();
for row in rows {
let Some(&season_id) = stage_id_to_season.get(&row.stage_id) else {
continue;
};
let key = (row.hydro_id, season_id);
let canonical_stage = first_stage.entry(key).or_insert(row.stage_id);
if *canonical_stage != row.stage_id {
continue;
}
groups.entry(key).or_default().push(row.coefficient);
}
groups
.into_iter()
.map(
|((hydro_id, season_id), coefficients)| ArCoefficientEstimate {
hydro_id,
season_id,
coefficients,
annual: None,
},
)
.collect()
}
fn user_stats_to_rows(system: &System) -> Vec<InflowSeasonalStatsRow> {
system
.inflow_models()
.iter()
.map(|m| InflowSeasonalStatsRow {
hydro_id: m.hydro_id,
stage_id: m.stage_id,
mean_m3s: m.mean_m3s,
std_m3s: m.std_m3s,
})
.collect()
}
fn check_std_ratio_divergence(
system: &System,
fitting_stats: &[SeasonalStats],
stages: &[Stage],
) -> Vec<StdRatioDivergence> {
let stage_id_to_season: HashMap<i32, usize> = stages
.iter()
.filter_map(|s| s.season_id.map(|sid| (s.id, sid)))
.collect();
let mut user_std: BTreeMap<(EntityId, usize), f64> = BTreeMap::new();
for m in system.inflow_models() {
let Some(&season_id) = stage_id_to_season.get(&m.stage_id) else {
continue;
};
user_std.entry((m.hydro_id, season_id)).or_insert(m.std_m3s);
}
let mut est_std: BTreeMap<(EntityId, usize), f64> = BTreeMap::new();
for s in fitting_stats {
let Some(&season_id) = stage_id_to_season.get(&s.stage_id) else {
continue;
};
est_std.entry((s.entity_id, season_id)).or_insert(s.std);
}
let user_hydros: std::collections::BTreeSet<EntityId> =
user_std.keys().map(|(h, _)| *h).collect();
let est_hydros: std::collections::BTreeSet<EntityId> =
est_std.keys().map(|(h, _)| *h).collect();
let common_hydros: Vec<EntityId> = user_hydros.intersection(&est_hydros).copied().collect();
let mut warnings: Vec<StdRatioDivergence> = Vec::new();
for hydro_id in common_hydros {
let season_ids: Vec<usize> = {
let mut ids: Vec<usize> = user_std
.keys()
.filter(|(h, _)| *h == hydro_id)
.map(|(_, s)| *s)
.collect();
ids.sort_unstable();
ids.dedup();
ids
};
let n = season_ids.len();
if n < 2 {
continue;
}
for i in 0..n {
let season_a = season_ids[i];
let season_b = season_ids[(i + 1) % n];
let Some(&u_a) = user_std.get(&(hydro_id, season_a)) else {
continue;
};
let Some(&u_b) = user_std.get(&(hydro_id, season_b)) else {
continue;
};
let Some(&e_a) = est_std.get(&(hydro_id, season_a)) else {
continue;
};
let Some(&e_b) = est_std.get(&(hydro_id, season_b)) else {
continue;
};
if u_b.abs() < 1e-12 || e_b.abs() < 1e-12 {
continue;
}
let ratio_user = u_a / u_b;
let ratio_est = e_a / e_b;
if ratio_user.abs() < 1e-12 || ratio_est.abs() < 1e-12 {
continue;
}
let divergence = (ratio_user / ratio_est)
.abs()
.max((ratio_est / ratio_user).abs());
if divergence > 2.0 {
warnings.push(StdRatioDivergence {
hydro_id,
season_a,
season_b,
user_ratio: ratio_user,
estimated_ratio: ratio_est,
divergence,
});
}
}
}
warnings.sort_by_key(|w| (w.hydro_id, w.season_a));
warnings
}
fn synthesize_prestudy_stages(
stages: &[Stage],
max_order: usize,
season_map: Option<&SeasonMap>,
) -> Vec<Stage> {
let Some(sm) = season_map else {
return Vec::new();
};
let cycle_len = sm.seasons.len();
if max_order == 0 || cycle_len == 0 {
return Vec::new();
}
let Some(first) = stages
.iter()
.filter(|s| s.id >= 0 && s.season_id.is_some())
.min_by_key(|s| s.id)
else {
return Vec::new();
};
let Some(first_season) = first.season_id else {
return Vec::new();
};
let study_seasons: HashSet<usize> = stages.iter().filter_map(|s| s.season_id).collect();
let lag_window = max_order.min(cycle_len - 1);
let mut synthetic = Vec::with_capacity(lag_window);
for k in 1..=lag_window {
let season_k = (first_season + cycle_len - (k % cycle_len)) % cycle_len;
if study_seasons.contains(&season_k) {
continue;
}
let (Some(start_k), Some(end_k)) = (
first
.start_date
.checked_sub_months(Months::new(u32::try_from(k).unwrap_or(u32::MAX))),
first
.start_date
.checked_sub_months(Months::new(u32::try_from(k - 1).unwrap_or(u32::MAX))),
) else {
continue;
};
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let id = first.id - k as i32;
let mut stage = first.clone();
stage.index = 0;
stage.id = id;
stage.start_date = start_k;
stage.end_date = end_k;
stage.season_id = Some(season_k);
synthetic.push(stage);
}
synthetic
}
fn prestudy_seasonal_rows(
fitting_stats: &[SeasonalStats],
prestudy: &[Stage],
) -> Vec<InflowSeasonalStatsRow> {
if prestudy.is_empty() {
return Vec::new();
}
let prestudy_ids: HashSet<i32> = prestudy.iter().map(|s| s.id).collect();
let mut rows: Vec<InflowSeasonalStatsRow> = fitting_stats
.iter()
.filter(|s| prestudy_ids.contains(&s.stage_id))
.map(|s| InflowSeasonalStatsRow {
hydro_id: s.entity_id,
stage_id: s.stage_id,
mean_m3s: s.mean,
std_m3s: s.std,
})
.collect();
rows.sort_by_key(|r| (r.hydro_id.0, r.stage_id));
rows
}
fn build_season_to_stages(stages: &[Stage]) -> HashMap<usize, Vec<i32>> {
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);
}
}
season_to_stages
}
fn seasonal_stats_to_rows(
stats: &[SeasonalStats],
stages: &[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 season_to_stages = build_season_to_stages(stages);
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)
&& 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: &[Stage],
) -> Vec<InflowArCoefficientRow> {
let season_to_stages = build_season_to_stages(stages);
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,
});
}
}
}
rows.sort_by_key(|r| (r.hydro_id.0, r.stage_id, r.lag));
rows
}
fn ar_estimates_to_annual_rows(
ar_estimates: &[ArCoefficientEstimate],
stages: &[Stage],
) -> Vec<InflowAnnualComponentRow> {
let season_to_stages = build_season_to_stages(stages);
let mut rows: Vec<InflowAnnualComponentRow> = Vec::new();
for est in ar_estimates {
let Some(ref ann) = est.annual else {
continue;
};
let Some(stage_ids) = season_to_stages.get(&est.season_id) else {
continue;
};
for &stage_id in stage_ids {
rows.push(InflowAnnualComponentRow {
hydro_id: est.hydro_id,
stage_id,
annual_coefficient: ann.coefficient,
annual_mean_m3s: ann.mean_m3s,
annual_std_m3s: ann.std_m3s,
});
}
}
rows.sort_by_key(|r| (r.hydro_id.0, r.stage_id));
rows
}
#[cfg(test)]
mod tests;