Skip to main content

cobre_io/extensions/
production_models.rs

1//! Parsing for `system/hydro_production_models.json` — per-hydro production model configuration.
2//!
3//! [`parse_production_models`] reads `system/hydro_production_models.json` and returns a sorted
4//! `Vec<ProductionModelConfig>` describing the HPF model selection for each configured hydro.
5//!
6//! ## JSON structure
7//!
8//! ```json
9//! {
10//!   "$schema": "https://raw.githubusercontent.com/cobre-rs/cobre/refs/heads/main/schemas/production_models.schema.json",
11//!   "production_models": [
12//!     {
13//!       "hydro_id": 0,
14//!       "selection_mode": "stage_ranges",
15//!       "stage_ranges": [
16//!         {
17//!           "start_stage_id": 0, "end_stage_id": 24,
18//!           "model": "fpha",
19//!           "fpha_config": { "source": "computed" }
20//!         }
21//!       ]
22//!     }
23//!   ]
24//! }
25//! ```
26//!
27//! ## Selection modes
28//!
29//! - **`stage_ranges`**: Each stage maps to a model via explicit `[start, end)` ranges.
30//! - **`seasonal`**: Each stage maps to a model via its season index. Seasons not listed
31//!   fall back to `default_model`.
32//!
33//! ## Output ordering
34//!
35//! Results are sorted by `hydro_id` ascending. Duplicate `hydro_id` values are rejected
36//! as a `SchemaError`.
37//!
38//! ## Validation
39//!
40//! Per-entry constraints enforced by this parser:
41//!
42//! - No two entries share the same `hydro_id`.
43//! - For `stage_ranges` mode: `start_stage_id <= end_stage_id` when `end_stage_id` is not null.
44//! - In `fitting_window`: absolute bounds (`volume_min_hm3` / `volume_max_hm3`) and percentile
45//!   bounds (`volume_min_percentile` / `volume_max_percentile`) are mutually exclusive.
46//! - `productivity_mw_per_m3s` is **rejected** for `"fpha"` entries (both stage-range and
47//!   seasonal); FPHA derives productivity from its hyperplane geometry.
48//! - `productivity_mw_per_m3s` is **optional** for `"constant_productivity"` and
49//!   `"linearized_head"` entries. When omitted or `null`, the value is expected to be supplied by
50//!   `system/hydro_energy_productivity.parquet`; cross-file resolution is enforced by
51//!   `validation::productivity_resolution`.
52//! - When `productivity_mw_per_m3s` is present for a non-FPHA entry it must be finite and
53//!   non-negative (`>= 0.0`). A value of `0.0` is accepted as a planned-outage marker.
54//!
55//! Deferred validations (not performed here):
56//!
57//! - `hydro_id` existence in the hydro registry — Layer 3.
58//! - Cross-validation that `source: "precomputed"` hydros have FPHA hyperplanes — Layer 3/5.
59//! - That exactly one source (JSON or parquet) provides `productivity_mw_per_m3s` for each
60//!   `(hydro, stage)` pair — `validation::productivity_resolution`.
61
62use cobre_core::EntityId;
63use serde::Deserialize;
64use std::collections::HashSet;
65use std::path::Path;
66
67use crate::LoadError;
68
69/// Production model configuration for one hydro plant.
70///
71/// Loaded from `system/hydro_production_models.json`. Specifies how the hydro
72/// production function (HPF) model is selected across stages or seasons.
73///
74/// # Examples
75///
76/// ```
77/// use cobre_io::extensions::{ProductionModelConfig, SelectionMode};
78/// use cobre_core::EntityId;
79///
80/// let config = ProductionModelConfig {
81///     hydro_id: EntityId::from(0),
82///     selection_mode: SelectionMode::StageRanges {
83///         ranges: vec![],
84///     },
85/// };
86/// assert_eq!(config.hydro_id, EntityId::from(0));
87/// ```
88#[derive(Debug, Clone, PartialEq)]
89pub struct ProductionModelConfig {
90    /// Hydro plant this configuration applies to.
91    pub hydro_id: EntityId,
92    /// How the model variant is selected for each stage.
93    pub selection_mode: SelectionMode,
94}
95
96/// Parsed contents of `system/hydro_production_models.json`.
97///
98/// Bundles the per-hydro production model configs with the optional file-level
99/// FPHA plane-reduction block. `plane_reduction` is `None` when the file carries
100/// no `fpha_plane_reduction` key (the off-by-default case).
101#[derive(Debug, Clone, PartialEq, Default)]
102pub struct ProductionModelFile {
103    /// Per-hydro production model configurations, sorted by `hydro_id` ascending.
104    pub configs: Vec<ProductionModelConfig>,
105    /// File-level similar-hyperplane reduction config, applied uniformly to
106    /// every plant. `None` ⇒ no reduction.
107    pub plane_reduction: Option<PlaneReductionConfig>,
108}
109
110/// Model selection strategy for a hydro plant.
111///
112/// The two variants are mutually exclusive within a single hydro entry.
113#[derive(Debug, Clone, PartialEq)]
114pub enum SelectionMode {
115    /// Models are selected by stage ID ranges.
116    StageRanges {
117        /// Ordered list of stage range descriptors.
118        ranges: Vec<StageRange>,
119    },
120    /// Models are selected by season index, with a fallback default.
121    Seasonal {
122        /// Fallback model for seasons not listed in `seasons`.
123        default_model: String,
124        /// Season-specific overrides.
125        seasons: Vec<SeasonConfig>,
126    },
127}
128
129/// A stage range descriptor for the `stage_ranges` selection mode.
130#[derive(Debug, Clone, PartialEq)]
131pub struct StageRange {
132    /// First stage (inclusive) to which this entry applies.
133    pub start_stage_id: i32,
134    /// Last stage (inclusive) to which this entry applies. `None` means "until end of horizon".
135    pub end_stage_id: Option<i32>,
136    /// Model name: `"constant_productivity"`, `"linearized_head"`, or `"fpha"`.
137    pub model: String,
138    /// FPHA configuration, required when `model == "fpha"`.
139    pub fpha_config: Option<FphaColumnLayout>,
140    /// Optional reference operating volume, a sibling of `fpha_config`. `None`
141    /// when not declared; a default is applied later in resolution, not here.
142    pub reference_volume: Option<ReferenceVolume>,
143    /// Per-stage productivity coefficient [MW/(m³/s)]; see the module doc for the
144    /// optional / `0.0`-outage / `None`-for-fpha rules.
145    pub productivity_mw_per_m3s: Option<f64>,
146}
147
148/// A season-specific model descriptor for the `seasonal` selection mode.
149#[derive(Debug, Clone, PartialEq)]
150pub struct SeasonConfig {
151    /// Season index (0-based, matching `stages.json` season map).
152    pub season_id: i32,
153    /// Model name: `"constant_productivity"`, `"linearized_head"`, or `"fpha"`.
154    pub model: String,
155    /// FPHA configuration, required when `model == "fpha"`.
156    pub fpha_config: Option<FphaColumnLayout>,
157    /// Optional reference operating volume, a sibling of `fpha_config`. `None`
158    /// when not declared; a default is applied later in resolution, not here.
159    pub reference_volume: Option<ReferenceVolume>,
160    /// Per-season productivity coefficient [MW/(m³/s)]; see the module doc for the
161    /// optional / `0.0`-outage / `None`-for-fpha rules.
162    pub productivity_mw_per_m3s: Option<f64>,
163}
164
165/// Configuration for the FPHA production function model.
166#[derive(Debug, Clone, PartialEq)]
167pub struct FphaColumnLayout {
168    /// `"computed"` (fit from topology) or `"precomputed"` (from `fpha_hyperplanes.parquet`).
169    pub source: String,
170    /// Number of volume discretization points used when computing hyperplanes.
171    pub volume_discretization_points: Option<i32>,
172    /// Number of turbine flow discretization points used when computing hyperplanes.
173    pub turbine_discretization_points: Option<i32>,
174    /// Number of spillage discretization points used when computing hyperplanes.
175    pub spillage_discretization_points: Option<i32>,
176    /// Maximum number of planes per hydro after heuristic selection.
177    pub max_planes_per_hydro: Option<i32>,
178    /// Optional fitting window restricting the volume range for hyperplane computation.
179    pub fitting_window: Option<FittingWindow>,
180}
181
182/// Similar-hyperplane reduction configuration for FPHA planes.
183///
184/// Selects how near-parallel / near-coincident FPHA planes are merged into
185/// their mean hyperplane to shrink the LP. The two variants are mutually
186/// exclusive (the input picks one `method`); both are applied uniformly to
187/// every plant. Absent in the input means no reduction.
188#[derive(Debug, Clone, PartialEq)]
189pub enum PlaneReductionConfig {
190    /// Merge planes whose normal vectors lie within `tolerance_deg` of each
191    /// other. `tolerance_deg` is an angle in degrees, in `[0.0, 90.0]`.
192    Angle {
193        /// Maximum angle (degrees) between plane normals to treat them as
194        /// parallel.
195        tolerance_deg: f64,
196    },
197    /// Merge planes whose mean-squared distance over `n_samples` sampled points
198    /// stays within `tolerance_pct`.
199    Distance {
200        /// Maximum relative MSE distance (fraction) to treat two planes as
201        /// coincident.
202        tolerance_pct: f64,
203        /// Number of sample points used to estimate the distance.
204        n_samples: u32,
205    },
206}
207
208/// Volume fitting window for computed FPHA hyperplanes.
209///
210/// Absolute bounds (`volume_min_hm3` / `volume_max_hm3`) and percentile bounds
211/// (`volume_min_percentile` / `volume_max_percentile`) are mutually exclusive.
212#[derive(Debug, Clone, PartialEq)]
213pub struct FittingWindow {
214    /// Explicit minimum volume for fitting (hm³). Mutually exclusive with `volume_min_percentile`.
215    pub volume_min_hm3: Option<f64>,
216    /// Explicit maximum volume for fitting (hm³). Mutually exclusive with `volume_max_percentile`.
217    pub volume_max_hm3: Option<f64>,
218    /// Minimum as percentile of the operating range. Mutually exclusive with `volume_min_hm3`.
219    pub volume_min_percentile: Option<f64>,
220    /// Maximum as percentile of the operating range. Mutually exclusive with `volume_max_hm3`.
221    pub volume_max_percentile: Option<f64>,
222}
223
224/// Reference operating volume for a stage range or season.
225///
226/// The input declares the reference volume either as an absolute storage value
227/// (hm³) or as a percentile of the plant's operating range; the two are mutually
228/// exclusive. Resolution of the percentile form to an absolute value happens in a
229/// later stage, not here.
230#[derive(Debug, Clone, PartialEq)]
231pub enum ReferenceVolume {
232    /// Absolute reference volume `[hm³]`. Finite and `> 0.0`.
233    AbsoluteHm3(f64),
234    /// Reference volume as a percentile of the operating range, in `[0.0, 1.0]`.
235    Percentile(f64),
236}
237
238/// Per-hydro production model configuration loaded from
239/// `system/hydro_production_models.json`.
240///
241/// Specifies how the hydro production function (HPF) model variant is selected
242/// for each stage or season. Two selection modes are supported:
243///
244/// - `stage_ranges`: maps each stage to a model via explicit `[start, end]`
245///   intervals.
246/// - `seasonal`: maps each stage to a model via its season index, with a
247///   fallback default.
248///
249/// Each hydro may appear at most once. Results are sorted by `hydro_id`
250/// ascending.
251#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
252#[derive(Deserialize)]
253#[serde(deny_unknown_fields)]
254pub(crate) struct RawProductionModelFile {
255    /// JSON schema URI — informational, not validated.
256    #[serde(rename = "$schema")]
257    _schema: Option<String>,
258
259    /// Array of per-hydro production model configurations. Each `hydro_id`
260    /// must be unique.
261    production_models: Vec<RawProductionModel>,
262
263    /// Optional file-level FPHA plane-reduction block, applied uniformly to
264    /// every plant. Absent ⇒ no reduction. Carries a `method` tag selecting
265    /// the `angle` or `distance` reduction method and its tolerance.
266    #[serde(default)]
267    fpha_plane_reduction: Option<RawPlaneReductionConfig>,
268}
269
270/// Production model configuration for one hydro plant.
271///
272/// The `selection_mode` field discriminates between two layouts:
273/// `stage_ranges` carries a stage-range array, while `seasonal` carries a
274/// `default_model` plus a `seasons` override list.
275#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
276#[derive(Deserialize)]
277struct RawProductionModel {
278    /// Hydro plant identifier. Must be unique within the file.
279    hydro_id: i32,
280
281    /// Tagged-union payload for the model selection.
282    #[serde(flatten)]
283    selection: RawSelectionMode,
284}
285
286/// Model selection layout for a hydro plant, discriminated by the
287/// `selection_mode` JSON field.
288#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
289#[derive(Deserialize)]
290#[serde(tag = "selection_mode", rename_all = "snake_case")]
291enum RawSelectionMode {
292    /// Stage-range selection: each stage maps to a model via explicit
293    /// `[start, end]` ranges.
294    StageRanges {
295        /// Ordered list of stage range descriptors.
296        stage_ranges: Vec<RawStageRange>,
297    },
298    /// Seasonal selection: each stage maps to a model via its season index.
299    Seasonal {
300        /// Fallback model for seasons not listed in `seasons`. One of
301        /// `"constant_productivity"`, `"linearized_head"`, or `"fpha"`.
302        default_model: String,
303        /// Season-specific model overrides.
304        seasons: Vec<RawSeasonConfig>,
305    },
306}
307
308/// Stage range descriptor for the `stage_ranges` selection mode.
309#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
310#[derive(Deserialize)]
311#[serde(deny_unknown_fields)]
312struct RawStageRange {
313    /// First stage (inclusive) to which this entry applies. Must be <=
314    /// `end_stage_id` when `end_stage_id` is set.
315    start_stage_id: i32,
316    /// Last stage (inclusive) to which this entry applies. `null` = until end
317    /// of horizon.
318    end_stage_id: Option<i32>,
319    /// Model name: `"constant_productivity"`, `"linearized_head"`, or `"fpha"`.
320    model: String,
321    /// FPHA configuration. Required when `model` is `"fpha"`. Absent or null
322    /// otherwise.
323    fpha_config: Option<RawFphaColumnLayout>,
324    /// Reference operating volume for this stage range, a sibling of
325    /// `fpha_config` (not nested). Set exactly one of `volume_hm3` (absolute,
326    /// hm³) or `percentile` (`[0.0, 1.0]`). Absent or null = no reference volume
327    /// declared.
328    reference_volume: Option<RawReferenceVolume>,
329    /// Per-stage productivity coefficient [MW/(m³/s)]. Optional for
330    /// `"constant_productivity"` and `"linearized_head"` models; when absent or
331    /// null the value is expected from `system/hydro_energy_productivity.parquet`.
332    /// When present must be `> 0.0` and finite. Must be absent or null for `"fpha"`.
333    productivity_mw_per_m3s: Option<f64>,
334}
335
336/// Season-specific model descriptor for the `seasonal` selection mode.
337#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
338#[derive(Deserialize)]
339#[serde(deny_unknown_fields)]
340struct RawSeasonConfig {
341    /// Season index (0-based, matching the `stages.json` season map).
342    season_id: i32,
343    /// Model name: `"constant_productivity"`, `"linearized_head"`, or `"fpha"`.
344    model: String,
345    /// FPHA configuration. Required when `model` is `"fpha"`. Absent or null
346    /// otherwise.
347    fpha_config: Option<RawFphaColumnLayout>,
348    /// Reference operating volume for this season, a sibling of `fpha_config`
349    /// (not nested). Set exactly one of `volume_hm3` (absolute, hm³) or
350    /// `percentile` (`[0.0, 1.0]`). Absent or null = no reference volume
351    /// declared.
352    reference_volume: Option<RawReferenceVolume>,
353    /// Per-season productivity coefficient [MW/(m³/s)]. Optional for
354    /// `"constant_productivity"` and `"linearized_head"` models; when absent or
355    /// null the value is expected from `system/hydro_energy_productivity.parquet`.
356    /// When present must be `> 0.0` and finite. Must be absent or null for `"fpha"`.
357    productivity_mw_per_m3s: Option<f64>,
358}
359
360/// Configuration for the FPHA production function model.
361#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
362#[derive(Deserialize)]
363#[serde(deny_unknown_fields)]
364struct RawFphaColumnLayout {
365    /// Hyperplane source: `"computed"` (fit from topology) or
366    /// `"precomputed"` (from `fpha_hyperplanes.parquet`).
367    source: String,
368    /// Number of volume discretization points used when computing hyperplanes.
369    /// Absent = algorithm default (5).
370    volume_discretization_points: Option<i32>,
371    /// Number of turbine flow discretization points used when computing
372    /// hyperplanes. Absent = algorithm default (5).
373    turbine_discretization_points: Option<i32>,
374    /// Number of spillage discretization points used when computing
375    /// hyperplanes. Absent = algorithm default (5).
376    spillage_discretization_points: Option<i32>,
377    /// Maximum number of planes per hydro after heuristic selection. Absent =
378    /// algorithm default (10).
379    max_planes_per_hydro: Option<i32>,
380    /// Optional volume fitting window for hyperplane computation. Absent or
381    /// null = full operating range.
382    fitting_window: Option<RawFittingWindow>,
383}
384
385/// File-level FPHA plane-reduction block, discriminated by the `method` JSON
386/// field.
387///
388/// An internally-tagged union: `{ "method": "angle", "tolerance_deg": <f64> }`
389/// merges planes whose normals are within `tolerance_deg` degrees, while
390/// `{ "method": "distance", "tolerance_pct": <f64>, "n_samples": <u32> }` merges
391/// planes whose sampled mean-squared distance stays within `tolerance_pct`. The
392/// tag selects exactly one method; `deny_unknown_fields` rejects a tolerance
393/// field belonging to the other method.
394#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
395#[derive(Deserialize)]
396#[serde(tag = "method", rename_all = "snake_case", deny_unknown_fields)]
397enum RawPlaneReductionConfig {
398    /// Normal-vector angle method. Merges planes whose normals lie within
399    /// `tolerance_deg` of each other.
400    Angle {
401        /// Maximum angle (degrees) between plane normals to treat them as
402        /// parallel. Must be finite and in `[0.0, 90.0]` inclusive.
403        tolerance_deg: f64,
404    },
405    /// Mean-squared-distance method. Merges planes whose sampled MSE distance
406    /// stays within `tolerance_pct`.
407    Distance {
408        /// Maximum relative MSE distance (fraction) to treat two planes as
409        /// coincident. Must be finite and `>= 0.0`.
410        tolerance_pct: f64,
411        /// Number of sample points used to estimate the distance. Must be `>= 1`.
412        n_samples: u32,
413    },
414}
415
416/// Volume fitting window restricting the range used for FPHA hyperplane
417/// computation.
418///
419/// Absolute bounds (`volume_min_hm3` / `volume_max_hm3`) and percentile bounds
420/// (`volume_min_percentile` / `volume_max_percentile`) are mutually exclusive:
421/// set one pair or the other, not both for the same bound.
422#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
423#[allow(clippy::struct_field_names)]
424#[derive(Deserialize)]
425#[serde(deny_unknown_fields)]
426struct RawFittingWindow {
427    /// Explicit minimum volume for fitting [hm³]. Mutually exclusive with
428    /// `volume_min_percentile`.
429    volume_min_hm3: Option<f64>,
430    /// Explicit maximum volume for fitting [hm³]. Mutually exclusive with
431    /// `volume_max_percentile`.
432    volume_max_hm3: Option<f64>,
433    /// Minimum as a percentile of the operating range. Mutually exclusive
434    /// with `volume_min_hm3`.
435    volume_min_percentile: Option<f64>,
436    /// Maximum as a percentile of the operating range. Mutually exclusive
437    /// with `volume_max_hm3`.
438    volume_max_percentile: Option<f64>,
439}
440
441/// Reference operating volume declared on a stage range or season.
442///
443/// Set exactly one of `volume_hm3` (absolute, hm³) or `percentile` (a fraction
444/// of the operating range). The two are mutually exclusive; setting both, or
445/// neither, is rejected during validation.
446#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
447#[derive(Deserialize)]
448#[serde(deny_unknown_fields)]
449struct RawReferenceVolume {
450    /// Absolute reference volume [hm³]. Mutually exclusive with `percentile`.
451    /// When present must be finite and `> 0.0`.
452    volume_hm3: Option<f64>,
453    /// Reference volume as a percentile of the operating range. Mutually
454    /// exclusive with `volume_hm3`. When present must be finite and in
455    /// `[0.0, 1.0]`.
456    percentile: Option<f64>,
457}
458
459// ── Parser ────────────────────────────────────────────────────────────────────
460
461/// Parse `system/hydro_production_models.json` into a [`ProductionModelFile`].
462///
463/// # Errors
464///
465/// | Condition                                                   | Error variant              |
466/// |------------------------------------------------------------ |--------------------------- |
467/// | File not found or permission denied                         | [`LoadError::IoError`]     |
468/// | Invalid JSON syntax or unrecognised `selection_mode`        | [`LoadError::ParseError`] / [`LoadError::SchemaError`] |
469/// | Duplicate `hydro_id`                                        | [`LoadError::SchemaError`] |
470/// | `start_stage_id > end_stage_id` (when `end_stage_id` set)  | [`LoadError::SchemaError`] |
471/// | Both absolute and percentile fitting bounds set             | [`LoadError::SchemaError`] |
472/// | `fpha_plane_reduction` tolerance out of range / `n_samples < 1` | [`LoadError::SchemaError`] |
473///
474/// # Examples
475///
476/// ```no_run
477/// use cobre_io::extensions::parse_production_models;
478/// use std::path::Path;
479///
480/// let file = parse_production_models(Path::new("system/hydro_production_models.json"))
481///     .expect("valid production models file");
482/// println!("loaded {} hydro model configs", file.configs.len());
483/// ```
484pub fn parse_production_models(path: &Path) -> Result<ProductionModelFile, LoadError> {
485    let raw_text = std::fs::read_to_string(path).map_err(|e| LoadError::io(path, e))?;
486
487    let raw: RawProductionModelFile = serde_json::from_str(&raw_text).map_err(|e| {
488        let msg = e.to_string();
489        if msg.contains("unknown variant") {
490            LoadError::SchemaError {
491                path: path.to_path_buf(),
492                field: "selection_mode".to_string(),
493                message: msg,
494            }
495        } else {
496            LoadError::parse(path, msg)
497        }
498    })?;
499
500    validate_production_models(
501        &raw.production_models,
502        raw.fpha_plane_reduction.as_ref(),
503        path,
504    )?;
505
506    let mut configs: Vec<ProductionModelConfig> = raw
507        .production_models
508        .into_iter()
509        .map(convert_production_model)
510        .collect();
511
512    configs.sort_by_key(|c| c.hydro_id.0);
513
514    let plane_reduction = raw
515        .fpha_plane_reduction
516        .as_ref()
517        .map(convert_plane_reduction);
518
519    Ok(ProductionModelFile {
520        configs,
521        plane_reduction,
522    })
523}
524
525// ── Validation ────────────────────────────────────────────────────────────────
526
527/// Validate all cross-entry and per-entry constraints on raw production model data.
528fn validate_production_models(
529    models: &[RawProductionModel],
530    plane_reduction: Option<&RawPlaneReductionConfig>,
531    path: &Path,
532) -> Result<(), LoadError> {
533    let mut seen_ids: HashSet<i32> = HashSet::new();
534
535    for (entry_idx, model) in models.iter().enumerate() {
536        if !seen_ids.insert(model.hydro_id) {
537            return Err(LoadError::SchemaError {
538                path: path.to_path_buf(),
539                field: format!("production_models[{entry_idx}].hydro_id"),
540                message: format!(
541                    "duplicate hydro_id {} — each hydro may appear at most once",
542                    model.hydro_id
543                ),
544            });
545        }
546
547        match &model.selection {
548            RawSelectionMode::StageRanges { stage_ranges } => {
549                for (range_idx, range) in stage_ranges.iter().enumerate() {
550                    validate_stage_range(range, entry_idx, range_idx, path)?;
551                }
552            }
553            RawSelectionMode::Seasonal { seasons, .. } => {
554                for (season_idx, season) in seasons.iter().enumerate() {
555                    let field_base = format!(
556                        "production_models[{entry_idx}].seasons[{season_idx}].productivity_mw_per_m3s"
557                    );
558
559                    if season.model == "fpha" && season.productivity_mw_per_m3s.is_some() {
560                        return Err(LoadError::SchemaError {
561                            path: path.to_path_buf(),
562                            field: field_base,
563                            message: "productivity_mw_per_m3s must not be set when model is 'fpha'"
564                                .to_string(),
565                        });
566                    }
567
568                    // `0.0` is a planned-outage marker; reject only negative or non-finite.
569                    if season.model != "fpha"
570                        && let Some(val) = season.productivity_mw_per_m3s
571                        && (val < 0.0 || !val.is_finite())
572                    {
573                        return Err(LoadError::SchemaError {
574                            path: path.to_path_buf(),
575                            field: field_base,
576                            message: format!(
577                                "productivity_mw_per_m3s must be finite and non-negative, got {val}"
578                            ),
579                        });
580                    }
581
582                    if let Some(cfg) = &season.fpha_config {
583                        validate_fitting_window(
584                            cfg,
585                            &format!(
586                                "production_models[{entry_idx}].seasons[{season_idx}].fpha_config.fitting_window"
587                            ),
588                            path,
589                        )?;
590                    }
591
592                    if let Some(rv) = &season.reference_volume {
593                        validate_reference_volume(
594                            rv,
595                            &format!(
596                                "production_models[{entry_idx}].seasons[{season_idx}].reference_volume"
597                            ),
598                            path,
599                        )?;
600                    }
601                }
602            }
603        }
604    }
605
606    if let Some(reduction) = plane_reduction {
607        validate_plane_reduction(reduction, path)?;
608    }
609
610    Ok(())
611}
612
613/// Validate one stage range descriptor.
614fn validate_stage_range(
615    range: &RawStageRange,
616    entry_idx: usize,
617    range_idx: usize,
618    path: &Path,
619) -> Result<(), LoadError> {
620    if let Some(end) = range.end_stage_id
621        && range.start_stage_id > end
622    {
623        return Err(LoadError::SchemaError {
624            path: path.to_path_buf(),
625            field: format!(
626                "production_models[{entry_idx}].stage_ranges[{range_idx}].start_stage_id"
627            ),
628            message: format!(
629                "stage_ranges entry has start_stage_id ({}) > end_stage_id ({}); \
630                     start_stage_id must be <= end_stage_id",
631                range.start_stage_id, end
632            ),
633        });
634    }
635
636    let field_base =
637        format!("production_models[{entry_idx}].stage_ranges[{range_idx}].productivity_mw_per_m3s");
638
639    if range.model == "fpha" && range.productivity_mw_per_m3s.is_some() {
640        return Err(LoadError::SchemaError {
641            path: path.to_path_buf(),
642            field: field_base,
643            message: "productivity_mw_per_m3s must not be set when model is 'fpha'".to_string(),
644        });
645    }
646
647    // `0.0` is a planned-outage marker; reject only negative or non-finite.
648    if range.model != "fpha"
649        && let Some(val) = range.productivity_mw_per_m3s
650        && (val < 0.0 || !val.is_finite())
651    {
652        return Err(LoadError::SchemaError {
653            path: path.to_path_buf(),
654            field: field_base,
655            message: format!("productivity_mw_per_m3s must be finite and non-negative, got {val}"),
656        });
657    }
658
659    if let Some(cfg) = &range.fpha_config {
660        validate_fitting_window(
661            cfg,
662            &format!(
663                "production_models[{entry_idx}].stage_ranges[{range_idx}].fpha_config.fitting_window"
664            ),
665            path,
666        )?;
667    }
668
669    if let Some(rv) = &range.reference_volume {
670        validate_reference_volume(
671            rv,
672            &format!("production_models[{entry_idx}].stage_ranges[{range_idx}].reference_volume"),
673            path,
674        )?;
675    }
676
677    Ok(())
678}
679
680/// Reject a fitting window that sets an absolute bound and its percentile
681/// counterpart together (the two are mutually exclusive per bound).
682fn validate_fitting_window(
683    cfg: &RawFphaColumnLayout,
684    field_prefix: &str,
685    path: &Path,
686) -> Result<(), LoadError> {
687    let Some(fw) = &cfg.fitting_window else {
688        return Ok(());
689    };
690
691    if fw.volume_min_hm3.is_some() && fw.volume_min_percentile.is_some() {
692        return Err(LoadError::SchemaError {
693            path: path.to_path_buf(),
694            field: field_prefix.to_string(),
695            message: "mutually exclusive bounds: volume_min_hm3 and volume_min_percentile \
696                      cannot both be set; use absolute bounds OR percentiles, not both"
697                .to_string(),
698        });
699    }
700
701    if fw.volume_max_hm3.is_some() && fw.volume_max_percentile.is_some() {
702        return Err(LoadError::SchemaError {
703            path: path.to_path_buf(),
704            field: field_prefix.to_string(),
705            message: "mutually exclusive bounds: volume_max_hm3 and volume_max_percentile \
706                      cannot both be set; use absolute bounds OR percentiles, not both"
707                .to_string(),
708        });
709    }
710
711    Ok(())
712}
713
714/// Validate a reference-volume entry's absolute-XOR-percentile invariant.
715///
716/// A value that passes here has exactly one of `volume_hm3` / `percentile` set —
717/// `convert_reference_volume` relies on this to disambiguate.
718fn validate_reference_volume(
719    rv: &RawReferenceVolume,
720    field_prefix: &str,
721    path: &Path,
722) -> Result<(), LoadError> {
723    if rv.volume_hm3.is_some() && rv.percentile.is_some() {
724        return Err(LoadError::SchemaError {
725            path: path.to_path_buf(),
726            field: field_prefix.to_string(),
727            message: "mutually exclusive fields: volume_hm3 and percentile cannot both be \
728                      set; use an absolute volume OR a percentile, not both"
729                .to_string(),
730        });
731    }
732
733    if rv.volume_hm3.is_none() && rv.percentile.is_none() {
734        return Err(LoadError::SchemaError {
735            path: path.to_path_buf(),
736            field: field_prefix.to_string(),
737            message: "reference_volume must set exactly one of volume_hm3 or percentile"
738                .to_string(),
739        });
740    }
741
742    if let Some(vol) = rv.volume_hm3
743        && (!vol.is_finite() || vol <= 0.0)
744    {
745        return Err(LoadError::SchemaError {
746            path: path.to_path_buf(),
747            field: field_prefix.to_string(),
748            message: format!("volume_hm3 must be finite and > 0.0, got {vol}"),
749        });
750    }
751
752    if let Some(pct) = rv.percentile
753        && (!pct.is_finite() || !(0.0..=1.0).contains(&pct))
754    {
755        return Err(LoadError::SchemaError {
756            path: path.to_path_buf(),
757            field: field_prefix.to_string(),
758            message: format!("percentile must be finite and in [0.0, 1.0], got {pct}"),
759        });
760    }
761
762    Ok(())
763}
764
765/// Validate the per-method tolerance ranges of the file-level plane-reduction
766/// block.
767///
768/// Method exclusivity is already enforced structurally by serde's `method` tag
769/// and `deny_unknown_fields`; this is the config-layer range check.
770fn validate_plane_reduction(
771    reduction: &RawPlaneReductionConfig,
772    path: &Path,
773) -> Result<(), LoadError> {
774    match reduction {
775        RawPlaneReductionConfig::Angle { tolerance_deg } => {
776            if !tolerance_deg.is_finite() || *tolerance_deg < 0.0 || *tolerance_deg > 90.0 {
777                return Err(LoadError::SchemaError {
778                    path: path.to_path_buf(),
779                    field: "fpha_plane_reduction".to_string(),
780                    message: format!(
781                        "angle tolerance_deg must be finite and in [0, 90], got {tolerance_deg}"
782                    ),
783                });
784            }
785        }
786        RawPlaneReductionConfig::Distance {
787            tolerance_pct,
788            n_samples,
789        } => {
790            if !tolerance_pct.is_finite() || *tolerance_pct < 0.0 {
791                return Err(LoadError::SchemaError {
792                    path: path.to_path_buf(),
793                    field: "fpha_plane_reduction".to_string(),
794                    message: format!(
795                        "distance tolerance_pct must be finite and >= 0, got {tolerance_pct}"
796                    ),
797                });
798            }
799            if *n_samples < 1 {
800                return Err(LoadError::SchemaError {
801                    path: path.to_path_buf(),
802                    field: "fpha_plane_reduction".to_string(),
803                    message: format!("distance n_samples must be >= 1, got {n_samples}"),
804                });
805            }
806        }
807    }
808
809    Ok(())
810}
811
812// ── Conversion ────────────────────────────────────────────────────────────────
813
814/// Convert a validated raw production model entry into the public type.
815fn convert_production_model(raw: RawProductionModel) -> ProductionModelConfig {
816    let selection_mode = match raw.selection {
817        RawSelectionMode::StageRanges { stage_ranges } => SelectionMode::StageRanges {
818            ranges: stage_ranges.into_iter().map(convert_stage_range).collect(),
819        },
820        RawSelectionMode::Seasonal {
821            default_model,
822            seasons,
823        } => SelectionMode::Seasonal {
824            default_model,
825            seasons: seasons.into_iter().map(convert_season_config).collect(),
826        },
827    };
828
829    ProductionModelConfig {
830        hydro_id: EntityId::from(raw.hydro_id),
831        selection_mode,
832    }
833}
834
835fn convert_stage_range(raw: RawStageRange) -> StageRange {
836    StageRange {
837        start_stage_id: raw.start_stage_id,
838        end_stage_id: raw.end_stage_id,
839        model: raw.model,
840        fpha_config: raw.fpha_config.map(convert_fpha_column_layout),
841        reference_volume: raw.reference_volume.as_ref().map(convert_reference_volume),
842        productivity_mw_per_m3s: raw.productivity_mw_per_m3s,
843    }
844}
845
846fn convert_season_config(raw: RawSeasonConfig) -> SeasonConfig {
847    SeasonConfig {
848        season_id: raw.season_id,
849        model: raw.model,
850        fpha_config: raw.fpha_config.map(convert_fpha_column_layout),
851        reference_volume: raw.reference_volume.as_ref().map(convert_reference_volume),
852        productivity_mw_per_m3s: raw.productivity_mw_per_m3s,
853    }
854}
855
856/// Pick the public variant from whichever field is `Some`.
857///
858/// `validate_reference_volume` guarantees exactly one field is `Some`, so the
859/// `volume_hm3` priority is unambiguous and the `unwrap_or` default is
860/// unreachable — it only keeps the conversion total without a panic.
861fn convert_reference_volume(raw: &RawReferenceVolume) -> ReferenceVolume {
862    match raw.volume_hm3 {
863        Some(vol) => ReferenceVolume::AbsoluteHm3(vol),
864        None => ReferenceVolume::Percentile(raw.percentile.unwrap_or_default()),
865    }
866}
867
868fn convert_fpha_column_layout(raw: RawFphaColumnLayout) -> FphaColumnLayout {
869    FphaColumnLayout {
870        source: raw.source,
871        volume_discretization_points: raw.volume_discretization_points,
872        turbine_discretization_points: raw.turbine_discretization_points,
873        spillage_discretization_points: raw.spillage_discretization_points,
874        max_planes_per_hydro: raw.max_planes_per_hydro,
875        fitting_window: raw.fitting_window.map(|fw| FittingWindow {
876            volume_min_hm3: fw.volume_min_hm3,
877            volume_max_hm3: fw.volume_max_hm3,
878            volume_min_percentile: fw.volume_min_percentile,
879            volume_max_percentile: fw.volume_max_percentile,
880        }),
881    }
882}
883
884fn convert_plane_reduction(raw: &RawPlaneReductionConfig) -> PlaneReductionConfig {
885    match raw {
886        RawPlaneReductionConfig::Angle { tolerance_deg } => PlaneReductionConfig::Angle {
887            tolerance_deg: *tolerance_deg,
888        },
889        RawPlaneReductionConfig::Distance {
890            tolerance_pct,
891            n_samples,
892        } => PlaneReductionConfig::Distance {
893            tolerance_pct: *tolerance_pct,
894            n_samples: *n_samples,
895        },
896    }
897}
898
899// ── Tests ─────────────────────────────────────────────────────────────────────
900
901#[cfg(test)]
902#[allow(
903    clippy::doc_markdown,
904    clippy::expect_used,
905    clippy::match_wildcard_for_single_variants,
906    clippy::panic,
907    clippy::too_many_lines,
908    clippy::unwrap_used
909)]
910mod tests {
911    use super::*;
912    use std::io::Write;
913    use tempfile::NamedTempFile;
914
915    // ── helpers ───────────────────────────────────────────────────────────────
916
917    fn write_json(content: &str) -> NamedTempFile {
918        let mut f = NamedTempFile::new().unwrap();
919        f.write_all(content.as_bytes()).unwrap();
920        f
921    }
922
923    // ── AC: valid stage_ranges mode ───────────────────────────────────────────
924
925    /// Given a valid file with one hydro using `stage_ranges` mode, returns Ok with
926    /// one entry containing the correct SelectionMode variant.
927    #[test]
928    fn test_valid_stage_ranges_mode() {
929        let json = r#"{
930          "production_models": [{
931            "hydro_id": 0,
932            "selection_mode": "stage_ranges",
933            "stage_ranges": [
934              {
935                "start_stage_id": 0, "end_stage_id": 24,
936                "model": "fpha",
937                "fpha_config": {
938                  "source": "computed",
939                  "volume_discretization_points": 7,
940                  "turbine_discretization_points": 15,
941                  "fitting_window": { "volume_min_hm3": null, "volume_max_hm3": null }
942                }
943              },
944              {
945                "start_stage_id": 25, "end_stage_id": null,
946                "model": "constant_productivity",
947                "productivity_mw_per_m3s": 0.9
948              }
949            ]
950          }]
951        }"#;
952        let f = write_json(json);
953        let models = parse_production_models(f.path()).unwrap().configs;
954
955        assert_eq!(models.len(), 1);
956        let m = &models[0];
957        assert_eq!(m.hydro_id, EntityId::from(0));
958        match &m.selection_mode {
959            SelectionMode::StageRanges { ranges } => {
960                assert_eq!(ranges.len(), 2);
961                assert_eq!(ranges[0].start_stage_id, 0);
962                assert_eq!(ranges[0].end_stage_id, Some(24));
963                assert_eq!(ranges[0].model, "fpha");
964                let fpha = ranges[0].fpha_config.as_ref().unwrap();
965                assert_eq!(fpha.source, "computed");
966                assert_eq!(fpha.volume_discretization_points, Some(7));
967                assert_eq!(fpha.turbine_discretization_points, Some(15));
968                // Fitting window present but both bounds null
969                let fw = fpha.fitting_window.as_ref().unwrap();
970                assert!(fw.volume_min_hm3.is_none());
971                assert!(fw.volume_max_hm3.is_none());
972
973                assert_eq!(ranges[1].start_stage_id, 25);
974                assert!(ranges[1].end_stage_id.is_none());
975                assert_eq!(ranges[1].model, "constant_productivity");
976                assert!(ranges[1].fpha_config.is_none());
977                assert_eq!(ranges[1].productivity_mw_per_m3s, Some(0.9));
978            }
979            other => panic!("expected StageRanges, got: {other:?}"),
980        }
981    }
982
983    // ── AC: valid seasonal mode ───────────────────────────────────────────────
984
985    /// Given a valid file with one hydro using `seasonal` mode, returns Ok with one
986    /// entry containing the correct SelectionMode variant with default_model and seasons.
987    #[test]
988    fn test_valid_seasonal_mode() {
989        let json = r#"{
990          "production_models": [{
991            "hydro_id": 5,
992            "selection_mode": "seasonal",
993            "default_model": "linearized_head",
994            "seasons": [
995              {
996                "season_id": 0,
997                "model": "fpha",
998                "fpha_config": { "source": "computed", "volume_discretization_points": 5 }
999              },
1000              {
1001                "season_id": 1, "model": "fpha",
1002                "fpha_config": { "source": "computed", "turbine_discretization_points": 10 }
1003              }
1004            ]
1005          }]
1006        }"#;
1007        let f = write_json(json);
1008        let models = parse_production_models(f.path()).unwrap().configs;
1009
1010        assert_eq!(models.len(), 1);
1011        let m = &models[0];
1012        assert_eq!(m.hydro_id, EntityId::from(5));
1013        match &m.selection_mode {
1014            SelectionMode::Seasonal {
1015                default_model,
1016                seasons,
1017            } => {
1018                assert_eq!(default_model, "linearized_head");
1019                assert_eq!(seasons.len(), 2);
1020                assert_eq!(seasons[0].season_id, 0);
1021                assert_eq!(seasons[0].model, "fpha");
1022                let fpha0 = seasons[0].fpha_config.as_ref().unwrap();
1023                assert_eq!(fpha0.source, "computed");
1024                assert_eq!(fpha0.volume_discretization_points, Some(5));
1025                assert!(fpha0.turbine_discretization_points.is_none());
1026
1027                assert_eq!(seasons[1].season_id, 1);
1028                let fpha1 = seasons[1].fpha_config.as_ref().unwrap();
1029                assert_eq!(fpha1.turbine_discretization_points, Some(10));
1030                assert!(fpha1.volume_discretization_points.is_none());
1031            }
1032            other => panic!("expected Seasonal, got: {other:?}"),
1033        }
1034    }
1035
1036    // ── AC: mixed — one stage_ranges, one seasonal ────────────────────────────
1037
1038    /// Given a valid file with one hydro in stage_ranges mode and one in seasonal mode,
1039    /// returns Ok with 2 entries sorted by hydro_id.
1040    #[test]
1041    fn test_mixed_modes_sorted_by_hydro_id() {
1042        let json = r#"{
1043          "production_models": [
1044            {
1045              "hydro_id": 10,
1046              "selection_mode": "seasonal",
1047              "default_model": "constant_productivity",
1048              "seasons": []
1049            },
1050            {
1051              "hydro_id": 3,
1052              "selection_mode": "stage_ranges",
1053              "stage_ranges": [
1054                {
1055                  "start_stage_id": 0, "end_stage_id": null,
1056                  "model": "constant_productivity",
1057                  "productivity_mw_per_m3s": 0.8
1058                }
1059              ]
1060            }
1061          ]
1062        }"#;
1063        let f = write_json(json);
1064        let models = parse_production_models(f.path()).unwrap().configs;
1065
1066        assert_eq!(models.len(), 2);
1067        // Sorted by hydro_id ascending
1068        assert_eq!(models[0].hydro_id, EntityId::from(3));
1069        assert_eq!(models[1].hydro_id, EntityId::from(10));
1070        assert!(matches!(
1071            models[0].selection_mode,
1072            SelectionMode::StageRanges { .. }
1073        ));
1074        assert!(matches!(
1075            models[1].selection_mode,
1076            SelectionMode::Seasonal { .. }
1077        ));
1078    }
1079
1080    // ── AC: duplicate hydro_id -> SchemaError ─────────────────────────────────
1081
1082    /// Duplicate hydro_id in the file -> SchemaError mentioning the duplicate.
1083    #[test]
1084    fn test_duplicate_hydro_id() {
1085        let json = r#"{
1086          "production_models": [
1087            {
1088              "hydro_id": 5,
1089              "selection_mode": "stage_ranges",
1090              "stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "fpha", "fpha_config": { "source": "computed" } }]
1091            },
1092            {
1093              "hydro_id": 5,
1094              "selection_mode": "stage_ranges",
1095              "stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }]
1096            }
1097          ]
1098        }"#;
1099        let f = write_json(json);
1100        let err = parse_production_models(f.path()).unwrap_err();
1101        match &err {
1102            LoadError::SchemaError { field, message, .. } => {
1103                assert!(
1104                    field.contains("hydro_id"),
1105                    "field should mention hydro_id, got: {field}"
1106                );
1107                assert!(
1108                    message.contains("duplicate"),
1109                    "message should mention duplicate, got: {message}"
1110                );
1111            }
1112            other => panic!("expected SchemaError, got: {other:?}"),
1113        }
1114    }
1115
1116    // ── AC: invalid stage range (start > end) -> SchemaError ─────────────────
1117
1118    /// stage_ranges with start_stage_id > end_stage_id -> SchemaError with
1119    /// field containing "stage_ranges" and message containing "start_stage_id".
1120    #[test]
1121    fn test_invalid_stage_range_start_greater_than_end() {
1122        let json = r#"{
1123          "production_models": [{
1124            "hydro_id": 0,
1125            "selection_mode": "stage_ranges",
1126            "stage_ranges": [
1127              {
1128                "start_stage_id": 25, "end_stage_id": 10,
1129                "model": "constant_productivity",
1130                "productivity_mw_per_m3s": 0.9
1131              }
1132            ]
1133          }]
1134        }"#;
1135        let f = write_json(json);
1136        let err = parse_production_models(f.path()).unwrap_err();
1137        match &err {
1138            LoadError::SchemaError { field, message, .. } => {
1139                assert!(
1140                    field.contains("stage_ranges"),
1141                    "field should contain 'stage_ranges', got: {field}"
1142                );
1143                assert!(
1144                    message.contains("start_stage_id"),
1145                    "message should contain 'start_stage_id', got: {message}"
1146                );
1147            }
1148            other => panic!("expected SchemaError, got: {other:?}"),
1149        }
1150    }
1151
1152    // ── AC: equal start == end is valid ───────────────────────────────────────
1153
1154    /// start_stage_id == end_stage_id is valid (single-stage range).
1155    #[test]
1156    fn test_stage_range_start_equals_end_is_valid() {
1157        let json = r#"{
1158          "production_models": [{
1159            "hydro_id": 0,
1160            "selection_mode": "stage_ranges",
1161            "stage_ranges": [
1162              {
1163                "start_stage_id": 5, "end_stage_id": 5,
1164                "model": "constant_productivity",
1165                "productivity_mw_per_m3s": 0.9
1166              }
1167            ]
1168          }]
1169        }"#;
1170        let f = write_json(json);
1171        let result = parse_production_models(f.path());
1172        assert!(
1173            result.is_ok(),
1174            "equal start==end should be valid, got: {result:?}"
1175        );
1176    }
1177
1178    // ── AC: mutually exclusive fitting window -> SchemaError ─────────────────
1179
1180    /// Both volume_min_hm3 and volume_min_percentile set -> SchemaError with
1181    /// message containing "mutually exclusive".
1182    #[test]
1183    fn test_mutually_exclusive_fitting_window_min() {
1184        let json = r#"{
1185          "production_models": [{
1186            "hydro_id": 0,
1187            "selection_mode": "stage_ranges",
1188            "stage_ranges": [{
1189              "start_stage_id": 0, "end_stage_id": null,
1190              "model": "fpha",
1191              "fpha_config": {
1192                "source": "computed",
1193                "fitting_window": {
1194                  "volume_min_hm3": 1000.0,
1195                  "volume_max_hm3": null,
1196                  "volume_min_percentile": 0.1,
1197                  "volume_max_percentile": null
1198                }
1199              }
1200            }]
1201          }]
1202        }"#;
1203        let f = write_json(json);
1204        let err = parse_production_models(f.path()).unwrap_err();
1205        match &err {
1206            LoadError::SchemaError { message, .. } => {
1207                assert!(
1208                    message.contains("mutually exclusive"),
1209                    "message should contain 'mutually exclusive', got: {message}"
1210                );
1211            }
1212            other => panic!("expected SchemaError, got: {other:?}"),
1213        }
1214    }
1215
1216    /// Both volume_max_hm3 and volume_max_percentile set -> SchemaError with
1217    /// message containing "mutually exclusive".
1218    #[test]
1219    fn test_mutually_exclusive_fitting_window_max() {
1220        let json = r#"{
1221          "production_models": [{
1222            "hydro_id": 0,
1223            "selection_mode": "stage_ranges",
1224            "stage_ranges": [{
1225              "start_stage_id": 0, "end_stage_id": null,
1226              "model": "fpha",
1227              "fpha_config": {
1228                "source": "computed",
1229                "fitting_window": {
1230                  "volume_min_hm3": null,
1231                  "volume_max_hm3": 8000.0,
1232                  "volume_min_percentile": null,
1233                  "volume_max_percentile": 0.9
1234                }
1235              }
1236            }]
1237          }]
1238        }"#;
1239        let f = write_json(json);
1240        let err = parse_production_models(f.path()).unwrap_err();
1241        match &err {
1242            LoadError::SchemaError { message, .. } => {
1243                assert!(
1244                    message.contains("mutually exclusive"),
1245                    "message should contain 'mutually exclusive', got: {message}"
1246                );
1247            }
1248            other => panic!("expected SchemaError, got: {other:?}"),
1249        }
1250    }
1251
1252    /// Both absolute and percentile set in seasonal mode -> SchemaError.
1253    #[test]
1254    fn test_mutually_exclusive_fitting_window_seasonal() {
1255        let json = r#"{
1256          "production_models": [{
1257            "hydro_id": 1,
1258            "selection_mode": "seasonal",
1259            "default_model": "constant_productivity",
1260            "seasons": [{
1261              "season_id": 0,
1262              "model": "fpha",
1263              "fpha_config": {
1264                "source": "computed",
1265                "fitting_window": {
1266                  "volume_min_hm3": 500.0,
1267                  "volume_min_percentile": 0.2
1268                }
1269              }
1270            }]
1271          }]
1272        }"#;
1273        let f = write_json(json);
1274        let err = parse_production_models(f.path()).unwrap_err();
1275        assert!(
1276            matches!(err, LoadError::SchemaError { .. }),
1277            "expected SchemaError, got: {err:?}"
1278        );
1279    }
1280
1281    // ── AC: None path wrapper returns empty vec ───────────────────────────────
1282    // (tested in extensions/mod.rs — see load_production_models)
1283
1284    // ── AC: file not found -> IoError ────────────────────────────────────────
1285
1286    /// Non-existent path -> IoError.
1287    #[test]
1288    fn test_file_not_found() {
1289        let path = Path::new("/nonexistent/path/hydro_production_models.json");
1290        let err = parse_production_models(path).unwrap_err();
1291        match &err {
1292            LoadError::IoError { path: p, .. } => {
1293                assert_eq!(p, path);
1294            }
1295            other => panic!("expected IoError, got: {other:?}"),
1296        }
1297    }
1298
1299    // ── AC: unknown selection_mode -> SchemaError ─────────────────────────────
1300
1301    /// Unknown selection_mode -> SchemaError (tagged union deserialization failure).
1302    #[test]
1303    fn test_unknown_selection_mode() {
1304        let json = r#"{
1305          "production_models": [{
1306            "hydro_id": 0,
1307            "selection_mode": "unknown_mode"
1308          }]
1309        }"#;
1310        let f = write_json(json);
1311        let err = parse_production_models(f.path()).unwrap_err();
1312        assert!(
1313            matches!(err, LoadError::SchemaError { .. }),
1314            "expected SchemaError for unknown selection_mode, got: {err:?}"
1315        );
1316    }
1317
1318    // ── AC: empty production_models array -> Ok(vec![]) ──────────────────────
1319
1320    /// An empty `production_models` array deserialises to `Ok(Vec::new())`.
1321    #[test]
1322    fn test_empty_array_returns_empty_vec() {
1323        let json = r#"{ "production_models": [] }"#;
1324        let f = write_json(json);
1325        let models = parse_production_models(f.path()).unwrap().configs;
1326        assert!(models.is_empty());
1327    }
1328
1329    // ── AC: declaration-order invariance ─────────────────────────────────────
1330
1331    /// Reordering the entries in the JSON does not change the output ordering.
1332    #[test]
1333    fn test_declaration_order_invariance() {
1334        let json_asc = r#"{
1335          "production_models": [
1336            { "hydro_id": 1, "selection_mode": "stage_ranges",
1337              "stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }] },
1338            { "hydro_id": 5, "selection_mode": "stage_ranges",
1339              "stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }] },
1340            { "hydro_id": 99, "selection_mode": "stage_ranges",
1341              "stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }] }
1342          ]
1343        }"#;
1344        let json_desc = r#"{
1345          "production_models": [
1346            { "hydro_id": 99, "selection_mode": "stage_ranges",
1347              "stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }] },
1348            { "hydro_id": 5, "selection_mode": "stage_ranges",
1349              "stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }] },
1350            { "hydro_id": 1, "selection_mode": "stage_ranges",
1351              "stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }] }
1352          ]
1353        }"#;
1354        let f_asc = write_json(json_asc);
1355        let f_desc = write_json(json_desc);
1356        let models_asc = parse_production_models(f_asc.path()).unwrap().configs;
1357        let models_desc = parse_production_models(f_desc.path()).unwrap().configs;
1358
1359        let ids_asc: Vec<i32> = models_asc.iter().map(|m| m.hydro_id.0).collect();
1360        let ids_desc: Vec<i32> = models_desc.iter().map(|m| m.hydro_id.0).collect();
1361        assert_eq!(
1362            ids_asc, ids_desc,
1363            "output order must be hydro_id-sorted regardless of input"
1364        );
1365        assert_eq!(ids_asc, vec![1, 5, 99]);
1366    }
1367
1368    // ── AC: fpha_config without fitting_window is valid ───────────────────────
1369
1370    /// FPHA config with no fitting_window field at all is valid.
1371    #[test]
1372    fn test_fpha_config_without_fitting_window() {
1373        let json = r#"{
1374          "production_models": [{
1375            "hydro_id": 0,
1376            "selection_mode": "stage_ranges",
1377            "stage_ranges": [{
1378              "start_stage_id": 0, "end_stage_id": null,
1379              "model": "fpha",
1380              "fpha_config": { "source": "precomputed" }
1381            }]
1382          }]
1383        }"#;
1384        let f = write_json(json);
1385        let models = parse_production_models(f.path()).unwrap().configs;
1386        assert_eq!(models.len(), 1);
1387        match &models[0].selection_mode {
1388            SelectionMode::StageRanges { ranges } => {
1389                let fpha = ranges[0].fpha_config.as_ref().unwrap();
1390                assert_eq!(fpha.source, "precomputed");
1391                assert!(fpha.fitting_window.is_none());
1392            }
1393            other => panic!("expected StageRanges, got: {other:?}"),
1394        }
1395    }
1396
1397    // ── productivity_mw_per_m3s tests ─────────────────────────────────────────
1398
1399    /// `constant_productivity` stage range with a positive value parses OK and
1400    /// exposes `productivity_mw_per_m3s = Some(0.85)`.
1401    #[test]
1402    fn constant_productivity_requires_coefficient() {
1403        let json = r#"{
1404          "production_models": [{
1405            "hydro_id": 0,
1406            "selection_mode": "stage_ranges",
1407            "stage_ranges": [
1408              {
1409                "start_stage_id": 0, "end_stage_id": 24,
1410                "model": "constant_productivity",
1411                "productivity_mw_per_m3s": 0.85
1412              }
1413            ]
1414          }]
1415        }"#;
1416        let f = write_json(json);
1417        let models = parse_production_models(f.path()).unwrap().configs;
1418        match &models[0].selection_mode {
1419            SelectionMode::StageRanges { ranges } => {
1420                assert_eq!(ranges[0].productivity_mw_per_m3s, Some(0.85));
1421            }
1422            other => panic!("expected StageRanges, got: {other:?}"),
1423        }
1424    }
1425
1426    /// Non-FPHA stage range with `productivity_mw_per_m3s` omitted parses to `Ok` with
1427    /// `productivity_mw_per_m3s: None`. The parquet override is expected to supply the value.
1428    #[test]
1429    fn test_non_fpha_stage_range_without_productivity_is_accepted() {
1430        let json = r#"{
1431          "production_models": [{
1432            "hydro_id": 0,
1433            "selection_mode": "stage_ranges",
1434            "stage_ranges": [
1435              {
1436                "start_stage_id": 0, "end_stage_id": 24,
1437                "model": "constant_productivity"
1438              }
1439            ]
1440          }]
1441        }"#;
1442        let f = write_json(json);
1443        let models = parse_production_models(f.path()).unwrap().configs;
1444        match &models[0].selection_mode {
1445            SelectionMode::StageRanges { ranges } => {
1446                assert!(
1447                    ranges[0].productivity_mw_per_m3s.is_none(),
1448                    "expected None when field is omitted, got: {:?}",
1449                    ranges[0].productivity_mw_per_m3s
1450                );
1451            }
1452            other => panic!("expected StageRanges, got: {other:?}"),
1453        }
1454    }
1455
1456    /// Non-FPHA stage range with `productivity_mw_per_m3s: null` parses to `Ok` with
1457    /// `productivity_mw_per_m3s: None`. The parquet override is expected to supply the value.
1458    #[test]
1459    fn test_non_fpha_stage_range_with_null_productivity_is_accepted() {
1460        let json = r#"{
1461          "production_models": [{
1462            "hydro_id": 0,
1463            "selection_mode": "stage_ranges",
1464            "stage_ranges": [
1465              {
1466                "start_stage_id": 0, "end_stage_id": 24,
1467                "model": "linearized_head",
1468                "productivity_mw_per_m3s": null
1469              }
1470            ]
1471          }]
1472        }"#;
1473        let f = write_json(json);
1474        let models = parse_production_models(f.path()).unwrap().configs;
1475        match &models[0].selection_mode {
1476            SelectionMode::StageRanges { ranges } => {
1477                assert!(
1478                    ranges[0].productivity_mw_per_m3s.is_none(),
1479                    "expected None when field is null, got: {:?}",
1480                    ranges[0].productivity_mw_per_m3s
1481                );
1482            }
1483            other => panic!("expected StageRanges, got: {other:?}"),
1484        }
1485    }
1486
1487    /// `fpha` stage range with `productivity_mw_per_m3s` set -> `SchemaError`
1488    /// with the exact message `"productivity_mw_per_m3s must not be set when model is 'fpha'"`.
1489    #[test]
1490    fn fpha_rejects_coefficient() {
1491        let json = r#"{
1492          "production_models": [{
1493            "hydro_id": 0,
1494            "selection_mode": "stage_ranges",
1495            "stage_ranges": [
1496              {
1497                "start_stage_id": 0, "end_stage_id": 24,
1498                "model": "fpha",
1499                "fpha_config": { "source": "computed" },
1500                "productivity_mw_per_m3s": 1.0
1501              }
1502            ]
1503          }]
1504        }"#;
1505        let f = write_json(json);
1506        let err = parse_production_models(f.path()).unwrap_err();
1507        match &err {
1508            LoadError::SchemaError { field, message, .. } => {
1509                assert!(
1510                    field.contains("productivity_mw_per_m3s"),
1511                    "field should contain 'productivity_mw_per_m3s', got: {field}"
1512                );
1513                assert_eq!(
1514                    message, "productivity_mw_per_m3s must not be set when model is 'fpha'",
1515                    "message must match exactly"
1516                );
1517            }
1518            other => panic!("expected SchemaError, got: {other:?}"),
1519        }
1520    }
1521
1522    /// Validation rejects non-positive `productivity_mw_per_m3s`.
1523    #[test]
1524    fn test_productivity_negative_rejected() {
1525        let json = r#"{
1526          "production_models": [{
1527            "hydro_id": 0,
1528            "selection_mode": "stage_ranges",
1529            "stage_ranges": [
1530              {
1531                "start_stage_id": 0, "end_stage_id": 24,
1532                "model": "constant_productivity",
1533                "productivity_mw_per_m3s": -1.0
1534              }
1535            ]
1536          }]
1537        }"#;
1538        let f = write_json(json);
1539        let err = parse_production_models(f.path()).unwrap_err();
1540        assert!(
1541            matches!(err, LoadError::SchemaError { .. }),
1542            "expected SchemaError, got: {err:?}"
1543        );
1544    }
1545
1546    /// `productivity_mw_per_m3s = 0.0` is accepted as a planned-outage marker.
1547    #[test]
1548    fn test_productivity_zero_accepted() {
1549        let json = r#"{
1550          "production_models": [{
1551            "hydro_id": 0,
1552            "selection_mode": "stage_ranges",
1553            "stage_ranges": [
1554              {
1555                "start_stage_id": 0, "end_stage_id": 24,
1556                "model": "constant_productivity",
1557                "productivity_mw_per_m3s": 0.0
1558              }
1559            ]
1560          }]
1561        }"#;
1562        let f = write_json(json);
1563        let parsed = parse_production_models(f.path())
1564            .expect("zero productivity must be accepted as a planned-outage marker")
1565            .configs;
1566        let SelectionMode::StageRanges { ranges } = &parsed[0].selection_mode else {
1567            panic!("expected StageRanges");
1568        };
1569        assert_eq!(ranges[0].productivity_mw_per_m3s, Some(0.0));
1570    }
1571
1572    /// Seasonal mode with `productivity_mw_per_m3s` parses correctly.
1573    #[test]
1574    fn test_seasonal_productivity_mw_per_m3s() {
1575        let json = r#"{
1576          "production_models": [{
1577            "hydro_id": 0,
1578            "selection_mode": "seasonal",
1579            "default_model": "constant_productivity",
1580            "seasons": [
1581              {
1582                "season_id": 0,
1583                "model": "constant_productivity",
1584                "productivity_mw_per_m3s": 0.75
1585              }
1586            ]
1587          }]
1588        }"#;
1589        let f = write_json(json);
1590        let models = parse_production_models(f.path()).unwrap().configs;
1591        match &models[0].selection_mode {
1592            SelectionMode::Seasonal { seasons, .. } => {
1593                assert_eq!(seasons[0].productivity_mw_per_m3s, Some(0.75));
1594            }
1595            other => panic!("expected Seasonal, got: {other:?}"),
1596        }
1597    }
1598
1599    /// Non-FPHA seasonal entry with `productivity_mw_per_m3s` omitted parses to `Ok` with
1600    /// `productivity_mw_per_m3s: None`. The parquet override is expected to supply the value.
1601    #[test]
1602    fn test_non_fpha_seasonal_without_productivity_is_accepted() {
1603        let json = r#"{
1604          "production_models": [{
1605            "hydro_id": 0,
1606            "selection_mode": "seasonal",
1607            "default_model": "constant_productivity",
1608            "seasons": [
1609              {
1610                "season_id": 0,
1611                "model": "constant_productivity"
1612              }
1613            ]
1614          }]
1615        }"#;
1616        let f = write_json(json);
1617        let models = parse_production_models(f.path()).unwrap().configs;
1618        match &models[0].selection_mode {
1619            SelectionMode::Seasonal { seasons, .. } => {
1620                assert!(
1621                    seasons[0].productivity_mw_per_m3s.is_none(),
1622                    "expected None when field is omitted, got: {:?}",
1623                    seasons[0].productivity_mw_per_m3s
1624                );
1625            }
1626            other => panic!("expected Seasonal, got: {other:?}"),
1627        }
1628    }
1629
1630    /// Regression guard: FPHA stage range with `productivity_mw_per_m3s` set is still rejected.
1631    #[test]
1632    fn test_fpha_stage_range_with_productivity_still_rejected() {
1633        let json = r#"{
1634          "production_models": [{
1635            "hydro_id": 0,
1636            "selection_mode": "stage_ranges",
1637            "stage_ranges": [
1638              {
1639                "start_stage_id": 0, "end_stage_id": 24,
1640                "model": "fpha",
1641                "fpha_config": { "source": "computed" },
1642                "productivity_mw_per_m3s": 0.9
1643              }
1644            ]
1645          }]
1646        }"#;
1647        let f = write_json(json);
1648        let err = parse_production_models(f.path()).unwrap_err();
1649        match &err {
1650            LoadError::SchemaError { message, .. } => {
1651                assert!(
1652                    message.contains("must not be set when model is 'fpha'"),
1653                    "message should mention fpha rejection, got: {message}"
1654                );
1655            }
1656            other => panic!("expected SchemaError, got: {other:?}"),
1657        }
1658    }
1659
1660    /// Regression guard: negative `productivity_mw_per_m3s` is still rejected when present.
1661    #[test]
1662    fn test_negative_productivity_still_rejected() {
1663        let json = r#"{
1664          "production_models": [{
1665            "hydro_id": 0,
1666            "selection_mode": "stage_ranges",
1667            "stage_ranges": [
1668              {
1669                "start_stage_id": 0, "end_stage_id": 24,
1670                "model": "constant_productivity",
1671                "productivity_mw_per_m3s": -0.1
1672              }
1673            ]
1674          }]
1675        }"#;
1676        let f = write_json(json);
1677        let err = parse_production_models(f.path()).unwrap_err();
1678        match &err {
1679            LoadError::SchemaError { message, .. } => {
1680                assert!(
1681                    message.contains("productivity_mw_per_m3s must be finite and non-negative"),
1682                    "message should mention non-negative requirement, got: {message}"
1683                );
1684            }
1685            other => panic!("expected SchemaError, got: {other:?}"),
1686        }
1687    }
1688
1689    // ── fpha_plane_reduction tests ────────────────────────────────────────────
1690
1691    /// A file with no `fpha_plane_reduction` key parses to `plane_reduction == None`.
1692    #[test]
1693    fn test_plane_reduction_absent_is_none() {
1694        let json = r#"{
1695          "production_models": [{
1696            "hydro_id": 0,
1697            "selection_mode": "stage_ranges",
1698            "stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "fpha", "fpha_config": { "source": "computed" } }]
1699          }]
1700        }"#;
1701        let f = write_json(json);
1702        let file = parse_production_models(f.path()).unwrap();
1703        assert!(
1704            file.plane_reduction.is_none(),
1705            "absent block must resolve to None, got: {:?}",
1706            file.plane_reduction
1707        );
1708        assert_eq!(file.configs.len(), 1);
1709    }
1710
1711    /// A valid `angle` block parses to `Some(Angle { tolerance_deg })`.
1712    #[test]
1713    fn test_plane_reduction_angle_valid() {
1714        let json = r#"{
1715          "production_models": [],
1716          "fpha_plane_reduction": { "method": "angle", "tolerance_deg": 5.0 }
1717        }"#;
1718        let f = write_json(json);
1719        let file = parse_production_models(f.path()).unwrap();
1720        assert_eq!(
1721            file.plane_reduction,
1722            Some(PlaneReductionConfig::Angle { tolerance_deg: 5.0 })
1723        );
1724    }
1725
1726    /// A valid `distance` block parses to `Some(Distance { tolerance_pct, n_samples })`.
1727    #[test]
1728    fn test_plane_reduction_distance_valid() {
1729        let json = r#"{
1730          "production_models": [],
1731          "fpha_plane_reduction": { "method": "distance", "tolerance_pct": 0.5, "n_samples": 64 }
1732        }"#;
1733        let f = write_json(json);
1734        let file = parse_production_models(f.path()).unwrap();
1735        assert_eq!(
1736            file.plane_reduction,
1737            Some(PlaneReductionConfig::Distance {
1738                tolerance_pct: 0.5,
1739                n_samples: 64
1740            })
1741        );
1742    }
1743
1744    /// `angle` with `tolerance_deg = 95.0` is rejected with a SchemaError naming the range.
1745    #[test]
1746    fn test_plane_reduction_angle_out_of_range() {
1747        let json = r#"{
1748          "production_models": [],
1749          "fpha_plane_reduction": { "method": "angle", "tolerance_deg": 95.0 }
1750        }"#;
1751        let f = write_json(json);
1752        let err = parse_production_models(f.path()).unwrap_err();
1753        match &err {
1754            LoadError::SchemaError { field, message, .. } => {
1755                assert_eq!(field, "fpha_plane_reduction");
1756                assert!(
1757                    message.contains("[0, 90]"),
1758                    "message should name the [0, 90] range, got: {message}"
1759                );
1760            }
1761            other => panic!("expected SchemaError, got: {other:?}"),
1762        }
1763    }
1764
1765    /// `distance` with negative `tolerance_pct` is rejected with a SchemaError.
1766    #[test]
1767    fn test_plane_reduction_distance_negative_tolerance() {
1768        let json = r#"{
1769          "production_models": [],
1770          "fpha_plane_reduction": { "method": "distance", "tolerance_pct": -1.0, "n_samples": 64 }
1771        }"#;
1772        let f = write_json(json);
1773        let err = parse_production_models(f.path()).unwrap_err();
1774        match &err {
1775            LoadError::SchemaError { field, .. } => {
1776                assert_eq!(field, "fpha_plane_reduction");
1777            }
1778            other => panic!("expected SchemaError, got: {other:?}"),
1779        }
1780    }
1781
1782    /// `distance` with `n_samples = 0` is rejected with a SchemaError.
1783    #[test]
1784    fn test_plane_reduction_distance_zero_samples() {
1785        let json = r#"{
1786          "production_models": [],
1787          "fpha_plane_reduction": { "method": "distance", "tolerance_pct": 0.5, "n_samples": 0 }
1788        }"#;
1789        let f = write_json(json);
1790        let err = parse_production_models(f.path()).unwrap_err();
1791        match &err {
1792            LoadError::SchemaError { field, .. } => {
1793                assert_eq!(field, "fpha_plane_reduction");
1794            }
1795            other => panic!("expected SchemaError, got: {other:?}"),
1796        }
1797    }
1798
1799    /// A distance field on an `angle` method is rejected by serde `deny_unknown_fields`.
1800    #[test]
1801    fn test_plane_reduction_cross_method_field_rejected() {
1802        let json = r#"{
1803          "production_models": [],
1804          "fpha_plane_reduction": { "method": "angle", "tolerance_pct": 5.0 }
1805        }"#;
1806        let f = write_json(json);
1807        let err = parse_production_models(f.path()).unwrap_err();
1808        assert!(
1809            matches!(
1810                err,
1811                LoadError::SchemaError { .. } | LoadError::ParseError { .. }
1812            ),
1813            "cross-method field must be rejected, got: {err:?}"
1814        );
1815    }
1816
1817    /// An `angle` method carrying BOTH its own required `tolerance_deg` AND the
1818    /// foreign `tolerance_pct` is rejected — isolating the `deny_unknown_fields`
1819    /// guarantee from the missing-required-field path (both fields are present,
1820    /// so the only rejection reason is the unknown `tolerance_pct`).
1821    #[test]
1822    fn test_plane_reduction_foreign_field_alongside_required_is_rejected() {
1823        let json = r#"{
1824          "production_models": [],
1825          "fpha_plane_reduction": { "method": "angle", "tolerance_deg": 2.0, "tolerance_pct": 5.0 }
1826        }"#;
1827        let f = write_json(json);
1828        let err = parse_production_models(f.path()).unwrap_err();
1829        assert!(
1830            matches!(
1831                err,
1832                LoadError::SchemaError { .. } | LoadError::ParseError { .. }
1833            ),
1834            "a foreign field alongside the required one must be rejected by deny_unknown_fields, got: {err:?}"
1835        );
1836    }
1837
1838    // ── reference_volume ──────────────────────────────────────────────────────
1839
1840    /// `{ volume_hm3 }` on a stage range parses to the absolute-volume variant.
1841    #[test]
1842    fn reference_volume_absolute_parses() {
1843        let json = r#"{
1844          "production_models": [{
1845            "hydro_id": 0,
1846            "selection_mode": "stage_ranges",
1847            "stage_ranges": [{
1848              "start_stage_id": 0, "end_stage_id": null,
1849              "model": "constant_productivity",
1850              "productivity_mw_per_m3s": 0.9,
1851              "reference_volume": { "volume_hm3": 1234.5 }
1852            }]
1853          }]
1854        }"#;
1855        let f = write_json(json);
1856        let models = parse_production_models(f.path()).unwrap().configs;
1857        match &models[0].selection_mode {
1858            SelectionMode::StageRanges { ranges } => {
1859                assert_eq!(
1860                    ranges[0].reference_volume,
1861                    Some(ReferenceVolume::AbsoluteHm3(1234.5))
1862                );
1863            }
1864            other => panic!("expected StageRanges, got: {other:?}"),
1865        }
1866    }
1867
1868    /// `{ percentile }` on a stage range parses to the percentile variant.
1869    #[test]
1870    fn reference_volume_percentile_parses() {
1871        let json = r#"{
1872          "production_models": [{
1873            "hydro_id": 0,
1874            "selection_mode": "stage_ranges",
1875            "stage_ranges": [{
1876              "start_stage_id": 0, "end_stage_id": null,
1877              "model": "constant_productivity",
1878              "productivity_mw_per_m3s": 0.9,
1879              "reference_volume": { "percentile": 0.5 }
1880            }]
1881          }]
1882        }"#;
1883        let f = write_json(json);
1884        let models = parse_production_models(f.path()).unwrap().configs;
1885        match &models[0].selection_mode {
1886            SelectionMode::StageRanges { ranges } => {
1887                assert_eq!(
1888                    ranges[0].reference_volume,
1889                    Some(ReferenceVolume::Percentile(0.5))
1890                );
1891            }
1892            other => panic!("expected StageRanges, got: {other:?}"),
1893        }
1894    }
1895
1896    /// Both `volume_hm3` and `percentile` set (on a season entry) -> SchemaError
1897    /// whose message contains "mutually exclusive" and whose field names seasons.
1898    #[test]
1899    fn reference_volume_both_set_is_rejected() {
1900        let json = r#"{
1901          "production_models": [{
1902            "hydro_id": 0,
1903            "selection_mode": "seasonal",
1904            "default_model": "constant_productivity",
1905            "seasons": [{
1906              "season_id": 0,
1907              "model": "constant_productivity",
1908              "productivity_mw_per_m3s": 0.9,
1909              "reference_volume": { "volume_hm3": 1.0, "percentile": 0.5 }
1910            }]
1911          }]
1912        }"#;
1913        let f = write_json(json);
1914        let err = parse_production_models(f.path()).unwrap_err();
1915        match &err {
1916            LoadError::SchemaError { field, message, .. } => {
1917                assert!(
1918                    message.contains("mutually exclusive"),
1919                    "message should contain 'mutually exclusive', got: {message}"
1920                );
1921                assert!(
1922                    field.contains("seasons"),
1923                    "field should name seasons, got: {field}"
1924                );
1925            }
1926            other => panic!("expected SchemaError, got: {other:?}"),
1927        }
1928    }
1929
1930    /// An empty `reference_volume: {}` (neither field set) -> SchemaError.
1931    #[test]
1932    fn reference_volume_neither_set_is_rejected() {
1933        let json = r#"{
1934          "production_models": [{
1935            "hydro_id": 0,
1936            "selection_mode": "stage_ranges",
1937            "stage_ranges": [{
1938              "start_stage_id": 0, "end_stage_id": null,
1939              "model": "constant_productivity",
1940              "productivity_mw_per_m3s": 0.9,
1941              "reference_volume": {}
1942            }]
1943          }]
1944        }"#;
1945        let f = write_json(json);
1946        let err = parse_production_models(f.path()).unwrap_err();
1947        match &err {
1948            LoadError::SchemaError { message, .. } => {
1949                assert!(
1950                    message.contains("exactly one"),
1951                    "message should require exactly one field, got: {message}"
1952                );
1953            }
1954            other => panic!("expected SchemaError, got: {other:?}"),
1955        }
1956    }
1957
1958    /// `percentile` outside `[0.0, 1.0]` -> SchemaError naming the range.
1959    #[test]
1960    fn reference_volume_percentile_out_of_range_is_rejected() {
1961        let json = r#"{
1962          "production_models": [{
1963            "hydro_id": 0,
1964            "selection_mode": "stage_ranges",
1965            "stage_ranges": [{
1966              "start_stage_id": 0, "end_stage_id": null,
1967              "model": "constant_productivity",
1968              "productivity_mw_per_m3s": 0.9,
1969              "reference_volume": { "percentile": 1.5 }
1970            }]
1971          }]
1972        }"#;
1973        let f = write_json(json);
1974        let err = parse_production_models(f.path()).unwrap_err();
1975        match &err {
1976            LoadError::SchemaError { message, .. } => {
1977                assert!(
1978                    message.contains("[0.0, 1.0]"),
1979                    "message should cite the [0.0, 1.0] range, got: {message}"
1980                );
1981            }
1982            other => panic!("expected SchemaError, got: {other:?}"),
1983        }
1984    }
1985
1986    /// `volume_hm3` of `0.0` (and negative) -> SchemaError.
1987    #[test]
1988    fn reference_volume_nonpositive_volume_is_rejected() {
1989        for bad in ["0.0", "-5.0"] {
1990            let json = format!(
1991                r#"{{
1992              "production_models": [{{
1993                "hydro_id": 0,
1994                "selection_mode": "stage_ranges",
1995                "stage_ranges": [{{
1996                  "start_stage_id": 0, "end_stage_id": null,
1997                  "model": "constant_productivity",
1998                  "productivity_mw_per_m3s": 0.9,
1999                  "reference_volume": {{ "volume_hm3": {bad} }}
2000                }}]
2001              }}]
2002            }}"#
2003            );
2004            let f = write_json(&json);
2005            let err = parse_production_models(f.path()).unwrap_err();
2006            match &err {
2007                LoadError::SchemaError { message, .. } => {
2008                    assert!(
2009                        message.contains("> 0.0"),
2010                        "message should require > 0.0, got: {message}"
2011                    );
2012                }
2013                other => panic!("expected SchemaError for volume_hm3={bad}, got: {other:?}"),
2014            }
2015        }
2016    }
2017
2018    /// `{ volume_hm3 }` on a season entry parses to the absolute-volume variant.
2019    #[test]
2020    fn reference_volume_on_season_entry_parses() {
2021        let json = r#"{
2022          "production_models": [{
2023            "hydro_id": 7,
2024            "selection_mode": "seasonal",
2025            "default_model": "constant_productivity",
2026            "seasons": [{
2027              "season_id": 0,
2028              "model": "constant_productivity",
2029              "productivity_mw_per_m3s": 0.9,
2030              "reference_volume": { "volume_hm3": 800.0 }
2031            }]
2032          }]
2033        }"#;
2034        let f = write_json(json);
2035        let models = parse_production_models(f.path()).unwrap().configs;
2036        match &models[0].selection_mode {
2037            SelectionMode::Seasonal { seasons, .. } => {
2038                assert_eq!(
2039                    seasons[0].reference_volume,
2040                    Some(ReferenceVolume::AbsoluteHm3(800.0))
2041                );
2042            }
2043            other => panic!("expected Seasonal, got: {other:?}"),
2044        }
2045    }
2046
2047    /// With the `schema` feature, the generated schema for `RawProductionModelFile`
2048    /// exposes a `reference_volume` property under both entry schemas.
2049    #[cfg(feature = "schema")]
2050    #[test]
2051    fn reference_volume_appears_in_generated_schema() {
2052        let schema = schemars::schema_for!(RawProductionModelFile);
2053        let value = serde_json::to_value(&schema).unwrap();
2054        let text = serde_json::to_string(&value).unwrap();
2055        assert!(
2056            text.contains("reference_volume"),
2057            "generated schema must expose the reference_volume property"
2058        );
2059    }
2060}