Skip to main content

cobre_core/model/resolved/
bounds.rs

1//! Pre-resolved per-(entity, stage) bound containers for O(1) solver lookup.
2//!
3//! Holds the stage-resolved bound structs (`HydroStageBounds`,
4//! `ThermalStageBounds`, `LineStageBounds`, `PumpingStageBounds`,
5//! `ContractStageBounds`) and the `ResolvedBounds` table. Most entity tables use
6//! the flat layout `data[entity_idx * n_stages + stage_idx]`; the thermal table
7//! uses an extended stride `n_stages + k_max` so the padded region
8//! `[n_stages, n_stages + k_max)` can host delivery-stage values for
9//! anticipated-decision columns. Populated by `cobre-io` after base bounds are
10//! overlaid with stage-specific overrides; never modified after construction.
11
12// ─── Per-(entity, stage) bound structs ───────────────────────────────────────
13
14/// All hydro bound values for a given (hydro, stage) pair.
15///
16/// The 11 fields match the 11 rows in spec SS11 hydro bounds table. These are
17/// the fully resolved bounds after base values from `hydros.json` have been
18/// overlaid with any stage-specific overrides from `constraints/hydro_bounds.parquet`.
19///
20/// `max_outflow_m3s` is `Option<f64>` because the outflow upper bound may be absent
21/// (unbounded above) when no flood-control limit is defined for the hydro.
22/// `water_withdrawal_m3s` defaults to `0.0` when no per-stage override is present.
23///
24/// # Examples
25///
26/// ```
27/// use cobre_core::resolved::HydroStageBounds;
28///
29/// let b = HydroStageBounds {
30///     min_storage_hm3: 10.0,
31///     max_storage_hm3: 200.0,
32///     min_turbined_m3s: 0.0,
33///     max_turbined_m3s: 500.0,
34///     min_outflow_m3s: 5.0,
35///     max_outflow_m3s: None,
36///     min_generation_mw: 0.0,
37///     max_generation_mw: 100.0,
38///     max_diversion_m3s: None,
39///     filling_inflow_m3s: 0.0,
40///     water_withdrawal_m3s: 0.0,
41/// };
42/// assert!((b.min_storage_hm3 - 10.0).abs() < f64::EPSILON);
43/// assert!(b.max_outflow_m3s.is_none());
44/// ```
45#[derive(Debug, Clone, Copy, PartialEq)]
46#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
47pub struct HydroStageBounds {
48    /// Minimum reservoir storage — dead volume \[hm³\]. Soft lower bound;
49    /// violation uses `storage_violation_below` slack.
50    pub min_storage_hm3: f64,
51    /// Maximum reservoir storage — physical capacity \[hm³\]. Hard upper bound;
52    /// emergency spillage handles excess.
53    pub max_storage_hm3: f64,
54    /// Minimum turbined flow \[m³/s\]. Soft lower bound;
55    /// violation uses `turbined_violation_below` slack.
56    pub min_turbined_m3s: f64,
57    /// Maximum turbined flow \[m³/s\]. Hard upper bound.
58    pub max_turbined_m3s: f64,
59    /// Minimum outflow — environmental flow requirement \[m³/s\]. Soft lower bound;
60    /// violation uses `outflow_violation_below` slack.
61    pub min_outflow_m3s: f64,
62    /// Maximum outflow — flood-control limit \[m³/s\]. Soft upper bound;
63    /// violation uses `outflow_violation_above` slack. `None` = unbounded.
64    pub max_outflow_m3s: Option<f64>,
65    /// Minimum generation \[MW\]. Soft lower bound;
66    /// violation uses `generation_violation_below` slack.
67    pub min_generation_mw: f64,
68    /// Maximum generation \[MW\]. Hard upper bound.
69    pub max_generation_mw: f64,
70    /// Maximum diversion flow \[m³/s\]. Hard upper bound. `None` = no diversion channel.
71    pub max_diversion_m3s: Option<f64>,
72    /// Filling inflow retained for dead-volume filling during filling stages \[m³/s\].
73    /// Resolved from entity default → stage override cascade. Default `0.0`.
74    pub filling_inflow_m3s: f64,
75    /// Water withdrawal from reservoir per stage \[m³/s\]. Positive = water removed;
76    /// negative = external addition. Default `0.0`.
77    pub water_withdrawal_m3s: f64,
78}
79
80/// Thermal bound values for a given (thermal, stage) pair.
81///
82/// Resolved from base values in `thermals.json` with optional per-stage overrides
83/// from `constraints/thermal_bounds.parquet`.
84///
85/// # Examples
86///
87/// ```
88/// use cobre_core::resolved::ThermalStageBounds;
89///
90/// let b = ThermalStageBounds { min_generation_mw: 50.0, max_generation_mw: 400.0, cost_per_mwh: 120.0 };
91/// let c = b; // Copy
92/// assert!((c.max_generation_mw - 400.0).abs() < f64::EPSILON);
93/// assert!((c.cost_per_mwh - 120.0).abs() < f64::EPSILON);
94/// ```
95#[derive(Debug, Clone, Copy, PartialEq)]
96#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
97pub struct ThermalStageBounds {
98    /// Minimum stable generation \[MW\]. Hard lower bound.
99    pub min_generation_mw: f64,
100    /// Maximum generation capacity \[MW\]. Hard upper bound.
101    pub max_generation_mw: f64,
102    /// Dispatch cost override (`$/MWh`). Resolved from `Thermal.cost_per_mwh` with optional
103    /// per-stage override from `constraints/thermal_bounds.parquet` (null `block_id` rows only).
104    pub cost_per_mwh: f64,
105}
106
107/// Transmission line bound values for a given (line, stage) pair.
108///
109/// Resolved from base values in `lines.json` with optional per-stage overrides
110/// from `constraints/line_bounds.parquet`. Note that block-level exchange factors
111/// (per-block capacity multipliers) are stored separately and applied on top of
112/// these stage-level bounds at LP construction time.
113///
114/// # Examples
115///
116/// ```
117/// use cobre_core::resolved::LineStageBounds;
118///
119/// let b = LineStageBounds { direct_mw: 1000.0, reverse_mw: 800.0 };
120/// let c = b; // Copy
121/// assert!((c.direct_mw - 1000.0).abs() < f64::EPSILON);
122/// ```
123#[derive(Debug, Clone, Copy, PartialEq)]
124#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
125pub struct LineStageBounds {
126    /// Maximum direct flow capacity \[MW\]. Hard upper bound.
127    pub direct_mw: f64,
128    /// Maximum reverse flow capacity \[MW\]. Hard upper bound.
129    pub reverse_mw: f64,
130}
131
132/// Pumping station bound values for a given (pumping, stage) pair.
133///
134/// Resolved from base values in `pumping_stations.json` with optional per-stage
135/// overrides from `constraints/pumping_bounds.parquet`.
136///
137/// # Examples
138///
139/// ```
140/// use cobre_core::resolved::PumpingStageBounds;
141///
142/// let b = PumpingStageBounds { min_flow_m3s: 0.0, max_flow_m3s: 50.0 };
143/// let c = b; // Copy
144/// assert!((c.max_flow_m3s - 50.0).abs() < f64::EPSILON);
145/// ```
146#[derive(Debug, Clone, Copy, PartialEq)]
147#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
148pub struct PumpingStageBounds {
149    /// Minimum pumped flow \[m³/s\]. Hard lower bound.
150    pub min_flow_m3s: f64,
151    /// Maximum pumped flow \[m³/s\]. Hard upper bound.
152    pub max_flow_m3s: f64,
153}
154
155/// Energy contract bound values for a given (contract, stage) pair.
156///
157/// Resolved from base values in `energy_contracts.json` with optional per-stage
158/// overrides from `constraints/contract_bounds.parquet`. The price field can also
159/// be stage-varying.
160///
161/// # Examples
162///
163/// ```
164/// use cobre_core::resolved::ContractStageBounds;
165///
166/// let b = ContractStageBounds { min_mw: 0.0, max_mw: 200.0, price_per_mwh: 80.0 };
167/// let c = b; // Copy
168/// assert!((c.max_mw - 200.0).abs() < f64::EPSILON);
169/// ```
170#[derive(Debug, Clone, Copy, PartialEq)]
171#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
172pub struct ContractStageBounds {
173    /// Minimum contract usage \[MW\]. Hard lower bound.
174    pub min_mw: f64,
175    /// Maximum contract usage \[MW\]. Hard upper bound.
176    pub max_mw: f64,
177    /// Effective contract price \[$/`MWh`\]. May differ from base when a stage override
178    /// supplies a per-stage price.
179    pub price_per_mwh: f64,
180}
181
182// ─── Pre-resolved containers ──────────────────────────────────────────────────
183
184/// Pre-resolved bound table for all entities across all stages.
185///
186/// Populated by `cobre-io` after base bounds are overlaid with stage-specific
187/// overrides. Provides O(1) lookup via direct array indexing.
188///
189/// Internal layout: most tables use `data[entity_idx * n_stages + stage_idx]`.
190/// The `thermal` table uses an extended stride
191/// `data[thermal_idx * thermal_stage_axis_len + stage_idx]` with
192/// `thermal_stage_axis_len = n_stages + k_max`, where `k_max` is the maximum
193/// lead-stages across anticipated thermals. The padded region
194/// `[n_stages, n_stages + k_max)` is reserved for delivery-stage lookups by
195/// anticipated-decision columns.
196///
197/// # Examples
198///
199/// ```
200/// use cobre_core::resolved::{
201///     BoundsCountsSpec, BoundsDefaults, ContractStageBounds, HydroStageBounds,
202///     LineStageBounds, PumpingStageBounds, ResolvedBounds, ThermalStageBounds,
203/// };
204///
205/// let hydro_default = HydroStageBounds {
206///     min_storage_hm3: 0.0, max_storage_hm3: 100.0,
207///     min_turbined_m3s: 0.0, max_turbined_m3s: 50.0,
208///     min_outflow_m3s: 0.0, max_outflow_m3s: None,
209///     min_generation_mw: 0.0, max_generation_mw: 30.0,
210///     max_diversion_m3s: None,
211///     filling_inflow_m3s: 0.0, water_withdrawal_m3s: 0.0,
212/// };
213/// let thermal_default = ThermalStageBounds { min_generation_mw: 0.0, max_generation_mw: 100.0, cost_per_mwh: 50.0 };
214/// let line_default = LineStageBounds { direct_mw: 500.0, reverse_mw: 500.0 };
215/// let pumping_default = PumpingStageBounds { min_flow_m3s: 0.0, max_flow_m3s: 20.0 };
216/// let contract_default = ContractStageBounds { min_mw: 0.0, max_mw: 50.0, price_per_mwh: 80.0 };
217///
218/// let table = ResolvedBounds::new(
219///     &BoundsCountsSpec { n_hydros: 2, n_thermals: 1, n_lines: 1, n_pumping: 1, n_contracts: 1, n_stages: 3, k_max: 0 },
220///     &BoundsDefaults { hydro: hydro_default, thermal: thermal_default, line: line_default, pumping: pumping_default, contract: contract_default },
221/// );
222///
223/// let b = table.hydro_bounds(0, 2);
224/// assert!((b.max_storage_hm3 - 100.0).abs() < f64::EPSILON);
225/// ```
226#[derive(Debug, Clone, PartialEq)]
227#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
228#[cfg_attr(feature = "serde", serde(try_from = "ResolvedBoundsWire"))]
229pub struct ResolvedBounds {
230    /// Total number of stages. Used to compute flat indices.
231    n_stages: usize,
232    /// Stride used to index the `thermal` Vec; equals `n_stages + k_max`.
233    ///
234    /// Stored as a denormalized scalar so the hot-path accessors do not need
235    /// to recompute it from a `BoundsCountsSpec` (which is not retained).
236    ///
237    /// Contract: this field is **required** on the wire — it is never defaulted.
238    /// A payload that omits it, or that supplies `0` while `thermal` is
239    /// non-empty, is rejected by [`ResolvedBoundsWire`]'s `TryFrom`. Defaulting a
240    /// missing field to `0` would alias every thermal to thermal 0's stage block
241    /// (the divisor in `thermal_idx * thermal_stage_axis_len + stage_idx`
242    /// collapses to `stage_idx`), silently returning wrong bounds.
243    thermal_stage_axis_len: usize,
244    /// Flat `n_hydros * n_stages` array indexed `[hydro_idx * n_stages + stage_idx]`.
245    hydro: Vec<HydroStageBounds>,
246    /// Flat `n_thermals * (n_stages + k_max)` array indexed
247    /// `[thermal_idx * thermal_stage_axis_len + stage_idx]`.
248    ///
249    /// The stage axis is asymmetric relative to the other entity tables: it is
250    /// extended by `k_max` cells per thermal to host delivery-stage values for
251    /// anticipated-decision columns. Indices `[0, n_stages)` are the regular
252    /// study horizon; indices `[n_stages, n_stages + k_max)` are the padded
253    /// region.
254    thermal: Vec<ThermalStageBounds>,
255    /// Flat `n_lines * n_stages` array indexed `[line_idx * n_stages + stage_idx]`.
256    line: Vec<LineStageBounds>,
257    /// Flat `n_pumping * n_stages` array indexed `[pumping_idx * n_stages + stage_idx]`.
258    pumping: Vec<PumpingStageBounds>,
259    /// Flat `n_contracts * n_stages` array indexed `[contract_idx * n_stages + stage_idx]`.
260    contract: Vec<ContractStageBounds>,
261}
262
263/// Deserialization shadow for [`ResolvedBounds`].
264///
265/// Mirrors the serialized field layout exactly so round-trips are lossless, but
266/// crucially does **not** apply `serde(default)` to `thermal_stage_axis_len`: a
267/// payload missing that field fails at the field level rather than aliasing
268/// every thermal to thermal 0. The `TryFrom` below additionally rejects a
269/// present-but-zero stride when the thermal table is non-empty.
270#[cfg(feature = "serde")]
271#[derive(serde::Deserialize)]
272struct ResolvedBoundsWire {
273    n_stages: usize,
274    thermal_stage_axis_len: usize,
275    hydro: Vec<HydroStageBounds>,
276    thermal: Vec<ThermalStageBounds>,
277    line: Vec<LineStageBounds>,
278    pumping: Vec<PumpingStageBounds>,
279    contract: Vec<ContractStageBounds>,
280}
281
282#[cfg(feature = "serde")]
283impl TryFrom<ResolvedBoundsWire> for ResolvedBounds {
284    type Error = String;
285
286    fn try_from(wire: ResolvedBoundsWire) -> Result<Self, Self::Error> {
287        if !wire.thermal.is_empty() && wire.thermal_stage_axis_len == 0 {
288            return Err(
289                "thermal_stage_axis_len must be > 0 when the thermal table is non-empty; \
290                 a zero stride aliases every thermal to thermal 0"
291                    .to_string(),
292            );
293        }
294        Ok(Self {
295            n_stages: wire.n_stages,
296            thermal_stage_axis_len: wire.thermal_stage_axis_len,
297            hydro: wire.hydro,
298            thermal: wire.thermal,
299            line: wire.line,
300            pumping: wire.pumping,
301            contract: wire.contract,
302        })
303    }
304}
305
306/// Entity counts for constructing a [`ResolvedBounds`] table.
307#[derive(Debug, Clone)]
308pub struct BoundsCountsSpec {
309    /// Number of hydro plants.
310    pub n_hydros: usize,
311    /// Number of thermal units.
312    pub n_thermals: usize,
313    /// Number of transmission lines.
314    pub n_lines: usize,
315    /// Number of pumping stations.
316    pub n_pumping: usize,
317    /// Number of energy contracts.
318    pub n_contracts: usize,
319    /// Number of time stages.
320    pub n_stages: usize,
321    /// Maximum lead-stages `K_max` across anticipated thermals; the thermal
322    /// Vec stage axis is sized `n_stages + k_max`. Zero means no padding.
323    pub k_max: usize,
324}
325
326/// Default per-stage bound values for each entity type.
327#[derive(Debug, Clone)]
328pub struct BoundsDefaults {
329    /// Default hydro bounds for all (hydro, stage) cells.
330    pub hydro: HydroStageBounds,
331    /// Default thermal bounds for all (thermal, stage) cells.
332    pub thermal: ThermalStageBounds,
333    /// Default line bounds for all (line, stage) cells.
334    pub line: LineStageBounds,
335    /// Default pumping bounds for all (pumping, stage) cells.
336    pub pumping: PumpingStageBounds,
337    /// Default contract bounds for all (contract, stage) cells.
338    pub contract: ContractStageBounds,
339}
340
341impl ResolvedBounds {
342    /// Return an empty bounds table with zero entities and zero stages.
343    ///
344    /// Used as the default value in [`System`](crate::System) when no bound
345    /// resolution has been performed yet (e.g., when building a `System` from
346    /// raw entity collections without `cobre-io`).
347    ///
348    /// # Examples
349    ///
350    /// ```
351    /// use cobre_core::ResolvedBounds;
352    ///
353    /// let empty = ResolvedBounds::empty();
354    /// assert_eq!(empty.n_stages(), 0);
355    /// ```
356    #[must_use]
357    pub fn empty() -> Self {
358        Self {
359            n_stages: 0,
360            thermal_stage_axis_len: 0,
361            hydro: Vec::new(),
362            thermal: Vec::new(),
363            line: Vec::new(),
364            pumping: Vec::new(),
365            contract: Vec::new(),
366        }
367    }
368
369    /// Allocate a new resolved-bounds table filled with the given defaults.
370    ///
371    /// `counts.n_stages` must be `> 0`. Entity counts may be `0`.
372    ///
373    /// # Arguments
374    ///
375    /// * `counts` — entity counts grouped into [`BoundsCountsSpec`]
376    /// * `defaults` — default per-stage bound values grouped into [`BoundsDefaults`]
377    #[must_use]
378    pub fn new(counts: &BoundsCountsSpec, defaults: &BoundsDefaults) -> Self {
379        debug_assert!(
380            counts.n_stages > 0,
381            "ResolvedBounds::new: n_stages must be > 0 (got 0)"
382        );
383        let thermal_axis = counts.n_stages + counts.k_max;
384        Self {
385            n_stages: counts.n_stages,
386            thermal_stage_axis_len: thermal_axis,
387            hydro: vec![defaults.hydro; counts.n_hydros * counts.n_stages],
388            thermal: vec![defaults.thermal; counts.n_thermals * thermal_axis],
389            line: vec![defaults.line; counts.n_lines * counts.n_stages],
390            pumping: vec![defaults.pumping; counts.n_pumping * counts.n_stages],
391            contract: vec![defaults.contract; counts.n_contracts * counts.n_stages],
392        }
393    }
394
395    /// Return the resolved bounds for a hydro plant at a specific stage.
396    ///
397    /// Returns a shared reference to avoid copying the 11-field struct on hot paths.
398    ///
399    /// # Panics
400    ///
401    /// Panics in debug builds if `hydro_index >= n_hydros` or `stage_index >= n_stages`.
402    #[inline]
403    #[must_use]
404    pub fn hydro_bounds(&self, hydro_index: usize, stage_index: usize) -> &HydroStageBounds {
405        &self.hydro[hydro_index * self.n_stages + stage_index]
406    }
407
408    /// Return the resolved bounds for a thermal unit at a specific stage.
409    ///
410    /// `stage_index` is valid in `[0, thermal_stage_axis_len())`, which equals
411    /// `n_stages() + k_max`. Indices `>= n_stages()` access the padded region
412    /// reserved for delivery-stage lookups by anticipated-decision columns.
413    #[inline]
414    #[must_use]
415    pub fn thermal_bounds(&self, thermal_index: usize, stage_index: usize) -> ThermalStageBounds {
416        debug_assert!(
417            self.thermal.is_empty() || self.thermal_stage_axis_len > 0,
418            "thermal_stage_axis_len must be > 0 when the thermal table is non-empty"
419        );
420        self.thermal[thermal_index * self.thermal_stage_axis_len + stage_index]
421    }
422
423    /// Return the resolved bounds for a transmission line at a specific stage.
424    #[inline]
425    #[must_use]
426    pub fn line_bounds(&self, line_index: usize, stage_index: usize) -> LineStageBounds {
427        self.line[line_index * self.n_stages + stage_index]
428    }
429
430    /// Return the resolved bounds for a pumping station at a specific stage.
431    #[inline]
432    #[must_use]
433    pub fn pumping_bounds(&self, pumping_index: usize, stage_index: usize) -> PumpingStageBounds {
434        self.pumping[pumping_index * self.n_stages + stage_index]
435    }
436
437    /// Return the resolved bounds for an energy contract at a specific stage.
438    #[inline]
439    #[must_use]
440    pub fn contract_bounds(
441        &self,
442        contract_index: usize,
443        stage_index: usize,
444    ) -> ContractStageBounds {
445        self.contract[contract_index * self.n_stages + stage_index]
446    }
447
448    /// Return a mutable reference to the hydro bounds cell for in-place update.
449    ///
450    /// Used by `cobre-io` during bound resolution to set stage-specific overrides.
451    #[inline]
452    pub fn hydro_bounds_mut(
453        &mut self,
454        hydro_index: usize,
455        stage_index: usize,
456    ) -> &mut HydroStageBounds {
457        &mut self.hydro[hydro_index * self.n_stages + stage_index]
458    }
459
460    /// Return a mutable reference to the thermal bounds cell for in-place update.
461    ///
462    /// `stage_index` is valid in `[0, thermal_stage_axis_len())`. Indices
463    /// `>= n_stages()` write into the padded region reserved for
464    /// delivery-stage lookups by anticipated-decision columns.
465    #[inline]
466    pub fn thermal_bounds_mut(
467        &mut self,
468        thermal_index: usize,
469        stage_index: usize,
470    ) -> &mut ThermalStageBounds {
471        debug_assert!(
472            self.thermal.is_empty() || self.thermal_stage_axis_len > 0,
473            "thermal_stage_axis_len must be > 0 when the thermal table is non-empty"
474        );
475        &mut self.thermal[thermal_index * self.thermal_stage_axis_len + stage_index]
476    }
477
478    /// Return a mutable reference to the line bounds cell for in-place update.
479    #[inline]
480    pub fn line_bounds_mut(
481        &mut self,
482        line_index: usize,
483        stage_index: usize,
484    ) -> &mut LineStageBounds {
485        &mut self.line[line_index * self.n_stages + stage_index]
486    }
487
488    /// Return a mutable reference to the pumping bounds cell for in-place update.
489    #[inline]
490    pub fn pumping_bounds_mut(
491        &mut self,
492        pumping_index: usize,
493        stage_index: usize,
494    ) -> &mut PumpingStageBounds {
495        &mut self.pumping[pumping_index * self.n_stages + stage_index]
496    }
497
498    /// Return a mutable reference to the contract bounds cell for in-place update.
499    #[inline]
500    pub fn contract_bounds_mut(
501        &mut self,
502        contract_index: usize,
503        stage_index: usize,
504    ) -> &mut ContractStageBounds {
505        &mut self.contract[contract_index * self.n_stages + stage_index]
506    }
507
508    /// Return the number of stages in this table.
509    #[inline]
510    #[must_use]
511    pub fn n_stages(&self) -> usize {
512        self.n_stages
513    }
514
515    /// Return the stride used to index the thermal Vec.
516    ///
517    /// Equals `n_stages() + k_max`, where `k_max` is the maximum lead-stages
518    /// across anticipated thermals. When `k_max == 0` this equals
519    /// `n_stages()`. The thermal table reserves indices
520    /// `[n_stages(), thermal_stage_axis_len())` for delivery-stage lookups by
521    /// anticipated-decision columns.
522    #[inline]
523    #[must_use]
524    pub fn thermal_stage_axis_len(&self) -> usize {
525        self.thermal_stage_axis_len
526    }
527}
528
529// ─── Tests ────────────────────────────────────────────────────────────────────
530
531#[cfg(test)]
532mod tests {
533    use super::{
534        BoundsCountsSpec, BoundsDefaults, ContractStageBounds, HydroStageBounds, LineStageBounds,
535        PumpingStageBounds, ResolvedBounds, ThermalStageBounds,
536    };
537
538    fn make_hydro_bounds() -> HydroStageBounds {
539        HydroStageBounds {
540            min_storage_hm3: 10.0,
541            max_storage_hm3: 200.0,
542            min_turbined_m3s: 0.0,
543            max_turbined_m3s: 500.0,
544            min_outflow_m3s: 5.0,
545            max_outflow_m3s: None,
546            min_generation_mw: 0.0,
547            max_generation_mw: 100.0,
548            max_diversion_m3s: None,
549            filling_inflow_m3s: 0.0,
550            water_withdrawal_m3s: 0.0,
551        }
552    }
553
554    #[test]
555    fn test_all_bound_structs_are_copy() {
556        let hb = make_hydro_bounds();
557        let tb = ThermalStageBounds {
558            min_generation_mw: 0.0,
559            max_generation_mw: 100.0,
560            cost_per_mwh: 50.0,
561        };
562        let lb = LineStageBounds {
563            direct_mw: 500.0,
564            reverse_mw: 500.0,
565        };
566        let pb = PumpingStageBounds {
567            min_flow_m3s: 0.0,
568            max_flow_m3s: 20.0,
569        };
570        let cb = ContractStageBounds {
571            min_mw: 0.0,
572            max_mw: 50.0,
573            price_per_mwh: 80.0,
574        };
575
576        let hb2 = hb;
577        let tb2 = tb;
578        let lb2 = lb;
579        let pb2 = pb;
580        let cb2 = cb;
581        assert_eq!(hb, hb2);
582        assert_eq!(tb, tb2);
583        assert_eq!(lb, lb2);
584        assert_eq!(pb, pb2);
585        assert_eq!(cb, cb2);
586    }
587
588    #[test]
589    fn test_resolved_bounds_construction() {
590        let hb = make_hydro_bounds();
591        let tb = ThermalStageBounds {
592            min_generation_mw: 50.0,
593            max_generation_mw: 400.0,
594            cost_per_mwh: 0.0,
595        };
596        let lb = LineStageBounds {
597            direct_mw: 1000.0,
598            reverse_mw: 800.0,
599        };
600        let pb = PumpingStageBounds {
601            min_flow_m3s: 0.0,
602            max_flow_m3s: 20.0,
603        };
604        let cb = ContractStageBounds {
605            min_mw: 0.0,
606            max_mw: 100.0,
607            price_per_mwh: 80.0,
608        };
609
610        let table = ResolvedBounds::new(
611            &BoundsCountsSpec {
612                n_hydros: 1,
613                n_thermals: 2,
614                n_lines: 1,
615                n_pumping: 1,
616                n_contracts: 1,
617                n_stages: 3,
618                k_max: 0,
619            },
620            &BoundsDefaults {
621                hydro: hb,
622                thermal: tb,
623                line: lb,
624                pumping: pb,
625                contract: cb,
626            },
627        );
628
629        let b = table.hydro_bounds(0, 2);
630        assert!((b.min_storage_hm3 - 10.0).abs() < f64::EPSILON);
631        assert!((b.max_storage_hm3 - 200.0).abs() < f64::EPSILON);
632        assert!(b.max_outflow_m3s.is_none());
633        assert!(b.max_diversion_m3s.is_none());
634
635        let t0 = table.thermal_bounds(0, 0);
636        let t1 = table.thermal_bounds(1, 2);
637        assert!((t0.max_generation_mw - 400.0).abs() < f64::EPSILON);
638        assert!((t1.min_generation_mw - 50.0).abs() < f64::EPSILON);
639
640        assert!((table.line_bounds(0, 1).direct_mw - 1000.0).abs() < f64::EPSILON);
641        assert!((table.pumping_bounds(0, 0).max_flow_m3s - 20.0).abs() < f64::EPSILON);
642        assert!((table.contract_bounds(0, 2).price_per_mwh - 80.0).abs() < f64::EPSILON);
643    }
644
645    #[test]
646    fn test_resolved_bounds_mutable_update() {
647        let hb = make_hydro_bounds();
648        let tb = ThermalStageBounds {
649            min_generation_mw: 0.0,
650            max_generation_mw: 200.0,
651            cost_per_mwh: 0.0,
652        };
653        let lb = LineStageBounds {
654            direct_mw: 500.0,
655            reverse_mw: 500.0,
656        };
657        let pb = PumpingStageBounds {
658            min_flow_m3s: 0.0,
659            max_flow_m3s: 30.0,
660        };
661        let cb = ContractStageBounds {
662            min_mw: 0.0,
663            max_mw: 50.0,
664            price_per_mwh: 60.0,
665        };
666
667        let mut table = ResolvedBounds::new(
668            &BoundsCountsSpec {
669                n_hydros: 2,
670                n_thermals: 1,
671                n_lines: 1,
672                n_pumping: 1,
673                n_contracts: 1,
674                n_stages: 3,
675                k_max: 0,
676            },
677            &BoundsDefaults {
678                hydro: hb,
679                thermal: tb,
680                line: lb,
681                pumping: pb,
682                contract: cb,
683            },
684        );
685
686        let cell = table.hydro_bounds_mut(1, 0);
687        cell.min_storage_hm3 = 25.0;
688        cell.max_outflow_m3s = Some(1000.0);
689
690        assert!((table.hydro_bounds(1, 0).min_storage_hm3 - 25.0).abs() < f64::EPSILON);
691        assert_eq!(table.hydro_bounds(1, 0).max_outflow_m3s, Some(1000.0));
692        assert!((table.hydro_bounds(0, 0).min_storage_hm3 - 10.0).abs() < f64::EPSILON);
693        assert!(table.hydro_bounds(1, 1).max_outflow_m3s.is_none());
694
695        table.thermal_bounds_mut(0, 2).max_generation_mw = 150.0;
696        assert!((table.thermal_bounds(0, 2).max_generation_mw - 150.0).abs() < f64::EPSILON);
697        assert!((table.thermal_bounds(0, 0).max_generation_mw - 200.0).abs() < f64::EPSILON);
698    }
699
700    #[test]
701    fn test_thermal_stage_axis_extends_with_k_max() {
702        let tb = ThermalStageBounds {
703            min_generation_mw: 0.0,
704            max_generation_mw: 100.0,
705            cost_per_mwh: 0.0,
706        };
707        let table = ResolvedBounds::new(
708            &BoundsCountsSpec {
709                n_hydros: 0,
710                n_thermals: 2,
711                n_lines: 0,
712                n_pumping: 0,
713                n_contracts: 0,
714                n_stages: 3,
715                k_max: 2,
716            },
717            &BoundsDefaults {
718                hydro: zero_hydro_default_for_tests(),
719                thermal: tb,
720                line: LineStageBounds {
721                    direct_mw: 0.0,
722                    reverse_mw: 0.0,
723                },
724                pumping: PumpingStageBounds {
725                    min_flow_m3s: 0.0,
726                    max_flow_m3s: 0.0,
727                },
728                contract: ContractStageBounds {
729                    min_mw: 0.0,
730                    max_mw: 0.0,
731                    price_per_mwh: 0.0,
732                },
733            },
734        );
735        assert_eq!(table.thermal_stage_axis_len(), 5);
736        // Padded region inherits the default ThermalStageBounds.
737        let padded = table.thermal_bounds(1, 4);
738        assert!((padded.max_generation_mw - 100.0).abs() < f64::EPSILON);
739    }
740
741    #[test]
742    fn test_thermal_stage_axis_zero_k_max_unchanged() {
743        let tb = ThermalStageBounds {
744            min_generation_mw: 0.0,
745            max_generation_mw: 50.0,
746            cost_per_mwh: 0.0,
747        };
748        let table = ResolvedBounds::new(
749            &BoundsCountsSpec {
750                n_hydros: 0,
751                n_thermals: 1,
752                n_lines: 0,
753                n_pumping: 0,
754                n_contracts: 0,
755                n_stages: 4,
756                k_max: 0,
757            },
758            &BoundsDefaults {
759                hydro: zero_hydro_default_for_tests(),
760                thermal: tb,
761                line: LineStageBounds {
762                    direct_mw: 0.0,
763                    reverse_mw: 0.0,
764                },
765                pumping: PumpingStageBounds {
766                    min_flow_m3s: 0.0,
767                    max_flow_m3s: 0.0,
768                },
769                contract: ContractStageBounds {
770                    min_mw: 0.0,
771                    max_mw: 0.0,
772                    price_per_mwh: 0.0,
773                },
774            },
775        );
776        assert_eq!(table.thermal_stage_axis_len(), table.n_stages());
777        // Last valid horizon stage still works.
778        let last = table.thermal_bounds(0, 3);
779        assert!((last.max_generation_mw - 50.0).abs() < f64::EPSILON);
780    }
781
782    #[test]
783    fn test_empty_bounds_has_zero_thermal_axis() {
784        let empty = ResolvedBounds::empty();
785        assert_eq!(empty.thermal_stage_axis_len(), 0);
786        assert_eq!(empty.n_stages(), 0);
787    }
788
789    // ─── Thermal-bounds padding boundary tests ───────────────────────────────
790    //
791    // These tests pin down the lookup contract at the four boundary stage
792    // indices that the LP-template wiring exercises:
793    //
794    //   * `T - 1` — last study stage (real, possibly overridden).
795    //   * `T`     — first padded stage (must inherit plant base).
796    //   * `T + K - 1` — last padded stage (still plant base).
797    //   * `T + K` — one past the padding (panics in debug builds).
798    //
799    // The per-thermal base-fill semantics are verified in
800    // `crates/cobre-io/src/resolution/bounds.rs::tests` because that file owns
801    // `Thermal` entity construction; this module only verifies the uniform
802    // `BoundsDefaults.thermal` fill behavior.
803
804    /// Sentinel default used by the thermal-padding boundary tests. Values are
805    /// picked so an off-by-one read returns a value that does not collide with
806    /// any plausible production default.
807    const T_DEFAULT: ThermalStageBounds = ThermalStageBounds {
808        min_generation_mw: 7.0,
809        max_generation_mw: 77.0,
810        cost_per_mwh: 7.7,
811    };
812
813    /// Construct a `ResolvedBounds` with one thermal entity, the given
814    /// `n_stages` / `k_max`, and `T_DEFAULT` as the thermal default. Other
815    /// entity types are zero-sized.
816    fn make_bounds_for_boundary_tests(n_stages: usize, k_max: usize) -> ResolvedBounds {
817        ResolvedBounds::new(
818            &BoundsCountsSpec {
819                n_hydros: 0,
820                n_thermals: 1,
821                n_lines: 0,
822                n_pumping: 0,
823                n_contracts: 0,
824                n_stages,
825                k_max,
826            },
827            &BoundsDefaults {
828                hydro: zero_hydro_default_for_tests(),
829                thermal: T_DEFAULT,
830                line: LineStageBounds {
831                    direct_mw: 0.0,
832                    reverse_mw: 0.0,
833                },
834                pumping: PumpingStageBounds {
835                    min_flow_m3s: 0.0,
836                    max_flow_m3s: 0.0,
837                },
838                contract: ContractStageBounds {
839                    min_mw: 0.0,
840                    max_mw: 0.0,
841                    price_per_mwh: 0.0,
842                },
843            },
844        )
845    }
846
847    /// `T - 1`: writing a distinctive value via `thermal_bounds_mut` at the
848    /// last study stage and reading it back via `thermal_bounds` must return
849    /// the written value — the padding region must not shadow study stages.
850    #[test]
851    fn test_thermal_bounds_at_last_study_stage() {
852        let mut table = make_bounds_for_boundary_tests(5, 3);
853        let written = ThermalStageBounds {
854            min_generation_mw: 11.0,
855            max_generation_mw: 111.0,
856            cost_per_mwh: 1.1,
857        };
858        *table.thermal_bounds_mut(0, 4) = written;
859        let read = table.thermal_bounds(0, 4);
860        assert!((read.min_generation_mw - 11.0).abs() < f64::EPSILON);
861        assert!((read.max_generation_mw - 111.0).abs() < f64::EPSILON);
862        assert!((read.cost_per_mwh - 1.1).abs() < f64::EPSILON);
863    }
864
865    /// `T`: the first padded stage must contain the uniform thermal default
866    /// after `ResolvedBounds::new` — no spillover from any non-existent prior
867    /// override and no zero-initialization regression.
868    #[test]
869    fn test_thermal_bounds_at_first_padded_stage() {
870        let table = make_bounds_for_boundary_tests(5, 3);
871        let padded = table.thermal_bounds(0, 5);
872        assert!((padded.min_generation_mw - T_DEFAULT.min_generation_mw).abs() < f64::EPSILON);
873        assert!((padded.max_generation_mw - T_DEFAULT.max_generation_mw).abs() < f64::EPSILON);
874        assert!((padded.cost_per_mwh - T_DEFAULT.cost_per_mwh).abs() < f64::EPSILON);
875    }
876
877    /// `T + K_max - 1`: the last padded stage must still return the uniform
878    /// thermal default — the padded region is contiguous and uniform.
879    #[test]
880    fn test_thermal_bounds_at_last_padded_stage() {
881        let table = make_bounds_for_boundary_tests(5, 3);
882        // 5 + 3 - 1 == 7
883        let padded = table.thermal_bounds(0, 7);
884        assert!((padded.min_generation_mw - T_DEFAULT.min_generation_mw).abs() < f64::EPSILON);
885        assert!((padded.max_generation_mw - T_DEFAULT.max_generation_mw).abs() < f64::EPSILON);
886        assert!((padded.cost_per_mwh - T_DEFAULT.cost_per_mwh).abs() < f64::EPSILON);
887    }
888
889    /// `T + K_max`: one past the padding region must panic in debug builds.
890    /// Gated by `#[cfg(debug_assertions)]` because release builds may silently
891    /// read adjacent memory via `Vec` indexing (see `thermal_bounds` docs).
892    #[test]
893    #[cfg(debug_assertions)]
894    fn test_thermal_bounds_out_of_range_panics_in_debug() {
895        let table = make_bounds_for_boundary_tests(5, 3);
896        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
897            // 5 + 3 == 8: one past the last valid padded stage.
898            let _ = table.thermal_bounds(0, 8);
899        }));
900        assert!(
901            result.is_err(),
902            "thermal_bounds(0, 8) must panic in debug builds when n_stages=5, k_max=3"
903        );
904    }
905
906    /// `n_stages()` returns the *study horizon* length, not the padded axis.
907    /// The padded region is internal to the thermal storage; consumers that
908    /// iterate the study horizon (forward/backward passes, simulation) must
909    /// continue to see `n_stages() == 5`.
910    #[test]
911    fn test_n_stages_unchanged_with_padding() {
912        let table = make_bounds_for_boundary_tests(5, 3);
913        assert_eq!(table.n_stages(), 5);
914    }
915
916    /// `thermal_stage_axis_len()` returns `n_stages + k_max`. This is the
917    /// public accessor anticipated-decision consumers use to validate that
918    /// `t + K_i` lookups remain in-range.
919    #[test]
920    fn test_thermal_stage_axis_len_equals_n_plus_k_max() {
921        let table = make_bounds_for_boundary_tests(5, 3);
922        assert_eq!(table.thermal_stage_axis_len(), 8);
923    }
924
925    /// Parameter-sweep invariant test (`anticipated_invariants`
926    /// pattern). Asserts `thermal_stage_axis_len() == n_stages + k_max` across
927    /// a 3 x 4 x 3 grid of configurations. The coverage gate at the end
928    /// confirms every combination was reached.
929    mod bounds_padding_invariants {
930        use super::{
931            BoundsCountsSpec, BoundsDefaults, ContractStageBounds, LineStageBounds,
932            PumpingStageBounds, ResolvedBounds, T_DEFAULT, zero_hydro_default_for_tests,
933        };
934
935        #[test]
936        fn axis_len_matches_n_plus_k_max() {
937            // n_stages starts at 1: ResolvedBounds::new debug-asserts n_stages > 0,
938            // so the 0 case is exercised separately by
939            // new_with_zero_n_stages_panics_in_debug.
940            let n_stages_grid = [1_usize, 5, 12];
941            let k_max_grid = [0_usize, 1, 3, 10];
942            let n_thermals_grid = [0_usize, 1, 5];
943
944            let mut count: usize = 0;
945            for &n_stages in &n_stages_grid {
946                for &k_max in &k_max_grid {
947                    for &n_thermals in &n_thermals_grid {
948                        let table = ResolvedBounds::new(
949                            &BoundsCountsSpec {
950                                n_hydros: 0,
951                                n_thermals,
952                                n_lines: 0,
953                                n_pumping: 0,
954                                n_contracts: 0,
955                                n_stages,
956                                k_max,
957                            },
958                            &BoundsDefaults {
959                                hydro: zero_hydro_default_for_tests(),
960                                thermal: T_DEFAULT,
961                                line: LineStageBounds {
962                                    direct_mw: 0.0,
963                                    reverse_mw: 0.0,
964                                },
965                                pumping: PumpingStageBounds {
966                                    min_flow_m3s: 0.0,
967                                    max_flow_m3s: 0.0,
968                                },
969                                contract: ContractStageBounds {
970                                    min_mw: 0.0,
971                                    max_mw: 0.0,
972                                    price_per_mwh: 0.0,
973                                },
974                            },
975                        );
976                        assert_eq!(
977                            table.thermal_stage_axis_len(),
978                            n_stages + k_max,
979                            "axis_len mismatch at (n_stages={n_stages}, k_max={k_max}, n_thermals={n_thermals})"
980                        );
981                        assert_eq!(
982                            table.n_stages(),
983                            n_stages,
984                            "n_stages mismatch at (n_stages={n_stages}, k_max={k_max}, n_thermals={n_thermals})"
985                        );
986                        count += 1;
987                    }
988                }
989            }
990            // Coverage gate: 3 * 4 * 3 == 36 combinations expected; assert the
991            // documented minimum of 27 just to guard against accidental loop
992            // truncation if the grids are edited.
993            assert!(
994                count >= 27,
995                "expected at least 27 sweep combinations, got {count}"
996            );
997        }
998    }
999
1000    /// Helper returning a zero-valued [`HydroStageBounds`] for tests that do
1001    /// not exercise the hydro entity table.
1002    fn zero_hydro_default_for_tests() -> HydroStageBounds {
1003        HydroStageBounds {
1004            min_storage_hm3: 0.0,
1005            max_storage_hm3: 0.0,
1006            min_turbined_m3s: 0.0,
1007            max_turbined_m3s: 0.0,
1008            min_outflow_m3s: 0.0,
1009            max_outflow_m3s: None,
1010            min_generation_mw: 0.0,
1011            max_generation_mw: 0.0,
1012            max_diversion_m3s: None,
1013            filling_inflow_m3s: 0.0,
1014            water_withdrawal_m3s: 0.0,
1015        }
1016    }
1017
1018    #[test]
1019    fn test_hydro_stage_bounds_has_eleven_fields() {
1020        let b = HydroStageBounds {
1021            min_storage_hm3: 1.0,
1022            max_storage_hm3: 2.0,
1023            min_turbined_m3s: 3.0,
1024            max_turbined_m3s: 4.0,
1025            min_outflow_m3s: 5.0,
1026            max_outflow_m3s: Some(6.0),
1027            min_generation_mw: 7.0,
1028            max_generation_mw: 8.0,
1029            max_diversion_m3s: Some(9.0),
1030            filling_inflow_m3s: 10.0,
1031            water_withdrawal_m3s: 11.0,
1032        };
1033        assert!((b.min_storage_hm3 - 1.0).abs() < f64::EPSILON);
1034        assert!((b.water_withdrawal_m3s - 11.0).abs() < f64::EPSILON);
1035        assert_eq!(b.max_outflow_m3s, Some(6.0));
1036        assert_eq!(b.max_diversion_m3s, Some(9.0));
1037    }
1038
1039    #[test]
1040    #[cfg(feature = "serde")]
1041    fn test_resolved_bounds_serde_roundtrip() {
1042        let hb = make_hydro_bounds();
1043        let tb = ThermalStageBounds {
1044            min_generation_mw: 0.0,
1045            max_generation_mw: 100.0,
1046            cost_per_mwh: 0.0,
1047        };
1048        let lb = LineStageBounds {
1049            direct_mw: 500.0,
1050            reverse_mw: 500.0,
1051        };
1052        let pb = PumpingStageBounds {
1053            min_flow_m3s: 0.0,
1054            max_flow_m3s: 20.0,
1055        };
1056        let cb = ContractStageBounds {
1057            min_mw: 0.0,
1058            max_mw: 50.0,
1059            price_per_mwh: 80.0,
1060        };
1061
1062        let original = ResolvedBounds::new(
1063            &BoundsCountsSpec {
1064                n_hydros: 1,
1065                n_thermals: 1,
1066                n_lines: 1,
1067                n_pumping: 1,
1068                n_contracts: 1,
1069                n_stages: 3,
1070                k_max: 0,
1071            },
1072            &BoundsDefaults {
1073                hydro: hb,
1074                thermal: tb,
1075                line: lb,
1076                pumping: pb,
1077                contract: cb,
1078            },
1079        );
1080        let json = serde_json::to_string(&original).expect("serialize");
1081        let restored: ResolvedBounds = serde_json::from_str(&json).expect("deserialize");
1082        assert_eq!(original, restored);
1083    }
1084
1085    /// Roundtrip with a non-zero `k_max`: guards against silent data loss in
1086    /// the `thermal_stage_axis_len` field. With `serde(default)` on that
1087    /// field, an absent JSON key would deserialize back to `0`, aliasing all
1088    /// thermals to thermal 0's cells. This test ensures the field is actually
1089    /// serialized.
1090    #[cfg(feature = "serde")]
1091    #[test]
1092    fn test_resolved_bounds_serde_roundtrip_with_padding() {
1093        let hb = make_hydro_bounds();
1094        let tb = ThermalStageBounds {
1095            min_generation_mw: 0.0,
1096            max_generation_mw: 200.0,
1097            cost_per_mwh: 60.0,
1098        };
1099        let lb = LineStageBounds {
1100            direct_mw: 50.0,
1101            reverse_mw: 50.0,
1102        };
1103        let pb = PumpingStageBounds {
1104            min_flow_m3s: 0.0,
1105            max_flow_m3s: 20.0,
1106        };
1107        let cb = ContractStageBounds {
1108            min_mw: 0.0,
1109            max_mw: 50.0,
1110            price_per_mwh: 80.0,
1111        };
1112
1113        let original = ResolvedBounds::new(
1114            &BoundsCountsSpec {
1115                n_hydros: 1,
1116                n_thermals: 2,
1117                n_lines: 1,
1118                n_pumping: 1,
1119                n_contracts: 1,
1120                n_stages: 3,
1121                k_max: 2,
1122            },
1123            &BoundsDefaults {
1124                hydro: hb,
1125                thermal: tb,
1126                line: lb,
1127                pumping: pb,
1128                contract: cb,
1129            },
1130        );
1131        assert_eq!(original.thermal_stage_axis_len(), 5);
1132        let json = serde_json::to_string(&original).expect("serialize");
1133        let restored: ResolvedBounds = serde_json::from_str(&json).expect("deserialize");
1134        assert_eq!(
1135            restored.thermal_stage_axis_len(),
1136            original.thermal_stage_axis_len(),
1137            "thermal_stage_axis_len must survive serde roundtrip"
1138        );
1139        assert_eq!(original, restored);
1140    }
1141
1142    /// A JSON payload that omits `thermal_stage_axis_len` while the thermal
1143    /// table is non-empty must be **rejected**, not silently defaulted to `0`.
1144    /// A zero stride would alias every thermal to thermal 0's stage block; the
1145    /// `serde(try_from = "ResolvedBoundsWire")` path errors instead.
1146    #[cfg(feature = "serde")]
1147    #[test]
1148    fn deserialize_missing_thermal_axis_len_with_thermals_is_rejected() {
1149        // One thermal, one stage: the thermal table is non-empty, so the
1150        // absent stride must trigger a deserialization error.
1151        let json = r#"{
1152            "n_stages": 1,
1153            "hydro": [],
1154            "thermal": [{"min_generation_mw": 0.0, "max_generation_mw": 100.0, "cost_per_mwh": 50.0}],
1155            "line": [],
1156            "pumping": [],
1157            "contract": []
1158        }"#;
1159        let result: Result<ResolvedBounds, _> = serde_json::from_str(json);
1160        assert!(
1161            result.is_err(),
1162            "deserializing a non-empty thermal table without thermal_stage_axis_len \
1163             must error, got Ok"
1164        );
1165    }
1166
1167    /// A present-but-zero `thermal_stage_axis_len` with a non-empty thermal
1168    /// table is also rejected by the `TryFrom` cross-field check.
1169    #[cfg(feature = "serde")]
1170    #[test]
1171    fn deserialize_zero_thermal_axis_len_with_thermals_is_rejected() {
1172        let json = r#"{
1173            "n_stages": 1,
1174            "thermal_stage_axis_len": 0,
1175            "hydro": [],
1176            "thermal": [{"min_generation_mw": 0.0, "max_generation_mw": 100.0, "cost_per_mwh": 50.0}],
1177            "line": [],
1178            "pumping": [],
1179            "contract": []
1180        }"#;
1181        let result: Result<ResolvedBounds, _> = serde_json::from_str(json);
1182        assert!(
1183            result.is_err(),
1184            "deserializing a non-empty thermal table with thermal_stage_axis_len=0 \
1185             must error, got Ok"
1186        );
1187    }
1188
1189    /// `ResolvedBounds::new` documents `n_stages > 0` as a precondition and
1190    /// enforces it with a `debug_assert!`. Verify the debug-build panic.
1191    #[test]
1192    #[cfg(debug_assertions)]
1193    fn new_with_zero_n_stages_panics_in_debug() {
1194        let result = std::panic::catch_unwind(|| {
1195            ResolvedBounds::new(
1196                &BoundsCountsSpec {
1197                    n_hydros: 1,
1198                    n_thermals: 1,
1199                    n_lines: 1,
1200                    n_pumping: 1,
1201                    n_contracts: 1,
1202                    n_stages: 0,
1203                    k_max: 0,
1204                },
1205                &BoundsDefaults {
1206                    hydro: zero_hydro_default_for_tests(),
1207                    thermal: ThermalStageBounds {
1208                        min_generation_mw: 0.0,
1209                        max_generation_mw: 0.0,
1210                        cost_per_mwh: 0.0,
1211                    },
1212                    line: LineStageBounds {
1213                        direct_mw: 0.0,
1214                        reverse_mw: 0.0,
1215                    },
1216                    pumping: PumpingStageBounds {
1217                        min_flow_m3s: 0.0,
1218                        max_flow_m3s: 0.0,
1219                    },
1220                    contract: ContractStageBounds {
1221                        min_mw: 0.0,
1222                        max_mw: 0.0,
1223                        price_per_mwh: 0.0,
1224                    },
1225                },
1226            )
1227        });
1228        assert!(
1229            result.is_err(),
1230            "ResolvedBounds::new(n_stages=0) must panic in debug builds"
1231        );
1232    }
1233}