Skip to main content

cobre_io/scenarios/
estimation.rs

1//! Automatic PAR(p) parameter estimation from historical inflow observations.
2//!
3//! This module bridges case loading (this crate) and PAR fitting (`cobre-stochastic`).
4//! It inspects the input file manifest, resolves which of seven input paths applies
5//! (see [`EstimationPath`]), and dispatches to the appropriate estimation function.
6//!
7//! ## Input path matrix
8//!
9//! Three boolean flags determine the path: whether `inflow_history.parquet` (H),
10//! `inflow_seasonal_stats.parquet` (S), and `inflow_ar_coefficients.parquet` (R)
11//! are present in the case directory.
12//!
13//! | Row | H | S | R | Variant | Behaviour |
14//! |-----|---|---|---|---------|-----------|
15//! |  1  | 0 | 0 | 0 | [`Deterministic`](EstimationPath::Deterministic) | Return `system` unchanged. |
16//! |  2  | 0 | 1 | 0 | [`UserStatsWhiteNoise`](EstimationPath::UserStatsWhiteNoise) | Return `system` unchanged (white-noise stats from user). |
17//! |  3  | 0 | 1 | 1 | [`UserProvidedNoHistory`](EstimationPath::UserProvidedNoHistory) | Return `system` unchanged (complete user model). |
18//! |  4  | 1 | 0 | 0 | [`FullEstimation`](EstimationPath::FullEstimation) | Full estimation via `run_estimation`. |
19//! |  5  | 1 | 0 | 1 | [`UserArHistoryStats`](EstimationPath::UserArHistoryStats) | Stats from history, AR from user via `run_user_ar_estimation`. |
20//! |  6  | 1 | 1 | 0 | [`PartialEstimation`](EstimationPath::PartialEstimation) | Stats from user, AR estimated from history via `run_partial_estimation`. |
21//! |  7  | 1 | 1 | 1 | [`UserProvidedAll`](EstimationPath::UserProvidedAll) | Return `system` unchanged (all parameters from user). |
22//!
23//! Invalid combinations (R=1 without H or S) fall back to row 1 (`Deterministic`).
24//!
25//! ## Role 1 / Role 2
26//!
27//! Each inflow model requires two parameter groups:
28//!
29//! - **Role 1 (seasonal stats)**: `mean_m3s` and `std_m3s` per hydro per stage.
30//!   These drive the LP assembly (scenario scaling) and can come from either the
31//!   user file (`inflow_seasonal_stats.parquet`) or history estimation.
32//! - **Role 2 (AR coefficients)**: `ar_coefficients` per hydro per stage. These
33//!   drive the autoregressive scenario noise and can come from either the user
34//!   file (`inflow_ar_coefficients.parquet`) or history estimation.
35//!   `residual_std_ratio` is not a Role 2 input: it is always derived at load
36//!   from the (user-provided or estimated) `ar_coefficients` via the
37//!   periodic-ACF closure (`populate_derived_residual_ratios`), never read
38//!   from a file.
39//!
40//! Rows 4-6 are the "active" paths where at least one role is estimated from
41//! history. In rows 4 and 6, Role 2 is estimated via periodic Yule-Walker / PACF.
42//! In row 5, Role 1 is estimated from history while Role 2's `ar_coefficients`
43//! are preserved from user (`residual_std_ratio` is still derived, not preserved).
44//!
45//! `correlation.json` is handled independently: if present, the existing
46//! `system.correlation()` is kept; if absent, the correlation is estimated from
47//! residuals.
48//!
49//! ## PACF order selection
50//!
51//! When `config.estimation.order_selection = "pacf"` (the default and only
52//! supported method), the module computes the periodic PACF via progressive
53//! periodic Yule-Walker matrix solves, selects the order using a 95% significance
54//! threshold, then estimates coefficients at the selected order using the periodic
55//! YW system.
56
57use std::collections::{BTreeMap, HashMap, HashSet};
58use std::path::Path;
59
60use chrono::{Months, NaiveDate};
61use cobre_core::{EntityId, SeasonMap, Stage, System};
62use cobre_stochastic::{
63    StochasticError,
64    par::aggregate::aggregate_observations_to_season,
65    par::fitting::{
66        ArCoefficientEstimate, ArEstimationConfig, SeasonalStats, StdRatioDivergence,
67        estimate_ar_coefficients_with_selection, estimate_correlation_with_season_map,
68        estimate_seasonal_stats_with_season_map,
69    },
70    season_cast::{
71        RealizedWindow, SeasonPeriodWindow, cast, next_season_period_window, season_period_window,
72    },
73};
74
75use crate::LoadError::ConstraintError;
76use crate::{
77    Config, FileManifest, LoadError, OrderSelectionMethod, ValidationContext,
78    parse_inflow_ar_coefficients, parse_inflow_history,
79    scenarios::{
80        InflowAnnualComponentRow, InflowArCoefficientRow, InflowHistoryRow, InflowSeasonalStatsRow,
81        assemble_inflow_models, populate_derived_residual_ratios, resolve_stage_seasons,
82    },
83    validate_structure,
84};
85
86// `EstimationReport` lives in `cobre_stochastic::par::fitting`; re-exported here
87// so callers resolve it alongside `EstimationPath`/`estimate_from_history`.
88pub use cobre_stochastic::par::fitting::EstimationReport;
89
90/// Classification of the estimation path taken for a given input file manifest.
91///
92/// Each variant is one row of the input-path matrix in the module doc, keyed on
93/// the three flags H (history), S (seasonal stats), R (AR coefficients). AR
94/// without history is meaningless, so `R` alone resolves to a no-history row.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum EstimationPath {
97    /// No history: system returned unchanged (also the fallback for AR-without-history).
98    Deterministic,
99    /// User-provided white-noise stats, no history: system returned unchanged.
100    UserStatsWhiteNoise,
101    /// User-provided complete model, no history: system returned unchanged.
102    UserProvidedNoHistory,
103    /// History only: both seasonal stats (Role 1) and AR coefficients (Role 2) estimated.
104    FullEstimation,
105    /// History + user AR: stats estimated from history (Role 1), user AR preserved bitwise (Role 2).
106    UserArHistoryStats,
107    /// History + user stats: user stats preserved (Role 1), AR estimated from history (Role 2).
108    PartialEstimation,
109    /// All parameters user-provided: system returned unchanged.
110    UserProvidedAll,
111}
112
113impl EstimationPath {
114    /// Resolve the estimation path from the three boolean manifest flags.
115    ///
116    /// This function is a total map over all 8 boolean combinations. Invalid
117    /// combinations (AR present without history or stats) fall back to
118    /// `Deterministic` because AR coefficients alone cannot drive estimation.
119    #[must_use]
120    pub fn resolve(manifest: &FileManifest) -> Self {
121        match (
122            manifest.scenarios_inflow_history_parquet,
123            manifest.scenarios_inflow_seasonal_stats_parquet,
124            manifest.scenarios_inflow_ar_coefficients_parquet,
125        ) {
126            // `_`: with no history, R is ignored — AR alone cannot drive estimation.
127            (false, false, _) => Self::Deterministic,
128            (false, true, false) => Self::UserStatsWhiteNoise,
129            (false, true, true) => Self::UserProvidedNoHistory,
130            (true, false, false) => Self::FullEstimation,
131            (true, false, true) => Self::UserArHistoryStats,
132            (true, true, false) => Self::PartialEstimation,
133            (true, true, true) => Self::UserProvidedAll,
134        }
135    }
136
137    /// Convert to a stable string representation for diagnostic output.
138    #[must_use]
139    pub fn as_str(self) -> &'static str {
140        match self {
141            Self::Deterministic => "deterministic",
142            Self::UserStatsWhiteNoise => "user_stats_white_noise",
143            Self::UserProvidedNoHistory => "user_provided_no_history",
144            Self::FullEstimation => "full_estimation",
145            Self::UserArHistoryStats => "user_ar_history_stats",
146            Self::PartialEstimation => "partial_estimation",
147            Self::UserProvidedAll => "user_provided_all",
148        }
149    }
150}
151
152/// Errors that can occur during the automatic estimation pipeline.
153#[derive(Debug, thiserror::Error)]
154pub enum EstimationError {
155    /// File read or parse failure during estimation.
156    #[error("load error: {0}")]
157    Load(#[from] LoadError),
158
159    /// Estimation failed due to insufficient data.
160    #[error("estimation failed: {0}")]
161    Stochastic(#[from] StochasticError),
162}
163
164/// Estimate or load PAR(p) model parameters based on the input file manifest.
165///
166/// Resolves the [`EstimationPath`] for `case_dir` and dispatches to the matching
167/// `run_*` pipeline; pass-through paths return `system` unchanged with `None` report.
168///
169/// # Errors
170///
171/// - [`EstimationError::Load`] -- file read, parse, or validation failure.
172/// - [`EstimationError::Stochastic`] -- insufficient observations for any
173///   `(entity, season)` group during AR or stats estimation.
174pub fn estimate_from_history(
175    system: System,
176    case_dir: &Path,
177    config: &Config,
178) -> Result<(System, Option<EstimationReport>, EstimationPath), EstimationError> {
179    let mut ctx = ValidationContext::new();
180    let manifest = validate_structure(case_dir, &mut ctx);
181
182    // Treat structural validation errors as a no-op deterministic path.
183    if ctx.into_result().is_err() {
184        return Ok((system, None, EstimationPath::Deterministic));
185    }
186
187    let path = EstimationPath::resolve(&manifest);
188
189    match path {
190        EstimationPath::Deterministic
191        | EstimationPath::UserStatsWhiteNoise
192        | EstimationPath::UserProvidedNoHistory
193        | EstimationPath::UserProvidedAll => Ok((system, None, path)),
194
195        EstimationPath::PartialEstimation => {
196            let (system, report) = run_partial_estimation(system, case_dir, config, &manifest)?;
197            Ok((system, Some(report), path))
198        }
199
200        EstimationPath::FullEstimation => {
201            let (system, report) = run_estimation(system, case_dir, config, &manifest)?;
202            Ok((system, Some(report), path))
203        }
204
205        EstimationPath::UserArHistoryStats => {
206            let (system, report) = run_user_ar_estimation(system, case_dir, config, &manifest)?;
207            Ok((system, Some(report), path))
208        }
209    }
210}
211
212/// Inner function that runs the full estimation pipeline once path conditions are met.
213fn run_estimation(
214    system: System,
215    case_dir: &Path,
216    config: &Config,
217    manifest: &FileManifest,
218) -> Result<(System, EstimationReport), EstimationError> {
219    let hydro_ids: Vec<EntityId> = system.hydros().iter().map(|h| h.id).collect();
220
221    // Use the system's stages, avoiding a re-parse of stages.json.
222    let study_stages = system.stages();
223    let season_map = system.policy_graph().season_map.as_ref();
224    let max_order = config.estimation.max_order as usize;
225
226    // Empty for a full-year study, so `stages == study_stages` and the estimation
227    // is bit-identical to the no-prestudy path.
228    let prestudy = synthesize_prestudy_stages(study_stages, max_order, season_map);
229    let stages: Vec<Stage> = study_stages
230        .iter()
231        .cloned()
232        .chain(prestudy.iter().cloned())
233        .collect();
234    let stages = stages.as_slice();
235
236    let observations = load_and_aggregate_observations(case_dir, study_stages, season_map)?;
237
238    let seasonal_stats =
239        estimate_seasonal_stats_with_season_map(&observations, stages, &hydro_ids, season_map)?;
240
241    let (ar_estimates, estimation_report) = estimate_ar_coefficients_with_selection(
242        &observations,
243        &seasonal_stats,
244        stages,
245        &hydro_ids,
246        &ArEstimationConfig {
247            max_order,
248            max_coeff_magnitude: config.estimation.max_coefficient_magnitude,
249            season_map,
250            use_annual_component: matches!(
251                config.estimation.order_selection,
252                OrderSelectionMethod::PacfAnnual
253            ),
254        },
255    )?;
256
257    let correlation = if manifest.scenarios_correlation_json {
258        system.correlation().clone()
259    } else {
260        estimate_correlation_with_season_map(
261            &observations,
262            &ar_estimates,
263            &seasonal_stats,
264            stages,
265            &hydro_ids,
266            season_map,
267        )?
268    };
269
270    let stats_rows = seasonal_stats_to_rows(&seasonal_stats, stages);
271    let coeff_rows = ar_estimates_to_rows(&ar_estimates, stages);
272    let annual_rows = ar_estimates_to_annual_rows(&ar_estimates, stages);
273
274    let mut inflow_models = assemble_inflow_models(stats_rows, coeff_rows, annual_rows)?;
275    // `stages` (study + synthesized prestudy) matches `seasonal_stats_to_rows`'s own
276    // stage_to_season construction above: prestudy stage_ids appear in
277    // `inflow_models` too, so `system.stages()` alone would under-resolve them.
278    let (stage_to_season, n_seasons) = resolve_stage_seasons(stages, season_map);
279    populate_derived_residual_ratios(&mut inflow_models, &stage_to_season, n_seasons)?;
280
281    Ok((
282        system.with_scenario_models(inflow_models, correlation),
283        estimation_report,
284    ))
285}
286
287/// Partial estimation: history + user seasonal stats present, AR coefficients absent.
288///
289/// User stats (`mean_m3s`, `std_m3s`) are preserved exactly for LP assembly; only
290/// AR coefficients are estimated from history. The distinction vs [`run_estimation`]:
291/// history-derived **fitting stats** drive the YW matrix construction, while
292/// **user stats** drive the final `assemble_inflow_models` call.
293fn run_partial_estimation(
294    system: System,
295    case_dir: &Path,
296    config: &Config,
297    manifest: &FileManifest,
298) -> Result<(System, EstimationReport), EstimationError> {
299    let hydro_ids: Vec<EntityId> = system.hydros().iter().map(|h| h.id).collect();
300    let study_stages = system.stages();
301    let season_map = system.policy_graph().season_map.as_ref();
302    let max_order = config.estimation.max_order as usize;
303
304    // Empty for a full-year study, so `stages == study_stages` and the partial
305    // estimation is bit-identical to the no-prestudy path.
306    let prestudy = synthesize_prestudy_stages(study_stages, max_order, season_map);
307    let stages_owned: Vec<Stage> = study_stages
308        .iter()
309        .cloned()
310        .chain(prestudy.iter().cloned())
311        .collect();
312    let stages = stages_owned.as_slice();
313
314    // Aggregate against study_stages (the season map resolves observations there).
315    let observations = load_and_aggregate_observations(case_dir, study_stages, season_map)?;
316
317    if system.inflow_models().is_empty() {
318        return Err(EstimationError::Load(ConstraintError {
319            description: "manifest indicates inflow_seasonal_stats.parquet is present \
320                          but system.inflow_models() is empty; \
321                          no user stats available for partial estimation"
322                .to_string(),
323        }));
324    }
325
326    // Fitting stats: used only for the YW solve below, never for LP assembly.
327    let fitting_stats =
328        estimate_seasonal_stats_with_season_map(&observations, stages, &hydro_ids, season_map)?;
329
330    let (ar_estimates, mut estimation_report) = estimate_ar_coefficients_with_selection(
331        &observations,
332        &fitting_stats,
333        stages,
334        &hydro_ids,
335        &ArEstimationConfig {
336            max_order,
337            max_coeff_magnitude: config.estimation.max_coefficient_magnitude,
338            season_map,
339            use_annual_component: matches!(
340                config.estimation.order_selection,
341                OrderSelectionMethod::PacfAnnual
342            ),
343        },
344    )?;
345
346    // Coverage compares over study_stages only — pre-study stages are excluded.
347    let (white_noise_fallbacks, std_ratio_warnings) =
348        validate_partial_estimation_coverage(&system, &fitting_stats, study_stages)?;
349
350    let correlation = if manifest.scenarios_correlation_json {
351        system.correlation().clone()
352    } else {
353        estimate_correlation_with_season_map(
354            &observations,
355            &ar_estimates,
356            &fitting_stats,
357            stages,
358            &hydro_ids,
359            season_map,
360        )?
361    };
362
363    // LP assembly uses USER stats, not the fitting stats above.
364    let mut stats_rows = user_stats_to_rows(&system);
365    // Out-of-window lag seasons have no user stat; source their (mean, std) from
366    // fitting_stats so PrecomputedPar::build gets a Tier-1 lag hit at the negative
367    // stage_id rather than zeroing the lag. In-window wrap lags stay on the
368    // Tier-2 → user-stat path. Empty for full-year.
369    stats_rows.extend(prestudy_seasonal_rows(&fitting_stats, &prestudy));
370    let coeff_rows = ar_estimates_to_rows(&ar_estimates, stages);
371    let annual_rows = ar_estimates_to_annual_rows(&ar_estimates, stages);
372    let mut inflow_models = assemble_inflow_models(stats_rows, coeff_rows, annual_rows)?;
373    // `stages` (study + synthesized prestudy) — see `run_estimation`'s identical
374    // rationale: prestudy stage_ids appear in `inflow_models` too.
375    let (stage_to_season, n_seasons) = resolve_stage_seasons(stages, season_map);
376    populate_derived_residual_ratios(&mut inflow_models, &stage_to_season, n_seasons)?;
377
378    estimation_report.white_noise_fallbacks = white_noise_fallbacks;
379    estimation_report.std_ratio_warnings = std_ratio_warnings;
380
381    Ok((
382        system.with_scenario_models(inflow_models, correlation),
383        estimation_report,
384    ))
385}
386
387/// Load inflow history from the case directory, gate each hydro's season
388/// occurrences on full record coverage, and aggregate
389/// the resulting samples to season resolution when a season map is present.
390///
391/// See [`resolve_coverage_gated_observations`] for the coverage gate itself
392/// (record windows only — the initial-conditions conditioning layer is never
393/// read here).
394fn load_and_aggregate_observations(
395    case_dir: &Path,
396    stages: &[Stage],
397    season_map: Option<&SeasonMap>,
398) -> Result<Vec<(EntityId, NaiveDate, f64)>, EstimationError> {
399    let history_path = case_dir.join("scenarios/inflow_history.parquet");
400    let history = parse_inflow_history(&history_path)?;
401
402    let (observations, skipped_partial) =
403        resolve_coverage_gated_observations(&history, season_map, stages.first());
404    log_skipped_partial_occurrences(&skipped_partial);
405
406    if let Some(sm) = season_map {
407        Ok(aggregate_observations_to_season(&observations, stages, sm)?)
408    } else {
409        Ok(observations)
410    }
411}
412
413/// Return type of [`resolve_coverage_gated_observations`]:
414/// `(observations, skipped_partial)`.
415type CoverageGatedObservations = (Vec<(EntityId, NaiveDate, f64)>, BTreeMap<EntityId, usize>);
416
417/// Coverage-gated occurrence resolution for one case's windowed inflow
418/// history, over the **record layer only** — the
419/// initial-conditions conditioning layer and its layered-merge helper are
420/// never read here (owner gate: a conditioning window must change no fitted
421/// PAR statistic).
422///
423/// For each hydro, record windows are grouped by season occurrence via
424/// [`cast`]: an occurrence with `coverage == 1.0` contributes one sample
425/// (`cast(...).value`, keyed at the occurrence's own start date); a partial
426/// occurrence (`0.0 < coverage < 1.0`) is skipped and counted per hydro; a
427/// zero-coverage occurrence is never enumerated (no window touches it, so it
428/// never becomes a candidate).
429///
430/// When `season_map` or `stage_template` is unavailable, there is no season
431/// occurrence to project onto: every row passes through unchanged.
432// `coverage == 1.0` is an exact gate, not a tolerance shortcut: both
433// `overlap` and `period.hours` are built from whole-day counts times 24.0, so
434// a fully-covered occurrence's ratio is bit-exact 1.0 — see `cast`'s doc.
435#[allow(clippy::float_cmp)]
436fn resolve_coverage_gated_observations(
437    history: &[InflowHistoryRow],
438    season_map: Option<&SeasonMap>,
439    stage_template: Option<&Stage>,
440) -> CoverageGatedObservations {
441    let (Some(season_map), Some(stage_template)) = (season_map, stage_template) else {
442        let observations = history
443            .iter()
444            .map(|row| (row.hydro_id, row.start_date, row.value_m3s))
445            .collect();
446        return (observations, BTreeMap::new());
447    };
448
449    let mut windows_by_hydro: BTreeMap<EntityId, Vec<RealizedWindow>> = BTreeMap::new();
450    for row in history {
451        windows_by_hydro
452            .entry(row.hydro_id)
453            .or_default()
454            .push(RealizedWindow {
455                start_date: row.start_date,
456                end_date: row.end_date,
457                value_m3s: row.value_m3s,
458            });
459    }
460
461    let mut observations = Vec::new();
462    let mut skipped_partial = BTreeMap::new();
463
464    for (&hydro_id, windows) in &windows_by_hydro {
465        let occurrences = discover_hydro_occurrences(season_map, stage_template, windows);
466        let mut skip_count = 0usize;
467
468        for occurrence in &occurrences {
469            let overlapping: Vec<RealizedWindow> = windows
470                .iter()
471                .filter(|w| w.start_date < occurrence.end && w.end_date > occurrence.start)
472                .map(|w| RealizedWindow {
473                    start_date: w.start_date,
474                    end_date: w.end_date,
475                    value_m3s: w.value_m3s,
476                })
477                .collect();
478
479            let projection = cast(&overlapping, occurrence);
480
481            if projection.coverage == 1.0 {
482                observations.push((hydro_id, occurrence.start, projection.value));
483            } else if projection.coverage > 0.0 {
484                skip_count += 1;
485            }
486        }
487
488        if skip_count > 0 {
489            skipped_partial.insert(hydro_id, skip_count);
490        }
491    }
492
493    observations.sort_by_key(|(id, date, _)| (id.0, *date));
494
495    (observations, skipped_partial)
496}
497
498/// Every season-period occurrence overlapped by any of `windows`, deduplicated
499/// by occurrence start date. Walks forward from each window's own occurrence
500/// via [`next_season_period_window`] so a window straddling more than one
501/// occurrence contributes every occurrence it touches, not just the first.
502fn discover_hydro_occurrences(
503    season_map: &SeasonMap,
504    stage_template: &Stage,
505    windows: &[RealizedWindow],
506) -> Vec<SeasonPeriodWindow> {
507    let mut discovered: BTreeMap<NaiveDate, SeasonPeriodWindow> = BTreeMap::new();
508
509    for window in windows {
510        let Some(mut occurrence) = occurrence_containing(season_map, stage_template, window) else {
511            continue;
512        };
513
514        while occurrence.start < window.end_date {
515            let key = occurrence.start;
516            discovered.entry(key).or_insert_with(|| SeasonPeriodWindow {
517                start: occurrence.start,
518                end: occurrence.end,
519                hours: occurrence.hours,
520            });
521
522            let Some(season_id) = season_map.season_for_date(occurrence.start) else {
523                break;
524            };
525            let Some(season_def) = season_map.seasons.iter().find(|s| s.id == season_id) else {
526                break;
527            };
528            let Some(next) = next_season_period_window(season_map, season_def, &occurrence) else {
529                break;
530            };
531            occurrence = next;
532        }
533    }
534
535    discovered.into_values().collect()
536}
537
538/// The season-period occurrence containing `window.start_date`, anchored on
539/// `window`'s own `[start_date, end_date)` span — the natural stage analogue
540/// [`season_period_window`] expects for disambiguating a cycle-crossing
541/// candidate year. `stage_template` donates every field `season_period_window`
542/// does not read (only `start_date`/`end_date` are read); any real `Stage`
543/// works as the donor.
544fn occurrence_containing(
545    season_map: &SeasonMap,
546    stage_template: &Stage,
547    window: &RealizedWindow,
548) -> Option<SeasonPeriodWindow> {
549    let season_id = season_map.season_for_date(window.start_date)?;
550    let season_def = season_map.seasons.iter().find(|s| s.id == season_id)?;
551
552    let mut probe = stage_template.clone();
553    probe.start_date = window.start_date;
554    probe.end_date = window.end_date;
555
556    Some(season_period_window(season_map, season_def, &probe))
557}
558
559/// Emit one aggregate info-level diagnostic summarizing partial-coverage
560/// occurrences skipped during estimation observation loading; a
561/// no-op when nothing was skipped. Estimation's `run_*` pipelines carry no
562/// `ValidationContext` (that lives only in [`estimate_from_history`]'s
563/// structural pre-check), so `tracing::info!` is the channel already used for
564/// this file's other estimation-time diagnostics (see
565/// [`validate_partial_estimation_coverage`]'s `tracing::warn!`).
566fn log_skipped_partial_occurrences(skipped_partial: &BTreeMap<EntityId, usize>) {
567    if skipped_partial.is_empty() {
568        return;
569    }
570
571    let total: usize = skipped_partial.values().sum();
572    let per_hydro: Vec<String> = skipped_partial
573        .iter()
574        .map(|(hydro_id, count)| format!("hydro {hydro_id}: {count}"))
575        .collect();
576
577    tracing::info!(
578        "estimation observation loading skipped {total} partial-coverage season \
579         occurrence(s) across {} hydro(s) ({})",
580        skipped_partial.len(),
581        per_hydro.join(", ")
582    );
583}
584
585/// Return type of [`validate_partial_estimation_coverage`]:
586/// `(white_noise_fallbacks, std_ratio_warnings)`.
587type CoverageCheckResult = (Vec<EntityId>, Vec<StdRatioDivergence>);
588
589/// Validate bidirectional user-vs-estimated coverage and emit advisory warnings.
590///
591/// Errors only on hard coverage failures (an estimated hydro missing user stats).
592fn validate_partial_estimation_coverage(
593    system: &System,
594    fitting_stats: &[SeasonalStats],
595    stages: &[Stage],
596) -> Result<CoverageCheckResult, EstimationError> {
597    let estimated_hydro_ids: HashSet<EntityId> =
598        fitting_stats.iter().map(|s| s.entity_id).collect();
599    let user_stats_hydro_ids: HashSet<EntityId> =
600        system.inflow_models().iter().map(|m| m.hydro_id).collect();
601
602    // Direction A: AR estimated but no user stats → hard error.
603    let mut missing_stats: Vec<EntityId> = estimated_hydro_ids
604        .difference(&user_stats_hydro_ids)
605        .copied()
606        .collect();
607    missing_stats.sort();
608    if !missing_stats.is_empty() {
609        let ids: Vec<String> = missing_stats.iter().map(|id| id.0.to_string()).collect();
610        return Err(EstimationError::Load(ConstraintError {
611            description: format!(
612                "partial estimation: AR coefficients were estimated for hydro(s) \
613                     [{ids}] but inflow_seasonal_stats.parquet has no entry for them; \
614                     all hydros with estimated AR must have user-provided stats",
615                ids = ids.join(", ")
616            ),
617        }));
618    }
619
620    // Direction B: user stats but no AR estimated → white noise fallback.
621    let mut white_noise_fallbacks: Vec<EntityId> = user_stats_hydro_ids
622        .difference(&estimated_hydro_ids)
623        .copied()
624        .collect();
625    white_noise_fallbacks.sort();
626
627    // Cross-season std-ratio divergence check (advisory only).
628    let std_ratio_warnings = check_std_ratio_divergence(system, fitting_stats, stages);
629    for w in &std_ratio_warnings {
630        tracing::warn!(
631            "hydro {} season {}->{} std ratio diverges {:.1}x between \
632             user ({:.2}) and estimated ({:.2})",
633            w.hydro_id.0,
634            w.season_a,
635            w.season_b,
636            w.divergence,
637            w.user_ratio,
638            w.estimated_ratio
639        );
640    }
641
642    Ok((white_noise_fallbacks, std_ratio_warnings))
643}
644
645/// `UserArHistoryStats`: history + user AR coefficients present, seasonal stats absent.
646///
647/// Seasonal stats are estimated from history (driving both LP assembly and
648/// correlation estimation); AR coefficients are loaded from the user file and
649/// preserved bitwise — **no re-estimation from history is performed**. The
650/// returned [`EstimationReport`] carries an empty `entries` map and method
651/// `"user_provided"` to signal that no AR estimation ran.
652fn run_user_ar_estimation(
653    system: System,
654    case_dir: &Path,
655    config: &Config,
656    manifest: &FileManifest,
657) -> Result<(System, EstimationReport), EstimationError> {
658    let hydro_ids: Vec<EntityId> = system.hydros().iter().map(|h| h.id).collect();
659    let stages = system.stages();
660    let season_map = system.policy_graph().season_map.as_ref();
661    let max_order = config.estimation.max_order as usize;
662
663    // Empty for a full-year study, so `extended == stages` and the estimation
664    // is bit-identical to the no-prestudy path.
665    let prestudy = synthesize_prestudy_stages(stages, max_order, season_map);
666    let extended: Vec<Stage> = stages
667        .iter()
668        .cloned()
669        .chain(prestudy.iter().cloned())
670        .collect();
671    let extended = extended.as_slice();
672
673    let observations = load_and_aggregate_observations(case_dir, stages, season_map)?;
674
675    let seasonal_stats =
676        estimate_seasonal_stats_with_season_map(&observations, extended, &hydro_ids, season_map)?;
677
678    // Read AR from file: system.inflow_models() is empty on this path (no user stats).
679    let ar_path = case_dir.join("scenarios/inflow_ar_coefficients.parquet");
680    let user_ar_rows = parse_inflow_ar_coefficients(&ar_path)?;
681
682    let user_ar_estimates = ar_rows_to_estimates(&user_ar_rows, stages);
683
684    let correlation = if manifest.scenarios_correlation_json {
685        system.correlation().clone()
686    } else {
687        estimate_correlation_with_season_map(
688            &observations,
689            &user_ar_estimates,
690            &seasonal_stats,
691            extended,
692            &hydro_ids,
693            season_map,
694        )?
695    };
696
697    // History stats drive mean_m3s/std_m3s; user AR rows drive ar_coefficients
698    // (residual_std_ratio is derived below, not read from the user file).
699    let stats_rows = seasonal_stats_to_rows(&seasonal_stats, extended);
700
701    let mut inflow_models = assemble_inflow_models(stats_rows, user_ar_rows, vec![])?;
702    // `extended` (study + synthesized prestudy) matches `seasonal_stats_to_rows`'s
703    // own coverage above — see `run_estimation`'s identical rationale.
704    let (stage_to_season, n_seasons) = resolve_stage_seasons(extended, season_map);
705    populate_derived_residual_ratios(&mut inflow_models, &stage_to_season, n_seasons)?;
706
707    let estimation_report = EstimationReport {
708        entries: BTreeMap::new(),
709        method: "user_provided".to_string(),
710        white_noise_fallbacks: Vec::new(),
711        std_ratio_warnings: Vec::new(),
712    };
713
714    Ok((
715        system.with_scenario_models(inflow_models, correlation),
716        estimation_report,
717    ))
718}
719
720/// Convert [`InflowArCoefficientRow`] entries to [`ArCoefficientEstimate`] values.
721///
722/// This is the inverse of [`ar_estimates_to_rows`]: it groups coefficient rows by
723/// `(hydro_id, season_id)` — using the stage-to-season mapping from `stages` —
724/// and produces one [`ArCoefficientEstimate`] per group.
725///
726/// When multiple stages map to the same season, each stage produces duplicate
727/// rows in the `InflowArCoefficientRow` format (all lags repeated for every stage
728/// in the season). This function deduplicates by processing only the first stage
729/// encountered for each season per hydro. Coefficient order is preserved (lag 1,
730/// lag 2, …). The innovation scale is derived downstream by
731/// [`crate::scenarios::populate_derived_residual_ratios`] on the assembled
732/// `InflowModel`s — it is not part of the estimate.
733///
734/// The result is sorted by `(hydro_id, season_id)` ascending, matching the
735/// canonical ordering expected by `estimate_correlation_with_season_map`.
736fn ar_rows_to_estimates(
737    rows: &[InflowArCoefficientRow],
738    stages: &[Stage],
739) -> Vec<ArCoefficientEstimate> {
740    let stage_id_to_season: HashMap<i32, usize> = stages
741        .iter()
742        .filter_map(|s| s.season_id.map(|sid| (s.id, sid)))
743        .collect();
744
745    // Rows are pre-sorted by (hydro_id, stage_id, lag), so the first stage per
746    // season is canonical; later same-season stages are duplicates emitted by
747    // ar_estimates_to_rows and are skipped.
748    let mut first_stage: HashMap<(EntityId, usize), i32> = HashMap::new();
749
750    // BTreeMap for deterministic (hydro_id, season_id) output ordering.
751    let mut groups: BTreeMap<(EntityId, usize), Vec<f64>> = BTreeMap::new();
752
753    for row in rows {
754        let Some(&season_id) = stage_id_to_season.get(&row.stage_id) else {
755            continue;
756        };
757
758        let key = (row.hydro_id, season_id);
759
760        let canonical_stage = first_stage.entry(key).or_insert(row.stage_id);
761        if *canonical_stage != row.stage_id {
762            continue;
763        }
764
765        groups.entry(key).or_default().push(row.coefficient);
766    }
767
768    groups
769        .into_iter()
770        .map(
771            |((hydro_id, season_id), coefficients)| ArCoefficientEstimate {
772                hydro_id,
773                season_id,
774                coefficients,
775                annual: None,
776            },
777        )
778        .collect()
779}
780
781/// Extract user-provided seasonal stats from `system.inflow_models()` as
782/// [`InflowSeasonalStatsRow`] entries.
783///
784/// Each `InflowModel` in the system contributes one row with its `mean_m3s`
785/// and `std_m3s` preserved bitwise — no transformation is applied. This is
786/// used by [`run_partial_estimation`] to pass user stats into `assemble_inflow_models`
787/// instead of history-derived fitting stats.
788fn user_stats_to_rows(system: &System) -> Vec<InflowSeasonalStatsRow> {
789    system
790        .inflow_models()
791        .iter()
792        .map(|m| InflowSeasonalStatsRow {
793            hydro_id: m.hydro_id,
794            stage_id: m.stage_id,
795            mean_m3s: m.mean_m3s,
796            std_m3s: m.std_m3s,
797        })
798        .collect()
799}
800
801/// Flag hydros whose consecutive-season std ratios diverge between the user and
802/// estimated profiles, advisory only.
803///
804/// For each hydro in both user stats and `fitting_stats`, over consecutive season
805/// pairs `(m, (m+1) % n)`, pushes a [`StdRatioDivergence`] when the symmetric
806/// ratio-of-ratios exceeds `2.0`. Near-zero denominators (`< 1e-12`) are skipped.
807fn check_std_ratio_divergence(
808    system: &System,
809    fitting_stats: &[SeasonalStats],
810    stages: &[Stage],
811) -> Vec<StdRatioDivergence> {
812    let stage_id_to_season: HashMap<i32, usize> = stages
813        .iter()
814        .filter_map(|s| s.season_id.map(|sid| (s.id, sid)))
815        .collect();
816
817    // First entry wins: stages sharing a season carry the same std.
818    let mut user_std: BTreeMap<(EntityId, usize), f64> = BTreeMap::new();
819    for m in system.inflow_models() {
820        let Some(&season_id) = stage_id_to_season.get(&m.stage_id) else {
821            continue;
822        };
823        user_std.entry((m.hydro_id, season_id)).or_insert(m.std_m3s);
824    }
825
826    let mut est_std: BTreeMap<(EntityId, usize), f64> = BTreeMap::new();
827    for s in fitting_stats {
828        let Some(&season_id) = stage_id_to_season.get(&s.stage_id) else {
829            continue;
830        };
831        est_std.entry((s.entity_id, season_id)).or_insert(s.std);
832    }
833
834    let user_hydros: std::collections::BTreeSet<EntityId> =
835        user_std.keys().map(|(h, _)| *h).collect();
836    let est_hydros: std::collections::BTreeSet<EntityId> =
837        est_std.keys().map(|(h, _)| *h).collect();
838    let common_hydros: Vec<EntityId> = user_hydros.intersection(&est_hydros).copied().collect();
839
840    let mut warnings: Vec<StdRatioDivergence> = Vec::new();
841
842    for hydro_id in common_hydros {
843        let season_ids: Vec<usize> = {
844            let mut ids: Vec<usize> = user_std
845                .keys()
846                .filter(|(h, _)| *h == hydro_id)
847                .map(|(_, s)| *s)
848                .collect();
849            ids.sort_unstable();
850            ids.dedup();
851            ids
852        };
853
854        let n = season_ids.len();
855        if n < 2 {
856            continue;
857        }
858
859        for i in 0..n {
860            let season_a = season_ids[i];
861            let season_b = season_ids[(i + 1) % n];
862
863            let Some(&u_a) = user_std.get(&(hydro_id, season_a)) else {
864                continue;
865            };
866            let Some(&u_b) = user_std.get(&(hydro_id, season_b)) else {
867                continue;
868            };
869            let Some(&e_a) = est_std.get(&(hydro_id, season_a)) else {
870                continue;
871            };
872            let Some(&e_b) = est_std.get(&(hydro_id, season_b)) else {
873                continue;
874            };
875
876            if u_b.abs() < 1e-12 || e_b.abs() < 1e-12 {
877                continue;
878            }
879
880            let ratio_user = u_a / u_b;
881            let ratio_est = e_a / e_b;
882
883            // Guard the ratio-of-ratios divergence below against division by zero.
884            if ratio_user.abs() < 1e-12 || ratio_est.abs() < 1e-12 {
885                continue;
886            }
887
888            let divergence = (ratio_user / ratio_est)
889                .abs()
890                .max((ratio_est / ratio_user).abs());
891
892            if divergence > 2.0 {
893                warnings.push(StdRatioDivergence {
894                    hydro_id,
895                    season_a,
896                    season_b,
897                    user_ratio: ratio_user,
898                    estimated_ratio: ratio_est,
899                    divergence,
900                });
901            }
902        }
903    }
904
905    // Sort by (hydro_id, season_a) for deterministic output.
906    warnings.sort_by_key(|w| (w.hydro_id, w.season_a));
907    warnings
908}
909
910/// Synthesize pre-study stages covering the PAR(p) lag window for a
911/// partial-year study (one whose horizon is narrower than the seasonal cycle).
912///
913/// For a study starting mid-cycle (e.g. a monthly model spanning September–
914/// December, seasons 8–11), the first study stage's AR lags reach back into
915/// months that have no study stage (August, July, …). Without a stage carrying
916/// those seasons, the season-aware estimators have no place to attach the
917/// out-of-window lag statistics, and the precompute silently zeroes them.
918///
919/// This helper emits, for each lag `k = 1..=min(max_order, cycle_len-1)`, a
920/// pre-study [`Stage`](cobre_core::temporal::Stage) with:
921/// - `id = first_study_stage.id - k` (negative, descending),
922/// - `season_id` = the season `k` calendar positions before the first study
923///   stage's season (modular on `cycle_len`),
924/// - `start_date`/`end_date` = the calendar month `k` positions before the
925///   first study stage's `start_date`.
926///
927/// A pre-study stage is emitted **only** when its `season_id` is not already
928/// among the study stages' seasons. A full-year study therefore synthesizes
929/// nothing (every season already has a study stage), making this a no-op for
930/// the existing in-horizon cases.
931///
932/// Returns an empty `Vec` when `season_map` is `None`, `max_order == 0`, or
933/// the study has no stage with a `season_id`.
934fn synthesize_prestudy_stages(
935    stages: &[Stage],
936    max_order: usize,
937    season_map: Option<&SeasonMap>,
938) -> Vec<Stage> {
939    let Some(sm) = season_map else {
940        return Vec::new();
941    };
942    let cycle_len = sm.seasons.len();
943    if max_order == 0 || cycle_len == 0 {
944        return Vec::new();
945    }
946
947    let Some(first) = stages
948        .iter()
949        .filter(|s| s.id >= 0 && s.season_id.is_some())
950        .min_by_key(|s| s.id)
951    else {
952        return Vec::new();
953    };
954    let Some(first_season) = first.season_id else {
955        return Vec::new();
956    };
957
958    let study_seasons: HashSet<usize> = stages.iter().filter_map(|s| s.season_id).collect();
959
960    let lag_window = max_order.min(cycle_len - 1);
961    let mut synthetic = Vec::with_capacity(lag_window);
962
963    for k in 1..=lag_window {
964        // The season k calendar positions before first_season (modular on cycle_len).
965        let season_k = (first_season + cycle_len - (k % cycle_len)) % cycle_len;
966        if study_seasons.contains(&season_k) {
967            // In-window wrap lags are served by the cycle-correct Tier-2 / user-stat path.
968            continue;
969        }
970
971        // [start_k, end_k): k and k-1 months before first.start_date give a half-open span.
972        let (Some(start_k), Some(end_k)) = (
973            first
974                .start_date
975                .checked_sub_months(Months::new(u32::try_from(k).unwrap_or(u32::MAX))),
976            first
977                .start_date
978                .checked_sub_months(Months::new(u32::try_from(k - 1).unwrap_or(u32::MAX))),
979        ) else {
980            continue;
981        };
982
983        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
984        let id = first.id - k as i32;
985
986        // Override only identity/dates/season; estimation keys off id + season_id,
987        // so the cloned block/state/risk/scenario config only keeps the stage valid.
988        let mut stage = first.clone();
989        stage.index = 0;
990        stage.id = id;
991        stage.start_date = start_k;
992        stage.end_date = end_k;
993        stage.season_id = Some(season_k);
994        synthetic.push(stage);
995    }
996
997    synthetic
998}
999
1000/// Emit history-derived seasonal rows for the synthetic pre-study stages of a
1001/// partial-year study.
1002///
1003/// Each out-of-window lag season is covered by exactly one synthetic pre-study
1004/// stage (negative `id`). Because the season-aware fitting keys each season to
1005/// its lowest-`start_date` stage, and the synthetic pre-study stages sort
1006/// before every study stage, the corresponding [`SeasonalStats`] entry already
1007/// carries that negative `stage_id`. This helper selects those entries and
1008/// emits one [`InflowSeasonalStatsRow`] per (hydro, pre-study stage), giving
1009/// `PrecomputedPar::build` a Tier-1 lag hit at the negative `stage_id`.
1010///
1011/// Returns an empty `Vec` when `prestudy` is empty (full-year studies).
1012fn prestudy_seasonal_rows(
1013    fitting_stats: &[SeasonalStats],
1014    prestudy: &[Stage],
1015) -> Vec<InflowSeasonalStatsRow> {
1016    if prestudy.is_empty() {
1017        return Vec::new();
1018    }
1019    let prestudy_ids: HashSet<i32> = prestudy.iter().map(|s| s.id).collect();
1020    let mut rows: Vec<InflowSeasonalStatsRow> = fitting_stats
1021        .iter()
1022        .filter(|s| prestudy_ids.contains(&s.stage_id))
1023        .map(|s| InflowSeasonalStatsRow {
1024            hydro_id: s.entity_id,
1025            stage_id: s.stage_id,
1026            mean_m3s: s.mean,
1027            std_m3s: s.std,
1028        })
1029        .collect();
1030    rows.sort_by_key(|r| (r.hydro_id.0, r.stage_id));
1031    rows
1032}
1033
1034/// Index stage ids by their `season_id`, skipping stages without one.
1035fn build_season_to_stages(stages: &[Stage]) -> HashMap<usize, Vec<i32>> {
1036    let mut season_to_stages: HashMap<usize, Vec<i32>> = HashMap::new();
1037    for stage in stages {
1038        if let Some(sid) = stage.season_id {
1039            season_to_stages.entry(sid).or_default().push(stage.id);
1040        }
1041    }
1042    season_to_stages
1043}
1044
1045/// Convert [`SeasonalStats`] to [`InflowSeasonalStatsRow`], expanding each
1046/// per-season estimate to every stage sharing its `season_id` so that
1047/// [`cobre_stochastic::PrecomputedPar`] finds a model at every stage index.
1048///
1049/// Pre-study stages (negative `id`) are included in the expansion, emitting rows
1050/// at their negative `stage_id` for direct lag-stage hits.
1051fn seasonal_stats_to_rows(
1052    stats: &[SeasonalStats],
1053    stages: &[Stage],
1054) -> Vec<InflowSeasonalStatsRow> {
1055    let stage_to_season: HashMap<i32, usize> = stages
1056        .iter()
1057        .filter_map(|s| s.season_id.map(|sid| (s.id, sid)))
1058        .collect();
1059
1060    let season_to_stages = build_season_to_stages(stages);
1061
1062    let mut rows = Vec::with_capacity(stats.len() * 10);
1063    for s in stats {
1064        if let Some(&season_id) = stage_to_season.get(&s.stage_id)
1065            && let Some(stage_ids) = season_to_stages.get(&season_id)
1066        {
1067            for &stage_id in stage_ids {
1068                rows.push(InflowSeasonalStatsRow {
1069                    hydro_id: s.entity_id,
1070                    stage_id,
1071                    mean_m3s: s.mean,
1072                    std_m3s: s.std,
1073                });
1074            }
1075            continue;
1076        }
1077        // No season mapping: emit the stat's own stage_id unexpanded.
1078        rows.push(InflowSeasonalStatsRow {
1079            hydro_id: s.entity_id,
1080            stage_id: s.stage_id,
1081            mean_m3s: s.mean,
1082            std_m3s: s.std,
1083        });
1084    }
1085
1086    rows.sort_by_key(|r| (r.hydro_id.0, r.stage_id));
1087    rows
1088}
1089
1090/// Convert [`ArCoefficientEstimate`] to [`InflowArCoefficientRow`], expanding
1091/// each per-season estimate to every stage sharing its `season_id` (covering the
1092/// full horizon, not just the season's first occurrence).
1093///
1094/// Pre-study stages (negative `id`) are included, emitting coefficient rows at
1095/// their negative `stage_id` for direct lag lookups.
1096fn ar_estimates_to_rows(
1097    ar_estimates: &[ArCoefficientEstimate],
1098    stages: &[Stage],
1099) -> Vec<InflowArCoefficientRow> {
1100    let season_to_stages = build_season_to_stages(stages);
1101
1102    let mut rows: Vec<InflowArCoefficientRow> = Vec::new();
1103
1104    for est in ar_estimates {
1105        let Some(stage_ids) = season_to_stages.get(&est.season_id) else {
1106            continue;
1107        };
1108
1109        for &stage_id in stage_ids {
1110            for (lag_idx, &coeff) in est.coefficients.iter().enumerate() {
1111                #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1112                let lag = (lag_idx + 1) as i32;
1113                rows.push(InflowArCoefficientRow {
1114                    hydro_id: est.hydro_id,
1115                    stage_id,
1116                    lag,
1117                    coefficient: coeff,
1118                });
1119            }
1120        }
1121    }
1122
1123    // Sort by (hydro_id, stage_id, lag) ascending — matches parser convention.
1124    rows.sort_by_key(|r| (r.hydro_id.0, r.stage_id, r.lag));
1125
1126    rows
1127}
1128
1129/// Convert [`ArCoefficientEstimate`] to [`InflowAnnualComponentRow`], expanding
1130/// per-season annual components to every stage that shares the same `season_id`.
1131///
1132/// Estimates without an `annual` field (`annual.is_none()`) are silently skipped,
1133/// so the function is safe to call for classical-PAR estimates (the result will be
1134/// an empty `Vec`).
1135fn ar_estimates_to_annual_rows(
1136    ar_estimates: &[ArCoefficientEstimate],
1137    stages: &[Stage],
1138) -> Vec<InflowAnnualComponentRow> {
1139    let season_to_stages = build_season_to_stages(stages);
1140
1141    let mut rows: Vec<InflowAnnualComponentRow> = Vec::new();
1142
1143    for est in ar_estimates {
1144        let Some(ref ann) = est.annual else {
1145            continue;
1146        };
1147        let Some(stage_ids) = season_to_stages.get(&est.season_id) else {
1148            continue;
1149        };
1150        for &stage_id in stage_ids {
1151            rows.push(InflowAnnualComponentRow {
1152                hydro_id: est.hydro_id,
1153                stage_id,
1154                annual_coefficient: ann.coefficient,
1155                annual_mean_m3s: ann.mean_m3s,
1156                annual_std_m3s: ann.std_m3s,
1157            });
1158        }
1159    }
1160
1161    // Sort by (hydro_id, stage_id) ascending — matches parser convention.
1162    rows.sort_by_key(|r| (r.hydro_id.0, r.stage_id));
1163
1164    rows
1165}
1166
1167// ── Tests ─────────────────────────────────────────────────────────────────────
1168
1169#[cfg(test)]
1170mod tests;