Skip to main content

cobre_core/model/resolved/
factors.rs

1//! Pre-resolved per-block factor and NCS-availability lookup tables.
2//!
3//! Holds the dense factor tables consumed on the LP-building hot path:
4//! `ResolvedLoadFactors` and `ResolvedExchangeFactors` (per-`(entity, stage,
5//! block)` scaling), plus the non-controllable-source family `ResolvedNcsBounds`
6//! (per-`(ncs, stage)` available generation) and `ResolvedNcsFactors`
7//! (per-`(ncs, stage, block)` scaling). Absent entries return the no-scaling
8//! identity (`1.0`, or `(1.0, 1.0)` for exchange) and absent NCS availability
9//! returns `0.0`. Populated by `cobre-io`; never modified after construction.
10
11// ─── Block factor lookup tables ──────────────────────────────────────────────
12
13/// Pre-resolved per-block load scaling factors.
14///
15/// Provides O(1) lookup of load block factors by `(bus_index, stage_index,
16/// block_index)`. Returns `1.0` for absent entries (no scaling). Populated
17/// by `cobre-io` during the resolution step and stored in [`crate::System`].
18///
19/// Uses dense 3D storage (`n_buses * n_stages * max_blocks`) initialized to
20/// `1.0`. The total size is small (typically < 10K entries) and the lookup is
21/// on the LP-building hot path.
22///
23/// # Examples
24///
25/// ```
26/// use cobre_core::resolved::ResolvedLoadFactors;
27///
28/// let empty = ResolvedLoadFactors::empty();
29/// assert!((empty.factor(0, 0, 0) - 1.0).abs() < f64::EPSILON);
30/// ```
31#[derive(Debug, Clone, PartialEq)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
33pub struct ResolvedLoadFactors {
34    /// Dense 3D array stored flat: `[bus_idx][stage_idx][block_idx]`.
35    /// Dimensions: `n_buses * n_stages * max_blocks`.
36    factors: Vec<f64>,
37    /// Number of stages.
38    n_stages: usize,
39    /// Maximum number of blocks across all stages.
40    max_blocks: usize,
41}
42
43impl ResolvedLoadFactors {
44    /// Create an empty load factors table. All lookups return `1.0`.
45    ///
46    /// Used as the default when no `load_factors.json` exists.
47    ///
48    /// # Examples
49    ///
50    /// ```
51    /// use cobre_core::resolved::ResolvedLoadFactors;
52    ///
53    /// let t = ResolvedLoadFactors::empty();
54    /// assert!((t.factor(5, 3, 2) - 1.0).abs() < f64::EPSILON);
55    /// ```
56    #[must_use]
57    pub fn empty() -> Self {
58        Self {
59            factors: Vec::new(),
60            n_stages: 0,
61            max_blocks: 0,
62        }
63    }
64
65    /// Create a new load factors table with the given dimensions.
66    ///
67    /// All entries are initialized to `1.0` (no scaling). Use [`set`] to
68    /// populate individual entries.
69    ///
70    /// [`set`]: Self::set
71    #[must_use]
72    pub fn new(n_buses: usize, n_stages: usize, max_blocks: usize) -> Self {
73        Self {
74            factors: vec![1.0; n_buses * n_stages * max_blocks],
75            n_stages,
76            max_blocks,
77        }
78    }
79
80    /// Set the load factor for a specific `(bus_idx, stage_idx, block_idx)` triple.
81    ///
82    /// # Panics
83    ///
84    /// Panics if any index is out of bounds.
85    pub fn set(&mut self, bus_idx: usize, stage_idx: usize, block_idx: usize, value: f64) {
86        let idx = (bus_idx * self.n_stages + stage_idx) * self.max_blocks + block_idx;
87        self.factors[idx] = value;
88    }
89
90    /// Look up the load factor for a `(bus_idx, stage_idx, block_idx)` triple.
91    ///
92    /// Returns `1.0` when the table is empty or the computed flat index falls
93    /// past `Vec::len`.
94    ///
95    /// Contract: the `1.0` identity fallback is only guaranteed when the flat
96    /// index `(bus_idx * n_stages + stage_idx) * max_blocks + block_idx` lands
97    /// past the end of the backing `Vec`. A per-dimension overflow that stays
98    /// within `Vec::len` — e.g. `block_idx >= max_blocks` while `bus_idx` is
99    /// small — aliases into a neighbouring cell rather than returning `1.0`.
100    /// Callers (`lp/builder/matrix.rs`) only pass in-range dimensions, so this
101    /// is unreachable in practice; do not rely on the fallback for arbitrary
102    /// out-of-range dimension combinations.
103    #[inline]
104    #[must_use]
105    pub fn factor(&self, bus_idx: usize, stage_idx: usize, block_idx: usize) -> f64 {
106        if self.factors.is_empty() {
107            return 1.0;
108        }
109        let idx = (bus_idx * self.n_stages + stage_idx) * self.max_blocks + block_idx;
110        self.factors.get(idx).copied().unwrap_or(1.0)
111    }
112}
113
114/// Pre-resolved per-block exchange capacity factors.
115///
116/// Provides O(1) lookup of exchange factors by `(line_index, stage_index,
117/// block_index)` returning `(direct_factor, reverse_factor)`. Returns
118/// `(1.0, 1.0)` for absent entries. Populated by `cobre-io` during the
119/// resolution step and stored in [`crate::System`].
120///
121/// # Examples
122///
123/// ```
124/// use cobre_core::resolved::ResolvedExchangeFactors;
125///
126/// let empty = ResolvedExchangeFactors::empty();
127/// assert_eq!(empty.factors(0, 0, 0), (1.0, 1.0));
128/// ```
129#[derive(Debug, Clone, PartialEq)]
130#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
131pub struct ResolvedExchangeFactors {
132    /// Dense 3D array stored flat: `[line_idx][stage_idx][block_idx]`.
133    /// Each entry stores `(direct_factor, reverse_factor)`.
134    data: Vec<(f64, f64)>,
135    /// Number of stages.
136    n_stages: usize,
137    /// Maximum number of blocks across all stages.
138    max_blocks: usize,
139}
140
141impl ResolvedExchangeFactors {
142    /// Create an empty exchange factors table. All lookups return `(1.0, 1.0)`.
143    ///
144    /// Used as the default when no `exchange_factors.json` exists.
145    ///
146    /// # Examples
147    ///
148    /// ```
149    /// use cobre_core::resolved::ResolvedExchangeFactors;
150    ///
151    /// let t = ResolvedExchangeFactors::empty();
152    /// assert_eq!(t.factors(5, 3, 2), (1.0, 1.0));
153    /// ```
154    #[must_use]
155    pub fn empty() -> Self {
156        Self {
157            data: Vec::new(),
158            n_stages: 0,
159            max_blocks: 0,
160        }
161    }
162
163    /// Create a new exchange factors table with the given dimensions.
164    ///
165    /// All entries are initialized to `(1.0, 1.0)` (no scaling). Use [`set`]
166    /// to populate individual entries.
167    ///
168    /// [`set`]: Self::set
169    #[must_use]
170    pub fn new(n_lines: usize, n_stages: usize, max_blocks: usize) -> Self {
171        Self {
172            data: vec![(1.0, 1.0); n_lines * n_stages * max_blocks],
173            n_stages,
174            max_blocks,
175        }
176    }
177
178    /// Set the exchange factors for a specific `(line_idx, stage_idx, block_idx)` triple.
179    ///
180    /// # Panics
181    ///
182    /// Panics if any index is out of bounds.
183    pub fn set(
184        &mut self,
185        line_idx: usize,
186        stage_idx: usize,
187        block_idx: usize,
188        direct_factor: f64,
189        reverse_factor: f64,
190    ) {
191        let idx = (line_idx * self.n_stages + stage_idx) * self.max_blocks + block_idx;
192        self.data[idx] = (direct_factor, reverse_factor);
193    }
194
195    /// Look up the exchange factors for a `(line_idx, stage_idx, block_idx)` triple.
196    ///
197    /// Returns `(direct_factor, reverse_factor)`. Returns `(1.0, 1.0)` when the
198    /// table is empty or the computed flat index falls past `Vec::len`.
199    ///
200    /// Contract: the `(1.0, 1.0)` fallback is only guaranteed when the flat
201    /// index lands past the end of the backing `Vec`. A per-dimension overflow
202    /// that stays within `Vec::len` aliases into a neighbouring cell rather than
203    /// returning the identity. Callers only pass in-range dimensions, so this is
204    /// unreachable in practice.
205    #[inline]
206    #[must_use]
207    pub fn factors(&self, line_idx: usize, stage_idx: usize, block_idx: usize) -> (f64, f64) {
208        if self.data.is_empty() {
209            return (1.0, 1.0);
210        }
211        let idx = (line_idx * self.n_stages + stage_idx) * self.max_blocks + block_idx;
212        self.data.get(idx).copied().unwrap_or((1.0, 1.0))
213    }
214}
215
216/// Pre-resolved per-stage NCS available generation bounds.
217///
218/// Provides O(1) lookup of `available_generation_mw` by `(ncs_index, stage_index)`.
219/// Returns `0.0` for out-of-bounds access. Populated by `cobre-io` during the
220/// resolution step and stored in [`crate::System`].
221///
222/// Uses dense 2D storage (`n_ncs * n_stages`) initialized with each NCS entity's
223/// installed capacity (`max_generation_mw`). Stage-varying overrides from
224/// `constraints/ncs_bounds.parquet` replace individual entries.
225///
226/// # Examples
227///
228/// ```
229/// use cobre_core::resolved::ResolvedNcsBounds;
230///
231/// let empty = ResolvedNcsBounds::empty();
232/// assert!(empty.is_empty());
233/// ```
234#[derive(Debug, Clone, PartialEq)]
235#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
236pub struct ResolvedNcsBounds {
237    /// Dense 2D array: `[ncs_idx * n_stages + stage_idx]`.
238    data: Vec<f64>,
239    /// Number of stages.
240    n_stages: usize,
241}
242
243impl ResolvedNcsBounds {
244    /// Create an empty NCS bounds table.
245    ///
246    /// Used as the default when no NCS entities exist or no bounds file is provided.
247    ///
248    /// # Examples
249    ///
250    /// ```
251    /// use cobre_core::resolved::ResolvedNcsBounds;
252    ///
253    /// let t = ResolvedNcsBounds::empty();
254    /// assert!(t.is_empty());
255    /// ```
256    #[must_use]
257    pub fn empty() -> Self {
258        Self {
259            data: Vec::new(),
260            n_stages: 0,
261        }
262    }
263
264    /// Create a new NCS bounds table with per-entity defaults.
265    ///
266    /// All stages for NCS entity `i` are initialized to `default_mw[i]`
267    /// (the installed capacity). Use [`set`] to apply stage-varying overrides.
268    ///
269    /// [`set`]: Self::set
270    ///
271    /// # Panics
272    ///
273    /// Panics if `default_mw.len() != n_ncs`.
274    #[must_use]
275    pub fn new(n_ncs: usize, n_stages: usize, default_mw: &[f64]) -> Self {
276        assert!(
277            default_mw.len() == n_ncs,
278            "default_mw length ({}) must equal n_ncs ({n_ncs})",
279            default_mw.len()
280        );
281        let mut data = vec![0.0; n_ncs * n_stages];
282        for (ncs_idx, &mw) in default_mw.iter().enumerate() {
283            for stage_idx in 0..n_stages {
284                data[ncs_idx * n_stages + stage_idx] = mw;
285            }
286        }
287        Self { data, n_stages }
288    }
289
290    /// Set the available generation for a specific `(ncs_idx, stage_idx)` pair.
291    ///
292    /// # Panics
293    ///
294    /// Panics if any index is out of bounds.
295    pub fn set(&mut self, ncs_idx: usize, stage_idx: usize, value: f64) {
296        let idx = ncs_idx * self.n_stages + stage_idx;
297        self.data[idx] = value;
298    }
299
300    /// Look up the available generation (MW) for a `(ncs_idx, stage_idx)` pair.
301    ///
302    /// Returns `0.0` when the index is out of bounds or the table is empty.
303    #[inline]
304    #[must_use]
305    pub fn available_generation(&self, ncs_idx: usize, stage_idx: usize) -> f64 {
306        if self.data.is_empty() {
307            return 0.0;
308        }
309        let idx = ncs_idx * self.n_stages + stage_idx;
310        self.data.get(idx).copied().unwrap_or(0.0)
311    }
312
313    /// Returns `true` when the table has no data.
314    #[inline]
315    #[must_use]
316    pub fn is_empty(&self) -> bool {
317        self.data.is_empty()
318    }
319}
320
321/// Pre-resolved per-block NCS generation scaling factors.
322///
323/// Provides O(1) lookup of the generation factor by `(ncs_index, stage_index,
324/// block_index)`. Returns `1.0` for absent entries (no scaling). Populated
325/// by `cobre-io` during the resolution step and stored in [`crate::System`].
326///
327/// Uses dense 3D storage (`n_ncs * n_stages * max_blocks`) initialized to
328/// `1.0`. The total size is small (typically < 10K entries) and the lookup is
329/// on the LP-building hot path.
330///
331/// # Examples
332///
333/// ```
334/// use cobre_core::resolved::ResolvedNcsFactors;
335///
336/// let empty = ResolvedNcsFactors::empty();
337/// assert!((empty.factor(0, 0, 0) - 1.0).abs() < f64::EPSILON);
338/// ```
339#[derive(Debug, Clone, PartialEq)]
340#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
341pub struct ResolvedNcsFactors {
342    /// Dense 3D array stored flat: `[ncs_idx][stage_idx][block_idx]`.
343    /// Dimensions: `n_ncs * n_stages * max_blocks`.
344    factors: Vec<f64>,
345    /// Number of stages.
346    n_stages: usize,
347    /// Maximum number of blocks across all stages.
348    max_blocks: usize,
349}
350
351impl ResolvedNcsFactors {
352    /// Create an empty NCS factors table. All lookups return `1.0`.
353    ///
354    /// Used as the default when no `non_controllable_factors.json` exists.
355    ///
356    /// # Examples
357    ///
358    /// ```
359    /// use cobre_core::resolved::ResolvedNcsFactors;
360    ///
361    /// let t = ResolvedNcsFactors::empty();
362    /// assert!((t.factor(5, 3, 2) - 1.0).abs() < f64::EPSILON);
363    /// ```
364    #[must_use]
365    pub fn empty() -> Self {
366        Self {
367            factors: Vec::new(),
368            n_stages: 0,
369            max_blocks: 0,
370        }
371    }
372
373    /// Create a new NCS factors table with the given dimensions.
374    ///
375    /// All entries are initialized to `1.0` (no scaling). Use [`set`] to
376    /// populate individual entries.
377    ///
378    /// [`set`]: Self::set
379    #[must_use]
380    pub fn new(n_ncs: usize, n_stages: usize, max_blocks: usize) -> Self {
381        Self {
382            factors: vec![1.0; n_ncs * n_stages * max_blocks],
383            n_stages,
384            max_blocks,
385        }
386    }
387
388    /// Set the NCS factor for a specific `(ncs_idx, stage_idx, block_idx)` triple.
389    ///
390    /// # Panics
391    ///
392    /// Panics if any index is out of bounds.
393    pub fn set(&mut self, ncs_idx: usize, stage_idx: usize, block_idx: usize, value: f64) {
394        let idx = (ncs_idx * self.n_stages + stage_idx) * self.max_blocks + block_idx;
395        self.factors[idx] = value;
396    }
397
398    /// Look up the NCS factor for a `(ncs_idx, stage_idx, block_idx)` triple.
399    ///
400    /// Returns `1.0` when the table is empty or the computed flat index falls
401    /// past `Vec::len`.
402    ///
403    /// Contract: the `1.0` identity fallback is only guaranteed when the flat
404    /// index lands past the end of the backing `Vec`. A per-dimension overflow
405    /// that stays within `Vec::len` aliases into a neighbouring cell rather than
406    /// returning `1.0`. Callers only pass in-range dimensions, so this is
407    /// unreachable in practice.
408    #[inline]
409    #[must_use]
410    pub fn factor(&self, ncs_idx: usize, stage_idx: usize, block_idx: usize) -> f64 {
411        if self.factors.is_empty() {
412            return 1.0;
413        }
414        let idx = (ncs_idx * self.n_stages + stage_idx) * self.max_blocks + block_idx;
415        self.factors.get(idx).copied().unwrap_or(1.0)
416    }
417}
418
419// ─── Tests ────────────────────────────────────────────────────────────────────
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    // ─── ResolvedLoadFactors tests ─────────────────────────────────────────────
426
427    #[test]
428    fn test_load_factors_empty_returns_one() {
429        let t = ResolvedLoadFactors::empty();
430        assert!((t.factor(0, 0, 0) - 1.0).abs() < f64::EPSILON);
431        assert!((t.factor(5, 3, 2) - 1.0).abs() < f64::EPSILON);
432    }
433
434    #[test]
435    fn test_load_factors_new_default_is_one() {
436        let t = ResolvedLoadFactors::new(2, 1, 3);
437        for bus in 0..2 {
438            for blk in 0..3 {
439                assert!(
440                    (t.factor(bus, 0, blk) - 1.0).abs() < f64::EPSILON,
441                    "expected 1.0 at ({bus}, 0, {blk})"
442                );
443            }
444        }
445    }
446
447    #[test]
448    fn test_load_factors_set_and_get() {
449        let mut t = ResolvedLoadFactors::new(2, 1, 3);
450        t.set(0, 0, 0, 0.85);
451        t.set(0, 0, 1, 1.15);
452        assert!((t.factor(0, 0, 0) - 0.85).abs() < 1e-10);
453        assert!((t.factor(0, 0, 1) - 1.15).abs() < 1e-10);
454        assert!((t.factor(0, 0, 2) - 1.0).abs() < f64::EPSILON);
455        // Bus 1 untouched.
456        assert!((t.factor(1, 0, 0) - 1.0).abs() < f64::EPSILON);
457    }
458
459    #[test]
460    fn test_load_factors_out_of_bounds_returns_one() {
461        let t = ResolvedLoadFactors::new(1, 1, 2);
462        // Out of bounds on bus index.
463        assert!((t.factor(5, 0, 0) - 1.0).abs() < f64::EPSILON);
464        // Out of bounds on block index.
465        assert!((t.factor(0, 0, 99) - 1.0).abs() < f64::EPSILON);
466    }
467
468    // ─── ResolvedExchangeFactors tests ─────────────────────────────────────────
469
470    #[test]
471    fn test_exchange_factors_empty_returns_one_one() {
472        let t = ResolvedExchangeFactors::empty();
473        assert_eq!(t.factors(0, 0, 0), (1.0, 1.0));
474        assert_eq!(t.factors(5, 3, 2), (1.0, 1.0));
475    }
476
477    #[test]
478    fn test_exchange_factors_new_default_is_one_one() {
479        let t = ResolvedExchangeFactors::new(1, 1, 2);
480        assert_eq!(t.factors(0, 0, 0), (1.0, 1.0));
481        assert_eq!(t.factors(0, 0, 1), (1.0, 1.0));
482    }
483
484    #[test]
485    fn test_exchange_factors_set_and_get() {
486        let mut t = ResolvedExchangeFactors::new(1, 1, 2);
487        t.set(0, 0, 0, 0.9, 0.85);
488        assert_eq!(t.factors(0, 0, 0), (0.9, 0.85));
489        assert_eq!(t.factors(0, 0, 1), (1.0, 1.0));
490    }
491
492    #[test]
493    fn test_exchange_factors_out_of_bounds_returns_default() {
494        let t = ResolvedExchangeFactors::new(1, 1, 1);
495        assert_eq!(t.factors(5, 0, 0), (1.0, 1.0));
496    }
497
498    // ─── ResolvedNcsBounds tests ──────────────────────────────────────────────
499
500    #[test]
501    fn test_ncs_bounds_empty_is_empty() {
502        let t = ResolvedNcsBounds::empty();
503        assert!(t.is_empty());
504        assert!((t.available_generation(0, 0) - 0.0).abs() < f64::EPSILON);
505    }
506
507    #[test]
508    fn test_ncs_bounds_new_uses_defaults() {
509        let t = ResolvedNcsBounds::new(2, 3, &[100.0, 200.0]);
510        assert!(!t.is_empty());
511        assert!((t.available_generation(0, 0) - 100.0).abs() < f64::EPSILON);
512        assert!((t.available_generation(0, 2) - 100.0).abs() < f64::EPSILON);
513        assert!((t.available_generation(1, 0) - 200.0).abs() < f64::EPSILON);
514        assert!((t.available_generation(1, 2) - 200.0).abs() < f64::EPSILON);
515    }
516
517    #[test]
518    fn test_ncs_bounds_set_and_get() {
519        let mut t = ResolvedNcsBounds::new(2, 3, &[100.0, 200.0]);
520        t.set(0, 1, 50.0);
521        assert!((t.available_generation(0, 1) - 50.0).abs() < f64::EPSILON);
522        // Other entries unchanged.
523        assert!((t.available_generation(0, 0) - 100.0).abs() < f64::EPSILON);
524        assert!((t.available_generation(1, 0) - 200.0).abs() < f64::EPSILON);
525    }
526
527    #[test]
528    fn test_ncs_bounds_out_of_bounds_returns_zero() {
529        let t = ResolvedNcsBounds::new(1, 1, &[100.0]);
530        assert!((t.available_generation(5, 0) - 0.0).abs() < f64::EPSILON);
531        assert!((t.available_generation(0, 99) - 0.0).abs() < f64::EPSILON);
532    }
533
534    // ─── ResolvedNcsFactors tests ─────────────────────────────────────────────
535
536    #[test]
537    fn test_ncs_factors_empty_returns_one() {
538        let t = ResolvedNcsFactors::empty();
539        assert!((t.factor(0, 0, 0) - 1.0).abs() < f64::EPSILON);
540        assert!((t.factor(5, 3, 2) - 1.0).abs() < f64::EPSILON);
541    }
542
543    #[test]
544    fn test_ncs_factors_new_default_is_one() {
545        let t = ResolvedNcsFactors::new(2, 1, 3);
546        for ncs in 0..2 {
547            for blk in 0..3 {
548                assert!(
549                    (t.factor(ncs, 0, blk) - 1.0).abs() < f64::EPSILON,
550                    "factor({ncs}, 0, {blk}) should be 1.0"
551                );
552            }
553        }
554    }
555
556    #[test]
557    fn test_ncs_factors_set_and_get() {
558        let mut t = ResolvedNcsFactors::new(2, 1, 3);
559        t.set(0, 0, 1, 0.8);
560        assert!((t.factor(0, 0, 1) - 0.8).abs() < 1e-10);
561        assert!((t.factor(0, 0, 0) - 1.0).abs() < f64::EPSILON);
562        assert!((t.factor(1, 0, 0) - 1.0).abs() < f64::EPSILON);
563    }
564
565    #[test]
566    fn test_ncs_factors_out_of_bounds_returns_one() {
567        let t = ResolvedNcsFactors::new(1, 1, 2);
568        assert!((t.factor(5, 0, 0) - 1.0).abs() < f64::EPSILON);
569        assert!((t.factor(0, 0, 99) - 1.0).abs() < f64::EPSILON);
570    }
571}