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//! Each block-eligible family exposes up to three accessor kinds:
4//!
5//! - **Stage bounds** (`<family>_bounds`) — the columns with no block
6//!   dimension ([`HydroStageBounds`], [`ThermalStageBounds`]). Lines, pumping
7//!   stations, and contracts have no stage-only columns, so they have no
8//!   `<family>_bounds` accessor.
9//! - **Block bounds** (`<family>_bounds_at_block`) — the value that applies at
10//!   a named block: the block-base cell with the per-block override overlay
11//!   applied on top. With an empty overlay this is bit-identical to
12//!   `<family>_block_base` for every `block_index`; the empty-overlay path is
13//!   never special-cased.
14//! - **Base** (`<family>_block_base`) — the block-eligible columns at
15//!   `(entity, stage)` granularity, ignoring the overlay. Reserved for exactly
16//!   two sanctioned callers: the dictionary report path
17//!   (`write_bounds_parquet`'s null-`block_id` base row, all five families)
18//!   and the anticipated-commitment decision column
19//!   (`fill_anticipated_columns`, thermal only). Any other caller is a design
20//!   question, not an implementation detail.
21//!
22//! Most entity tables use the flat layout `data[entity_idx * n_stages + stage_idx]`;
23//! the thermal table's extended stride is documented on [`ResolvedBounds`].
24//! Populated by `cobre-io` after base bounds are overlaid with stage-specific
25//! overrides; never modified after construction.
26
27use super::{ResolvedBlockBounds, ResolvedHydroUnitGroupBounds};
28
29/// Stage-level hydro bounds for a given (hydro, stage) pair — the four
30/// stage-boundary-stock columns; [`HydroBlockBounds`] holds the
31/// block-eligible columns instead.
32///
33/// Resolved from `hydros.json` overlaid with optional per-stage overrides from
34/// `constraints/hydro_bounds.parquet`. Rows mirror the spec SS11 hydro bounds table.
35///
36/// # Examples
37///
38/// ```
39/// use cobre_core::resolved::HydroStageBounds;
40///
41/// let b = HydroStageBounds {
42///     min_storage_hm3: 10.0,
43///     max_storage_hm3: 200.0,
44///     filling_min_rate_m3s: 0.0,
45///     water_withdrawal_m3s: 0.0,
46/// };
47/// assert!((b.min_storage_hm3 - 10.0).abs() < f64::EPSILON);
48/// ```
49#[derive(Debug, Clone, Copy, PartialEq)]
50#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
51pub struct HydroStageBounds {
52    /// Dead volume \[hm³\]. Soft lower bound; slack `storage_violation_below`.
53    pub min_storage_hm3: f64,
54    /// Physical capacity \[hm³\]. Hard upper bound.
55    pub max_storage_hm3: f64,
56    /// Minimum dead-volume filling rate \[m³/s\], anchoring a per-stage minimum
57    /// target-storage trajectory on `min_storage_hm3`. Not an inflow and not a cap. Default `0.0`.
58    pub filling_min_rate_m3s: f64,
59    /// Water withdrawal per stage \[m³/s\]. Positive = removed; negative = added. Default `0.0`.
60    pub water_withdrawal_m3s: f64,
61}
62
63/// Block-eligible hydro flow/generation bounds for a given (hydro, stage)
64/// pair — the capacity half of the hydro split; [`HydroStageBounds`] holds
65/// the stage-boundary-stock half.
66///
67/// # Examples
68///
69/// ```
70/// use cobre_core::resolved::HydroBlockBounds;
71///
72/// let b = HydroBlockBounds {
73///     min_turbined_m3s: 0.0,
74///     max_turbined_m3s: 500.0,
75///     min_outflow_m3s: 5.0,
76///     max_outflow_m3s: None,
77///     min_generation_mw: 0.0,
78///     max_generation_mw: 100.0,
79///     min_diversion_m3s: None,
80///     max_diversion_m3s: None,
81///     min_spillage_m3s: None,
82///     max_spillage_m3s: None,
83/// };
84/// let c = b; // Copy
85/// assert!((c.max_turbined_m3s - 500.0).abs() < f64::EPSILON);
86/// assert!(c.max_outflow_m3s.is_none());
87/// ```
88#[derive(Debug, Clone, Copy, PartialEq)]
89#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
90pub struct HydroBlockBounds {
91    /// Minimum turbined flow \[m³/s\]. Soft lower bound; slack `turbined_violation_below`.
92    pub min_turbined_m3s: f64,
93    /// Maximum turbined flow \[m³/s\]. Hard upper bound.
94    pub max_turbined_m3s: f64,
95    /// Environmental flow requirement \[m³/s\]. Soft lower bound; slack `outflow_violation_below`.
96    pub min_outflow_m3s: f64,
97    /// Flood-control limit \[m³/s\]. Soft upper bound; slack `outflow_violation_above`. `None` = unbounded.
98    pub max_outflow_m3s: Option<f64>,
99    /// Minimum generation \[MW\]. Soft lower bound; slack `generation_violation_below`.
100    pub min_generation_mw: f64,
101    /// Maximum generation \[MW\]. Hard upper bound.
102    pub max_generation_mw: f64,
103    /// Minimum diversion flow \[m³/s\]. `None` = unbounded below.
104    pub min_diversion_m3s: Option<f64>,
105    /// Maximum diversion flow \[m³/s\]. Hard upper bound. `None` = no diversion channel.
106    pub max_diversion_m3s: Option<f64>,
107    /// Minimum spillage flow \[m³/s\]. `None` = unbounded below.
108    pub min_spillage_m3s: Option<f64>,
109    /// Maximum spillage flow \[m³/s\]. `None` = unbounded above.
110    pub max_spillage_m3s: Option<f64>,
111}
112
113/// Neutral test scaffold — `0.0` minima/maxima, `None` optionals — never a
114/// bound source; production sites construct `HydroBlockBounds` exhaustively.
115impl Default for HydroBlockBounds {
116    fn default() -> Self {
117        Self {
118            min_turbined_m3s: 0.0,
119            max_turbined_m3s: 0.0,
120            min_outflow_m3s: 0.0,
121            max_outflow_m3s: None,
122            min_generation_mw: 0.0,
123            max_generation_mw: 0.0,
124            min_diversion_m3s: None,
125            max_diversion_m3s: None,
126            min_spillage_m3s: None,
127            max_spillage_m3s: None,
128        }
129    }
130}
131
132/// Dense per-(hydro, stage) cell pairing [`HydroStageBounds`] and
133/// [`HydroBlockBounds`] — one stride and one bounds check per hydro
134/// lookup; splitting into two parallel `Vec`s could desync their lengths.
135#[derive(Debug, Clone, Copy, PartialEq)]
136#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
137struct HydroCell {
138    stage: HydroStageBounds,
139    block: HydroBlockBounds,
140}
141
142/// Block-eligible thermal generation bounds for a given (thermal, stage)
143/// pair — the capacity half of the thermal split; [`ThermalStageBounds`]
144/// holds the stage-level cost half.
145///
146/// # Examples
147///
148/// ```
149/// use cobre_core::resolved::ThermalBlockBounds;
150///
151/// let b = ThermalBlockBounds { min_generation_mw: 50.0, max_generation_mw: 400.0 };
152/// let c = b; // Copy
153/// assert!((c.max_generation_mw - 400.0).abs() < f64::EPSILON);
154/// ```
155#[derive(Debug, Clone, Copy, PartialEq)]
156#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
157pub struct ThermalBlockBounds {
158    /// Minimum stable generation \[MW\]. Hard lower bound.
159    pub min_generation_mw: f64,
160    /// Maximum generation capacity \[MW\]. Hard upper bound.
161    pub max_generation_mw: f64,
162}
163
164/// Thermal cost for a given (thermal, stage) pair; block-eligible generation
165/// capacity lives in [`ThermalBlockBounds`] instead — per-block thermal cost
166/// is out of scope (spec §6).
167///
168/// Resolved from `thermals.json` overlaid with `constraints/thermal_bounds.parquet`.
169///
170/// # Examples
171///
172/// ```
173/// use cobre_core::resolved::ThermalStageBounds;
174///
175/// let b = ThermalStageBounds { cost_per_mwh: 120.0 };
176/// let c = b; // Copy
177/// assert!((c.cost_per_mwh - 120.0).abs() < f64::EPSILON);
178/// ```
179#[derive(Debug, Clone, Copy, PartialEq)]
180#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
181pub struct ThermalStageBounds {
182    /// Dispatch cost override (`$/MWh`). Resolved from `Thermal.cost_per_mwh` with optional
183    /// per-stage override from `constraints/thermal_bounds.parquet` (null `block_id` rows only).
184    pub cost_per_mwh: f64,
185}
186
187/// Dense per-(thermal, stage) cell pairing [`ThermalStageBounds`] and
188/// [`ThermalBlockBounds`] — one stride and one bounds check per thermal
189/// lookup; splitting into two parallel `Vec`s could desync their lengths.
190#[derive(Debug, Clone, Copy, PartialEq)]
191#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
192struct ThermalCell {
193    stage: ThermalStageBounds,
194    block: ThermalBlockBounds,
195}
196
197/// Block-eligible transmission line bounds for a given (line, stage) pair.
198///
199/// Lines have no stage-only bound column — every line column is block-eligible —
200/// so this is line's only per-(line, stage) bound type, stored directly with no
201/// stage half to pair against. Resolved from `lines.json` overlaid with
202/// `constraints/line_bounds.parquet`. A per-block capacity override
203/// ([`LineBlockOverride`](crate::resolved::LineBlockOverride)) wins over this
204/// stage-wide value at LP construction time.
205///
206/// # Examples
207///
208/// ```
209/// use cobre_core::resolved::LineBlockBounds;
210///
211/// let b = LineBlockBounds { direct_mw: 1000.0, reverse_mw: 800.0 };
212/// let c = b; // Copy
213/// assert!((c.direct_mw - 1000.0).abs() < f64::EPSILON);
214/// ```
215#[derive(Debug, Clone, Copy, PartialEq)]
216#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
217pub struct LineBlockBounds {
218    /// Maximum direct flow capacity \[MW\]. Hard upper bound.
219    pub direct_mw: f64,
220    /// Maximum reverse flow capacity \[MW\]. Hard upper bound.
221    pub reverse_mw: f64,
222}
223
224/// Block-eligible pumping station bounds for a given (pumping, stage) pair.
225///
226/// Pumping stations have no stage-only bound column — every pumping column is
227/// block-eligible — so this is pumping's only per-(pumping, stage) bound type,
228/// stored directly with no stage half to pair against. Resolved from
229/// `pumping_stations.json` overlaid with `constraints/pumping_bounds.parquet`.
230///
231/// # Examples
232///
233/// ```
234/// use cobre_core::resolved::PumpingBlockBounds;
235///
236/// let b = PumpingBlockBounds { min_flow_m3s: 0.0, max_flow_m3s: 50.0 };
237/// let c = b; // Copy
238/// assert!((c.max_flow_m3s - 50.0).abs() < f64::EPSILON);
239/// ```
240#[derive(Debug, Clone, Copy, PartialEq)]
241#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
242pub struct PumpingBlockBounds {
243    /// Minimum pumped flow \[m³/s\]. Hard lower bound.
244    pub min_flow_m3s: f64,
245    /// Maximum pumped flow \[m³/s\]. Hard upper bound.
246    pub max_flow_m3s: f64,
247}
248
249/// Block-eligible energy contract bounds for a given (contract, stage) pair.
250///
251/// Contracts have no stage-only bound column — every contract column,
252/// including `price_per_mwh`, is block-eligible — so this is contract's only
253/// per-(contract, stage) bound type, stored directly with no stage half to
254/// pair against. Resolved from `energy_contracts.json` overlaid with
255/// `constraints/contract_bounds.parquet`. `price_per_mwh` being block-eligible
256/// is deliberately asymmetric with [`ThermalStageBounds`]'s stage-only
257/// `cost_per_mwh` (spec §7 decision 6 against §6) — do not symmetrize the two.
258///
259/// # Examples
260///
261/// ```
262/// use cobre_core::resolved::ContractBlockBounds;
263///
264/// let b = ContractBlockBounds { min_mw: 0.0, max_mw: 200.0, price_per_mwh: 80.0 };
265/// let c = b; // Copy
266/// assert!((c.max_mw - 200.0).abs() < f64::EPSILON);
267/// ```
268#[derive(Debug, Clone, Copy, PartialEq)]
269#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
270pub struct ContractBlockBounds {
271    /// Minimum contract usage \[MW\]. Hard lower bound.
272    pub min_mw: f64,
273    /// Maximum contract usage \[MW\]. Hard upper bound.
274    pub max_mw: f64,
275    /// Effective contract price \[$/`MWh`\]. May differ from base when a block override
276    /// supplies a per-block price.
277    pub price_per_mwh: f64,
278}
279
280// ─── Pre-resolved containers ──────────────────────────────────────────────────
281
282/// Pre-resolved bound table for all entities across all stages.
283///
284/// Most tables index `data[entity_idx * n_stages + stage_idx]`; the `thermal`
285/// table uses an extended `n_stages + k_max` stride — see
286/// [`thermal_stage_axis_len`](Self::thermal_stage_axis_len).
287///
288/// # Examples
289///
290/// ```
291/// use cobre_core::resolved::{
292///     BoundsCountsSpec, BoundsDefaults, ContractBlockBounds, HydroBlockBounds, HydroStageBounds,
293///     LineBlockBounds, PumpingBlockBounds, ResolvedBounds, ThermalBlockBounds,
294///     ThermalStageBounds,
295/// };
296///
297/// let hydro_default = HydroStageBounds {
298///     min_storage_hm3: 0.0, max_storage_hm3: 100.0,
299///     filling_min_rate_m3s: 0.0, water_withdrawal_m3s: 0.0,
300/// };
301/// let hydro_block_default = HydroBlockBounds {
302///     min_turbined_m3s: 0.0, max_turbined_m3s: 50.0,
303///     min_outflow_m3s: 0.0, max_outflow_m3s: None,
304///     min_generation_mw: 0.0, max_generation_mw: 30.0,
305///     min_diversion_m3s: None, max_diversion_m3s: None,
306///     min_spillage_m3s: None, max_spillage_m3s: None,
307/// };
308/// let thermal_default = ThermalStageBounds { cost_per_mwh: 50.0 };
309/// let thermal_block_default = ThermalBlockBounds { min_generation_mw: 0.0, max_generation_mw: 100.0 };
310/// let line_default = LineBlockBounds { direct_mw: 500.0, reverse_mw: 500.0 };
311/// let pumping_default = PumpingBlockBounds { min_flow_m3s: 0.0, max_flow_m3s: 20.0 };
312/// let contract_default = ContractBlockBounds { min_mw: 0.0, max_mw: 50.0, price_per_mwh: 80.0 };
313///
314/// let table = ResolvedBounds::new(
315///     &BoundsCountsSpec { n_hydros: 2, n_thermals: 1, n_lines: 1, n_pumping: 1, n_contracts: 1, n_stages: 3, k_max: 0 },
316///     &BoundsDefaults {
317///         hydro: hydro_default, hydro_block: hydro_block_default,
318///         thermal: thermal_default, thermal_block: thermal_block_default,
319///         line_block: line_default, pumping_block: pumping_default, contract_block: contract_default,
320///     },
321/// );
322///
323/// let b = table.hydro_bounds(0, 2);
324/// assert!((b.max_storage_hm3 - 100.0).abs() < f64::EPSILON);
325/// ```
326#[derive(Debug, Clone, PartialEq)]
327#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
328#[cfg_attr(feature = "serde", serde(try_from = "ResolvedBoundsWire"))]
329pub struct ResolvedBounds {
330    /// Stride for every entity table except `thermal`: `data[entity_idx * n_stages + stage_idx]`.
331    n_stages: usize,
332    /// Stride for the `thermal` Vec; equals `n_stages + k_max`. Required on the
333    /// wire and never defaulted: a missing or zero stride (with `thermal`
334    /// non-empty) is rejected by [`ResolvedBoundsWire`]'s `TryFrom`, because
335    /// defaulting to `0` would alias every thermal to thermal 0's stage block and
336    /// silently return wrong bounds.
337    thermal_stage_axis_len: usize,
338    hydro: Vec<HydroCell>,
339    /// Indexed `[thermal_idx * thermal_stage_axis_len + stage_idx]`. The stage axis
340    /// is extended by `k_max` cells per thermal: `[0, n_stages)` is the study
341    /// horizon, `[n_stages, n_stages + k_max)` the padding for delivery-stage
342    /// lookups by anticipated-decision columns.
343    thermal: Vec<ThermalCell>,
344    line: Vec<LineBlockBounds>,
345    pumping: Vec<PumpingBlockBounds>,
346    contract: Vec<ContractBlockBounds>,
347    block: ResolvedBlockBounds,
348    group: ResolvedHydroUnitGroupBounds,
349}
350
351/// Deserialization shadow for [`ResolvedBounds`].
352///
353/// Has no `serde(default)` on `thermal_stage_axis_len`, so a missing field is
354/// rejected rather than aliasing every thermal to thermal 0; the `TryFrom` below
355/// also rejects a present-but-zero stride with a non-empty thermal table.
356#[cfg(feature = "serde")]
357#[derive(serde::Deserialize)]
358struct ResolvedBoundsWire {
359    n_stages: usize,
360    thermal_stage_axis_len: usize,
361    hydro: Vec<HydroCell>,
362    thermal: Vec<ThermalCell>,
363    line: Vec<LineBlockBounds>,
364    pumping: Vec<PumpingBlockBounds>,
365    contract: Vec<ContractBlockBounds>,
366    #[serde(default)]
367    block: ResolvedBlockBounds,
368    #[serde(default)]
369    group: ResolvedHydroUnitGroupBounds,
370}
371
372#[cfg(feature = "serde")]
373impl TryFrom<ResolvedBoundsWire> for ResolvedBounds {
374    type Error = String;
375
376    fn try_from(wire: ResolvedBoundsWire) -> Result<Self, Self::Error> {
377        if !wire.thermal.is_empty() && wire.thermal_stage_axis_len == 0 {
378            return Err(
379                "thermal_stage_axis_len must be > 0 when the thermal table is non-empty; \
380                 a zero stride aliases every thermal to thermal 0"
381                    .to_string(),
382            );
383        }
384        Ok(Self {
385            n_stages: wire.n_stages,
386            thermal_stage_axis_len: wire.thermal_stage_axis_len,
387            hydro: wire.hydro,
388            thermal: wire.thermal,
389            line: wire.line,
390            pumping: wire.pumping,
391            contract: wire.contract,
392            block: wire.block,
393            group: wire.group,
394        })
395    }
396}
397
398/// Entity counts for constructing a [`ResolvedBounds`] table.
399#[derive(Debug, Clone)]
400pub struct BoundsCountsSpec {
401    /// Number of hydro plants.
402    pub n_hydros: usize,
403    /// Number of thermal units.
404    pub n_thermals: usize,
405    /// Number of transmission lines.
406    pub n_lines: usize,
407    /// Number of pumping stations.
408    pub n_pumping: usize,
409    /// Number of energy contracts.
410    pub n_contracts: usize,
411    /// Number of time stages.
412    pub n_stages: usize,
413    /// Maximum lead-stages `K_max` across anticipated thermals; the thermal
414    /// Vec stage axis is sized `n_stages + k_max`. Zero means no padding.
415    pub k_max: usize,
416}
417
418/// Default per-stage bound values for each entity type.
419#[derive(Debug, Clone)]
420pub struct BoundsDefaults {
421    /// Default stage-level hydro bounds for all (hydro, stage) cells.
422    pub hydro: HydroStageBounds,
423    /// Default block-eligible hydro bounds for all (hydro, stage) cells.
424    pub hydro_block: HydroBlockBounds,
425    /// Default thermal cost for all (thermal, stage) cells.
426    pub thermal: ThermalStageBounds,
427    /// Default thermal generation capacity for all (thermal, stage) cells.
428    pub thermal_block: ThermalBlockBounds,
429    /// Default line bounds for all (line, stage) cells.
430    pub line_block: LineBlockBounds,
431    /// Default pumping bounds for all (pumping, stage) cells.
432    pub pumping_block: PumpingBlockBounds,
433    /// Default contract bounds for all (contract, stage) cells.
434    pub contract_block: ContractBlockBounds,
435}
436
437impl ResolvedBounds {
438    /// Return an empty bounds table with zero entities and zero stages.
439    ///
440    /// The default value in [`System`](crate::System) before bound resolution.
441    ///
442    /// # Examples
443    ///
444    /// ```
445    /// use cobre_core::ResolvedBounds;
446    ///
447    /// let empty = ResolvedBounds::empty();
448    /// assert_eq!(empty.n_stages(), 0);
449    /// ```
450    #[must_use]
451    pub fn empty() -> Self {
452        Self {
453            n_stages: 0,
454            thermal_stage_axis_len: 0,
455            hydro: Vec::new(),
456            thermal: Vec::new(),
457            line: Vec::new(),
458            pumping: Vec::new(),
459            contract: Vec::new(),
460            block: ResolvedBlockBounds::empty(),
461            group: ResolvedHydroUnitGroupBounds::empty(),
462        }
463    }
464
465    /// Allocate a new resolved-bounds table filled with the given defaults.
466    ///
467    /// `counts.n_stages` must be `> 0`. Entity counts may be `0`.
468    #[must_use]
469    pub fn new(counts: &BoundsCountsSpec, defaults: &BoundsDefaults) -> Self {
470        debug_assert!(
471            counts.n_stages > 0,
472            "ResolvedBounds::new: n_stages must be > 0 (got 0)"
473        );
474        let thermal_axis = counts.n_stages + counts.k_max;
475        let thermal_cell = ThermalCell {
476            stage: defaults.thermal,
477            block: defaults.thermal_block,
478        };
479        let hydro_cell = HydroCell {
480            stage: defaults.hydro,
481            block: defaults.hydro_block,
482        };
483        Self {
484            n_stages: counts.n_stages,
485            thermal_stage_axis_len: thermal_axis,
486            hydro: vec![hydro_cell; counts.n_hydros * counts.n_stages],
487            thermal: vec![thermal_cell; counts.n_thermals * thermal_axis],
488            line: vec![defaults.line_block; counts.n_lines * counts.n_stages],
489            pumping: vec![defaults.pumping_block; counts.n_pumping * counts.n_stages],
490            contract: vec![defaults.contract_block; counts.n_contracts * counts.n_stages],
491            block: ResolvedBlockBounds::empty(),
492            group: ResolvedHydroUnitGroupBounds::empty(),
493        }
494    }
495
496    /// Return the resolved stage-level bounds for a hydro plant at a specific stage.
497    ///
498    /// Returns a reference rather than a copy to avoid copying the struct on hot paths.
499    ///
500    /// [`HydroStageBounds`] carries the four stage-boundary-stock columns only;
501    /// the block-eligible columns live on [`HydroBlockBounds`], read through
502    /// [`hydro_bounds_at_block`](Self::hydro_bounds_at_block) or
503    /// [`hydro_block_base`](Self::hydro_block_base):
504    ///
505    /// ```compile_fail
506    /// use cobre_core::ResolvedBounds;
507    ///
508    /// let bounds = ResolvedBounds::empty();
509    /// let _ = bounds.hydro_bounds(0, 0).max_turbined_m3s;
510    /// ```
511    ///
512    /// The sibling below differs only in the accessor — it reads the same
513    /// turbined capacity through [`hydro_bounds_at_block`](Self::hydro_bounds_at_block)
514    /// and compiles:
515    ///
516    /// ```
517    /// use cobre_core::resolved::{
518    ///     BoundsCountsSpec, BoundsDefaults, ContractBlockBounds, HydroBlockBounds, HydroStageBounds,
519    ///     LineBlockBounds, PumpingBlockBounds, ResolvedBounds, ThermalBlockBounds,
520    ///     ThermalStageBounds,
521    /// };
522    ///
523    /// let bounds = ResolvedBounds::new(
524    ///     &BoundsCountsSpec {
525    ///         n_hydros: 1, n_thermals: 0, n_lines: 0, n_pumping: 0, n_contracts: 0,
526    ///         n_stages: 1, k_max: 0,
527    ///     },
528    ///     &BoundsDefaults {
529    ///         hydro: HydroStageBounds {
530    ///             min_storage_hm3: 0.0, max_storage_hm3: 0.0,
531    ///             filling_min_rate_m3s: 0.0, water_withdrawal_m3s: 0.0,
532    ///         },
533    ///         hydro_block: HydroBlockBounds {
534    ///             min_turbined_m3s: 0.0, max_turbined_m3s: 500.0,
535    ///             min_outflow_m3s: 0.0, max_outflow_m3s: None,
536    ///             min_generation_mw: 0.0, max_generation_mw: 0.0,
537    ///             min_diversion_m3s: None, max_diversion_m3s: None,
538    ///             min_spillage_m3s: None, max_spillage_m3s: None,
539    ///         },
540    ///         thermal: ThermalStageBounds { cost_per_mwh: 0.0 },
541    ///         thermal_block: ThermalBlockBounds { min_generation_mw: 0.0, max_generation_mw: 0.0 },
542    ///         line_block: LineBlockBounds { direct_mw: 0.0, reverse_mw: 0.0 },
543    ///         pumping_block: PumpingBlockBounds { min_flow_m3s: 0.0, max_flow_m3s: 0.0 },
544    ///         contract_block: ContractBlockBounds { min_mw: 0.0, max_mw: 0.0, price_per_mwh: 0.0 },
545    ///     },
546    /// );
547    ///
548    /// let block = bounds.hydro_bounds_at_block(0, 0, 0);
549    /// assert!((block.max_turbined_m3s - 500.0).abs() < f64::EPSILON);
550    /// ```
551    #[inline]
552    #[must_use]
553    pub fn hydro_bounds(&self, hydro_index: usize, stage_index: usize) -> &HydroStageBounds {
554        &self.hydro[hydro_index * self.n_stages + stage_index].stage
555    }
556
557    /// Return the flat `self.thermal` index for `(thermal_index, stage_index)`,
558    /// asserting the stride invariant every thermal accessor relies on.
559    #[inline]
560    fn thermal_cell_index(&self, thermal_index: usize, stage_index: usize) -> usize {
561        debug_assert!(
562            self.thermal.is_empty() || self.thermal_stage_axis_len > 0,
563            "thermal_stage_axis_len must be > 0 when the thermal table is non-empty"
564        );
565        thermal_index * self.thermal_stage_axis_len + stage_index
566    }
567
568    /// Return the resolved cost bounds for a thermal unit at a specific stage.
569    ///
570    /// `stage_index` is valid in `[0, thermal_stage_axis_len())`; indices
571    /// `>= n_stages()` access the padded delivery-stage region.
572    ///
573    /// [`ThermalStageBounds`] carries `cost_per_mwh` only; the block-eligible
574    /// generation capacity lives on [`ThermalBlockBounds`], read through
575    /// [`thermal_bounds_at_block`](Self::thermal_bounds_at_block) or
576    /// [`thermal_block_base`](Self::thermal_block_base):
577    ///
578    /// ```compile_fail
579    /// use cobre_core::ResolvedBounds;
580    ///
581    /// let bounds = ResolvedBounds::empty();
582    /// let _ = bounds.thermal_bounds(0, 0).max_generation_mw;
583    /// ```
584    ///
585    /// The sibling below differs only in the accessor — it reads the same
586    /// capacity pair through [`thermal_bounds_at_block`](Self::thermal_bounds_at_block)
587    /// and compiles:
588    ///
589    /// ```
590    /// use cobre_core::resolved::{
591    ///     BoundsCountsSpec, BoundsDefaults, ContractBlockBounds, HydroBlockBounds, HydroStageBounds,
592    ///     LineBlockBounds, PumpingBlockBounds, ResolvedBounds, ThermalBlockBounds,
593    ///     ThermalStageBounds,
594    /// };
595    ///
596    /// let hydro_default = HydroStageBounds {
597    ///     min_storage_hm3: 0.0, max_storage_hm3: 0.0,
598    ///     filling_min_rate_m3s: 0.0, water_withdrawal_m3s: 0.0,
599    /// };
600    /// let hydro_block_default = HydroBlockBounds {
601    ///     min_turbined_m3s: 0.0, max_turbined_m3s: 0.0,
602    ///     min_outflow_m3s: 0.0, max_outflow_m3s: None,
603    ///     min_generation_mw: 0.0, max_generation_mw: 0.0,
604    ///     min_diversion_m3s: None, max_diversion_m3s: None,
605    ///     min_spillage_m3s: None, max_spillage_m3s: None,
606    /// };
607    ///
608    /// let bounds = ResolvedBounds::new(
609    ///     &BoundsCountsSpec {
610    ///         n_hydros: 0, n_thermals: 1, n_lines: 0, n_pumping: 0, n_contracts: 0,
611    ///         n_stages: 1, k_max: 0,
612    ///     },
613    ///     &BoundsDefaults {
614    ///         hydro: hydro_default,
615    ///         hydro_block: hydro_block_default,
616    ///         thermal: ThermalStageBounds { cost_per_mwh: 50.0 },
617    ///         thermal_block: ThermalBlockBounds { min_generation_mw: 0.0, max_generation_mw: 400.0 },
618    ///         line_block: LineBlockBounds { direct_mw: 0.0, reverse_mw: 0.0 },
619    ///         pumping_block: PumpingBlockBounds { min_flow_m3s: 0.0, max_flow_m3s: 0.0 },
620    ///         contract_block: ContractBlockBounds { min_mw: 0.0, max_mw: 0.0, price_per_mwh: 0.0 },
621    ///     },
622    /// );
623    ///
624    /// let block = bounds.thermal_bounds_at_block(0, 0, 0);
625    /// assert!((block.max_generation_mw - 400.0).abs() < f64::EPSILON);
626    /// ```
627    #[inline]
628    #[must_use]
629    pub fn thermal_bounds(&self, thermal_index: usize, stage_index: usize) -> ThermalStageBounds {
630        self.thermal[self.thermal_cell_index(thermal_index, stage_index)].stage
631    }
632
633    /// Return a mutable reference to the hydro stage-level bounds cell for
634    /// in-place update.
635    #[inline]
636    pub fn hydro_bounds_mut(
637        &mut self,
638        hydro_index: usize,
639        stage_index: usize,
640    ) -> &mut HydroStageBounds {
641        &mut self.hydro[hydro_index * self.n_stages + stage_index].stage
642    }
643
644    /// Return a mutable reference to the hydro block-base cell for in-place
645    /// update; see [`hydro_block_base`](Self::hydro_block_base) for the
646    /// overlay-ignoring read this writes through to.
647    #[inline]
648    pub fn hydro_block_base_mut(
649        &mut self,
650        hydro_index: usize,
651        stage_index: usize,
652    ) -> &mut HydroBlockBounds {
653        &mut self.hydro[hydro_index * self.n_stages + stage_index].block
654    }
655
656    /// Return a mutable reference to the thermal cost cell for in-place update.
657    ///
658    /// `stage_index` is valid in `[0, thermal_stage_axis_len())`; indices
659    /// `>= n_stages()` write into the padded delivery-stage region.
660    #[inline]
661    pub fn thermal_bounds_mut(
662        &mut self,
663        thermal_index: usize,
664        stage_index: usize,
665    ) -> &mut ThermalStageBounds {
666        let idx = self.thermal_cell_index(thermal_index, stage_index);
667        &mut self.thermal[idx].stage
668    }
669
670    /// Return a mutable reference to the thermal block-base cell for in-place
671    /// update; see [`thermal_block_base`](Self::thermal_block_base) for the
672    /// overlay-ignoring read this writes through to.
673    ///
674    /// `stage_index` is valid in `[0, thermal_stage_axis_len())`; indices
675    /// `>= n_stages()` write into the padded delivery-stage region.
676    #[inline]
677    pub fn thermal_block_base_mut(
678        &mut self,
679        thermal_index: usize,
680        stage_index: usize,
681    ) -> &mut ThermalBlockBounds {
682        let idx = self.thermal_cell_index(thermal_index, stage_index);
683        &mut self.thermal[idx].block
684    }
685
686    /// Return a mutable reference to the line bounds cell for in-place update.
687    #[inline]
688    pub fn line_bounds_mut(
689        &mut self,
690        line_index: usize,
691        stage_index: usize,
692    ) -> &mut LineBlockBounds {
693        &mut self.line[line_index * self.n_stages + stage_index]
694    }
695
696    /// Return a mutable reference to the pumping bounds cell for in-place update.
697    #[inline]
698    pub fn pumping_bounds_mut(
699        &mut self,
700        pumping_index: usize,
701        stage_index: usize,
702    ) -> &mut PumpingBlockBounds {
703        &mut self.pumping[pumping_index * self.n_stages + stage_index]
704    }
705
706    /// Return a mutable reference to the contract bounds cell for in-place update.
707    #[inline]
708    pub fn contract_bounds_mut(
709        &mut self,
710        contract_index: usize,
711        stage_index: usize,
712    ) -> &mut ContractBlockBounds {
713        &mut self.contract[contract_index * self.n_stages + stage_index]
714    }
715
716    /// Install the per-block override overlay (bound-precedence layer 1).
717    ///
718    /// The overlay stays [`ResolvedBlockBounds::empty`] — every `*_bounds_at_block`
719    /// call falls through to the stage cell — until this is called.
720    pub fn set_block_overlay(&mut self, block: ResolvedBlockBounds) {
721        self.block = block;
722    }
723
724    /// Return the installed per-block override overlay.
725    #[inline]
726    #[must_use]
727    pub fn block_overlay(&self) -> &ResolvedBlockBounds {
728        &self.block
729    }
730
731    /// Return a mutable handle to the per-block override overlay.
732    #[inline]
733    pub fn block_overlay_mut(&mut self) -> &mut ResolvedBlockBounds {
734        &mut self.block
735    }
736
737    /// Install the hydro unit group override overlay (bound-precedence layers
738    /// 1 and 2 on the group axis).
739    ///
740    /// The overlay stays [`ResolvedHydroUnitGroupBounds::empty`] until this is
741    /// called; no reader here consults it — the group axis has no consumer on
742    /// [`ResolvedBounds`] itself.
743    pub fn set_group_overlay(&mut self, group: ResolvedHydroUnitGroupBounds) {
744        self.group = group;
745    }
746
747    /// Return the installed hydro unit group override overlay.
748    #[inline]
749    #[must_use]
750    pub fn group_overlay(&self) -> &ResolvedHydroUnitGroupBounds {
751        &self.group
752    }
753
754    /// Return a mutable handle to the hydro unit group override overlay.
755    #[inline]
756    pub fn group_overlay_mut(&mut self) -> &mut ResolvedHydroUnitGroupBounds {
757        &mut self.group
758    }
759
760    /// Return the resolved hydro block-eligible bounds for
761    /// `(hydro_index, stage_index, block_index)`, applying the block overlay
762    /// over the block-base cell from [`hydro_block_base`](Self::hydro_block_base):
763    /// each block-eligible column takes the overlay's value when `Some`,
764    /// otherwise falls through to the block-base cell. With an empty overlay
765    /// this returns a value bit-identical to `hydro_block_base` for every
766    /// `block_index` — never special-case the empty-overlay path.
767    #[inline]
768    #[must_use]
769    pub fn hydro_bounds_at_block(
770        &self,
771        hydro_index: usize,
772        stage_index: usize,
773        block_index: usize,
774    ) -> HydroBlockBounds {
775        let cell = self.hydro[hydro_index * self.n_stages + stage_index].block;
776        let over = self
777            .block
778            .hydro_override(hydro_index, stage_index, block_index);
779        HydroBlockBounds {
780            min_turbined_m3s: over.min_turbined_m3s.unwrap_or(cell.min_turbined_m3s),
781            max_turbined_m3s: over.max_turbined_m3s.unwrap_or(cell.max_turbined_m3s),
782            min_outflow_m3s: over.min_outflow_m3s.unwrap_or(cell.min_outflow_m3s),
783            max_outflow_m3s: over.max_outflow_m3s.or(cell.max_outflow_m3s),
784            min_generation_mw: over.min_generation_mw.unwrap_or(cell.min_generation_mw),
785            max_generation_mw: over.max_generation_mw.unwrap_or(cell.max_generation_mw),
786            min_diversion_m3s: over.min_diversion_m3s.or(cell.min_diversion_m3s),
787            max_diversion_m3s: over.max_diversion_m3s.or(cell.max_diversion_m3s),
788            min_spillage_m3s: over.min_spillage_m3s.or(cell.min_spillage_m3s),
789            max_spillage_m3s: over.max_spillage_m3s.or(cell.max_spillage_m3s),
790        }
791    }
792
793    /// Return the resolved thermal generation bounds for a specific block; see
794    /// [`hydro_bounds_at_block`](Self::hydro_bounds_at_block) for the overlay
795    /// contract. Thermal cost has no overlay column and no per-block reader —
796    /// read it stage-level via [`thermal_bounds`](Self::thermal_bounds).
797    #[inline]
798    #[must_use]
799    pub fn thermal_bounds_at_block(
800        &self,
801        thermal_index: usize,
802        stage_index: usize,
803        block_index: usize,
804    ) -> ThermalBlockBounds {
805        let cell = self.thermal[self.thermal_cell_index(thermal_index, stage_index)].block;
806        let over = self
807            .block
808            .thermal_override(thermal_index, stage_index, block_index);
809        ThermalBlockBounds {
810            min_generation_mw: over.min_generation_mw.unwrap_or(cell.min_generation_mw),
811            max_generation_mw: over.max_generation_mw.unwrap_or(cell.max_generation_mw),
812        }
813    }
814
815    /// Return the resolved line bounds for a specific block; see
816    /// [`hydro_bounds_at_block`](Self::hydro_bounds_at_block) for the overlay contract.
817    ///
818    /// Lines have no stage-level bound column — there is no `line_bounds`
819    /// accessor to call. Read capacity through this accessor or
820    /// [`line_block_base`](Self::line_block_base):
821    ///
822    /// ```compile_fail
823    /// use cobre_core::ResolvedBounds;
824    ///
825    /// let bounds = ResolvedBounds::empty();
826    /// let _ = bounds.line_bounds(0, 0).direct_mw;
827    /// ```
828    ///
829    /// The sibling below differs only in the accessor — it reads the same line
830    /// capacity through this method and compiles:
831    ///
832    /// ```
833    /// use cobre_core::resolved::{
834    ///     BoundsCountsSpec, BoundsDefaults, ContractBlockBounds, HydroBlockBounds, HydroStageBounds,
835    ///     LineBlockBounds, PumpingBlockBounds, ResolvedBounds, ThermalBlockBounds,
836    ///     ThermalStageBounds,
837    /// };
838    ///
839    /// let hydro_default = HydroStageBounds {
840    ///     min_storage_hm3: 0.0, max_storage_hm3: 0.0,
841    ///     filling_min_rate_m3s: 0.0, water_withdrawal_m3s: 0.0,
842    /// };
843    /// let hydro_block_default = HydroBlockBounds {
844    ///     min_turbined_m3s: 0.0, max_turbined_m3s: 0.0,
845    ///     min_outflow_m3s: 0.0, max_outflow_m3s: None,
846    ///     min_generation_mw: 0.0, max_generation_mw: 0.0,
847    ///     min_diversion_m3s: None, max_diversion_m3s: None,
848    ///     min_spillage_m3s: None, max_spillage_m3s: None,
849    /// };
850    ///
851    /// let bounds = ResolvedBounds::new(
852    ///     &BoundsCountsSpec {
853    ///         n_hydros: 0, n_thermals: 0, n_lines: 1, n_pumping: 0, n_contracts: 0,
854    ///         n_stages: 1, k_max: 0,
855    ///     },
856    ///     &BoundsDefaults {
857    ///         hydro: hydro_default,
858    ///         hydro_block: hydro_block_default,
859    ///         thermal: ThermalStageBounds { cost_per_mwh: 0.0 },
860    ///         thermal_block: ThermalBlockBounds { min_generation_mw: 0.0, max_generation_mw: 0.0 },
861    ///         line_block: LineBlockBounds { direct_mw: 1000.0, reverse_mw: 800.0 },
862    ///         pumping_block: PumpingBlockBounds { min_flow_m3s: 0.0, max_flow_m3s: 0.0 },
863    ///         contract_block: ContractBlockBounds { min_mw: 0.0, max_mw: 0.0, price_per_mwh: 0.0 },
864    ///     },
865    /// );
866    ///
867    /// let block = bounds.line_bounds_at_block(0, 0, 0);
868    /// assert!((block.direct_mw - 1000.0).abs() < f64::EPSILON);
869    /// ```
870    #[inline]
871    #[must_use]
872    pub fn line_bounds_at_block(
873        &self,
874        line_index: usize,
875        stage_index: usize,
876        block_index: usize,
877    ) -> LineBlockBounds {
878        let cell = self.line[line_index * self.n_stages + stage_index];
879        let over = self
880            .block
881            .line_override(line_index, stage_index, block_index);
882        LineBlockBounds {
883            direct_mw: over.direct_mw.unwrap_or(cell.direct_mw),
884            reverse_mw: over.reverse_mw.unwrap_or(cell.reverse_mw),
885        }
886    }
887
888    /// Return the resolved pumping bounds for a specific block; see
889    /// [`hydro_bounds_at_block`](Self::hydro_bounds_at_block) for the overlay contract.
890    ///
891    /// Pumping stations have no stage-level bound column — there is no
892    /// `pumping_bounds` accessor to call. Read flow limits through this
893    /// accessor or [`pumping_block_base`](Self::pumping_block_base):
894    ///
895    /// ```compile_fail
896    /// use cobre_core::ResolvedBounds;
897    ///
898    /// let bounds = ResolvedBounds::empty();
899    /// let _ = bounds.pumping_bounds(0, 0).max_flow_m3s;
900    /// ```
901    ///
902    /// The sibling below differs only in the accessor — it reads the same
903    /// pumping flow limit through this method and compiles:
904    ///
905    /// ```
906    /// use cobre_core::resolved::{
907    ///     BoundsCountsSpec, BoundsDefaults, ContractBlockBounds, HydroBlockBounds, HydroStageBounds,
908    ///     LineBlockBounds, PumpingBlockBounds, ResolvedBounds, ThermalBlockBounds,
909    ///     ThermalStageBounds,
910    /// };
911    ///
912    /// let hydro_default = HydroStageBounds {
913    ///     min_storage_hm3: 0.0, max_storage_hm3: 0.0,
914    ///     filling_min_rate_m3s: 0.0, water_withdrawal_m3s: 0.0,
915    /// };
916    /// let hydro_block_default = HydroBlockBounds {
917    ///     min_turbined_m3s: 0.0, max_turbined_m3s: 0.0,
918    ///     min_outflow_m3s: 0.0, max_outflow_m3s: None,
919    ///     min_generation_mw: 0.0, max_generation_mw: 0.0,
920    ///     min_diversion_m3s: None, max_diversion_m3s: None,
921    ///     min_spillage_m3s: None, max_spillage_m3s: None,
922    /// };
923    ///
924    /// let bounds = ResolvedBounds::new(
925    ///     &BoundsCountsSpec {
926    ///         n_hydros: 0, n_thermals: 0, n_lines: 0, n_pumping: 1, n_contracts: 0,
927    ///         n_stages: 1, k_max: 0,
928    ///     },
929    ///     &BoundsDefaults {
930    ///         hydro: hydro_default,
931    ///         hydro_block: hydro_block_default,
932    ///         thermal: ThermalStageBounds { cost_per_mwh: 0.0 },
933    ///         thermal_block: ThermalBlockBounds { min_generation_mw: 0.0, max_generation_mw: 0.0 },
934    ///         line_block: LineBlockBounds { direct_mw: 0.0, reverse_mw: 0.0 },
935    ///         pumping_block: PumpingBlockBounds { min_flow_m3s: 0.0, max_flow_m3s: 50.0 },
936    ///         contract_block: ContractBlockBounds { min_mw: 0.0, max_mw: 0.0, price_per_mwh: 0.0 },
937    ///     },
938    /// );
939    ///
940    /// let block = bounds.pumping_bounds_at_block(0, 0, 0);
941    /// assert!((block.max_flow_m3s - 50.0).abs() < f64::EPSILON);
942    /// ```
943    #[inline]
944    #[must_use]
945    pub fn pumping_bounds_at_block(
946        &self,
947        pumping_index: usize,
948        stage_index: usize,
949        block_index: usize,
950    ) -> PumpingBlockBounds {
951        let cell = self.pumping[pumping_index * self.n_stages + stage_index];
952        let over = self
953            .block
954            .pumping_override(pumping_index, stage_index, block_index);
955        PumpingBlockBounds {
956            min_flow_m3s: over.min_flow_m3s.unwrap_or(cell.min_flow_m3s),
957            max_flow_m3s: over.max_flow_m3s.unwrap_or(cell.max_flow_m3s),
958        }
959    }
960
961    /// Return the resolved contract bounds for a specific block; see
962    /// [`hydro_bounds_at_block`](Self::hydro_bounds_at_block) for the overlay
963    /// contract. `price_per_mwh` IS block-eligible, deliberately asymmetric
964    /// with [`thermal_bounds_at_block`](Self::thermal_bounds_at_block)'s
965    /// `cost_per_mwh`.
966    ///
967    /// Contracts have no stage-level bound column — there is no
968    /// `contract_bounds` accessor to call. Read all three columns through this
969    /// accessor or [`contract_block_base`](Self::contract_block_base):
970    ///
971    /// ```compile_fail
972    /// use cobre_core::ResolvedBounds;
973    ///
974    /// let bounds = ResolvedBounds::empty();
975    /// let _ = bounds.contract_bounds(0, 0).price_per_mwh;
976    /// ```
977    ///
978    /// The sibling below differs only in the accessor — it reads the same
979    /// contract price through this method and compiles:
980    ///
981    /// ```
982    /// use cobre_core::resolved::{
983    ///     BoundsCountsSpec, BoundsDefaults, ContractBlockBounds, HydroBlockBounds, HydroStageBounds,
984    ///     LineBlockBounds, PumpingBlockBounds, ResolvedBounds, ThermalBlockBounds,
985    ///     ThermalStageBounds,
986    /// };
987    ///
988    /// let hydro_default = HydroStageBounds {
989    ///     min_storage_hm3: 0.0, max_storage_hm3: 0.0,
990    ///     filling_min_rate_m3s: 0.0, water_withdrawal_m3s: 0.0,
991    /// };
992    /// let hydro_block_default = HydroBlockBounds {
993    ///     min_turbined_m3s: 0.0, max_turbined_m3s: 0.0,
994    ///     min_outflow_m3s: 0.0, max_outflow_m3s: None,
995    ///     min_generation_mw: 0.0, max_generation_mw: 0.0,
996    ///     min_diversion_m3s: None, max_diversion_m3s: None,
997    ///     min_spillage_m3s: None, max_spillage_m3s: None,
998    /// };
999    ///
1000    /// let bounds = ResolvedBounds::new(
1001    ///     &BoundsCountsSpec {
1002    ///         n_hydros: 0, n_thermals: 0, n_lines: 0, n_pumping: 0, n_contracts: 1,
1003    ///         n_stages: 1, k_max: 0,
1004    ///     },
1005    ///     &BoundsDefaults {
1006    ///         hydro: hydro_default,
1007    ///         hydro_block: hydro_block_default,
1008    ///         thermal: ThermalStageBounds { cost_per_mwh: 0.0 },
1009    ///         thermal_block: ThermalBlockBounds { min_generation_mw: 0.0, max_generation_mw: 0.0 },
1010    ///         line_block: LineBlockBounds { direct_mw: 0.0, reverse_mw: 0.0 },
1011    ///         pumping_block: PumpingBlockBounds { min_flow_m3s: 0.0, max_flow_m3s: 0.0 },
1012    ///         contract_block: ContractBlockBounds { min_mw: 0.0, max_mw: 0.0, price_per_mwh: 80.0 },
1013    ///     },
1014    /// );
1015    ///
1016    /// let block = bounds.contract_bounds_at_block(0, 0, 0);
1017    /// assert!((block.price_per_mwh - 80.0).abs() < f64::EPSILON);
1018    /// ```
1019    #[inline]
1020    #[must_use]
1021    pub fn contract_bounds_at_block(
1022        &self,
1023        contract_index: usize,
1024        stage_index: usize,
1025        block_index: usize,
1026    ) -> ContractBlockBounds {
1027        let cell = self.contract[contract_index * self.n_stages + stage_index];
1028        let over = self
1029            .block
1030            .contract_override(contract_index, stage_index, block_index);
1031        ContractBlockBounds {
1032            min_mw: over.min_mw.unwrap_or(cell.min_mw),
1033            max_mw: over.max_mw.unwrap_or(cell.max_mw),
1034            price_per_mwh: over.price_per_mwh.unwrap_or(cell.price_per_mwh),
1035        }
1036    }
1037
1038    /// Return the block-eligible hydro columns at `(hydro_index, stage_index)`,
1039    /// ignoring the per-block overlay. This is **not** the value that applies at
1040    /// a block — see [`hydro_bounds_at_block`](Self::hydro_bounds_at_block) for
1041    /// the block-resolved reader. Only the dictionary path (`write_bounds_parquet`)
1042    /// calls this one — see the module docs for the two-caller `*_block_base`
1043    /// contract.
1044    #[inline]
1045    #[must_use]
1046    pub fn hydro_block_base(&self, hydro_index: usize, stage_index: usize) -> HydroBlockBounds {
1047        self.hydro[hydro_index * self.n_stages + stage_index].block
1048    }
1049
1050    /// Return the block-eligible thermal columns at `(thermal_index,
1051    /// stage_index)`, ignoring the per-block overlay. This is **not** the value
1052    /// that applies at a block — see
1053    /// [`thermal_bounds_at_block`](Self::thermal_bounds_at_block) for the
1054    /// block-resolved reader. `*_block_base` accessors exist for exactly two
1055    /// sanctioned caller categories: the dictionary report path
1056    /// (`write_bounds_parquet`'s null-`block_id` base row) and the
1057    /// anticipated-commitment decision column (`fill_anticipated_columns`,
1058    /// reading the delivery stage's bounds); this accessor serves both. Any
1059    /// other caller is a design question, not an implementation detail.
1060    /// `stage_index` may land in the padded delivery-stage region.
1061    #[inline]
1062    #[must_use]
1063    pub fn thermal_block_base(
1064        &self,
1065        thermal_index: usize,
1066        stage_index: usize,
1067    ) -> ThermalBlockBounds {
1068        self.thermal[self.thermal_cell_index(thermal_index, stage_index)].block
1069    }
1070
1071    /// Return the block-eligible line columns at `(line_index, stage_index)`,
1072    /// ignoring the per-block overlay. This is **not** the value that applies at
1073    /// a block — see [`line_bounds_at_block`](Self::line_bounds_at_block) for the
1074    /// block-resolved reader. Only the dictionary path (`write_bounds_parquet`)
1075    /// calls this one — see the module docs for the two-caller `*_block_base`
1076    /// contract.
1077    #[inline]
1078    #[must_use]
1079    pub fn line_block_base(&self, line_index: usize, stage_index: usize) -> LineBlockBounds {
1080        self.line[line_index * self.n_stages + stage_index]
1081    }
1082
1083    /// Return the block-eligible pumping columns at `(pumping_index,
1084    /// stage_index)`, ignoring the per-block overlay. This is **not** the value
1085    /// that applies at a block — see
1086    /// [`pumping_bounds_at_block`](Self::pumping_bounds_at_block) for the
1087    /// block-resolved reader. Only the dictionary path (`write_bounds_parquet`)
1088    /// calls this one — see the module docs for the two-caller `*_block_base`
1089    /// contract.
1090    #[inline]
1091    #[must_use]
1092    pub fn pumping_block_base(
1093        &self,
1094        pumping_index: usize,
1095        stage_index: usize,
1096    ) -> PumpingBlockBounds {
1097        self.pumping[pumping_index * self.n_stages + stage_index]
1098    }
1099
1100    /// Return the block-eligible contract columns at `(contract_index,
1101    /// stage_index)`, ignoring the per-block overlay. This is **not** the value
1102    /// that applies at a block — see
1103    /// [`contract_bounds_at_block`](Self::contract_bounds_at_block) for the
1104    /// block-resolved reader. Only the dictionary path (`write_bounds_parquet`)
1105    /// calls this one — see the module docs for the two-caller `*_block_base`
1106    /// contract.
1107    #[inline]
1108    #[must_use]
1109    pub fn contract_block_base(
1110        &self,
1111        contract_index: usize,
1112        stage_index: usize,
1113    ) -> ContractBlockBounds {
1114        self.contract[contract_index * self.n_stages + stage_index]
1115    }
1116
1117    /// Return the number of stages in this table.
1118    #[inline]
1119    #[must_use]
1120    pub fn n_stages(&self) -> usize {
1121        self.n_stages
1122    }
1123
1124    /// Return the number of pumping stations.
1125    ///
1126    /// Derived from the `pumping` Vec length and `n_stages` rather than a stored
1127    /// count, since `n_pumping` is never serialized. The `n_stages == 0` guard
1128    /// avoids divide-by-zero on [`ResolvedBounds::empty`].
1129    #[inline]
1130    #[must_use]
1131    pub fn n_pumping(&self) -> usize {
1132        if self.n_stages == 0 {
1133            0
1134        } else {
1135            self.pumping.len() / self.n_stages
1136        }
1137    }
1138
1139    /// Return the number of energy contracts.
1140    ///
1141    /// Derived from the `contract` Vec length and `n_stages` rather than a stored
1142    /// count, since `n_contracts` is never serialized. The `n_stages == 0` guard
1143    /// avoids divide-by-zero on [`ResolvedBounds::empty`].
1144    #[inline]
1145    #[must_use]
1146    pub fn n_contracts(&self) -> usize {
1147        if self.n_stages == 0 {
1148            0
1149        } else {
1150            debug_assert_eq!(
1151                self.contract.len() % self.n_stages,
1152                0,
1153                "contract Vec length must be a multiple of n_stages"
1154            );
1155            self.contract.len() / self.n_stages
1156        }
1157    }
1158
1159    /// Return the stride used to index the thermal Vec; equals `n_stages() + k_max`.
1160    ///
1161    /// `k_max` is the maximum lead-stages across anticipated thermals. The thermal
1162    /// table reserves indices `[n_stages(), thermal_stage_axis_len())` for
1163    /// delivery-stage lookups by anticipated-decision columns.
1164    #[inline]
1165    #[must_use]
1166    pub fn thermal_stage_axis_len(&self) -> usize {
1167        self.thermal_stage_axis_len
1168    }
1169}
1170
1171// ─── Tests ────────────────────────────────────────────────────────────────────
1172
1173#[cfg(test)]
1174mod tests {
1175    use super::super::{BlockBoundsCountsSpec, HydroUnitGroupBoundsCountsSpec};
1176    use super::{
1177        BoundsCountsSpec, BoundsDefaults, ContractBlockBounds, HydroBlockBounds, HydroStageBounds,
1178        LineBlockBounds, PumpingBlockBounds, ResolvedBlockBounds, ResolvedBounds,
1179        ResolvedHydroUnitGroupBounds, ThermalBlockBounds, ThermalStageBounds,
1180    };
1181
1182    fn make_hydro_bounds() -> HydroStageBounds {
1183        HydroStageBounds {
1184            min_storage_hm3: 10.0,
1185            max_storage_hm3: 200.0,
1186            filling_min_rate_m3s: 0.0,
1187            water_withdrawal_m3s: 0.0,
1188        }
1189    }
1190
1191    fn make_hydro_block_bounds() -> HydroBlockBounds {
1192        HydroBlockBounds {
1193            max_turbined_m3s: 500.0,
1194            min_outflow_m3s: 5.0,
1195            max_generation_mw: 100.0,
1196            ..Default::default()
1197        }
1198    }
1199
1200    #[test]
1201    fn test_all_bound_structs_are_copy() {
1202        let hb = make_hydro_bounds();
1203        let hydro_block = make_hydro_block_bounds();
1204        let cost_bounds = ThermalStageBounds { cost_per_mwh: 50.0 };
1205        let gen_bounds = ThermalBlockBounds {
1206            min_generation_mw: 0.0,
1207            max_generation_mw: 100.0,
1208        };
1209        let lb = LineBlockBounds {
1210            direct_mw: 500.0,
1211            reverse_mw: 500.0,
1212        };
1213        let pb = PumpingBlockBounds {
1214            min_flow_m3s: 0.0,
1215            max_flow_m3s: 20.0,
1216        };
1217        let cb = ContractBlockBounds {
1218            min_mw: 0.0,
1219            max_mw: 50.0,
1220            price_per_mwh: 80.0,
1221        };
1222
1223        let hb2 = hb;
1224        let hydro_block2 = hydro_block;
1225        let cost_bounds_copy = cost_bounds;
1226        let gen_bounds_copy = gen_bounds;
1227        let lb2 = lb;
1228        let pb2 = pb;
1229        let cb2 = cb;
1230        assert_eq!(hb, hb2);
1231        assert_eq!(hydro_block, hydro_block2);
1232        assert_eq!(cost_bounds, cost_bounds_copy);
1233        assert_eq!(gen_bounds, gen_bounds_copy);
1234        assert_eq!(lb, lb2);
1235        assert_eq!(pb, pb2);
1236        assert_eq!(cb, cb2);
1237    }
1238
1239    #[test]
1240    fn test_resolved_bounds_construction() {
1241        let hb = make_hydro_bounds();
1242        let hbl = make_hydro_block_bounds();
1243        let tb = ThermalStageBounds { cost_per_mwh: 0.0 };
1244        let tbb = ThermalBlockBounds {
1245            min_generation_mw: 50.0,
1246            max_generation_mw: 400.0,
1247        };
1248        let lb = LineBlockBounds {
1249            direct_mw: 1000.0,
1250            reverse_mw: 800.0,
1251        };
1252        let pb = PumpingBlockBounds {
1253            min_flow_m3s: 0.0,
1254            max_flow_m3s: 20.0,
1255        };
1256        let cb = ContractBlockBounds {
1257            min_mw: 0.0,
1258            max_mw: 100.0,
1259            price_per_mwh: 80.0,
1260        };
1261
1262        let table = ResolvedBounds::new(
1263            &BoundsCountsSpec {
1264                n_hydros: 1,
1265                n_thermals: 2,
1266                n_lines: 1,
1267                n_pumping: 1,
1268                n_contracts: 1,
1269                n_stages: 3,
1270                k_max: 0,
1271            },
1272            &BoundsDefaults {
1273                hydro: hb,
1274                hydro_block: hbl,
1275                thermal: tb,
1276                thermal_block: tbb,
1277                line_block: lb,
1278                pumping_block: pb,
1279                contract_block: cb,
1280            },
1281        );
1282
1283        let b = table.hydro_bounds(0, 2);
1284        assert!((b.min_storage_hm3 - 10.0).abs() < f64::EPSILON);
1285        assert!((b.max_storage_hm3 - 200.0).abs() < f64::EPSILON);
1286        let block_base = table.hydro_block_base(0, 2);
1287        assert!(block_base.max_outflow_m3s.is_none());
1288        assert!(block_base.max_diversion_m3s.is_none());
1289
1290        let t0 = table.thermal_block_base(0, 0);
1291        let t1 = table.thermal_block_base(1, 2);
1292        assert!((t0.max_generation_mw - 400.0).abs() < f64::EPSILON);
1293        assert!((t1.min_generation_mw - 50.0).abs() < f64::EPSILON);
1294
1295        assert!((table.line_block_base(0, 1).direct_mw - 1000.0).abs() < f64::EPSILON);
1296        assert!((table.pumping_block_base(0, 0).max_flow_m3s - 20.0).abs() < f64::EPSILON);
1297        assert!((table.contract_block_base(0, 2).price_per_mwh - 80.0).abs() < f64::EPSILON);
1298    }
1299
1300    #[test]
1301    fn test_resolved_bounds_mutable_update() {
1302        let hb = make_hydro_bounds();
1303        let hbl = make_hydro_block_bounds();
1304        let tb = ThermalStageBounds { cost_per_mwh: 0.0 };
1305        let tbb = ThermalBlockBounds {
1306            min_generation_mw: 0.0,
1307            max_generation_mw: 200.0,
1308        };
1309        let lb = LineBlockBounds {
1310            direct_mw: 500.0,
1311            reverse_mw: 500.0,
1312        };
1313        let pb = PumpingBlockBounds {
1314            min_flow_m3s: 0.0,
1315            max_flow_m3s: 30.0,
1316        };
1317        let cb = ContractBlockBounds {
1318            min_mw: 0.0,
1319            max_mw: 50.0,
1320            price_per_mwh: 60.0,
1321        };
1322
1323        let mut table = ResolvedBounds::new(
1324            &BoundsCountsSpec {
1325                n_hydros: 2,
1326                n_thermals: 1,
1327                n_lines: 1,
1328                n_pumping: 1,
1329                n_contracts: 1,
1330                n_stages: 3,
1331                k_max: 0,
1332            },
1333            &BoundsDefaults {
1334                hydro: hb,
1335                hydro_block: hbl,
1336                thermal: tb,
1337                thermal_block: tbb,
1338                line_block: lb,
1339                pumping_block: pb,
1340                contract_block: cb,
1341            },
1342        );
1343
1344        let cell = table.hydro_bounds_mut(1, 0);
1345        cell.min_storage_hm3 = 25.0;
1346        table.hydro_block_base_mut(1, 0).max_outflow_m3s = Some(1000.0);
1347
1348        assert!((table.hydro_bounds(1, 0).min_storage_hm3 - 25.0).abs() < f64::EPSILON);
1349        assert_eq!(table.hydro_block_base(1, 0).max_outflow_m3s, Some(1000.0));
1350        assert!((table.hydro_bounds(0, 0).min_storage_hm3 - 10.0).abs() < f64::EPSILON);
1351        assert!(table.hydro_block_base(1, 1).max_outflow_m3s.is_none());
1352
1353        table.thermal_block_base_mut(0, 2).max_generation_mw = 150.0;
1354        assert!((table.thermal_block_base(0, 2).max_generation_mw - 150.0).abs() < f64::EPSILON);
1355        assert!((table.thermal_block_base(0, 0).max_generation_mw - 200.0).abs() < f64::EPSILON);
1356    }
1357
1358    #[test]
1359    fn test_thermal_stage_axis_extends_with_k_max() {
1360        let tbb = ThermalBlockBounds {
1361            min_generation_mw: 0.0,
1362            max_generation_mw: 100.0,
1363        };
1364        let table = ResolvedBounds::new(
1365            &BoundsCountsSpec {
1366                n_hydros: 0,
1367                n_thermals: 2,
1368                n_lines: 0,
1369                n_pumping: 0,
1370                n_contracts: 0,
1371                n_stages: 3,
1372                k_max: 2,
1373            },
1374            &BoundsDefaults {
1375                thermal_block: tbb,
1376                ..zero_defaults()
1377            },
1378        );
1379        assert_eq!(table.thermal_stage_axis_len(), 5);
1380        let padded = table.thermal_block_base(1, 4);
1381        assert!((padded.max_generation_mw - 100.0).abs() < f64::EPSILON);
1382    }
1383
1384    #[test]
1385    fn test_thermal_stage_axis_zero_k_max_unchanged() {
1386        let tbb = ThermalBlockBounds {
1387            min_generation_mw: 0.0,
1388            max_generation_mw: 50.0,
1389        };
1390        let table = ResolvedBounds::new(
1391            &BoundsCountsSpec {
1392                n_hydros: 0,
1393                n_thermals: 1,
1394                n_lines: 0,
1395                n_pumping: 0,
1396                n_contracts: 0,
1397                n_stages: 4,
1398                k_max: 0,
1399            },
1400            &BoundsDefaults {
1401                thermal_block: tbb,
1402                ..zero_defaults()
1403            },
1404        );
1405        assert_eq!(table.thermal_stage_axis_len(), table.n_stages());
1406        let last = table.thermal_block_base(0, 3);
1407        assert!((last.max_generation_mw - 50.0).abs() < f64::EPSILON);
1408    }
1409
1410    #[test]
1411    fn test_empty_bounds_has_zero_thermal_axis() {
1412        let empty = ResolvedBounds::empty();
1413        assert_eq!(empty.thermal_stage_axis_len(), 0);
1414        assert_eq!(empty.n_stages(), 0);
1415    }
1416
1417    #[test]
1418    fn test_n_pumping_recovers_station_count() {
1419        let table = ResolvedBounds::new(
1420            &BoundsCountsSpec {
1421                n_hydros: 0,
1422                n_thermals: 0,
1423                n_lines: 0,
1424                n_pumping: 2,
1425                n_contracts: 0,
1426                n_stages: 3,
1427                k_max: 0,
1428            },
1429            &zero_defaults(),
1430        );
1431        assert_eq!(table.n_pumping(), 2);
1432    }
1433
1434    #[test]
1435    fn test_n_pumping_zero_when_no_stations() {
1436        let table = make_bounds_for_boundary_tests(4, 0);
1437        assert_eq!(table.n_pumping(), 0);
1438    }
1439
1440    #[test]
1441    fn test_n_pumping_empty_table_is_zero() {
1442        assert_eq!(ResolvedBounds::empty().n_pumping(), 0);
1443    }
1444
1445    // ─── Thermal-bounds padding boundary tests ───────────────────────────────
1446    //
1447    // This module verifies only the uniform `BoundsDefaults.thermal` fill; the
1448    // per-thermal base-fill semantics are owned by `cobre-io`'s resolution tests,
1449    // which construct `Thermal` entities.
1450
1451    /// Sentinel defaults used by the thermal-padding boundary tests. Values are
1452    /// picked so an off-by-one read returns a value that does not collide with
1453    /// any plausible production default.
1454    const T_DEFAULT: ThermalStageBounds = ThermalStageBounds { cost_per_mwh: 7.7 };
1455    const T_BLOCK_DEFAULT: ThermalBlockBounds = ThermalBlockBounds {
1456        min_generation_mw: 7.0,
1457        max_generation_mw: 77.0,
1458    };
1459
1460    /// Construct a `ResolvedBounds` with one thermal entity, the given
1461    /// `n_stages` / `k_max`, and `T_DEFAULT`/`T_BLOCK_DEFAULT` as the thermal
1462    /// defaults. Other entity types are zero-sized.
1463    fn make_bounds_for_boundary_tests(n_stages: usize, k_max: usize) -> ResolvedBounds {
1464        ResolvedBounds::new(
1465            &BoundsCountsSpec {
1466                n_hydros: 0,
1467                n_thermals: 1,
1468                n_lines: 0,
1469                n_pumping: 0,
1470                n_contracts: 0,
1471                n_stages,
1472                k_max,
1473            },
1474            &BoundsDefaults {
1475                thermal: T_DEFAULT,
1476                thermal_block: T_BLOCK_DEFAULT,
1477                ..zero_defaults()
1478            },
1479        )
1480    }
1481
1482    /// `T - 1`: writing a distinctive value via `thermal_bounds_mut` at the
1483    /// last study stage and reading it back via `thermal_bounds` must return
1484    /// the written value — the padding region must not shadow study stages.
1485    #[test]
1486    fn test_thermal_bounds_at_last_study_stage() {
1487        let mut table = make_bounds_for_boundary_tests(5, 3);
1488        let written_stage = ThermalStageBounds { cost_per_mwh: 1.1 };
1489        let written_block = ThermalBlockBounds {
1490            min_generation_mw: 11.0,
1491            max_generation_mw: 111.0,
1492        };
1493        *table.thermal_bounds_mut(0, 4) = written_stage;
1494        *table.thermal_block_base_mut(0, 4) = written_block;
1495        let read = table.thermal_bounds(0, 4);
1496        let read_block = table.thermal_block_base(0, 4);
1497        assert!((read_block.min_generation_mw - 11.0).abs() < f64::EPSILON);
1498        assert!((read_block.max_generation_mw - 111.0).abs() < f64::EPSILON);
1499        assert!((read.cost_per_mwh - 1.1).abs() < f64::EPSILON);
1500    }
1501
1502    /// `T`: the first padded stage must contain the uniform thermal default
1503    /// after `ResolvedBounds::new` — no spillover from any non-existent prior
1504    /// override and no zero-initialization regression.
1505    #[test]
1506    fn test_thermal_bounds_at_first_padded_stage() {
1507        let table = make_bounds_for_boundary_tests(5, 3);
1508        let padded = table.thermal_block_base(0, 5);
1509        assert!(
1510            (padded.min_generation_mw - T_BLOCK_DEFAULT.min_generation_mw).abs() < f64::EPSILON
1511        );
1512        assert!(
1513            (padded.max_generation_mw - T_BLOCK_DEFAULT.max_generation_mw).abs() < f64::EPSILON
1514        );
1515        assert!(
1516            (table.thermal_bounds(0, 5).cost_per_mwh - T_DEFAULT.cost_per_mwh).abs() < f64::EPSILON
1517        );
1518    }
1519
1520    /// `T + K_max - 1`: the last padded stage must still return the uniform
1521    /// thermal default — the padded region is contiguous and uniform.
1522    #[test]
1523    fn test_thermal_bounds_at_last_padded_stage() {
1524        let table = make_bounds_for_boundary_tests(5, 3);
1525        let padded = table.thermal_block_base(0, 7);
1526        assert!(
1527            (padded.min_generation_mw - T_BLOCK_DEFAULT.min_generation_mw).abs() < f64::EPSILON
1528        );
1529        assert!(
1530            (padded.max_generation_mw - T_BLOCK_DEFAULT.max_generation_mw).abs() < f64::EPSILON
1531        );
1532        assert!(
1533            (table.thermal_bounds(0, 7).cost_per_mwh - T_DEFAULT.cost_per_mwh).abs() < f64::EPSILON
1534        );
1535    }
1536
1537    /// `T + K_max`: one past the padding region must panic in debug builds.
1538    /// Gated by `#[cfg(debug_assertions)]` because release builds may silently
1539    /// read adjacent memory via `Vec` indexing (see `thermal_bounds` docs).
1540    #[test]
1541    #[cfg(debug_assertions)]
1542    fn test_thermal_bounds_out_of_range_panics_in_debug() {
1543        let table = make_bounds_for_boundary_tests(5, 3);
1544        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1545            let _ = table.thermal_bounds(0, 8);
1546        }));
1547        assert!(
1548            result.is_err(),
1549            "thermal_bounds(0, 8) must panic in debug builds when n_stages=5, k_max=3"
1550        );
1551    }
1552
1553    /// `n_stages()` returns the *study horizon* length, not the padded axis.
1554    /// The padded region is internal to the thermal storage; consumers that
1555    /// iterate the study horizon (forward/backward passes, simulation) must
1556    /// continue to see `n_stages() == 5`.
1557    #[test]
1558    fn test_n_stages_unchanged_with_padding() {
1559        let table = make_bounds_for_boundary_tests(5, 3);
1560        assert_eq!(table.n_stages(), 5);
1561    }
1562
1563    /// `thermal_stage_axis_len()` returns `n_stages + k_max`. This is the
1564    /// public accessor anticipated-decision consumers use to validate that
1565    /// `t + K_i` lookups remain in-range.
1566    #[test]
1567    fn test_thermal_stage_axis_len_equals_n_plus_k_max() {
1568        let table = make_bounds_for_boundary_tests(5, 3);
1569        assert_eq!(table.thermal_stage_axis_len(), 8);
1570    }
1571
1572    /// Asserts `thermal_stage_axis_len() == n_stages + k_max` across a sweep of
1573    /// `(n_stages, k_max, n_thermals)` configurations.
1574    mod bounds_padding_invariants {
1575        use super::{
1576            BoundsCountsSpec, BoundsDefaults, ResolvedBounds, T_BLOCK_DEFAULT, T_DEFAULT,
1577            zero_defaults,
1578        };
1579
1580        #[test]
1581        fn axis_len_matches_n_plus_k_max() {
1582            // n_stages starts at 1: ResolvedBounds::new debug-asserts n_stages > 0,
1583            // so the 0 case is exercised separately by
1584            // new_with_zero_n_stages_panics_in_debug.
1585            let n_stages_grid = [1_usize, 5, 12];
1586            let k_max_grid = [0_usize, 1, 3, 10];
1587            let n_thermals_grid = [0_usize, 1, 5];
1588
1589            let mut count: usize = 0;
1590            for &n_stages in &n_stages_grid {
1591                for &k_max in &k_max_grid {
1592                    for &n_thermals in &n_thermals_grid {
1593                        let table = ResolvedBounds::new(
1594                            &BoundsCountsSpec {
1595                                n_hydros: 0,
1596                                n_thermals,
1597                                n_lines: 0,
1598                                n_pumping: 0,
1599                                n_contracts: 0,
1600                                n_stages,
1601                                k_max,
1602                            },
1603                            &BoundsDefaults {
1604                                thermal: T_DEFAULT,
1605                                thermal_block: T_BLOCK_DEFAULT,
1606                                ..zero_defaults()
1607                            },
1608                        );
1609                        assert_eq!(
1610                            table.thermal_stage_axis_len(),
1611                            n_stages + k_max,
1612                            "axis_len mismatch at (n_stages={n_stages}, k_max={k_max}, n_thermals={n_thermals})"
1613                        );
1614                        assert_eq!(
1615                            table.n_stages(),
1616                            n_stages,
1617                            "n_stages mismatch at (n_stages={n_stages}, k_max={k_max}, n_thermals={n_thermals})"
1618                        );
1619                        count += 1;
1620                    }
1621                }
1622            }
1623            // Guards against accidental loop truncation if the grids are edited.
1624            assert!(
1625                count >= 27,
1626                "expected at least 27 sweep combinations, got {count}"
1627            );
1628        }
1629    }
1630
1631    /// Zero-valued defaults for every entity family; a test overrides only the
1632    /// families it exercises via struct-update syntax.
1633    fn zero_defaults() -> BoundsDefaults {
1634        BoundsDefaults {
1635            hydro: HydroStageBounds {
1636                min_storage_hm3: 0.0,
1637                max_storage_hm3: 0.0,
1638                filling_min_rate_m3s: 0.0,
1639                water_withdrawal_m3s: 0.0,
1640            },
1641            hydro_block: HydroBlockBounds::default(),
1642            thermal: ThermalStageBounds { cost_per_mwh: 0.0 },
1643            thermal_block: ThermalBlockBounds {
1644                min_generation_mw: 0.0,
1645                max_generation_mw: 0.0,
1646            },
1647            line_block: LineBlockBounds {
1648                direct_mw: 0.0,
1649                reverse_mw: 0.0,
1650            },
1651            pumping_block: PumpingBlockBounds {
1652                min_flow_m3s: 0.0,
1653                max_flow_m3s: 0.0,
1654            },
1655            contract_block: ContractBlockBounds {
1656                min_mw: 0.0,
1657                max_mw: 0.0,
1658                price_per_mwh: 0.0,
1659            },
1660        }
1661    }
1662
1663    #[test]
1664    fn test_hydro_stage_bounds_has_four_fields() {
1665        let b = HydroStageBounds {
1666            min_storage_hm3: 1.0,
1667            max_storage_hm3: 2.0,
1668            filling_min_rate_m3s: 10.0,
1669            water_withdrawal_m3s: 11.0,
1670        };
1671        assert!((b.min_storage_hm3 - 1.0).abs() < f64::EPSILON);
1672        assert!((b.max_storage_hm3 - 2.0).abs() < f64::EPSILON);
1673        assert!((b.filling_min_rate_m3s - 10.0).abs() < f64::EPSILON);
1674        assert!((b.water_withdrawal_m3s - 11.0).abs() < f64::EPSILON);
1675    }
1676
1677    #[test]
1678    fn test_hydro_block_bounds_has_ten_fields() {
1679        let b = HydroBlockBounds {
1680            min_turbined_m3s: 3.0,
1681            max_turbined_m3s: 4.0,
1682            min_outflow_m3s: 5.0,
1683            max_outflow_m3s: Some(6.0),
1684            min_generation_mw: 7.0,
1685            max_generation_mw: 8.0,
1686            min_diversion_m3s: Some(9.0),
1687            max_diversion_m3s: Some(10.0),
1688            min_spillage_m3s: Some(11.0),
1689            max_spillage_m3s: Some(12.0),
1690        };
1691        assert!((b.min_turbined_m3s - 3.0).abs() < f64::EPSILON);
1692        assert!((b.max_turbined_m3s - 4.0).abs() < f64::EPSILON);
1693        assert!((b.min_generation_mw - 7.0).abs() < f64::EPSILON);
1694        assert!((b.max_generation_mw - 8.0).abs() < f64::EPSILON);
1695        assert_eq!(b.max_outflow_m3s, Some(6.0));
1696        assert_eq!(b.min_diversion_m3s, Some(9.0));
1697        assert_eq!(b.max_diversion_m3s, Some(10.0));
1698        assert_eq!(b.min_spillage_m3s, Some(11.0));
1699        assert_eq!(b.max_spillage_m3s, Some(12.0));
1700    }
1701
1702    #[test]
1703    #[cfg(feature = "serde")]
1704    fn test_resolved_bounds_serde_roundtrip() {
1705        let hb = make_hydro_bounds();
1706        let hbl = make_hydro_block_bounds();
1707        let tb = ThermalStageBounds { cost_per_mwh: 0.0 };
1708        let tbb = ThermalBlockBounds {
1709            min_generation_mw: 0.0,
1710            max_generation_mw: 100.0,
1711        };
1712        let lb = LineBlockBounds {
1713            direct_mw: 500.0,
1714            reverse_mw: 500.0,
1715        };
1716        let pb = PumpingBlockBounds {
1717            min_flow_m3s: 0.0,
1718            max_flow_m3s: 20.0,
1719        };
1720        let cb = ContractBlockBounds {
1721            min_mw: 0.0,
1722            max_mw: 50.0,
1723            price_per_mwh: 80.0,
1724        };
1725
1726        let original = ResolvedBounds::new(
1727            &BoundsCountsSpec {
1728                n_hydros: 1,
1729                n_thermals: 1,
1730                n_lines: 1,
1731                n_pumping: 1,
1732                n_contracts: 1,
1733                n_stages: 3,
1734                k_max: 0,
1735            },
1736            &BoundsDefaults {
1737                hydro: hb,
1738                hydro_block: hbl,
1739                thermal: tb,
1740                thermal_block: tbb,
1741                line_block: lb,
1742                pumping_block: pb,
1743                contract_block: cb,
1744            },
1745        );
1746        let json = serde_json::to_string(&original).expect("serialize");
1747        let restored: ResolvedBounds = serde_json::from_str(&json).expect("deserialize");
1748        assert_eq!(original, restored);
1749    }
1750
1751    /// Roundtrip with a non-zero `k_max`: guards against silent data loss in
1752    /// the `thermal_stage_axis_len` field. With `serde(default)` on that
1753    /// field, an absent JSON key would deserialize back to `0`, aliasing all
1754    /// thermals to thermal 0's cells. This test ensures the field is actually
1755    /// serialized.
1756    #[cfg(feature = "serde")]
1757    #[test]
1758    fn test_resolved_bounds_serde_roundtrip_with_padding() {
1759        let hb = make_hydro_bounds();
1760        let hbl = make_hydro_block_bounds();
1761        let tb = ThermalStageBounds { cost_per_mwh: 60.0 };
1762        let tbb = ThermalBlockBounds {
1763            min_generation_mw: 0.0,
1764            max_generation_mw: 200.0,
1765        };
1766        let lb = LineBlockBounds {
1767            direct_mw: 50.0,
1768            reverse_mw: 50.0,
1769        };
1770        let pb = PumpingBlockBounds {
1771            min_flow_m3s: 0.0,
1772            max_flow_m3s: 20.0,
1773        };
1774        let cb = ContractBlockBounds {
1775            min_mw: 0.0,
1776            max_mw: 50.0,
1777            price_per_mwh: 80.0,
1778        };
1779
1780        let original = ResolvedBounds::new(
1781            &BoundsCountsSpec {
1782                n_hydros: 1,
1783                n_thermals: 2,
1784                n_lines: 1,
1785                n_pumping: 1,
1786                n_contracts: 1,
1787                n_stages: 3,
1788                k_max: 2,
1789            },
1790            &BoundsDefaults {
1791                hydro: hb,
1792                hydro_block: hbl,
1793                thermal: tb,
1794                thermal_block: tbb,
1795                line_block: lb,
1796                pumping_block: pb,
1797                contract_block: cb,
1798            },
1799        );
1800        assert_eq!(original.thermal_stage_axis_len(), 5);
1801        let json = serde_json::to_string(&original).expect("serialize");
1802        let restored: ResolvedBounds = serde_json::from_str(&json).expect("deserialize");
1803        assert_eq!(
1804            restored.thermal_stage_axis_len(),
1805            original.thermal_stage_axis_len(),
1806            "thermal_stage_axis_len must survive serde roundtrip"
1807        );
1808        assert_eq!(original, restored);
1809    }
1810
1811    /// A JSON payload that omits `thermal_stage_axis_len` while the thermal
1812    /// table is non-empty must be **rejected**, not silently defaulted to `0`.
1813    /// A zero stride would alias every thermal to thermal 0's stage block; the
1814    /// `serde(try_from = "ResolvedBoundsWire")` path errors instead.
1815    #[cfg(feature = "serde")]
1816    #[test]
1817    fn deserialize_missing_thermal_axis_len_with_thermals_is_rejected() {
1818        let json = r#"{
1819            "n_stages": 1,
1820            "hydro": [],
1821            "thermal": [{"stage": {"cost_per_mwh": 50.0}, "block": {"min_generation_mw": 0.0, "max_generation_mw": 100.0}}],
1822            "line": [],
1823            "pumping": [],
1824            "contract": []
1825        }"#;
1826        let result: Result<ResolvedBounds, _> = serde_json::from_str(json);
1827        assert!(
1828            result.is_err(),
1829            "deserializing a non-empty thermal table without thermal_stage_axis_len \
1830             must error, got Ok"
1831        );
1832    }
1833
1834    /// A present-but-zero `thermal_stage_axis_len` with a non-empty thermal
1835    /// table is also rejected by the `TryFrom` cross-field check.
1836    #[cfg(feature = "serde")]
1837    #[test]
1838    fn deserialize_zero_thermal_axis_len_with_thermals_is_rejected() {
1839        let json = r#"{
1840            "n_stages": 1,
1841            "thermal_stage_axis_len": 0,
1842            "hydro": [],
1843            "thermal": [{"stage": {"cost_per_mwh": 50.0}, "block": {"min_generation_mw": 0.0, "max_generation_mw": 100.0}}],
1844            "line": [],
1845            "pumping": [],
1846            "contract": []
1847        }"#;
1848        let result: Result<ResolvedBounds, _> = serde_json::from_str(json);
1849        assert!(
1850            result.is_err(),
1851            "deserializing a non-empty thermal table with thermal_stage_axis_len=0 \
1852             must error, got Ok"
1853        );
1854    }
1855
1856    /// `ResolvedBounds::new` documents `n_stages > 0` as a precondition and
1857    /// enforces it with a `debug_assert!`. Verify the debug-build panic.
1858    #[test]
1859    #[cfg(debug_assertions)]
1860    fn new_with_zero_n_stages_panics_in_debug() {
1861        let result = std::panic::catch_unwind(|| {
1862            ResolvedBounds::new(
1863                &BoundsCountsSpec {
1864                    n_hydros: 1,
1865                    n_thermals: 1,
1866                    n_lines: 1,
1867                    n_pumping: 1,
1868                    n_contracts: 1,
1869                    n_stages: 0,
1870                    k_max: 0,
1871                },
1872                &zero_defaults(),
1873            )
1874        });
1875        assert!(
1876            result.is_err(),
1877            "ResolvedBounds::new(n_stages=0) must panic in debug builds"
1878        );
1879    }
1880
1881    // ─── Block overlay tests (bound-precedence layer 1) ─────────────────────
1882
1883    fn opt_f64_bits_eq(a: Option<f64>, b: Option<f64>) -> bool {
1884        match (a, b) {
1885            (None, None) => true,
1886            (Some(x), Some(y)) => x.to_bits() == y.to_bits(),
1887            _ => false,
1888        }
1889    }
1890
1891    fn hydro_stage_bounds_bits_eq(a: &HydroStageBounds, b: &HydroStageBounds) -> bool {
1892        a.min_storage_hm3.to_bits() == b.min_storage_hm3.to_bits()
1893            && a.max_storage_hm3.to_bits() == b.max_storage_hm3.to_bits()
1894            && a.filling_min_rate_m3s.to_bits() == b.filling_min_rate_m3s.to_bits()
1895            && a.water_withdrawal_m3s.to_bits() == b.water_withdrawal_m3s.to_bits()
1896    }
1897
1898    fn hydro_block_bounds_bits_eq(a: &HydroBlockBounds, b: &HydroBlockBounds) -> bool {
1899        a.min_turbined_m3s.to_bits() == b.min_turbined_m3s.to_bits()
1900            && a.max_turbined_m3s.to_bits() == b.max_turbined_m3s.to_bits()
1901            && a.min_outflow_m3s.to_bits() == b.min_outflow_m3s.to_bits()
1902            && opt_f64_bits_eq(a.max_outflow_m3s, b.max_outflow_m3s)
1903            && a.min_generation_mw.to_bits() == b.min_generation_mw.to_bits()
1904            && a.max_generation_mw.to_bits() == b.max_generation_mw.to_bits()
1905            && opt_f64_bits_eq(a.min_diversion_m3s, b.min_diversion_m3s)
1906            && opt_f64_bits_eq(a.max_diversion_m3s, b.max_diversion_m3s)
1907            && opt_f64_bits_eq(a.min_spillage_m3s, b.min_spillage_m3s)
1908            && opt_f64_bits_eq(a.max_spillage_m3s, b.max_spillage_m3s)
1909    }
1910
1911    fn thermal_block_bounds_bits_eq(a: &ThermalBlockBounds, b: &ThermalBlockBounds) -> bool {
1912        a.min_generation_mw.to_bits() == b.min_generation_mw.to_bits()
1913            && a.max_generation_mw.to_bits() == b.max_generation_mw.to_bits()
1914    }
1915
1916    fn line_bounds_bits_eq(a: &LineBlockBounds, b: &LineBlockBounds) -> bool {
1917        a.direct_mw.to_bits() == b.direct_mw.to_bits()
1918            && a.reverse_mw.to_bits() == b.reverse_mw.to_bits()
1919    }
1920
1921    fn pumping_bounds_bits_eq(a: &PumpingBlockBounds, b: &PumpingBlockBounds) -> bool {
1922        a.min_flow_m3s.to_bits() == b.min_flow_m3s.to_bits()
1923            && a.max_flow_m3s.to_bits() == b.max_flow_m3s.to_bits()
1924    }
1925
1926    fn contract_bounds_bits_eq(a: &ContractBlockBounds, b: &ContractBlockBounds) -> bool {
1927        a.min_mw.to_bits() == b.min_mw.to_bits()
1928            && a.max_mw.to_bits() == b.max_mw.to_bits()
1929            && a.price_per_mwh.to_bits() == b.price_per_mwh.to_bits()
1930    }
1931
1932    /// Builds a table with distinct per-(entity, stage) values for every family
1933    /// so an indexing/stride bug in an `*_bounds_at_block` accessor surfaces as
1934    /// a bit mismatch rather than a coincidental match.
1935    #[allow(clippy::cast_precision_loss)] // entity/stage indices stay well within f64's exact-integer range
1936    fn make_distinct_bounds_table(n_entities: usize, n_stages: usize) -> ResolvedBounds {
1937        let mut table = ResolvedBounds::new(
1938            &BoundsCountsSpec {
1939                n_hydros: n_entities,
1940                n_thermals: n_entities,
1941                n_lines: n_entities,
1942                n_pumping: n_entities,
1943                n_contracts: n_entities,
1944                n_stages,
1945                k_max: 0,
1946            },
1947            &zero_defaults(),
1948        );
1949        for e in 0..n_entities {
1950            for s in 0..n_stages {
1951                let base = (e * 1000 + s) as f64;
1952                *table.hydro_bounds_mut(e, s) = HydroStageBounds {
1953                    min_storage_hm3: base + 1.0,
1954                    max_storage_hm3: base + 2.0,
1955                    filling_min_rate_m3s: base + 10.0,
1956                    water_withdrawal_m3s: base + 11.0,
1957                };
1958                *table.hydro_block_base_mut(e, s) = HydroBlockBounds {
1959                    min_turbined_m3s: base + 3.0,
1960                    max_turbined_m3s: base + 4.0,
1961                    min_outflow_m3s: base + 5.0,
1962                    max_outflow_m3s: if (e + s) % 2 == 0 {
1963                        Some(base + 6.0)
1964                    } else {
1965                        None
1966                    },
1967                    min_generation_mw: base + 7.0,
1968                    max_generation_mw: base + 8.0,
1969                    min_diversion_m3s: if (e + s) % 2 == 0 {
1970                        Some(base + 9.0)
1971                    } else {
1972                        None
1973                    },
1974                    max_diversion_m3s: if (e + s) % 2 == 0 {
1975                        None
1976                    } else {
1977                        Some(base + 9.0)
1978                    },
1979                    min_spillage_m3s: if (e + s) % 2 == 0 {
1980                        None
1981                    } else {
1982                        Some(base + 12.0)
1983                    },
1984                    max_spillage_m3s: if (e + s) % 2 == 0 {
1985                        Some(base + 13.0)
1986                    } else {
1987                        None
1988                    },
1989                };
1990                *table.thermal_bounds_mut(e, s) = ThermalStageBounds {
1991                    cost_per_mwh: base + 3.0,
1992                };
1993                *table.thermal_block_base_mut(e, s) = ThermalBlockBounds {
1994                    min_generation_mw: base + 1.0,
1995                    max_generation_mw: base + 2.0,
1996                };
1997                *table.line_bounds_mut(e, s) = LineBlockBounds {
1998                    direct_mw: base + 1.0,
1999                    reverse_mw: base + 2.0,
2000                };
2001                *table.pumping_bounds_mut(e, s) = PumpingBlockBounds {
2002                    min_flow_m3s: base + 1.0,
2003                    max_flow_m3s: base + 2.0,
2004                };
2005                *table.contract_bounds_mut(e, s) = ContractBlockBounds {
2006                    min_mw: base + 1.0,
2007                    max_mw: base + 2.0,
2008                    price_per_mwh: base + 3.0,
2009                };
2010            }
2011        }
2012        table
2013    }
2014
2015    #[test]
2016    fn test_empty_overlay_block_accessor_is_bit_identical_to_stage_accessor() {
2017        let n_entities = 2;
2018        let n_stages = 3;
2019        let table = make_distinct_bounds_table(n_entities, n_stages);
2020
2021        for e in 0..n_entities {
2022            for s in 0..n_stages {
2023                let hydro_expected = table.hydro_block_base(e, s);
2024                let thermal_block_expected = table.thermal_block_base(e, s);
2025                let line_expected = table.line_block_base(e, s);
2026                let pumping_expected = table.pumping_block_base(e, s);
2027                let contract_expected = table.contract_block_base(e, s);
2028                for b in 0..5 {
2029                    assert!(
2030                        hydro_block_bounds_bits_eq(
2031                            &hydro_expected,
2032                            &table.hydro_bounds_at_block(e, s, b)
2033                        ),
2034                        "hydro mismatch at (e={e}, s={s}, b={b})"
2035                    );
2036                    assert!(
2037                        thermal_block_bounds_bits_eq(
2038                            &thermal_block_expected,
2039                            &table.thermal_bounds_at_block(e, s, b)
2040                        ),
2041                        "thermal mismatch at (e={e}, s={s}, b={b})"
2042                    );
2043                    assert!(
2044                        line_bounds_bits_eq(&line_expected, &table.line_bounds_at_block(e, s, b)),
2045                        "line mismatch at (e={e}, s={s}, b={b})"
2046                    );
2047                    assert!(
2048                        pumping_bounds_bits_eq(
2049                            &pumping_expected,
2050                            &table.pumping_bounds_at_block(e, s, b)
2051                        ),
2052                        "pumping mismatch at (e={e}, s={s}, b={b})"
2053                    );
2054                    assert!(
2055                        contract_bounds_bits_eq(
2056                            &contract_expected,
2057                            &table.contract_bounds_at_block(e, s, b)
2058                        ),
2059                        "contract mismatch at (e={e}, s={s}, b={b})"
2060                    );
2061                }
2062            }
2063        }
2064    }
2065
2066    #[test]
2067    fn test_block_override_replaces_only_its_own_column_and_block() {
2068        let tbb = ThermalBlockBounds {
2069            min_generation_mw: 10.0,
2070            max_generation_mw: 50.0,
2071        };
2072        let mut table = ResolvedBounds::new(
2073            &BoundsCountsSpec {
2074                n_hydros: 0,
2075                n_thermals: 1,
2076                n_lines: 0,
2077                n_pumping: 0,
2078                n_contracts: 0,
2079                n_stages: 2,
2080                k_max: 0,
2081            },
2082            &BoundsDefaults {
2083                thermal_block: tbb,
2084                ..zero_defaults()
2085            },
2086        );
2087
2088        let mut block = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
2089            n_hydros: 0,
2090            n_thermals: 1,
2091            n_lines: 0,
2092            n_pumping: 0,
2093            n_contracts: 0,
2094            n_stages: 2,
2095            max_blocks: 3,
2096        });
2097        block
2098            .thermal_override_mut(0, 1, 2)
2099            .expect("in-range override cell")
2100            .max_generation_mw = Some(100.0);
2101        table.set_block_overlay(block);
2102
2103        let overridden = table.thermal_bounds_at_block(0, 1, 2);
2104        assert!((overridden.max_generation_mw - 100.0).abs() < f64::EPSILON);
2105        assert!((overridden.min_generation_mw - tbb.min_generation_mw).abs() < f64::EPSILON);
2106
2107        let other_block = table.thermal_bounds_at_block(0, 1, 0);
2108        let block_cell = table.thermal_block_base(0, 1);
2109        assert!(
2110            (other_block.min_generation_mw - block_cell.min_generation_mw).abs() < f64::EPSILON
2111        );
2112        assert!(
2113            (other_block.max_generation_mw - block_cell.max_generation_mw).abs() < f64::EPSILON
2114        );
2115    }
2116
2117    #[test]
2118    fn test_thermal_block_base_ignores_the_overlay() {
2119        let tbb = ThermalBlockBounds {
2120            min_generation_mw: 10.0,
2121            max_generation_mw: 50.0,
2122        };
2123        let mut table = ResolvedBounds::new(
2124            &BoundsCountsSpec {
2125                n_hydros: 0,
2126                n_thermals: 1,
2127                n_lines: 0,
2128                n_pumping: 0,
2129                n_contracts: 0,
2130                n_stages: 2,
2131                k_max: 0,
2132            },
2133            &BoundsDefaults {
2134                thermal_block: tbb,
2135                ..zero_defaults()
2136            },
2137        );
2138
2139        let mut block = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
2140            n_hydros: 0,
2141            n_thermals: 1,
2142            n_lines: 0,
2143            n_pumping: 0,
2144            n_contracts: 0,
2145            n_stages: 2,
2146            max_blocks: 3,
2147        });
2148        block
2149            .thermal_override_mut(0, 1, 0)
2150            .expect("in-range override cell")
2151            .max_generation_mw = Some(100.0);
2152        table.set_block_overlay(block);
2153
2154        let base = table.thermal_block_base(0, 1);
2155        let at_block_0 = table.thermal_bounds_at_block(0, 1, 0);
2156
2157        assert_eq!(
2158            base.max_generation_mw.to_bits(),
2159            tbb.max_generation_mw.to_bits(),
2160            "base must ignore the overlay and return the unoverridden stage cell"
2161        );
2162        assert_eq!(
2163            at_block_0.max_generation_mw.to_bits(),
2164            100.0_f64.to_bits(),
2165            "at-block must return the overridden value"
2166        );
2167        assert_ne!(
2168            base.max_generation_mw.to_bits(),
2169            at_block_0.max_generation_mw.to_bits(),
2170            "base and at-block must diverge once a block override is installed"
2171        );
2172    }
2173
2174    /// Mirrors `test_thermal_block_base_ignores_the_overlay` for line: the
2175    /// at-block read carries the override, the base read is the unoverridden
2176    /// stage-wide value, compared under `f64::to_bits`.
2177    #[test]
2178    fn test_line_block_base_ignores_the_overlay() {
2179        let lb = LineBlockBounds {
2180            direct_mw: 1000.0,
2181            reverse_mw: 800.0,
2182        };
2183        let mut table = ResolvedBounds::new(
2184            &BoundsCountsSpec {
2185                n_hydros: 0,
2186                n_thermals: 0,
2187                n_lines: 1,
2188                n_pumping: 0,
2189                n_contracts: 0,
2190                n_stages: 2,
2191                k_max: 0,
2192            },
2193            &BoundsDefaults {
2194                line_block: lb,
2195                ..zero_defaults()
2196            },
2197        );
2198
2199        let mut block = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
2200            n_hydros: 0,
2201            n_thermals: 0,
2202            n_lines: 1,
2203            n_pumping: 0,
2204            n_contracts: 0,
2205            n_stages: 2,
2206            max_blocks: 3,
2207        });
2208        block
2209            .line_override_mut(0, 1, 0)
2210            .expect("in-range override cell")
2211            .direct_mw = Some(400.0);
2212        table.set_block_overlay(block);
2213
2214        let base = table.line_block_base(0, 1);
2215        let at_block_0 = table.line_bounds_at_block(0, 1, 0);
2216
2217        assert_eq!(
2218            base.direct_mw.to_bits(),
2219            lb.direct_mw.to_bits(),
2220            "base must ignore the overlay and return the unoverridden stage cell"
2221        );
2222        assert_eq!(
2223            at_block_0.direct_mw.to_bits(),
2224            400.0_f64.to_bits(),
2225            "at-block must return the overridden value"
2226        );
2227        assert_ne!(
2228            base.direct_mw.to_bits(),
2229            at_block_0.direct_mw.to_bits(),
2230            "base and at-block must diverge once a block override is installed"
2231        );
2232    }
2233
2234    /// Mirrors `test_thermal_block_base_ignores_the_overlay` for pumping: the
2235    /// at-block read carries the override, the base read is the unoverridden
2236    /// stage-wide value, compared under `f64::to_bits`.
2237    #[test]
2238    fn test_pumping_block_base_ignores_the_overlay() {
2239        let pb = PumpingBlockBounds {
2240            min_flow_m3s: 0.0,
2241            max_flow_m3s: 50.0,
2242        };
2243        let mut table = ResolvedBounds::new(
2244            &BoundsCountsSpec {
2245                n_hydros: 0,
2246                n_thermals: 0,
2247                n_lines: 0,
2248                n_pumping: 1,
2249                n_contracts: 0,
2250                n_stages: 2,
2251                k_max: 0,
2252            },
2253            &BoundsDefaults {
2254                pumping_block: pb,
2255                ..zero_defaults()
2256            },
2257        );
2258
2259        let mut block = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
2260            n_hydros: 0,
2261            n_thermals: 0,
2262            n_lines: 0,
2263            n_pumping: 1,
2264            n_contracts: 0,
2265            n_stages: 2,
2266            max_blocks: 3,
2267        });
2268        block
2269            .pumping_override_mut(0, 1, 0)
2270            .expect("in-range override cell")
2271            .max_flow_m3s = Some(20.0);
2272        table.set_block_overlay(block);
2273
2274        let base = table.pumping_block_base(0, 1);
2275        let at_block_0 = table.pumping_bounds_at_block(0, 1, 0);
2276
2277        assert_eq!(
2278            base.max_flow_m3s.to_bits(),
2279            pb.max_flow_m3s.to_bits(),
2280            "base must ignore the overlay and return the unoverridden stage cell"
2281        );
2282        assert_eq!(
2283            at_block_0.max_flow_m3s.to_bits(),
2284            20.0_f64.to_bits(),
2285            "at-block must return the overridden value"
2286        );
2287        assert_ne!(
2288            base.max_flow_m3s.to_bits(),
2289            at_block_0.max_flow_m3s.to_bits(),
2290            "base and at-block must diverge once a block override is installed"
2291        );
2292    }
2293
2294    /// Mirrors `test_thermal_block_base_ignores_the_overlay` for contract: the
2295    /// at-block read carries the overridden `price_per_mwh`, the base read is
2296    /// the unoverridden stage-wide value, compared under `f64::to_bits`.
2297    #[test]
2298    fn test_contract_block_base_ignores_the_overlay() {
2299        let cb = ContractBlockBounds {
2300            min_mw: 0.0,
2301            max_mw: 200.0,
2302            price_per_mwh: 80.0,
2303        };
2304        let mut table = ResolvedBounds::new(
2305            &BoundsCountsSpec {
2306                n_hydros: 0,
2307                n_thermals: 0,
2308                n_lines: 0,
2309                n_pumping: 0,
2310                n_contracts: 1,
2311                n_stages: 2,
2312                k_max: 0,
2313            },
2314            &BoundsDefaults {
2315                contract_block: cb,
2316                ..zero_defaults()
2317            },
2318        );
2319
2320        let mut block = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
2321            n_hydros: 0,
2322            n_thermals: 0,
2323            n_lines: 0,
2324            n_pumping: 0,
2325            n_contracts: 1,
2326            n_stages: 2,
2327            max_blocks: 3,
2328        });
2329        block
2330            .contract_override_mut(0, 1, 0)
2331            .expect("in-range override cell")
2332            .price_per_mwh = Some(120.0);
2333        table.set_block_overlay(block);
2334
2335        let base = table.contract_block_base(0, 1);
2336        let at_block_0 = table.contract_bounds_at_block(0, 1, 0);
2337
2338        assert_eq!(
2339            base.price_per_mwh.to_bits(),
2340            cb.price_per_mwh.to_bits(),
2341            "base must ignore the overlay and return the unoverridden stage cell"
2342        );
2343        assert_eq!(
2344            at_block_0.price_per_mwh.to_bits(),
2345            120.0_f64.to_bits(),
2346            "at-block must return the overridden value"
2347        );
2348        assert_ne!(
2349            base.price_per_mwh.to_bits(),
2350            at_block_0.price_per_mwh.to_bits(),
2351            "base and at-block must diverge once a block override is installed"
2352        );
2353    }
2354
2355    /// Mirrors `test_thermal_block_base_ignores_the_overlay` for hydro: the
2356    /// at-block read carries the overridden `max_turbined_m3s`, the base read
2357    /// is the unoverridden block-base cell, compared under `f64::to_bits`.
2358    /// Also pins that installing a block overlay never perturbs the stage
2359    /// cell `hydro_bounds` reads — the two halves of a [`HydroCell`] are
2360    /// independent axes.
2361    #[test]
2362    fn test_hydro_block_base_ignores_the_overlay() {
2363        let hbl = HydroBlockBounds {
2364            max_turbined_m3s: 500.0,
2365            max_generation_mw: 100.0,
2366            ..Default::default()
2367        };
2368        let mut table = ResolvedBounds::new(
2369            &BoundsCountsSpec {
2370                n_hydros: 1,
2371                n_thermals: 0,
2372                n_lines: 0,
2373                n_pumping: 0,
2374                n_contracts: 0,
2375                n_stages: 2,
2376                k_max: 0,
2377            },
2378            &BoundsDefaults {
2379                hydro: make_hydro_bounds(),
2380                hydro_block: hbl,
2381                ..zero_defaults()
2382            },
2383        );
2384        let stage_before = *table.hydro_bounds(0, 1);
2385
2386        let mut block = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
2387            n_hydros: 1,
2388            n_thermals: 0,
2389            n_lines: 0,
2390            n_pumping: 0,
2391            n_contracts: 0,
2392            n_stages: 2,
2393            max_blocks: 3,
2394        });
2395        block
2396            .hydro_override_mut(0, 1, 0)
2397            .expect("in-range override cell")
2398            .max_turbined_m3s = Some(999.0);
2399        table.set_block_overlay(block);
2400
2401        let base = table.hydro_block_base(0, 1);
2402        let at_block_0 = table.hydro_bounds_at_block(0, 1, 0);
2403        let at_block_1 = table.hydro_bounds_at_block(0, 1, 1);
2404
2405        assert_eq!(
2406            base.max_turbined_m3s.to_bits(),
2407            hbl.max_turbined_m3s.to_bits(),
2408            "base must ignore the overlay and return the unoverridden block-base cell"
2409        );
2410        assert_eq!(
2411            at_block_0.max_turbined_m3s.to_bits(),
2412            999.0_f64.to_bits(),
2413            "at-block must return the overridden value"
2414        );
2415        assert_ne!(
2416            base.max_turbined_m3s.to_bits(),
2417            at_block_0.max_turbined_m3s.to_bits(),
2418            "base and at-block must diverge once a block override is installed"
2419        );
2420        assert_eq!(
2421            at_block_1.max_turbined_m3s.to_bits(),
2422            base.max_turbined_m3s.to_bits(),
2423            "a block with no override falls through to the block-base cell"
2424        );
2425
2426        let stage_after = *table.hydro_bounds(0, 1);
2427        assert!(
2428            hydro_stage_bounds_bits_eq(&stage_before, &stage_after),
2429            "installing a block overlay must not perturb the stage cell"
2430        );
2431    }
2432
2433    #[test]
2434    fn test_thermal_block_base_reads_padded_delivery_stage() {
2435        let mut table = make_bounds_for_boundary_tests(5, 3);
2436        let written_stage = ThermalStageBounds { cost_per_mwh: 2.1 };
2437        let written_block = ThermalBlockBounds {
2438            min_generation_mw: 21.0,
2439            max_generation_mw: 210.0,
2440        };
2441        *table.thermal_bounds_mut(0, 6) = written_stage;
2442        *table.thermal_block_base_mut(0, 6) = written_block;
2443
2444        let base = table.thermal_block_base(0, 6);
2445        assert_eq!(
2446            base.min_generation_mw.to_bits(),
2447            written_block.min_generation_mw.to_bits()
2448        );
2449        assert_eq!(
2450            base.max_generation_mw.to_bits(),
2451            written_block.max_generation_mw.to_bits()
2452        );
2453        assert_eq!(
2454            table.thermal_bounds(0, 6).cost_per_mwh.to_bits(),
2455            written_stage.cost_per_mwh.to_bits()
2456        );
2457
2458        let neighbor = table.thermal_block_base(0, 5);
2459        assert_eq!(
2460            neighbor.min_generation_mw.to_bits(),
2461            T_BLOCK_DEFAULT.min_generation_mw.to_bits(),
2462            "an untouched padded cell must keep the uniform default, distinguishing \
2463             a correct stage_index-aware read from one that ignores stage_index"
2464        );
2465    }
2466
2467    #[test]
2468    fn test_optional_column_override_replaces_and_falls_through() {
2469        let mut hydro_block_default = make_hydro_block_bounds();
2470        hydro_block_default.max_outflow_m3s = None;
2471
2472        let mut table = ResolvedBounds::new(
2473            &BoundsCountsSpec {
2474                n_hydros: 1,
2475                n_thermals: 0,
2476                n_lines: 0,
2477                n_pumping: 0,
2478                n_contracts: 0,
2479                n_stages: 2,
2480                k_max: 0,
2481            },
2482            &BoundsDefaults {
2483                hydro_block: hydro_block_default,
2484                ..zero_defaults()
2485            },
2486        );
2487
2488        let mut block = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
2489            n_hydros: 1,
2490            n_thermals: 0,
2491            n_lines: 0,
2492            n_pumping: 0,
2493            n_contracts: 0,
2494            n_stages: 2,
2495            max_blocks: 2,
2496        });
2497        block
2498            .hydro_override_mut(0, 0, 0)
2499            .expect("in-range override cell")
2500            .max_outflow_m3s = Some(250.0);
2501        table.set_block_overlay(block);
2502
2503        assert_eq!(
2504            table.hydro_bounds_at_block(0, 0, 0).max_outflow_m3s,
2505            Some(250.0)
2506        );
2507        assert_eq!(table.hydro_bounds_at_block(0, 0, 1).max_outflow_m3s, None);
2508
2509        // `over.X.or(cell.X)` also type-checks with the operands swapped
2510        // (`cell.X.or(over.X)`, block-base beating the block override); with at
2511        // most one side `Some` above, the two are indistinguishable. Pin it
2512        // with both sides `Some` and distinct.
2513        table.hydro_block_base_mut(0, 1).max_outflow_m3s = Some(400.0);
2514        table.hydro_block_base_mut(0, 1).max_diversion_m3s = Some(40.0);
2515        table
2516            .block_overlay_mut()
2517            .hydro_override_mut(0, 1, 0)
2518            .expect("in-range override cell")
2519            .max_outflow_m3s = Some(650.0);
2520        table
2521            .block_overlay_mut()
2522            .hydro_override_mut(0, 1, 1)
2523            .expect("in-range override cell")
2524            .max_diversion_m3s = Some(80.0);
2525
2526        assert_eq!(
2527            table.hydro_bounds_at_block(0, 1, 0).max_outflow_m3s,
2528            Some(650.0)
2529        );
2530        assert_eq!(
2531            table.hydro_bounds_at_block(0, 1, 1).max_diversion_m3s,
2532            Some(80.0)
2533        );
2534    }
2535
2536    /// The three widened axes (`min_diversion_m3s`, `min_spillage_m3s`,
2537    /// `max_spillage_m3s`) merge via `.or(...)`, matching every other
2538    /// `Option<f64>` block-eligible column: a base of `None` plus an overlay
2539    /// `Some` on block 1 resolves to that value at block 1 only; block 0,
2540    /// with no override, stays `None`.
2541    #[test]
2542    fn test_widened_axes_or_precedence() {
2543        let mut table = ResolvedBounds::new(
2544            &BoundsCountsSpec {
2545                n_hydros: 1,
2546                n_thermals: 0,
2547                n_lines: 0,
2548                n_pumping: 0,
2549                n_contracts: 0,
2550                n_stages: 1,
2551                k_max: 0,
2552            },
2553            &BoundsDefaults {
2554                hydro_block: HydroBlockBounds::default(),
2555                ..zero_defaults()
2556            },
2557        );
2558
2559        let mut block = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
2560            n_hydros: 1,
2561            n_thermals: 0,
2562            n_lines: 0,
2563            n_pumping: 0,
2564            n_contracts: 0,
2565            n_stages: 1,
2566            max_blocks: 2,
2567        });
2568        {
2569            let over = block
2570                .hydro_override_mut(0, 0, 1)
2571                .expect("in-range override cell");
2572            over.min_diversion_m3s = Some(3.0);
2573            over.min_spillage_m3s = Some(4.0);
2574            over.max_spillage_m3s = Some(5.0);
2575        }
2576        table.set_block_overlay(block);
2577
2578        let block_1 = table.hydro_bounds_at_block(0, 0, 1);
2579        assert_eq!(block_1.min_diversion_m3s, Some(3.0));
2580        assert_eq!(block_1.min_spillage_m3s, Some(4.0));
2581        assert_eq!(block_1.max_spillage_m3s, Some(5.0));
2582
2583        let block_0 = table.hydro_bounds_at_block(0, 0, 0);
2584        assert_eq!(block_0.min_diversion_m3s, None);
2585        assert_eq!(block_0.min_spillage_m3s, None);
2586        assert_eq!(block_0.max_spillage_m3s, None);
2587    }
2588
2589    #[cfg(feature = "serde")]
2590    #[test]
2591    fn test_resolved_bounds_wire_round_trip_with_overlay() {
2592        let hb = make_hydro_bounds();
2593        let hbl = make_hydro_block_bounds();
2594        let tb = ThermalStageBounds { cost_per_mwh: 20.0 };
2595        let tbb = ThermalBlockBounds {
2596            min_generation_mw: 0.0,
2597            max_generation_mw: 100.0,
2598        };
2599        let lb = LineBlockBounds {
2600            direct_mw: 500.0,
2601            reverse_mw: 500.0,
2602        };
2603        let pb = PumpingBlockBounds {
2604            min_flow_m3s: 0.0,
2605            max_flow_m3s: 20.0,
2606        };
2607        let cb = ContractBlockBounds {
2608            min_mw: 0.0,
2609            max_mw: 50.0,
2610            price_per_mwh: 80.0,
2611        };
2612
2613        let mut original = ResolvedBounds::new(
2614            &BoundsCountsSpec {
2615                n_hydros: 1,
2616                n_thermals: 1,
2617                n_lines: 1,
2618                n_pumping: 1,
2619                n_contracts: 1,
2620                n_stages: 2,
2621                k_max: 0,
2622            },
2623            &BoundsDefaults {
2624                hydro: hb,
2625                hydro_block: hbl,
2626                thermal: tb,
2627                thermal_block: tbb,
2628                line_block: lb,
2629                pumping_block: pb,
2630                contract_block: cb,
2631            },
2632        );
2633
2634        let mut block = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
2635            n_hydros: 1,
2636            n_thermals: 1,
2637            n_lines: 1,
2638            n_pumping: 1,
2639            n_contracts: 1,
2640            n_stages: 2,
2641            max_blocks: 2,
2642        });
2643        block
2644            .thermal_override_mut(0, 0, 0)
2645            .expect("in-range override cell")
2646            .max_generation_mw = Some(75.0);
2647        original.set_block_overlay(block);
2648
2649        let json = serde_json::to_string(&original).expect("serialize json");
2650        let restored_json: ResolvedBounds = serde_json::from_str(&json).expect("deserialize json");
2651        assert_eq!(original, restored_json);
2652
2653        let bytes = postcard::to_allocvec(&original).expect("serialize postcard");
2654        let restored_postcard: ResolvedBounds =
2655            postcard::from_bytes(&bytes).expect("deserialize postcard");
2656        assert_eq!(original, restored_postcard);
2657    }
2658
2659    #[cfg(feature = "serde")]
2660    #[test]
2661    fn test_resolved_bounds_wire_absent_overlay_defaults_to_empty() {
2662        let json = r#"{
2663            "n_stages": 1,
2664            "thermal_stage_axis_len": 1,
2665            "hydro": [],
2666            "thermal": [],
2667            "line": [],
2668            "pumping": [],
2669            "contract": []
2670        }"#;
2671        let restored: ResolvedBounds = serde_json::from_str(json).expect("deserialize");
2672        assert!(restored.block_overlay().is_empty());
2673    }
2674
2675    #[cfg(feature = "serde")]
2676    #[test]
2677    fn resolved_bounds_group_overlay_round_trips_and_defaults() {
2678        let mut original = ResolvedBounds::new(
2679            &BoundsCountsSpec {
2680                n_hydros: 1,
2681                n_thermals: 0,
2682                n_lines: 0,
2683                n_pumping: 0,
2684                n_contracts: 0,
2685                n_stages: 2,
2686                k_max: 0,
2687            },
2688            &BoundsDefaults {
2689                hydro: make_hydro_bounds(),
2690                ..zero_defaults()
2691            },
2692        );
2693
2694        let mut group = ResolvedHydroUnitGroupBounds::new(&HydroUnitGroupBoundsCountsSpec {
2695            groups_per_plant: &[2],
2696            n_stages: 2,
2697            max_blocks: 2,
2698        });
2699        group
2700            .stage_override_mut(0, 1, 1)
2701            .expect("in-range stage cell")
2702            .max_turbined_m3s = Some(40.0);
2703        group
2704            .block_override_mut(0, 1, 1, 0)
2705            .expect("in-range block cell")
2706            .max_turbined_m3s = Some(12.0);
2707        original.set_group_overlay(group);
2708
2709        let json = serde_json::to_string(&original).expect("serialize");
2710        let restored: ResolvedBounds = serde_json::from_str(&json).expect("deserialize");
2711
2712        for (hydro_idx, group_pos, stage_idx, block_idx) in
2713            [(0, 0, 0, 0), (0, 1, 1, 0), (0, 1, 1, 1), (0, 1, 0, 0)]
2714        {
2715            let original_over = original
2716                .group_overlay()
2717                .override_at_block(hydro_idx, group_pos, stage_idx, block_idx);
2718            let restored_over = restored
2719                .group_overlay()
2720                .override_at_block(hydro_idx, group_pos, stage_idx, block_idx);
2721            assert_eq!(
2722                original_over.max_turbined_m3s.map(f64::to_bits),
2723                restored_over.max_turbined_m3s.map(f64::to_bits),
2724                "mismatch at (h={hydro_idx}, g={group_pos}, t={stage_idx}, b={block_idx})"
2725            );
2726        }
2727
2728        let absent_group_json = r#"{
2729            "n_stages": 1,
2730            "thermal_stage_axis_len": 1,
2731            "hydro": [],
2732            "thermal": [],
2733            "line": [],
2734            "pumping": [],
2735            "contract": []
2736        }"#;
2737        let restored_without_group: ResolvedBounds =
2738            serde_json::from_str(absent_group_json).expect("deserialize without group field");
2739        assert!(restored_without_group.group_overlay().is_empty());
2740    }
2741}