Skip to main content

cobre_core/model/resolved/
factors.rs

1//! Pre-resolved per-block factor and NCS-availability lookup tables, consumed on
2//! the LP-building hot path. Absent factor entries return the no-scaling identity
3//! `1.0`; absent NCS availability returns `0.0`.
4//! Populated by `cobre-io`; never modified after construction.
5
6/// Pre-resolved per-block load scaling factors.
7///
8/// O(1) lookup by `(bus_index, stage_index, block_index)` into dense 3D storage
9/// (`n_buses * n_stages * max_blocks`); `1.0` for absent entries (no scaling).
10///
11/// # Examples
12///
13/// ```
14/// use cobre_core::resolved::ResolvedLoadFactors;
15///
16/// let empty = ResolvedLoadFactors::empty();
17/// assert!((empty.factor(0, 0, 0) - 1.0).abs() < f64::EPSILON);
18/// ```
19#[derive(Debug, Clone, PartialEq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21pub struct ResolvedLoadFactors {
22    /// Flat 3D array indexed `(bus_idx * n_stages + stage_idx) * max_blocks + block_idx`.
23    factors: Vec<f64>,
24    n_stages: usize,
25    max_blocks: usize,
26}
27
28impl ResolvedLoadFactors {
29    /// Create an empty load factors table; all lookups return `1.0`.
30    ///
31    /// The default when no `load_factors.json` exists.
32    ///
33    /// # Examples
34    ///
35    /// ```
36    /// use cobre_core::resolved::ResolvedLoadFactors;
37    ///
38    /// let t = ResolvedLoadFactors::empty();
39    /// assert!((t.factor(5, 3, 2) - 1.0).abs() < f64::EPSILON);
40    /// ```
41    #[must_use]
42    pub fn empty() -> Self {
43        Self {
44            factors: Vec::new(),
45            n_stages: 0,
46            max_blocks: 0,
47        }
48    }
49
50    /// Create a new load factors table with the given dimensions.
51    ///
52    /// All entries are initialized to `1.0` (no scaling). Use [`set`] to
53    /// populate individual entries.
54    ///
55    /// [`set`]: Self::set
56    #[must_use]
57    pub fn new(n_buses: usize, n_stages: usize, max_blocks: usize) -> Self {
58        Self {
59            factors: vec![1.0; n_buses * n_stages * max_blocks],
60            n_stages,
61            max_blocks,
62        }
63    }
64
65    /// Set the load factor for a specific `(bus_idx, stage_idx, block_idx)` triple.
66    ///
67    /// # Panics
68    ///
69    /// Panics if any index is out of bounds.
70    pub fn set(&mut self, bus_idx: usize, stage_idx: usize, block_idx: usize, value: f64) {
71        let idx = (bus_idx * self.n_stages + stage_idx) * self.max_blocks + block_idx;
72        self.factors[idx] = value;
73    }
74
75    /// Look up the load factor for a `(bus_idx, stage_idx, block_idx)` triple.
76    /// Returns `1.0` when the table is empty or the flat index lands past `Vec::len`.
77    ///
78    /// The `1.0` fallback only holds for indices past `Vec::len`; a per-dimension
79    /// overflow that stays within `Vec::len` (e.g. `block_idx >= max_blocks` with a
80    /// small `bus_idx`) aliases a neighbouring cell. Callers pass only in-range
81    /// dimensions — do not rely on the fallback for arbitrary out-of-range triples.
82    #[inline]
83    #[must_use]
84    pub fn factor(&self, bus_idx: usize, stage_idx: usize, block_idx: usize) -> f64 {
85        if self.factors.is_empty() {
86            return 1.0;
87        }
88        let idx = (bus_idx * self.n_stages + stage_idx) * self.max_blocks + block_idx;
89        self.factors.get(idx).copied().unwrap_or(1.0)
90    }
91}
92
93/// Pre-resolved per-stage NCS available generation bounds.
94///
95/// O(1) lookup of `available_generation_mw` by `(ncs_index, stage_index)` into
96/// dense 2D storage (`n_ncs * n_stages`); `0.0` for out-of-bounds access. Each NCS
97/// is initialized to its installed capacity (`max_generation_mw`), then
98/// stage-varying entries from `constraints/ncs_bounds.parquet` overwrite individual cells.
99///
100/// # Examples
101///
102/// ```
103/// use cobre_core::resolved::ResolvedNcsBounds;
104///
105/// let empty = ResolvedNcsBounds::empty();
106/// assert!(empty.is_empty());
107/// ```
108#[derive(Debug, Clone, PartialEq)]
109#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
110pub struct ResolvedNcsBounds {
111    /// Flat 2D array indexed `ncs_idx * n_stages + stage_idx`.
112    data: Vec<f64>,
113    n_stages: usize,
114}
115
116impl ResolvedNcsBounds {
117    /// Create an empty NCS bounds table.
118    ///
119    /// The default when no NCS entities exist or no bounds file is provided.
120    ///
121    /// # Examples
122    ///
123    /// ```
124    /// use cobre_core::resolved::ResolvedNcsBounds;
125    ///
126    /// let t = ResolvedNcsBounds::empty();
127    /// assert!(t.is_empty());
128    /// ```
129    #[must_use]
130    pub fn empty() -> Self {
131        Self {
132            data: Vec::new(),
133            n_stages: 0,
134        }
135    }
136
137    /// Create a new NCS bounds table with per-entity defaults.
138    ///
139    /// All stages for NCS entity `i` are initialized to `default_mw[i]`
140    /// (the installed capacity). Use [`set`] to apply stage-varying overrides.
141    ///
142    /// [`set`]: Self::set
143    ///
144    /// # Panics
145    ///
146    /// Panics if `default_mw.len() != n_ncs`.
147    #[must_use]
148    pub fn new(n_ncs: usize, n_stages: usize, default_mw: &[f64]) -> Self {
149        assert!(
150            default_mw.len() == n_ncs,
151            "default_mw length ({}) must equal n_ncs ({n_ncs})",
152            default_mw.len()
153        );
154        let mut data = vec![0.0; n_ncs * n_stages];
155        for (ncs_idx, &mw) in default_mw.iter().enumerate() {
156            data[ncs_idx * n_stages..(ncs_idx + 1) * n_stages].fill(mw);
157        }
158        Self { data, n_stages }
159    }
160
161    /// Set the available generation for a specific `(ncs_idx, stage_idx)` pair.
162    ///
163    /// # Panics
164    ///
165    /// Panics if any index is out of bounds.
166    pub fn set(&mut self, ncs_idx: usize, stage_idx: usize, value: f64) {
167        let idx = ncs_idx * self.n_stages + stage_idx;
168        self.data[idx] = value;
169    }
170
171    /// Look up the available generation (MW) for a `(ncs_idx, stage_idx)` pair.
172    ///
173    /// Returns `0.0` when the index is out of bounds or the table is empty.
174    #[inline]
175    #[must_use]
176    pub fn available_generation(&self, ncs_idx: usize, stage_idx: usize) -> f64 {
177        if self.data.is_empty() {
178            return 0.0;
179        }
180        let idx = ncs_idx * self.n_stages + stage_idx;
181        self.data.get(idx).copied().unwrap_or(0.0)
182    }
183
184    /// Returns `true` when the table has no data.
185    #[inline]
186    #[must_use]
187    pub fn is_empty(&self) -> bool {
188        self.data.is_empty()
189    }
190}
191
192/// Pre-resolved per-block NCS generation scaling factors.
193///
194/// O(1) lookup by `(ncs_index, stage_index, block_index)` into dense 3D storage
195/// (`n_ncs * n_stages * max_blocks`); `1.0` for absent entries (no scaling).
196///
197/// # Examples
198///
199/// ```
200/// use cobre_core::resolved::ResolvedNcsFactors;
201///
202/// let empty = ResolvedNcsFactors::empty();
203/// assert!((empty.factor(0, 0, 0) - 1.0).abs() < f64::EPSILON);
204/// ```
205#[derive(Debug, Clone, PartialEq)]
206#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
207pub struct ResolvedNcsFactors {
208    /// Flat 3D array indexed `(ncs_idx * n_stages + stage_idx) * max_blocks + block_idx`.
209    factors: Vec<f64>,
210    n_stages: usize,
211    max_blocks: usize,
212}
213
214impl ResolvedNcsFactors {
215    /// Create an empty NCS factors table; all lookups return `1.0`.
216    ///
217    /// The default when no `non_controllable_factors.json` exists.
218    ///
219    /// # Examples
220    ///
221    /// ```
222    /// use cobre_core::resolved::ResolvedNcsFactors;
223    ///
224    /// let t = ResolvedNcsFactors::empty();
225    /// assert!((t.factor(5, 3, 2) - 1.0).abs() < f64::EPSILON);
226    /// ```
227    #[must_use]
228    pub fn empty() -> Self {
229        Self {
230            factors: Vec::new(),
231            n_stages: 0,
232            max_blocks: 0,
233        }
234    }
235
236    /// Create a new NCS factors table with the given dimensions.
237    ///
238    /// All entries are initialized to `1.0` (no scaling). Use [`set`] to
239    /// populate individual entries.
240    ///
241    /// [`set`]: Self::set
242    #[must_use]
243    pub fn new(n_ncs: usize, n_stages: usize, max_blocks: usize) -> Self {
244        Self {
245            factors: vec![1.0; n_ncs * n_stages * max_blocks],
246            n_stages,
247            max_blocks,
248        }
249    }
250
251    /// Set the NCS factor for a specific `(ncs_idx, stage_idx, block_idx)` triple.
252    ///
253    /// # Panics
254    ///
255    /// Panics if any index is out of bounds.
256    pub fn set(&mut self, ncs_idx: usize, stage_idx: usize, block_idx: usize, value: f64) {
257        let idx = (ncs_idx * self.n_stages + stage_idx) * self.max_blocks + block_idx;
258        self.factors[idx] = value;
259    }
260
261    /// Look up the NCS factor for a `(ncs_idx, stage_idx, block_idx)` triple.
262    /// Returns `1.0` when the table is empty or the flat index lands past `Vec::len`;
263    /// an in-range per-dimension overflow aliases a neighbouring cell (see
264    /// [`ResolvedLoadFactors::factor`]).
265    #[inline]
266    #[must_use]
267    pub fn factor(&self, ncs_idx: usize, stage_idx: usize, block_idx: usize) -> f64 {
268        if self.factors.is_empty() {
269            return 1.0;
270        }
271        let idx = (ncs_idx * self.n_stages + stage_idx) * self.max_blocks + block_idx;
272        self.factors.get(idx).copied().unwrap_or(1.0)
273    }
274}
275
276// ─── Tests ────────────────────────────────────────────────────────────────────
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    // ─── ResolvedLoadFactors tests ─────────────────────────────────────────────
283
284    #[test]
285    fn test_load_factors_empty_returns_one() {
286        let t = ResolvedLoadFactors::empty();
287        assert!((t.factor(0, 0, 0) - 1.0).abs() < f64::EPSILON);
288        assert!((t.factor(5, 3, 2) - 1.0).abs() < f64::EPSILON);
289    }
290
291    #[test]
292    fn test_load_factors_new_default_is_one() {
293        let t = ResolvedLoadFactors::new(2, 1, 3);
294        for bus in 0..2 {
295            for blk in 0..3 {
296                assert!(
297                    (t.factor(bus, 0, blk) - 1.0).abs() < f64::EPSILON,
298                    "expected 1.0 at ({bus}, 0, {blk})"
299                );
300            }
301        }
302    }
303
304    #[test]
305    fn test_load_factors_set_and_get() {
306        let mut t = ResolvedLoadFactors::new(2, 1, 3);
307        t.set(0, 0, 0, 0.85);
308        t.set(0, 0, 1, 1.15);
309        assert!((t.factor(0, 0, 0) - 0.85).abs() < 1e-10);
310        assert!((t.factor(0, 0, 1) - 1.15).abs() < 1e-10);
311        assert!((t.factor(0, 0, 2) - 1.0).abs() < f64::EPSILON);
312        assert!((t.factor(1, 0, 0) - 1.0).abs() < f64::EPSILON);
313    }
314
315    #[test]
316    fn test_load_factors_out_of_bounds_returns_one() {
317        let t = ResolvedLoadFactors::new(1, 1, 2);
318        assert!((t.factor(5, 0, 0) - 1.0).abs() < f64::EPSILON);
319        assert!((t.factor(0, 0, 99) - 1.0).abs() < f64::EPSILON);
320    }
321
322    // ─── ResolvedNcsBounds tests ──────────────────────────────────────────────
323
324    #[test]
325    fn test_ncs_bounds_empty_is_empty() {
326        let t = ResolvedNcsBounds::empty();
327        assert!(t.is_empty());
328        assert!((t.available_generation(0, 0) - 0.0).abs() < f64::EPSILON);
329    }
330
331    #[test]
332    fn test_ncs_bounds_new_uses_defaults() {
333        let t = ResolvedNcsBounds::new(2, 3, &[100.0, 200.0]);
334        assert!(!t.is_empty());
335        assert!((t.available_generation(0, 0) - 100.0).abs() < f64::EPSILON);
336        assert!((t.available_generation(0, 2) - 100.0).abs() < f64::EPSILON);
337        assert!((t.available_generation(1, 0) - 200.0).abs() < f64::EPSILON);
338        assert!((t.available_generation(1, 2) - 200.0).abs() < f64::EPSILON);
339    }
340
341    #[test]
342    fn test_ncs_bounds_set_and_get() {
343        let mut t = ResolvedNcsBounds::new(2, 3, &[100.0, 200.0]);
344        t.set(0, 1, 50.0);
345        assert!((t.available_generation(0, 1) - 50.0).abs() < f64::EPSILON);
346        assert!((t.available_generation(0, 0) - 100.0).abs() < f64::EPSILON);
347        assert!((t.available_generation(1, 0) - 200.0).abs() < f64::EPSILON);
348    }
349
350    #[test]
351    fn test_ncs_bounds_out_of_bounds_returns_zero() {
352        let t = ResolvedNcsBounds::new(1, 1, &[100.0]);
353        assert!((t.available_generation(5, 0) - 0.0).abs() < f64::EPSILON);
354        assert!((t.available_generation(0, 99) - 0.0).abs() < f64::EPSILON);
355    }
356
357    // ─── ResolvedNcsFactors tests ─────────────────────────────────────────────
358
359    #[test]
360    fn test_ncs_factors_empty_returns_one() {
361        let t = ResolvedNcsFactors::empty();
362        assert!((t.factor(0, 0, 0) - 1.0).abs() < f64::EPSILON);
363        assert!((t.factor(5, 3, 2) - 1.0).abs() < f64::EPSILON);
364    }
365
366    #[test]
367    fn test_ncs_factors_new_default_is_one() {
368        let t = ResolvedNcsFactors::new(2, 1, 3);
369        for ncs in 0..2 {
370            for blk in 0..3 {
371                assert!(
372                    (t.factor(ncs, 0, blk) - 1.0).abs() < f64::EPSILON,
373                    "factor({ncs}, 0, {blk}) should be 1.0"
374                );
375            }
376        }
377    }
378
379    #[test]
380    fn test_ncs_factors_set_and_get() {
381        let mut t = ResolvedNcsFactors::new(2, 1, 3);
382        t.set(0, 0, 1, 0.8);
383        assert!((t.factor(0, 0, 1) - 0.8).abs() < 1e-10);
384        assert!((t.factor(0, 0, 0) - 1.0).abs() < f64::EPSILON);
385        assert!((t.factor(1, 0, 0) - 1.0).abs() < f64::EPSILON);
386    }
387
388    #[test]
389    fn test_ncs_factors_out_of_bounds_returns_one() {
390        let t = ResolvedNcsFactors::new(1, 1, 2);
391        assert!((t.factor(5, 0, 0) - 1.0).abs() < f64::EPSILON);
392        assert!((t.factor(0, 0, 99) - 1.0).abs() < f64::EPSILON);
393    }
394}