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