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