Skip to main content

cobre_core/model/resolved/
block_bounds.rs

1//! Pre-resolved per-block bound override overlay: layer 1 of the bound
2//! precedence law. A bound row's optional `block_id` selects one block of a
3//! stage rather than the whole stage; this overlay carries only the
4//! per-family columns that a `block_id` may target. A `None` field in any
5//! per-family override struct means no override for that column. An empty
6//! overlay ([`ResolvedBlockBounds::empty`]) makes every per-block lookup fall
7//! back to exactly the stage-wide cell it would otherwise override. Populated
8//! by `cobre-io`; never modified after construction.
9//!
10//! Each `<Family>BlockOverride` struct is a full field-for-field mirror of its
11//! `<Family>BlockBounds` counterpart rather than an `Option<<Family>BlockBounds>`
12//! because optionality is per-**column**, not per-row: a layer-1 row may set
13//! one column and fall through to the base on the rest, which a single
14//! `Option` around the whole struct cannot express.
15
16/// Per-block override for a hydro plant's block-eligible bounds.
17///
18/// Carries only the block-eligible hydro columns — every field on
19/// [`HydroBlockBounds`](super::HydroBlockBounds). Deliberately excludes
20/// `min_storage_hm3`, `max_storage_hm3`, `filling_min_rate_m3s`, and
21/// `water_withdrawal_m3s` — those are stage-level `HydroStageBounds` columns;
22/// a `block_id` on them is an error, not a silent skip.
23#[derive(Debug, Clone, Copy, PartialEq, Default)]
24#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
25pub struct HydroBlockOverride {
26    /// Minimum turbined flow override \[m³/s\].
27    pub min_turbined_m3s: Option<f64>,
28    /// Maximum turbined flow override \[m³/s\].
29    pub max_turbined_m3s: Option<f64>,
30    /// Environmental flow requirement override \[m³/s\].
31    pub min_outflow_m3s: Option<f64>,
32    /// Flood-control limit override \[m³/s\].
33    pub max_outflow_m3s: Option<f64>,
34    /// Minimum generation override \[MW\].
35    pub min_generation_mw: Option<f64>,
36    /// Maximum generation override \[MW\].
37    pub max_generation_mw: Option<f64>,
38    /// Minimum diversion flow override \[m³/s\].
39    pub min_diversion_m3s: Option<f64>,
40    /// Maximum diversion flow override \[m³/s\].
41    pub max_diversion_m3s: Option<f64>,
42    /// Minimum spillage flow override \[m³/s\].
43    pub min_spillage_m3s: Option<f64>,
44    /// Maximum spillage flow override \[m³/s\].
45    pub max_spillage_m3s: Option<f64>,
46}
47
48/// Per-block override for a thermal unit's block-eligible bounds.
49///
50/// Deliberately has no `cost_per_mwh` field: per-block thermal cost is out of
51/// scope, asymmetric with [`ContractBlockOverride::price_per_mwh`], which is
52/// block-eligible.
53#[derive(Debug, Clone, Copy, PartialEq, Default)]
54#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
55pub struct ThermalBlockOverride {
56    /// Minimum stable generation override \[MW\].
57    pub min_generation_mw: Option<f64>,
58    /// Maximum generation capacity override \[MW\].
59    pub max_generation_mw: Option<f64>,
60}
61
62/// Per-block override for a transmission line's block-eligible bounds.
63#[derive(Debug, Clone, Copy, PartialEq, Default)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
65pub struct LineBlockOverride {
66    /// Maximum direct flow capacity override \[MW\].
67    pub direct_mw: Option<f64>,
68    /// Maximum reverse flow capacity override \[MW\].
69    pub reverse_mw: Option<f64>,
70}
71
72/// Per-block override for an energy contract's block-eligible bounds.
73///
74/// `price_per_mwh` IS block-eligible — deliberately asymmetric with
75/// [`ThermalBlockOverride`]'s excluded `cost_per_mwh`.
76#[derive(Debug, Clone, Copy, PartialEq, Default)]
77#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
78pub struct ContractBlockOverride {
79    /// Minimum contract usage override \[MW\].
80    pub min_mw: Option<f64>,
81    /// Maximum contract usage override \[MW\].
82    pub max_mw: Option<f64>,
83    /// Contract price override \[$/`MWh`\].
84    pub price_per_mwh: Option<f64>,
85}
86
87/// Per-block override for a pumping station's block-eligible bounds.
88#[derive(Debug, Clone, Copy, PartialEq, Default)]
89#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
90pub struct PumpingBlockOverride {
91    /// Minimum pumped flow override \[m³/s\].
92    pub min_flow_m3s: Option<f64>,
93    /// Maximum pumped flow override \[m³/s\].
94    pub max_flow_m3s: Option<f64>,
95}
96
97// ─── Pre-resolved container ───────────────────────────────────────────────────
98
99/// Entity counts for constructing a [`ResolvedBlockBounds`] table.
100#[derive(Debug, Clone)]
101pub struct BlockBoundsCountsSpec {
102    /// Number of hydro plants.
103    pub n_hydros: usize,
104    /// Number of thermal units.
105    pub n_thermals: usize,
106    /// Number of transmission lines.
107    pub n_lines: usize,
108    /// Number of pumping stations.
109    pub n_pumping: usize,
110    /// Number of energy contracts.
111    pub n_contracts: usize,
112    /// Number of time stages.
113    pub n_stages: usize,
114    /// Maximum `stage.blocks.len()` across study stages.
115    pub max_blocks: usize,
116}
117
118/// Pre-resolved per-block bound override table for every block-eligible
119/// entity family, across all stages and blocks.
120///
121/// Every family `Vec` is indexed
122/// `(entity_idx * n_stages + stage_idx) * max_blocks + block_idx`, and is
123/// independently lazy: a family stays empty until a row actually resolves to
124/// a cell in that family (via its own `<family>_override_mut`), so a study
125/// with a block row in only one family never allocates the other four. An
126/// empty table ([`ResolvedBlockBounds::empty`]) is the state for every study
127/// with no `block_id` bound row at all; every reader then returns the family
128/// default and every writer returns `None`.
129///
130/// # Examples
131///
132/// ```
133/// use cobre_core::resolved::ResolvedBlockBounds;
134///
135/// let empty = ResolvedBlockBounds::empty();
136/// assert!(empty.is_empty());
137/// assert_eq!(empty.thermal_override(3, 7, 2).max_generation_mw, None);
138/// ```
139#[derive(Debug, Clone, PartialEq)]
140#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
141pub struct ResolvedBlockBounds {
142    n_stages: usize,
143    max_blocks: usize,
144    n_hydros: usize,
145    n_thermals: usize,
146    n_lines: usize,
147    n_pumping: usize,
148    n_contracts: usize,
149    hydro: Vec<HydroBlockOverride>,
150    thermal: Vec<ThermalBlockOverride>,
151    line: Vec<LineBlockOverride>,
152    pumping: Vec<PumpingBlockOverride>,
153    contract: Vec<ContractBlockOverride>,
154}
155
156impl Default for ResolvedBlockBounds {
157    fn default() -> Self {
158        Self::empty()
159    }
160}
161
162impl ResolvedBlockBounds {
163    /// Create an empty per-block override table; every reader returns the
164    /// family default and every writer returns `None`.
165    ///
166    /// # Examples
167    ///
168    /// ```
169    /// use cobre_core::resolved::ResolvedBlockBounds;
170    ///
171    /// let t = ResolvedBlockBounds::empty();
172    /// assert!(t.is_empty());
173    /// assert_eq!(t.hydro_override(5, 3, 2).min_turbined_m3s, None);
174    /// ```
175    #[must_use]
176    pub fn empty() -> Self {
177        Self {
178            n_stages: 0,
179            max_blocks: 0,
180            n_hydros: 0,
181            n_thermals: 0,
182            n_lines: 0,
183            n_pumping: 0,
184            n_contracts: 0,
185            hydro: Vec::new(),
186            thermal: Vec::new(),
187            line: Vec::new(),
188            pumping: Vec::new(),
189            contract: Vec::new(),
190        }
191    }
192
193    /// Store the table's dimensions; every family `Vec` starts empty —
194    /// `<family>_override_mut` grows its own family to full size on its first
195    /// successful write, so a family with zero applied rows stays empty (and
196    /// [`is_empty`](Self::is_empty) stays `true` while every family does).
197    #[must_use]
198    pub fn new(counts: &BlockBoundsCountsSpec) -> Self {
199        Self {
200            n_stages: counts.n_stages,
201            max_blocks: counts.max_blocks,
202            n_hydros: counts.n_hydros,
203            n_thermals: counts.n_thermals,
204            n_lines: counts.n_lines,
205            n_pumping: counts.n_pumping,
206            n_contracts: counts.n_contracts,
207            hydro: Vec::new(),
208            thermal: Vec::new(),
209            line: Vec::new(),
210            pumping: Vec::new(),
211            contract: Vec::new(),
212        }
213    }
214
215    fn flat_index(&self, entity_idx: usize, stage_idx: usize, block_idx: usize) -> Option<usize> {
216        if stage_idx >= self.n_stages || block_idx >= self.max_blocks {
217            return None;
218        }
219        Some((entity_idx * self.n_stages + stage_idx) * self.max_blocks + block_idx)
220    }
221
222    /// Look up the hydro per-block override at `(hydro_idx, stage_idx, block_idx)`.
223    /// Returns [`HydroBlockOverride::default`] (all `None`) when the table is
224    /// empty or any index is out of range.
225    #[inline]
226    #[must_use]
227    pub fn hydro_override(
228        &self,
229        hydro_idx: usize,
230        stage_idx: usize,
231        block_idx: usize,
232    ) -> HydroBlockOverride {
233        self.flat_index(hydro_idx, stage_idx, block_idx)
234            .and_then(|idx| self.hydro.get(idx))
235            .copied()
236            .unwrap_or_default()
237    }
238
239    /// Return a mutable handle to the hydro per-block override cell, growing
240    /// this family to full size on its first call, or `None` when the table
241    /// is empty or any index is out of range.
242    #[inline]
243    pub fn hydro_override_mut(
244        &mut self,
245        hydro_idx: usize,
246        stage_idx: usize,
247        block_idx: usize,
248    ) -> Option<&mut HydroBlockOverride> {
249        let idx = self.flat_index(hydro_idx, stage_idx, block_idx)?;
250        if self.hydro.is_empty() {
251            self.hydro = vec![
252                HydroBlockOverride::default();
253                self.n_hydros * self.n_stages * self.max_blocks
254            ];
255        }
256        self.hydro.get_mut(idx)
257    }
258
259    /// Look up the thermal per-block override at `(thermal_idx, stage_idx, block_idx)`.
260    /// Returns [`ThermalBlockOverride::default`] (all `None`) when the table
261    /// is empty or any index is out of range.
262    #[inline]
263    #[must_use]
264    pub fn thermal_override(
265        &self,
266        thermal_idx: usize,
267        stage_idx: usize,
268        block_idx: usize,
269    ) -> ThermalBlockOverride {
270        self.flat_index(thermal_idx, stage_idx, block_idx)
271            .and_then(|idx| self.thermal.get(idx))
272            .copied()
273            .unwrap_or_default()
274    }
275
276    /// Return a mutable handle to the thermal per-block override cell,
277    /// growing this family to full size on its first call, or `None` when
278    /// the table is empty or any index is out of range.
279    #[inline]
280    pub fn thermal_override_mut(
281        &mut self,
282        thermal_idx: usize,
283        stage_idx: usize,
284        block_idx: usize,
285    ) -> Option<&mut ThermalBlockOverride> {
286        let idx = self.flat_index(thermal_idx, stage_idx, block_idx)?;
287        if self.thermal.is_empty() {
288            self.thermal = vec![
289                ThermalBlockOverride::default();
290                self.n_thermals * self.n_stages * self.max_blocks
291            ];
292        }
293        self.thermal.get_mut(idx)
294    }
295
296    /// Look up the line per-block override at `(line_idx, stage_idx, block_idx)`.
297    /// Returns [`LineBlockOverride::default`] (all `None`) when the table is
298    /// empty or any index is out of range.
299    #[inline]
300    #[must_use]
301    pub fn line_override(
302        &self,
303        line_idx: usize,
304        stage_idx: usize,
305        block_idx: usize,
306    ) -> LineBlockOverride {
307        self.flat_index(line_idx, stage_idx, block_idx)
308            .and_then(|idx| self.line.get(idx))
309            .copied()
310            .unwrap_or_default()
311    }
312
313    /// Return a mutable handle to the line per-block override cell, growing
314    /// this family to full size on its first call, or `None` when the table
315    /// is empty or any index is out of range.
316    #[inline]
317    pub fn line_override_mut(
318        &mut self,
319        line_idx: usize,
320        stage_idx: usize,
321        block_idx: usize,
322    ) -> Option<&mut LineBlockOverride> {
323        let idx = self.flat_index(line_idx, stage_idx, block_idx)?;
324        if self.line.is_empty() {
325            self.line =
326                vec![LineBlockOverride::default(); self.n_lines * self.n_stages * self.max_blocks];
327        }
328        self.line.get_mut(idx)
329    }
330
331    /// Look up the pumping per-block override at `(pumping_idx, stage_idx, block_idx)`.
332    /// Returns [`PumpingBlockOverride::default`] (all `None`) when the table
333    /// is empty or any index is out of range.
334    #[inline]
335    #[must_use]
336    pub fn pumping_override(
337        &self,
338        pumping_idx: usize,
339        stage_idx: usize,
340        block_idx: usize,
341    ) -> PumpingBlockOverride {
342        self.flat_index(pumping_idx, stage_idx, block_idx)
343            .and_then(|idx| self.pumping.get(idx))
344            .copied()
345            .unwrap_or_default()
346    }
347
348    /// Return a mutable handle to the pumping per-block override cell,
349    /// growing this family to full size on its first call, or `None` when
350    /// the table is empty or any index is out of range.
351    #[inline]
352    pub fn pumping_override_mut(
353        &mut self,
354        pumping_idx: usize,
355        stage_idx: usize,
356        block_idx: usize,
357    ) -> Option<&mut PumpingBlockOverride> {
358        let idx = self.flat_index(pumping_idx, stage_idx, block_idx)?;
359        if self.pumping.is_empty() {
360            self.pumping = vec![
361                PumpingBlockOverride::default();
362                self.n_pumping * self.n_stages * self.max_blocks
363            ];
364        }
365        self.pumping.get_mut(idx)
366    }
367
368    /// Look up the contract per-block override at `(contract_idx, stage_idx, block_idx)`.
369    /// Returns [`ContractBlockOverride::default`] (all `None`) when the table
370    /// is empty or any index is out of range.
371    #[inline]
372    #[must_use]
373    pub fn contract_override(
374        &self,
375        contract_idx: usize,
376        stage_idx: usize,
377        block_idx: usize,
378    ) -> ContractBlockOverride {
379        self.flat_index(contract_idx, stage_idx, block_idx)
380            .and_then(|idx| self.contract.get(idx))
381            .copied()
382            .unwrap_or_default()
383    }
384
385    /// Return a mutable handle to the contract per-block override cell,
386    /// growing this family to full size on its first call, or `None` when
387    /// the table is empty or any index is out of range.
388    #[inline]
389    pub fn contract_override_mut(
390        &mut self,
391        contract_idx: usize,
392        stage_idx: usize,
393        block_idx: usize,
394    ) -> Option<&mut ContractBlockOverride> {
395        let idx = self.flat_index(contract_idx, stage_idx, block_idx)?;
396        if self.contract.is_empty() {
397            self.contract = vec![
398                ContractBlockOverride::default();
399                self.n_contracts * self.n_stages * self.max_blocks
400            ];
401        }
402        self.contract.get_mut(idx)
403    }
404
405    /// Returns `true` when every family table is empty.
406    #[inline]
407    #[must_use]
408    pub fn is_empty(&self) -> bool {
409        self.hydro.is_empty()
410            && self.thermal.is_empty()
411            && self.line.is_empty()
412            && self.pumping.is_empty()
413            && self.contract.is_empty()
414    }
415}
416
417// ─── Tests ────────────────────────────────────────────────────────────────────
418
419#[cfg(test)]
420mod tests {
421    use super::{
422        BlockBoundsCountsSpec, ContractBlockOverride, HydroBlockOverride, LineBlockOverride,
423        PumpingBlockOverride, ResolvedBlockBounds, ThermalBlockOverride,
424    };
425
426    #[test]
427    fn test_empty_block_bounds_returns_all_none_and_never_panics() {
428        let table = ResolvedBlockBounds::empty();
429        let result = table.thermal_override(3, 7, 2);
430        assert_eq!(result, ThermalBlockOverride::default());
431        assert_eq!(result.min_generation_mw, None);
432        assert_eq!(result.max_generation_mw, None);
433        assert!(table.is_empty());
434    }
435
436    #[test]
437    fn test_block_override_write_is_visible_at_its_own_triple_only() {
438        let mut table = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
439            n_hydros: 0,
440            n_thermals: 2,
441            n_lines: 0,
442            n_pumping: 0,
443            n_contracts: 0,
444            n_stages: 3,
445            max_blocks: 3,
446        });
447
448        table
449            .thermal_override_mut(1, 2, 0)
450            .unwrap()
451            .max_generation_mw = Some(100.0);
452
453        assert_eq!(
454            table.thermal_override(1, 2, 0).max_generation_mw,
455            Some(100.0)
456        );
457        assert_eq!(table.thermal_override(1, 2, 1).max_generation_mw, None);
458        assert_eq!(table.thermal_override(0, 2, 0).max_generation_mw, None);
459        assert_eq!(table.thermal_override(1, 0, 0).max_generation_mw, None);
460
461        // Cross-axis aliasing guard: with `n_stages == max_blocks` (as here), the
462        // unparenthesized `entity_idx * n_stages + stage_idx * max_blocks +
463        // block_idx` computes the identical cell for (0, 1, 0) and (1, 0, 0) —
464        // this write must not be visible at the transposed triple.
465        table
466            .thermal_override_mut(0, 1, 0)
467            .unwrap()
468            .max_generation_mw = Some(55.0);
469        assert_eq!(
470            table.thermal_override(0, 1, 0).max_generation_mw,
471            Some(55.0)
472        );
473        assert_eq!(table.thermal_override(1, 0, 0).max_generation_mw, None);
474    }
475
476    #[test]
477    fn test_out_of_range_block_override_read_returns_default() {
478        let mut table = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
479            n_hydros: 2,
480            n_thermals: 0,
481            n_lines: 0,
482            n_pumping: 0,
483            n_contracts: 0,
484            n_stages: 3,
485            max_blocks: 3,
486        });
487        table.hydro_override_mut(0, 0, 0).unwrap().max_turbined_m3s = Some(42.0);
488
489        assert_eq!(
490            table.hydro_override(99, 0, 0),
491            HydroBlockOverride::default()
492        );
493        assert_eq!(
494            table.hydro_override(0, 0, 99),
495            HydroBlockOverride::default()
496        );
497        assert!(table.hydro_override_mut(99, 0, 0).is_none());
498        assert!(table.hydro_override_mut(0, 0, 99).is_none());
499    }
500
501    #[test]
502    fn test_block_override_structs_pin_the_cost_price_asymmetry() {
503        let hydro = HydroBlockOverride {
504            min_turbined_m3s: Some(1.0),
505            max_turbined_m3s: Some(2.0),
506            min_outflow_m3s: Some(3.0),
507            max_outflow_m3s: Some(4.0),
508            min_generation_mw: Some(5.0),
509            max_generation_mw: Some(6.0),
510            min_diversion_m3s: Some(7.0),
511            max_diversion_m3s: Some(8.0),
512            min_spillage_m3s: Some(9.0),
513            max_spillage_m3s: Some(10.0),
514        };
515        assert_eq!(hydro.min_turbined_m3s, Some(1.0));
516        assert_eq!(hydro.min_diversion_m3s, Some(7.0));
517        assert_eq!(hydro.max_diversion_m3s, Some(8.0));
518        assert_eq!(hydro.min_spillage_m3s, Some(9.0));
519        assert_eq!(hydro.max_spillage_m3s, Some(10.0));
520
521        let thermal = ThermalBlockOverride {
522            min_generation_mw: Some(1.0),
523            max_generation_mw: Some(2.0),
524        };
525        assert_eq!(thermal.max_generation_mw, Some(2.0));
526
527        let contract = ContractBlockOverride {
528            min_mw: Some(1.0),
529            max_mw: Some(2.0),
530            price_per_mwh: Some(3.0),
531        };
532        assert_eq!(contract.price_per_mwh, Some(3.0));
533    }
534
535    #[test]
536    fn test_round_trip_writes_a_distinct_value_per_family() {
537        let mut table = ResolvedBlockBounds::new(&BlockBoundsCountsSpec {
538            n_hydros: 1,
539            n_thermals: 1,
540            n_lines: 1,
541            n_pumping: 1,
542            n_contracts: 1,
543            n_stages: 2,
544            max_blocks: 2,
545        });
546
547        table.hydro_override_mut(0, 0, 0).unwrap().max_generation_mw = Some(11.0);
548        table
549            .thermal_override_mut(0, 0, 0)
550            .unwrap()
551            .max_generation_mw = Some(22.0);
552        table.line_override_mut(0, 0, 0).unwrap().direct_mw = Some(33.0);
553        table.pumping_override_mut(0, 0, 0).unwrap().max_flow_m3s = Some(44.0);
554        table.contract_override_mut(0, 0, 0).unwrap().price_per_mwh = Some(55.0);
555
556        assert_eq!(table.hydro_override(0, 0, 0).max_generation_mw, Some(11.0));
557        assert_eq!(
558            table.thermal_override(0, 0, 0).max_generation_mw,
559            Some(22.0)
560        );
561        assert_eq!(table.line_override(0, 0, 0).direct_mw, Some(33.0));
562        assert_eq!(table.pumping_override(0, 0, 0).max_flow_m3s, Some(44.0));
563        assert_eq!(table.contract_override(0, 0, 0).price_per_mwh, Some(55.0));
564
565        let l = LineBlockOverride::default();
566        assert_eq!(l.reverse_mw, None);
567        let p = PumpingBlockOverride::default();
568        assert_eq!(p.min_flow_m3s, None);
569    }
570}