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
26use std::collections::HashMap;
27use std::ops::Range;
28
29// ─── Per-(entity, stage) penalty structs ─────────────────────────────────────
30
31/// All 16 hydro penalty values for a given (hydro, stage) pair.
32///
33/// This is the stage-resolved form of [`crate::HydroPenalties`]. All fields hold
34/// the final effective penalty after the full three-tier cascade has been applied.
35///
36/// # Examples
37///
38/// ```
39/// use cobre_core::resolved::HydroStagePenalties;
40///
41/// let p = HydroStagePenalties {
42///     spillage_cost: 0.01,
43///     diversion_cost: 0.02,
44///     fpha_turbined_cost: 0.03,
45///     storage_violation_below_cost: 1000.0,
46///     filling_target_violation_cost: 5000.0,
47///     turbined_violation_below_cost: 500.0,
48///     outflow_violation_below_cost: 500.0,
49///     outflow_violation_above_cost: 500.0,
50///     generation_violation_below_cost: 500.0,
51///     evaporation_violation_cost: 500.0,
52///     water_withdrawal_violation_cost: 500.0,
53///     water_withdrawal_violation_pos_cost: 500.0,
54///     water_withdrawal_violation_neg_cost: 500.0,
55///     evaporation_violation_pos_cost: 500.0,
56///     evaporation_violation_neg_cost: 500.0,
57///     inflow_nonnegativity_cost: 1000.0,
58/// };
59/// // Copy-semantics: can be passed by value
60/// let q = p;
61/// assert!((q.spillage_cost - 0.01).abs() < f64::EPSILON);
62/// ```
63// Field count is 16 — update the doc line above when adding/removing fields.
64#[derive(Debug, Clone, Copy, PartialEq)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66pub struct HydroStagePenalties {
67    /// Spillage regularization cost \[$/m³/s\]. Prefer turbining over spilling.
68    pub spillage_cost: f64,
69    /// Diversion regularization cost \[$/m³/s\]. Prefer main-channel flow.
70    pub diversion_cost: f64,
71    /// FPHA turbined regularization cost \[$/`MWh`\]. Prevents interior FPHA solutions.
72    /// Must be `> spillage_cost` for FPHA hydros.
73    pub fpha_turbined_cost: f64,
74    /// Constraint-violation cost for storage below dead volume \[$/hm³\].
75    pub storage_violation_below_cost: f64,
76    /// Constraint-violation cost for missing the dead-volume filling target \[$/hm³\].
77    /// Must be the highest penalty in the system.
78    pub filling_target_violation_cost: f64,
79    /// Constraint-violation cost for turbined flow below minimum \[$/m³/s\].
80    pub turbined_violation_below_cost: f64,
81    /// Constraint-violation cost for outflow below environmental minimum \[$/m³/s\].
82    pub outflow_violation_below_cost: f64,
83    /// Constraint-violation cost for outflow above flood-control limit \[$/m³/s\].
84    pub outflow_violation_above_cost: f64,
85    /// Constraint-violation cost for generation below contractual minimum \[$/MW\].
86    pub generation_violation_below_cost: f64,
87    /// Constraint-violation cost for evaporation constraint violation \[$/mm\].
88    pub evaporation_violation_cost: f64,
89    /// Constraint-violation cost for unmet water withdrawal \[$/m³/s\].
90    pub water_withdrawal_violation_cost: f64,
91    /// Constraint-violation cost for over-withdrawal (withdrew more than target) \[$/m³/s\].
92    pub water_withdrawal_violation_pos_cost: f64,
93    /// Constraint-violation cost for under-withdrawal (withdrew less than target) \[$/m³/s\].
94    pub water_withdrawal_violation_neg_cost: f64,
95    /// Constraint-violation cost for over-evaporation \[$/mm\].
96    pub evaporation_violation_pos_cost: f64,
97    /// Constraint-violation cost for under-evaporation \[$/mm\].
98    pub evaporation_violation_neg_cost: f64,
99    /// Constraint-violation cost for inflow non-negativity slack \[$/m³/s\].
100    pub inflow_nonnegativity_cost: f64,
101}
102
103/// Bus penalty values for a given (bus, stage) pair.
104///
105/// Contains only `excess_cost` because deficit segments are **not** stage-varying
106/// (Penalty System spec SS3). The piecewise-linear deficit structure is fixed at
107/// the entity or global level and applies uniformly across all stages.
108///
109/// # Examples
110///
111/// ```
112/// use cobre_core::resolved::BusStagePenalties;
113///
114/// let p = BusStagePenalties { excess_cost: 0.01 };
115/// let q = p; // Copy
116/// assert!((q.excess_cost - 0.01).abs() < f64::EPSILON);
117/// ```
118#[derive(Debug, Clone, Copy, PartialEq)]
119#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
120pub struct BusStagePenalties {
121    /// Excess generation absorption cost \[$/`MWh`\].
122    pub excess_cost: f64,
123}
124
125/// Line penalty values for a given (line, stage) pair.
126///
127/// # Examples
128///
129/// ```
130/// use cobre_core::resolved::LineStagePenalties;
131///
132/// let p = LineStagePenalties { exchange_cost: 0.5 };
133/// let q = p; // Copy
134/// assert!((q.exchange_cost - 0.5).abs() < f64::EPSILON);
135/// ```
136#[derive(Debug, Clone, Copy, PartialEq)]
137#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
138pub struct LineStagePenalties {
139    /// Flow regularization cost \[$/`MWh`\]. Discourages unnecessary exchange.
140    pub exchange_cost: f64,
141}
142
143/// Non-controllable source penalty values for a given (source, stage) pair.
144///
145/// # Examples
146///
147/// ```
148/// use cobre_core::resolved::NcsStagePenalties;
149///
150/// let p = NcsStagePenalties { curtailment_cost: 10.0 };
151/// let q = p; // Copy
152/// assert!((q.curtailment_cost - 10.0).abs() < f64::EPSILON);
153/// ```
154#[derive(Debug, Clone, Copy, PartialEq)]
155#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
156pub struct NcsStagePenalties {
157    /// Curtailment regularization cost \[$/`MWh`\]. Penalizes curtailing available generation.
158    pub curtailment_cost: f64,
159}
160
161// ─── Per-(entity, stage) bound structs ───────────────────────────────────────
162
163/// All hydro bound values for a given (hydro, stage) pair.
164///
165/// The 11 fields match the 11 rows in spec SS11 hydro bounds table. These are
166/// the fully resolved bounds after base values from `hydros.json` have been
167/// overlaid with any stage-specific overrides from `constraints/hydro_bounds.parquet`.
168///
169/// `max_outflow_m3s` is `Option<f64>` because the outflow upper bound may be absent
170/// (unbounded above) when no flood-control limit is defined for the hydro.
171/// `water_withdrawal_m3s` defaults to `0.0` when no per-stage override is present.
172///
173/// # Examples
174///
175/// ```
176/// use cobre_core::resolved::HydroStageBounds;
177///
178/// let b = HydroStageBounds {
179///     min_storage_hm3: 10.0,
180///     max_storage_hm3: 200.0,
181///     min_turbined_m3s: 0.0,
182///     max_turbined_m3s: 500.0,
183///     min_outflow_m3s: 5.0,
184///     max_outflow_m3s: None,
185///     min_generation_mw: 0.0,
186///     max_generation_mw: 100.0,
187///     max_diversion_m3s: None,
188///     filling_inflow_m3s: 0.0,
189///     water_withdrawal_m3s: 0.0,
190/// };
191/// assert!((b.min_storage_hm3 - 10.0).abs() < f64::EPSILON);
192/// assert!(b.max_outflow_m3s.is_none());
193/// ```
194#[derive(Debug, Clone, Copy, PartialEq)]
195#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
196pub struct HydroStageBounds {
197    /// Minimum reservoir storage — dead volume \[hm³\]. Soft lower bound;
198    /// violation uses `storage_violation_below` slack.
199    pub min_storage_hm3: f64,
200    /// Maximum reservoir storage — physical capacity \[hm³\]. Hard upper bound;
201    /// emergency spillage handles excess.
202    pub max_storage_hm3: f64,
203    /// Minimum turbined flow \[m³/s\]. Soft lower bound;
204    /// violation uses `turbined_violation_below` slack.
205    pub min_turbined_m3s: f64,
206    /// Maximum turbined flow \[m³/s\]. Hard upper bound.
207    pub max_turbined_m3s: f64,
208    /// Minimum outflow — environmental flow requirement \[m³/s\]. Soft lower bound;
209    /// violation uses `outflow_violation_below` slack.
210    pub min_outflow_m3s: f64,
211    /// Maximum outflow — flood-control limit \[m³/s\]. Soft upper bound;
212    /// violation uses `outflow_violation_above` slack. `None` = unbounded.
213    pub max_outflow_m3s: Option<f64>,
214    /// Minimum generation \[MW\]. Soft lower bound;
215    /// violation uses `generation_violation_below` slack.
216    pub min_generation_mw: f64,
217    /// Maximum generation \[MW\]. Hard upper bound.
218    pub max_generation_mw: f64,
219    /// Maximum diversion flow \[m³/s\]. Hard upper bound. `None` = no diversion channel.
220    pub max_diversion_m3s: Option<f64>,
221    /// Filling inflow retained for dead-volume filling during filling stages \[m³/s\].
222    /// Resolved from entity default → stage override cascade. Default `0.0`.
223    pub filling_inflow_m3s: f64,
224    /// Water withdrawal from reservoir per stage \[m³/s\]. Positive = water removed;
225    /// negative = external addition. Default `0.0`.
226    pub water_withdrawal_m3s: f64,
227}
228
229/// Thermal bound values for a given (thermal, stage) pair.
230///
231/// Resolved from base values in `thermals.json` with optional per-stage overrides
232/// from `constraints/thermal_bounds.parquet`.
233///
234/// # Examples
235///
236/// ```
237/// use cobre_core::resolved::ThermalStageBounds;
238///
239/// let b = ThermalStageBounds { min_generation_mw: 50.0, max_generation_mw: 400.0, cost_per_mwh: 120.0 };
240/// let c = b; // Copy
241/// assert!((c.max_generation_mw - 400.0).abs() < f64::EPSILON);
242/// assert!((c.cost_per_mwh - 120.0).abs() < f64::EPSILON);
243/// ```
244#[derive(Debug, Clone, Copy, PartialEq)]
245#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
246pub struct ThermalStageBounds {
247    /// Minimum stable generation \[MW\]. Hard lower bound.
248    pub min_generation_mw: f64,
249    /// Maximum generation capacity \[MW\]. Hard upper bound.
250    pub max_generation_mw: f64,
251    /// Dispatch cost override (`$/MWh`). Resolved from `Thermal.cost_per_mwh` with optional
252    /// per-stage override from `constraints/thermal_bounds.parquet` (null `block_id` rows only).
253    pub cost_per_mwh: f64,
254}
255
256/// Transmission line bound values for a given (line, stage) pair.
257///
258/// Resolved from base values in `lines.json` with optional per-stage overrides
259/// from `constraints/line_bounds.parquet`. Note that block-level exchange factors
260/// (per-block capacity multipliers) are stored separately and applied on top of
261/// these stage-level bounds at LP construction time.
262///
263/// # Examples
264///
265/// ```
266/// use cobre_core::resolved::LineStageBounds;
267///
268/// let b = LineStageBounds { direct_mw: 1000.0, reverse_mw: 800.0 };
269/// let c = b; // Copy
270/// assert!((c.direct_mw - 1000.0).abs() < f64::EPSILON);
271/// ```
272#[derive(Debug, Clone, Copy, PartialEq)]
273#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
274pub struct LineStageBounds {
275    /// Maximum direct flow capacity \[MW\]. Hard upper bound.
276    pub direct_mw: f64,
277    /// Maximum reverse flow capacity \[MW\]. Hard upper bound.
278    pub reverse_mw: f64,
279}
280
281/// Pumping station bound values for a given (pumping, stage) pair.
282///
283/// Resolved from base values in `pumping_stations.json` with optional per-stage
284/// overrides from `constraints/pumping_bounds.parquet`.
285///
286/// # Examples
287///
288/// ```
289/// use cobre_core::resolved::PumpingStageBounds;
290///
291/// let b = PumpingStageBounds { min_flow_m3s: 0.0, max_flow_m3s: 50.0 };
292/// let c = b; // Copy
293/// assert!((c.max_flow_m3s - 50.0).abs() < f64::EPSILON);
294/// ```
295#[derive(Debug, Clone, Copy, PartialEq)]
296#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
297pub struct PumpingStageBounds {
298    /// Minimum pumped flow \[m³/s\]. Hard lower bound.
299    pub min_flow_m3s: f64,
300    /// Maximum pumped flow \[m³/s\]. Hard upper bound.
301    pub max_flow_m3s: f64,
302}
303
304/// Energy contract bound values for a given (contract, stage) pair.
305///
306/// Resolved from base values in `energy_contracts.json` with optional per-stage
307/// overrides from `constraints/contract_bounds.parquet`. The price field can also
308/// be stage-varying.
309///
310/// # Examples
311///
312/// ```
313/// use cobre_core::resolved::ContractStageBounds;
314///
315/// let b = ContractStageBounds { min_mw: 0.0, max_mw: 200.0, price_per_mwh: 80.0 };
316/// let c = b; // Copy
317/// assert!((c.max_mw - 200.0).abs() < f64::EPSILON);
318/// ```
319#[derive(Debug, Clone, Copy, PartialEq)]
320#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
321pub struct ContractStageBounds {
322    /// Minimum contract usage \[MW\]. Hard lower bound.
323    pub min_mw: f64,
324    /// Maximum contract usage \[MW\]. Hard upper bound.
325    pub max_mw: f64,
326    /// Effective contract price \[$/`MWh`\]. May differ from base when a stage override
327    /// supplies a per-stage price.
328    pub price_per_mwh: f64,
329}
330
331// ─── Pre-resolved containers ──────────────────────────────────────────────────
332
333/// Pre-resolved penalty table for all entities across all stages.
334///
335/// Populated by `cobre-io` after the three-tier penalty cascade is applied.
336/// Provides O(1) lookup via direct array indexing.
337///
338/// Internal layout: `data[entity_idx * n_stages + stage_idx]` — iterating
339/// stages for a fixed entity accesses a contiguous memory region.
340///
341/// # Construction
342///
343/// Use [`ResolvedPenalties::new`] to allocate the table with a given default
344/// value, then populate by writing into the flat slice returned by the internal
345/// accessors. `cobre-io` is responsible for filling the data.
346///
347/// # Examples
348///
349/// ```
350/// use cobre_core::resolved::{
351///     BusStagePenalties, HydroStagePenalties, LineStagePenalties,
352///     NcsStagePenalties, PenaltiesCountsSpec, PenaltiesDefaults, ResolvedPenalties,
353/// };
354///
355/// let hydro_default = HydroStagePenalties {
356///     spillage_cost: 0.01,
357///     diversion_cost: 0.02,
358///     fpha_turbined_cost: 0.03,
359///     storage_violation_below_cost: 1000.0,
360///     filling_target_violation_cost: 5000.0,
361///     turbined_violation_below_cost: 500.0,
362///     outflow_violation_below_cost: 500.0,
363///     outflow_violation_above_cost: 500.0,
364///     generation_violation_below_cost: 500.0,
365///     evaporation_violation_cost: 500.0,
366///     water_withdrawal_violation_cost: 500.0,
367///     water_withdrawal_violation_pos_cost: 500.0,
368///     water_withdrawal_violation_neg_cost: 500.0,
369///     evaporation_violation_pos_cost: 500.0,
370///     evaporation_violation_neg_cost: 500.0,
371///     inflow_nonnegativity_cost: 1000.0,
372/// };
373/// let bus_default = BusStagePenalties { excess_cost: 100.0 };
374/// let line_default = LineStagePenalties { exchange_cost: 5.0 };
375/// let ncs_default = NcsStagePenalties { curtailment_cost: 50.0 };
376///
377/// let table = ResolvedPenalties::new(
378///     &PenaltiesCountsSpec { n_hydros: 3, n_buses: 2, n_lines: 1, n_ncs: 4, n_stages: 5 },
379///     &PenaltiesDefaults { hydro: hydro_default, bus: bus_default, line: line_default, ncs: ncs_default },
380/// );
381///
382/// // Hydro 1, stage 2 returns the default penalties.
383/// let p = table.hydro_penalties(1, 2);
384/// assert!((p.spillage_cost - 0.01).abs() < f64::EPSILON);
385/// ```
386#[derive(Debug, Clone, PartialEq)]
387#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
388pub struct ResolvedPenalties {
389    /// Total number of stages. Used to compute flat indices.
390    n_stages: usize,
391    /// Flat `n_hydros * n_stages` array indexed `[hydro_idx * n_stages + stage_idx]`.
392    hydro: Vec<HydroStagePenalties>,
393    /// Flat `n_buses * n_stages` array indexed `[bus_idx * n_stages + stage_idx]`.
394    bus: Vec<BusStagePenalties>,
395    /// Flat `n_lines * n_stages` array indexed `[line_idx * n_stages + stage_idx]`.
396    line: Vec<LineStagePenalties>,
397    /// Flat `n_ncs * n_stages` array indexed `[ncs_idx * n_stages + stage_idx]`.
398    ncs: Vec<NcsStagePenalties>,
399}
400
401/// Entity counts for constructing a [`ResolvedPenalties`] table.
402#[derive(Debug, Clone)]
403pub struct PenaltiesCountsSpec {
404    /// Number of hydro plants.
405    pub n_hydros: usize,
406    /// Number of buses.
407    pub n_buses: usize,
408    /// Number of transmission lines.
409    pub n_lines: usize,
410    /// Number of non-controllable sources.
411    pub n_ncs: usize,
412    /// Number of time stages.
413    pub n_stages: usize,
414}
415
416/// Default per-stage penalty values for each entity type.
417#[derive(Debug, Clone)]
418pub struct PenaltiesDefaults {
419    /// Default hydro penalties for all (hydro, stage) cells.
420    pub hydro: HydroStagePenalties,
421    /// Default bus penalties for all (bus, stage) cells.
422    pub bus: BusStagePenalties,
423    /// Default line penalties for all (line, stage) cells.
424    pub line: LineStagePenalties,
425    /// Default NCS penalties for all (ncs, stage) cells.
426    pub ncs: NcsStagePenalties,
427}
428
429impl ResolvedPenalties {
430    /// Return an empty penalty table with zero entities and zero stages.
431    ///
432    /// Used as the default value in [`System`](crate::System) when no penalty
433    /// resolution has been performed yet (e.g., when building a `System` from
434    /// raw entity collections without `cobre-io`).
435    ///
436    /// # Examples
437    ///
438    /// ```
439    /// use cobre_core::ResolvedPenalties;
440    ///
441    /// let empty = ResolvedPenalties::empty();
442    /// assert_eq!(empty.n_stages(), 0);
443    /// ```
444    #[must_use]
445    pub fn empty() -> Self {
446        Self {
447            n_stages: 0,
448            hydro: Vec::new(),
449            bus: Vec::new(),
450            line: Vec::new(),
451            ncs: Vec::new(),
452        }
453    }
454
455    /// Allocate a new resolved-penalties table filled with the given defaults.
456    ///
457    /// `n_stages` must be `> 0`. Entity counts may be `0` (empty vectors are valid).
458    ///
459    /// # Arguments
460    ///
461    /// * `n_hydros` — number of hydro plants
462    /// * `n_buses` — number of buses
463    /// * `n_lines` — number of transmission lines
464    /// * `n_ncs` — number of non-controllable sources
465    /// * `n_stages` — number of study stages
466    /// * `hydro_default` — initial value for all (hydro, stage) cells
467    /// * `bus_default` — initial value for all (bus, stage) cells
468    /// * `line_default` — initial value for all (line, stage) cells
469    /// * `ncs_default` — initial value for all (ncs, stage) cells
470    #[must_use]
471    pub fn new(counts: &PenaltiesCountsSpec, defaults: &PenaltiesDefaults) -> Self {
472        Self {
473            n_stages: counts.n_stages,
474            hydro: vec![defaults.hydro; counts.n_hydros * counts.n_stages],
475            bus: vec![defaults.bus; counts.n_buses * counts.n_stages],
476            line: vec![defaults.line; counts.n_lines * counts.n_stages],
477            ncs: vec![defaults.ncs; counts.n_ncs * counts.n_stages],
478        }
479    }
480
481    /// Return the resolved penalties for a hydro plant at a specific stage.
482    #[inline]
483    #[must_use]
484    pub fn hydro_penalties(&self, hydro_index: usize, stage_index: usize) -> HydroStagePenalties {
485        self.hydro[hydro_index * self.n_stages + stage_index]
486    }
487
488    /// Return the resolved penalties for a bus at a specific stage.
489    #[inline]
490    #[must_use]
491    pub fn bus_penalties(&self, bus_index: usize, stage_index: usize) -> BusStagePenalties {
492        self.bus[bus_index * self.n_stages + stage_index]
493    }
494
495    /// Return the resolved penalties for a line at a specific stage.
496    #[inline]
497    #[must_use]
498    pub fn line_penalties(&self, line_index: usize, stage_index: usize) -> LineStagePenalties {
499        self.line[line_index * self.n_stages + stage_index]
500    }
501
502    /// Return the resolved penalties for a non-controllable source at a specific stage.
503    #[inline]
504    #[must_use]
505    pub fn ncs_penalties(&self, ncs_index: usize, stage_index: usize) -> NcsStagePenalties {
506        self.ncs[ncs_index * self.n_stages + stage_index]
507    }
508
509    /// Return a mutable reference to the hydro penalty cell for in-place update.
510    ///
511    /// Used by `cobre-io` during penalty cascade resolution to set resolved values.
512    #[inline]
513    pub fn hydro_penalties_mut(
514        &mut self,
515        hydro_index: usize,
516        stage_index: usize,
517    ) -> &mut HydroStagePenalties {
518        let idx = hydro_index * self.n_stages + stage_index;
519        &mut self.hydro[idx]
520    }
521
522    /// Return a mutable reference to the bus penalty cell for in-place update.
523    #[inline]
524    pub fn bus_penalties_mut(
525        &mut self,
526        bus_index: usize,
527        stage_index: usize,
528    ) -> &mut BusStagePenalties {
529        let idx = bus_index * self.n_stages + stage_index;
530        &mut self.bus[idx]
531    }
532
533    /// Return a mutable reference to the line penalty cell for in-place update.
534    #[inline]
535    pub fn line_penalties_mut(
536        &mut self,
537        line_index: usize,
538        stage_index: usize,
539    ) -> &mut LineStagePenalties {
540        let idx = line_index * self.n_stages + stage_index;
541        &mut self.line[idx]
542    }
543
544    /// Return a mutable reference to the NCS penalty cell for in-place update.
545    #[inline]
546    pub fn ncs_penalties_mut(
547        &mut self,
548        ncs_index: usize,
549        stage_index: usize,
550    ) -> &mut NcsStagePenalties {
551        let idx = ncs_index * self.n_stages + stage_index;
552        &mut self.ncs[idx]
553    }
554
555    /// Return the number of stages in this table.
556    #[inline]
557    #[must_use]
558    pub fn n_stages(&self) -> usize {
559        self.n_stages
560    }
561}
562
563/// Pre-resolved bound table for all entities across all stages.
564///
565/// Populated by `cobre-io` after base bounds are overlaid with stage-specific
566/// overrides. Provides O(1) lookup via direct array indexing.
567///
568/// Internal layout: `data[entity_idx * n_stages + stage_idx]`.
569///
570/// # Examples
571///
572/// ```
573/// use cobre_core::resolved::{
574///     BoundsCountsSpec, BoundsDefaults, ContractStageBounds, HydroStageBounds,
575///     LineStageBounds, PumpingStageBounds, ResolvedBounds, ThermalStageBounds,
576/// };
577///
578/// let hydro_default = HydroStageBounds {
579///     min_storage_hm3: 0.0, max_storage_hm3: 100.0,
580///     min_turbined_m3s: 0.0, max_turbined_m3s: 50.0,
581///     min_outflow_m3s: 0.0, max_outflow_m3s: None,
582///     min_generation_mw: 0.0, max_generation_mw: 30.0,
583///     max_diversion_m3s: None,
584///     filling_inflow_m3s: 0.0, water_withdrawal_m3s: 0.0,
585/// };
586/// let thermal_default = ThermalStageBounds { min_generation_mw: 0.0, max_generation_mw: 100.0, cost_per_mwh: 50.0 };
587/// let line_default = LineStageBounds { direct_mw: 500.0, reverse_mw: 500.0 };
588/// let pumping_default = PumpingStageBounds { min_flow_m3s: 0.0, max_flow_m3s: 20.0 };
589/// let contract_default = ContractStageBounds { min_mw: 0.0, max_mw: 50.0, price_per_mwh: 80.0 };
590///
591/// let table = ResolvedBounds::new(
592///     &BoundsCountsSpec { n_hydros: 2, n_thermals: 1, n_lines: 1, n_pumping: 1, n_contracts: 1, n_stages: 3 },
593///     &BoundsDefaults { hydro: hydro_default, thermal: thermal_default, line: line_default, pumping: pumping_default, contract: contract_default },
594/// );
595///
596/// let b = table.hydro_bounds(0, 2);
597/// assert!((b.max_storage_hm3 - 100.0).abs() < f64::EPSILON);
598/// ```
599#[derive(Debug, Clone, PartialEq)]
600#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
601pub struct ResolvedBounds {
602    /// Total number of stages. Used to compute flat indices.
603    n_stages: usize,
604    /// Flat `n_hydros * n_stages` array indexed `[hydro_idx * n_stages + stage_idx]`.
605    hydro: Vec<HydroStageBounds>,
606    /// Flat `n_thermals * n_stages` array indexed `[thermal_idx * n_stages + stage_idx]`.
607    thermal: Vec<ThermalStageBounds>,
608    /// Flat `n_lines * n_stages` array indexed `[line_idx * n_stages + stage_idx]`.
609    line: Vec<LineStageBounds>,
610    /// Flat `n_pumping * n_stages` array indexed `[pumping_idx * n_stages + stage_idx]`.
611    pumping: Vec<PumpingStageBounds>,
612    /// Flat `n_contracts * n_stages` array indexed `[contract_idx * n_stages + stage_idx]`.
613    contract: Vec<ContractStageBounds>,
614}
615
616/// Entity counts for constructing a [`ResolvedBounds`] table.
617#[derive(Debug, Clone)]
618pub struct BoundsCountsSpec {
619    /// Number of hydro plants.
620    pub n_hydros: usize,
621    /// Number of thermal units.
622    pub n_thermals: usize,
623    /// Number of transmission lines.
624    pub n_lines: usize,
625    /// Number of pumping stations.
626    pub n_pumping: usize,
627    /// Number of energy contracts.
628    pub n_contracts: usize,
629    /// Number of time stages.
630    pub n_stages: usize,
631}
632
633/// Default per-stage bound values for each entity type.
634#[derive(Debug, Clone)]
635pub struct BoundsDefaults {
636    /// Default hydro bounds for all (hydro, stage) cells.
637    pub hydro: HydroStageBounds,
638    /// Default thermal bounds for all (thermal, stage) cells.
639    pub thermal: ThermalStageBounds,
640    /// Default line bounds for all (line, stage) cells.
641    pub line: LineStageBounds,
642    /// Default pumping bounds for all (pumping, stage) cells.
643    pub pumping: PumpingStageBounds,
644    /// Default contract bounds for all (contract, stage) cells.
645    pub contract: ContractStageBounds,
646}
647
648impl ResolvedBounds {
649    /// Return an empty bounds table with zero entities and zero stages.
650    ///
651    /// Used as the default value in [`System`](crate::System) when no bound
652    /// resolution has been performed yet (e.g., when building a `System` from
653    /// raw entity collections without `cobre-io`).
654    ///
655    /// # Examples
656    ///
657    /// ```
658    /// use cobre_core::ResolvedBounds;
659    ///
660    /// let empty = ResolvedBounds::empty();
661    /// assert_eq!(empty.n_stages(), 0);
662    /// ```
663    #[must_use]
664    pub fn empty() -> Self {
665        Self {
666            n_stages: 0,
667            hydro: Vec::new(),
668            thermal: Vec::new(),
669            line: Vec::new(),
670            pumping: Vec::new(),
671            contract: Vec::new(),
672        }
673    }
674
675    /// Allocate a new resolved-bounds table filled with the given defaults.
676    ///
677    /// `counts.n_stages` must be `> 0`. Entity counts may be `0`.
678    ///
679    /// # Arguments
680    ///
681    /// * `counts` — entity counts grouped into [`BoundsCountsSpec`]
682    /// * `defaults` — default per-stage bound values grouped into [`BoundsDefaults`]
683    #[must_use]
684    pub fn new(counts: &BoundsCountsSpec, defaults: &BoundsDefaults) -> Self {
685        Self {
686            n_stages: counts.n_stages,
687            hydro: vec![defaults.hydro; counts.n_hydros * counts.n_stages],
688            thermal: vec![defaults.thermal; counts.n_thermals * counts.n_stages],
689            line: vec![defaults.line; counts.n_lines * counts.n_stages],
690            pumping: vec![defaults.pumping; counts.n_pumping * counts.n_stages],
691            contract: vec![defaults.contract; counts.n_contracts * counts.n_stages],
692        }
693    }
694
695    /// Return the resolved bounds for a hydro plant at a specific stage.
696    ///
697    /// Returns a shared reference to avoid copying the 11-field struct on hot paths.
698    ///
699    /// # Panics
700    ///
701    /// Panics in debug builds if `hydro_index >= n_hydros` or `stage_index >= n_stages`.
702    #[inline]
703    #[must_use]
704    pub fn hydro_bounds(&self, hydro_index: usize, stage_index: usize) -> &HydroStageBounds {
705        &self.hydro[hydro_index * self.n_stages + stage_index]
706    }
707
708    /// Return the resolved bounds for a thermal unit at a specific stage.
709    #[inline]
710    #[must_use]
711    pub fn thermal_bounds(&self, thermal_index: usize, stage_index: usize) -> ThermalStageBounds {
712        self.thermal[thermal_index * self.n_stages + stage_index]
713    }
714
715    /// Return the resolved bounds for a transmission line at a specific stage.
716    #[inline]
717    #[must_use]
718    pub fn line_bounds(&self, line_index: usize, stage_index: usize) -> LineStageBounds {
719        self.line[line_index * self.n_stages + stage_index]
720    }
721
722    /// Return the resolved bounds for a pumping station at a specific stage.
723    #[inline]
724    #[must_use]
725    pub fn pumping_bounds(&self, pumping_index: usize, stage_index: usize) -> PumpingStageBounds {
726        self.pumping[pumping_index * self.n_stages + stage_index]
727    }
728
729    /// Return the resolved bounds for an energy contract at a specific stage.
730    #[inline]
731    #[must_use]
732    pub fn contract_bounds(
733        &self,
734        contract_index: usize,
735        stage_index: usize,
736    ) -> ContractStageBounds {
737        self.contract[contract_index * self.n_stages + stage_index]
738    }
739
740    /// Return a mutable reference to the hydro bounds cell for in-place update.
741    ///
742    /// Used by `cobre-io` during bound resolution to set stage-specific overrides.
743    #[inline]
744    pub fn hydro_bounds_mut(
745        &mut self,
746        hydro_index: usize,
747        stage_index: usize,
748    ) -> &mut HydroStageBounds {
749        let idx = hydro_index * self.n_stages + stage_index;
750        &mut self.hydro[idx]
751    }
752
753    /// Return a mutable reference to the thermal bounds cell for in-place update.
754    #[inline]
755    pub fn thermal_bounds_mut(
756        &mut self,
757        thermal_index: usize,
758        stage_index: usize,
759    ) -> &mut ThermalStageBounds {
760        let idx = thermal_index * self.n_stages + stage_index;
761        &mut self.thermal[idx]
762    }
763
764    /// Return a mutable reference to the line bounds cell for in-place update.
765    #[inline]
766    pub fn line_bounds_mut(
767        &mut self,
768        line_index: usize,
769        stage_index: usize,
770    ) -> &mut LineStageBounds {
771        let idx = line_index * self.n_stages + stage_index;
772        &mut self.line[idx]
773    }
774
775    /// Return a mutable reference to the pumping bounds cell for in-place update.
776    #[inline]
777    pub fn pumping_bounds_mut(
778        &mut self,
779        pumping_index: usize,
780        stage_index: usize,
781    ) -> &mut PumpingStageBounds {
782        let idx = pumping_index * self.n_stages + stage_index;
783        &mut self.pumping[idx]
784    }
785
786    /// Return a mutable reference to the contract bounds cell for in-place update.
787    #[inline]
788    pub fn contract_bounds_mut(
789        &mut self,
790        contract_index: usize,
791        stage_index: usize,
792    ) -> &mut ContractStageBounds {
793        let idx = contract_index * self.n_stages + stage_index;
794        &mut self.contract[idx]
795    }
796
797    /// Return the number of stages in this table.
798    #[inline]
799    #[must_use]
800    pub fn n_stages(&self) -> usize {
801        self.n_stages
802    }
803}
804
805// ─── Generic constraint bounds ────────────────────────────────────────────────
806
807/// Pre-resolved RHS bound table for user-defined generic linear constraints.
808///
809/// Indexed by `(constraint_index, stage_id)` using a sparse `HashMap`. Provides O(1)
810/// lookup of the active bounds for LP row construction.
811///
812/// Entries are stored in a flat `Vec<(Option<i32>, f64)>` of `(block_id, bound)` pairs.
813/// Each `(constraint_index, stage_id)` key maps to a contiguous `Range<usize>` slice
814/// within that flat vec.
815///
816/// When no bounds exist for a `(constraint_index, stage_id)` pair, [`is_active`]
817/// returns `false` and [`bounds_for_stage`] returns an empty slice — there is no
818/// panic or error.
819///
820/// # Construction
821///
822/// Use [`ResolvedGenericConstraintBounds::empty`] as the default (no generic constraints),
823/// or [`ResolvedGenericConstraintBounds::new`] to build from parsed bound rows.
824/// `cobre-io` is responsible for populating the table.
825///
826/// # Examples
827///
828/// ```
829/// use cobre_core::ResolvedGenericConstraintBounds;
830///
831/// let empty = ResolvedGenericConstraintBounds::empty();
832/// assert!(!empty.is_active(0, 0));
833/// assert!(empty.bounds_for_stage(0, 0).is_empty());
834/// ```
835///
836/// [`is_active`]: ResolvedGenericConstraintBounds::is_active
837/// [`bounds_for_stage`]: ResolvedGenericConstraintBounds::bounds_for_stage
838#[derive(Debug, Clone, PartialEq)]
839pub struct ResolvedGenericConstraintBounds {
840    /// Sparse index: maps `(constraint_idx, stage_id)` to a range in `entries`.
841    ///
842    /// Using `i32` for `stage_id` because domain-level stage IDs are `i32` and may
843    /// be negative for pre-study stages (though generic constraint bounds should only
844    /// reference study stages).
845    index: HashMap<(usize, i32), Range<usize>>,
846    /// Flat storage of `(block_id, bound)` pairs, grouped by `(constraint_idx, stage_id)`.
847    ///
848    /// Entries for each key occupy a contiguous region; the [`index`] map provides
849    /// the `Range<usize>` slice boundaries.
850    ///
851    /// [`index`]: Self::index
852    entries: Vec<(Option<i32>, f64)>,
853}
854
855#[cfg(feature = "serde")]
856mod serde_generic_bounds {
857    use serde::{Deserialize, Deserializer, Serialize, Serializer};
858
859    use super::ResolvedGenericConstraintBounds;
860
861    /// Wire format for serde: a list of `(constraint_idx, stage_id, pairs)` groups.
862    ///
863    /// JSON/postcard cannot serialize `HashMap<(usize, i32), Range<usize>>` directly
864    /// because composite tuple keys are not strings. This wire format avoids that
865    /// by encoding each group as a tagged list of entries.
866    #[derive(Serialize, Deserialize)]
867    struct WireEntry {
868        constraint_idx: usize,
869        stage_id: i32,
870        pairs: Vec<(Option<i32>, f64)>,
871    }
872
873    impl Serialize for ResolvedGenericConstraintBounds {
874        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
875            // Collect all keys from the index, sort for deterministic output, then
876            // emit each group as a `WireEntry`.
877            let mut keys: Vec<(usize, i32)> = self.index.keys().copied().collect();
878            keys.sort_unstable();
879
880            let wire: Vec<WireEntry> = keys
881                .into_iter()
882                .map(|(constraint_idx, stage_id)| {
883                    let range = self.index[&(constraint_idx, stage_id)].clone();
884                    WireEntry {
885                        constraint_idx,
886                        stage_id,
887                        pairs: self.entries[range].to_vec(),
888                    }
889                })
890                .collect();
891
892            wire.serialize(serializer)
893        }
894    }
895
896    impl<'de> Deserialize<'de> for ResolvedGenericConstraintBounds {
897        fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
898            let wire = Vec::<WireEntry>::deserialize(deserializer)?;
899
900            let mut index = std::collections::HashMap::new();
901            let mut entries = Vec::new();
902
903            for entry in wire {
904                let start = entries.len();
905                entries.extend_from_slice(&entry.pairs);
906                let end = entries.len();
907                if end > start {
908                    index.insert((entry.constraint_idx, entry.stage_id), start..end);
909                }
910            }
911
912            Ok(ResolvedGenericConstraintBounds { index, entries })
913        }
914    }
915}
916
917impl ResolvedGenericConstraintBounds {
918    /// Return an empty table with no constraints and no bounds.
919    ///
920    /// Used as the default value in [`System`](crate::System) when no generic constraints
921    /// are loaded. All queries on the empty table return `false` / empty slices.
922    ///
923    /// # Examples
924    ///
925    /// ```
926    /// use cobre_core::ResolvedGenericConstraintBounds;
927    ///
928    /// let t = ResolvedGenericConstraintBounds::empty();
929    /// assert!(!t.is_active(0, 0));
930    /// assert!(t.bounds_for_stage(99, 5).is_empty());
931    /// ```
932    #[must_use]
933    pub fn empty() -> Self {
934        Self {
935            index: HashMap::new(),
936            entries: Vec::new(),
937        }
938    }
939
940    /// Build a resolved table from sorted bound rows.
941    ///
942    /// `constraint_id_to_idx` maps domain-level `constraint_id: i32` values to
943    /// positional indices in the constraint collection. Rows whose `constraint_id`
944    /// is not present in that map are silently skipped (they would have been caught
945    /// by referential validation upstream).
946    ///
947    /// `raw_bounds` must be sorted by `(constraint_id, stage_id, block_id)` ascending
948    /// (the ordering produced by `parse_generic_constraint_bounds`).
949    ///
950    /// # Arguments
951    ///
952    /// * `constraint_id_to_idx` — maps domain `constraint_id` to positional index
953    /// * `raw_bounds` — sorted rows from `constraints/generic_constraint_bounds.parquet`
954    ///
955    /// # Examples
956    ///
957    /// ```
958    /// use std::collections::HashMap;
959    /// use cobre_core::ResolvedGenericConstraintBounds;
960    ///
961    /// // Two constraints with IDs 10 and 20, mapped to positions 0 and 1.
962    /// let id_map: HashMap<i32, usize> = [(10, 0), (20, 1)].into_iter().collect();
963    ///
964    /// // One bound row: constraint 10 at stage 3, block_id = None, bound = 500.0.
965    /// let rows = vec![(10i32, 3i32, None::<i32>, 500.0f64)];
966    ///
967    /// let table = ResolvedGenericConstraintBounds::new(
968    ///     &id_map,
969    ///     rows.iter().map(|(cid, sid, bid, b)| (*cid, *sid, *bid, *b)),
970    /// );
971    ///
972    /// assert!(table.is_active(0, 3));
973    /// assert!(!table.is_active(1, 3));
974    ///
975    /// let slice = table.bounds_for_stage(0, 3);
976    /// assert_eq!(slice.len(), 1);
977    /// assert_eq!(slice[0], (None, 500.0));
978    /// ```
979    pub fn new<I>(constraint_id_to_idx: &HashMap<i32, usize>, raw_bounds: I) -> Self
980    where
981        I: Iterator<Item = (i32, i32, Option<i32>, f64)>,
982    {
983        let mut index: HashMap<(usize, i32), Range<usize>> = HashMap::new();
984        let mut entries: Vec<(Option<i32>, f64)> = Vec::new();
985
986        // The input rows are sorted by (constraint_id, stage_id, block_id).
987        // We group consecutive rows with the same (constraint_idx, stage_id) key
988        // into a contiguous range in `entries`.
989
990        let mut current_key: Option<(usize, i32)> = None;
991        let mut range_start: usize = 0;
992
993        for (constraint_id, stage_id, block_id, bound) in raw_bounds {
994            let Some(&constraint_idx) = constraint_id_to_idx.get(&constraint_id) else {
995                // Unknown constraint ID — silently skip (referential validation concern).
996                continue;
997            };
998
999            let key = (constraint_idx, stage_id);
1000
1001            // When the key changes, commit the range for the previous key.
1002            if current_key != Some(key) {
1003                if let Some(prev_key) = current_key {
1004                    let range_end = entries.len();
1005                    if range_end > range_start {
1006                        index.insert(prev_key, range_start..range_end);
1007                    }
1008                }
1009                range_start = entries.len();
1010                current_key = Some(key);
1011            }
1012
1013            entries.push((block_id, bound));
1014        }
1015
1016        // Commit the final key.
1017        if let Some(last_key) = current_key {
1018            let range_end = entries.len();
1019            if range_end > range_start {
1020                index.insert(last_key, range_start..range_end);
1021            }
1022        }
1023
1024        Self { index, entries }
1025    }
1026
1027    /// Return `true` if at least one bound entry exists for this constraint at the given stage.
1028    ///
1029    /// Returns `false` for any unknown `(constraint_idx, stage_id)` pair.
1030    ///
1031    /// # Examples
1032    ///
1033    /// ```
1034    /// use cobre_core::ResolvedGenericConstraintBounds;
1035    ///
1036    /// let empty = ResolvedGenericConstraintBounds::empty();
1037    /// assert!(!empty.is_active(0, 0));
1038    /// ```
1039    #[inline]
1040    #[must_use]
1041    pub fn is_active(&self, constraint_idx: usize, stage_id: i32) -> bool {
1042        self.index.contains_key(&(constraint_idx, stage_id))
1043    }
1044
1045    /// Return the `(block_id, bound)` pairs for a constraint at the given stage.
1046    ///
1047    /// Returns an empty slice when no bounds exist for the `(constraint_idx, stage_id)` pair.
1048    ///
1049    /// # Examples
1050    ///
1051    /// ```
1052    /// use cobre_core::ResolvedGenericConstraintBounds;
1053    ///
1054    /// let empty = ResolvedGenericConstraintBounds::empty();
1055    /// assert!(empty.bounds_for_stage(0, 0).is_empty());
1056    /// ```
1057    #[inline]
1058    #[must_use]
1059    pub fn bounds_for_stage(&self, constraint_idx: usize, stage_id: i32) -> &[(Option<i32>, f64)] {
1060        match self.index.get(&(constraint_idx, stage_id)) {
1061            Some(range) => &self.entries[range.clone()],
1062            None => &[],
1063        }
1064    }
1065}
1066
1067// ─── Block factor lookup tables ──────────────────────────────────────────────
1068
1069/// Pre-resolved per-block load scaling factors.
1070///
1071/// Provides O(1) lookup of load block factors by `(bus_index, stage_index,
1072/// block_index)`. Returns `1.0` for absent entries (no scaling). Populated
1073/// by `cobre-io` during the resolution step and stored in [`crate::System`].
1074///
1075/// Uses dense 3D storage (`n_buses * n_stages * max_blocks`) initialized to
1076/// `1.0`. The total size is small (typically < 10K entries) and the lookup is
1077/// on the LP-building hot path.
1078///
1079/// # Examples
1080///
1081/// ```
1082/// use cobre_core::resolved::ResolvedLoadFactors;
1083///
1084/// let empty = ResolvedLoadFactors::empty();
1085/// assert!((empty.factor(0, 0, 0) - 1.0).abs() < f64::EPSILON);
1086/// ```
1087#[derive(Debug, Clone, PartialEq)]
1088#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1089pub struct ResolvedLoadFactors {
1090    /// Dense 3D array stored flat: `[bus_idx][stage_idx][block_idx]`.
1091    /// Dimensions: `n_buses * n_stages * max_blocks`.
1092    factors: Vec<f64>,
1093    /// Number of stages.
1094    n_stages: usize,
1095    /// Maximum number of blocks across all stages.
1096    max_blocks: usize,
1097}
1098
1099impl ResolvedLoadFactors {
1100    /// Create an empty load factors table. All lookups return `1.0`.
1101    ///
1102    /// Used as the default when no `load_factors.json` exists.
1103    ///
1104    /// # Examples
1105    ///
1106    /// ```
1107    /// use cobre_core::resolved::ResolvedLoadFactors;
1108    ///
1109    /// let t = ResolvedLoadFactors::empty();
1110    /// assert!((t.factor(5, 3, 2) - 1.0).abs() < f64::EPSILON);
1111    /// ```
1112    #[must_use]
1113    pub fn empty() -> Self {
1114        Self {
1115            factors: Vec::new(),
1116            n_stages: 0,
1117            max_blocks: 0,
1118        }
1119    }
1120
1121    /// Create a new load factors table with the given dimensions.
1122    ///
1123    /// All entries are initialized to `1.0` (no scaling). Use [`set`] to
1124    /// populate individual entries.
1125    ///
1126    /// [`set`]: Self::set
1127    #[must_use]
1128    pub fn new(n_buses: usize, n_stages: usize, max_blocks: usize) -> Self {
1129        Self {
1130            factors: vec![1.0; n_buses * n_stages * max_blocks],
1131            n_stages,
1132            max_blocks,
1133        }
1134    }
1135
1136    /// Set the load factor for a specific `(bus_idx, stage_idx, block_idx)` triple.
1137    ///
1138    /// # Panics
1139    ///
1140    /// Panics if any index is out of bounds.
1141    pub fn set(&mut self, bus_idx: usize, stage_idx: usize, block_idx: usize, value: f64) {
1142        let idx = (bus_idx * self.n_stages + stage_idx) * self.max_blocks + block_idx;
1143        self.factors[idx] = value;
1144    }
1145
1146    /// Look up the load factor for a `(bus_idx, stage_idx, block_idx)` triple.
1147    ///
1148    /// Returns `1.0` when the index is out of bounds or the table is empty.
1149    #[inline]
1150    #[must_use]
1151    pub fn factor(&self, bus_idx: usize, stage_idx: usize, block_idx: usize) -> f64 {
1152        if self.factors.is_empty() {
1153            return 1.0;
1154        }
1155        let idx = (bus_idx * self.n_stages + stage_idx) * self.max_blocks + block_idx;
1156        self.factors.get(idx).copied().unwrap_or(1.0)
1157    }
1158}
1159
1160/// Pre-resolved per-block exchange capacity factors.
1161///
1162/// Provides O(1) lookup of exchange factors by `(line_index, stage_index,
1163/// block_index)` returning `(direct_factor, reverse_factor)`. Returns
1164/// `(1.0, 1.0)` for absent entries. Populated by `cobre-io` during the
1165/// resolution step and stored in [`crate::System`].
1166///
1167/// # Examples
1168///
1169/// ```
1170/// use cobre_core::resolved::ResolvedExchangeFactors;
1171///
1172/// let empty = ResolvedExchangeFactors::empty();
1173/// assert_eq!(empty.factors(0, 0, 0), (1.0, 1.0));
1174/// ```
1175#[derive(Debug, Clone, PartialEq)]
1176#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1177pub struct ResolvedExchangeFactors {
1178    /// Dense 3D array stored flat: `[line_idx][stage_idx][block_idx]`.
1179    /// Each entry stores `(direct_factor, reverse_factor)`.
1180    data: Vec<(f64, f64)>,
1181    /// Number of stages.
1182    n_stages: usize,
1183    /// Maximum number of blocks across all stages.
1184    max_blocks: usize,
1185}
1186
1187impl ResolvedExchangeFactors {
1188    /// Create an empty exchange factors table. All lookups return `(1.0, 1.0)`.
1189    ///
1190    /// Used as the default when no `exchange_factors.json` exists.
1191    ///
1192    /// # Examples
1193    ///
1194    /// ```
1195    /// use cobre_core::resolved::ResolvedExchangeFactors;
1196    ///
1197    /// let t = ResolvedExchangeFactors::empty();
1198    /// assert_eq!(t.factors(5, 3, 2), (1.0, 1.0));
1199    /// ```
1200    #[must_use]
1201    pub fn empty() -> Self {
1202        Self {
1203            data: Vec::new(),
1204            n_stages: 0,
1205            max_blocks: 0,
1206        }
1207    }
1208
1209    /// Create a new exchange factors table with the given dimensions.
1210    ///
1211    /// All entries are initialized to `(1.0, 1.0)` (no scaling). Use [`set`]
1212    /// to populate individual entries.
1213    ///
1214    /// [`set`]: Self::set
1215    #[must_use]
1216    pub fn new(n_lines: usize, n_stages: usize, max_blocks: usize) -> Self {
1217        Self {
1218            data: vec![(1.0, 1.0); n_lines * n_stages * max_blocks],
1219            n_stages,
1220            max_blocks,
1221        }
1222    }
1223
1224    /// Set the exchange factors for a specific `(line_idx, stage_idx, block_idx)` triple.
1225    ///
1226    /// # Panics
1227    ///
1228    /// Panics if any index is out of bounds.
1229    pub fn set(
1230        &mut self,
1231        line_idx: usize,
1232        stage_idx: usize,
1233        block_idx: usize,
1234        direct_factor: f64,
1235        reverse_factor: f64,
1236    ) {
1237        let idx = (line_idx * self.n_stages + stage_idx) * self.max_blocks + block_idx;
1238        self.data[idx] = (direct_factor, reverse_factor);
1239    }
1240
1241    /// Look up the exchange factors for a `(line_idx, stage_idx, block_idx)` triple.
1242    ///
1243    /// Returns `(direct_factor, reverse_factor)`. Returns `(1.0, 1.0)` when the
1244    /// index is out of bounds or the table is empty.
1245    #[inline]
1246    #[must_use]
1247    pub fn factors(&self, line_idx: usize, stage_idx: usize, block_idx: usize) -> (f64, f64) {
1248        if self.data.is_empty() {
1249            return (1.0, 1.0);
1250        }
1251        let idx = (line_idx * self.n_stages + stage_idx) * self.max_blocks + block_idx;
1252        self.data.get(idx).copied().unwrap_or((1.0, 1.0))
1253    }
1254}
1255
1256/// Pre-resolved per-stage NCS available generation bounds.
1257///
1258/// Provides O(1) lookup of `available_generation_mw` by `(ncs_index, stage_index)`.
1259/// Returns `0.0` for out-of-bounds access. Populated by `cobre-io` during the
1260/// resolution step and stored in [`crate::System`].
1261///
1262/// Uses dense 2D storage (`n_ncs * n_stages`) initialized with each NCS entity's
1263/// installed capacity (`max_generation_mw`). Stage-varying overrides from
1264/// `constraints/ncs_bounds.parquet` replace individual entries.
1265///
1266/// # Examples
1267///
1268/// ```
1269/// use cobre_core::resolved::ResolvedNcsBounds;
1270///
1271/// let empty = ResolvedNcsBounds::empty();
1272/// assert!(empty.is_empty());
1273/// ```
1274#[derive(Debug, Clone, PartialEq)]
1275#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1276pub struct ResolvedNcsBounds {
1277    /// Dense 2D array: `[ncs_idx * n_stages + stage_idx]`.
1278    data: Vec<f64>,
1279    /// Number of stages.
1280    n_stages: usize,
1281}
1282
1283impl ResolvedNcsBounds {
1284    /// Create an empty NCS bounds table.
1285    ///
1286    /// Used as the default when no NCS entities exist or no bounds file is provided.
1287    ///
1288    /// # Examples
1289    ///
1290    /// ```
1291    /// use cobre_core::resolved::ResolvedNcsBounds;
1292    ///
1293    /// let t = ResolvedNcsBounds::empty();
1294    /// assert!(t.is_empty());
1295    /// ```
1296    #[must_use]
1297    pub fn empty() -> Self {
1298        Self {
1299            data: Vec::new(),
1300            n_stages: 0,
1301        }
1302    }
1303
1304    /// Create a new NCS bounds table with per-entity defaults.
1305    ///
1306    /// All stages for NCS entity `i` are initialized to `default_mw[i]`
1307    /// (the installed capacity). Use [`set`] to apply stage-varying overrides.
1308    ///
1309    /// [`set`]: Self::set
1310    ///
1311    /// # Panics
1312    ///
1313    /// Panics if `default_mw.len() != n_ncs`.
1314    #[must_use]
1315    pub fn new(n_ncs: usize, n_stages: usize, default_mw: &[f64]) -> Self {
1316        assert!(
1317            default_mw.len() == n_ncs,
1318            "default_mw length ({}) must equal n_ncs ({n_ncs})",
1319            default_mw.len()
1320        );
1321        let mut data = vec![0.0; n_ncs * n_stages];
1322        for (ncs_idx, &mw) in default_mw.iter().enumerate() {
1323            for stage_idx in 0..n_stages {
1324                data[ncs_idx * n_stages + stage_idx] = mw;
1325            }
1326        }
1327        Self { data, n_stages }
1328    }
1329
1330    /// Set the available generation for a specific `(ncs_idx, stage_idx)` pair.
1331    ///
1332    /// # Panics
1333    ///
1334    /// Panics if any index is out of bounds.
1335    pub fn set(&mut self, ncs_idx: usize, stage_idx: usize, value: f64) {
1336        let idx = ncs_idx * self.n_stages + stage_idx;
1337        self.data[idx] = value;
1338    }
1339
1340    /// Look up the available generation (MW) for a `(ncs_idx, stage_idx)` pair.
1341    ///
1342    /// Returns `0.0` when the index is out of bounds or the table is empty.
1343    #[inline]
1344    #[must_use]
1345    pub fn available_generation(&self, ncs_idx: usize, stage_idx: usize) -> f64 {
1346        if self.data.is_empty() {
1347            return 0.0;
1348        }
1349        let idx = ncs_idx * self.n_stages + stage_idx;
1350        self.data.get(idx).copied().unwrap_or(0.0)
1351    }
1352
1353    /// Returns `true` when the table has no data.
1354    #[inline]
1355    #[must_use]
1356    pub fn is_empty(&self) -> bool {
1357        self.data.is_empty()
1358    }
1359}
1360
1361/// Pre-resolved per-block NCS generation scaling factors.
1362///
1363/// Provides O(1) lookup of the generation factor by `(ncs_index, stage_index,
1364/// block_index)`. Returns `1.0` for absent entries (no scaling). Populated
1365/// by `cobre-io` during the resolution step and stored in [`crate::System`].
1366///
1367/// Uses dense 3D storage (`n_ncs * n_stages * max_blocks`) initialized to
1368/// `1.0`. The total size is small (typically < 10K entries) and the lookup is
1369/// on the LP-building hot path.
1370///
1371/// # Examples
1372///
1373/// ```
1374/// use cobre_core::resolved::ResolvedNcsFactors;
1375///
1376/// let empty = ResolvedNcsFactors::empty();
1377/// assert!((empty.factor(0, 0, 0) - 1.0).abs() < f64::EPSILON);
1378/// ```
1379#[derive(Debug, Clone, PartialEq)]
1380#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1381pub struct ResolvedNcsFactors {
1382    /// Dense 3D array stored flat: `[ncs_idx][stage_idx][block_idx]`.
1383    /// Dimensions: `n_ncs * n_stages * max_blocks`.
1384    factors: Vec<f64>,
1385    /// Number of stages.
1386    n_stages: usize,
1387    /// Maximum number of blocks across all stages.
1388    max_blocks: usize,
1389}
1390
1391impl ResolvedNcsFactors {
1392    /// Create an empty NCS factors table. All lookups return `1.0`.
1393    ///
1394    /// Used as the default when no `non_controllable_factors.json` exists.
1395    ///
1396    /// # Examples
1397    ///
1398    /// ```
1399    /// use cobre_core::resolved::ResolvedNcsFactors;
1400    ///
1401    /// let t = ResolvedNcsFactors::empty();
1402    /// assert!((t.factor(5, 3, 2) - 1.0).abs() < f64::EPSILON);
1403    /// ```
1404    #[must_use]
1405    pub fn empty() -> Self {
1406        Self {
1407            factors: Vec::new(),
1408            n_stages: 0,
1409            max_blocks: 0,
1410        }
1411    }
1412
1413    /// Create a new NCS factors table with the given dimensions.
1414    ///
1415    /// All entries are initialized to `1.0` (no scaling). Use [`set`] to
1416    /// populate individual entries.
1417    ///
1418    /// [`set`]: Self::set
1419    #[must_use]
1420    pub fn new(n_ncs: usize, n_stages: usize, max_blocks: usize) -> Self {
1421        Self {
1422            factors: vec![1.0; n_ncs * n_stages * max_blocks],
1423            n_stages,
1424            max_blocks,
1425        }
1426    }
1427
1428    /// Set the NCS factor for a specific `(ncs_idx, stage_idx, block_idx)` triple.
1429    ///
1430    /// # Panics
1431    ///
1432    /// Panics if any index is out of bounds.
1433    pub fn set(&mut self, ncs_idx: usize, stage_idx: usize, block_idx: usize, value: f64) {
1434        let idx = (ncs_idx * self.n_stages + stage_idx) * self.max_blocks + block_idx;
1435        self.factors[idx] = value;
1436    }
1437
1438    /// Look up the NCS factor for a `(ncs_idx, stage_idx, block_idx)` triple.
1439    ///
1440    /// Returns `1.0` when the index is out of bounds or the table is empty.
1441    #[inline]
1442    #[must_use]
1443    pub fn factor(&self, ncs_idx: usize, stage_idx: usize, block_idx: usize) -> f64 {
1444        if self.factors.is_empty() {
1445            return 1.0;
1446        }
1447        let idx = (ncs_idx * self.n_stages + stage_idx) * self.max_blocks + block_idx;
1448        self.factors.get(idx).copied().unwrap_or(1.0)
1449    }
1450}
1451
1452// ─── Tests ────────────────────────────────────────────────────────────────────
1453
1454#[cfg(test)]
1455mod tests {
1456    use super::*;
1457
1458    fn make_hydro_penalties() -> HydroStagePenalties {
1459        HydroStagePenalties {
1460            spillage_cost: 0.01,
1461            diversion_cost: 0.02,
1462            fpha_turbined_cost: 0.03,
1463            storage_violation_below_cost: 1000.0,
1464            filling_target_violation_cost: 5000.0,
1465            turbined_violation_below_cost: 500.0,
1466            outflow_violation_below_cost: 400.0,
1467            outflow_violation_above_cost: 300.0,
1468            generation_violation_below_cost: 200.0,
1469            evaporation_violation_cost: 150.0,
1470            water_withdrawal_violation_cost: 100.0,
1471            water_withdrawal_violation_pos_cost: 100.0,
1472            water_withdrawal_violation_neg_cost: 100.0,
1473            evaporation_violation_pos_cost: 150.0,
1474            evaporation_violation_neg_cost: 150.0,
1475            inflow_nonnegativity_cost: 1000.0,
1476        }
1477    }
1478
1479    fn make_hydro_bounds() -> HydroStageBounds {
1480        HydroStageBounds {
1481            min_storage_hm3: 10.0,
1482            max_storage_hm3: 200.0,
1483            min_turbined_m3s: 0.0,
1484            max_turbined_m3s: 500.0,
1485            min_outflow_m3s: 5.0,
1486            max_outflow_m3s: None,
1487            min_generation_mw: 0.0,
1488            max_generation_mw: 100.0,
1489            max_diversion_m3s: None,
1490            filling_inflow_m3s: 0.0,
1491            water_withdrawal_m3s: 0.0,
1492        }
1493    }
1494
1495    #[test]
1496    fn test_hydro_stage_penalties_copy() {
1497        let p = make_hydro_penalties();
1498        let q = p;
1499        let r = p;
1500        assert_eq!(q, r);
1501        assert!((q.spillage_cost - p.spillage_cost).abs() < f64::EPSILON);
1502    }
1503
1504    #[test]
1505    fn test_all_penalty_structs_are_copy() {
1506        let bp = BusStagePenalties { excess_cost: 1.0 };
1507        let lp = LineStagePenalties { exchange_cost: 2.0 };
1508        let np = NcsStagePenalties {
1509            curtailment_cost: 3.0,
1510        };
1511
1512        assert_eq!(bp, bp);
1513        assert_eq!(lp, lp);
1514        assert_eq!(np, np);
1515        let bp2 = bp;
1516        let lp2 = lp;
1517        let np2 = np;
1518        assert!((bp2.excess_cost - 1.0).abs() < f64::EPSILON);
1519        assert!((lp2.exchange_cost - 2.0).abs() < f64::EPSILON);
1520        assert!((np2.curtailment_cost - 3.0).abs() < f64::EPSILON);
1521    }
1522
1523    #[test]
1524    fn test_all_bound_structs_are_copy() {
1525        let hb = make_hydro_bounds();
1526        let tb = ThermalStageBounds {
1527            min_generation_mw: 0.0,
1528            max_generation_mw: 100.0,
1529            cost_per_mwh: 50.0,
1530        };
1531        let lb = LineStageBounds {
1532            direct_mw: 500.0,
1533            reverse_mw: 500.0,
1534        };
1535        let pb = PumpingStageBounds {
1536            min_flow_m3s: 0.0,
1537            max_flow_m3s: 20.0,
1538        };
1539        let cb = ContractStageBounds {
1540            min_mw: 0.0,
1541            max_mw: 50.0,
1542            price_per_mwh: 80.0,
1543        };
1544
1545        let hb2 = hb;
1546        let tb2 = tb;
1547        let lb2 = lb;
1548        let pb2 = pb;
1549        let cb2 = cb;
1550        assert_eq!(hb, hb2);
1551        assert_eq!(tb, tb2);
1552        assert_eq!(lb, lb2);
1553        assert_eq!(pb, pb2);
1554        assert_eq!(cb, cb2);
1555    }
1556
1557    #[test]
1558    fn test_resolved_penalties_construction() {
1559        // 2 hydros, 1 bus, 1 line, 1 ncs, 3 stages
1560        let hp = make_hydro_penalties();
1561        let bp = BusStagePenalties { excess_cost: 100.0 };
1562        let lp = LineStagePenalties { exchange_cost: 5.0 };
1563        let np = NcsStagePenalties {
1564            curtailment_cost: 50.0,
1565        };
1566
1567        let table = ResolvedPenalties::new(
1568            &PenaltiesCountsSpec {
1569                n_hydros: 2,
1570                n_buses: 1,
1571                n_lines: 1,
1572                n_ncs: 1,
1573                n_stages: 3,
1574            },
1575            &PenaltiesDefaults {
1576                hydro: hp,
1577                bus: bp,
1578                line: lp,
1579                ncs: np,
1580            },
1581        );
1582
1583        for hydro_idx in 0..2 {
1584            for stage_idx in 0..3 {
1585                let p = table.hydro_penalties(hydro_idx, stage_idx);
1586                assert!((p.spillage_cost - 0.01).abs() < f64::EPSILON);
1587                assert!((p.storage_violation_below_cost - 1000.0).abs() < f64::EPSILON);
1588            }
1589        }
1590
1591        assert!((table.bus_penalties(0, 0).excess_cost - 100.0).abs() < f64::EPSILON);
1592        assert!((table.line_penalties(0, 1).exchange_cost - 5.0).abs() < f64::EPSILON);
1593        assert!((table.ncs_penalties(0, 2).curtailment_cost - 50.0).abs() < f64::EPSILON);
1594    }
1595
1596    #[test]
1597    fn test_resolved_penalties_indexed_access() {
1598        let hp = make_hydro_penalties();
1599        let bp = BusStagePenalties { excess_cost: 10.0 };
1600        let lp = LineStagePenalties { exchange_cost: 1.0 };
1601        let np = NcsStagePenalties {
1602            curtailment_cost: 5.0,
1603        };
1604
1605        let table = ResolvedPenalties::new(
1606            &PenaltiesCountsSpec {
1607                n_hydros: 3,
1608                n_buses: 0,
1609                n_lines: 0,
1610                n_ncs: 0,
1611                n_stages: 5,
1612            },
1613            &PenaltiesDefaults {
1614                hydro: hp,
1615                bus: bp,
1616                line: lp,
1617                ncs: np,
1618            },
1619        );
1620        assert_eq!(table.n_stages(), 5);
1621
1622        let p = table.hydro_penalties(1, 3);
1623        assert!((p.diversion_cost - 0.02).abs() < f64::EPSILON);
1624        assert!((p.filling_target_violation_cost - 5000.0).abs() < f64::EPSILON);
1625    }
1626
1627    #[test]
1628    fn test_resolved_penalties_mutable_update() {
1629        let hp = make_hydro_penalties();
1630        let bp = BusStagePenalties { excess_cost: 10.0 };
1631        let lp = LineStagePenalties { exchange_cost: 1.0 };
1632        let np = NcsStagePenalties {
1633            curtailment_cost: 5.0,
1634        };
1635
1636        let mut table = ResolvedPenalties::new(
1637            &PenaltiesCountsSpec {
1638                n_hydros: 2,
1639                n_buses: 2,
1640                n_lines: 1,
1641                n_ncs: 1,
1642                n_stages: 3,
1643            },
1644            &PenaltiesDefaults {
1645                hydro: hp,
1646                bus: bp,
1647                line: lp,
1648                ncs: np,
1649            },
1650        );
1651
1652        table.hydro_penalties_mut(0, 1).spillage_cost = 99.0;
1653
1654        assert!((table.hydro_penalties(0, 1).spillage_cost - 99.0).abs() < f64::EPSILON);
1655        assert!((table.hydro_penalties(0, 0).spillage_cost - 0.01).abs() < f64::EPSILON);
1656        assert!((table.hydro_penalties(1, 1).spillage_cost - 0.01).abs() < f64::EPSILON);
1657
1658        table.bus_penalties_mut(1, 2).excess_cost = 999.0;
1659        assert!((table.bus_penalties(1, 2).excess_cost - 999.0).abs() < f64::EPSILON);
1660        assert!((table.bus_penalties(0, 2).excess_cost - 10.0).abs() < f64::EPSILON);
1661    }
1662
1663    #[test]
1664    fn test_resolved_bounds_construction() {
1665        let hb = make_hydro_bounds();
1666        let tb = ThermalStageBounds {
1667            min_generation_mw: 50.0,
1668            max_generation_mw: 400.0,
1669            cost_per_mwh: 0.0,
1670        };
1671        let lb = LineStageBounds {
1672            direct_mw: 1000.0,
1673            reverse_mw: 800.0,
1674        };
1675        let pb = PumpingStageBounds {
1676            min_flow_m3s: 0.0,
1677            max_flow_m3s: 20.0,
1678        };
1679        let cb = ContractStageBounds {
1680            min_mw: 0.0,
1681            max_mw: 100.0,
1682            price_per_mwh: 80.0,
1683        };
1684
1685        let table = ResolvedBounds::new(
1686            &BoundsCountsSpec {
1687                n_hydros: 1,
1688                n_thermals: 2,
1689                n_lines: 1,
1690                n_pumping: 1,
1691                n_contracts: 1,
1692                n_stages: 3,
1693            },
1694            &BoundsDefaults {
1695                hydro: hb,
1696                thermal: tb,
1697                line: lb,
1698                pumping: pb,
1699                contract: cb,
1700            },
1701        );
1702
1703        let b = table.hydro_bounds(0, 2);
1704        assert!((b.min_storage_hm3 - 10.0).abs() < f64::EPSILON);
1705        assert!((b.max_storage_hm3 - 200.0).abs() < f64::EPSILON);
1706        assert!(b.max_outflow_m3s.is_none());
1707        assert!(b.max_diversion_m3s.is_none());
1708
1709        let t0 = table.thermal_bounds(0, 0);
1710        let t1 = table.thermal_bounds(1, 2);
1711        assert!((t0.max_generation_mw - 400.0).abs() < f64::EPSILON);
1712        assert!((t1.min_generation_mw - 50.0).abs() < f64::EPSILON);
1713
1714        assert!((table.line_bounds(0, 1).direct_mw - 1000.0).abs() < f64::EPSILON);
1715        assert!((table.pumping_bounds(0, 0).max_flow_m3s - 20.0).abs() < f64::EPSILON);
1716        assert!((table.contract_bounds(0, 2).price_per_mwh - 80.0).abs() < f64::EPSILON);
1717    }
1718
1719    #[test]
1720    fn test_resolved_bounds_mutable_update() {
1721        let hb = make_hydro_bounds();
1722        let tb = ThermalStageBounds {
1723            min_generation_mw: 0.0,
1724            max_generation_mw: 200.0,
1725            cost_per_mwh: 0.0,
1726        };
1727        let lb = LineStageBounds {
1728            direct_mw: 500.0,
1729            reverse_mw: 500.0,
1730        };
1731        let pb = PumpingStageBounds {
1732            min_flow_m3s: 0.0,
1733            max_flow_m3s: 30.0,
1734        };
1735        let cb = ContractStageBounds {
1736            min_mw: 0.0,
1737            max_mw: 50.0,
1738            price_per_mwh: 60.0,
1739        };
1740
1741        let mut table = ResolvedBounds::new(
1742            &BoundsCountsSpec {
1743                n_hydros: 2,
1744                n_thermals: 1,
1745                n_lines: 1,
1746                n_pumping: 1,
1747                n_contracts: 1,
1748                n_stages: 3,
1749            },
1750            &BoundsDefaults {
1751                hydro: hb,
1752                thermal: tb,
1753                line: lb,
1754                pumping: pb,
1755                contract: cb,
1756            },
1757        );
1758
1759        let cell = table.hydro_bounds_mut(1, 0);
1760        cell.min_storage_hm3 = 25.0;
1761        cell.max_outflow_m3s = Some(1000.0);
1762
1763        assert!((table.hydro_bounds(1, 0).min_storage_hm3 - 25.0).abs() < f64::EPSILON);
1764        assert_eq!(table.hydro_bounds(1, 0).max_outflow_m3s, Some(1000.0));
1765        assert!((table.hydro_bounds(0, 0).min_storage_hm3 - 10.0).abs() < f64::EPSILON);
1766        assert!(table.hydro_bounds(1, 1).max_outflow_m3s.is_none());
1767
1768        table.thermal_bounds_mut(0, 2).max_generation_mw = 150.0;
1769        assert!((table.thermal_bounds(0, 2).max_generation_mw - 150.0).abs() < f64::EPSILON);
1770        assert!((table.thermal_bounds(0, 0).max_generation_mw - 200.0).abs() < f64::EPSILON);
1771    }
1772
1773    #[test]
1774    fn test_hydro_stage_bounds_has_eleven_fields() {
1775        let b = HydroStageBounds {
1776            min_storage_hm3: 1.0,
1777            max_storage_hm3: 2.0,
1778            min_turbined_m3s: 3.0,
1779            max_turbined_m3s: 4.0,
1780            min_outflow_m3s: 5.0,
1781            max_outflow_m3s: Some(6.0),
1782            min_generation_mw: 7.0,
1783            max_generation_mw: 8.0,
1784            max_diversion_m3s: Some(9.0),
1785            filling_inflow_m3s: 10.0,
1786            water_withdrawal_m3s: 11.0,
1787        };
1788        assert!((b.min_storage_hm3 - 1.0).abs() < f64::EPSILON);
1789        assert!((b.water_withdrawal_m3s - 11.0).abs() < f64::EPSILON);
1790        assert_eq!(b.max_outflow_m3s, Some(6.0));
1791        assert_eq!(b.max_diversion_m3s, Some(9.0));
1792    }
1793
1794    #[test]
1795    #[cfg(feature = "serde")]
1796    fn test_resolved_penalties_serde_roundtrip() {
1797        let hp = make_hydro_penalties();
1798        let bp = BusStagePenalties { excess_cost: 100.0 };
1799        let lp = LineStagePenalties { exchange_cost: 5.0 };
1800        let np = NcsStagePenalties {
1801            curtailment_cost: 50.0,
1802        };
1803
1804        let original = ResolvedPenalties::new(
1805            &PenaltiesCountsSpec {
1806                n_hydros: 2,
1807                n_buses: 1,
1808                n_lines: 1,
1809                n_ncs: 1,
1810                n_stages: 3,
1811            },
1812            &PenaltiesDefaults {
1813                hydro: hp,
1814                bus: bp,
1815                line: lp,
1816                ncs: np,
1817            },
1818        );
1819        let json = serde_json::to_string(&original).expect("serialize");
1820        let restored: ResolvedPenalties = serde_json::from_str(&json).expect("deserialize");
1821        assert_eq!(original, restored);
1822    }
1823
1824    #[test]
1825    #[cfg(feature = "serde")]
1826    fn test_resolved_bounds_serde_roundtrip() {
1827        let hb = make_hydro_bounds();
1828        let tb = ThermalStageBounds {
1829            min_generation_mw: 0.0,
1830            max_generation_mw: 100.0,
1831            cost_per_mwh: 0.0,
1832        };
1833        let lb = LineStageBounds {
1834            direct_mw: 500.0,
1835            reverse_mw: 500.0,
1836        };
1837        let pb = PumpingStageBounds {
1838            min_flow_m3s: 0.0,
1839            max_flow_m3s: 20.0,
1840        };
1841        let cb = ContractStageBounds {
1842            min_mw: 0.0,
1843            max_mw: 50.0,
1844            price_per_mwh: 80.0,
1845        };
1846
1847        let original = ResolvedBounds::new(
1848            &BoundsCountsSpec {
1849                n_hydros: 1,
1850                n_thermals: 1,
1851                n_lines: 1,
1852                n_pumping: 1,
1853                n_contracts: 1,
1854                n_stages: 3,
1855            },
1856            &BoundsDefaults {
1857                hydro: hb,
1858                thermal: tb,
1859                line: lb,
1860                pumping: pb,
1861                contract: cb,
1862            },
1863        );
1864        let json = serde_json::to_string(&original).expect("serialize");
1865        let restored: ResolvedBounds = serde_json::from_str(&json).expect("deserialize");
1866        assert_eq!(original, restored);
1867    }
1868
1869    // ─── ResolvedGenericConstraintBounds tests ────────────────────────────────
1870
1871    /// `empty()` returns a table where all queries return false/empty.
1872    #[test]
1873    fn test_generic_bounds_empty() {
1874        let t = ResolvedGenericConstraintBounds::empty();
1875        assert!(!t.is_active(0, 0));
1876        assert!(!t.is_active(99, -1));
1877        assert!(t.bounds_for_stage(0, 0).is_empty());
1878        assert!(t.bounds_for_stage(99, 5).is_empty());
1879    }
1880
1881    /// `new()` with 2 constraints, sparse bounds: constraint 0 at stage 0 is active;
1882    /// constraint 1 at stage 0 is not active.
1883    #[test]
1884    fn test_generic_bounds_sparse_active() {
1885        let id_map: HashMap<i32, usize> = [(0, 0), (1, 1)].into_iter().collect();
1886
1887        // One row: constraint_id=0, stage_id=0, block_id=None, bound=100.0
1888        let rows = vec![(0i32, 0i32, None::<i32>, 100.0f64)];
1889        let t = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
1890
1891        assert!(t.is_active(0, 0), "constraint 0 at stage 0 must be active");
1892        assert!(
1893            !t.is_active(1, 0),
1894            "constraint 1 at stage 0 must not be active"
1895        );
1896        assert!(
1897            !t.is_active(0, 1),
1898            "constraint 0 at stage 1 must not be active"
1899        );
1900    }
1901
1902    /// `bounds_for_stage()` with `block_id=None` returns the correct single-entry slice.
1903    #[test]
1904    fn test_generic_bounds_single_block_none() {
1905        let id_map: HashMap<i32, usize> = [(0, 0)].into_iter().collect();
1906        let rows = vec![(0i32, 0i32, None::<i32>, 100.0f64)];
1907        let t = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
1908
1909        let slice = t.bounds_for_stage(0, 0);
1910        assert_eq!(slice.len(), 1);
1911        assert_eq!(slice[0], (None, 100.0));
1912    }
1913
1914    /// Multiple (`block_id`, bound) pairs for the same (constraint, stage).
1915    #[test]
1916    fn test_generic_bounds_multiple_blocks() {
1917        let id_map: HashMap<i32, usize> = [(0, 0)].into_iter().collect();
1918        // Three rows for constraint 0 at stage 2: block None, block 0, block 1.
1919        let rows = vec![
1920            (0i32, 2i32, None::<i32>, 50.0f64),
1921            (0i32, 2i32, Some(0i32), 60.0f64),
1922            (0i32, 2i32, Some(1i32), 70.0f64),
1923        ];
1924        let t = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
1925
1926        assert!(t.is_active(0, 2));
1927        let slice = t.bounds_for_stage(0, 2);
1928        assert_eq!(slice.len(), 3);
1929        assert_eq!(slice[0], (None, 50.0));
1930        assert_eq!(slice[1], (Some(0), 60.0));
1931        assert_eq!(slice[2], (Some(1), 70.0));
1932    }
1933
1934    /// Rows with unknown `constraint_id` are silently skipped.
1935    #[test]
1936    fn test_generic_bounds_unknown_constraint_id_skipped() {
1937        let id_map: HashMap<i32, usize> = [(0, 0)].into_iter().collect();
1938        // Row with constraint_id=99 not in id_map.
1939        let rows = vec![(99i32, 0i32, None::<i32>, 1000.0f64)];
1940        let t = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
1941
1942        assert!(!t.is_active(0, 0), "unknown constraint_id must be skipped");
1943        assert!(t.bounds_for_stage(0, 0).is_empty());
1944    }
1945
1946    /// Empty `raw_bounds` produces a table identical to `empty()`.
1947    #[test]
1948    fn test_generic_bounds_no_rows() {
1949        let id_map: HashMap<i32, usize> = [(0, 0), (1, 1)].into_iter().collect();
1950        let t = ResolvedGenericConstraintBounds::new(&id_map, std::iter::empty());
1951
1952        assert!(!t.is_active(0, 0));
1953        assert!(!t.is_active(1, 0));
1954        assert!(t.bounds_for_stage(0, 0).is_empty());
1955    }
1956
1957    /// Bounds for constraint 0 at stages 0 and 1; constraint 1 has no bounds.
1958    #[test]
1959    fn test_generic_bounds_two_stages_one_constraint() {
1960        let id_map: HashMap<i32, usize> = [(0, 0), (1, 1)].into_iter().collect();
1961        let rows = vec![
1962            (0i32, 0i32, None::<i32>, 100.0f64),
1963            (0i32, 1i32, None::<i32>, 200.0f64),
1964        ];
1965        let t = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
1966
1967        assert!(t.is_active(0, 0));
1968        assert!(t.is_active(0, 1));
1969        assert!(!t.is_active(1, 0));
1970        assert!(!t.is_active(1, 1));
1971
1972        let s0 = t.bounds_for_stage(0, 0);
1973        assert_eq!(s0.len(), 1);
1974        assert!((s0[0].1 - 100.0).abs() < f64::EPSILON);
1975
1976        let s1 = t.bounds_for_stage(0, 1);
1977        assert_eq!(s1.len(), 1);
1978        assert!((s1[0].1 - 200.0).abs() < f64::EPSILON);
1979    }
1980
1981    #[test]
1982    #[cfg(feature = "serde")]
1983    fn test_generic_bounds_serde_roundtrip() {
1984        let id_map: HashMap<i32, usize> = [(0, 0), (1, 1)].into_iter().collect();
1985        let rows = vec![
1986            (0i32, 0i32, None::<i32>, 100.0f64),
1987            (0i32, 0i32, Some(1i32), 150.0f64),
1988            (1i32, 2i32, None::<i32>, 300.0f64),
1989        ];
1990        let original = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
1991        let json = serde_json::to_string(&original).expect("serialize");
1992        let restored: ResolvedGenericConstraintBounds =
1993            serde_json::from_str(&json).expect("deserialize");
1994        assert_eq!(original, restored);
1995    }
1996
1997    // ─── ResolvedLoadFactors tests ─────────────────────────────────────────────
1998
1999    #[test]
2000    fn test_load_factors_empty_returns_one() {
2001        let t = ResolvedLoadFactors::empty();
2002        assert!((t.factor(0, 0, 0) - 1.0).abs() < f64::EPSILON);
2003        assert!((t.factor(5, 3, 2) - 1.0).abs() < f64::EPSILON);
2004    }
2005
2006    #[test]
2007    fn test_load_factors_new_default_is_one() {
2008        let t = ResolvedLoadFactors::new(2, 1, 3);
2009        for bus in 0..2 {
2010            for blk in 0..3 {
2011                assert!(
2012                    (t.factor(bus, 0, blk) - 1.0).abs() < f64::EPSILON,
2013                    "expected 1.0 at ({bus}, 0, {blk})"
2014                );
2015            }
2016        }
2017    }
2018
2019    #[test]
2020    fn test_load_factors_set_and_get() {
2021        let mut t = ResolvedLoadFactors::new(2, 1, 3);
2022        t.set(0, 0, 0, 0.85);
2023        t.set(0, 0, 1, 1.15);
2024        assert!((t.factor(0, 0, 0) - 0.85).abs() < 1e-10);
2025        assert!((t.factor(0, 0, 1) - 1.15).abs() < 1e-10);
2026        assert!((t.factor(0, 0, 2) - 1.0).abs() < f64::EPSILON);
2027        // Bus 1 untouched.
2028        assert!((t.factor(1, 0, 0) - 1.0).abs() < f64::EPSILON);
2029    }
2030
2031    #[test]
2032    fn test_load_factors_out_of_bounds_returns_one() {
2033        let t = ResolvedLoadFactors::new(1, 1, 2);
2034        // Out of bounds on bus index.
2035        assert!((t.factor(5, 0, 0) - 1.0).abs() < f64::EPSILON);
2036        // Out of bounds on block index.
2037        assert!((t.factor(0, 0, 99) - 1.0).abs() < f64::EPSILON);
2038    }
2039
2040    // ─── ResolvedExchangeFactors tests ─────────────────────────────────────────
2041
2042    #[test]
2043    fn test_exchange_factors_empty_returns_one_one() {
2044        let t = ResolvedExchangeFactors::empty();
2045        assert_eq!(t.factors(0, 0, 0), (1.0, 1.0));
2046        assert_eq!(t.factors(5, 3, 2), (1.0, 1.0));
2047    }
2048
2049    #[test]
2050    fn test_exchange_factors_new_default_is_one_one() {
2051        let t = ResolvedExchangeFactors::new(1, 1, 2);
2052        assert_eq!(t.factors(0, 0, 0), (1.0, 1.0));
2053        assert_eq!(t.factors(0, 0, 1), (1.0, 1.0));
2054    }
2055
2056    #[test]
2057    fn test_exchange_factors_set_and_get() {
2058        let mut t = ResolvedExchangeFactors::new(1, 1, 2);
2059        t.set(0, 0, 0, 0.9, 0.85);
2060        assert_eq!(t.factors(0, 0, 0), (0.9, 0.85));
2061        assert_eq!(t.factors(0, 0, 1), (1.0, 1.0));
2062    }
2063
2064    #[test]
2065    fn test_exchange_factors_out_of_bounds_returns_default() {
2066        let t = ResolvedExchangeFactors::new(1, 1, 1);
2067        assert_eq!(t.factors(5, 0, 0), (1.0, 1.0));
2068    }
2069
2070    // ─── ResolvedNcsBounds tests ──────────────────────────────────────────────
2071
2072    #[test]
2073    fn test_ncs_bounds_empty_is_empty() {
2074        let t = ResolvedNcsBounds::empty();
2075        assert!(t.is_empty());
2076        assert!((t.available_generation(0, 0) - 0.0).abs() < f64::EPSILON);
2077    }
2078
2079    #[test]
2080    fn test_ncs_bounds_new_uses_defaults() {
2081        let t = ResolvedNcsBounds::new(2, 3, &[100.0, 200.0]);
2082        assert!(!t.is_empty());
2083        assert!((t.available_generation(0, 0) - 100.0).abs() < f64::EPSILON);
2084        assert!((t.available_generation(0, 2) - 100.0).abs() < f64::EPSILON);
2085        assert!((t.available_generation(1, 0) - 200.0).abs() < f64::EPSILON);
2086        assert!((t.available_generation(1, 2) - 200.0).abs() < f64::EPSILON);
2087    }
2088
2089    #[test]
2090    fn test_ncs_bounds_set_and_get() {
2091        let mut t = ResolvedNcsBounds::new(2, 3, &[100.0, 200.0]);
2092        t.set(0, 1, 50.0);
2093        assert!((t.available_generation(0, 1) - 50.0).abs() < f64::EPSILON);
2094        // Other entries unchanged.
2095        assert!((t.available_generation(0, 0) - 100.0).abs() < f64::EPSILON);
2096        assert!((t.available_generation(1, 0) - 200.0).abs() < f64::EPSILON);
2097    }
2098
2099    #[test]
2100    fn test_ncs_bounds_out_of_bounds_returns_zero() {
2101        let t = ResolvedNcsBounds::new(1, 1, &[100.0]);
2102        assert!((t.available_generation(5, 0) - 0.0).abs() < f64::EPSILON);
2103        assert!((t.available_generation(0, 99) - 0.0).abs() < f64::EPSILON);
2104    }
2105
2106    // ─── ResolvedNcsFactors tests ─────────────────────────────────────────────
2107
2108    #[test]
2109    fn test_ncs_factors_empty_returns_one() {
2110        let t = ResolvedNcsFactors::empty();
2111        assert!((t.factor(0, 0, 0) - 1.0).abs() < f64::EPSILON);
2112        assert!((t.factor(5, 3, 2) - 1.0).abs() < f64::EPSILON);
2113    }
2114
2115    #[test]
2116    fn test_ncs_factors_new_default_is_one() {
2117        let t = ResolvedNcsFactors::new(2, 1, 3);
2118        for ncs in 0..2 {
2119            for blk in 0..3 {
2120                assert!(
2121                    (t.factor(ncs, 0, blk) - 1.0).abs() < f64::EPSILON,
2122                    "factor({ncs}, 0, {blk}) should be 1.0"
2123                );
2124            }
2125        }
2126    }
2127
2128    #[test]
2129    fn test_ncs_factors_set_and_get() {
2130        let mut t = ResolvedNcsFactors::new(2, 1, 3);
2131        t.set(0, 0, 1, 0.8);
2132        assert!((t.factor(0, 0, 1) - 0.8).abs() < 1e-10);
2133        assert!((t.factor(0, 0, 0) - 1.0).abs() < f64::EPSILON);
2134        assert!((t.factor(1, 0, 0) - 1.0).abs() < f64::EPSILON);
2135    }
2136
2137    #[test]
2138    fn test_ncs_factors_out_of_bounds_returns_one() {
2139        let t = ResolvedNcsFactors::new(1, 1, 2);
2140        assert!((t.factor(5, 0, 0) - 1.0).abs() < f64::EPSILON);
2141        assert!((t.factor(0, 0, 99) - 1.0).abs() < f64::EPSILON);
2142    }
2143}