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