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