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}