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}