Skip to main content

cobre_core/
resolved.rs

1//! Pre-resolved penalty and bound containers for O(1) solver lookup.
2//!
3//! During input loading, the three-tier cascade (global defaults → entity overrides
4//! → stage overrides) is evaluated once and the results stored in these containers.
5//! Solvers and LP builders then query resolved values in constant time via direct
6//! array indexing — no re-evaluation of the cascade at solve time.
7//!
8//! The storage layout for both [`ResolvedPenalties`] and [`ResolvedBounds`] uses a
9//! flat `Vec<T>` with manual 2D indexing:
10//! `data[entity_idx * n_stages + stage_idx]`
11//!
12//! This gives cache-friendly sequential access when iterating over stages for a
13//! fixed entity (the inner loop pattern used by LP builders).
14//!
15//! # Population
16//!
17//! These containers are populated by `cobre-io` during the penalty/bound resolution
18//! step. They are never modified after construction.
19//!
20//! # Note on deficit segments
21//!
22//! Bus deficit segments are **not** stage-varying (see Penalty System spec SS3).
23//! The piecewise structure is too complex for per-stage override. Therefore
24//! [`BusStagePenalties`] contains only `excess_cost`.
25
26// ─── Per-(entity, stage) penalty structs ─────────────────────────────────────
27
28/// All 11 hydro penalty values for a given (hydro, stage) pair.
29///
30/// This is the stage-resolved form of [`crate::HydroPenalties`]. All fields hold
31/// the final effective penalty after the full three-tier cascade has been applied.
32///
33/// # Examples
34///
35/// ```
36/// use cobre_core::resolved::HydroStagePenalties;
37///
38/// let p = HydroStagePenalties {
39///     spillage_cost: 0.01,
40///     diversion_cost: 0.02,
41///     fpha_turbined_cost: 0.03,
42///     storage_violation_below_cost: 1000.0,
43///     filling_target_violation_cost: 5000.0,
44///     turbined_violation_below_cost: 500.0,
45///     outflow_violation_below_cost: 500.0,
46///     outflow_violation_above_cost: 500.0,
47///     generation_violation_below_cost: 500.0,
48///     evaporation_violation_cost: 500.0,
49///     water_withdrawal_violation_cost: 500.0,
50/// };
51/// // Copy-semantics: can be passed by value
52/// let q = p;
53/// assert!((q.spillage_cost - 0.01).abs() < f64::EPSILON);
54/// ```
55#[derive(Debug, Clone, Copy, PartialEq)]
56#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
57pub struct HydroStagePenalties {
58    /// Spillage regularization cost \[$/m³/s\]. Prefer turbining over spilling.
59    pub spillage_cost: f64,
60    /// Diversion regularization cost \[$/m³/s\]. Prefer main-channel flow.
61    pub diversion_cost: f64,
62    /// FPHA turbined regularization cost \[$/`MWh`\]. Prevents interior FPHA solutions.
63    /// Must be `> spillage_cost` for FPHA hydros.
64    pub fpha_turbined_cost: f64,
65    /// Constraint-violation cost for storage below dead volume \[$/hm³\].
66    pub storage_violation_below_cost: f64,
67    /// Constraint-violation cost for missing the dead-volume filling target \[$/hm³\].
68    /// Must be the highest penalty in the system.
69    pub filling_target_violation_cost: f64,
70    /// Constraint-violation cost for turbined flow below minimum \[$/m³/s\].
71    pub turbined_violation_below_cost: f64,
72    /// Constraint-violation cost for outflow below environmental minimum \[$/m³/s\].
73    pub outflow_violation_below_cost: f64,
74    /// Constraint-violation cost for outflow above flood-control limit \[$/m³/s\].
75    pub outflow_violation_above_cost: f64,
76    /// Constraint-violation cost for generation below contractual minimum \[$/MW\].
77    pub generation_violation_below_cost: f64,
78    /// Constraint-violation cost for evaporation constraint violation \[$/mm\].
79    pub evaporation_violation_cost: f64,
80    /// Constraint-violation cost for unmet water withdrawal \[$/m³/s\].
81    pub water_withdrawal_violation_cost: f64,
82}
83
84/// Bus penalty values for a given (bus, stage) pair.
85///
86/// Contains only `excess_cost` because deficit segments are **not** stage-varying
87/// (Penalty System spec SS3). The piecewise-linear deficit structure is fixed at
88/// the entity or global level and applies uniformly across all stages.
89///
90/// # Examples
91///
92/// ```
93/// use cobre_core::resolved::BusStagePenalties;
94///
95/// let p = BusStagePenalties { excess_cost: 0.01 };
96/// let q = p; // Copy
97/// assert!((q.excess_cost - 0.01).abs() < f64::EPSILON);
98/// ```
99#[derive(Debug, Clone, Copy, PartialEq)]
100#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
101pub struct BusStagePenalties {
102    /// Excess generation absorption cost \[$/`MWh`\].
103    pub excess_cost: f64,
104}
105
106/// Line penalty values for a given (line, stage) pair.
107///
108/// # Examples
109///
110/// ```
111/// use cobre_core::resolved::LineStagePenalties;
112///
113/// let p = LineStagePenalties { exchange_cost: 0.5 };
114/// let q = p; // Copy
115/// assert!((q.exchange_cost - 0.5).abs() < f64::EPSILON);
116/// ```
117#[derive(Debug, Clone, Copy, PartialEq)]
118#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
119pub struct LineStagePenalties {
120    /// Flow regularization cost \[$/`MWh`\]. Discourages unnecessary exchange.
121    pub exchange_cost: f64,
122}
123
124/// Non-controllable source penalty values for a given (source, stage) pair.
125///
126/// # Examples
127///
128/// ```
129/// use cobre_core::resolved::NcsStagePenalties;
130///
131/// let p = NcsStagePenalties { curtailment_cost: 10.0 };
132/// let q = p; // Copy
133/// assert!((q.curtailment_cost - 10.0).abs() < f64::EPSILON);
134/// ```
135#[derive(Debug, Clone, Copy, PartialEq)]
136#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
137pub struct NcsStagePenalties {
138    /// Curtailment regularization cost \[$/`MWh`\]. Penalizes curtailing available generation.
139    pub curtailment_cost: f64,
140}
141
142// ─── Per-(entity, stage) bound structs ───────────────────────────────────────
143
144/// All hydro bound values for a given (hydro, stage) pair.
145///
146/// The 11 fields match the 11 rows in spec SS11 hydro bounds table. These are
147/// the fully resolved bounds after base values from `hydros.json` have been
148/// overlaid with any stage-specific overrides from `constraints/hydro_bounds.parquet`.
149///
150/// `max_outflow_m3s` is `Option<f64>` because the outflow upper bound may be absent
151/// (unbounded above) when no flood-control limit is defined for the hydro.
152/// `water_withdrawal_m3s` defaults to `0.0` when no per-stage override is present.
153///
154/// # Examples
155///
156/// ```
157/// use cobre_core::resolved::HydroStageBounds;
158///
159/// let b = HydroStageBounds {
160///     min_storage_hm3: 10.0,
161///     max_storage_hm3: 200.0,
162///     min_turbined_m3s: 0.0,
163///     max_turbined_m3s: 500.0,
164///     min_outflow_m3s: 5.0,
165///     max_outflow_m3s: None,
166///     min_generation_mw: 0.0,
167///     max_generation_mw: 100.0,
168///     max_diversion_m3s: None,
169///     filling_inflow_m3s: 0.0,
170///     water_withdrawal_m3s: 0.0,
171/// };
172/// assert!((b.min_storage_hm3 - 10.0).abs() < f64::EPSILON);
173/// assert!(b.max_outflow_m3s.is_none());
174/// ```
175#[derive(Debug, Clone, Copy, PartialEq)]
176#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
177pub struct HydroStageBounds {
178    /// Minimum reservoir storage — dead volume \[hm³\]. Soft lower bound;
179    /// violation uses `storage_violation_below` slack.
180    pub min_storage_hm3: f64,
181    /// Maximum reservoir storage — physical capacity \[hm³\]. Hard upper bound;
182    /// emergency spillage handles excess.
183    pub max_storage_hm3: f64,
184    /// Minimum turbined flow \[m³/s\]. Soft lower bound;
185    /// violation uses `turbined_violation_below` slack.
186    pub min_turbined_m3s: f64,
187    /// Maximum turbined flow \[m³/s\]. Hard upper bound.
188    pub max_turbined_m3s: f64,
189    /// Minimum outflow — environmental flow requirement \[m³/s\]. Soft lower bound;
190    /// violation uses `outflow_violation_below` slack.
191    pub min_outflow_m3s: f64,
192    /// Maximum outflow — flood-control limit \[m³/s\]. Soft upper bound;
193    /// violation uses `outflow_violation_above` slack. `None` = unbounded.
194    pub max_outflow_m3s: Option<f64>,
195    /// Minimum generation \[MW\]. Soft lower bound;
196    /// violation uses `generation_violation_below` slack.
197    pub min_generation_mw: f64,
198    /// Maximum generation \[MW\]. Hard upper bound.
199    pub max_generation_mw: f64,
200    /// Maximum diversion flow \[m³/s\]. Hard upper bound. `None` = no diversion channel.
201    pub max_diversion_m3s: Option<f64>,
202    /// Filling inflow retained for dead-volume filling during filling stages \[m³/s\].
203    /// Resolved from entity default → stage override cascade. Default `0.0`.
204    pub filling_inflow_m3s: f64,
205    /// Water withdrawal from reservoir per stage \[m³/s\]. Positive = water removed;
206    /// negative = external addition. Default `0.0`.
207    pub water_withdrawal_m3s: f64,
208}
209
210/// Thermal bound values for a given (thermal, stage) pair.
211///
212/// Resolved from base values in `thermals.json` with optional per-stage overrides
213/// from `constraints/thermal_bounds.parquet`.
214///
215/// # Examples
216///
217/// ```
218/// use cobre_core::resolved::ThermalStageBounds;
219///
220/// let b = ThermalStageBounds { min_generation_mw: 50.0, max_generation_mw: 400.0 };
221/// let c = b; // Copy
222/// assert!((c.max_generation_mw - 400.0).abs() < f64::EPSILON);
223/// ```
224#[derive(Debug, Clone, Copy, PartialEq)]
225#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
226pub struct ThermalStageBounds {
227    /// Minimum stable generation \[MW\]. Hard lower bound.
228    pub min_generation_mw: f64,
229    /// Maximum generation capacity \[MW\]. Hard upper bound.
230    pub max_generation_mw: f64,
231}
232
233/// Transmission line bound values for a given (line, stage) pair.
234///
235/// Resolved from base values in `lines.json` with optional per-stage overrides
236/// from `constraints/line_bounds.parquet`. Note that block-level exchange factors
237/// (per-block capacity multipliers) are stored separately and applied on top of
238/// these stage-level bounds at LP construction time.
239///
240/// # Examples
241///
242/// ```
243/// use cobre_core::resolved::LineStageBounds;
244///
245/// let b = LineStageBounds { direct_mw: 1000.0, reverse_mw: 800.0 };
246/// let c = b; // Copy
247/// assert!((c.direct_mw - 1000.0).abs() < f64::EPSILON);
248/// ```
249#[derive(Debug, Clone, Copy, PartialEq)]
250#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
251pub struct LineStageBounds {
252    /// Maximum direct flow capacity \[MW\]. Hard upper bound.
253    pub direct_mw: f64,
254    /// Maximum reverse flow capacity \[MW\]. Hard upper bound.
255    pub reverse_mw: f64,
256}
257
258/// Pumping station bound values for a given (pumping, stage) pair.
259///
260/// Resolved from base values in `pumping_stations.json` with optional per-stage
261/// overrides from `constraints/pumping_bounds.parquet`.
262///
263/// # Examples
264///
265/// ```
266/// use cobre_core::resolved::PumpingStageBounds;
267///
268/// let b = PumpingStageBounds { min_flow_m3s: 0.0, max_flow_m3s: 50.0 };
269/// let c = b; // Copy
270/// assert!((c.max_flow_m3s - 50.0).abs() < f64::EPSILON);
271/// ```
272#[derive(Debug, Clone, Copy, PartialEq)]
273#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
274pub struct PumpingStageBounds {
275    /// Minimum pumped flow \[m³/s\]. Hard lower bound.
276    pub min_flow_m3s: f64,
277    /// Maximum pumped flow \[m³/s\]. Hard upper bound.
278    pub max_flow_m3s: f64,
279}
280
281/// Energy contract bound values for a given (contract, stage) pair.
282///
283/// Resolved from base values in `energy_contracts.json` with optional per-stage
284/// overrides from `constraints/contract_bounds.parquet`. The price field can also
285/// be stage-varying.
286///
287/// # Examples
288///
289/// ```
290/// use cobre_core::resolved::ContractStageBounds;
291///
292/// let b = ContractStageBounds { min_mw: 0.0, max_mw: 200.0, price_per_mwh: 80.0 };
293/// let c = b; // Copy
294/// assert!((c.max_mw - 200.0).abs() < f64::EPSILON);
295/// ```
296#[derive(Debug, Clone, Copy, PartialEq)]
297#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
298pub struct ContractStageBounds {
299    /// Minimum contract usage \[MW\]. Hard lower bound.
300    pub min_mw: f64,
301    /// Maximum contract usage \[MW\]. Hard upper bound.
302    pub max_mw: f64,
303    /// Effective contract price \[$/`MWh`\]. May differ from base when a stage override
304    /// supplies a per-stage price.
305    pub price_per_mwh: f64,
306}
307
308// ─── Pre-resolved containers ──────────────────────────────────────────────────
309
310/// Pre-resolved penalty table for all entities across all stages.
311///
312/// Populated by `cobre-io` after the three-tier penalty cascade is applied.
313/// Provides O(1) lookup via direct array indexing.
314///
315/// Internal layout: `data[entity_idx * n_stages + stage_idx]` — iterating
316/// stages for a fixed entity accesses a contiguous memory region.
317///
318/// # Construction
319///
320/// Use [`ResolvedPenalties::new`] to allocate the table with a given default
321/// value, then populate by writing into the flat slice returned by the internal
322/// accessors. `cobre-io` is responsible for filling the data.
323///
324/// # Examples
325///
326/// ```
327/// use cobre_core::resolved::{
328///     BusStagePenalties, HydroStagePenalties, LineStagePenalties,
329///     NcsStagePenalties, ResolvedPenalties,
330/// };
331///
332/// let hydro_default = HydroStagePenalties {
333///     spillage_cost: 0.01,
334///     diversion_cost: 0.02,
335///     fpha_turbined_cost: 0.03,
336///     storage_violation_below_cost: 1000.0,
337///     filling_target_violation_cost: 5000.0,
338///     turbined_violation_below_cost: 500.0,
339///     outflow_violation_below_cost: 500.0,
340///     outflow_violation_above_cost: 500.0,
341///     generation_violation_below_cost: 500.0,
342///     evaporation_violation_cost: 500.0,
343///     water_withdrawal_violation_cost: 500.0,
344/// };
345/// let bus_default = BusStagePenalties { excess_cost: 100.0 };
346/// let line_default = LineStagePenalties { exchange_cost: 5.0 };
347/// let ncs_default = NcsStagePenalties { curtailment_cost: 50.0 };
348///
349/// let table = ResolvedPenalties::new(
350///     3, 2, 1, 4, 5,
351///     hydro_default, bus_default, line_default, ncs_default,
352/// );
353///
354/// // Hydro 1, stage 2 returns the default penalties.
355/// let p = table.hydro_penalties(1, 2);
356/// assert!((p.spillage_cost - 0.01).abs() < f64::EPSILON);
357/// ```
358#[derive(Debug, Clone, PartialEq)]
359#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
360pub struct ResolvedPenalties {
361    /// Total number of stages. Used to compute flat indices.
362    n_stages: usize,
363    /// Flat `n_hydros * n_stages` array indexed `[hydro_idx * n_stages + stage_idx]`.
364    hydro: Vec<HydroStagePenalties>,
365    /// Flat `n_buses * n_stages` array indexed `[bus_idx * n_stages + stage_idx]`.
366    bus: Vec<BusStagePenalties>,
367    /// Flat `n_lines * n_stages` array indexed `[line_idx * n_stages + stage_idx]`.
368    line: Vec<LineStagePenalties>,
369    /// Flat `n_ncs * n_stages` array indexed `[ncs_idx * n_stages + stage_idx]`.
370    ncs: Vec<NcsStagePenalties>,
371}
372
373impl ResolvedPenalties {
374    /// Return an empty penalty table with zero entities and zero stages.
375    ///
376    /// Used as the default value in [`System`](crate::System) when no penalty
377    /// resolution has been performed yet (e.g., when building a `System` from
378    /// raw entity collections without `cobre-io`).
379    ///
380    /// # Examples
381    ///
382    /// ```
383    /// use cobre_core::ResolvedPenalties;
384    ///
385    /// let empty = ResolvedPenalties::empty();
386    /// assert_eq!(empty.n_stages(), 0);
387    /// ```
388    #[must_use]
389    pub fn empty() -> Self {
390        Self {
391            n_stages: 0,
392            hydro: Vec::new(),
393            bus: Vec::new(),
394            line: Vec::new(),
395            ncs: Vec::new(),
396        }
397    }
398
399    /// Allocate a new resolved-penalties table filled with the given defaults.
400    ///
401    /// `n_stages` must be `> 0`. Entity counts may be `0` (empty vectors are valid).
402    ///
403    /// # Arguments
404    ///
405    /// * `n_hydros` — number of hydro plants
406    /// * `n_buses` — number of buses
407    /// * `n_lines` — number of transmission lines
408    /// * `n_ncs` — number of non-controllable sources
409    /// * `n_stages` — number of study stages
410    /// * `hydro_default` — initial value for all (hydro, stage) cells
411    /// * `bus_default` — initial value for all (bus, stage) cells
412    /// * `line_default` — initial value for all (line, stage) cells
413    /// * `ncs_default` — initial value for all (ncs, stage) cells
414    #[must_use]
415    #[allow(clippy::too_many_arguments)]
416    pub fn new(
417        n_hydros: usize,
418        n_buses: usize,
419        n_lines: usize,
420        n_ncs: usize,
421        n_stages: usize,
422        hydro_default: HydroStagePenalties,
423        bus_default: BusStagePenalties,
424        line_default: LineStagePenalties,
425        ncs_default: NcsStagePenalties,
426    ) -> Self {
427        Self {
428            n_stages,
429            hydro: vec![hydro_default; n_hydros * n_stages],
430            bus: vec![bus_default; n_buses * n_stages],
431            line: vec![line_default; n_lines * n_stages],
432            ncs: vec![ncs_default; n_ncs * n_stages],
433        }
434    }
435
436    /// Return the resolved penalties for a hydro plant at a specific stage.
437    #[inline]
438    #[must_use]
439    pub fn hydro_penalties(&self, hydro_index: usize, stage_index: usize) -> HydroStagePenalties {
440        self.hydro[hydro_index * self.n_stages + stage_index]
441    }
442
443    /// Return the resolved penalties for a bus at a specific stage.
444    #[inline]
445    #[must_use]
446    pub fn bus_penalties(&self, bus_index: usize, stage_index: usize) -> BusStagePenalties {
447        self.bus[bus_index * self.n_stages + stage_index]
448    }
449
450    /// Return the resolved penalties for a line at a specific stage.
451    #[inline]
452    #[must_use]
453    pub fn line_penalties(&self, line_index: usize, stage_index: usize) -> LineStagePenalties {
454        self.line[line_index * self.n_stages + stage_index]
455    }
456
457    /// Return the resolved penalties for a non-controllable source at a specific stage.
458    #[inline]
459    #[must_use]
460    pub fn ncs_penalties(&self, ncs_index: usize, stage_index: usize) -> NcsStagePenalties {
461        self.ncs[ncs_index * self.n_stages + stage_index]
462    }
463
464    /// Return a mutable reference to the hydro penalty cell for in-place update.
465    ///
466    /// Used by `cobre-io` during penalty cascade resolution to set resolved values.
467    #[inline]
468    pub fn hydro_penalties_mut(
469        &mut self,
470        hydro_index: usize,
471        stage_index: usize,
472    ) -> &mut HydroStagePenalties {
473        let idx = hydro_index * self.n_stages + stage_index;
474        &mut self.hydro[idx]
475    }
476
477    /// Return a mutable reference to the bus penalty cell for in-place update.
478    #[inline]
479    pub fn bus_penalties_mut(
480        &mut self,
481        bus_index: usize,
482        stage_index: usize,
483    ) -> &mut BusStagePenalties {
484        let idx = bus_index * self.n_stages + stage_index;
485        &mut self.bus[idx]
486    }
487
488    /// Return a mutable reference to the line penalty cell for in-place update.
489    #[inline]
490    pub fn line_penalties_mut(
491        &mut self,
492        line_index: usize,
493        stage_index: usize,
494    ) -> &mut LineStagePenalties {
495        let idx = line_index * self.n_stages + stage_index;
496        &mut self.line[idx]
497    }
498
499    /// Return a mutable reference to the NCS penalty cell for in-place update.
500    #[inline]
501    pub fn ncs_penalties_mut(
502        &mut self,
503        ncs_index: usize,
504        stage_index: usize,
505    ) -> &mut NcsStagePenalties {
506        let idx = ncs_index * self.n_stages + stage_index;
507        &mut self.ncs[idx]
508    }
509
510    /// Return the number of stages in this table.
511    #[inline]
512    #[must_use]
513    pub fn n_stages(&self) -> usize {
514        self.n_stages
515    }
516}
517
518/// Pre-resolved bound table for all entities across all stages.
519///
520/// Populated by `cobre-io` after base bounds are overlaid with stage-specific
521/// overrides. Provides O(1) lookup via direct array indexing.
522///
523/// Internal layout: `data[entity_idx * n_stages + stage_idx]`.
524///
525/// # Examples
526///
527/// ```
528/// use cobre_core::resolved::{
529///     ContractStageBounds, HydroStageBounds, LineStageBounds,
530///     PumpingStageBounds, ResolvedBounds, ThermalStageBounds,
531/// };
532///
533/// let hydro_default = HydroStageBounds {
534///     min_storage_hm3: 0.0, max_storage_hm3: 100.0,
535///     min_turbined_m3s: 0.0, max_turbined_m3s: 50.0,
536///     min_outflow_m3s: 0.0, max_outflow_m3s: None,
537///     min_generation_mw: 0.0, max_generation_mw: 30.0,
538///     max_diversion_m3s: None,
539///     filling_inflow_m3s: 0.0, water_withdrawal_m3s: 0.0,
540/// };
541/// let thermal_default = ThermalStageBounds { min_generation_mw: 0.0, max_generation_mw: 100.0 };
542/// let line_default = LineStageBounds { direct_mw: 500.0, reverse_mw: 500.0 };
543/// let pumping_default = PumpingStageBounds { min_flow_m3s: 0.0, max_flow_m3s: 20.0 };
544/// let contract_default = ContractStageBounds { min_mw: 0.0, max_mw: 50.0, price_per_mwh: 80.0 };
545///
546/// let table = ResolvedBounds::new(
547///     2, 1, 1, 1, 1, 3,
548///     hydro_default, thermal_default, line_default, pumping_default, contract_default,
549/// );
550///
551/// let b = table.hydro_bounds(0, 2);
552/// assert!((b.max_storage_hm3 - 100.0).abs() < f64::EPSILON);
553/// ```
554#[derive(Debug, Clone, PartialEq)]
555#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
556pub struct ResolvedBounds {
557    /// Total number of stages. Used to compute flat indices.
558    n_stages: usize,
559    /// Flat `n_hydros * n_stages` array indexed `[hydro_idx * n_stages + stage_idx]`.
560    hydro: Vec<HydroStageBounds>,
561    /// Flat `n_thermals * n_stages` array indexed `[thermal_idx * n_stages + stage_idx]`.
562    thermal: Vec<ThermalStageBounds>,
563    /// Flat `n_lines * n_stages` array indexed `[line_idx * n_stages + stage_idx]`.
564    line: Vec<LineStageBounds>,
565    /// Flat `n_pumping * n_stages` array indexed `[pumping_idx * n_stages + stage_idx]`.
566    pumping: Vec<PumpingStageBounds>,
567    /// Flat `n_contracts * n_stages` array indexed `[contract_idx * n_stages + stage_idx]`.
568    contract: Vec<ContractStageBounds>,
569}
570
571impl ResolvedBounds {
572    /// Return an empty bounds table with zero entities and zero stages.
573    ///
574    /// Used as the default value in [`System`](crate::System) when no bound
575    /// resolution has been performed yet (e.g., when building a `System` from
576    /// raw entity collections without `cobre-io`).
577    ///
578    /// # Examples
579    ///
580    /// ```
581    /// use cobre_core::ResolvedBounds;
582    ///
583    /// let empty = ResolvedBounds::empty();
584    /// assert_eq!(empty.n_stages(), 0);
585    /// ```
586    #[must_use]
587    pub fn empty() -> Self {
588        Self {
589            n_stages: 0,
590            hydro: Vec::new(),
591            thermal: Vec::new(),
592            line: Vec::new(),
593            pumping: Vec::new(),
594            contract: Vec::new(),
595        }
596    }
597
598    /// Allocate a new resolved-bounds table filled with the given defaults.
599    ///
600    /// `n_stages` must be `> 0`. Entity counts may be `0`.
601    ///
602    /// # Arguments
603    ///
604    /// * `n_hydros` — number of hydro plants
605    /// * `n_thermals` — number of thermal units
606    /// * `n_lines` — number of transmission lines
607    /// * `n_pumping` — number of pumping stations
608    /// * `n_contracts` — number of energy contracts
609    /// * `n_stages` — number of study stages
610    /// * `hydro_default` — initial value for all (hydro, stage) cells
611    /// * `thermal_default` — initial value for all (thermal, stage) cells
612    /// * `line_default` — initial value for all (line, stage) cells
613    /// * `pumping_default` — initial value for all (pumping, stage) cells
614    /// * `contract_default` — initial value for all (contract, stage) cells
615    #[must_use]
616    #[allow(clippy::too_many_arguments)]
617    pub fn new(
618        n_hydros: usize,
619        n_thermals: usize,
620        n_lines: usize,
621        n_pumping: usize,
622        n_contracts: usize,
623        n_stages: usize,
624        hydro_default: HydroStageBounds,
625        thermal_default: ThermalStageBounds,
626        line_default: LineStageBounds,
627        pumping_default: PumpingStageBounds,
628        contract_default: ContractStageBounds,
629    ) -> Self {
630        Self {
631            n_stages,
632            hydro: vec![hydro_default; n_hydros * n_stages],
633            thermal: vec![thermal_default; n_thermals * n_stages],
634            line: vec![line_default; n_lines * n_stages],
635            pumping: vec![pumping_default; n_pumping * n_stages],
636            contract: vec![contract_default; n_contracts * n_stages],
637        }
638    }
639
640    /// Return the resolved bounds for a hydro plant at a specific stage.
641    ///
642    /// Returns a shared reference to avoid copying the 11-field struct on hot paths.
643    ///
644    /// # Panics
645    ///
646    /// Panics in debug builds if `hydro_index >= n_hydros` or `stage_index >= n_stages`.
647    #[inline]
648    #[must_use]
649    pub fn hydro_bounds(&self, hydro_index: usize, stage_index: usize) -> &HydroStageBounds {
650        &self.hydro[hydro_index * self.n_stages + stage_index]
651    }
652
653    /// Return the resolved bounds for a thermal unit at a specific stage.
654    #[inline]
655    #[must_use]
656    pub fn thermal_bounds(&self, thermal_index: usize, stage_index: usize) -> ThermalStageBounds {
657        self.thermal[thermal_index * self.n_stages + stage_index]
658    }
659
660    /// Return the resolved bounds for a transmission line at a specific stage.
661    #[inline]
662    #[must_use]
663    pub fn line_bounds(&self, line_index: usize, stage_index: usize) -> LineStageBounds {
664        self.line[line_index * self.n_stages + stage_index]
665    }
666
667    /// Return the resolved bounds for a pumping station at a specific stage.
668    #[inline]
669    #[must_use]
670    pub fn pumping_bounds(&self, pumping_index: usize, stage_index: usize) -> PumpingStageBounds {
671        self.pumping[pumping_index * self.n_stages + stage_index]
672    }
673
674    /// Return the resolved bounds for an energy contract at a specific stage.
675    #[inline]
676    #[must_use]
677    pub fn contract_bounds(
678        &self,
679        contract_index: usize,
680        stage_index: usize,
681    ) -> ContractStageBounds {
682        self.contract[contract_index * self.n_stages + stage_index]
683    }
684
685    /// Return a mutable reference to the hydro bounds cell for in-place update.
686    ///
687    /// Used by `cobre-io` during bound resolution to set stage-specific overrides.
688    #[inline]
689    pub fn hydro_bounds_mut(
690        &mut self,
691        hydro_index: usize,
692        stage_index: usize,
693    ) -> &mut HydroStageBounds {
694        let idx = hydro_index * self.n_stages + stage_index;
695        &mut self.hydro[idx]
696    }
697
698    /// Return a mutable reference to the thermal bounds cell for in-place update.
699    #[inline]
700    pub fn thermal_bounds_mut(
701        &mut self,
702        thermal_index: usize,
703        stage_index: usize,
704    ) -> &mut ThermalStageBounds {
705        let idx = thermal_index * self.n_stages + stage_index;
706        &mut self.thermal[idx]
707    }
708
709    /// Return a mutable reference to the line bounds cell for in-place update.
710    #[inline]
711    pub fn line_bounds_mut(
712        &mut self,
713        line_index: usize,
714        stage_index: usize,
715    ) -> &mut LineStageBounds {
716        let idx = line_index * self.n_stages + stage_index;
717        &mut self.line[idx]
718    }
719
720    /// Return a mutable reference to the pumping bounds cell for in-place update.
721    #[inline]
722    pub fn pumping_bounds_mut(
723        &mut self,
724        pumping_index: usize,
725        stage_index: usize,
726    ) -> &mut PumpingStageBounds {
727        let idx = pumping_index * self.n_stages + stage_index;
728        &mut self.pumping[idx]
729    }
730
731    /// Return a mutable reference to the contract bounds cell for in-place update.
732    #[inline]
733    pub fn contract_bounds_mut(
734        &mut self,
735        contract_index: usize,
736        stage_index: usize,
737    ) -> &mut ContractStageBounds {
738        let idx = contract_index * self.n_stages + stage_index;
739        &mut self.contract[idx]
740    }
741
742    /// Return the number of stages in this table.
743    #[inline]
744    #[must_use]
745    pub fn n_stages(&self) -> usize {
746        self.n_stages
747    }
748}
749
750// ─── Tests ────────────────────────────────────────────────────────────────────
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755
756    fn make_hydro_penalties() -> HydroStagePenalties {
757        HydroStagePenalties {
758            spillage_cost: 0.01,
759            diversion_cost: 0.02,
760            fpha_turbined_cost: 0.03,
761            storage_violation_below_cost: 1000.0,
762            filling_target_violation_cost: 5000.0,
763            turbined_violation_below_cost: 500.0,
764            outflow_violation_below_cost: 400.0,
765            outflow_violation_above_cost: 300.0,
766            generation_violation_below_cost: 200.0,
767            evaporation_violation_cost: 150.0,
768            water_withdrawal_violation_cost: 100.0,
769        }
770    }
771
772    fn make_hydro_bounds() -> HydroStageBounds {
773        HydroStageBounds {
774            min_storage_hm3: 10.0,
775            max_storage_hm3: 200.0,
776            min_turbined_m3s: 0.0,
777            max_turbined_m3s: 500.0,
778            min_outflow_m3s: 5.0,
779            max_outflow_m3s: None,
780            min_generation_mw: 0.0,
781            max_generation_mw: 100.0,
782            max_diversion_m3s: None,
783            filling_inflow_m3s: 0.0,
784            water_withdrawal_m3s: 0.0,
785        }
786    }
787
788    #[test]
789    fn test_hydro_stage_penalties_copy() {
790        let p = make_hydro_penalties();
791        let q = p;
792        let r = p;
793        assert_eq!(q, r);
794        assert!((q.spillage_cost - p.spillage_cost).abs() < f64::EPSILON);
795    }
796
797    #[test]
798    fn test_all_penalty_structs_are_copy() {
799        let bp = BusStagePenalties { excess_cost: 1.0 };
800        let lp = LineStagePenalties { exchange_cost: 2.0 };
801        let np = NcsStagePenalties {
802            curtailment_cost: 3.0,
803        };
804
805        assert_eq!(bp, bp);
806        assert_eq!(lp, lp);
807        assert_eq!(np, np);
808        let bp2 = bp;
809        let lp2 = lp;
810        let np2 = np;
811        assert!((bp2.excess_cost - 1.0).abs() < f64::EPSILON);
812        assert!((lp2.exchange_cost - 2.0).abs() < f64::EPSILON);
813        assert!((np2.curtailment_cost - 3.0).abs() < f64::EPSILON);
814    }
815
816    #[test]
817    fn test_all_bound_structs_are_copy() {
818        let hb = make_hydro_bounds();
819        let tb = ThermalStageBounds {
820            min_generation_mw: 0.0,
821            max_generation_mw: 100.0,
822        };
823        let lb = LineStageBounds {
824            direct_mw: 500.0,
825            reverse_mw: 500.0,
826        };
827        let pb = PumpingStageBounds {
828            min_flow_m3s: 0.0,
829            max_flow_m3s: 20.0,
830        };
831        let cb = ContractStageBounds {
832            min_mw: 0.0,
833            max_mw: 50.0,
834            price_per_mwh: 80.0,
835        };
836
837        let hb2 = hb;
838        let tb2 = tb;
839        let lb2 = lb;
840        let pb2 = pb;
841        let cb2 = cb;
842        assert_eq!(hb, hb2);
843        assert_eq!(tb, tb2);
844        assert_eq!(lb, lb2);
845        assert_eq!(pb, pb2);
846        assert_eq!(cb, cb2);
847    }
848
849    #[test]
850    fn test_resolved_penalties_construction() {
851        // 2 hydros, 1 bus, 1 line, 1 ncs, 3 stages
852        let hp = make_hydro_penalties();
853        let bp = BusStagePenalties { excess_cost: 100.0 };
854        let lp = LineStagePenalties { exchange_cost: 5.0 };
855        let np = NcsStagePenalties {
856            curtailment_cost: 50.0,
857        };
858
859        let table = ResolvedPenalties::new(2, 1, 1, 1, 3, hp, bp, lp, np);
860
861        for hydro_idx in 0..2 {
862            for stage_idx in 0..3 {
863                let p = table.hydro_penalties(hydro_idx, stage_idx);
864                assert!((p.spillage_cost - 0.01).abs() < f64::EPSILON);
865                assert!((p.storage_violation_below_cost - 1000.0).abs() < f64::EPSILON);
866            }
867        }
868
869        assert!((table.bus_penalties(0, 0).excess_cost - 100.0).abs() < f64::EPSILON);
870        assert!((table.line_penalties(0, 1).exchange_cost - 5.0).abs() < f64::EPSILON);
871        assert!((table.ncs_penalties(0, 2).curtailment_cost - 50.0).abs() < f64::EPSILON);
872    }
873
874    #[test]
875    fn test_resolved_penalties_indexed_access() {
876        let hp = make_hydro_penalties();
877        let bp = BusStagePenalties { excess_cost: 10.0 };
878        let lp = LineStagePenalties { exchange_cost: 1.0 };
879        let np = NcsStagePenalties {
880            curtailment_cost: 5.0,
881        };
882
883        let table = ResolvedPenalties::new(3, 0, 0, 0, 5, hp, bp, lp, np);
884        assert_eq!(table.n_stages(), 5);
885
886        let p = table.hydro_penalties(1, 3);
887        assert!((p.diversion_cost - 0.02).abs() < f64::EPSILON);
888        assert!((p.filling_target_violation_cost - 5000.0).abs() < f64::EPSILON);
889    }
890
891    #[test]
892    fn test_resolved_penalties_mutable_update() {
893        let hp = make_hydro_penalties();
894        let bp = BusStagePenalties { excess_cost: 10.0 };
895        let lp = LineStagePenalties { exchange_cost: 1.0 };
896        let np = NcsStagePenalties {
897            curtailment_cost: 5.0,
898        };
899
900        let mut table = ResolvedPenalties::new(2, 2, 1, 1, 3, hp, bp, lp, np);
901
902        table.hydro_penalties_mut(0, 1).spillage_cost = 99.0;
903
904        assert!((table.hydro_penalties(0, 1).spillage_cost - 99.0).abs() < f64::EPSILON);
905        assert!((table.hydro_penalties(0, 0).spillage_cost - 0.01).abs() < f64::EPSILON);
906        assert!((table.hydro_penalties(1, 1).spillage_cost - 0.01).abs() < f64::EPSILON);
907
908        table.bus_penalties_mut(1, 2).excess_cost = 999.0;
909        assert!((table.bus_penalties(1, 2).excess_cost - 999.0).abs() < f64::EPSILON);
910        assert!((table.bus_penalties(0, 2).excess_cost - 10.0).abs() < f64::EPSILON);
911    }
912
913    #[test]
914    fn test_resolved_bounds_construction() {
915        let hb = make_hydro_bounds();
916        let tb = ThermalStageBounds {
917            min_generation_mw: 50.0,
918            max_generation_mw: 400.0,
919        };
920        let lb = LineStageBounds {
921            direct_mw: 1000.0,
922            reverse_mw: 800.0,
923        };
924        let pb = PumpingStageBounds {
925            min_flow_m3s: 0.0,
926            max_flow_m3s: 20.0,
927        };
928        let cb = ContractStageBounds {
929            min_mw: 0.0,
930            max_mw: 100.0,
931            price_per_mwh: 80.0,
932        };
933
934        let table = ResolvedBounds::new(1, 2, 1, 1, 1, 3, hb, tb, lb, pb, cb);
935
936        let b = table.hydro_bounds(0, 2);
937        assert!((b.min_storage_hm3 - 10.0).abs() < f64::EPSILON);
938        assert!((b.max_storage_hm3 - 200.0).abs() < f64::EPSILON);
939        assert!(b.max_outflow_m3s.is_none());
940        assert!(b.max_diversion_m3s.is_none());
941
942        let t0 = table.thermal_bounds(0, 0);
943        let t1 = table.thermal_bounds(1, 2);
944        assert!((t0.max_generation_mw - 400.0).abs() < f64::EPSILON);
945        assert!((t1.min_generation_mw - 50.0).abs() < f64::EPSILON);
946
947        assert!((table.line_bounds(0, 1).direct_mw - 1000.0).abs() < f64::EPSILON);
948        assert!((table.pumping_bounds(0, 0).max_flow_m3s - 20.0).abs() < f64::EPSILON);
949        assert!((table.contract_bounds(0, 2).price_per_mwh - 80.0).abs() < f64::EPSILON);
950    }
951
952    #[test]
953    fn test_resolved_bounds_mutable_update() {
954        let hb = make_hydro_bounds();
955        let tb = ThermalStageBounds {
956            min_generation_mw: 0.0,
957            max_generation_mw: 200.0,
958        };
959        let lb = LineStageBounds {
960            direct_mw: 500.0,
961            reverse_mw: 500.0,
962        };
963        let pb = PumpingStageBounds {
964            min_flow_m3s: 0.0,
965            max_flow_m3s: 30.0,
966        };
967        let cb = ContractStageBounds {
968            min_mw: 0.0,
969            max_mw: 50.0,
970            price_per_mwh: 60.0,
971        };
972
973        let mut table = ResolvedBounds::new(2, 1, 1, 1, 1, 3, hb, tb, lb, pb, cb);
974
975        let cell = table.hydro_bounds_mut(1, 0);
976        cell.min_storage_hm3 = 25.0;
977        cell.max_outflow_m3s = Some(1000.0);
978
979        assert!((table.hydro_bounds(1, 0).min_storage_hm3 - 25.0).abs() < f64::EPSILON);
980        assert_eq!(table.hydro_bounds(1, 0).max_outflow_m3s, Some(1000.0));
981        assert!((table.hydro_bounds(0, 0).min_storage_hm3 - 10.0).abs() < f64::EPSILON);
982        assert!(table.hydro_bounds(1, 1).max_outflow_m3s.is_none());
983
984        table.thermal_bounds_mut(0, 2).max_generation_mw = 150.0;
985        assert!((table.thermal_bounds(0, 2).max_generation_mw - 150.0).abs() < f64::EPSILON);
986        assert!((table.thermal_bounds(0, 0).max_generation_mw - 200.0).abs() < f64::EPSILON);
987    }
988
989    #[test]
990    fn test_hydro_stage_bounds_has_eleven_fields() {
991        let b = HydroStageBounds {
992            min_storage_hm3: 1.0,
993            max_storage_hm3: 2.0,
994            min_turbined_m3s: 3.0,
995            max_turbined_m3s: 4.0,
996            min_outflow_m3s: 5.0,
997            max_outflow_m3s: Some(6.0),
998            min_generation_mw: 7.0,
999            max_generation_mw: 8.0,
1000            max_diversion_m3s: Some(9.0),
1001            filling_inflow_m3s: 10.0,
1002            water_withdrawal_m3s: 11.0,
1003        };
1004        assert!((b.min_storage_hm3 - 1.0).abs() < f64::EPSILON);
1005        assert!((b.water_withdrawal_m3s - 11.0).abs() < f64::EPSILON);
1006        assert_eq!(b.max_outflow_m3s, Some(6.0));
1007        assert_eq!(b.max_diversion_m3s, Some(9.0));
1008    }
1009
1010    #[test]
1011    #[cfg(feature = "serde")]
1012    fn test_resolved_penalties_serde_roundtrip() {
1013        let hp = make_hydro_penalties();
1014        let bp = BusStagePenalties { excess_cost: 100.0 };
1015        let lp = LineStagePenalties { exchange_cost: 5.0 };
1016        let np = NcsStagePenalties {
1017            curtailment_cost: 50.0,
1018        };
1019
1020        let original = ResolvedPenalties::new(2, 1, 1, 1, 3, hp, bp, lp, np);
1021        let json = serde_json::to_string(&original).expect("serialize");
1022        let restored: ResolvedPenalties = serde_json::from_str(&json).expect("deserialize");
1023        assert_eq!(original, restored);
1024    }
1025
1026    #[test]
1027    #[cfg(feature = "serde")]
1028    fn test_resolved_bounds_serde_roundtrip() {
1029        let hb = make_hydro_bounds();
1030        let tb = ThermalStageBounds {
1031            min_generation_mw: 0.0,
1032            max_generation_mw: 100.0,
1033        };
1034        let lb = LineStageBounds {
1035            direct_mw: 500.0,
1036            reverse_mw: 500.0,
1037        };
1038        let pb = PumpingStageBounds {
1039            min_flow_m3s: 0.0,
1040            max_flow_m3s: 20.0,
1041        };
1042        let cb = ContractStageBounds {
1043            min_mw: 0.0,
1044            max_mw: 50.0,
1045            price_per_mwh: 80.0,
1046        };
1047
1048        let original = ResolvedBounds::new(1, 1, 1, 1, 1, 3, hb, tb, lb, pb, cb);
1049        let json = serde_json::to_string(&original).expect("serialize");
1050        let restored: ResolvedBounds = serde_json::from_str(&json).expect("deserialize");
1051        assert_eq!(original, restored);
1052    }
1053}