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