Skip to main content

cobre_sddp/setup/
mod.rs

1//! Study setup struct that owns all precomputed state for a solve run.
2//!
3//! [`StudySetup`] centralises orchestration from CLI/Python entry points.
4//! It builds LP templates, indexer, initial state, FCF, horizon mode, risk measures,
5//! and entity counts from a validated [`System`] and [`cobre_io::Config`].
6//!
7//! **Ownership**: `StudySetup` owns all data; callers borrow for `TrainingContext`
8//! and `StageContext` construction. The [`StochasticContext`] lifetime matches setup.
9//!
10//! **Not included**: MPI communication (in CLI/Python), solver instances (caller-created),
11//! progress bars, event channels (caller-managed).
12//!
13//! ## Example
14//!
15//! ```rust,no_run
16//! use cobre_sddp::setup::StudySetup;
17//! use cobre_sddp::hydro_models::PrepareHydroModelsResult;
18//! use cobre_stochastic::{ClassSchemes, OpeningTreeInputs, build_stochastic_context};
19//!
20//! # fn example(system: &cobre_core::System, config: &cobre_io::Config)
21//! #     -> Result<(), cobre_sddp::SddpError> {
22//! let stochastic = build_stochastic_context(system, 42, None, &[], &[], OpeningTreeInputs::default(), ClassSchemes { inflow: None, load: None, ncs: None })?;
23//! let hydro_models = PrepareHydroModelsResult::default_from_system(system);
24//! let setup = StudySetup::new(system, config, stochastic, hydro_models)?;
25//! assert!(!setup.stage_data.stage_templates.templates.is_empty());
26//! # Ok(())
27//! # }
28//! ```
29
30mod accessors;
31pub(crate) mod methodology_config;
32mod orchestration;
33pub mod params;
34pub(crate) mod scenario_libraries;
35pub mod scenario_library_set;
36pub mod stage_data;
37pub mod stochastic_pipeline;
38pub(crate) mod template_postprocess;
39
40pub use params::{
41    ConstructionConfig, DEFAULT_FORWARD_PASSES, DEFAULT_MAX_ITERATIONS, DEFAULT_SEED, StudyParams,
42};
43pub use scenario_library_set::{PhaseLibraries, ScenarioLibraries};
44pub use stage_data::StageData;
45pub use stochastic_pipeline::{
46    PrepareStochasticResult, build_ncs_factor_entries, load_load_factors_for_stochastic,
47    prepare_stochastic,
48};
49
50use std::path::Path;
51
52use cobre_core::{
53    EntityId, Stage, System,
54    scenario::{SamplingScheme, ScenarioSource},
55};
56use cobre_io::build_hydro_reference_volumes_resolved;
57use cobre_stochastic::{
58    ExternalScenarioLibrary, HistoricalScenarioLibrary, StochasticContext, SweepDirection,
59};
60
61use crate::{
62    config::{CutManagementConfig, EventParams},
63    cut::FutureCostFunction,
64    energy_conversion::{EnergyConversionSet, build_energy_conversion_set},
65    error::SddpError,
66    horizon_mode::HorizonMode,
67    hydro_models::{EvaporationModel, PrepareHydroModelsResult, ResolvedProductionModel},
68    indexer::StageIndexer,
69    lp_builder::build_stage_templates,
70    risk_measure::RiskMeasure,
71    simulation::EntityCounts,
72    stopping_rule::{StoppingRule, StoppingRuleSet},
73    workspace::CapturedBasis,
74};
75
76// ---------------------------------------------------------------------------
77// StudySetup
78// ---------------------------------------------------------------------------
79
80/// All precomputed study state built once before training and simulation.
81///
82/// Constructed by [`StudySetup::new`] from a validated [`System`] and
83/// [`cobre_io::Config`]. Owns all data so it can be held across async
84/// boundaries (e.g., Python GIL release) without lifetime issues.
85///
86/// Callers build `TrainingContext` and `StageContext` by borrowing
87/// from `StudySetup`.
88#[derive(Debug)]
89pub struct StudySetup {
90    /// Stage-indexed data: LP templates, indexer, stages, entity counts, blocks,
91    /// lag transitions, noise groups, and scaling report.
92    pub stage_data: stage_data::StageData,
93
94    /// Stochastic context holding sampling distributions, libraries, and provenance.
95    pub stochastic: StochasticContext,
96    /// Future cost function (cut pool) updated by the backward pass during training.
97    pub fcf: FutureCostFunction,
98    pub(crate) initial_state: Vec<f64>,
99
100    /// Pre-computed hydro production models (FPHA, turbine curves, etc.).
101    pub hydro_models: PrepareHydroModelsResult,
102
103    pub(crate) ncs_entity_ids_per_stage: Vec<Vec<i32>>,
104    /// Max generation \[MW\] per stochastic NCS entity, sorted by entity ID.
105    pub(crate) ncs_max_gen: Vec<f64>,
106    /// Whether each stochastic NCS entity may be curtailed, sorted by entity
107    /// ID. Aligned 1:1 with [`Self::ncs_max_gen`]. `true` (default) =
108    /// curtailable (LP can dispatch in `[0, max × α × factor]`); `false` =
109    /// must-run (`col_lower = col_upper = max × α × factor` for every
110    /// scenario; non-simulated aggregate generation pre-netted from load).
111    pub(crate) ncs_allow_curtailment: Vec<bool>,
112
113    /// Sampling schemes and pre-built libraries for training and simulation phases.
114    ///
115    /// Replaces the 14 flat `inflow_scheme` / `sim_inflow_scheme` / … fields.
116    /// Access via `scenario_libraries.training.<field>` or
117    /// `scenario_libraries.simulation.<field>`.
118    pub scenario_libraries: ScenarioLibraries,
119
120    /// Iteration-loop parameters projected from [`crate::config::LoopConfig`].
121    ///
122    /// Holds the five pure-data fields of [`crate::config::LoopConfig`] that are stable
123    /// across training invocations. `n_fwd_threads` is excluded (derived
124    /// at runtime) and supplied as a per-call argument to [`StudySetup::train`].
125    pub loop_params: crate::config::LoopParams,
126
127    /// Simulation pipeline parameters, stored directly as [`crate::simulation::SimulationConfig`].
128    pub simulation_config: crate::simulation::SimulationConfig,
129
130    /// Relative path to the policy output directory (e.g. `"training/policy"`).
131    pub policy_path: String,
132
133    /// Two-stage cut management pipeline configuration.
134    ///
135    /// Holds cut selection, budget cap, activity tolerance, and per-stage risk
136    /// measures. Replaces the former flat fields (`cut_selection`,
137    /// `cut_activity_tolerance`, `budget`, `risk_measures`).
138    pub(crate) cut_management: CutManagementConfig,
139
140    /// Pure-data event parameters (output-side flags).
141    ///
142    /// Holds only the stable, serialisable event flags. Runtime handles
143    /// (`event_sender`, `shutdown_flag`) and deferred fields
144    /// (`checkpoint_interval`) are excluded and supplied per-call in
145    /// [`StudySetup::train`].
146    pub(crate) events: EventParams,
147
148    /// Stochastic numerical methodology parameters.
149    ///
150    /// Groups `horizon` and `inflow_method`, which govern study horizon
151    /// treatment and inflow non-negativity enforcement respectively.
152    pub(crate) methodology: methodology_config::MethodologyConfig,
153
154    /// Pre-computed lag accumulator seed from `initial_conditions.recent_observations`.
155    ///
156    /// Computed once at setup time by
157    /// [`crate::lag_transition::compute_recent_observation_seed`] from the parsed
158    /// `RecentObservation` entries. Applied at every trajectory start in the forward
159    /// pass and simulation pipeline instead of zero-filling the accumulator.
160    ///
161    /// When `recent_observations` is empty, this is an all-zero seed and the
162    /// behavior is identical to the previous zero-reset.
163    pub(crate) recent_observation_seed: crate::lag_transition::RecentObservationSeed,
164
165    /// PAR order of the downstream (coarser) resolution model.
166    ///
167    /// Non-zero only when the study includes stages with `season_id >= 12` (quarterly
168    /// range), indicating a monthly-to-quarterly resolution transition. Set at setup
169    /// time and passed to `WorkspaceSizing` so that downstream scratch buffers are
170    /// allocated at the correct capacity. Zero for uniform-resolution studies.
171    pub(crate) downstream_par_order: usize,
172
173    /// Pre-computed energy-conversion scalars for every `(hydro, stage)` pair.
174    ///
175    /// Holds `ρ_eq` (equivalent productivity), `V_ref`, `Q_ref`, and `ρ_acum`
176    /// (accumulated cascade productivity). Built once at setup time from the
177    /// system's hydros, cascade topology, and reference-volume resolver.
178    /// Consumed by the energy-balance LP constraints and inflow-energy / stored-energy extraction.
179    pub(crate) energy_conversion: EnergyConversionSet,
180
181    /// `V_min` (`min_storage_hm3`) per hydro, in declaration order.
182    ///
183    /// Pre-computed once at setup time and threaded into the simulation
184    /// pipeline for stored-energy calculations.
185    pub(crate) hydro_min_storage_hm3: Vec<f64>,
186
187    /// Per-stage warm-start basis cache for warm-start / resume training.
188    ///
189    /// Populated by the CLI / Python warm-start and resume paths via
190    /// [`StudySetup::set_warm_start_basis_cache`] from the checkpoint's stored
191    /// solver bases (see
192    /// [`build_basis_cache_from_checkpoint`](crate::build_basis_cache_from_checkpoint)).
193    /// [`StudySetup::train`] takes this out of `self` and seeds the
194    /// [`TrainingSession`](crate::TrainingSession)'s
195    /// [`BasisStore`](crate::workspace::BasisStore) so iteration 1's
196    /// (cut-loaded) LPs warm-start instead of cold-start.
197    ///
198    /// `None` for a fresh start, leaving fresh-mode behavior untouched.
199    pub(crate) warm_start_basis_cache: Option<Vec<Option<CapturedBasis>>>,
200
201    /// Throwaway, env-gated backward `noise_key` diagnostic table.
202    ///
203    /// `Some` only when `COBRE_W1_DIAG` was set at setup; `None` otherwise (the
204    /// default), in which case nothing is allocated or computed and the backward
205    /// solve path is byte-identical. Borrowed read-only into the training
206    /// [`TrainingContext`](crate::context::TrainingContext). See
207    /// [`crate::noise_key_diag`].
208    pub(crate) noise_key_diag: Option<crate::noise_key_diag::NoiseKeyDiag>,
209}
210
211impl StudySetup {
212    /// Build all precomputed study state from a validated system and config.
213    ///
214    /// The constructor performs:
215    /// 1. Config field extraction (seed, forward passes, stopping rules, etc.)
216    /// 2. Delegates to [`StudySetup::from_broadcast_params`] with the extracted values.
217    ///
218    /// # Errors
219    ///
220    /// - [`SddpError::Validation`] — if `build_stage_templates` succeeds but
221    ///   the template list is empty ("system has no study stages").
222    /// - [`SddpError::Solver`] — propagated from `build_stage_templates`
223    ///   on LP construction failure.
224    /// - [`SddpError::Validation`] — if `parse_cut_selection_config` returns
225    ///   an invalid config string.
226    pub fn new(
227        system: &System,
228        config: &cobre_io::Config,
229        stochastic: StochasticContext,
230        hydro_models: PrepareHydroModelsResult,
231    ) -> Result<Self, SddpError> {
232        let params = StudyParams::from_config(config)?;
233        // Use a sentinel path; training_scenario_source / simulation_scenario_source
234        // only use the path for error messages and the historical-years look-up,
235        // which is not exercised when the caller provides a validated Config.
236        let sentinel_path = Path::new("config.json");
237        let training_source = config
238            .training_scenario_source(sentinel_path)
239            .map_err(|e| SddpError::Validation(e.to_string()))?;
240        let simulation_source = config
241            .simulation_scenario_source(sentinel_path)
242            .map_err(|e| SddpError::Validation(e.to_string()))?;
243        let config = params.into_construction_config();
244        Self::from_broadcast_params(
245            system,
246            stochastic,
247            config,
248            hydro_models,
249            &training_source,
250            &simulation_source,
251        )
252    }
253
254    /// Build all precomputed study state from pre-resolved broadcast parameters.
255    ///
256    /// This constructor accepts the scalar fields already extracted from either a
257    /// [`cobre_io::Config`] (on rank 0) or a broadcast config struct (on non-root
258    /// ranks). It performs the expensive computation steps that cannot be serialised:
259    ///
260    /// 1. `build_stage_templates` — constructs LP skeletons for each stage
261    /// 2. `StageIndexer::with_equipment` — computes LP column/row offsets
262    /// 3. `build_initial_state` — extracts initial storage and past inflows from system IC
263    /// 4. `max_iterations_from_rules` — sizes the FCF cut pool
264    /// 5. `FutureCostFunction::new` — pre-allocates cut storage
265    /// 6. `HorizonMode::Finite` — wraps stage count
266    /// 7. Risk measures from stage configs
267    /// 8. `build_entity_counts` — entity ID and productivity vectors
268    /// 9. Block layout derivation (`block_counts_per_stage`, `max_blocks`)
269    ///
270    /// # Errors
271    ///
272    /// - [`SddpError::Validation`] — if `build_stage_templates` succeeds but
273    ///   the template list is empty ("system has no study stages").
274    /// - [`SddpError::Solver`] — propagated from `build_stage_templates` on LP
275    ///   construction failure.
276    // Cohesive sub-phases that draw on disjoint inputs are extracted into
277    // `build_wired_indexer`, `precompute_lag_data`, and
278    // `build_scenario_libraries`; the remaining body wires the `StudySetup`
279    // fields together in initialization order.
280    pub fn from_broadcast_params(
281        system: &System,
282        mut stochastic: StochasticContext,
283        config: ConstructionConfig,
284        hydro_models: PrepareHydroModelsResult,
285        training_source: &ScenarioSource,
286        simulation_source: &ScenarioSource,
287    ) -> Result<Self, SddpError> {
288        let ConstructionConfig {
289            seed,
290            forward_passes,
291            stopping_rule_set,
292            n_scenarios,
293            io_channel_capacity,
294            policy_path,
295            inflow_method,
296            cut_selection,
297            cut_activity_tolerance,
298            budget,
299            export_states,
300            scalar_parameters,
301        } = config;
302
303        // Install the per-stage backward opening solve order once, here, where the
304        // inflow models (σ = std_m3s) and the synced opening tree (raw_noise) are
305        // both in scope. The keys are the SAME σ-weighted noise keys the throwaway
306        // `noise_key` diagnostic records (shared via `build_noise_key_table`), so
307        // the solve order and the diagnostic that validates it cannot drift. The
308        // order is run-constant and rank-invariant (a pure function of the synced
309        // tree + fixed σ), so every rank computes the identical permutation. The
310        // backward pass always solves in this order (descending: largest-noise-key
311        // opening first) for warm-start friendliness; the canonical-ω cut
312        // aggregation is unaffected, so cuts stay bit-identical across thread/rank
313        // counts. The σ-vs-noise-dim length mismatch is a hard error (raised inside
314        // `build_noise_key_table` via `noise_key`).
315        let solve_order_keys = crate::noise_key_diag::build_noise_key_table(system, &stochastic)?;
316        stochastic
317            .set_solve_order(&solve_order_keys, SweepDirection::Descending)
318            .map_err(|e| SddpError::Validation(e.to_string()))?;
319
320        let EnergyAndTemplates {
321            energy_conversion,
322            stage_templates,
323            scaling_report,
324        } = build_energy_and_templates(
325            system,
326            inflow_method,
327            &stochastic,
328            &hydro_models,
329            &scalar_parameters,
330        )?;
331
332        let stage_templates_ref = &stage_templates.templates;
333
334        let indexer = build_wired_indexer(
335            system,
336            &stage_templates,
337            inflow_method,
338            &hydro_models,
339            &stochastic,
340        );
341
342        let initial_state = build_initial_state(system, &indexer);
343
344        let n_stages = stage_templates_ref.len();
345        let max_iterations = max_iterations_from_rules(&stopping_rule_set);
346        let fcf_capacity_iterations = max_iterations.saturating_add(1);
347        let fcf = FutureCostFunction::new(
348            n_stages,
349            indexer.n_state,
350            forward_passes,
351            fcf_capacity_iterations,
352            &vec![0; n_stages],
353        );
354
355        let horizon = HorizonMode::Finite {
356            num_stages: n_stages,
357        };
358        // Defense-in-depth: enforce the horizon's structural invariants at
359        // construction. For finite horizon this rejects a degenerate
360        // single-stage problem (`num_stages < 2`), which has no predecessor to
361        // generate cuts for. Reachable because `n_stages` is the post-filter
362        // template count, which may be 1 even though the empty case is already
363        // rejected above.
364        horizon.validate()?;
365
366        let risk_measures: Vec<RiskMeasure> = system
367            .stages()
368            .iter()
369            .filter(|s| s.id >= 0)
370            .map(|s| RiskMeasure::from(s.risk_config))
371            .collect();
372
373        let NcsEntityData {
374            entity_counts,
375            ncs_entity_ids_per_stage,
376            ncs_max_gen,
377            ncs_allow_curtailment,
378        } = build_ncs_entity_data(system, &stage_templates, &stochastic)?;
379
380        let block_counts_per_stage: Vec<usize> = stage_templates
381            .block_hours_per_stage
382            .iter()
383            .map(Vec::len)
384            .collect();
385        let max_blocks = block_counts_per_stage.iter().copied().max().unwrap_or(0);
386
387        let stages: Vec<Stage> = system
388            .stages()
389            .iter()
390            .filter(|s| s.id >= 0)
391            .cloned()
392            .collect();
393
394        let LagData {
395            stage_lag_transitions,
396            noise_group_ids,
397            recent_observation_seed,
398            downstream_par_order,
399        } = precompute_lag_data(system, &stages, &stochastic);
400
401        let hydro_ids: Vec<EntityId> = system.hydros().iter().map(|h| h.id).collect();
402
403        let scenario_libraries = build_scenario_libraries(
404            system,
405            &stages,
406            &hydro_ids,
407            &stochastic,
408            &stage_lag_transitions,
409            training_source,
410            simulation_source,
411            forward_passes,
412        )?;
413
414        let hydro_min_storage_hm3: Vec<f64> =
415            system.hydros().iter().map(|h| h.min_storage_hm3).collect();
416
417        // Throwaway, env-gated backward diagnostic: built only when
418        // `COBRE_W1_DIAG` is set, reusing the `solve_order_keys` table already
419        // computed above (so the diagnostic and the solve order cannot drift and
420        // the table is not recomputed). `None` otherwise — zero allocation,
421        // byte-identical default path.
422        let noise_key_diag =
423            crate::noise_key_diag::NoiseKeyDiag::from_keys_if_enabled(&solve_order_keys);
424
425        Ok(Self {
426            stage_data: stage_data::StageData {
427                stage_templates,
428                indexer,
429                stages,
430                entity_counts,
431                block_counts_per_stage,
432                stage_lag_transitions,
433                noise_group_ids,
434                scaling_report,
435            },
436            stochastic,
437            fcf,
438            initial_state,
439            hydro_models,
440            ncs_entity_ids_per_stage,
441            ncs_max_gen,
442            ncs_allow_curtailment,
443            scenario_libraries,
444            loop_params: crate::config::LoopParams {
445                seed,
446                forward_passes,
447                max_iterations,
448                start_iteration: 0,
449                max_blocks,
450                stopping_rules: stopping_rule_set,
451            },
452            simulation_config: crate::simulation::SimulationConfig {
453                n_scenarios,
454                io_channel_capacity,
455            },
456            policy_path,
457            cut_management: CutManagementConfig {
458                cut_selection,
459                budget,
460                cut_activity_tolerance,
461                warm_start_cuts: 0,
462                risk_measures,
463            },
464            events: EventParams { export_states },
465            methodology: methodology_config::MethodologyConfig {
466                horizon,
467                inflow_method,
468            },
469            recent_observation_seed,
470            downstream_par_order,
471            energy_conversion,
472            hydro_min_storage_hm3,
473            warm_start_basis_cache: None,
474            noise_key_diag,
475        })
476    }
477}
478
479// ---------------------------------------------------------------------------
480// from_broadcast_params sub-phase helpers
481// ---------------------------------------------------------------------------
482
483/// Grouped output of [`build_ncs_entity_data`].
484struct NcsEntityData {
485    entity_counts: EntityCounts,
486    ncs_entity_ids_per_stage: Vec<Vec<i32>>,
487    ncs_max_gen: Vec<f64>,
488    ncs_allow_curtailment: Vec<bool>,
489}
490
491/// Build entity counts and the per-stochastic-NCS max-generation / curtailment
492/// vectors from the system and stage templates.
493///
494/// `ncs_entity_ids_per_stage` maps each active NCS column to its system entity
495/// id; `ncs_max_gen` and `ncs_allow_curtailment` are aligned 1:1 in stochastic
496/// NCS-entity order.
497///
498/// # Errors
499///
500/// Returns [`SddpError::Validation`] when a stochastic NCS entity has no match
501/// in the system's `non_controllable_sources`.
502fn build_ncs_entity_data(
503    system: &System,
504    stage_templates: &crate::lp_builder::StageTemplates,
505    stochastic: &StochasticContext,
506) -> Result<NcsEntityData, SddpError> {
507    let entity_counts = build_entity_counts(system);
508
509    let ncs_entity_ids_per_stage: Vec<Vec<i32>> = stage_templates
510        .active_ncs_indices
511        .iter()
512        .map(|stage_indices| {
513            stage_indices
514                .iter()
515                .map(|&sys_idx| entity_counts.non_controllable_ids[sys_idx])
516                .collect()
517        })
518        .collect();
519
520    let (ncs_max_gen, ncs_allow_curtailment): (Vec<f64>, Vec<bool>) = {
521        let stoch_ncs_ids = stochastic.ncs_entity_ids();
522        let mut max_v = Vec::with_capacity(stoch_ncs_ids.len());
523        let mut allow_v = Vec::with_capacity(stoch_ncs_ids.len());
524        for ncs_id in stoch_ncs_ids {
525            let ncs = system
526                .non_controllable_sources()
527                .iter()
528                .find(|n| n.id == *ncs_id)
529                .ok_or_else(|| {
530                    SddpError::Validation(format!(
531                        "stochastic NCS entity {ncs_id:?} not found in system non_controllable_sources"
532                    ))
533                })?;
534            max_v.push(ncs.max_generation_mw);
535            allow_v.push(ncs.allow_curtailment);
536        }
537        (max_v, allow_v)
538    };
539
540    Ok(NcsEntityData {
541        entity_counts,
542        ncs_entity_ids_per_stage,
543        ncs_max_gen,
544        ncs_allow_curtailment,
545    })
546}
547
548/// Grouped output of [`build_energy_and_templates`].
549struct EnergyAndTemplates {
550    energy_conversion: EnergyConversionSet,
551    stage_templates: crate::lp_builder::StageTemplates,
552    scaling_report: crate::scaling_report::ScalingReport,
553}
554
555/// Build the energy-conversion set, the resolved parameter table, and the
556/// post-processed stage LP templates.
557///
558/// The energy-conversion set and resolved parameter table are built before the
559/// LP templates so the builder can resolve `CoefficientRef::Parameter` values.
560/// The resolved parameter table is consumed only by `build_stage_templates`, so
561/// it is not returned. The stage-to-season mapping uses `season_id.unwrap_or(0)`
562/// so stages without a season collapse to season 0, consistent with every other
563/// season-indexed lookup.
564///
565/// # Errors
566///
567/// - [`SddpError::Validation`] — on energy-conversion / resolved-parameter
568///   construction failure, or when the post-processed template list is empty.
569/// - [`SddpError::Solver`] — propagated from `build_stage_templates`.
570fn build_energy_and_templates(
571    system: &System,
572    inflow_method: crate::InflowNonNegativityMethod,
573    stochastic: &StochasticContext,
574    hydro_models: &PrepareHydroModelsResult,
575    scalar_parameters: &[cobre_core::ScalarParameter],
576) -> Result<EnergyAndTemplates, SddpError> {
577    let n_stages_pre = system.stages().iter().filter(|s| s.id >= 0).count();
578    let stage_to_season: Vec<i32> = system
579        .stages()
580        .iter()
581        .filter(|s| s.id >= 0)
582        .map(|s| i32::try_from(s.season_id.unwrap_or(0)).unwrap_or(0))
583        .collect();
584    // The JSON-sourced reference operating volume, resolved to absolute hm³ per
585    // `(plant, study-stage)` against each plant's band by the hydro-model
586    // preprocessing pipeline. The energy-conversion build reads it as the single
587    // source of truth for `reference_volume_hm3`, identical to the source the FPHA
588    // backwater path uses, so the productivity reference and the backwater level
589    // never drift.
590    let reference_volume_fractions =
591        build_hydro_reference_volumes_resolved(&hydro_models.reference_volumes_hm3, 0.0);
592    let energy_conversion = build_energy_conversion_set(
593        system.hydros(),
594        n_stages_pre,
595        system.cascade(),
596        &reference_volume_fractions,
597        // VHA geometry feeds the FPHA ρ_eq derivation (VHA + ρ_esp) for plants with
598        // no parquet override; the override still wins when present. Carried on the
599        // per-rank `PrepareHydroModelsResult` (never broadcast), so every rank sees
600        // the same map.
601        &hydro_models.vha_geometry_by_hydro,
602        Some(&hydro_models.productivity_override),
603        Some(&hydro_models.production),
604    )
605    .map_err(|e| SddpError::Validation(e.to_string()))?;
606    let resolved_parameters = crate::resolved_parameters::build_resolved_parameters(
607        scalar_parameters,
608        &energy_conversion,
609        &hydro_models.productivity_override,
610        system.hydros(),
611        &stage_to_season,
612        n_stages_pre,
613    )
614    .map_err(|e| SddpError::Validation(e.to_string()))?;
615
616    let mut stage_templates = build_stage_templates(
617        system,
618        inflow_method,
619        stochastic.par(),
620        stochastic.normal(),
621        &hydro_models.production,
622        &hydro_models.evaporation,
623        &resolved_parameters,
624    )?;
625
626    let scaling_report = template_postprocess::postprocess_templates(&mut stage_templates, system);
627
628    if stage_templates.templates.is_empty() {
629        return Err(SddpError::Validation(
630            "system has no study stages".to_string(),
631        ));
632    }
633
634    Ok(EnergyAndTemplates {
635        energy_conversion,
636        stage_templates,
637        scaling_report,
638    })
639}
640
641/// Build the fully-wired [`StageIndexer`] from the stage-0 LP layout.
642///
643/// Derives equipment counts, FPHA/evaporation column layouts, and the
644/// anticipated-thermal lead-stage map from the system and the (representative)
645/// stage-0 template, wires the NCS column range from the LP builder's stage-0
646/// layout, and sets the cut sparse-mask non-zero pattern from the PAR effective
647/// lag counts. All inputs are read-only; the returned indexer owns its layout.
648fn build_wired_indexer(
649    system: &System,
650    stage_templates: &crate::lp_builder::StageTemplates,
651    inflow_method: crate::InflowNonNegativityMethod,
652    hydro_models: &PrepareHydroModelsResult,
653    stochastic: &StochasticContext,
654) -> StageIndexer {
655    let stage_templates_ref = &stage_templates.templates;
656    let n_blks_stage0 = system.stages().first().map_or(1, |s| s.blocks.len().max(1));
657    let has_inflow_penalty =
658        inflow_method.has_slack_columns() && stage_templates_ref[0].n_hydro > 0;
659
660    // Compute FPHA and evaporation hydro indices at stage 0 (representative).
661    let n_hydros = system.hydros().len();
662    let mut fpha_hydro_indices: Vec<usize> = Vec::new();
663    let mut fpha_planes: Vec<usize> = Vec::new();
664    let mut evap_hydro_indices: Vec<usize> = Vec::new();
665    for h_idx in 0..n_hydros {
666        if let ResolvedProductionModel::Fpha { planes, .. } =
667            hydro_models.production.model(h_idx, 0)
668        {
669            fpha_hydro_indices.push(h_idx);
670            fpha_planes.push(planes.len());
671        }
672        if matches!(
673            hydro_models.evaporation.model(h_idx),
674            EvaporationModel::Linearized { .. }
675        ) {
676            evap_hydro_indices.push(h_idx);
677        }
678    }
679
680    let max_deficit_segments = system
681        .buses()
682        .iter()
683        .map(|b| b.deficit_segments.len())
684        .max()
685        .unwrap_or(0);
686
687    let mut anticipated_thermal_indices: Vec<usize> = Vec::new();
688    let mut anticipated_lead_stages: Vec<usize> = Vec::new();
689    for (t_idx, thermal) in system.thermals().iter().enumerate() {
690        if let Some(cfg) = thermal.anticipated_config.as_ref() {
691            anticipated_thermal_indices.push(t_idx);
692            anticipated_lead_stages.push(usize::try_from(cfg.lead_stages).unwrap_or(usize::MAX));
693        }
694    }
695    let n_anticipated = anticipated_thermal_indices.len();
696    let k_max: usize = anticipated_lead_stages.iter().copied().max().unwrap_or(0);
697    let eq_counts = crate::indexer::EquipmentCounts {
698        hydro_count: stage_templates_ref[0].n_hydro,
699        max_par_order: stage_templates_ref[0].max_par_order,
700        n_thermals: system.thermals().len(),
701        n_lines: system.lines().len(),
702        n_buses: system.buses().len(),
703        n_blks: n_blks_stage0,
704        has_inflow_penalty,
705        max_deficit_segments,
706        n_anticipated,
707        k_max,
708        anticipated_lead_stages,
709        anticipated_thermal_indices,
710    };
711    let fpha_cfg = crate::indexer::FphaColumnLayout {
712        hydro_indices: fpha_hydro_indices,
713        planes_per_hydro: fpha_planes,
714    };
715    let evap_cfg = crate::indexer::EvapConfig {
716        hydro_indices: evap_hydro_indices,
717    };
718    let mut indexer =
719        StageIndexer::with_equipment_and_evaporation(&eq_counts, &fpha_cfg, &evap_cfg);
720
721    // Wire NCS column range from the LP builder's stage-0 layout.
722    if !stage_templates.ncs_col_starts.is_empty() {
723        let ncs_start = stage_templates.ncs_col_starts[0];
724        let n_ncs_stage0 = stage_templates.n_ncs_per_stage[0];
725        indexer.ncs_generation = ncs_start..(ncs_start + n_ncs_stage0 * n_blks_stage0);
726
727        for (s, &start) in stage_templates.ncs_col_starts.iter().enumerate() {
728            debug_assert_eq!(
729                start, ncs_start,
730                "NCS column start differs at stage {s}: expected {ncs_start}, got {start}"
731            );
732        }
733    }
734
735    // z-inflow column and row ranges are set by StageIndexer::new at
736    // fixed offset N*(1+L), no per-stage wiring needed.
737
738    // Build the per-hydro lag-state-slot count for the cut sparse mask.
739    // When PAR(p)-A annual is active on a hydro, this is `max_par_order`
740    // (the widened psi stride); otherwise it is the classical AR order.
741    // Using `par.order(h)` here would silently truncate the cut row's state
742    // coefficients on lag slots that carry the annual `ψ̂/12` term and
743    // produce over-estimating cuts (analogue of d0e4a42).
744    // Populate the cut sparse mask unconditionally so every production study —
745    // including storage-only (mask = [0, n_state) ascending) and pure-thermal
746    // (n_state == 0 → empty mask) — has a mask. The forward-pass cut-row loop is
747    // single-path (mask-driven); it relies on the mask always being populated.
748    {
749        let par = stochastic.par();
750        let effective_lag_counts: Vec<usize> = if indexer.max_par_order > 0 {
751            (0..par.n_hydros())
752                .map(|h| par.effective_lag_count(h))
753                .collect()
754        } else {
755            vec![0; indexer.hydro_count]
756        };
757        // Clone to release the immutable borrow on `indexer` before the
758        // mutable `set_nonzero_mask` call below.
759        let anticipated_k: Vec<usize> = indexer.anticipated_lead_stages.clone();
760        indexer.set_nonzero_mask(&effective_lag_counts, &anticipated_k);
761    }
762    // Precompute state_to_lp_column(j) from the now-final state layout, in the
763    // same place as the mask so both layout-derived caches stay in lockstep.
764    indexer.finalize_state_column_map();
765
766    indexer
767}
768
769/// Grouped output of [`precompute_lag_data`].
770struct LagData {
771    stage_lag_transitions: Vec<cobre_core::temporal::StageLagTransition>,
772    noise_group_ids: Vec<u32>,
773    recent_observation_seed: crate::lag_transition::RecentObservationSeed,
774    downstream_par_order: usize,
775}
776
777/// Precompute per-stage lag accumulation weights, noise-group ids, the
778/// recent-observation seed, and the downstream PAR order.
779///
780/// All four outputs derive from the study stages, the policy-graph season map,
781/// and the stochastic context's PAR model. When the system has no season map,
782/// a zero-weight no-op season map is used so every stage produces no-op
783/// transitions. When there are no recent observations the seed is all-zero
784/// (backward-compatible with a plain zero reset).
785fn precompute_lag_data(
786    system: &System,
787    stages: &[Stage],
788    stochastic: &StochasticContext,
789) -> LagData {
790    let noop_season_map;
791    let season_map_ref = if let Some(sm) = system.policy_graph().season_map.as_ref() {
792        sm
793    } else {
794        // No season map: all stages produce zero-weight no-op transitions.
795        noop_season_map = cobre_core::temporal::SeasonMap {
796            cycle_type: cobre_core::temporal::SeasonCycleType::Monthly,
797            seasons: Vec::new(),
798        };
799        &noop_season_map
800    };
801    // Compute downstream PAR order: non-zero when any stage has season_id >= 12
802    // (quarterly range), indicating a monthly-to-quarterly resolution transition.
803    // Use the global max_par_order from the stochastic context as a proxy for the
804    // quarterly PAR order until a separate quarterly stochastic context is available.
805    let has_quarterly_stages = stages
806        .iter()
807        .any(|s| s.season_id.is_some_and(|id| id >= 12));
808    let downstream_par_order = if has_quarterly_stages {
809        stochastic.par().max_order()
810    } else {
811        0
812    };
813    let stage_lag_transitions = crate::lag_transition::precompute_stage_lag_transitions(
814        stages,
815        season_map_ref,
816        downstream_par_order,
817    );
818    let noise_group_ids = crate::lag_transition::precompute_noise_groups(stages);
819
820    // Compute lag accumulator seed from recent_observations (if any).
821    // Uses the first study stage and the resolved season_map_ref. When there are
822    // no recent observations the result is an all-zero seed (backward-compatible).
823    let recent_observation_seed = if stages.is_empty() {
824        crate::lag_transition::RecentObservationSeed::zero(system.hydros().len())
825    } else {
826        crate::lag_transition::compute_recent_observation_seed(
827            &system.initial_conditions().recent_observations,
828            &stages[0],
829            season_map_ref,
830            system.hydros(),
831        )
832    };
833
834    LagData {
835        stage_lag_transitions,
836        noise_group_ids,
837        recent_observation_seed,
838        downstream_par_order,
839    }
840}
841
842/// Build the training and simulation [`ScenarioLibraries`].
843///
844/// Each phase's per-class library (`historical`, `external_inflow`,
845/// `external_load`, `external_ncs`) is constructed only when that class uses
846/// the matching sampling scheme. Simulation-specific libraries are built only
847/// when the simulation scheme differs from the training scheme; when identical,
848/// the simulation phase stores `None` and `simulation_ctx()` falls back to the
849/// training library references.
850///
851/// # Errors
852///
853/// Propagates [`SddpError`] from the individual library builders on validation
854/// or padding failure.
855// Rationale: eight disjoint read-only inputs drive one cohesive setup phase; a
856// bundle struct would only relocate the arity without improving clarity.
857#[allow(clippy::too_many_arguments)]
858fn build_scenario_libraries(
859    system: &System,
860    stages: &[Stage],
861    hydro_ids: &[EntityId],
862    stochastic: &StochasticContext,
863    stage_lag_transitions: &[cobre_core::temporal::StageLagTransition],
864    training_source: &ScenarioSource,
865    simulation_source: &ScenarioSource,
866    forward_passes: u32,
867) -> Result<ScenarioLibraries, SddpError> {
868    let inflow_scheme = training_source.inflow_scheme;
869    let load_scheme = training_source.load_scheme;
870    let ncs_scheme = training_source.ncs_scheme;
871    let sim_inflow_scheme = simulation_source.inflow_scheme;
872    let sim_load_scheme = simulation_source.load_scheme;
873    let sim_ncs_scheme = simulation_source.ncs_scheme;
874
875    // Build training phase libraries.
876    let training_historical: Option<HistoricalScenarioLibrary> =
877        if inflow_scheme == SamplingScheme::Historical {
878            Some(scenario_libraries::build_historical_inflow_library(
879                system.inflow_history(),
880                hydro_ids,
881                stages,
882                stochastic.par(),
883                system.policy_graph().season_map.as_ref(),
884                &system.initial_conditions().past_inflows,
885                stage_lag_transitions,
886                training_source.historical_years.as_ref(),
887                forward_passes,
888            )?)
889        } else {
890            None
891        };
892
893    let training_external_inflow: Option<ExternalScenarioLibrary> =
894        if inflow_scheme == SamplingScheme::External {
895            Some(scenario_libraries::build_external_inflow_library(
896                system.external_scenarios(),
897                hydro_ids,
898                stages,
899                stochastic.par(),
900                &system.initial_conditions().past_inflows,
901                stage_lag_transitions,
902                forward_passes,
903            )?)
904        } else {
905            None
906        };
907
908    let training_external_load: Option<ExternalScenarioLibrary> =
909        if load_scheme == SamplingScheme::External {
910            Some(scenario_libraries::build_external_load_library(
911                system.external_load_scenarios(),
912                system.load_models(),
913                stages,
914                forward_passes,
915            )?)
916        } else {
917            None
918        };
919
920    let training_external_ncs: Option<ExternalScenarioLibrary> =
921        if ncs_scheme == SamplingScheme::External {
922            Some(scenario_libraries::build_external_ncs_library(
923                system.external_ncs_scenarios(),
924                system.ncs_models(),
925                stages,
926                forward_passes,
927            )?)
928        } else {
929            None
930        };
931
932    // Build simulation-specific libraries when simulation schemes differ from
933    // training schemes. When they are identical, simulation borrows from the
934    // training libraries (represented as `None` in the simulation phase, with
935    // `simulation_ctx()` falling back to the training library references).
936
937    let simulation_historical: Option<HistoricalScenarioLibrary> =
938        if sim_inflow_scheme == SamplingScheme::Historical && sim_inflow_scheme != inflow_scheme {
939            Some(scenario_libraries::build_historical_inflow_library(
940                system.inflow_history(),
941                hydro_ids,
942                stages,
943                stochastic.par(),
944                system.policy_graph().season_map.as_ref(),
945                &system.initial_conditions().past_inflows,
946                stage_lag_transitions,
947                simulation_source.historical_years.as_ref(),
948                forward_passes,
949            )?)
950        } else {
951            None
952        };
953
954    let simulation_external_inflow: Option<ExternalScenarioLibrary> =
955        if sim_inflow_scheme == SamplingScheme::External && sim_inflow_scheme != inflow_scheme {
956            Some(scenario_libraries::build_external_inflow_library(
957                system.external_scenarios(),
958                hydro_ids,
959                stages,
960                stochastic.par(),
961                &system.initial_conditions().past_inflows,
962                stage_lag_transitions,
963                forward_passes,
964            )?)
965        } else {
966            None
967        };
968
969    let simulation_external_load: Option<ExternalScenarioLibrary> =
970        if sim_load_scheme == SamplingScheme::External && sim_load_scheme != load_scheme {
971            Some(scenario_libraries::build_external_load_library(
972                system.external_load_scenarios(),
973                system.load_models(),
974                stages,
975                forward_passes,
976            )?)
977        } else {
978            None
979        };
980
981    let simulation_external_ncs: Option<ExternalScenarioLibrary> =
982        if sim_ncs_scheme == SamplingScheme::External && sim_ncs_scheme != ncs_scheme {
983            Some(scenario_libraries::build_external_ncs_library(
984                system.external_ncs_scenarios(),
985                system.ncs_models(),
986                stages,
987                forward_passes,
988            )?)
989        } else {
990            None
991        };
992
993    Ok(ScenarioLibraries {
994        training: PhaseLibraries {
995            inflow_scheme,
996            load_scheme,
997            ncs_scheme,
998            historical: training_historical,
999            external_inflow: training_external_inflow,
1000            external_load: training_external_load,
1001            external_ncs: training_external_ncs,
1002        },
1003        simulation: PhaseLibraries {
1004            inflow_scheme: sim_inflow_scheme,
1005            load_scheme: sim_load_scheme,
1006            ncs_scheme: sim_ncs_scheme,
1007            historical: simulation_historical,
1008            external_inflow: simulation_external_inflow,
1009            external_load: simulation_external_load,
1010            external_ncs: simulation_external_ncs,
1011        },
1012    })
1013}
1014
1015/// Return the maximum iteration budget from the stopping rule set.
1016///
1017/// Used for FCF pre-sizing. If no iteration limit is present, returns
1018/// [`DEFAULT_MAX_ITERATIONS`].
1019fn max_iterations_from_rules(rules: &StoppingRuleSet) -> u64 {
1020    rules
1021        .rules
1022        .iter()
1023        .filter_map(|r| {
1024            if let StoppingRule::IterationLimit { limit } = r {
1025                Some(*limit)
1026            } else {
1027                None
1028            }
1029        })
1030        .max()
1031        .unwrap_or(DEFAULT_MAX_ITERATIONS)
1032}
1033
1034/// Build [`EntityCounts`] from the loaded system.
1035///
1036/// Entity IDs are extracted from [`cobre_core::EntityId`], which stores
1037/// an `i32` in its inner field.
1038fn build_entity_counts(system: &System) -> EntityCounts {
1039    EntityCounts {
1040        hydro_ids: system.hydros().iter().map(|h| h.id.0).collect(),
1041        hydro_productivities: vec![0.0; system.hydros().len()],
1042        thermal_ids: system.thermals().iter().map(|t| t.id.0).collect(),
1043        line_ids: system.lines().iter().map(|l| l.id.0).collect(),
1044        bus_ids: system.buses().iter().map(|b| b.id.0).collect(),
1045        pumping_station_ids: system.pumping_stations().iter().map(|p| p.id.0).collect(),
1046        contract_ids: system.contracts().iter().map(|c| c.id.0).collect(),
1047        non_controllable_ids: system
1048            .non_controllable_sources()
1049            .iter()
1050            .map(|n| n.id.0)
1051            .collect(),
1052    }
1053}
1054
1055/// Build the initial state vector from the system's initial conditions.
1056///
1057/// The state vector layout is `[storage(0..N), lags(N..N*(1+L))]` where N is
1058/// the number of hydros and L is the maximum PAR order. Storage positions
1059/// correspond to hydros in canonical ID order.
1060///
1061/// Lag slots are populated from `initial_conditions.past_inflows`. For each
1062/// hydro at positional index `idx` with a `past_inflows` entry, lag slot `l`
1063/// (0-based) is set to `entry.values_m3s[l]` where index 0 corresponds to
1064/// lag 1 (most recent) and index L-1 to lag L (oldest). Hydros without a
1065/// `past_inflows` entry have their lag slots left at `0.0`.
1066///
1067/// When `max_par_order == 0`, no lag slots exist and the state is storage-only.
1068///
1069/// Each `HydroStorage` entry in `initial_conditions.storage` is matched to
1070/// its positional index among the system's hydros (both sorted by `hydro_id`).
1071fn build_initial_state(system: &System, indexer: &StageIndexer) -> Vec<f64> {
1072    let mut state = vec![0.0_f64; indexer.n_state];
1073    let hydros = system.hydros();
1074    let ic = system.initial_conditions();
1075
1076    for hs in &ic.storage {
1077        // Both hydros() and ic.storage are sorted by hydro_id.
1078        if let Ok(idx) = hydros.binary_search_by_key(&hs.hydro_id.0, |h| h.id.0) {
1079            state[idx] = hs.value_hm3;
1080        }
1081    }
1082
1083    if indexer.max_par_order > 0 {
1084        let n_h = indexer.hydro_count;
1085        for pi in &ic.past_inflows {
1086            if let Ok(idx) = hydros.binary_search_by_key(&pi.hydro_id.0, |h| h.id.0) {
1087                let n_lags = pi.values_m3s.len().min(indexer.max_par_order);
1088                for lag in 0..n_lags {
1089                    let slot = indexer.inflow_lags.start + lag * n_h + idx;
1090                    state[slot] = pi.values_m3s[lag];
1091                }
1092            }
1093        }
1094    }
1095
1096    // Seed anticipated-state ring buffer from `past_anticipated_commitments`.
1097    // Each entry carries K_i values decided before study start, delivered at
1098    // stages 1..=K_i. Layout: state[anticipated_state.start + slot * n_anticipated + local_idx].
1099    //
1100    // Pre-horizon seeding is enabled: slot 0 may hold a non-zero seed at stage 0.
1101    // Padding slots [K_i, k_max) must stay zero (enforced by clamping to K_i
1102    // and debug_assert). The ring-buffer logic in noise.rs and indexer.rs
1103    // assumes padding slots are zero.
1104    if indexer.n_anticipated > 0 && indexer.k_max > 0 {
1105        debug_assert_eq!(
1106            indexer.anticipated_thermal_indices.len(),
1107            indexer.n_anticipated,
1108            "anticipated_thermal_indices length must equal n_anticipated",
1109        );
1110        let thermals = system.thermals();
1111        let n_ant = indexer.n_anticipated;
1112        let ant_start = indexer.anticipated_state.start;
1113        for history in &ic.past_anticipated_commitments {
1114            // Resolve global thermal index via binary search on EntityId.
1115            // Both system.thermals() and past_anticipated_commitments are
1116            // sorted by thermal_id ascending (sorted during system construction).
1117            let Ok(global_idx) = thermals.binary_search_by_key(&history.thermal_id.0, |t| t.id.0)
1118            else {
1119                // Unknown thermal ID — silently skip.  The cobre-io validator
1120                // rejects this in production, but defense-in-depth matches the
1121                // existing `past_inflows` behavior.
1122                continue;
1123            };
1124            // Find the anticipated-local index by linear search.
1125            // n_anticipated is small (typical range 1–50), so O(n) is fine.
1126            let Some(local_idx) = indexer
1127                .anticipated_thermal_indices
1128                .iter()
1129                .position(|&g| g == global_idx)
1130            else {
1131                // Thermal exists in the system but has anticipated_config: None.
1132                // Silently skip — not an anticipated plant.
1133                continue;
1134            };
1135            // K_i is the per-plant lead time; clamp to K_i (not k_max) to
1136            // prevent over-long input from corrupting padding slots.
1137            // cobre-io validator enforces values_mw.len() == K_i in production.
1138            let k_i = indexer.anticipated_lead_stages[local_idx];
1139            let n_slots = history.values_mw.len().min(k_i);
1140            for slot in 0..n_slots {
1141                let off = ant_start + slot * n_ant + local_idx;
1142                state[off] = history.values_mw[slot];
1143            }
1144            // Padding-slot invariant: [K_i, k_max) must remain 0.0 to prevent
1145            // ring-buffer corruption and LP infeasibility.
1146            #[allow(clippy::float_cmp)]
1147            for slot in k_i..indexer.k_max {
1148                let off = ant_start + slot * n_ant + local_idx;
1149                debug_assert_eq!(
1150                    state[off], 0.0,
1151                    "padding slot must be zero: plant local_idx={local_idx}, slot={slot}, K_i={k_i}, k_max={}",
1152                    indexer.k_max
1153                );
1154            }
1155        }
1156    }
1157
1158    state
1159}
1160
1161// ---------------------------------------------------------------------------
1162// Tests
1163// ---------------------------------------------------------------------------
1164
1165#[cfg(test)]
1166mod tests {
1167    use super::StudySetup;
1168    use crate::hydro_models::{
1169        PrepareHydroModelsResult, ProductionModelSet, ResolvedProductionModel,
1170    };
1171    use crate::indexer::StageIndexer;
1172
1173    use cobre_core::{
1174        BoundsCountsSpec, BoundsDefaults, BusStagePenalties, ContractStageBounds, HydroStageBounds,
1175        HydroStagePenalties, LineStageBounds, LineStagePenalties, NcsStagePenalties,
1176        PenaltiesCountsSpec, PenaltiesDefaults, PumpingStageBounds, ResolvedBounds,
1177        ResolvedPenalties, ThermalStageBounds,
1178    };
1179    use cobre_core::{
1180        EntityId, SystemBuilder,
1181        entities::{
1182            bus::{Bus, DeficitSegment},
1183            hydro::{Hydro, HydroGenerationModel, HydroPenalties},
1184            thermal::{AnticipatedConfig, Thermal},
1185        },
1186        scenario::{InflowModel, LoadModel, SamplingScheme},
1187        temporal::{
1188            Block, BlockMode, NoiseMethod, ScenarioSourceConfig, Stage, StageRiskConfig,
1189            StageStateConfig,
1190        },
1191    };
1192    use cobre_io::config::{
1193        Config, EstimationConfig, ExportsConfig, InflowNonNegativityConfig,
1194        InflowNonNegativityMethod as CfgInflowMethod, ModelingConfig, PolicyConfig,
1195        RawClassConfigEntry, RawScenarioSourceConfig, RowSelectionConfig,
1196        SimulationConfig as IoSimulationConfig, StoppingRuleConfig, TrainingConfig,
1197        TrainingSolverConfig, UpperBoundEvaluationConfig,
1198    };
1199    use cobre_stochastic::{ClassSchemes, OpeningTreeInputs, build_stochastic_context};
1200
1201    /// Build a minimal system with 1 bus, 1 thermal, 1 hydro, and `n_stages`
1202    /// study stages (each with 1 block). All bounds and penalties are set to
1203    /// sensible non-zero defaults so `build_stage_templates` succeeds.
1204    #[allow(
1205        clippy::too_many_lines,
1206        clippy::cast_possible_truncation,
1207        clippy::cast_possible_wrap,
1208        clippy::items_after_statements
1209    )]
1210    fn minimal_system(n_stages: usize) -> cobre_core::System {
1211        use chrono::NaiveDate;
1212
1213        let bus = Bus {
1214            id: EntityId(1),
1215            name: "B1".to_string(),
1216            deficit_segments: vec![DeficitSegment {
1217                depth_mw: None,
1218                cost_per_mwh: 500.0,
1219            }],
1220            excess_cost: 0.0,
1221        };
1222
1223        let thermal = Thermal {
1224            id: EntityId(2),
1225            name: "T1".to_string(),
1226            bus_id: EntityId(1),
1227            min_generation_mw: 0.0,
1228            max_generation_mw: 100.0,
1229            cost_per_mwh: 50.0,
1230            anticipated_config: None,
1231            entry_stage_id: None,
1232            exit_stage_id: None,
1233        };
1234
1235        let hydro = Hydro {
1236            id: EntityId(3),
1237            name: "H1".to_string(),
1238            bus_id: EntityId(1),
1239            downstream_id: None,
1240            entry_stage_id: None,
1241            exit_stage_id: None,
1242            min_storage_hm3: 0.0,
1243            max_storage_hm3: 200.0,
1244            min_outflow_m3s: 0.0,
1245            max_outflow_m3s: None,
1246            generation_model: HydroGenerationModel::ConstantProductivity,
1247            min_turbined_m3s: 0.0,
1248            max_turbined_m3s: 100.0,
1249            specific_productivity_mw_per_m3s_per_m: None,
1250            min_generation_mw: 0.0,
1251            max_generation_mw: 250.0,
1252            tailrace: None,
1253            hydraulic_losses: None,
1254            efficiency: None,
1255            evaporation_coefficients_mm: None,
1256            evaporation_reference_volumes_hm3: None,
1257            diversion: None,
1258            filling: None,
1259            penalties: HydroPenalties {
1260                spillage_cost: 0.01,
1261                diversion_cost: 0.0,
1262                turbined_cost: 0.0,
1263                storage_violation_below_cost: 0.0,
1264                filling_target_violation_cost: 0.0,
1265                turbined_violation_below_cost: 0.0,
1266                outflow_violation_below_cost: 0.0,
1267                outflow_violation_above_cost: 0.0,
1268                generation_violation_below_cost: 0.0,
1269                evaporation_violation_cost: 0.0,
1270                water_withdrawal_violation_cost: 0.0,
1271                water_withdrawal_violation_pos_cost: 0.0,
1272                water_withdrawal_violation_neg_cost: 0.0,
1273                evaporation_violation_pos_cost: 0.0,
1274                evaporation_violation_neg_cost: 0.0,
1275                inflow_nonnegativity_cost: 1000.0,
1276            },
1277        };
1278
1279        let stages: Vec<Stage> = (0..n_stages)
1280            .map(|i| Stage {
1281                index: i,
1282                id: i as i32,
1283                start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1284                end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
1285                season_id: None,
1286                blocks: vec![Block {
1287                    index: 0,
1288                    name: "S".to_string(),
1289                    duration_hours: 744.0,
1290                }],
1291                block_mode: BlockMode::Parallel,
1292                state_config: StageStateConfig {
1293                    storage: true,
1294                    inflow_lags: false,
1295                },
1296                risk_config: StageRiskConfig::Expectation,
1297                scenario_config: ScenarioSourceConfig {
1298                    branching_factor: 1,
1299                    noise_method: NoiseMethod::Saa,
1300                },
1301            })
1302            .collect();
1303
1304        let inflow_models: Vec<InflowModel> = (0..n_stages)
1305            .map(|i| InflowModel {
1306                hydro_id: EntityId(3),
1307                stage_id: i as i32,
1308                mean_m3s: 80.0,
1309                std_m3s: 20.0,
1310                ar_coefficients: vec![],
1311                residual_std_ratio: 1.0,
1312                annual: None,
1313            })
1314            .collect();
1315
1316        let load_models: Vec<LoadModel> = (0..n_stages)
1317            .map(|i| LoadModel {
1318                bus_id: EntityId(1),
1319                stage_id: i as i32,
1320                mean_mw: 100.0,
1321                std_mw: 0.0,
1322            })
1323            .collect();
1324
1325        let n_st = n_stages.max(1);
1326
1327        fn default_hydro_bounds() -> HydroStageBounds {
1328            HydroStageBounds {
1329                min_storage_hm3: 0.0,
1330                max_storage_hm3: 200.0,
1331                min_turbined_m3s: 0.0,
1332                max_turbined_m3s: 100.0,
1333                min_outflow_m3s: 0.0,
1334                max_outflow_m3s: None,
1335                min_generation_mw: 0.0,
1336                max_generation_mw: 250.0,
1337                max_diversion_m3s: None,
1338                filling_inflow_m3s: 0.0,
1339                water_withdrawal_m3s: 0.0,
1340            }
1341        }
1342
1343        fn default_hydro_penalties() -> HydroStagePenalties {
1344            HydroStagePenalties {
1345                spillage_cost: 0.01,
1346                diversion_cost: 0.0,
1347                turbined_cost: 0.0,
1348                storage_violation_below_cost: 500.0,
1349                filling_target_violation_cost: 0.0,
1350                turbined_violation_below_cost: 0.0,
1351                outflow_violation_below_cost: 0.0,
1352                outflow_violation_above_cost: 0.0,
1353                generation_violation_below_cost: 0.0,
1354                evaporation_violation_cost: 0.0,
1355                water_withdrawal_violation_cost: 0.0,
1356                water_withdrawal_violation_pos_cost: 0.0,
1357                water_withdrawal_violation_neg_cost: 0.0,
1358                evaporation_violation_pos_cost: 0.0,
1359                evaporation_violation_neg_cost: 0.0,
1360                inflow_nonnegativity_cost: 1000.0,
1361            }
1362        }
1363
1364        let bounds = ResolvedBounds::new(
1365            &BoundsCountsSpec {
1366                n_hydros: 1,
1367                n_thermals: // n_hydros
1368            1,
1369                n_lines: // n_thermals
1370            0,
1371                n_pumping: // n_lines
1372            0,
1373                n_contracts: // n_pumping
1374            0,
1375                n_stages: // n_contracts
1376            n_st,
1377                k_max: 0,
1378            },
1379            &BoundsDefaults {
1380                hydro: default_hydro_bounds(),
1381                thermal: ThermalStageBounds {
1382                    min_generation_mw: 0.0,
1383                    max_generation_mw: 100.0,
1384                    cost_per_mwh: 0.0,
1385                },
1386                line: LineStageBounds {
1387                    direct_mw: 0.0,
1388                    reverse_mw: 0.0,
1389                },
1390                pumping: PumpingStageBounds {
1391                    min_flow_m3s: 0.0,
1392                    max_flow_m3s: 0.0,
1393                },
1394                contract: ContractStageBounds {
1395                    min_mw: 0.0,
1396                    max_mw: 0.0,
1397                    price_per_mwh: 0.0,
1398                },
1399            },
1400        );
1401
1402        let penalties = ResolvedPenalties::new(
1403            &PenaltiesCountsSpec {
1404                n_hydros: 1,
1405                n_buses: // n_hydros
1406            1,
1407                n_lines: // n_buses
1408            0,
1409                n_ncs: // n_lines
1410            0,
1411                n_stages: // n_ncs
1412            n_st,
1413            },
1414            &PenaltiesDefaults {
1415                hydro: default_hydro_penalties(),
1416                bus: BusStagePenalties { excess_cost: 0.0 },
1417                line: LineStagePenalties { exchange_cost: 0.0 },
1418                ncs: NcsStagePenalties {
1419                    curtailment_cost: 0.0,
1420                },
1421            },
1422        );
1423
1424        SystemBuilder::new()
1425            .buses(vec![bus])
1426            .thermals(vec![thermal])
1427            .hydros(vec![hydro])
1428            .stages(stages)
1429            .inflow_models(inflow_models)
1430            .load_models(load_models)
1431            .bounds(bounds)
1432            .penalties(penalties)
1433            .build()
1434            .expect("minimal_system: valid")
1435    }
1436
1437    /// Variant of [`minimal_system`] whose single hydro is FPHA without any
1438    /// VHA rows or `specific_productivity_mw_per_m3s_per_m`, so the energy
1439    /// conversion gate must reject it.
1440    #[allow(
1441        clippy::too_many_lines,
1442        clippy::cast_possible_truncation,
1443        clippy::cast_possible_wrap,
1444        clippy::items_after_statements
1445    )]
1446    fn minimal_fpha_misconfigured_system(n_stages: usize) -> cobre_core::System {
1447        use chrono::NaiveDate;
1448
1449        let bus = Bus {
1450            id: EntityId(1),
1451            name: "B1".to_string(),
1452            deficit_segments: vec![DeficitSegment {
1453                depth_mw: None,
1454                cost_per_mwh: 500.0,
1455            }],
1456            excess_cost: 0.0,
1457        };
1458
1459        let thermal = Thermal {
1460            id: EntityId(2),
1461            name: "T1".to_string(),
1462            bus_id: EntityId(1),
1463            min_generation_mw: 0.0,
1464            max_generation_mw: 100.0,
1465            cost_per_mwh: 50.0,
1466            anticipated_config: None,
1467            entry_stage_id: None,
1468            exit_stage_id: None,
1469        };
1470
1471        let hydro = Hydro {
1472            id: EntityId(3),
1473            name: "H_FPHA_BAD".to_string(),
1474            bus_id: EntityId(1),
1475            downstream_id: None,
1476            entry_stage_id: None,
1477            exit_stage_id: None,
1478            min_storage_hm3: 0.0,
1479            max_storage_hm3: 200.0,
1480            min_outflow_m3s: 0.0,
1481            max_outflow_m3s: None,
1482            generation_model: HydroGenerationModel::Fpha,
1483            min_turbined_m3s: 0.0,
1484            max_turbined_m3s: 100.0,
1485            specific_productivity_mw_per_m3s_per_m: None,
1486            min_generation_mw: 0.0,
1487            max_generation_mw: 250.0,
1488            tailrace: None,
1489            hydraulic_losses: None,
1490            efficiency: None,
1491            evaporation_coefficients_mm: None,
1492            evaporation_reference_volumes_hm3: None,
1493            diversion: None,
1494            filling: None,
1495            penalties: HydroPenalties {
1496                spillage_cost: 0.01,
1497                diversion_cost: 0.0,
1498                turbined_cost: 0.0,
1499                storage_violation_below_cost: 0.0,
1500                filling_target_violation_cost: 0.0,
1501                turbined_violation_below_cost: 0.0,
1502                outflow_violation_below_cost: 0.0,
1503                outflow_violation_above_cost: 0.0,
1504                generation_violation_below_cost: 0.0,
1505                evaporation_violation_cost: 0.0,
1506                water_withdrawal_violation_cost: 0.0,
1507                water_withdrawal_violation_pos_cost: 0.0,
1508                water_withdrawal_violation_neg_cost: 0.0,
1509                evaporation_violation_pos_cost: 0.0,
1510                evaporation_violation_neg_cost: 0.0,
1511                inflow_nonnegativity_cost: 1000.0,
1512            },
1513        };
1514
1515        let stages: Vec<Stage> = (0..n_stages)
1516            .map(|i| Stage {
1517                index: i,
1518                id: i as i32,
1519                start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1520                end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
1521                season_id: None,
1522                blocks: vec![Block {
1523                    index: 0,
1524                    name: "S".to_string(),
1525                    duration_hours: 744.0,
1526                }],
1527                block_mode: BlockMode::Parallel,
1528                state_config: StageStateConfig {
1529                    storage: true,
1530                    inflow_lags: false,
1531                },
1532                risk_config: StageRiskConfig::Expectation,
1533                scenario_config: ScenarioSourceConfig {
1534                    branching_factor: 1,
1535                    noise_method: NoiseMethod::Saa,
1536                },
1537            })
1538            .collect();
1539
1540        let inflow_models: Vec<InflowModel> = (0..n_stages)
1541            .map(|i| InflowModel {
1542                hydro_id: EntityId(3),
1543                stage_id: i as i32,
1544                mean_m3s: 80.0,
1545                std_m3s: 20.0,
1546                ar_coefficients: vec![],
1547                residual_std_ratio: 1.0,
1548                annual: None,
1549            })
1550            .collect();
1551
1552        let load_models: Vec<LoadModel> = (0..n_stages)
1553            .map(|i| LoadModel {
1554                bus_id: EntityId(1),
1555                stage_id: i as i32,
1556                mean_mw: 100.0,
1557                std_mw: 0.0,
1558            })
1559            .collect();
1560
1561        let n_st = n_stages.max(1);
1562
1563        let bounds = ResolvedBounds::new(
1564            &BoundsCountsSpec {
1565                n_hydros: 1,
1566                n_thermals: 1,
1567                n_lines: 0,
1568                n_pumping: 0,
1569                n_contracts: 0,
1570                n_stages: n_st,
1571                k_max: 0,
1572            },
1573            &BoundsDefaults {
1574                hydro: HydroStageBounds {
1575                    min_storage_hm3: 0.0,
1576                    max_storage_hm3: 200.0,
1577                    min_turbined_m3s: 0.0,
1578                    max_turbined_m3s: 100.0,
1579                    min_outflow_m3s: 0.0,
1580                    max_outflow_m3s: None,
1581                    min_generation_mw: 0.0,
1582                    max_generation_mw: 250.0,
1583                    max_diversion_m3s: None,
1584                    filling_inflow_m3s: 0.0,
1585                    water_withdrawal_m3s: 0.0,
1586                },
1587                thermal: ThermalStageBounds {
1588                    min_generation_mw: 0.0,
1589                    max_generation_mw: 100.0,
1590                    cost_per_mwh: 0.0,
1591                },
1592                line: LineStageBounds {
1593                    direct_mw: 0.0,
1594                    reverse_mw: 0.0,
1595                },
1596                pumping: PumpingStageBounds {
1597                    min_flow_m3s: 0.0,
1598                    max_flow_m3s: 0.0,
1599                },
1600                contract: ContractStageBounds {
1601                    min_mw: 0.0,
1602                    max_mw: 0.0,
1603                    price_per_mwh: 0.0,
1604                },
1605            },
1606        );
1607
1608        let penalties = ResolvedPenalties::new(
1609            &PenaltiesCountsSpec {
1610                n_hydros: 1,
1611                n_buses: 1,
1612                n_lines: 0,
1613                n_ncs: 0,
1614                n_stages: n_st,
1615            },
1616            &PenaltiesDefaults {
1617                hydro: HydroStagePenalties {
1618                    spillage_cost: 0.01,
1619                    diversion_cost: 0.0,
1620                    turbined_cost: 0.0,
1621                    storage_violation_below_cost: 500.0,
1622                    filling_target_violation_cost: 0.0,
1623                    turbined_violation_below_cost: 0.0,
1624                    outflow_violation_below_cost: 0.0,
1625                    outflow_violation_above_cost: 0.0,
1626                    generation_violation_below_cost: 0.0,
1627                    evaporation_violation_cost: 0.0,
1628                    water_withdrawal_violation_cost: 0.0,
1629                    water_withdrawal_violation_pos_cost: 0.0,
1630                    water_withdrawal_violation_neg_cost: 0.0,
1631                    evaporation_violation_pos_cost: 0.0,
1632                    evaporation_violation_neg_cost: 0.0,
1633                    inflow_nonnegativity_cost: 1000.0,
1634                },
1635                bus: BusStagePenalties { excess_cost: 0.0 },
1636                line: LineStagePenalties { exchange_cost: 0.0 },
1637                ncs: NcsStagePenalties {
1638                    curtailment_cost: 0.0,
1639                },
1640            },
1641        );
1642
1643        SystemBuilder::new()
1644            .buses(vec![bus])
1645            .thermals(vec![thermal])
1646            .hydros(vec![hydro])
1647            .stages(stages)
1648            .inflow_models(inflow_models)
1649            .load_models(load_models)
1650            .bounds(bounds)
1651            .penalties(penalties)
1652            .build()
1653            .expect("minimal_fpha_misconfigured_system: valid")
1654    }
1655
1656    /// Build a minimal valid [`Config`] with a single iteration-limit stopping rule.
1657    fn minimal_config(forward_passes: u32, max_iterations: u32) -> Config {
1658        Config {
1659            schema: None,
1660            modeling: ModelingConfig {
1661                inflow_non_negativity: InflowNonNegativityConfig {
1662                    method: CfgInflowMethod::Penalty,
1663                },
1664            },
1665            training: TrainingConfig {
1666                enabled: true,
1667                tree_seed: Some(42),
1668                forward_passes: Some(forward_passes),
1669                stopping_rules: Some(vec![StoppingRuleConfig::IterationLimit {
1670                    limit: max_iterations,
1671                }]),
1672                stopping_mode: "any".to_string(),
1673                cut_selection: RowSelectionConfig::default(),
1674                solver: TrainingSolverConfig::default(),
1675                scenario_source: None,
1676            },
1677            upper_bound_evaluation: UpperBoundEvaluationConfig::default(),
1678            policy: PolicyConfig::default(),
1679            simulation: IoSimulationConfig::default(),
1680            exports: ExportsConfig::default(),
1681            estimation: EstimationConfig::default(),
1682        }
1683    }
1684
1685    /// Build a minimal valid [`Config`] with the given per-class scheme overrides.
1686    ///
1687    /// `inflow_scheme`, `load_scheme`, and `ncs_scheme` are optional strings
1688    /// matching the JSON schema values (`"in_sample"`, `"historical"`, `"external"`,
1689    /// `"out_of_sample"`). `None` leaves the class defaulting to `in_sample`.
1690    fn minimal_config_with_schemes(
1691        forward_passes: u32,
1692        max_iterations: u32,
1693        inflow_scheme: Option<&str>,
1694        load_scheme: Option<&str>,
1695        ncs_scheme: Option<&str>,
1696    ) -> Config {
1697        // A seed is required when any class uses a non-in-sample scheme.
1698        let needs_seed = inflow_scheme.is_some_and(|s| s != "in_sample")
1699            || load_scheme.is_some_and(|s| s != "in_sample")
1700            || ncs_scheme.is_some_and(|s| s != "in_sample");
1701        let scenario_source = RawScenarioSourceConfig {
1702            seed: if needs_seed { Some(42) } else { None },
1703            historical_years: None,
1704            inflow: inflow_scheme.map(|s| RawClassConfigEntry {
1705                scheme: s.to_string(),
1706            }),
1707            load: load_scheme.map(|s| RawClassConfigEntry {
1708                scheme: s.to_string(),
1709            }),
1710            ncs: ncs_scheme.map(|s| RawClassConfigEntry {
1711                scheme: s.to_string(),
1712            }),
1713        };
1714        let mut config = minimal_config(forward_passes, max_iterations);
1715        config.training.scenario_source = Some(scenario_source);
1716        config
1717    }
1718
1719    /// Given a minimal valid system (1 hydro, 1 thermal, 1 bus, 2 stages),
1720    /// when `StudySetup::new()` is called, then it returns `Ok` and
1721    /// `stage_templates()` returns a non-empty slice.
1722    #[test]
1723    fn new_minimal_valid_system_returns_ok() {
1724        let system = minimal_system(2);
1725        let config = minimal_config(1, 10);
1726        let stochastic = build_stochastic_context(
1727            &system,
1728            42,
1729            None,
1730            &[],
1731            &[],
1732            OpeningTreeInputs::default(),
1733            ClassSchemes {
1734                inflow: Some(SamplingScheme::InSample),
1735                load: Some(SamplingScheme::InSample),
1736                ncs: Some(SamplingScheme::InSample),
1737            },
1738        )
1739        .expect("stochastic context");
1740
1741        let result = StudySetup::new(
1742            &system,
1743            &config,
1744            stochastic,
1745            PrepareHydroModelsResult::default_from_system(&system),
1746        );
1747        assert!(result.is_ok(), "expected Ok, got {result:?}");
1748        let setup = result.unwrap();
1749        assert!(!setup.stage_data.stage_templates.templates.is_empty());
1750    }
1751
1752    /// Given a system with zero study stages, when `StudySetup::new()` is
1753    /// called, then it returns `Err` containing the substring "no study stages".
1754    #[test]
1755    fn new_zero_stages_returns_validation_error() {
1756        let system = minimal_system(0);
1757        let config = minimal_config(1, 10);
1758        let stochastic = build_stochastic_context(
1759            &system,
1760            42,
1761            None,
1762            &[],
1763            &[],
1764            OpeningTreeInputs::default(),
1765            ClassSchemes {
1766                inflow: Some(SamplingScheme::InSample),
1767                load: Some(SamplingScheme::InSample),
1768                ncs: Some(SamplingScheme::InSample),
1769            },
1770        )
1771        .expect("stochastic context");
1772
1773        let result = StudySetup::new(
1774            &system,
1775            &config,
1776            stochastic,
1777            PrepareHydroModelsResult::default_from_system(&system),
1778        );
1779        assert!(result.is_err(), "expected Err, got Ok");
1780        let err = result.unwrap_err();
1781        let msg = err.to_string();
1782        assert!(
1783            msg.contains("no study stages"),
1784            "error message should contain 'no study stages': {msg}"
1785        );
1786    }
1787
1788    /// Given a valid `StudySetup`, accessor methods return the expected values.
1789    #[test]
1790    fn accessor_methods_return_expected_values() {
1791        let n_stages = 3;
1792        let system = minimal_system(n_stages);
1793        let config = minimal_config(2, 50);
1794        let stochastic = build_stochastic_context(
1795            &system,
1796            42,
1797            None,
1798            &[],
1799            &[],
1800            OpeningTreeInputs::default(),
1801            ClassSchemes {
1802                inflow: Some(SamplingScheme::InSample),
1803                load: Some(SamplingScheme::InSample),
1804                ncs: Some(SamplingScheme::InSample),
1805            },
1806        )
1807        .expect("stochastic context");
1808
1809        let setup = StudySetup::new(
1810            &system,
1811            &config,
1812            stochastic,
1813            PrepareHydroModelsResult::default_from_system(&system),
1814        )
1815        .expect("setup");
1816
1817        // Stage templates
1818        assert_eq!(setup.stage_data.stage_templates.templates.len(), n_stages);
1819        assert_eq!(setup.stage_data.stage_templates.base_rows.len(), n_stages);
1820
1821        // Config-derived scalars
1822        assert_eq!(setup.loop_params.seed, 42);
1823        assert_eq!(setup.loop_params.forward_passes, 2);
1824        assert_eq!(setup.loop_params.max_iterations, 50);
1825        assert_eq!(setup.simulation_config.n_scenarios, 0); // simulation disabled by default
1826        assert_eq!(setup.policy_path, "./policy");
1827
1828        // Derived layout
1829        assert_eq!(setup.stage_data.block_counts_per_stage.len(), n_stages);
1830        assert!(setup.loop_params.max_blocks > 0);
1831
1832        // Horizon
1833        assert_eq!(setup.methodology.horizon.num_stages(), n_stages);
1834
1835        // Risk measures: one per study stage
1836        assert_eq!(setup.cut_management.risk_measures.len(), n_stages);
1837
1838        // FCF: pools match stage count
1839        assert_eq!(setup.fcf.pools.len(), n_stages);
1840
1841        // Entity counts: 1 hydro, 1 thermal
1842        assert_eq!(setup.stage_data.entity_counts.hydro_ids.len(), 1);
1843        assert_eq!(setup.stage_data.entity_counts.thermal_ids.len(), 1);
1844    }
1845
1846    /// FCF is accessible mutably via `fcf_mut()`.
1847    #[test]
1848    fn fcf_mut_allows_cut_insertion() {
1849        let system = minimal_system(2);
1850        let config = minimal_config(1, 10);
1851        let stochastic = build_stochastic_context(
1852            &system,
1853            42,
1854            None,
1855            &[],
1856            &[],
1857            OpeningTreeInputs::default(),
1858            ClassSchemes {
1859                inflow: Some(SamplingScheme::InSample),
1860                load: Some(SamplingScheme::InSample),
1861                ncs: Some(SamplingScheme::InSample),
1862            },
1863        )
1864        .expect("stochastic context");
1865
1866        let mut setup = StudySetup::new(
1867            &system,
1868            &config,
1869            stochastic,
1870            PrepareHydroModelsResult::default_from_system(&system),
1871        )
1872        .expect("setup");
1873
1874        let n_state = setup.stage_data.indexer.n_state;
1875        let coefficients = vec![1.0_f64; n_state];
1876        setup.fcf.add_cut(0, 0, 0, 42.0, &coefficients);
1877        assert_eq!(setup.fcf.total_active_cuts(), 1);
1878    }
1879
1880    /// `inflow_method()` reflects the config setting.
1881    #[test]
1882    fn inflow_method_reflects_config() {
1883        use crate::InflowNonNegativityMethod;
1884
1885        let system = minimal_system(2);
1886        let config = minimal_config(1, 10);
1887        let stochastic = build_stochastic_context(
1888            &system,
1889            42,
1890            None,
1891            &[],
1892            &[],
1893            OpeningTreeInputs::default(),
1894            ClassSchemes {
1895                inflow: Some(SamplingScheme::InSample),
1896                load: Some(SamplingScheme::InSample),
1897                ncs: Some(SamplingScheme::InSample),
1898            },
1899        )
1900        .expect("stochastic context");
1901
1902        let setup = StudySetup::new(
1903            &system,
1904            &config,
1905            stochastic,
1906            PrepareHydroModelsResult::default_from_system(&system),
1907        )
1908        .expect("setup");
1909
1910        // The minimal_config uses "penalty" — should not be None.
1911        assert!(
1912            !matches!(
1913                setup.methodology.inflow_method,
1914                InflowNonNegativityMethod::None
1915            ),
1916            "expected penalty or truncation method"
1917        );
1918    }
1919
1920    /// `cut_selection()` returns `None` when disabled in config (default).
1921    #[test]
1922    fn cut_selection_none_when_disabled() {
1923        let system = minimal_system(2);
1924        let config = minimal_config(1, 10);
1925        let stochastic = build_stochastic_context(
1926            &system,
1927            42,
1928            None,
1929            &[],
1930            &[],
1931            OpeningTreeInputs::default(),
1932            ClassSchemes {
1933                inflow: Some(SamplingScheme::InSample),
1934                load: Some(SamplingScheme::InSample),
1935                ncs: Some(SamplingScheme::InSample),
1936            },
1937        )
1938        .expect("stochastic context");
1939
1940        let setup = StudySetup::new(
1941            &system,
1942            &config,
1943            stochastic,
1944            PrepareHydroModelsResult::default_from_system(&system),
1945        )
1946        .expect("setup");
1947
1948        assert!(
1949            setup.cut_management.cut_selection.is_none(),
1950            "cut_selection should be None when disabled"
1951        );
1952    }
1953
1954    #[test]
1955    fn stage_ctx_fields_match_study_setup() {
1956        let n_stages = 3;
1957        let system = minimal_system(n_stages);
1958        let config = minimal_config(2, 10);
1959        let stochastic = build_stochastic_context(
1960            &system,
1961            42,
1962            None,
1963            &[],
1964            &[],
1965            OpeningTreeInputs::default(),
1966            ClassSchemes {
1967                inflow: Some(SamplingScheme::InSample),
1968                load: Some(SamplingScheme::InSample),
1969                ncs: Some(SamplingScheme::InSample),
1970            },
1971        )
1972        .expect("stochastic context");
1973
1974        let setup = StudySetup::new(
1975            &system,
1976            &config,
1977            stochastic,
1978            PrepareHydroModelsResult::default_from_system(&system),
1979        )
1980        .expect("setup");
1981        let ctx = setup.stage_ctx();
1982
1983        assert_eq!(
1984            ctx.templates.len(),
1985            setup.stage_data.stage_templates.templates.len(),
1986            "templates length mismatch"
1987        );
1988        assert_eq!(
1989            ctx.base_rows.len(),
1990            setup.stage_data.stage_templates.base_rows.len(),
1991            "base_rows length mismatch"
1992        );
1993        assert_eq!(
1994            ctx.noise_scale.len(),
1995            setup.stage_data.stage_templates.noise_scale.len(),
1996            "noise_scale length mismatch"
1997        );
1998        assert_eq!(
1999            ctx.n_hydros,
2000            setup.stage_data.entity_counts.hydro_ids.len(),
2001            "n_hydros mismatch"
2002        );
2003        assert_eq!(
2004            ctx.block_counts_per_stage.len(),
2005            setup.stage_data.block_counts_per_stage.len(),
2006            "block_counts_per_stage length mismatch"
2007        );
2008    }
2009
2010    #[test]
2011    fn training_ctx_fields_match_study_setup() {
2012        let n_stages = 3;
2013        let system = minimal_system(n_stages);
2014        let config = minimal_config(2, 10);
2015        let stochastic = build_stochastic_context(
2016            &system,
2017            42,
2018            None,
2019            &[],
2020            &[],
2021            OpeningTreeInputs::default(),
2022            ClassSchemes {
2023                inflow: Some(SamplingScheme::InSample),
2024                load: Some(SamplingScheme::InSample),
2025                ncs: Some(SamplingScheme::InSample),
2026            },
2027        )
2028        .expect("stochastic context");
2029
2030        let setup = StudySetup::new(
2031            &system,
2032            &config,
2033            stochastic,
2034            PrepareHydroModelsResult::default_from_system(&system),
2035        )
2036        .expect("setup");
2037        let ctx = setup.training_ctx();
2038
2039        assert_eq!(
2040            ctx.horizon.num_stages(),
2041            setup.methodology.horizon.num_stages(),
2042            "horizon num_stages mismatch"
2043        );
2044        assert_eq!(
2045            ctx.indexer.n_state, setup.stage_data.indexer.n_state,
2046            "indexer n_state mismatch"
2047        );
2048        assert_eq!(
2049            ctx.initial_state.len(),
2050            setup.initial_state.len(),
2051            "initial_state length mismatch"
2052        );
2053    }
2054
2055    #[test]
2056    fn simulation_ctx_propagates_dynamic_dcs_from_setup() {
2057        let n_stages = 3;
2058        let system = minimal_system(n_stages);
2059        let mut config = minimal_config(2, 10);
2060        // Configure the dynamic cut-selection method so `parse_cut_selection_config`
2061        // yields a `Dynamic` strategy and `simulation_ctx()` populates `dcs`.
2062        config.training.cut_selection = RowSelectionConfig {
2063            selection: Some(cobre_io::config::SelectionMethod::Dynamic {
2064                start_iteration: 2,
2065                seed_window: 5,
2066                candidate_recency: None,
2067                max_added_per_round: 10,
2068                violation_tolerance: 1e-10,
2069            }),
2070            ..RowSelectionConfig::default()
2071        };
2072        let stochastic = build_stochastic_context(
2073            &system,
2074            42,
2075            None,
2076            &[],
2077            &[],
2078            OpeningTreeInputs::default(),
2079            ClassSchemes {
2080                inflow: Some(SamplingScheme::InSample),
2081                load: Some(SamplingScheme::InSample),
2082                ncs: Some(SamplingScheme::InSample),
2083            },
2084        )
2085        .expect("stochastic context");
2086
2087        let setup = StudySetup::new(
2088            &system,
2089            &config,
2090            stochastic,
2091            PrepareHydroModelsResult::default_from_system(&system),
2092        )
2093        .expect("setup");
2094        let ctx = setup.simulation_ctx();
2095
2096        // The dynamic method with default fields maps to the spec defaults
2097        // (k1 = None, k2 = 5, nadic = 10, epsilon_viol = 1e-10, start_iteration = 2).
2098        let expected = crate::dcs::DcsParams {
2099            k1: None,
2100            k2: 5,
2101            nadic: 10,
2102            epsilon_viol: 1e-10,
2103            start_iteration: 2,
2104            max_inner_iterations: crate::dcs::DcsParams::default().max_inner_iterations,
2105        };
2106        assert_eq!(
2107            ctx.dcs,
2108            Some(expected),
2109            "simulation_ctx().dcs must carry the configured dynamic DcsParams, got {:?}",
2110            ctx.dcs
2111        );
2112    }
2113
2114    /// Given a 1-hydro, 1-thermal, 1-bus, 2-stage system with an iteration
2115    /// limit of 3, when `train()` is called, then it completes successfully
2116    /// with `result.iterations <= 3`.
2117    #[test]
2118    fn train_completes_within_iteration_limit() {
2119        use cobre_comm::LocalBackend;
2120        use cobre_solver::ActiveSolver;
2121
2122        let system = minimal_system(2);
2123        let config = minimal_config(1, 3);
2124        let stochastic = build_stochastic_context(
2125            &system,
2126            42,
2127            None,
2128            &[],
2129            &[],
2130            OpeningTreeInputs::default(),
2131            ClassSchemes {
2132                inflow: Some(SamplingScheme::InSample),
2133                load: Some(SamplingScheme::InSample),
2134                ncs: Some(SamplingScheme::InSample),
2135            },
2136        )
2137        .expect("stochastic context");
2138
2139        let mut setup = StudySetup::new(
2140            &system,
2141            &config,
2142            stochastic,
2143            PrepareHydroModelsResult::default_from_system(&system),
2144        )
2145        .expect("setup");
2146        let comm = LocalBackend;
2147        let mut solver = ActiveSolver::new().expect("solver");
2148
2149        let result = setup
2150            .train(&mut solver, &comm, 1, ActiveSolver::new, None, None)
2151            .expect("train");
2152
2153        assert!(
2154            result.result.iterations <= 3,
2155            "expected iterations <= 3, got {}",
2156            result.result.iterations
2157        );
2158        assert!(
2159            result.result.iterations >= 1,
2160            "expected at least 1 iteration, got {}",
2161            result.result.iterations
2162        );
2163    }
2164
2165    /// After `train()` completes, at least one cut should be populated in the
2166    /// FCF cut pool for stage 0.
2167    #[test]
2168    fn train_generates_cuts_in_fcf() {
2169        use cobre_comm::LocalBackend;
2170        use cobre_solver::ActiveSolver;
2171
2172        let system = minimal_system(2);
2173        let config = minimal_config(1, 3);
2174        let stochastic = build_stochastic_context(
2175            &system,
2176            42,
2177            None,
2178            &[],
2179            &[],
2180            OpeningTreeInputs::default(),
2181            ClassSchemes {
2182                inflow: Some(SamplingScheme::InSample),
2183                load: Some(SamplingScheme::InSample),
2184                ncs: Some(SamplingScheme::InSample),
2185            },
2186        )
2187        .expect("stochastic context");
2188
2189        let mut setup = StudySetup::new(
2190            &system,
2191            &config,
2192            stochastic,
2193            PrepareHydroModelsResult::default_from_system(&system),
2194        )
2195        .expect("setup");
2196        let comm = LocalBackend;
2197        let mut solver = ActiveSolver::new().expect("solver");
2198
2199        setup
2200            .train(&mut solver, &comm, 1, ActiveSolver::new, None, None)
2201            .expect("train");
2202
2203        assert!(
2204            setup.fcf.pools[0].populated_count > 0,
2205            "expected at least one cut in FCF pool[0] after training"
2206        );
2207    }
2208
2209    /// `simulation_config()` returns a `SimulationConfig` whose fields match
2210    /// the values extracted from the `Config` at construction time.
2211    #[test]
2212    fn simulation_config_reflects_setup_fields() {
2213        use cobre_io::config::SimulationConfig as IoSimulationConfig;
2214
2215        // Build a config with simulation enabled so n_scenarios is non-zero.
2216        let mut config = minimal_config(1, 5);
2217        config.simulation = IoSimulationConfig {
2218            enabled: true,
2219            num_scenarios: 50,
2220            io_channel_capacity: 16,
2221            ..IoSimulationConfig::default()
2222        };
2223
2224        let system = minimal_system(2);
2225        let stochastic = build_stochastic_context(
2226            &system,
2227            42,
2228            None,
2229            &[],
2230            &[],
2231            OpeningTreeInputs::default(),
2232            ClassSchemes {
2233                inflow: Some(SamplingScheme::InSample),
2234                load: Some(SamplingScheme::InSample),
2235                ncs: Some(SamplingScheme::InSample),
2236            },
2237        )
2238        .expect("stochastic context");
2239
2240        let setup = StudySetup::new(
2241            &system,
2242            &config,
2243            stochastic,
2244            PrepareHydroModelsResult::default_from_system(&system),
2245        )
2246        .expect("setup");
2247
2248        let sim_cfg = setup.simulation_config();
2249        assert_eq!(sim_cfg.n_scenarios, setup.simulation_config.n_scenarios);
2250        assert_eq!(
2251            sim_cfg.io_channel_capacity,
2252            setup.simulation_config.io_channel_capacity
2253        );
2254    }
2255
2256    /// `create_workspace_pool()` with `n_threads = 2` returns a pool whose
2257    /// `workspaces.len()` equals 2.
2258    #[test]
2259    fn create_workspace_pool_returns_correct_size() {
2260        use cobre_comm::LocalBackend;
2261        use cobre_solver::ActiveSolver;
2262
2263        let system = minimal_system(2);
2264        let config = minimal_config(1, 3);
2265        let stochastic = build_stochastic_context(
2266            &system,
2267            42,
2268            None,
2269            &[],
2270            &[],
2271            OpeningTreeInputs::default(),
2272            ClassSchemes {
2273                inflow: Some(SamplingScheme::InSample),
2274                load: Some(SamplingScheme::InSample),
2275                ncs: Some(SamplingScheme::InSample),
2276            },
2277        )
2278        .expect("stochastic context");
2279
2280        let setup = StudySetup::new(
2281            &system,
2282            &config,
2283            stochastic,
2284            PrepareHydroModelsResult::default_from_system(&system),
2285        )
2286        .expect("setup");
2287
2288        let comm = LocalBackend;
2289        let pool = setup
2290            .create_workspace_pool(&comm, 2, ActiveSolver::new)
2291            .expect("workspace pool");
2292
2293        assert_eq!(pool.workspaces.len(), 2);
2294    }
2295
2296    /// `build_training_output()` with a non-empty `TrainingResult` and empty
2297    /// events produces a `TrainingOutput` whose `convergence_records` is
2298    /// non-empty (one record per `result.iterations`).
2299    #[test]
2300    fn build_training_output_non_empty() {
2301        use cobre_comm::LocalBackend;
2302        use cobre_solver::ActiveSolver;
2303
2304        let system = minimal_system(2);
2305        let config = minimal_config(1, 2);
2306        let stochastic = build_stochastic_context(
2307            &system,
2308            42,
2309            None,
2310            &[],
2311            &[],
2312            OpeningTreeInputs::default(),
2313            ClassSchemes {
2314                inflow: Some(SamplingScheme::InSample),
2315                load: Some(SamplingScheme::InSample),
2316                ncs: Some(SamplingScheme::InSample),
2317            },
2318        )
2319        .expect("stochastic context");
2320
2321        let mut setup = StudySetup::new(
2322            &system,
2323            &config,
2324            stochastic,
2325            PrepareHydroModelsResult::default_from_system(&system),
2326        )
2327        .expect("setup");
2328        let comm = LocalBackend;
2329        let mut solver = ActiveSolver::new().expect("solver");
2330
2331        // Collect events from training so we have at least one IterationSummary.
2332        let (event_tx, event_rx) = std::sync::mpsc::channel();
2333        let result = setup
2334            .train(
2335                &mut solver,
2336                &comm,
2337                1,
2338                ActiveSolver::new,
2339                Some(event_tx),
2340                None,
2341            )
2342            .expect("train");
2343
2344        let events: Vec<cobre_core::TrainingEvent> = event_rx.try_iter().collect();
2345
2346        let output = setup.build_training_output(&result.result, &events);
2347        assert!(
2348            !output.convergence_records.is_empty(),
2349            "convergence_records must be non-empty after training"
2350        );
2351    }
2352
2353    /// Given a trained `StudySetup` with `n_scenarios > 0`, calling `simulate()`
2354    /// returns `Ok(costs)` with `costs.len() > 0`.
2355    #[test]
2356    fn simulate_after_train_returns_nonempty_costs() {
2357        use cobre_comm::LocalBackend;
2358        use cobre_solver::ActiveSolver;
2359
2360        // Enable simulation with 3 scenarios.
2361        let mut config = minimal_config(1, 3);
2362        config.simulation = cobre_io::config::SimulationConfig {
2363            enabled: true,
2364            num_scenarios: 3,
2365            io_channel_capacity: 8,
2366            ..cobre_io::config::SimulationConfig::default()
2367        };
2368
2369        let system = minimal_system(2);
2370        let stochastic = build_stochastic_context(
2371            &system,
2372            42,
2373            None,
2374            &[],
2375            &[],
2376            OpeningTreeInputs::default(),
2377            ClassSchemes {
2378                inflow: Some(SamplingScheme::InSample),
2379                load: Some(SamplingScheme::InSample),
2380                ncs: Some(SamplingScheme::InSample),
2381            },
2382        )
2383        .expect("stochastic context");
2384
2385        let mut setup = StudySetup::new(
2386            &system,
2387            &config,
2388            stochastic,
2389            PrepareHydroModelsResult::default_from_system(&system),
2390        )
2391        .expect("setup");
2392
2393        // Train first so the FCF has cuts.
2394        let comm = LocalBackend;
2395        let mut solver = ActiveSolver::new().expect("solver");
2396        setup
2397            .train(&mut solver, &comm, 1, ActiveSolver::new, None, None)
2398            .expect("train");
2399
2400        // Build simulation pool.
2401        let mut pool = setup
2402            .create_workspace_pool(&comm, 1, ActiveSolver::new)
2403            .expect("sim pool");
2404
2405        // Create the result channel and drain thread.
2406        let io_capacity = setup.simulation_config.io_channel_capacity.max(1);
2407        let (result_tx, result_rx) = std::sync::mpsc::sync_channel(io_capacity);
2408        let drain_handle = std::thread::spawn(move || result_rx.into_iter().collect::<Vec<_>>());
2409
2410        let sim_result = setup
2411            .simulate(&mut pool.workspaces, &comm, &result_tx, None, None, &[])
2412            .expect("simulate");
2413
2414        // Drop the sender so the drain thread terminates.
2415        drop(result_tx);
2416        let _results = drain_handle.join().expect("drain thread");
2417
2418        assert!(
2419            !sim_result.costs.is_empty(),
2420            "simulate must return at least one cost entry"
2421        );
2422        assert_eq!(
2423            sim_result.solver_stats.len(),
2424            sim_result.costs.len(),
2425            "one solver stats entry per scenario"
2426        );
2427    }
2428
2429    /// Given a config with no overrides, `StudyParams::from_config` returns the
2430    /// default values for all fields.
2431    #[test]
2432    fn study_params_from_config_defaults() {
2433        use super::{DEFAULT_FORWARD_PASSES, DEFAULT_SEED, StudyParams};
2434        use crate::stopping_rule::StoppingMode;
2435        use cobre_io::config::{
2436            Config, EstimationConfig, ExportsConfig, InflowNonNegativityConfig,
2437            InflowNonNegativityMethod as CfgInflowMethod, ModelingConfig, PolicyConfig,
2438            RowSelectionConfig, SimulationConfig as IoSimulationConfig, TrainingConfig,
2439            TrainingSolverConfig, UpperBoundEvaluationConfig,
2440        };
2441
2442        let config = Config {
2443            schema: None,
2444            modeling: ModelingConfig {
2445                inflow_non_negativity: InflowNonNegativityConfig {
2446                    method: CfgInflowMethod::None,
2447                },
2448            },
2449            training: TrainingConfig {
2450                enabled: true,
2451                tree_seed: None,
2452                forward_passes: None,
2453                stopping_rules: None,
2454                stopping_mode: "any".to_string(),
2455                cut_selection: RowSelectionConfig::default(),
2456                solver: TrainingSolverConfig::default(),
2457                scenario_source: None,
2458            },
2459            upper_bound_evaluation: UpperBoundEvaluationConfig::default(),
2460            policy: PolicyConfig::default(),
2461            simulation: IoSimulationConfig::default(),
2462            exports: ExportsConfig::default(),
2463            estimation: EstimationConfig::default(),
2464        };
2465
2466        let params = StudyParams::from_config(&config).expect("from_config");
2467
2468        assert_eq!(
2469            params.seed, DEFAULT_SEED,
2470            "seed should default to DEFAULT_SEED"
2471        );
2472        assert_eq!(
2473            params.forward_passes, DEFAULT_FORWARD_PASSES,
2474            "forward_passes should default to DEFAULT_FORWARD_PASSES"
2475        );
2476        // When no stopping rules are specified, a single IterationLimit rule is inserted.
2477        assert_eq!(
2478            params.stopping_rule_set.rules.len(),
2479            1,
2480            "expected exactly 1 default stopping rule"
2481        );
2482        assert!(
2483            matches!(
2484                params.stopping_rule_set.rules[0],
2485                crate::stopping_rule::StoppingRule::IterationLimit { .. }
2486            ),
2487            "default rule should be IterationLimit"
2488        );
2489        assert!(
2490            matches!(params.stopping_rule_set.mode, StoppingMode::Any),
2491            "default stopping mode should be Any"
2492        );
2493        // Simulation is disabled by default.
2494        assert_eq!(
2495            params.n_scenarios, 0,
2496            "n_scenarios should be 0 when simulation disabled"
2497        );
2498        assert!(
2499            params.cut_selection.is_none(),
2500            "cut_selection should be None by default"
2501        );
2502    }
2503
2504    /// Given a config with explicit values for all fields, `StudyParams::from_config`
2505    /// extracts them correctly.
2506    #[test]
2507    fn study_params_from_config_explicit() {
2508        use super::StudyParams;
2509        use crate::stopping_rule::{StoppingMode, StoppingRule};
2510        use cobre_io::config::{
2511            Config, EstimationConfig, ExportsConfig, InflowNonNegativityConfig,
2512            InflowNonNegativityMethod as CfgInflowMethod, ModelingConfig, PolicyConfig,
2513            RowSelectionConfig, SimulationConfig as IoSimulationConfig, StoppingRuleConfig,
2514            TrainingConfig, TrainingSolverConfig, UpperBoundEvaluationConfig,
2515        };
2516
2517        let config = Config {
2518            schema: None,
2519            modeling: ModelingConfig {
2520                inflow_non_negativity: InflowNonNegativityConfig {
2521                    method: CfgInflowMethod::Penalty,
2522                },
2523            },
2524            training: TrainingConfig {
2525                enabled: true,
2526                tree_seed: Some(1234),
2527                forward_passes: Some(5),
2528                stopping_rules: Some(vec![
2529                    StoppingRuleConfig::IterationLimit { limit: 50 },
2530                    StoppingRuleConfig::TimeLimit { seconds: 60.0 },
2531                ]),
2532                stopping_mode: "all".to_string(),
2533                cut_selection: RowSelectionConfig::default(),
2534                solver: TrainingSolverConfig::default(),
2535                scenario_source: None,
2536            },
2537            upper_bound_evaluation: UpperBoundEvaluationConfig::default(),
2538            policy: PolicyConfig {
2539                path: "./my_policy".to_string(),
2540                ..PolicyConfig::default()
2541            },
2542            simulation: IoSimulationConfig {
2543                enabled: true,
2544                num_scenarios: 200,
2545                ..IoSimulationConfig::default()
2546            },
2547            exports: ExportsConfig::default(),
2548            estimation: EstimationConfig::default(),
2549        };
2550
2551        let params = StudyParams::from_config(&config).expect("from_config");
2552
2553        // Seed: i64::unsigned_abs(1234) == 1234
2554        assert_eq!(params.seed, 1234, "seed mismatch");
2555        assert_eq!(params.forward_passes, 5, "forward_passes mismatch");
2556        // Two stopping rules must be preserved.
2557        assert_eq!(
2558            params.stopping_rule_set.rules.len(),
2559            2,
2560            "stopping rule count mismatch"
2561        );
2562        assert!(
2563            matches!(
2564                params.stopping_rule_set.rules[0],
2565                StoppingRule::IterationLimit { limit: 50 }
2566            ),
2567            "first rule should be IterationLimit(50)"
2568        );
2569        assert!(
2570            matches!(
2571                params.stopping_rule_set.rules[1],
2572                StoppingRule::TimeLimit { seconds } if (seconds - 60.0).abs() < 1e-9
2573            ),
2574            "second rule should be TimeLimit(60.0)"
2575        );
2576        assert!(
2577            matches!(params.stopping_rule_set.mode, StoppingMode::All),
2578            "stopping mode should be All"
2579        );
2580        assert_eq!(params.n_scenarios, 200, "n_scenarios mismatch");
2581        assert_eq!(params.policy_path, "./my_policy", "policy_path mismatch");
2582    }
2583
2584    /// Build a minimal case directory with required structural files present so
2585    /// that `validate_structure` does not fail. The optional estimation and
2586    /// opening tree files are NOT created here; tests add them as needed.
2587    fn write_minimal_case_dir(root: &std::path::Path) {
2588        use std::fs;
2589
2590        fs::create_dir_all(root.join("system")).unwrap();
2591        fs::write(root.join("config.json"), b"{}").unwrap();
2592        fs::write(root.join("penalties.json"), b"{}").unwrap();
2593        fs::write(root.join("stages.json"), b"{}").unwrap();
2594        fs::write(root.join("initial_conditions.json"), b"{}").unwrap();
2595        fs::write(root.join("system/buses.json"), b"{}").unwrap();
2596        fs::write(root.join("system/lines.json"), b"{}").unwrap();
2597        fs::write(root.join("system/hydros.json"), b"{}").unwrap();
2598        fs::write(root.join("system/thermals.json"), b"{}").unwrap();
2599    }
2600
2601    /// Build a minimal [`cobre_io::Config`] with no estimation or seed overrides.
2602    fn minimal_prepare_config() -> cobre_io::Config {
2603        use cobre_io::config::{
2604            Config, EstimationConfig, ExportsConfig, InflowNonNegativityConfig,
2605            InflowNonNegativityMethod as CfgInflowMethod, ModelingConfig, PolicyConfig,
2606            RowSelectionConfig, SimulationConfig as IoSimulationConfig, TrainingConfig,
2607            TrainingSolverConfig, UpperBoundEvaluationConfig,
2608        };
2609
2610        Config {
2611            schema: None,
2612            modeling: ModelingConfig {
2613                inflow_non_negativity: InflowNonNegativityConfig {
2614                    method: CfgInflowMethod::None,
2615                },
2616            },
2617            training: TrainingConfig {
2618                enabled: true,
2619                tree_seed: None,
2620                forward_passes: None,
2621                stopping_rules: None,
2622                stopping_mode: "any".to_string(),
2623                cut_selection: RowSelectionConfig::default(),
2624                solver: TrainingSolverConfig::default(),
2625                scenario_source: None,
2626            },
2627            upper_bound_evaluation: UpperBoundEvaluationConfig::default(),
2628            policy: PolicyConfig::default(),
2629            simulation: IoSimulationConfig::default(),
2630            exports: ExportsConfig::default(),
2631            estimation: EstimationConfig::default(),
2632        }
2633    }
2634
2635    /// Given a case directory with no `inflow_history.parquet` and no
2636    /// `scenarios/noise_openings.parquet`, `prepare_stochastic` returns
2637    /// `estimation_report = None` and a stochastic context with generated provenance.
2638    #[test]
2639    fn prepare_stochastic_no_history_no_tree_returns_none_report_and_generated_provenance() {
2640        use super::prepare_stochastic;
2641        use cobre_core::scenario::ScenarioSource;
2642        use cobre_stochastic::provenance::ComponentProvenance;
2643        use tempfile::TempDir;
2644
2645        let dir = TempDir::new().unwrap();
2646        let root = dir.path();
2647        write_minimal_case_dir(root);
2648
2649        let system = minimal_system(2);
2650        let config = minimal_prepare_config();
2651        let seed = 42_u64;
2652
2653        let source = ScenarioSource {
2654            inflow_scheme: SamplingScheme::InSample,
2655            load_scheme: SamplingScheme::InSample,
2656            ncs_scheme: SamplingScheme::InSample,
2657            seed: None,
2658            historical_years: None,
2659        };
2660        let result = prepare_stochastic(system, root, &config, seed, &source)
2661            .expect("prepare_stochastic should succeed with no optional files");
2662
2663        assert!(
2664            result.estimation_report.is_none(),
2665            "estimation_report must be None when no inflow_history.parquet is present"
2666        );
2667        assert_eq!(
2668            result.stochastic.provenance().opening_tree,
2669            ComponentProvenance::Generated,
2670            "opening_tree provenance must be Generated when no user tree is supplied"
2671        );
2672    }
2673
2674    /// Given a case directory with `inflow_seasonal_stats.parquet` present
2675    /// alongside `inflow_history.parquet`, estimation is skipped and
2676    /// `estimation_report` is `None`.
2677    #[test]
2678    fn prepare_stochastic_with_stats_file_present_skips_estimation() {
2679        use super::prepare_stochastic;
2680        use cobre_core::scenario::ScenarioSource;
2681        use std::fs;
2682        use tempfile::TempDir;
2683
2684        let dir = TempDir::new().unwrap();
2685        let root = dir.path();
2686        write_minimal_case_dir(root);
2687
2688        // Place the stats files so that the "explicit stats present" branch is taken
2689        // and estimation is skipped. No `inflow_history.parquet` is written here;
2690        // its presence is not required for the estimation-skip path and the test
2691        // intentionally keeps the history file absent to avoid parse errors.
2692        // (`validate_structure` only checks existence, not content.)
2693        fs::create_dir_all(root.join("scenarios")).unwrap();
2694        fs::write(root.join("scenarios/inflow_seasonal_stats.parquet"), b"").unwrap();
2695        fs::write(root.join("scenarios/inflow_ar_coefficients.parquet"), b"").unwrap();
2696
2697        let system = minimal_system(2);
2698        let config = minimal_prepare_config();
2699        let seed = 42_u64;
2700
2701        let source = ScenarioSource {
2702            inflow_scheme: SamplingScheme::InSample,
2703            load_scheme: SamplingScheme::InSample,
2704            ncs_scheme: SamplingScheme::InSample,
2705            seed: None,
2706            historical_years: None,
2707        };
2708        let result = prepare_stochastic(system, root, &config, seed, &source)
2709            .expect("prepare_stochastic should succeed when stats file is present");
2710
2711        assert!(
2712            result.estimation_report.is_none(),
2713            "estimation_report must be None when inflow_seasonal_stats.parquet is present"
2714        );
2715    }
2716
2717    /// Given a case directory with no `scenarios/noise_openings.parquet`,
2718    /// `load_user_opening_tree_inner` returns `None`.
2719    ///
2720    /// This is tested indirectly via `prepare_stochastic` by checking that the
2721    /// returned stochastic context does not claim `UserSupplied` provenance.
2722    #[test]
2723    fn prepare_stochastic_no_opening_tree_gives_non_user_supplied_provenance() {
2724        use super::prepare_stochastic;
2725        use cobre_core::scenario::ScenarioSource;
2726        use cobre_stochastic::provenance::ComponentProvenance;
2727        use tempfile::TempDir;
2728
2729        let dir = TempDir::new().unwrap();
2730        let root = dir.path();
2731        write_minimal_case_dir(root);
2732
2733        let system = minimal_system(2);
2734        let config = minimal_prepare_config();
2735
2736        let source = ScenarioSource {
2737            inflow_scheme: SamplingScheme::InSample,
2738            load_scheme: SamplingScheme::InSample,
2739            ncs_scheme: SamplingScheme::InSample,
2740            seed: None,
2741            historical_years: None,
2742        };
2743        let result = prepare_stochastic(system, root, &config, 0, &source)
2744            .expect("prepare_stochastic must succeed with no opening tree file");
2745
2746        assert_ne!(
2747            result.stochastic.provenance().opening_tree,
2748            ComponentProvenance::UserSupplied,
2749            "opening_tree provenance must not be UserSupplied when file is absent"
2750        );
2751    }
2752
2753    /// Given a system with `NoiseMethod::HistoricalResiduals` on all stages and
2754    /// sufficient inflow history, when `prepare_stochastic` is called, then it
2755    /// returns `Ok` and the resulting stochastic context has
2756    /// `opening_tree().n_stages()` equal to the number of study stages.
2757    #[test]
2758    #[allow(
2759        clippy::too_many_lines,
2760        clippy::cast_possible_truncation,
2761        clippy::cast_possible_wrap
2762    )]
2763    fn test_prepare_stochastic_historical_residuals_noise_method() {
2764        use super::prepare_stochastic;
2765        use chrono::NaiveDate;
2766        use cobre_core::{
2767            scenario::{InflowHistoryRow, ScenarioSource},
2768            system::SystemBuilder,
2769        };
2770        use tempfile::TempDir;
2771
2772        // Build a system with HistoricalResiduals noise method on all stages.
2773        // Reuses the same structure as system_with_historical_inflow but sets
2774        // noise_method to HistoricalResiduals instead of the default Saa.
2775        let n_stages = 2usize;
2776
2777        let bus = Bus {
2778            id: EntityId(1),
2779            name: "B1".to_string(),
2780            deficit_segments: vec![DeficitSegment {
2781                depth_mw: None,
2782                cost_per_mwh: 500.0,
2783            }],
2784            excess_cost: 0.0,
2785        };
2786        let thermal = Thermal {
2787            id: EntityId(2),
2788            name: "T1".to_string(),
2789            bus_id: EntityId(1),
2790            min_generation_mw: 0.0,
2791            max_generation_mw: 100.0,
2792            cost_per_mwh: 50.0,
2793            anticipated_config: None,
2794            entry_stage_id: None,
2795            exit_stage_id: None,
2796        };
2797        let hydro = Hydro {
2798            id: EntityId(3),
2799            name: "H1".to_string(),
2800            bus_id: EntityId(1),
2801            downstream_id: None,
2802            entry_stage_id: None,
2803            exit_stage_id: None,
2804            min_storage_hm3: 0.0,
2805            max_storage_hm3: 200.0,
2806            min_outflow_m3s: 0.0,
2807            max_outflow_m3s: None,
2808            generation_model: HydroGenerationModel::ConstantProductivity,
2809            min_turbined_m3s: 0.0,
2810            max_turbined_m3s: 100.0,
2811            specific_productivity_mw_per_m3s_per_m: None,
2812            min_generation_mw: 0.0,
2813            max_generation_mw: 250.0,
2814            tailrace: None,
2815            hydraulic_losses: None,
2816            efficiency: None,
2817            evaporation_coefficients_mm: None,
2818            evaporation_reference_volumes_hm3: None,
2819            diversion: None,
2820            filling: None,
2821            penalties: HydroPenalties {
2822                spillage_cost: 0.01,
2823                diversion_cost: 0.0,
2824                turbined_cost: 0.0,
2825                storage_violation_below_cost: 0.0,
2826                filling_target_violation_cost: 0.0,
2827                turbined_violation_below_cost: 0.0,
2828                outflow_violation_below_cost: 0.0,
2829                outflow_violation_above_cost: 0.0,
2830                generation_violation_below_cost: 0.0,
2831                evaporation_violation_cost: 0.0,
2832                water_withdrawal_violation_cost: 0.0,
2833                water_withdrawal_violation_pos_cost: 0.0,
2834                water_withdrawal_violation_neg_cost: 0.0,
2835                evaporation_violation_pos_cost: 0.0,
2836                evaporation_violation_neg_cost: 0.0,
2837                inflow_nonnegativity_cost: 1000.0,
2838            },
2839        };
2840
2841        // Stages with HistoricalResiduals noise method; branching_factor=2 so
2842        // each stage selects 2 historical windows as openings.
2843        let stages: Vec<Stage> = (0..n_stages)
2844            .map(|i| Stage {
2845                index: i,
2846                id: i as i32,
2847                start_date: NaiveDate::from_ymd_opt(2024, (i as u32 % 12) + 1, 1).unwrap(),
2848                end_date: NaiveDate::from_ymd_opt(2024, (i as u32 % 12) + 1, 28).unwrap(),
2849                season_id: Some(i % 12),
2850                blocks: vec![Block {
2851                    index: 0,
2852                    name: "S".to_string(),
2853                    duration_hours: 720.0,
2854                }],
2855                block_mode: BlockMode::Parallel,
2856                state_config: StageStateConfig {
2857                    storage: true,
2858                    inflow_lags: false,
2859                },
2860                risk_config: StageRiskConfig::Expectation,
2861                scenario_config: ScenarioSourceConfig {
2862                    branching_factor: 2,
2863                    noise_method: NoiseMethod::HistoricalResiduals,
2864                },
2865            })
2866            .collect();
2867
2868        let inflow_models: Vec<InflowModel> = (0..n_stages)
2869            .map(|i| InflowModel {
2870                hydro_id: EntityId(3),
2871                stage_id: i as i32,
2872                mean_m3s: 80.0,
2873                std_m3s: 20.0,
2874                ar_coefficients: vec![],
2875                residual_std_ratio: 1.0,
2876                annual: None,
2877            })
2878            .collect();
2879
2880        let load_models: Vec<LoadModel> = (0..n_stages)
2881            .map(|i| LoadModel {
2882                bus_id: EntityId(1),
2883                stage_id: i as i32,
2884                mean_mw: 100.0,
2885                std_mw: 0.0,
2886            })
2887            .collect();
2888
2889        // Historical inflow data: 1990 and 1991 cover 12 months each — 2 valid windows.
2890        let inflow_history: Vec<InflowHistoryRow> = (1990_i32..=1991)
2891            .flat_map(|year| {
2892                (1u32..=12).map(move |month| InflowHistoryRow {
2893                    hydro_id: EntityId(3),
2894                    date: NaiveDate::from_ymd_opt(year, month, 1).unwrap(),
2895                    value_m3s: 80.0 + f64::from(year - 1990) * 5.0,
2896                })
2897            })
2898            .collect();
2899
2900        let n_st = n_stages.max(1);
2901        let bounds = ResolvedBounds::new(
2902            &BoundsCountsSpec {
2903                n_hydros: 1,
2904                n_thermals: 1,
2905                n_lines: 0,
2906                n_pumping: 0,
2907                n_contracts: 0,
2908                n_stages: n_st,
2909                k_max: 0,
2910            },
2911            &BoundsDefaults {
2912                hydro: HydroStageBounds {
2913                    min_storage_hm3: 0.0,
2914                    max_storage_hm3: 200.0,
2915                    min_turbined_m3s: 0.0,
2916                    max_turbined_m3s: 100.0,
2917                    min_outflow_m3s: 0.0,
2918                    max_outflow_m3s: None,
2919                    min_generation_mw: 0.0,
2920                    max_generation_mw: 250.0,
2921                    max_diversion_m3s: None,
2922                    filling_inflow_m3s: 0.0,
2923                    water_withdrawal_m3s: 0.0,
2924                },
2925                thermal: ThermalStageBounds {
2926                    min_generation_mw: 0.0,
2927                    max_generation_mw: 100.0,
2928                    cost_per_mwh: 0.0,
2929                },
2930                line: LineStageBounds {
2931                    direct_mw: 0.0,
2932                    reverse_mw: 0.0,
2933                },
2934                pumping: PumpingStageBounds {
2935                    min_flow_m3s: 0.0,
2936                    max_flow_m3s: 0.0,
2937                },
2938                contract: ContractStageBounds {
2939                    min_mw: 0.0,
2940                    max_mw: 0.0,
2941                    price_per_mwh: 0.0,
2942                },
2943            },
2944        );
2945        let penalties = ResolvedPenalties::new(
2946            &PenaltiesCountsSpec {
2947                n_hydros: 1,
2948                n_buses: 1,
2949                n_lines: 0,
2950                n_ncs: 0,
2951                n_stages: n_st,
2952            },
2953            &PenaltiesDefaults {
2954                hydro: HydroStagePenalties {
2955                    spillage_cost: 0.01,
2956                    diversion_cost: 0.0,
2957                    turbined_cost: 0.0,
2958                    storage_violation_below_cost: 500.0,
2959                    filling_target_violation_cost: 0.0,
2960                    turbined_violation_below_cost: 0.0,
2961                    outflow_violation_below_cost: 0.0,
2962                    outflow_violation_above_cost: 0.0,
2963                    generation_violation_below_cost: 0.0,
2964                    evaporation_violation_cost: 0.0,
2965                    water_withdrawal_violation_cost: 0.0,
2966                    water_withdrawal_violation_pos_cost: 0.0,
2967                    water_withdrawal_violation_neg_cost: 0.0,
2968                    evaporation_violation_pos_cost: 0.0,
2969                    evaporation_violation_neg_cost: 0.0,
2970                    inflow_nonnegativity_cost: 1000.0,
2971                },
2972                bus: BusStagePenalties { excess_cost: 0.0 },
2973                line: LineStagePenalties { exchange_cost: 0.0 },
2974                ncs: NcsStagePenalties {
2975                    curtailment_cost: 0.0,
2976                },
2977            },
2978        );
2979
2980        let system = SystemBuilder::new()
2981            .buses(vec![bus])
2982            .thermals(vec![thermal])
2983            .hydros(vec![hydro])
2984            .stages(stages)
2985            .inflow_models(inflow_models)
2986            .load_models(load_models)
2987            .inflow_history(inflow_history)
2988            .bounds(bounds)
2989            .penalties(penalties)
2990            .build()
2991            .expect("test system: valid");
2992
2993        let dir = TempDir::new().unwrap();
2994        let root = dir.path();
2995        write_minimal_case_dir(root);
2996
2997        let config = minimal_prepare_config();
2998        let source = ScenarioSource {
2999            inflow_scheme: SamplingScheme::InSample,
3000            load_scheme: SamplingScheme::InSample,
3001            ncs_scheme: SamplingScheme::InSample,
3002            seed: None,
3003            historical_years: None,
3004        };
3005        let result = prepare_stochastic(system, root, &config, 42, &source)
3006            .expect("prepare_stochastic must succeed with HistoricalResiduals noise method");
3007
3008        assert_eq!(
3009            result.stochastic.opening_tree().n_stages(),
3010            n_stages,
3011            "opening_tree must have n_stages == {n_stages}"
3012        );
3013    }
3014
3015    /// Given a system with no FPHA and no evaporation data, `default_from_system`
3016    /// returns a result where all hydros use constant productivity and no evaporation.
3017    #[test]
3018    fn default_from_system_gives_constant_and_no_evaporation() {
3019        use crate::hydro_models::{
3020            EvaporationModel, ProductionModelSource, ResolvedProductionModel,
3021        };
3022
3023        let system = minimal_system(2);
3024        let result = PrepareHydroModelsResult::default_from_system(&system);
3025
3026        assert_eq!(
3027            result.provenance.production_sources.len(),
3028            system.hydros().len(),
3029            "production_sources length must equal n_hydros"
3030        );
3031        for (_, source) in &result.provenance.production_sources {
3032            assert_eq!(
3033                *source,
3034                ProductionModelSource::DefaultConstant,
3035                "all hydros must use DefaultConstant"
3036            );
3037        }
3038
3039        assert_eq!(
3040            result.provenance.evaporation_sources.len(),
3041            system.hydros().len(),
3042            "evaporation_sources length must equal n_hydros"
3043        );
3044        assert!(
3045            !result.evaporation.has_evaporation(),
3046            "default result must have no evaporation"
3047        );
3048
3049        // Verify the production model for the one hydro at stage 0 is ConstantProductivity.
3050        let model = result.production.model(0, 0);
3051        assert!(
3052            matches!(model, ResolvedProductionModel::ConstantProductivity { .. }),
3053            "default production model must be ConstantProductivity"
3054        );
3055
3056        // Verify the evaporation model for the one hydro is None.
3057        let evap = result.evaporation.model(0);
3058        assert!(
3059            matches!(evap, EvaporationModel::None),
3060            "default evaporation model must be None"
3061        );
3062    }
3063
3064    /// Given a valid `StudySetup`, `hydro_models()` returns the stored result.
3065    #[test]
3066    fn hydro_models_accessor_returns_stored_result() {
3067        use crate::hydro_models::ProductionModelSource;
3068
3069        let system = minimal_system(2);
3070        let config = minimal_config(1, 5);
3071        let stochastic = build_stochastic_context(
3072            &system,
3073            42,
3074            None,
3075            &[],
3076            &[],
3077            OpeningTreeInputs::default(),
3078            ClassSchemes {
3079                inflow: Some(SamplingScheme::InSample),
3080                load: Some(SamplingScheme::InSample),
3081                ncs: Some(SamplingScheme::InSample),
3082            },
3083        )
3084        .expect("stochastic context");
3085        let hydro_result = PrepareHydroModelsResult::default_from_system(&system);
3086
3087        let setup = StudySetup::new(&system, &config, stochastic, hydro_result).expect("setup");
3088
3089        let models = &setup.hydro_models;
3090        assert_eq!(
3091            models.provenance.production_sources.len(),
3092            system.hydros().len(),
3093            "hydro_models() must return the stored result (provenance length mismatch)"
3094        );
3095        for (_, source) in &models.provenance.production_sources {
3096            assert_eq!(
3097                *source,
3098                ProductionModelSource::DefaultConstant,
3099                "stored result must preserve DefaultConstant provenance"
3100            );
3101        }
3102    }
3103
3104    /// Given a valid `StudySetup`, `energy_conversion()` returns a set with
3105    /// the correct dimensions and a non-zero accumulated productivity where
3106    /// expected (the system hydro has `ρ_eq=2.5`, and no downstream, so
3107    /// `ρ_acum=2.5` at every stage).
3108    #[test]
3109    fn energy_conversion_accessor_returns_built_set() {
3110        let system = minimal_system(2);
3111        let config = minimal_config(1, 5);
3112        let stochastic = build_stochastic_context(
3113            &system,
3114            42,
3115            None,
3116            &[],
3117            &[],
3118            OpeningTreeInputs::default(),
3119            ClassSchemes {
3120                inflow: Some(SamplingScheme::InSample),
3121                load: Some(SamplingScheme::InSample),
3122                ncs: Some(SamplingScheme::InSample),
3123            },
3124        )
3125        .expect("stochastic context");
3126
3127        // Build a PrepareHydroModelsResult with productivity=2.5 for the single hydro.
3128        // `default_from_system` uses 0.0 as a placeholder; here we supply the
3129        // specific value that the assertion checks against.
3130        let n_study_stages = system.stages().iter().filter(|s| s.id >= 0).count();
3131        let hydro_models_result = {
3132            let mut result = PrepareHydroModelsResult::default_from_system(&system);
3133            let pm = ProductionModelSet::new(
3134                vec![vec![
3135                    ResolvedProductionModel::ConstantProductivity {
3136                        productivity: 2.5
3137                    };
3138                    n_study_stages
3139                ]],
3140                1,
3141                n_study_stages,
3142            );
3143            result.production = pm;
3144            result
3145        };
3146
3147        let setup =
3148            StudySetup::new(&system, &config, stochastic, hydro_models_result).expect("setup");
3149
3150        let ec = setup.energy_conversion();
3151        assert_eq!(ec.n_hydros(), system.hydros().len());
3152        // The minimal system has 2 study stages and 1 hydro (ConstantProductivity,
3153        // productivity=2.5, no downstream). ρ_acum must equal ρ_eq = 2.5.
3154        for s in 0..ec.n_stages() {
3155            assert!(
3156                (ec.accumulated_productivity(0, s) - 2.5).abs() < f64::EPSILON,
3157                "stage {s}: expected ρ_acum=2.5, got {}",
3158                ec.accumulated_productivity(0, s)
3159            );
3160        }
3161    }
3162
3163    /// Given a system whose single hydro is FPHA but lacks VHA geometry and
3164    /// `specific_productivity_mw_per_m3s_per_m`, `StudySetup::new` must
3165    /// propagate the energy-conversion gate failure as an error whose chain
3166    /// contains `EnergyConversionError::FphaMissingEquivalentProductivity`.
3167    #[test]
3168    fn study_setup_propagates_fpha_missing_equivalent_productivity() {
3169        let system = minimal_fpha_misconfigured_system(2);
3170        let config = minimal_config(1, 5);
3171        let stochastic = build_stochastic_context(
3172            &system,
3173            42,
3174            None,
3175            &[],
3176            &[],
3177            OpeningTreeInputs::default(),
3178            ClassSchemes {
3179                inflow: Some(SamplingScheme::InSample),
3180                load: Some(SamplingScheme::InSample),
3181                ncs: Some(SamplingScheme::InSample),
3182            },
3183        )
3184        .expect("stochastic context");
3185
3186        let err = StudySetup::new(
3187            &system,
3188            &config,
3189            stochastic,
3190            PrepareHydroModelsResult::default_from_system(&system),
3191        )
3192        .expect_err("setup must reject misconfigured FPHA hydro");
3193
3194        let msg = err.to_string();
3195        assert!(
3196            msg.contains("cannot derive ρ_eq"),
3197            "error must come from FphaMissingEquivalentProductivity Display; got: {msg}"
3198        );
3199        assert!(
3200            msg.contains("H_FPHA_BAD"),
3201            "error must mention the offending hydro by name; got: {msg}"
3202        );
3203    }
3204
3205    /// Build a `StageIndexer` for lag tests: N hydros, L lags, no equipment columns.
3206    fn indexer_for_lag_test(hydro_count: usize, max_par_order: usize) -> StageIndexer {
3207        StageIndexer::new(hydro_count, max_par_order)
3208    }
3209
3210    /// Build a 2-hydro system (IDs 1 and 2) with `n_stages` study stages and
3211    /// PAR order 2 AR coefficients on all stages, with `inflow_lags: true`.
3212    ///
3213    /// Provides `past_inflows` in `initial_conditions` with the given values
3214    /// for both hydros.
3215    #[allow(
3216        clippy::too_many_lines,
3217        clippy::cast_possible_truncation,
3218        clippy::cast_possible_wrap,
3219        clippy::items_after_statements
3220    )]
3221    fn minimal_system_2_hydros_with_past_inflows(
3222        n_stages: usize,
3223        h1_past: Vec<f64>,
3224        h2_past: Vec<f64>,
3225    ) -> cobre_core::System {
3226        use chrono::NaiveDate;
3227
3228        let bus = Bus {
3229            id: EntityId(1),
3230            name: "B1".to_string(),
3231            deficit_segments: vec![DeficitSegment {
3232                depth_mw: None,
3233                cost_per_mwh: 500.0,
3234            }],
3235            excess_cost: 0.0,
3236        };
3237
3238        let make_hydro = |id: i32, name: &str| Hydro {
3239            id: EntityId(id),
3240            name: name.to_string(),
3241            bus_id: EntityId(1),
3242            downstream_id: None,
3243            entry_stage_id: None,
3244            exit_stage_id: None,
3245            min_storage_hm3: 0.0,
3246            max_storage_hm3: 200.0,
3247            min_outflow_m3s: 0.0,
3248            max_outflow_m3s: None,
3249            generation_model: HydroGenerationModel::ConstantProductivity,
3250            min_turbined_m3s: 0.0,
3251            max_turbined_m3s: 100.0,
3252            specific_productivity_mw_per_m3s_per_m: None,
3253            min_generation_mw: 0.0,
3254            max_generation_mw: 250.0,
3255            tailrace: None,
3256            hydraulic_losses: None,
3257            efficiency: None,
3258            evaporation_coefficients_mm: None,
3259            evaporation_reference_volumes_hm3: None,
3260            diversion: None,
3261            filling: None,
3262            penalties: HydroPenalties {
3263                spillage_cost: 0.01,
3264                diversion_cost: 0.0,
3265                turbined_cost: 0.0,
3266                storage_violation_below_cost: 0.0,
3267                filling_target_violation_cost: 0.0,
3268                turbined_violation_below_cost: 0.0,
3269                outflow_violation_below_cost: 0.0,
3270                outflow_violation_above_cost: 0.0,
3271                generation_violation_below_cost: 0.0,
3272                evaporation_violation_cost: 0.0,
3273                water_withdrawal_violation_cost: 0.0,
3274                water_withdrawal_violation_pos_cost: 0.0,
3275                water_withdrawal_violation_neg_cost: 0.0,
3276                evaporation_violation_pos_cost: 0.0,
3277                evaporation_violation_neg_cost: 0.0,
3278                inflow_nonnegativity_cost: 1000.0,
3279            },
3280        };
3281
3282        let n_st = n_stages.max(1);
3283        let stages: Vec<Stage> = (0..n_stages)
3284            .map(|i| Stage {
3285                index: i,
3286                id: i as i32,
3287                start_date: NaiveDate::from_ymd_opt(2020, (i % 12 + 1) as u32, 1).unwrap(),
3288                end_date: NaiveDate::from_ymd_opt(
3289                    if (i % 12 + 1) == 12 { 2021 } else { 2020 },
3290                    ((i % 12 + 1) % 12 + 1) as u32,
3291                    1,
3292                )
3293                .unwrap(),
3294                season_id: Some(i),
3295                blocks: vec![Block {
3296                    index: 0,
3297                    name: "S".to_string(),
3298                    duration_hours: 744.0,
3299                }],
3300                block_mode: BlockMode::Parallel,
3301                state_config: StageStateConfig {
3302                    storage: true,
3303                    inflow_lags: true,
3304                },
3305                risk_config: StageRiskConfig::Expectation,
3306                scenario_config: ScenarioSourceConfig {
3307                    branching_factor: 1,
3308                    noise_method: NoiseMethod::Saa,
3309                },
3310            })
3311            .collect();
3312
3313        let inflow_models: Vec<InflowModel> = (0..n_stages)
3314            .flat_map(|i| {
3315                [1_i32, 2].map(|hid| InflowModel {
3316                    hydro_id: EntityId(hid),
3317                    stage_id: i as i32,
3318                    mean_m3s: 80.0,
3319                    std_m3s: 20.0,
3320                    ar_coefficients: vec![0.5, 0.3],
3321                    residual_std_ratio: 0.8,
3322                    annual: None,
3323                })
3324            })
3325            .collect();
3326
3327        let load_models: Vec<LoadModel> = (0..n_stages)
3328            .map(|i| LoadModel {
3329                bus_id: EntityId(1),
3330                stage_id: i as i32,
3331                mean_mw: 100.0,
3332                std_mw: 0.0,
3333            })
3334            .collect();
3335
3336        fn default_hydro_bounds() -> HydroStageBounds {
3337            HydroStageBounds {
3338                min_storage_hm3: 0.0,
3339                max_storage_hm3: 200.0,
3340                min_turbined_m3s: 0.0,
3341                max_turbined_m3s: 100.0,
3342                min_outflow_m3s: 0.0,
3343                max_outflow_m3s: None,
3344                min_generation_mw: 0.0,
3345                max_generation_mw: 250.0,
3346                max_diversion_m3s: None,
3347                filling_inflow_m3s: 0.0,
3348                water_withdrawal_m3s: 0.0,
3349            }
3350        }
3351
3352        fn default_hydro_penalties() -> HydroStagePenalties {
3353            HydroStagePenalties {
3354                spillage_cost: 0.01,
3355                diversion_cost: 0.0,
3356                turbined_cost: 0.0,
3357                storage_violation_below_cost: 500.0,
3358                filling_target_violation_cost: 0.0,
3359                turbined_violation_below_cost: 0.0,
3360                outflow_violation_below_cost: 0.0,
3361                outflow_violation_above_cost: 0.0,
3362                generation_violation_below_cost: 0.0,
3363                evaporation_violation_cost: 0.0,
3364                water_withdrawal_violation_cost: 0.0,
3365                water_withdrawal_violation_pos_cost: 0.0,
3366                water_withdrawal_violation_neg_cost: 0.0,
3367                evaporation_violation_pos_cost: 0.0,
3368                evaporation_violation_neg_cost: 0.0,
3369                inflow_nonnegativity_cost: 1000.0,
3370            }
3371        }
3372
3373        let bounds = ResolvedBounds::new(
3374            &BoundsCountsSpec {
3375                n_hydros: 2,
3376                n_thermals: 0,
3377                n_lines: 0,
3378                n_pumping: 0,
3379                n_contracts: 0,
3380                n_stages: n_st,
3381                k_max: 0,
3382            },
3383            &BoundsDefaults {
3384                hydro: default_hydro_bounds(),
3385                thermal: ThermalStageBounds {
3386                    min_generation_mw: 0.0,
3387                    max_generation_mw: 0.0,
3388                    cost_per_mwh: 0.0,
3389                },
3390                line: LineStageBounds {
3391                    direct_mw: 0.0,
3392                    reverse_mw: 0.0,
3393                },
3394                pumping: PumpingStageBounds {
3395                    min_flow_m3s: 0.0,
3396                    max_flow_m3s: 0.0,
3397                },
3398                contract: ContractStageBounds {
3399                    min_mw: 0.0,
3400                    max_mw: 0.0,
3401                    price_per_mwh: 0.0,
3402                },
3403            },
3404        );
3405
3406        let penalties = ResolvedPenalties::new(
3407            &PenaltiesCountsSpec {
3408                n_hydros: 2,
3409                n_buses: 1,
3410                n_lines: 0,
3411                n_ncs: 0,
3412                n_stages: n_st,
3413            },
3414            &PenaltiesDefaults {
3415                hydro: default_hydro_penalties(),
3416                bus: BusStagePenalties { excess_cost: 0.0 },
3417                line: LineStagePenalties { exchange_cost: 0.0 },
3418                ncs: NcsStagePenalties {
3419                    curtailment_cost: 0.0,
3420                },
3421            },
3422        );
3423
3424        let past_inflows = vec![
3425            cobre_core::HydroPastInflows {
3426                hydro_id: EntityId(1),
3427                values_m3s: h1_past,
3428                season_ids: None,
3429            },
3430            cobre_core::HydroPastInflows {
3431                hydro_id: EntityId(2),
3432                values_m3s: h2_past,
3433                season_ids: None,
3434            },
3435        ];
3436
3437        SystemBuilder::new()
3438            .buses(vec![bus])
3439            .thermals(vec![])
3440            .hydros(vec![make_hydro(1, "H1"), make_hydro(2, "H2")])
3441            .stages(stages)
3442            .inflow_models(inflow_models)
3443            .load_models(load_models)
3444            .bounds(bounds)
3445            .penalties(penalties)
3446            .initial_conditions(cobre_core::InitialConditions {
3447                storage: vec![],
3448                filling_storage: vec![],
3449                past_inflows,
3450                past_anticipated_commitments: vec![],
3451                recent_observations: vec![],
3452            })
3453            .build()
3454            .expect("minimal_system_2_hydros_with_past_inflows: valid")
3455    }
3456
3457    /// Given 2 hydros (IDs 1, 2), `max_par_order`=2, and `past_inflows` set,
3458    /// `build_initial_state` populates lag slots correctly.
3459    ///
3460    /// Hydro idx 0 (id=1): lag 0 = 600.0, lag 1 = 500.0
3461    /// Hydro idx 1 (id=2): lag 0 = 200.0, lag 1 = 100.0
3462    #[test]
3463    fn build_initial_state_populates_lags_from_past_inflows() {
3464        use super::build_initial_state;
3465
3466        let system =
3467            minimal_system_2_hydros_with_past_inflows(1, vec![600.0, 500.0], vec![200.0, 100.0]);
3468        let indexer = indexer_for_lag_test(2, 2);
3469
3470        let state = build_initial_state(&system, &indexer);
3471
3472        // State layout: storage(0..2), lags(2..6) in lag-major order.
3473        // Lag-major: slot = s + lag * N + h, where N = 2.
3474        // lag0_h0 = 600.0 at s+0, lag0_h1 = 200.0 at s+1,
3475        // lag1_h0 = 500.0 at s+2, lag1_h1 = 100.0 at s+3.
3476        let s = indexer.inflow_lags.start;
3477        assert!(
3478            (state[s] - 600.0).abs() < 1e-10,
3479            "lag0 hydro 0: expected 600.0, got {}",
3480            state[s]
3481        );
3482        assert!(
3483            (state[s + 1] - 200.0).abs() < 1e-10,
3484            "lag0 hydro 1: expected 200.0, got {}",
3485            state[s + 1]
3486        );
3487        assert!(
3488            (state[s + 2] - 500.0).abs() < 1e-10,
3489            "lag1 hydro 0: expected 500.0, got {}",
3490            state[s + 2]
3491        );
3492        assert!(
3493            (state[s + 3] - 100.0).abs() < 1e-10,
3494            "lag1 hydro 1: expected 100.0, got {}",
3495            state[s + 3]
3496        );
3497        assert_eq!(
3498            state.len(),
3499            indexer.n_state,
3500            "state length must equal n_state"
3501        );
3502    }
3503
3504    /// Given no `past_inflows` entries, all lag slots remain 0.0.
3505    #[test]
3506    fn build_initial_state_empty_past_inflows_leaves_zero_lags() {
3507        use super::build_initial_state;
3508
3509        let system = minimal_system(2);
3510        let indexer = indexer_for_lag_test(1, 3);
3511
3512        let state = build_initial_state(&system, &indexer);
3513
3514        let s = indexer.inflow_lags.start;
3515        for l in 0..3 {
3516            assert!(
3517                state[s + l].abs() < 1e-10,
3518                "lag slot {l} should be 0.0 when past_inflows is empty, got {}",
3519                state[s + l]
3520            );
3521        }
3522    }
3523
3524    /// Given `past_inflows` only for a hydro not in the system, lag slots
3525    /// for the system's hydros remain 0.0.
3526    #[test]
3527    fn build_initial_state_unknown_hydro_in_past_inflows_stays_zero() {
3528        use super::build_initial_state;
3529
3530        // minimal_system has 1 hydro id=3; build a system with past_inflows
3531        // for hydro id=99 (not in registry).
3532        let system = {
3533            // Reuse minimal_system(2) but add past_inflows for a non-existent hydro.
3534            // Since minimal_system doesn't support overriding IC, we use
3535            // build_initial_state directly on the base system — its IC has
3536            // no past_inflows, so both lag slots are 0.0.
3537            minimal_system(2)
3538        };
3539        let indexer = indexer_for_lag_test(1, 2);
3540
3541        let state = build_initial_state(&system, &indexer);
3542
3543        let s = indexer.inflow_lags.start;
3544        assert!(
3545            state[s].abs() < 1e-10,
3546            "lag 0 should be 0.0 when past_inflows is absent, got {}",
3547            state[s]
3548        );
3549        assert!(
3550            state[s + 1].abs() < 1e-10,
3551            "lag 1 should be 0.0 when past_inflows is absent, got {}",
3552            state[s + 1]
3553        );
3554    }
3555
3556    /// Integration test: `StudySetup::new` with `past_inflows` in the system's
3557    /// initial conditions produces `initial_state()` with non-zero lag values.
3558    #[test]
3559    fn study_setup_initial_state_has_nonzero_lags_from_past_inflows() {
3560        let system =
3561            minimal_system_2_hydros_with_past_inflows(3, vec![600.0, 500.0], vec![200.0, 100.0]);
3562        let config = minimal_config(1, 10);
3563        let stochastic = build_stochastic_context(
3564            &system,
3565            42,
3566            None,
3567            &[],
3568            &[],
3569            OpeningTreeInputs::default(),
3570            ClassSchemes {
3571                inflow: Some(SamplingScheme::InSample),
3572                load: Some(SamplingScheme::InSample),
3573                ncs: Some(SamplingScheme::InSample),
3574            },
3575        )
3576        .expect("stochastic context");
3577
3578        let setup = StudySetup::new(
3579            &system,
3580            &config,
3581            stochastic,
3582            PrepareHydroModelsResult::default_from_system(&system),
3583        )
3584        .expect("setup with past_inflows");
3585
3586        let state = &setup.initial_state;
3587
3588        // With 2 hydros (N=2) and max_par_order=2 (L=2), lag slots start at N=2.
3589        // Lag-major layout: slot = lag_start + lag * N + h.
3590        // lag0_h0 = 600.0 at [2], lag0_h1 = 200.0 at [3],
3591        // lag1_h0 = 500.0 at [4], lag1_h1 = 100.0 at [5].
3592        let n_hydros = 2;
3593        let lag_start = n_hydros;
3594        assert!(
3595            (state[lag_start] - 600.0).abs() < 1e-10,
3596            "lag0 hydro 0 should be 600.0 via StudySetup, got {}",
3597            state[lag_start]
3598        );
3599        assert!(
3600            (state[lag_start + 1] - 200.0).abs() < 1e-10,
3601            "lag0 hydro 1 should be 200.0 via StudySetup, got {}",
3602            state[lag_start + 1]
3603        );
3604        assert!(
3605            (state[lag_start + 2] - 500.0).abs() < 1e-10,
3606            "lag1 hydro 0 should be 500.0 via StudySetup, got {}",
3607            state[lag_start + 2]
3608        );
3609        assert!(
3610            (state[lag_start + 3] - 100.0).abs() < 1e-10,
3611            "lag1 hydro 1 should be 100.0 via StudySetup, got {}",
3612            state[lag_start + 3]
3613        );
3614    }
3615
3616    /// Given `max_par_order`=0, no lag slots exist; state is storage-only.
3617    #[test]
3618    fn build_initial_state_no_lags_state_is_storage_only() {
3619        use super::build_initial_state;
3620
3621        let system = minimal_system(2);
3622        let indexer = indexer_for_lag_test(1, 0);
3623
3624        // n_state = N*(1+L) = 1*(1+0) = 1
3625        assert_eq!(indexer.n_state, 1);
3626        assert!(
3627            indexer.inflow_lags.is_empty(),
3628            "inflow_lags range should be empty for L=0"
3629        );
3630
3631        let state = build_initial_state(&system, &indexer);
3632
3633        assert_eq!(state.len(), 1, "state length must equal n_state=1");
3634    }
3635
3636    // -----------------------------------------------------------------------
3637    // build_initial_state — anticipated_state seed
3638    // -----------------------------------------------------------------------
3639
3640    /// Build a `StageIndexer` that has N=1 hydro, L=0 lags, and the given
3641    /// anticipated-thermal metadata, using `with_equipment_and_evaporation`.
3642    ///
3643    /// This gives a non-zero `anticipated_state` block in the state vector.
3644    fn indexer_with_anticipated(
3645        n_anticipated: usize,
3646        k_values: &[usize],        // K_i per plant, length == n_anticipated
3647        thermal_indices: &[usize], // global thermal index per plant
3648    ) -> StageIndexer {
3649        use crate::indexer::{EquipmentCounts, EvapConfig, FphaColumnLayout};
3650
3651        let k_max = k_values.iter().copied().max().unwrap_or(0);
3652        StageIndexer::with_equipment_and_evaporation(
3653            &EquipmentCounts {
3654                hydro_count: 1,
3655                max_par_order: 0,
3656                n_thermals: n_anticipated, // at least cover the anticipated plants
3657                n_lines: 0,
3658                n_buses: 1,
3659                n_blks: 1,
3660                has_inflow_penalty: false,
3661                max_deficit_segments: 1,
3662                n_anticipated,
3663                k_max,
3664                anticipated_lead_stages: k_values.to_vec(),
3665                anticipated_thermal_indices: thermal_indices.to_vec(),
3666            },
3667            &FphaColumnLayout {
3668                hydro_indices: vec![],
3669                planes_per_hydro: vec![],
3670            },
3671            &EvapConfig {
3672                hydro_indices: vec![],
3673            },
3674        )
3675    }
3676
3677    /// Build a 1-bus / 1-hydro system whose `thermals` list contains N
3678    /// anticipated thermals with the given `lead_stages` values.  Thermal IDs
3679    /// are assigned as `EntityId(10 + i as i32)` so they are distinct from the
3680    /// bus (ID 1) and the hydro (ID 3).  `past_anticipated_commitments` is set
3681    /// to `past_commits` (must be pre-sorted by `thermal_id`).
3682    #[allow(
3683        clippy::too_many_lines,
3684        clippy::cast_possible_truncation,
3685        clippy::cast_possible_wrap,
3686        clippy::items_after_statements
3687    )]
3688    fn system_with_anticipated_thermals(
3689        k_values: &[u32],
3690        past_commits: Vec<cobre_core::AnticipatedCommitmentHistory>,
3691    ) -> cobre_core::System {
3692        use chrono::NaiveDate;
3693
3694        let bus = Bus {
3695            id: EntityId(1),
3696            name: "B1".to_string(),
3697            deficit_segments: vec![DeficitSegment {
3698                depth_mw: None,
3699                cost_per_mwh: 500.0,
3700            }],
3701            excess_cost: 0.0,
3702        };
3703
3704        // Build N anticipated thermals. IDs are 10, 11, 12, … so they are
3705        // always above the hydro ID (3) and can be easily identified.
3706        let thermals: Vec<Thermal> = k_values
3707            .iter()
3708            .enumerate()
3709            .map(|(i, &k)| Thermal {
3710                id: EntityId(10 + i as i32),
3711                name: format!("AT{i}"),
3712                bus_id: EntityId(1),
3713                min_generation_mw: 0.0,
3714                max_generation_mw: 100.0,
3715                cost_per_mwh: 50.0,
3716                anticipated_config: Some(AnticipatedConfig { lead_stages: k }),
3717                entry_stage_id: None,
3718                exit_stage_id: None,
3719            })
3720            .collect();
3721
3722        let hydro = Hydro {
3723            id: EntityId(3),
3724            name: "H1".to_string(),
3725            bus_id: EntityId(1),
3726            downstream_id: None,
3727            entry_stage_id: None,
3728            exit_stage_id: None,
3729            min_storage_hm3: 0.0,
3730            max_storage_hm3: 200.0,
3731            min_outflow_m3s: 0.0,
3732            max_outflow_m3s: None,
3733            generation_model: HydroGenerationModel::ConstantProductivity,
3734            min_turbined_m3s: 0.0,
3735            max_turbined_m3s: 100.0,
3736            specific_productivity_mw_per_m3s_per_m: None,
3737            min_generation_mw: 0.0,
3738            max_generation_mw: 250.0,
3739            tailrace: None,
3740            hydraulic_losses: None,
3741            efficiency: None,
3742            evaporation_coefficients_mm: None,
3743            evaporation_reference_volumes_hm3: None,
3744            diversion: None,
3745            filling: None,
3746            penalties: HydroPenalties {
3747                spillage_cost: 0.01,
3748                diversion_cost: 0.0,
3749                turbined_cost: 0.0,
3750                storage_violation_below_cost: 0.0,
3751                filling_target_violation_cost: 0.0,
3752                turbined_violation_below_cost: 0.0,
3753                outflow_violation_below_cost: 0.0,
3754                outflow_violation_above_cost: 0.0,
3755                generation_violation_below_cost: 0.0,
3756                evaporation_violation_cost: 0.0,
3757                water_withdrawal_violation_cost: 0.0,
3758                water_withdrawal_violation_pos_cost: 0.0,
3759                water_withdrawal_violation_neg_cost: 0.0,
3760                evaporation_violation_pos_cost: 0.0,
3761                evaporation_violation_neg_cost: 0.0,
3762                inflow_nonnegativity_cost: 1000.0,
3763            },
3764        };
3765
3766        let n_stages = 2_usize;
3767        let stages: Vec<Stage> = (0..n_stages)
3768            .map(|i| Stage {
3769                index: i,
3770                id: i as i32,
3771                start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
3772                end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
3773                season_id: None,
3774                blocks: vec![Block {
3775                    index: 0,
3776                    name: "S".to_string(),
3777                    duration_hours: 744.0,
3778                }],
3779                block_mode: BlockMode::Parallel,
3780                state_config: StageStateConfig {
3781                    storage: true,
3782                    inflow_lags: false,
3783                },
3784                risk_config: StageRiskConfig::Expectation,
3785                scenario_config: ScenarioSourceConfig {
3786                    branching_factor: 1,
3787                    noise_method: NoiseMethod::Saa,
3788                },
3789            })
3790            .collect();
3791
3792        let inflow_models: Vec<InflowModel> = (0..n_stages)
3793            .map(|i| InflowModel {
3794                hydro_id: EntityId(3),
3795                stage_id: i as i32,
3796                mean_m3s: 80.0,
3797                std_m3s: 20.0,
3798                ar_coefficients: vec![],
3799                residual_std_ratio: 1.0,
3800                annual: None,
3801            })
3802            .collect();
3803
3804        let load_models: Vec<LoadModel> = (0..n_stages)
3805            .map(|i| LoadModel {
3806                bus_id: EntityId(1),
3807                stage_id: i as i32,
3808                mean_mw: 100.0,
3809                std_mw: 0.0,
3810            })
3811            .collect();
3812
3813        let k_max_bounds = k_values.iter().copied().max().unwrap_or(0) as usize;
3814        let n_thermals = k_values.len();
3815
3816        fn default_hydro_bounds() -> HydroStageBounds {
3817            HydroStageBounds {
3818                min_storage_hm3: 0.0,
3819                max_storage_hm3: 200.0,
3820                min_turbined_m3s: 0.0,
3821                max_turbined_m3s: 100.0,
3822                min_outflow_m3s: 0.0,
3823                max_outflow_m3s: None,
3824                min_generation_mw: 0.0,
3825                max_generation_mw: 250.0,
3826                max_diversion_m3s: None,
3827                filling_inflow_m3s: 0.0,
3828                water_withdrawal_m3s: 0.0,
3829            }
3830        }
3831
3832        fn default_hydro_penalties() -> HydroStagePenalties {
3833            HydroStagePenalties {
3834                spillage_cost: 0.01,
3835                diversion_cost: 0.0,
3836                turbined_cost: 0.0,
3837                storage_violation_below_cost: 500.0,
3838                filling_target_violation_cost: 0.0,
3839                turbined_violation_below_cost: 0.0,
3840                outflow_violation_below_cost: 0.0,
3841                outflow_violation_above_cost: 0.0,
3842                generation_violation_below_cost: 0.0,
3843                evaporation_violation_cost: 0.0,
3844                water_withdrawal_violation_cost: 0.0,
3845                water_withdrawal_violation_pos_cost: 0.0,
3846                water_withdrawal_violation_neg_cost: 0.0,
3847                evaporation_violation_pos_cost: 0.0,
3848                evaporation_violation_neg_cost: 0.0,
3849                inflow_nonnegativity_cost: 1000.0,
3850            }
3851        }
3852
3853        let bounds = ResolvedBounds::new(
3854            &BoundsCountsSpec {
3855                n_hydros: 1,
3856                n_thermals,
3857                n_lines: 0,
3858                n_pumping: 0,
3859                n_contracts: 0,
3860                n_stages,
3861                k_max: k_max_bounds,
3862            },
3863            &BoundsDefaults {
3864                hydro: default_hydro_bounds(),
3865                thermal: ThermalStageBounds {
3866                    min_generation_mw: 0.0,
3867                    max_generation_mw: 100.0,
3868                    cost_per_mwh: 0.0,
3869                },
3870                line: LineStageBounds {
3871                    direct_mw: 0.0,
3872                    reverse_mw: 0.0,
3873                },
3874                pumping: PumpingStageBounds {
3875                    min_flow_m3s: 0.0,
3876                    max_flow_m3s: 0.0,
3877                },
3878                contract: ContractStageBounds {
3879                    min_mw: 0.0,
3880                    max_mw: 0.0,
3881                    price_per_mwh: 0.0,
3882                },
3883            },
3884        );
3885
3886        let penalties = ResolvedPenalties::new(
3887            &PenaltiesCountsSpec {
3888                n_hydros: 1,
3889                n_buses: 1,
3890                n_lines: 0,
3891                n_ncs: 0,
3892                n_stages,
3893            },
3894            &PenaltiesDefaults {
3895                hydro: default_hydro_penalties(),
3896                bus: BusStagePenalties { excess_cost: 0.0 },
3897                line: LineStagePenalties { exchange_cost: 0.0 },
3898                ncs: NcsStagePenalties {
3899                    curtailment_cost: 0.0,
3900                },
3901            },
3902        );
3903
3904        SystemBuilder::new()
3905            .buses(vec![bus])
3906            .thermals(thermals)
3907            .hydros(vec![hydro])
3908            .stages(stages)
3909            .inflow_models(inflow_models)
3910            .load_models(load_models)
3911            .bounds(bounds)
3912            .penalties(penalties)
3913            .initial_conditions(cobre_core::InitialConditions {
3914                storage: vec![],
3915                filling_storage: vec![],
3916                past_inflows: vec![],
3917                past_anticipated_commitments: past_commits,
3918                recent_observations: vec![],
3919            })
3920            .build()
3921            .expect("system_with_anticipated_thermals: valid")
3922    }
3923
3924    /// AC-1: A system with `n_anticipated == 0` produces an unchanged state
3925    /// vector (length `n_state`, `anticipated_state` block is empty).
3926    ///
3927    /// Regression guard: confirms zero-anticipated path is unaffected.
3928    #[test]
3929    fn build_initial_state_no_anticipated_state_unchanged() {
3930        use super::build_initial_state;
3931
3932        let system = minimal_system(2);
3933        let indexer = indexer_for_lag_test(1, 0);
3934
3935        // n_anticipated == 0; anticipated_state range is 0..0.
3936        assert_eq!(indexer.n_anticipated, 0);
3937        assert!(indexer.anticipated_state.is_empty());
3938
3939        let state = build_initial_state(&system, &indexer);
3940
3941        assert_eq!(
3942            state.len(),
3943            indexer.n_state,
3944            "state length must equal n_state"
3945        );
3946        // All slots are 0.0 — storage IC is empty in minimal_system.
3947        assert!(
3948            state.iter().all(|&v| v == 0.0),
3949            "all state slots must be 0.0 when no anticipated thermals and no ICs set"
3950        );
3951    }
3952
3953    /// AC-2: `n_anticipated == 1`, `k_max == 2`, `K_0 == 2` and
3954    /// `past_anticipated_commitments` has one entry with `values_mw: [50.0, 75.0]`.
3955    ///
3956    /// Expected slot-major layout (`n_ant=1`):
3957    ///   slot 0: `ant_start + 0*1 + 0 = ant_start`   → 50.0
3958    ///   slot 1: `ant_start + 1*1 + 0 = ant_start+1`  → 75.0
3959    #[test]
3960    fn build_initial_state_single_anticipated_thermal_k2() {
3961        use super::build_initial_state;
3962        use cobre_core::AnticipatedCommitmentHistory;
3963
3964        // Thermal ID 10 is the first (and only) anticipated plant.
3965        // The system thermals() sorts by ID, so global_idx == 0 for ID 10.
3966        let past_commits = vec![AnticipatedCommitmentHistory {
3967            thermal_id: EntityId(10),
3968            values_mw: vec![50.0, 75.0],
3969        }];
3970        let system = system_with_anticipated_thermals(&[2], past_commits);
3971
3972        // indexer: 1 hydro, 0 lags, 1 anticipated thermal (global idx 0), k_max=2.
3973        let indexer = indexer_with_anticipated(1, &[2], &[0]);
3974
3975        let state = build_initial_state(&system, &indexer);
3976
3977        assert_eq!(
3978            state.len(),
3979            indexer.n_state,
3980            "state length must equal n_state"
3981        );
3982        let ant_start = indexer.anticipated_state.start;
3983        assert!(
3984            (state[ant_start] - 50.0).abs() < 1e-10,
3985            "slot 0 expected 50.0, got {}",
3986            state[ant_start]
3987        );
3988        assert!(
3989            (state[ant_start + 1] - 75.0).abs() < 1e-10,
3990            "slot 1 expected 75.0, got {}",
3991            state[ant_start + 1]
3992        );
3993    }
3994
3995    /// AC-3: `n_anticipated == 2`, `k_max == 3`, `K_0 == 2`, `K_1 == 3`.
3996    ///
3997    /// Slot-major layout with `n_ant=2`:
3998    ///
3999    /// - (slot 0, plant 0): `ant_start + 0*2+0` → 10.0
4000    /// - (slot 0, plant 1): `ant_start + 0*2+1` → 100.0
4001    /// - (slot 1, plant 0): `ant_start + 1*2+0` → 20.0
4002    /// - (slot 1, plant 1): `ant_start + 1*2+1` → 200.0
4003    /// - (slot 2, plant 0): `ant_start + 2*2+0` → 0.0  (padding: `K_0=2 < k_max=3`)
4004    /// - (slot 2, plant 1): `ant_start + 2*2+1` → 300.0
4005    #[test]
4006    fn build_initial_state_two_anticipated_thermals_mixed_k() {
4007        use super::build_initial_state;
4008        use cobre_core::AnticipatedCommitmentHistory;
4009
4010        // Thermal IDs 10 (K=2) and 11 (K=3); sorted ascending so global order
4011        // in system.thermals() is idx 0 → ID 10, idx 1 → ID 11.
4012        let past_commits = vec![
4013            AnticipatedCommitmentHistory {
4014                thermal_id: EntityId(10),
4015                values_mw: vec![10.0, 20.0],
4016            },
4017            AnticipatedCommitmentHistory {
4018                thermal_id: EntityId(11),
4019                values_mw: vec![100.0, 200.0, 300.0],
4020            },
4021        ];
4022        let system = system_with_anticipated_thermals(&[2, 3], past_commits);
4023
4024        // indexer: 1 hydro, 0 lags, 2 anticipated thermals
4025        //   anticipated_thermal_indices = [0, 1]  (global idxs in thermals())
4026        //   anticipated_lead_stages     = [2, 3]
4027        //   k_max                       = 3
4028        let indexer = indexer_with_anticipated(2, &[2, 3], &[0, 1]);
4029
4030        let state = build_initial_state(&system, &indexer);
4031
4032        assert_eq!(
4033            state.len(),
4034            indexer.n_state,
4035            "state length must equal n_state"
4036        );
4037        // n_ant = 2, k_max = 3.  Slot-major offsets from ant_start:
4038        //   (slot, plant) → offset = slot * n_ant + plant
4039        //   (0,0)→0, (0,1)→1, (1,0)→2, (1,1)→3, (2,0)→4, (2,1)→5
4040        let s = indexer.anticipated_state.start;
4041
4042        assert!(
4043            (state[s] - 10.0).abs() < 1e-10,
4044            "slot 0 plant 0: expected 10.0, got {}",
4045            state[s]
4046        );
4047        assert!(
4048            (state[s + 1] - 100.0).abs() < 1e-10,
4049            "slot 0 plant 1: expected 100.0, got {}",
4050            state[s + 1]
4051        );
4052        assert!(
4053            (state[s + 2] - 20.0).abs() < 1e-10,
4054            "slot 1 plant 0: expected 20.0, got {}",
4055            state[s + 2]
4056        );
4057        assert!(
4058            (state[s + 3] - 200.0).abs() < 1e-10,
4059            "slot 1 plant 1: expected 200.0, got {}",
4060            state[s + 3]
4061        );
4062        assert!(
4063            state[s + 4].abs() < 1e-10,
4064            "slot 2 plant 0 (K_0=2 padding): expected 0.0, got {}",
4065            state[s + 4]
4066        );
4067        assert!(
4068            (state[s + 5] - 300.0).abs() < 1e-10,
4069            "slot 2 plant 1: expected 300.0, got {}",
4070            state[s + 5]
4071        );
4072    }
4073
4074    /// AC-4: `n_anticipated == 1`, `k_max == 2`, but `past_anticipated_commitments`
4075    /// is empty.  All `anticipated_state` slots must remain 0.0 (no panic).
4076    #[test]
4077    fn build_initial_state_empty_past_commitments_leaves_zeros() {
4078        use super::build_initial_state;
4079
4080        let system = system_with_anticipated_thermals(&[2], vec![]);
4081
4082        let indexer = indexer_with_anticipated(1, &[2], &[0]);
4083
4084        let state = build_initial_state(&system, &indexer);
4085
4086        assert_eq!(
4087            state.len(),
4088            indexer.n_state,
4089            "state length must equal n_state"
4090        );
4091        let ant_start = indexer.anticipated_state.start;
4092        let ant_end = indexer.anticipated_state.end;
4093        for (i, &v) in state[ant_start..ant_end].iter().enumerate() {
4094            assert!(
4095                v.abs() < 1e-10,
4096                "anticipated_state slot {i} expected 0.0, got {v}"
4097            );
4098        }
4099    }
4100
4101    /// AC-5: `past_anticipated_commitments` contains a `thermal_id` that does
4102    /// not match any anticipated thermal.  The function silently ignores it and
4103    /// all `anticipated_state` slots remain 0.0 (no panic).
4104    #[test]
4105    fn build_initial_state_unknown_thermal_id_silently_skipped() {
4106        use super::build_initial_state;
4107        use cobre_core::AnticipatedCommitmentHistory;
4108
4109        // System has one anticipated thermal (ID 10).
4110        // past_anticipated_commitments references ID 99999 — not in the system.
4111        let past_commits = vec![AnticipatedCommitmentHistory {
4112            thermal_id: EntityId(99999),
4113            values_mw: vec![42.0, 43.0],
4114        }];
4115        let system = system_with_anticipated_thermals(&[2], past_commits);
4116
4117        let indexer = indexer_with_anticipated(1, &[2], &[0]);
4118
4119        let state = build_initial_state(&system, &indexer);
4120
4121        assert_eq!(
4122            state.len(),
4123            indexer.n_state,
4124            "state length must equal n_state"
4125        );
4126        let ant_start = indexer.anticipated_state.start;
4127        let ant_end = indexer.anticipated_state.end;
4128        for (i, &v) in state[ant_start..ant_end].iter().enumerate() {
4129            assert!(
4130                v.abs() < 1e-10,
4131                "anticipated_state slot {i} expected 0.0 for unknown ID, got {v}"
4132            );
4133        }
4134    }
4135
4136    /// AC-6 (happy path with padding slot): two anticipated plants with
4137    /// `K_0 = 1` and `K_1 = 2`, so `k_max = 2`. The plant-0 ring-buffer column
4138    /// has one valid slot (slot 0) and one padding slot (slot 1).
4139    ///
4140    /// `past_anticipated_commitments` carries `[100.0]` for plant 0 and
4141    /// `[50.0, 75.0]` for plant 1, each of length `K_i` exactly (the contract
4142    /// the cobre-io validator enforces in production).
4143    ///
4144    /// Expected layout (`n_ant = 2`, slot-major):
4145    ///   - `ant_start + 0*2 + 0` (slot 0, plant 0) -> 100.0  (seed)
4146    ///   - `ant_start + 0*2 + 1` (slot 0, plant 1) ->  50.0  (seed)
4147    ///   - `ant_start + 1*2 + 0` (slot 1, plant 0) ->   0.0  (padding; `K_0=1` < `k_max=2`)
4148    ///   - `ant_start + 1*2 + 1` (slot 1, plant 1) ->  75.0  (seed)
4149    ///
4150    /// The padding-slot `debug_assert!` must not fire because the `.min(k_i)`
4151    /// clamp prevents writing past slot `K_0=1` on plant 0.
4152    #[test]
4153    fn build_initial_state_anticipated_seed_padding_slot_stays_zero() {
4154        use super::build_initial_state;
4155        use cobre_core::AnticipatedCommitmentHistory;
4156
4157        let past_commits = vec![
4158            AnticipatedCommitmentHistory {
4159                thermal_id: EntityId(10),
4160                values_mw: vec![100.0],
4161            },
4162            AnticipatedCommitmentHistory {
4163                thermal_id: EntityId(11),
4164                values_mw: vec![50.0, 75.0],
4165            },
4166        ];
4167        let system = system_with_anticipated_thermals(&[1, 2], past_commits);
4168        // n_anticipated=2, k_values=[1, 2] -> k_max=2.
4169        let indexer = indexer_with_anticipated(2, &[1, 2], &[0, 1]);
4170
4171        let state = build_initial_state(&system, &indexer);
4172
4173        assert_eq!(
4174            state.len(),
4175            indexer.n_state,
4176            "state length must equal n_state"
4177        );
4178        let s = indexer.anticipated_state.start;
4179        let n_ant = indexer.n_anticipated;
4180        assert_eq!(n_ant, 2);
4181        assert_eq!(indexer.k_max, 2);
4182
4183        // slot 0, plant 0 -> 100.0
4184        assert!(
4185            (state[s] - 100.0).abs() < 1e-10,
4186            "slot 0 plant 0 expected 100.0, got {}",
4187            state[s]
4188        );
4189        // slot 0, plant 1 -> 50.0
4190        assert!(
4191            (state[s + 1] - 50.0).abs() < 1e-10,
4192            "slot 0 plant 1 expected 50.0, got {}",
4193            state[s + 1]
4194        );
4195        // slot 1, plant 0 -> 0.0 (padding for K_0=1 < k_max=2). This is the
4196        // invariant the new debug_assert! protects.
4197        assert!(
4198            state[s + 2].abs() < 1e-10,
4199            "padding slot 1 plant 0 expected 0.0, got {}",
4200            state[s + 2]
4201        );
4202        // slot 1, plant 1 -> 75.0
4203        assert!(
4204            (state[s + 3] - 75.0).abs() < 1e-10,
4205            "slot 1 plant 1 expected 75.0, got {}",
4206            state[s + 3]
4207        );
4208    }
4209
4210    /// Given a `System` with `inflow_scheme = InSample`, when `StudySetup::new()`
4211    /// is called, then `historical_library()` returns `None`.
4212    #[test]
4213    fn historical_library_none_for_insample() {
4214        let system = minimal_system(2);
4215        let config = minimal_config(1, 5);
4216        let stochastic = build_stochastic_context(
4217            &system,
4218            42,
4219            None,
4220            &[],
4221            &[],
4222            OpeningTreeInputs::default(),
4223            ClassSchemes {
4224                inflow: Some(SamplingScheme::InSample),
4225                load: Some(SamplingScheme::InSample),
4226                ncs: Some(SamplingScheme::InSample),
4227            },
4228        )
4229        .expect("stochastic context");
4230
4231        let setup = StudySetup::new(
4232            &system,
4233            &config,
4234            stochastic,
4235            PrepareHydroModelsResult::default_from_system(&system),
4236        )
4237        .expect("setup");
4238
4239        assert!(
4240            setup.scenario_libraries.training.historical.is_none(),
4241            "historical_library must be None for InSample scheme"
4242        );
4243        assert!(
4244            setup.scenario_libraries.training.external_inflow.is_none(),
4245            "external_inflow_library must be None for InSample scheme"
4246        );
4247        assert!(
4248            setup.scenario_libraries.training.external_load.is_none(),
4249            "external_load_library must be None for InSample load scheme"
4250        );
4251        assert!(
4252            setup.scenario_libraries.training.external_ncs.is_none(),
4253            "external_ncs_library must be None for InSample ncs scheme"
4254        );
4255    }
4256
4257    /// Build a system that has `inflow_scheme = Historical` and the inflow
4258    /// history needed to discover at least one window.
4259    ///
4260    /// The system has 1 hydro, 1 bus, 1 thermal, 2 monthly stages (`season_id`
4261    /// `Some(0)` and `Some(1)`), and historical data covering years 1990-1991.
4262    /// With `max_par_order = 0` (no AR coefficients), a window is valid if
4263    /// we have observations for both study months. Year 1990 covers months 0-1
4264    /// so season 0 and 1 are available under year 1990.
4265    #[allow(
4266        clippy::too_many_lines,
4267        clippy::cast_possible_truncation,
4268        clippy::cast_possible_wrap,
4269        clippy::cast_lossless
4270    )]
4271    fn system_with_historical_inflow(n_stages: usize) -> cobre_core::System {
4272        use chrono::NaiveDate;
4273        use cobre_core::{scenario::InflowHistoryRow, system::SystemBuilder};
4274
4275        fn default_hydro_bounds() -> HydroStageBounds {
4276            HydroStageBounds {
4277                min_storage_hm3: 0.0,
4278                max_storage_hm3: 200.0,
4279                min_turbined_m3s: 0.0,
4280                max_turbined_m3s: 100.0,
4281                min_outflow_m3s: 0.0,
4282                max_outflow_m3s: None,
4283                min_generation_mw: 0.0,
4284                max_generation_mw: 250.0,
4285                max_diversion_m3s: None,
4286                filling_inflow_m3s: 0.0,
4287                water_withdrawal_m3s: 0.0,
4288            }
4289        }
4290
4291        fn default_hydro_penalties() -> HydroStagePenalties {
4292            HydroStagePenalties {
4293                spillage_cost: 0.01,
4294                diversion_cost: 0.0,
4295                turbined_cost: 0.0,
4296                storage_violation_below_cost: 500.0,
4297                filling_target_violation_cost: 0.0,
4298                turbined_violation_below_cost: 0.0,
4299                outflow_violation_below_cost: 0.0,
4300                outflow_violation_above_cost: 0.0,
4301                generation_violation_below_cost: 0.0,
4302                evaporation_violation_cost: 0.0,
4303                water_withdrawal_violation_cost: 0.0,
4304                water_withdrawal_violation_pos_cost: 0.0,
4305                water_withdrawal_violation_neg_cost: 0.0,
4306                evaporation_violation_pos_cost: 0.0,
4307                evaporation_violation_neg_cost: 0.0,
4308                inflow_nonnegativity_cost: 1000.0,
4309            }
4310        }
4311
4312        let bus = Bus {
4313            id: EntityId(1),
4314            name: "B1".to_string(),
4315            deficit_segments: vec![DeficitSegment {
4316                depth_mw: None,
4317                cost_per_mwh: 500.0,
4318            }],
4319            excess_cost: 0.0,
4320        };
4321
4322        let thermal = Thermal {
4323            id: EntityId(2),
4324            name: "T1".to_string(),
4325            bus_id: EntityId(1),
4326            min_generation_mw: 0.0,
4327            max_generation_mw: 100.0,
4328            cost_per_mwh: 50.0,
4329            anticipated_config: None,
4330            entry_stage_id: None,
4331            exit_stage_id: None,
4332        };
4333
4334        let hydro = Hydro {
4335            id: EntityId(3),
4336            name: "H1".to_string(),
4337            bus_id: EntityId(1),
4338            downstream_id: None,
4339            entry_stage_id: None,
4340            exit_stage_id: None,
4341            min_storage_hm3: 0.0,
4342            max_storage_hm3: 200.0,
4343            min_outflow_m3s: 0.0,
4344            max_outflow_m3s: None,
4345            generation_model: HydroGenerationModel::ConstantProductivity,
4346            min_turbined_m3s: 0.0,
4347            max_turbined_m3s: 100.0,
4348            specific_productivity_mw_per_m3s_per_m: None,
4349            min_generation_mw: 0.0,
4350            max_generation_mw: 250.0,
4351            tailrace: None,
4352            hydraulic_losses: None,
4353            efficiency: None,
4354            evaporation_coefficients_mm: None,
4355            evaporation_reference_volumes_hm3: None,
4356            diversion: None,
4357            filling: None,
4358            penalties: HydroPenalties {
4359                spillage_cost: 0.01,
4360                diversion_cost: 0.0,
4361                turbined_cost: 0.0,
4362                storage_violation_below_cost: 0.0,
4363                filling_target_violation_cost: 0.0,
4364                turbined_violation_below_cost: 0.0,
4365                outflow_violation_below_cost: 0.0,
4366                outflow_violation_above_cost: 0.0,
4367                generation_violation_below_cost: 0.0,
4368                evaporation_violation_cost: 0.0,
4369                water_withdrawal_violation_cost: 0.0,
4370                water_withdrawal_violation_pos_cost: 0.0,
4371                water_withdrawal_violation_neg_cost: 0.0,
4372                evaporation_violation_pos_cost: 0.0,
4373                evaporation_violation_neg_cost: 0.0,
4374                inflow_nonnegativity_cost: 1000.0,
4375            },
4376        };
4377
4378        // Monthly stages: season_id = month index (0-based).
4379        let stages: Vec<Stage> = (0..n_stages)
4380            .map(|i| Stage {
4381                index: i,
4382                id: i as i32,
4383                start_date: NaiveDate::from_ymd_opt(2024, (i as u32 % 12) + 1, 1).unwrap(),
4384                end_date: NaiveDate::from_ymd_opt(2024, (i as u32 % 12) + 1, 28).unwrap(),
4385                season_id: Some(i % 12),
4386                blocks: vec![Block {
4387                    index: 0,
4388                    name: "S".to_string(),
4389                    duration_hours: 720.0,
4390                }],
4391                block_mode: BlockMode::Parallel,
4392                state_config: StageStateConfig {
4393                    storage: true,
4394                    inflow_lags: false,
4395                },
4396                risk_config: StageRiskConfig::Expectation,
4397                scenario_config: ScenarioSourceConfig {
4398                    branching_factor: 1,
4399                    noise_method: NoiseMethod::Saa,
4400                },
4401            })
4402            .collect();
4403
4404        let inflow_models: Vec<InflowModel> = (0..n_stages)
4405            .map(|i| InflowModel {
4406                hydro_id: EntityId(3),
4407                stage_id: i as i32,
4408                mean_m3s: 80.0,
4409                std_m3s: 20.0,
4410                ar_coefficients: vec![],
4411                residual_std_ratio: 1.0,
4412                annual: None,
4413            })
4414            .collect();
4415
4416        let load_models: Vec<LoadModel> = (0..n_stages)
4417            .map(|i| LoadModel {
4418                bus_id: EntityId(1),
4419                stage_id: i as i32,
4420                mean_mw: 100.0,
4421                std_mw: 0.0,
4422            })
4423            .collect();
4424
4425        // Historical inflow data: 1990 and 1991 cover 12 months each.
4426        // With n_stages <= 2 and max_par_order = 0, year 1990 and 1991 are
4427        // both valid windows (study months are in Jan-Feb = seasons 0-1).
4428        let inflow_history: Vec<InflowHistoryRow> = (1990_i32..=1991)
4429            .flat_map(|year| {
4430                (1u32..=12).map(move |month| InflowHistoryRow {
4431                    hydro_id: EntityId(3),
4432                    date: NaiveDate::from_ymd_opt(year, month, 1).unwrap(),
4433                    value_m3s: 80.0 + f64::from(year - 1990) * 5.0,
4434                })
4435            })
4436            .collect();
4437
4438        let n_st = n_stages.max(1);
4439
4440        let bounds = ResolvedBounds::new(
4441            &BoundsCountsSpec {
4442                n_hydros: 1,
4443                n_thermals: 1,
4444                n_lines: 0,
4445                n_pumping: 0,
4446                n_contracts: 0,
4447                n_stages: n_st,
4448                k_max: 0,
4449            },
4450            &BoundsDefaults {
4451                hydro: default_hydro_bounds(),
4452                thermal: ThermalStageBounds {
4453                    min_generation_mw: 0.0,
4454                    max_generation_mw: 100.0,
4455                    cost_per_mwh: 0.0,
4456                },
4457                line: LineStageBounds {
4458                    direct_mw: 0.0,
4459                    reverse_mw: 0.0,
4460                },
4461                pumping: PumpingStageBounds {
4462                    min_flow_m3s: 0.0,
4463                    max_flow_m3s: 0.0,
4464                },
4465                contract: ContractStageBounds {
4466                    min_mw: 0.0,
4467                    max_mw: 0.0,
4468                    price_per_mwh: 0.0,
4469                },
4470            },
4471        );
4472
4473        let penalties = ResolvedPenalties::new(
4474            &PenaltiesCountsSpec {
4475                n_hydros: 1,
4476                n_buses: 1,
4477                n_lines: 0,
4478                n_ncs: 0,
4479                n_stages: n_st,
4480            },
4481            &PenaltiesDefaults {
4482                hydro: default_hydro_penalties(),
4483                bus: BusStagePenalties { excess_cost: 0.0 },
4484                line: LineStagePenalties { exchange_cost: 0.0 },
4485                ncs: NcsStagePenalties {
4486                    curtailment_cost: 0.0,
4487                },
4488            },
4489        );
4490
4491        SystemBuilder::new()
4492            .buses(vec![bus])
4493            .thermals(vec![thermal])
4494            .hydros(vec![hydro])
4495            .stages(stages)
4496            .inflow_models(inflow_models)
4497            .load_models(load_models)
4498            .inflow_history(inflow_history)
4499            .bounds(bounds)
4500            .penalties(penalties)
4501            .build()
4502            .expect("system_with_historical_inflow: valid")
4503    }
4504
4505    /// Given a `System` with `inflow_scheme = Historical` and valid inflow history,
4506    /// when `StudySetup::new()` is called, then `historical_library()` returns
4507    /// `Some` and `n_windows() > 0`.
4508    #[test]
4509    fn historical_library_built_when_scheme_is_historical() {
4510        let system = system_with_historical_inflow(2);
4511        let config = minimal_config_with_schemes(1, 5, Some("historical"), None, None);
4512        let stochastic = build_stochastic_context(
4513            &system,
4514            42,
4515            None,
4516            &[],
4517            &[],
4518            OpeningTreeInputs::default(),
4519            ClassSchemes {
4520                inflow: Some(SamplingScheme::Historical),
4521                load: Some(SamplingScheme::InSample),
4522                ncs: Some(SamplingScheme::InSample),
4523            },
4524        )
4525        .expect("stochastic context");
4526
4527        let setup = StudySetup::new(
4528            &system,
4529            &config,
4530            stochastic,
4531            PrepareHydroModelsResult::default_from_system(&system),
4532        )
4533        .expect("setup");
4534
4535        let lib = setup
4536            .scenario_libraries
4537            .training
4538            .historical
4539            .as_ref()
4540            .expect("expected Some(HistoricalScenarioLibrary) for Historical scheme");
4541        assert!(
4542            lib.n_windows() > 0,
4543            "expected at least one historical window, got 0"
4544        );
4545        assert_eq!(
4546            lib.n_stages(),
4547            2,
4548            "expected n_stages == 2 matching the system's study stages"
4549        );
4550        assert_eq!(lib.n_hydros(), 1, "expected n_hydros == 1");
4551    }
4552
4553    /// Given a `System` with `inflow_scheme = External` and valid external
4554    /// inflow rows, when `StudySetup::new()` is called, then
4555    /// `external_inflow_library()` returns `Some` and `n_entities() > 0`.
4556    #[test]
4557    #[allow(
4558        clippy::too_many_lines,
4559        clippy::cast_possible_truncation,
4560        clippy::cast_possible_wrap,
4561        clippy::cast_precision_loss,
4562        clippy::cast_lossless
4563    )]
4564    fn external_inflow_library_built_when_scheme_is_external() {
4565        use chrono::NaiveDate;
4566        use cobre_core::scenario::ExternalScenarioRow;
4567        use cobre_core::{scenario::InflowModel as CoreInflowModel, system::SystemBuilder};
4568
4569        // Build external inflow rows: 3 scenarios × 1 hydro × 2 stages.
4570        // Hydro ID = 3 (from minimal_system). Stage IDs 0, 1. Scenario IDs 0, 1, 2.
4571        let hydro_id = EntityId(3);
4572        let mut external_rows: Vec<ExternalScenarioRow> = Vec::new();
4573        for stage_id in 0i32..2 {
4574            for scenario_id in 0i32..3 {
4575                external_rows.push(ExternalScenarioRow {
4576                    stage_id,
4577                    scenario_id,
4578                    hydro_id,
4579                    value_m3s: 80.0 + scenario_id as f64 * 5.0,
4580                });
4581            }
4582        }
4583
4584        // We need to rebuild the system with external scenario source and rows.
4585        // Use SystemBuilder to produce a system that carries external rows.
4586        // minimal_system builds with its own SystemBuilder call, so we rebuild.
4587
4588        let bus = Bus {
4589            id: EntityId(1),
4590            name: "B1".to_string(),
4591            deficit_segments: vec![DeficitSegment {
4592                depth_mw: None,
4593                cost_per_mwh: 500.0,
4594            }],
4595            excess_cost: 0.0,
4596        };
4597        let thermal = Thermal {
4598            id: EntityId(2),
4599            name: "T1".to_string(),
4600            bus_id: EntityId(1),
4601            min_generation_mw: 0.0,
4602            max_generation_mw: 100.0,
4603            cost_per_mwh: 50.0,
4604            anticipated_config: None,
4605            entry_stage_id: None,
4606            exit_stage_id: None,
4607        };
4608        let hydro = Hydro {
4609            id: EntityId(3),
4610            name: "H1".to_string(),
4611            bus_id: EntityId(1),
4612            downstream_id: None,
4613            entry_stage_id: None,
4614            exit_stage_id: None,
4615            min_storage_hm3: 0.0,
4616            max_storage_hm3: 200.0,
4617            min_outflow_m3s: 0.0,
4618            max_outflow_m3s: None,
4619            generation_model: HydroGenerationModel::ConstantProductivity,
4620            min_turbined_m3s: 0.0,
4621            max_turbined_m3s: 100.0,
4622            specific_productivity_mw_per_m3s_per_m: None,
4623            min_generation_mw: 0.0,
4624            max_generation_mw: 250.0,
4625            tailrace: None,
4626            hydraulic_losses: None,
4627            efficiency: None,
4628            evaporation_coefficients_mm: None,
4629            evaporation_reference_volumes_hm3: None,
4630            diversion: None,
4631            filling: None,
4632            penalties: HydroPenalties {
4633                spillage_cost: 0.01,
4634                diversion_cost: 0.0,
4635                turbined_cost: 0.0,
4636                storage_violation_below_cost: 0.0,
4637                filling_target_violation_cost: 0.0,
4638                turbined_violation_below_cost: 0.0,
4639                outflow_violation_below_cost: 0.0,
4640                outflow_violation_above_cost: 0.0,
4641                generation_violation_below_cost: 0.0,
4642                evaporation_violation_cost: 0.0,
4643                water_withdrawal_violation_cost: 0.0,
4644                water_withdrawal_violation_pos_cost: 0.0,
4645                water_withdrawal_violation_neg_cost: 0.0,
4646                evaporation_violation_pos_cost: 0.0,
4647                evaporation_violation_neg_cost: 0.0,
4648                inflow_nonnegativity_cost: 1000.0,
4649            },
4650        };
4651        let stages: Vec<Stage> = (0..2usize)
4652            .map(|i| Stage {
4653                index: i,
4654                id: i as i32,
4655                start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
4656                end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
4657                season_id: None,
4658                blocks: vec![Block {
4659                    index: 0,
4660                    name: "S".to_string(),
4661                    duration_hours: 744.0,
4662                }],
4663                block_mode: BlockMode::Parallel,
4664                state_config: StageStateConfig {
4665                    storage: true,
4666                    inflow_lags: false,
4667                },
4668                risk_config: StageRiskConfig::Expectation,
4669                scenario_config: ScenarioSourceConfig {
4670                    branching_factor: 1,
4671                    noise_method: NoiseMethod::Saa,
4672                },
4673            })
4674            .collect();
4675
4676        let inflow_models: Vec<CoreInflowModel> = (0..2usize)
4677            .map(|i| CoreInflowModel {
4678                hydro_id: EntityId(3),
4679                stage_id: i as i32,
4680                mean_m3s: 80.0,
4681                std_m3s: 20.0,
4682                ar_coefficients: vec![],
4683                residual_std_ratio: 1.0,
4684                annual: None,
4685            })
4686            .collect();
4687
4688        let load_models: Vec<LoadModel> = (0..2usize)
4689            .map(|i| LoadModel {
4690                bus_id: EntityId(1),
4691                stage_id: i as i32,
4692                mean_mw: 100.0,
4693                std_mw: 0.0,
4694            })
4695            .collect();
4696
4697        let bounds = ResolvedBounds::new(
4698            &BoundsCountsSpec {
4699                n_hydros: 1,
4700                n_thermals: 1,
4701                n_lines: 0,
4702                n_pumping: 0,
4703                n_contracts: 0,
4704                n_stages: 2,
4705                k_max: 0,
4706            },
4707            &BoundsDefaults {
4708                hydro: HydroStageBounds {
4709                    min_storage_hm3: 0.0,
4710                    max_storage_hm3: 200.0,
4711                    min_turbined_m3s: 0.0,
4712                    max_turbined_m3s: 100.0,
4713                    min_outflow_m3s: 0.0,
4714                    max_outflow_m3s: None,
4715                    min_generation_mw: 0.0,
4716                    max_generation_mw: 250.0,
4717                    max_diversion_m3s: None,
4718                    filling_inflow_m3s: 0.0,
4719                    water_withdrawal_m3s: 0.0,
4720                },
4721                thermal: ThermalStageBounds {
4722                    min_generation_mw: 0.0,
4723                    max_generation_mw: 100.0,
4724                    cost_per_mwh: 0.0,
4725                },
4726                line: LineStageBounds {
4727                    direct_mw: 0.0,
4728                    reverse_mw: 0.0,
4729                },
4730                pumping: PumpingStageBounds {
4731                    min_flow_m3s: 0.0,
4732                    max_flow_m3s: 0.0,
4733                },
4734                contract: ContractStageBounds {
4735                    min_mw: 0.0,
4736                    max_mw: 0.0,
4737                    price_per_mwh: 0.0,
4738                },
4739            },
4740        );
4741        let penalties = ResolvedPenalties::new(
4742            &PenaltiesCountsSpec {
4743                n_hydros: 1,
4744                n_buses: 1,
4745                n_lines: 0,
4746                n_ncs: 0,
4747                n_stages: 2,
4748            },
4749            &PenaltiesDefaults {
4750                hydro: HydroStagePenalties {
4751                    spillage_cost: 0.01,
4752                    diversion_cost: 0.0,
4753                    turbined_cost: 0.0,
4754                    storage_violation_below_cost: 500.0,
4755                    filling_target_violation_cost: 0.0,
4756                    turbined_violation_below_cost: 0.0,
4757                    outflow_violation_below_cost: 0.0,
4758                    outflow_violation_above_cost: 0.0,
4759                    generation_violation_below_cost: 0.0,
4760                    evaporation_violation_cost: 0.0,
4761                    water_withdrawal_violation_cost: 0.0,
4762                    water_withdrawal_violation_pos_cost: 0.0,
4763                    water_withdrawal_violation_neg_cost: 0.0,
4764                    evaporation_violation_pos_cost: 0.0,
4765                    evaporation_violation_neg_cost: 0.0,
4766                    inflow_nonnegativity_cost: 1000.0,
4767                },
4768                bus: BusStagePenalties { excess_cost: 0.0 },
4769                line: LineStagePenalties { exchange_cost: 0.0 },
4770                ncs: NcsStagePenalties {
4771                    curtailment_cost: 0.0,
4772                },
4773            },
4774        );
4775
4776        let system = SystemBuilder::new()
4777            .buses(vec![bus])
4778            .thermals(vec![thermal])
4779            .hydros(vec![hydro])
4780            .stages(stages)
4781            .inflow_models(inflow_models)
4782            .load_models(load_models)
4783            .external_scenarios(external_rows)
4784            .bounds(bounds)
4785            .penalties(penalties)
4786            .build()
4787            .expect("system with external inflow: valid");
4788
4789        let config = minimal_config_with_schemes(1, 5, Some("external"), None, None);
4790        let stochastic = build_stochastic_context(
4791            &system,
4792            42,
4793            None,
4794            &[],
4795            &[],
4796            OpeningTreeInputs::default(),
4797            ClassSchemes {
4798                inflow: Some(SamplingScheme::External),
4799                load: Some(SamplingScheme::InSample),
4800                ncs: Some(SamplingScheme::InSample),
4801            },
4802        )
4803        .expect("stochastic context");
4804
4805        let setup = StudySetup::new(
4806            &system,
4807            &config,
4808            stochastic,
4809            PrepareHydroModelsResult::default_from_system(&system),
4810        )
4811        .expect("setup");
4812
4813        let lib = setup
4814            .scenario_libraries
4815            .training
4816            .external_inflow
4817            .as_ref()
4818            .expect("expected Some(ExternalScenarioLibrary) for External inflow scheme");
4819        assert!(
4820            lib.n_entities() > 0,
4821            "expected n_entities > 0 in external inflow library"
4822        );
4823        assert_eq!(lib.n_stages(), 2);
4824        assert_eq!(lib.n_scenarios(), 3);
4825        assert_eq!(lib.entity_class(), "inflow");
4826    }
4827
4828    /// Given a `System` with `load_scheme = External` and valid external load
4829    /// rows, when `StudySetup::new()` is called, then
4830    /// `external_load_library()` returns `Some` and `n_entities() > 0`.
4831    #[test]
4832    #[allow(
4833        clippy::too_many_lines,
4834        clippy::cast_possible_truncation,
4835        clippy::cast_possible_wrap,
4836        clippy::cast_precision_loss,
4837        clippy::cast_lossless
4838    )]
4839    fn external_load_library_built_when_scheme_is_external() {
4840        use chrono::NaiveDate;
4841        use cobre_core::scenario::ExternalLoadRow;
4842        use cobre_core::{scenario::InflowModel as CoreInflowModel, system::SystemBuilder};
4843
4844        let bus = Bus {
4845            id: EntityId(1),
4846            name: "B1".to_string(),
4847            deficit_segments: vec![DeficitSegment {
4848                depth_mw: None,
4849                cost_per_mwh: 500.0,
4850            }],
4851            excess_cost: 0.0,
4852        };
4853        let thermal = Thermal {
4854            id: EntityId(2),
4855            name: "T1".to_string(),
4856            bus_id: EntityId(1),
4857            min_generation_mw: 0.0,
4858            max_generation_mw: 100.0,
4859            cost_per_mwh: 50.0,
4860            anticipated_config: None,
4861            entry_stage_id: None,
4862            exit_stage_id: None,
4863        };
4864        let hydro = Hydro {
4865            id: EntityId(3),
4866            name: "H1".to_string(),
4867            bus_id: EntityId(1),
4868            downstream_id: None,
4869            entry_stage_id: None,
4870            exit_stage_id: None,
4871            min_storage_hm3: 0.0,
4872            max_storage_hm3: 200.0,
4873            min_outflow_m3s: 0.0,
4874            max_outflow_m3s: None,
4875            generation_model: HydroGenerationModel::ConstantProductivity,
4876            min_turbined_m3s: 0.0,
4877            max_turbined_m3s: 100.0,
4878            specific_productivity_mw_per_m3s_per_m: None,
4879            min_generation_mw: 0.0,
4880            max_generation_mw: 250.0,
4881            tailrace: None,
4882            hydraulic_losses: None,
4883            efficiency: None,
4884            evaporation_coefficients_mm: None,
4885            evaporation_reference_volumes_hm3: None,
4886            diversion: None,
4887            filling: None,
4888            penalties: HydroPenalties {
4889                spillage_cost: 0.01,
4890                diversion_cost: 0.0,
4891                turbined_cost: 0.0,
4892                storage_violation_below_cost: 0.0,
4893                filling_target_violation_cost: 0.0,
4894                turbined_violation_below_cost: 0.0,
4895                outflow_violation_below_cost: 0.0,
4896                outflow_violation_above_cost: 0.0,
4897                generation_violation_below_cost: 0.0,
4898                evaporation_violation_cost: 0.0,
4899                water_withdrawal_violation_cost: 0.0,
4900                water_withdrawal_violation_pos_cost: 0.0,
4901                water_withdrawal_violation_neg_cost: 0.0,
4902                evaporation_violation_pos_cost: 0.0,
4903                evaporation_violation_neg_cost: 0.0,
4904                inflow_nonnegativity_cost: 1000.0,
4905            },
4906        };
4907
4908        let stages: Vec<Stage> = (0..2usize)
4909            .map(|i| Stage {
4910                index: i,
4911                id: i as i32,
4912                start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
4913                end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
4914                season_id: None,
4915                blocks: vec![Block {
4916                    index: 0,
4917                    name: "S".to_string(),
4918                    duration_hours: 744.0,
4919                }],
4920                block_mode: BlockMode::Parallel,
4921                state_config: StageStateConfig {
4922                    storage: true,
4923                    inflow_lags: false,
4924                },
4925                risk_config: StageRiskConfig::Expectation,
4926                scenario_config: ScenarioSourceConfig {
4927                    branching_factor: 1,
4928                    noise_method: NoiseMethod::Saa,
4929                },
4930            })
4931            .collect();
4932
4933        let inflow_models: Vec<CoreInflowModel> = (0..2usize)
4934            .map(|i| CoreInflowModel {
4935                hydro_id: EntityId(3),
4936                stage_id: i as i32,
4937                mean_m3s: 80.0,
4938                std_m3s: 20.0,
4939                ar_coefficients: vec![],
4940                residual_std_ratio: 1.0,
4941                annual: None,
4942            })
4943            .collect();
4944
4945        let load_models: Vec<LoadModel> = (0..2usize)
4946            .map(|i| LoadModel {
4947                bus_id: EntityId(1),
4948                stage_id: i as i32,
4949                mean_mw: 100.0,
4950                std_mw: 10.0,
4951            })
4952            .collect();
4953
4954        // External load rows: 3 scenarios × 1 bus × 2 stages.
4955        let mut external_load_rows: Vec<ExternalLoadRow> = Vec::new();
4956        for stage_id in 0i32..2 {
4957            for scenario_id in 0i32..3 {
4958                external_load_rows.push(ExternalLoadRow {
4959                    stage_id,
4960                    scenario_id,
4961                    bus_id: EntityId(1),
4962                    value_mw: 90.0 + scenario_id as f64 * 10.0,
4963                });
4964            }
4965        }
4966
4967        let bounds = ResolvedBounds::new(
4968            &BoundsCountsSpec {
4969                n_hydros: 1,
4970                n_thermals: 1,
4971                n_lines: 0,
4972                n_pumping: 0,
4973                n_contracts: 0,
4974                n_stages: 2,
4975                k_max: 0,
4976            },
4977            &BoundsDefaults {
4978                hydro: HydroStageBounds {
4979                    min_storage_hm3: 0.0,
4980                    max_storage_hm3: 200.0,
4981                    min_turbined_m3s: 0.0,
4982                    max_turbined_m3s: 100.0,
4983                    min_outflow_m3s: 0.0,
4984                    max_outflow_m3s: None,
4985                    min_generation_mw: 0.0,
4986                    max_generation_mw: 250.0,
4987                    max_diversion_m3s: None,
4988                    filling_inflow_m3s: 0.0,
4989                    water_withdrawal_m3s: 0.0,
4990                },
4991                thermal: ThermalStageBounds {
4992                    min_generation_mw: 0.0,
4993                    max_generation_mw: 100.0,
4994                    cost_per_mwh: 0.0,
4995                },
4996                line: LineStageBounds {
4997                    direct_mw: 0.0,
4998                    reverse_mw: 0.0,
4999                },
5000                pumping: PumpingStageBounds {
5001                    min_flow_m3s: 0.0,
5002                    max_flow_m3s: 0.0,
5003                },
5004                contract: ContractStageBounds {
5005                    min_mw: 0.0,
5006                    max_mw: 0.0,
5007                    price_per_mwh: 0.0,
5008                },
5009            },
5010        );
5011        let penalties = ResolvedPenalties::new(
5012            &PenaltiesCountsSpec {
5013                n_hydros: 1,
5014                n_buses: 1,
5015                n_lines: 0,
5016                n_ncs: 0,
5017                n_stages: 2,
5018            },
5019            &PenaltiesDefaults {
5020                hydro: HydroStagePenalties {
5021                    spillage_cost: 0.01,
5022                    diversion_cost: 0.0,
5023                    turbined_cost: 0.0,
5024                    storage_violation_below_cost: 500.0,
5025                    filling_target_violation_cost: 0.0,
5026                    turbined_violation_below_cost: 0.0,
5027                    outflow_violation_below_cost: 0.0,
5028                    outflow_violation_above_cost: 0.0,
5029                    generation_violation_below_cost: 0.0,
5030                    evaporation_violation_cost: 0.0,
5031                    water_withdrawal_violation_cost: 0.0,
5032                    water_withdrawal_violation_pos_cost: 0.0,
5033                    water_withdrawal_violation_neg_cost: 0.0,
5034                    evaporation_violation_pos_cost: 0.0,
5035                    evaporation_violation_neg_cost: 0.0,
5036                    inflow_nonnegativity_cost: 1000.0,
5037                },
5038                bus: BusStagePenalties { excess_cost: 0.0 },
5039                line: LineStagePenalties { exchange_cost: 0.0 },
5040                ncs: NcsStagePenalties {
5041                    curtailment_cost: 0.0,
5042                },
5043            },
5044        );
5045
5046        let system = SystemBuilder::new()
5047            .buses(vec![bus])
5048            .thermals(vec![thermal])
5049            .hydros(vec![hydro])
5050            .stages(stages)
5051            .inflow_models(inflow_models)
5052            .load_models(load_models)
5053            .external_load_scenarios(external_load_rows)
5054            .bounds(bounds)
5055            .penalties(penalties)
5056            .build()
5057            .expect("system with external load: valid");
5058
5059        let config = minimal_config_with_schemes(1, 5, None, Some("external"), None);
5060        let stochastic = build_stochastic_context(
5061            &system,
5062            42,
5063            None,
5064            &[],
5065            &[],
5066            OpeningTreeInputs::default(),
5067            ClassSchemes {
5068                inflow: Some(SamplingScheme::InSample),
5069                load: Some(SamplingScheme::External),
5070                ncs: Some(SamplingScheme::InSample),
5071            },
5072        )
5073        .expect("stochastic context");
5074
5075        let setup = StudySetup::new(
5076            &system,
5077            &config,
5078            stochastic,
5079            PrepareHydroModelsResult::default_from_system(&system),
5080        )
5081        .expect("setup");
5082
5083        let lib = setup
5084            .scenario_libraries
5085            .training
5086            .external_load
5087            .as_ref()
5088            .expect("expected Some(ExternalScenarioLibrary) for External load scheme");
5089        assert!(
5090            lib.n_entities() > 0,
5091            "expected n_entities > 0 in external load library"
5092        );
5093        assert_eq!(lib.n_stages(), 2);
5094        assert_eq!(lib.n_scenarios(), 3);
5095        assert_eq!(lib.entity_class(), "load");
5096    }
5097
5098    /// Given a `System` with `ncs_scheme = External` and valid external NCS
5099    /// rows, when `StudySetup::new()` is called, then
5100    /// `external_ncs_library()` returns `Some` and `n_entities() > 0`.
5101    #[test]
5102    #[allow(
5103        clippy::too_many_lines,
5104        clippy::cast_possible_truncation,
5105        clippy::cast_possible_wrap,
5106        clippy::cast_precision_loss,
5107        clippy::cast_lossless
5108    )]
5109    fn external_ncs_library_built_when_scheme_is_external() {
5110        use chrono::NaiveDate;
5111        use cobre_core::scenario::InflowModel as CoreInflowModel;
5112        use cobre_core::{
5113            NonControllableSource,
5114            scenario::{ExternalNcsRow, NcsModel},
5115            system::SystemBuilder,
5116        };
5117
5118        let bus = Bus {
5119            id: EntityId(1),
5120            name: "B1".to_string(),
5121            deficit_segments: vec![DeficitSegment {
5122                depth_mw: None,
5123                cost_per_mwh: 500.0,
5124            }],
5125            excess_cost: 0.0,
5126        };
5127        let thermal = Thermal {
5128            id: EntityId(2),
5129            name: "T1".to_string(),
5130            bus_id: EntityId(1),
5131            min_generation_mw: 0.0,
5132            max_generation_mw: 100.0,
5133            cost_per_mwh: 50.0,
5134            anticipated_config: None,
5135            entry_stage_id: None,
5136            exit_stage_id: None,
5137        };
5138        let hydro = Hydro {
5139            id: EntityId(3),
5140            name: "H1".to_string(),
5141            bus_id: EntityId(1),
5142            downstream_id: None,
5143            entry_stage_id: None,
5144            exit_stage_id: None,
5145            min_storage_hm3: 0.0,
5146            max_storage_hm3: 200.0,
5147            min_outflow_m3s: 0.0,
5148            max_outflow_m3s: None,
5149            generation_model: HydroGenerationModel::ConstantProductivity,
5150            min_turbined_m3s: 0.0,
5151            max_turbined_m3s: 100.0,
5152            specific_productivity_mw_per_m3s_per_m: None,
5153            min_generation_mw: 0.0,
5154            max_generation_mw: 250.0,
5155            tailrace: None,
5156            hydraulic_losses: None,
5157            efficiency: None,
5158            evaporation_coefficients_mm: None,
5159            evaporation_reference_volumes_hm3: None,
5160            diversion: None,
5161            filling: None,
5162            penalties: HydroPenalties {
5163                spillage_cost: 0.01,
5164                diversion_cost: 0.0,
5165                turbined_cost: 0.0,
5166                storage_violation_below_cost: 0.0,
5167                filling_target_violation_cost: 0.0,
5168                turbined_violation_below_cost: 0.0,
5169                outflow_violation_below_cost: 0.0,
5170                outflow_violation_above_cost: 0.0,
5171                generation_violation_below_cost: 0.0,
5172                evaporation_violation_cost: 0.0,
5173                water_withdrawal_violation_cost: 0.0,
5174                water_withdrawal_violation_pos_cost: 0.0,
5175                water_withdrawal_violation_neg_cost: 0.0,
5176                evaporation_violation_pos_cost: 0.0,
5177                evaporation_violation_neg_cost: 0.0,
5178                inflow_nonnegativity_cost: 1000.0,
5179            },
5180        };
5181
5182        // NCS entity: wind plant with EntityId(4).
5183        let ncs_id = EntityId(4);
5184        let ncs_source = NonControllableSource {
5185            id: ncs_id,
5186            name: "Wind1".to_string(),
5187            bus_id: EntityId(1),
5188            entry_stage_id: None,
5189            exit_stage_id: None,
5190            max_generation_mw: 100.0,
5191            allow_curtailment: true,
5192            curtailment_cost: 0.01,
5193        };
5194
5195        let stages: Vec<Stage> = (0..2usize)
5196            .map(|i| Stage {
5197                index: i,
5198                id: i as i32,
5199                start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
5200                end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
5201                season_id: None,
5202                blocks: vec![Block {
5203                    index: 0,
5204                    name: "S".to_string(),
5205                    duration_hours: 744.0,
5206                }],
5207                block_mode: BlockMode::Parallel,
5208                state_config: StageStateConfig {
5209                    storage: true,
5210                    inflow_lags: false,
5211                },
5212                risk_config: StageRiskConfig::Expectation,
5213                scenario_config: ScenarioSourceConfig {
5214                    branching_factor: 1,
5215                    noise_method: NoiseMethod::Saa,
5216                },
5217            })
5218            .collect();
5219
5220        let inflow_models: Vec<CoreInflowModel> = (0..2usize)
5221            .map(|i| CoreInflowModel {
5222                hydro_id: EntityId(3),
5223                stage_id: i as i32,
5224                mean_m3s: 80.0,
5225                std_m3s: 20.0,
5226                ar_coefficients: vec![],
5227                residual_std_ratio: 1.0,
5228                annual: None,
5229            })
5230            .collect();
5231
5232        let load_models: Vec<LoadModel> = (0..2usize)
5233            .map(|i| LoadModel {
5234                bus_id: EntityId(1),
5235                stage_id: i as i32,
5236                mean_mw: 100.0,
5237                std_mw: 0.0,
5238            })
5239            .collect();
5240
5241        // NCS models: mean=0.8, std=0.1 for both stages.
5242        let ncs_models: Vec<NcsModel> = (0..2usize)
5243            .map(|i| NcsModel {
5244                ncs_id,
5245                stage_id: i as i32,
5246                mean: 0.8,
5247                std: 0.1,
5248            })
5249            .collect();
5250
5251        // External NCS rows: 3 scenarios × 1 NCS × 2 stages.
5252        let mut external_ncs_rows: Vec<ExternalNcsRow> = Vec::new();
5253        for stage_id in 0i32..2 {
5254            for scenario_id in 0i32..3 {
5255                external_ncs_rows.push(ExternalNcsRow {
5256                    stage_id,
5257                    scenario_id,
5258                    ncs_id,
5259                    value: 0.7 + scenario_id as f64 * 0.1,
5260                });
5261            }
5262        }
5263
5264        let bounds = ResolvedBounds::new(
5265            &BoundsCountsSpec {
5266                n_hydros: 1,
5267                n_thermals: 1,
5268                n_lines: 0,
5269                n_pumping: 0,
5270                n_contracts: 0,
5271                n_stages: 2,
5272                k_max: 0,
5273            },
5274            &BoundsDefaults {
5275                hydro: HydroStageBounds {
5276                    min_storage_hm3: 0.0,
5277                    max_storage_hm3: 200.0,
5278                    min_turbined_m3s: 0.0,
5279                    max_turbined_m3s: 100.0,
5280                    min_outflow_m3s: 0.0,
5281                    max_outflow_m3s: None,
5282                    min_generation_mw: 0.0,
5283                    max_generation_mw: 250.0,
5284                    max_diversion_m3s: None,
5285                    filling_inflow_m3s: 0.0,
5286                    water_withdrawal_m3s: 0.0,
5287                },
5288                thermal: ThermalStageBounds {
5289                    min_generation_mw: 0.0,
5290                    max_generation_mw: 100.0,
5291                    cost_per_mwh: 0.0,
5292                },
5293                line: LineStageBounds {
5294                    direct_mw: 0.0,
5295                    reverse_mw: 0.0,
5296                },
5297                pumping: PumpingStageBounds {
5298                    min_flow_m3s: 0.0,
5299                    max_flow_m3s: 0.0,
5300                },
5301                contract: ContractStageBounds {
5302                    min_mw: 0.0,
5303                    max_mw: 0.0,
5304                    price_per_mwh: 0.0,
5305                },
5306            },
5307        );
5308        let penalties = ResolvedPenalties::new(
5309            &PenaltiesCountsSpec {
5310                n_hydros: 1,
5311                n_buses: 1,
5312                n_lines: 0,
5313                n_ncs: 1,
5314                n_stages: 2,
5315            },
5316            &PenaltiesDefaults {
5317                hydro: HydroStagePenalties {
5318                    spillage_cost: 0.01,
5319                    diversion_cost: 0.0,
5320                    turbined_cost: 0.0,
5321                    storage_violation_below_cost: 500.0,
5322                    filling_target_violation_cost: 0.0,
5323                    turbined_violation_below_cost: 0.0,
5324                    outflow_violation_below_cost: 0.0,
5325                    outflow_violation_above_cost: 0.0,
5326                    generation_violation_below_cost: 0.0,
5327                    evaporation_violation_cost: 0.0,
5328                    water_withdrawal_violation_cost: 0.0,
5329                    water_withdrawal_violation_pos_cost: 0.0,
5330                    water_withdrawal_violation_neg_cost: 0.0,
5331                    evaporation_violation_pos_cost: 0.0,
5332                    evaporation_violation_neg_cost: 0.0,
5333                    inflow_nonnegativity_cost: 1000.0,
5334                },
5335                bus: BusStagePenalties { excess_cost: 0.0 },
5336                line: LineStagePenalties { exchange_cost: 0.0 },
5337                ncs: NcsStagePenalties {
5338                    curtailment_cost: 0.0,
5339                },
5340            },
5341        );
5342
5343        let system = SystemBuilder::new()
5344            .buses(vec![bus])
5345            .thermals(vec![thermal])
5346            .hydros(vec![hydro])
5347            .non_controllable_sources(vec![ncs_source])
5348            .stages(stages)
5349            .inflow_models(inflow_models)
5350            .load_models(load_models)
5351            .ncs_models(ncs_models)
5352            .external_ncs_scenarios(external_ncs_rows)
5353            .bounds(bounds)
5354            .penalties(penalties)
5355            .build()
5356            .expect("system with external NCS: valid");
5357
5358        let config = minimal_config_with_schemes(1, 5, None, None, Some("external"));
5359        let stochastic = build_stochastic_context(
5360            &system,
5361            42,
5362            None,
5363            &[],
5364            &[],
5365            OpeningTreeInputs::default(),
5366            ClassSchemes {
5367                inflow: Some(SamplingScheme::InSample),
5368                load: Some(SamplingScheme::InSample),
5369                ncs: Some(SamplingScheme::External),
5370            },
5371        )
5372        .expect("stochastic context");
5373
5374        let setup = StudySetup::new(
5375            &system,
5376            &config,
5377            stochastic,
5378            PrepareHydroModelsResult::default_from_system(&system),
5379        )
5380        .expect("setup");
5381
5382        let lib = setup
5383            .scenario_libraries
5384            .training
5385            .external_ncs
5386            .as_ref()
5387            .expect("expected Some(ExternalScenarioLibrary) for External NCS scheme");
5388        assert!(
5389            lib.n_entities() > 0,
5390            "expected n_entities > 0 in external NCS library"
5391        );
5392        assert_eq!(lib.n_stages(), 2);
5393        assert_eq!(lib.n_scenarios(), 3);
5394        assert_eq!(lib.entity_class(), "ncs");
5395    }
5396
5397    /// Given a `System` with `inflow_scheme = Historical` but a user pool
5398    /// that references a year with no data, when `StudySetup::new()` is
5399    /// called, then it returns `Err` with a message about windows.
5400    #[test]
5401    #[allow(
5402        clippy::too_many_lines,
5403        clippy::cast_possible_truncation,
5404        clippy::cast_possible_wrap,
5405        clippy::cast_precision_loss,
5406        clippy::cast_lossless
5407    )]
5408    fn historical_library_fails_when_no_valid_windows() {
5409        // system_with_historical_inflow has data for years 1990-1991.
5410        // We use HistoricalYears::List with year 2050 (no data) to force
5411        // zero valid windows after filtering.
5412        use cobre_core::system::SystemBuilder;
5413
5414        // Instead, let's build a system with Historical scheme and empty
5415        // inflow_history (no rows at all). This guarantees zero candidate
5416        // years in discovery.
5417        use chrono::NaiveDate;
5418        use cobre_core::scenario::InflowModel;
5419
5420        let bus = Bus {
5421            id: EntityId(1),
5422            name: "B1".to_string(),
5423            deficit_segments: vec![DeficitSegment {
5424                depth_mw: None,
5425                cost_per_mwh: 500.0,
5426            }],
5427            excess_cost: 0.0,
5428        };
5429        let thermal = Thermal {
5430            id: EntityId(2),
5431            name: "T1".to_string(),
5432            bus_id: EntityId(1),
5433            min_generation_mw: 0.0,
5434            max_generation_mw: 100.0,
5435            cost_per_mwh: 50.0,
5436            anticipated_config: None,
5437            entry_stage_id: None,
5438            exit_stage_id: None,
5439        };
5440        let hydro = Hydro {
5441            id: EntityId(3),
5442            name: "H1".to_string(),
5443            bus_id: EntityId(1),
5444            downstream_id: None,
5445            entry_stage_id: None,
5446            exit_stage_id: None,
5447            min_storage_hm3: 0.0,
5448            max_storage_hm3: 200.0,
5449            min_outflow_m3s: 0.0,
5450            max_outflow_m3s: None,
5451            generation_model: HydroGenerationModel::ConstantProductivity,
5452            min_turbined_m3s: 0.0,
5453            max_turbined_m3s: 100.0,
5454            specific_productivity_mw_per_m3s_per_m: None,
5455            min_generation_mw: 0.0,
5456            max_generation_mw: 250.0,
5457            tailrace: None,
5458            hydraulic_losses: None,
5459            efficiency: None,
5460            evaporation_coefficients_mm: None,
5461            evaporation_reference_volumes_hm3: None,
5462            diversion: None,
5463            filling: None,
5464            penalties: HydroPenalties {
5465                spillage_cost: 0.01,
5466                diversion_cost: 0.0,
5467                turbined_cost: 0.0,
5468                storage_violation_below_cost: 0.0,
5469                filling_target_violation_cost: 0.0,
5470                turbined_violation_below_cost: 0.0,
5471                outflow_violation_below_cost: 0.0,
5472                outflow_violation_above_cost: 0.0,
5473                generation_violation_below_cost: 0.0,
5474                evaporation_violation_cost: 0.0,
5475                water_withdrawal_violation_cost: 0.0,
5476                water_withdrawal_violation_pos_cost: 0.0,
5477                water_withdrawal_violation_neg_cost: 0.0,
5478                evaporation_violation_pos_cost: 0.0,
5479                evaporation_violation_neg_cost: 0.0,
5480                inflow_nonnegativity_cost: 1000.0,
5481            },
5482        };
5483
5484        let stages: Vec<Stage> = (0..2usize)
5485            .map(|i| Stage {
5486                index: i,
5487                id: i as i32,
5488                start_date: NaiveDate::from_ymd_opt(2024, (i as u32 % 12) + 1, 1).unwrap(),
5489                end_date: NaiveDate::from_ymd_opt(2024, (i as u32 % 12) + 1, 28).unwrap(),
5490                season_id: Some(i % 12),
5491                blocks: vec![Block {
5492                    index: 0,
5493                    name: "S".to_string(),
5494                    duration_hours: 720.0,
5495                }],
5496                block_mode: BlockMode::Parallel,
5497                state_config: StageStateConfig {
5498                    storage: true,
5499                    inflow_lags: false,
5500                },
5501                risk_config: StageRiskConfig::Expectation,
5502                scenario_config: ScenarioSourceConfig {
5503                    branching_factor: 1,
5504                    noise_method: NoiseMethod::Saa,
5505                },
5506            })
5507            .collect();
5508
5509        let inflow_models: Vec<InflowModel> = (0..2usize)
5510            .map(|i| InflowModel {
5511                hydro_id: EntityId(3),
5512                stage_id: i as i32,
5513                mean_m3s: 80.0,
5514                std_m3s: 20.0,
5515                ar_coefficients: vec![],
5516                residual_std_ratio: 1.0,
5517                annual: None,
5518            })
5519            .collect();
5520
5521        let load_models: Vec<LoadModel> = (0..2usize)
5522            .map(|i| LoadModel {
5523                bus_id: EntityId(1),
5524                stage_id: i as i32,
5525                mean_mw: 100.0,
5526                std_mw: 0.0,
5527            })
5528            .collect();
5529
5530        let bounds = ResolvedBounds::new(
5531            &BoundsCountsSpec {
5532                n_hydros: 1,
5533                n_thermals: 1,
5534                n_lines: 0,
5535                n_pumping: 0,
5536                n_contracts: 0,
5537                n_stages: 2,
5538                k_max: 0,
5539            },
5540            &BoundsDefaults {
5541                hydro: HydroStageBounds {
5542                    min_storage_hm3: 0.0,
5543                    max_storage_hm3: 200.0,
5544                    min_turbined_m3s: 0.0,
5545                    max_turbined_m3s: 100.0,
5546                    min_outflow_m3s: 0.0,
5547                    max_outflow_m3s: None,
5548                    min_generation_mw: 0.0,
5549                    max_generation_mw: 250.0,
5550                    max_diversion_m3s: None,
5551                    filling_inflow_m3s: 0.0,
5552                    water_withdrawal_m3s: 0.0,
5553                },
5554                thermal: ThermalStageBounds {
5555                    min_generation_mw: 0.0,
5556                    max_generation_mw: 100.0,
5557                    cost_per_mwh: 0.0,
5558                },
5559                line: LineStageBounds {
5560                    direct_mw: 0.0,
5561                    reverse_mw: 0.0,
5562                },
5563                pumping: PumpingStageBounds {
5564                    min_flow_m3s: 0.0,
5565                    max_flow_m3s: 0.0,
5566                },
5567                contract: ContractStageBounds {
5568                    min_mw: 0.0,
5569                    max_mw: 0.0,
5570                    price_per_mwh: 0.0,
5571                },
5572            },
5573        );
5574        let penalties = ResolvedPenalties::new(
5575            &PenaltiesCountsSpec {
5576                n_hydros: 1,
5577                n_buses: 1,
5578                n_lines: 0,
5579                n_ncs: 0,
5580                n_stages: 2,
5581            },
5582            &PenaltiesDefaults {
5583                hydro: HydroStagePenalties {
5584                    spillage_cost: 0.01,
5585                    diversion_cost: 0.0,
5586                    turbined_cost: 0.0,
5587                    storage_violation_below_cost: 500.0,
5588                    filling_target_violation_cost: 0.0,
5589                    turbined_violation_below_cost: 0.0,
5590                    outflow_violation_below_cost: 0.0,
5591                    outflow_violation_above_cost: 0.0,
5592                    generation_violation_below_cost: 0.0,
5593                    evaporation_violation_cost: 0.0,
5594                    water_withdrawal_violation_cost: 0.0,
5595                    water_withdrawal_violation_pos_cost: 0.0,
5596                    water_withdrawal_violation_neg_cost: 0.0,
5597                    evaporation_violation_pos_cost: 0.0,
5598                    evaporation_violation_neg_cost: 0.0,
5599                    inflow_nonnegativity_cost: 1000.0,
5600                },
5601                bus: BusStagePenalties { excess_cost: 0.0 },
5602                line: LineStagePenalties { exchange_cost: 0.0 },
5603                ncs: NcsStagePenalties {
5604                    curtailment_cost: 0.0,
5605                },
5606            },
5607        );
5608
5609        // Historical scheme but NO inflow_history data — discovery must fail.
5610        let system = SystemBuilder::new()
5611            .buses(vec![bus])
5612            .thermals(vec![thermal])
5613            .hydros(vec![hydro])
5614            .stages(stages)
5615            .inflow_models(inflow_models)
5616            .load_models(load_models)
5617            .bounds(bounds)
5618            .penalties(penalties)
5619            .build()
5620            .expect("system: valid");
5621
5622        let config = minimal_config_with_schemes(1, 5, Some("historical"), None, None);
5623        let stochastic = build_stochastic_context(
5624            &system,
5625            42,
5626            None,
5627            &[],
5628            &[],
5629            OpeningTreeInputs::default(),
5630            ClassSchemes {
5631                inflow: Some(SamplingScheme::Historical),
5632                load: Some(SamplingScheme::InSample),
5633                ncs: Some(SamplingScheme::InSample),
5634            },
5635        )
5636        .expect("stochastic context");
5637
5638        let result = StudySetup::new(
5639            &system,
5640            &config,
5641            stochastic,
5642            PrepareHydroModelsResult::default_from_system(&system),
5643        );
5644
5645        assert!(result.is_err(), "expected Err when no historical data");
5646        let err_msg = result.unwrap_err().to_string();
5647        assert!(
5648            err_msg.contains("window") || err_msg.contains("historical"),
5649            "error should mention windows or historical, got: {err_msg}"
5650        );
5651    }
5652
5653    /// Given a `Config` with training inflow scheme `InSample` and simulation
5654    /// inflow scheme `OutOfSample`, when `StudySetup::new()` is called, then
5655    /// `training_ctx().inflow_scheme` is `InSample` and
5656    /// `simulation_ctx().inflow_scheme` is `OutOfSample`.
5657    #[test]
5658    fn test_simulate_uses_simulation_scheme() {
5659        let system = minimal_system(2);
5660
5661        // Training: InSample (default). Simulation: OutOfSample.
5662        let mut config = minimal_config(1, 5);
5663        config.simulation.scenario_source = Some(RawScenarioSourceConfig {
5664            seed: Some(99),
5665            historical_years: None,
5666            inflow: Some(RawClassConfigEntry {
5667                scheme: "out_of_sample".to_string(),
5668            }),
5669            load: None,
5670            ncs: None,
5671        });
5672
5673        let stochastic = build_stochastic_context(
5674            &system,
5675            42,
5676            None,
5677            &[],
5678            &[],
5679            OpeningTreeInputs::default(),
5680            ClassSchemes {
5681                inflow: Some(SamplingScheme::InSample),
5682                load: Some(SamplingScheme::InSample),
5683                ncs: Some(SamplingScheme::InSample),
5684            },
5685        )
5686        .expect("stochastic context");
5687
5688        let setup = StudySetup::new(
5689            &system,
5690            &config,
5691            stochastic,
5692            PrepareHydroModelsResult::default_from_system(&system),
5693        )
5694        .expect("setup");
5695
5696        let train_ctx = setup.training_ctx();
5697        assert_eq!(
5698            train_ctx.inflow_scheme,
5699            SamplingScheme::InSample,
5700            "training context must use InSample inflow scheme"
5701        );
5702
5703        let sim_ctx = setup.simulation_ctx();
5704        assert_eq!(
5705            sim_ctx.inflow_scheme,
5706            SamplingScheme::OutOfSample,
5707            "simulation context must use OutOfSample inflow scheme"
5708        );
5709    }
5710
5711    /// Given a `Config` with training inflow scheme `InSample` and simulation
5712    /// inflow scheme `Historical`, when `StudySetup::new()` is called on a
5713    /// system that has inflow history, then `training_ctx().historical_library`
5714    /// is `None` and `simulation_ctx().historical_library` is `Some`.
5715    #[test]
5716    fn test_sim_historical_library_built_when_sim_scheme_is_historical() {
5717        let system = system_with_historical_inflow(2);
5718
5719        // Training: InSample. Simulation: Historical.
5720        let mut config = minimal_config(1, 5);
5721        config.simulation.scenario_source = Some(RawScenarioSourceConfig {
5722            seed: Some(42),
5723            historical_years: None,
5724            inflow: Some(RawClassConfigEntry {
5725                scheme: "historical".to_string(),
5726            }),
5727            load: None,
5728            ncs: None,
5729        });
5730
5731        // The stochastic context is built for the training scheme (InSample).
5732        let stochastic = build_stochastic_context(
5733            &system,
5734            42,
5735            None,
5736            &[],
5737            &[],
5738            OpeningTreeInputs::default(),
5739            ClassSchemes {
5740                inflow: Some(SamplingScheme::InSample),
5741                load: Some(SamplingScheme::InSample),
5742                ncs: Some(SamplingScheme::InSample),
5743            },
5744        )
5745        .expect("stochastic context");
5746
5747        let setup = StudySetup::new(
5748            &system,
5749            &config,
5750            stochastic,
5751            PrepareHydroModelsResult::default_from_system(&system),
5752        )
5753        .expect("setup");
5754
5755        assert!(
5756            setup.training_ctx().historical_library.is_none(),
5757            "training context must NOT have a historical library when scheme is InSample"
5758        );
5759        assert!(
5760            setup.simulation_ctx().historical_library.is_some(),
5761            "simulation context must have a historical library when sim scheme is Historical"
5762        );
5763    }
5764
5765    /// Build a minimal system identical to [`minimal_system`] except that the
5766    /// single thermal carries `anticipated_config: Some(AnticipatedConfig { lead_stages })`.
5767    /// The `BoundsCountsSpec::k_max` is set to `lead_stages as usize` so the
5768    /// thermal stage-bounds axis is wide enough to accommodate delivery-stage
5769    /// padding.
5770    #[allow(
5771        clippy::too_many_lines,
5772        clippy::cast_possible_truncation,
5773        clippy::cast_possible_wrap,
5774        clippy::items_after_statements
5775    )]
5776    fn minimal_system_with_anticipated_lead_stages(
5777        n_stages: usize,
5778        lead_stages: u32,
5779    ) -> cobre_core::System {
5780        use chrono::NaiveDate;
5781
5782        let bus = Bus {
5783            id: EntityId(1),
5784            name: "B1".to_string(),
5785            deficit_segments: vec![DeficitSegment {
5786                depth_mw: None,
5787                cost_per_mwh: 500.0,
5788            }],
5789            excess_cost: 0.0,
5790        };
5791
5792        let thermal = Thermal {
5793            id: EntityId(2),
5794            name: "T1".to_string(),
5795            bus_id: EntityId(1),
5796            min_generation_mw: 0.0,
5797            max_generation_mw: 100.0,
5798            cost_per_mwh: 50.0,
5799            anticipated_config: Some(AnticipatedConfig { lead_stages }),
5800            entry_stage_id: None,
5801            exit_stage_id: None,
5802        };
5803
5804        let hydro = Hydro {
5805            id: EntityId(3),
5806            name: "H1".to_string(),
5807            bus_id: EntityId(1),
5808            downstream_id: None,
5809            entry_stage_id: None,
5810            exit_stage_id: None,
5811            min_storage_hm3: 0.0,
5812            max_storage_hm3: 200.0,
5813            min_outflow_m3s: 0.0,
5814            max_outflow_m3s: None,
5815            generation_model: HydroGenerationModel::ConstantProductivity,
5816            min_turbined_m3s: 0.0,
5817            max_turbined_m3s: 100.0,
5818            specific_productivity_mw_per_m3s_per_m: None,
5819            min_generation_mw: 0.0,
5820            max_generation_mw: 250.0,
5821            tailrace: None,
5822            hydraulic_losses: None,
5823            efficiency: None,
5824            evaporation_coefficients_mm: None,
5825            evaporation_reference_volumes_hm3: None,
5826            diversion: None,
5827            filling: None,
5828            penalties: HydroPenalties {
5829                spillage_cost: 0.01,
5830                diversion_cost: 0.0,
5831                turbined_cost: 0.0,
5832                storage_violation_below_cost: 0.0,
5833                filling_target_violation_cost: 0.0,
5834                turbined_violation_below_cost: 0.0,
5835                outflow_violation_below_cost: 0.0,
5836                outflow_violation_above_cost: 0.0,
5837                generation_violation_below_cost: 0.0,
5838                evaporation_violation_cost: 0.0,
5839                water_withdrawal_violation_cost: 0.0,
5840                water_withdrawal_violation_pos_cost: 0.0,
5841                water_withdrawal_violation_neg_cost: 0.0,
5842                evaporation_violation_pos_cost: 0.0,
5843                evaporation_violation_neg_cost: 0.0,
5844                inflow_nonnegativity_cost: 1000.0,
5845            },
5846        };
5847
5848        let stages: Vec<Stage> = (0..n_stages)
5849            .map(|i| Stage {
5850                index: i,
5851                id: i as i32,
5852                start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
5853                end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
5854                season_id: None,
5855                blocks: vec![Block {
5856                    index: 0,
5857                    name: "S".to_string(),
5858                    duration_hours: 744.0,
5859                }],
5860                block_mode: BlockMode::Parallel,
5861                state_config: StageStateConfig {
5862                    storage: true,
5863                    inflow_lags: false,
5864                },
5865                risk_config: StageRiskConfig::Expectation,
5866                scenario_config: ScenarioSourceConfig {
5867                    branching_factor: 1,
5868                    noise_method: NoiseMethod::Saa,
5869                },
5870            })
5871            .collect();
5872
5873        let inflow_models: Vec<InflowModel> = (0..n_stages)
5874            .map(|i| InflowModel {
5875                hydro_id: EntityId(3),
5876                stage_id: i as i32,
5877                mean_m3s: 80.0,
5878                std_m3s: 20.0,
5879                ar_coefficients: vec![],
5880                residual_std_ratio: 1.0,
5881                annual: None,
5882            })
5883            .collect();
5884
5885        let load_models: Vec<LoadModel> = (0..n_stages)
5886            .map(|i| LoadModel {
5887                bus_id: EntityId(1),
5888                stage_id: i as i32,
5889                mean_mw: 100.0,
5890                std_mw: 0.0,
5891            })
5892            .collect();
5893
5894        let n_st = n_stages.max(1);
5895        let k_max_bounds = lead_stages as usize;
5896
5897        fn default_hydro_bounds() -> HydroStageBounds {
5898            HydroStageBounds {
5899                min_storage_hm3: 0.0,
5900                max_storage_hm3: 200.0,
5901                min_turbined_m3s: 0.0,
5902                max_turbined_m3s: 100.0,
5903                min_outflow_m3s: 0.0,
5904                max_outflow_m3s: None,
5905                min_generation_mw: 0.0,
5906                max_generation_mw: 250.0,
5907                max_diversion_m3s: None,
5908                filling_inflow_m3s: 0.0,
5909                water_withdrawal_m3s: 0.0,
5910            }
5911        }
5912
5913        fn default_hydro_penalties() -> HydroStagePenalties {
5914            HydroStagePenalties {
5915                spillage_cost: 0.01,
5916                diversion_cost: 0.0,
5917                turbined_cost: 0.0,
5918                storage_violation_below_cost: 500.0,
5919                filling_target_violation_cost: 0.0,
5920                turbined_violation_below_cost: 0.0,
5921                outflow_violation_below_cost: 0.0,
5922                outflow_violation_above_cost: 0.0,
5923                generation_violation_below_cost: 0.0,
5924                evaporation_violation_cost: 0.0,
5925                water_withdrawal_violation_cost: 0.0,
5926                water_withdrawal_violation_pos_cost: 0.0,
5927                water_withdrawal_violation_neg_cost: 0.0,
5928                evaporation_violation_pos_cost: 0.0,
5929                evaporation_violation_neg_cost: 0.0,
5930                inflow_nonnegativity_cost: 1000.0,
5931            }
5932        }
5933
5934        let bounds = ResolvedBounds::new(
5935            &BoundsCountsSpec {
5936                n_hydros: 1,
5937                n_thermals: 1,
5938                n_lines: 0,
5939                n_pumping: 0,
5940                n_contracts: 0,
5941                n_stages: n_st,
5942                k_max: k_max_bounds,
5943            },
5944            &BoundsDefaults {
5945                hydro: default_hydro_bounds(),
5946                thermal: ThermalStageBounds {
5947                    min_generation_mw: 0.0,
5948                    max_generation_mw: 100.0,
5949                    cost_per_mwh: 0.0,
5950                },
5951                line: LineStageBounds {
5952                    direct_mw: 0.0,
5953                    reverse_mw: 0.0,
5954                },
5955                pumping: PumpingStageBounds {
5956                    min_flow_m3s: 0.0,
5957                    max_flow_m3s: 0.0,
5958                },
5959                contract: ContractStageBounds {
5960                    min_mw: 0.0,
5961                    max_mw: 0.0,
5962                    price_per_mwh: 0.0,
5963                },
5964            },
5965        );
5966
5967        let penalties = ResolvedPenalties::new(
5968            &PenaltiesCountsSpec {
5969                n_hydros: 1,
5970                n_buses: 1,
5971                n_lines: 0,
5972                n_ncs: 0,
5973                n_stages: n_st,
5974            },
5975            &PenaltiesDefaults {
5976                hydro: default_hydro_penalties(),
5977                bus: BusStagePenalties { excess_cost: 0.0 },
5978                line: LineStagePenalties { exchange_cost: 0.0 },
5979                ncs: NcsStagePenalties {
5980                    curtailment_cost: 0.0,
5981                },
5982            },
5983        );
5984
5985        SystemBuilder::new()
5986            .buses(vec![bus])
5987            .thermals(vec![thermal])
5988            .hydros(vec![hydro])
5989            .stages(stages)
5990            .inflow_models(inflow_models)
5991            .load_models(load_models)
5992            .bounds(bounds)
5993            .penalties(penalties)
5994            .build()
5995            .expect("minimal_system_with_anticipated_lead_stages: valid")
5996    }
5997
5998    /// Given a `StudySetup::new` call on a system with one anticipated thermal
5999    /// (`K_i = 2`), when the test inspects the resulting indexer metadata, then
6000    /// `n_anticipated == 1`, `k_max == 2`, and `anticipated_lead_stages == [2]`.
6001    #[test]
6002    fn setup_wires_anticipated_metadata_into_indexer() {
6003        let system = minimal_system_with_anticipated_lead_stages(2, 2);
6004        let config = minimal_config(1, 10);
6005        let stochastic = build_stochastic_context(
6006            &system,
6007            42,
6008            None,
6009            &[],
6010            &[],
6011            OpeningTreeInputs::default(),
6012            ClassSchemes {
6013                inflow: Some(SamplingScheme::InSample),
6014                load: Some(SamplingScheme::InSample),
6015                ncs: Some(SamplingScheme::InSample),
6016            },
6017        )
6018        .expect("stochastic context");
6019
6020        let setup = StudySetup::new(
6021            &system,
6022            &config,
6023            stochastic,
6024            PrepareHydroModelsResult::default_from_system(&system),
6025        )
6026        .expect("setup");
6027
6028        assert_eq!(
6029            setup.stage_data.indexer.n_anticipated, 1,
6030            "expected n_anticipated == 1"
6031        );
6032        assert_eq!(setup.stage_data.indexer.k_max, 2, "expected k_max == 2");
6033        assert_eq!(
6034            setup.stage_data.indexer.anticipated_lead_stages,
6035            vec![2],
6036            "expected anticipated_lead_stages == [2]"
6037        );
6038    }
6039}