1use 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
86pub use cobre_stochastic::par::fitting::EstimationReport;
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum EstimationPath {
97 Deterministic,
99 UserStatsWhiteNoise,
101 UserProvidedNoHistory,
103 FullEstimation,
105 UserArHistoryStats,
107 PartialEstimation,
109 UserProvidedAll,
111}
112
113impl EstimationPath {
114 #[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 (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 #[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#[derive(Debug, thiserror::Error)]
154pub enum EstimationError {
155 #[error("load error: {0}")]
157 Load(#[from] LoadError),
158
159 #[error("estimation failed: {0}")]
161 Stochastic(#[from] StochasticError),
162}
163
164pub 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 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
212fn 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 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 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 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
287fn 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 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 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 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 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 let mut stats_rows = user_stats_to_rows(&system);
365 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 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
387fn 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
413type CoverageGatedObservations = (Vec<(EntityId, NaiveDate, f64)>, BTreeMap<EntityId, usize>);
416
417#[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
498fn 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
538fn 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
559fn 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
585type CoverageCheckResult = (Vec<EntityId>, Vec<StdRatioDivergence>);
588
589fn 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 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 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 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
645fn 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 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 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 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 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
720fn 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 let mut first_stage: HashMap<(EntityId, usize), i32> = HashMap::new();
749
750 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
781fn 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
801fn 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 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 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 warnings.sort_by_key(|w| (w.hydro_id, w.season_a));
907 warnings
908}
909
910fn 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 let season_k = (first_season + cycle_len - (k % cycle_len)) % cycle_len;
966 if study_seasons.contains(&season_k) {
967 continue;
969 }
970
971 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 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
1000fn 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
1034fn 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
1045fn 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 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
1090fn 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 rows.sort_by_key(|r| (r.hydro_id.0, r.stage_id, r.lag));
1125
1126 rows
1127}
1128
1129fn 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 rows.sort_by_key(|r| (r.hydro_id.0, r.stage_id));
1163
1164 rows
1165}
1166
1167#[cfg(test)]
1170mod tests;