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}