Skip to main content

cobre_core/
initial_conditions.rs

1//! Initial conditions for the optimization study.
2//!
3//! [`InitialConditions`] holds the reservoir storage levels and past inflow
4//! values at the start of the study. Two storage arrays are kept separate
5//! because filling hydros can have an initial volume below dead storage
6//! (`min_storage_hm3`), which is not a valid operating level for regular hydros.
7//!
8//! See `internal-structures.md §16` and `input-constraints.md §1` for the
9//! full specification including validation rules.
10//!
11//! # Examples
12//!
13//! ```
14//! use cobre_core::{EntityId, InitialConditions, HydroStorage, HydroPastInflows};
15//!
16//! let ic = InitialConditions {
17//!     storage: vec![
18//!         HydroStorage { hydro_id: EntityId(0), value_hm3: 15_000.0 },
19//!         HydroStorage { hydro_id: EntityId(1), value_hm3:  8_500.0 },
20//!     ],
21//!     filling_storage: vec![
22//!         HydroStorage { hydro_id: EntityId(10), value_hm3: 200.0 },
23//!     ],
24//!     past_inflows: vec![
25//!         HydroPastInflows { hydro_id: EntityId(0), values_m3s: vec![600.0, 500.0], season_ids: None },
26//!     ],
27//!     recent_observations: vec![],
28//! };
29//!
30//! assert_eq!(ic.storage.len(), 2);
31//! assert_eq!(ic.filling_storage.len(), 1);
32//! assert_eq!(ic.past_inflows.len(), 1);
33//! assert_eq!(ic.recent_observations.len(), 0);
34//! ```
35
36use chrono::NaiveDate;
37
38use crate::EntityId;
39
40/// Initial storage volume for a single hydro plant.
41///
42/// For operating hydros, `value_hm3` must be within
43/// `[min_storage_hm3, max_storage_hm3]` (validated by `cobre-io`).
44/// For filling hydros (present in [`InitialConditions::filling_storage`]),
45/// `value_hm3` must be within `[0.0, min_storage_hm3]`.
46#[derive(Debug, Clone, PartialEq)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48pub struct HydroStorage {
49    /// Hydro plant identifier. Must reference a hydro entity in the system.
50    pub hydro_id: EntityId,
51    /// Reservoir volume at the start of the study, in hm³.
52    pub value_hm3: f64,
53}
54
55/// Past inflow values for PAR(p) lag initialization for a single hydro plant.
56///
57/// Each entry provides the most-recent inflow history for one hydro plant,
58/// ordered from most recent (lag 1) to oldest (lag p). The length of
59/// `values_m3s` must be >= the hydro's PAR order.
60#[derive(Debug, Clone, PartialEq)]
61#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
62pub struct HydroPastInflows {
63    /// Hydro plant identifier. Must reference a hydro entity in the system.
64    pub hydro_id: EntityId,
65    /// Past inflow values in m³/s, ordered from most recent (index 0 = lag 1)
66    /// to oldest (index p-1 = lag p).
67    pub values_m3s: Vec<f64>,
68    /// Optional season IDs corresponding to each lag entry in `values_m3s`.
69    ///
70    /// When present, `season_ids.len()` must equal `values_m3s.len()`. Each
71    /// element is a season ID (as defined in `season_definitions`) identifying
72    /// the temporal period of the corresponding lag entry. When absent, no
73    /// temporal validation of lag entries is performed.
74    ///
75    /// In JSON: the field is optional (`serde(default)` fills `None` when the
76    /// key is absent). Backward-compatible with existing JSON files.
77    #[cfg_attr(feature = "serde", serde(default))]
78    pub season_ids: Option<Vec<u32>>,
79}
80
81/// Observed inflow for a single hydro plant over a specific date range.
82///
83/// Used to seed the lag accumulator when a study begins mid-season. Each entry
84/// represents the average inflow (in m³/s) observed between `start_date`
85/// (inclusive) and `end_date` (exclusive) for one hydro. Multiple entries per
86/// hydro are allowed for rolling revisions with several observed weeks.
87///
88/// Date ranges for the same hydro must not overlap; adjacent ranges
89/// (`start_date == previous end_date`) are accepted.
90#[derive(Debug, Clone, PartialEq)]
91#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
92pub struct RecentObservation {
93    /// Hydro plant identifier. Must reference a hydro entity in the system.
94    pub hydro_id: EntityId,
95    /// Start of the observation period (inclusive).
96    pub start_date: NaiveDate,
97    /// End of the observation period (exclusive). Must be after `start_date`.
98    pub end_date: NaiveDate,
99    /// Average inflow observed during the period, in m³/s. Must be finite and
100    /// non-negative.
101    pub value_m3s: f64,
102}
103
104/// Initial system state at the start of the optimization study.
105///
106/// Produced by parsing `initial_conditions.json` (in `cobre-io`) and stored
107/// inside [`crate::System`]. All arrays are sorted by `hydro_id` after
108/// loading to satisfy the declaration-order invariance requirement.
109///
110/// A hydro must appear in exactly one of the two storage arrays, never both.
111/// Hydros with a `filling` configuration belong in [`filling_storage`]; all
112/// other hydros (including late-entry hydros) belong in
113/// [`storage`](InitialConditions::storage).
114///
115/// [`filling_storage`]: InitialConditions::filling_storage
116#[derive(Debug, Clone, PartialEq)]
117#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
118pub struct InitialConditions {
119    /// Initial storage for operating hydros, in hm³ per hydro.
120    pub storage: Vec<HydroStorage>,
121    /// Initial storage for filling hydros (below dead volume), in hm³ per hydro.
122    pub filling_storage: Vec<HydroStorage>,
123    /// Past inflow values for PAR(p) lag initialization, in m³/s per hydro.
124    ///
125    /// For each hydro, `values_m3s[0]` is the most recent past inflow (lag 1)
126    /// and `values_m3s[p-1]` is the oldest (lag p). Absent when lag
127    /// initialization is not required (no PAR models or `inflow_lags: false`).
128    ///
129    /// In JSON: the field is optional on input (`serde(default)` fills an empty
130    /// `Vec` when the key is absent). The field is always emitted on output —
131    /// omitting it would break postcard round-trips used by MPI broadcast.
132    #[cfg_attr(feature = "serde", serde(default))]
133    pub past_inflows: Vec<HydroPastInflows>,
134    /// Observed inflow data for partial periods before the study start.
135    ///
136    /// Used to seed the lag accumulator when a study begins mid-season (i.e.,
137    /// before the first lag-period boundary). Each entry covers one hydro over
138    /// a specific date range. Sorted by `(hydro_id, start_date)` after loading.
139    ///
140    /// In JSON: the field is optional (`serde(default)` fills an empty `Vec`
141    /// when the key is absent). Backward-compatible with existing JSON files.
142    #[cfg_attr(feature = "serde", serde(default))]
143    pub recent_observations: Vec<RecentObservation>,
144}
145
146impl Default for InitialConditions {
147    /// Returns an empty `InitialConditions` (no hydros).
148    fn default() -> Self {
149        Self {
150            storage: Vec::new(),
151            filling_storage: Vec::new(),
152            past_inflows: Vec::new(),
153            recent_observations: Vec::new(),
154        }
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn test_initial_conditions_construction() {
164        let ic = InitialConditions {
165            storage: vec![
166                HydroStorage {
167                    hydro_id: EntityId(0),
168                    value_hm3: 15_000.0,
169                },
170                HydroStorage {
171                    hydro_id: EntityId(1),
172                    value_hm3: 8_500.0,
173                },
174            ],
175            filling_storage: vec![HydroStorage {
176                hydro_id: EntityId(10),
177                value_hm3: 200.0,
178            }],
179            past_inflows: vec![HydroPastInflows {
180                hydro_id: EntityId(0),
181                values_m3s: vec![600.0, 500.0],
182                season_ids: None,
183            }],
184            recent_observations: vec![],
185        };
186
187        assert_eq!(ic.storage.len(), 2);
188        assert_eq!(ic.filling_storage.len(), 1);
189        assert_eq!(ic.past_inflows.len(), 1);
190        assert_eq!(ic.storage[0].hydro_id, EntityId(0));
191        assert_eq!(ic.storage[0].value_hm3, 15_000.0);
192        assert_eq!(ic.storage[1].hydro_id, EntityId(1));
193        assert_eq!(ic.filling_storage[0].hydro_id, EntityId(10));
194        assert_eq!(ic.filling_storage[0].value_hm3, 200.0);
195        assert_eq!(ic.past_inflows[0].hydro_id, EntityId(0));
196        assert_eq!(ic.past_inflows[0].values_m3s, vec![600.0, 500.0]);
197        assert!(ic.recent_observations.is_empty());
198    }
199
200    #[test]
201    fn test_initial_conditions_default_is_empty() {
202        let ic = InitialConditions::default();
203        assert!(ic.storage.is_empty());
204        assert!(ic.filling_storage.is_empty());
205        assert!(ic.past_inflows.is_empty());
206        assert!(ic.recent_observations.is_empty());
207    }
208
209    #[test]
210    fn test_hydro_storage_clone() {
211        let hs = HydroStorage {
212            hydro_id: EntityId(5),
213            value_hm3: 1_234.5,
214        };
215        let cloned = hs.clone();
216        assert_eq!(hs, cloned);
217        assert_eq!(cloned.hydro_id, EntityId(5));
218        assert_eq!(cloned.value_hm3, 1_234.5);
219    }
220
221    #[test]
222    fn test_hydro_past_inflows_clone() {
223        let hpi = HydroPastInflows {
224            hydro_id: EntityId(3),
225            values_m3s: vec![300.0, 200.0, 100.0],
226            season_ids: None,
227        };
228        let cloned = hpi.clone();
229        assert_eq!(hpi, cloned);
230        assert_eq!(cloned.hydro_id, EntityId(3));
231        assert_eq!(cloned.values_m3s, vec![300.0, 200.0, 100.0]);
232    }
233
234    #[cfg(feature = "serde")]
235    #[test]
236    fn test_initial_conditions_serde_roundtrip() {
237        let ic = InitialConditions {
238            storage: vec![
239                HydroStorage {
240                    hydro_id: EntityId(0),
241                    value_hm3: 15_000.0,
242                },
243                HydroStorage {
244                    hydro_id: EntityId(1),
245                    value_hm3: 8_500.0,
246                },
247            ],
248            filling_storage: vec![HydroStorage {
249                hydro_id: EntityId(10),
250                value_hm3: 200.0,
251            }],
252            past_inflows: vec![HydroPastInflows {
253                hydro_id: EntityId(0),
254                values_m3s: vec![600.0, 500.0],
255                season_ids: None,
256            }],
257            recent_observations: vec![],
258        };
259
260        let json = serde_json::to_string(&ic).unwrap();
261        let deserialized: InitialConditions = serde_json::from_str(&json).unwrap();
262        assert_eq!(ic, deserialized);
263    }
264
265    #[cfg(feature = "serde")]
266    #[test]
267    fn test_initial_conditions_serde_roundtrip_empty_past_inflows() {
268        // Empty past_inflows is always serialized as [] (never omitted) to keep
269        // postcard round-trips used by MPI broadcast working correctly.
270        let ic = InitialConditions {
271            storage: vec![HydroStorage {
272                hydro_id: EntityId(0),
273                value_hm3: 1_000.0,
274            }],
275            filling_storage: vec![],
276            past_inflows: vec![],
277            recent_observations: vec![],
278        };
279
280        let json = serde_json::to_string(&ic).unwrap();
281        let deserialized: InitialConditions = serde_json::from_str(&json).unwrap();
282        assert_eq!(ic, deserialized);
283        // Verify the field round-trips correctly (may or may not be present in JSON).
284        assert_eq!(deserialized.past_inflows.len(), 0);
285    }
286
287    #[test]
288    fn test_recent_observation_construction_and_clone() {
289        let obs = RecentObservation {
290            hydro_id: EntityId(2),
291            start_date: NaiveDate::from_ymd_opt(2026, 4, 1)
292                .unwrap_or_else(|| unreachable!("hardcoded date is valid")),
293            end_date: NaiveDate::from_ymd_opt(2026, 4, 4)
294                .unwrap_or_else(|| unreachable!("hardcoded date is valid")),
295            value_m3s: 500.0,
296        };
297        let cloned = obs.clone();
298        assert_eq!(obs, cloned);
299        assert_eq!(cloned.hydro_id, EntityId(2));
300        assert_eq!(cloned.value_m3s, 500.0);
301    }
302
303    #[test]
304    fn test_initial_conditions_construction_with_recent_observations() {
305        let ic = InitialConditions {
306            storage: vec![HydroStorage {
307                hydro_id: EntityId(0),
308                value_hm3: 1_000.0,
309            }],
310            filling_storage: vec![],
311            past_inflows: vec![],
312            recent_observations: vec![RecentObservation {
313                hydro_id: EntityId(0),
314                start_date: NaiveDate::from_ymd_opt(2026, 4, 1)
315                    .unwrap_or_else(|| unreachable!("hardcoded date is valid")),
316                end_date: NaiveDate::from_ymd_opt(2026, 4, 4)
317                    .unwrap_or_else(|| unreachable!("hardcoded date is valid")),
318                value_m3s: 500.0,
319            }],
320        };
321        assert_eq!(ic.recent_observations.len(), 1);
322        assert_eq!(ic.recent_observations[0].hydro_id, EntityId(0));
323        assert_eq!(ic.recent_observations[0].value_m3s, 500.0);
324    }
325
326    #[cfg(feature = "serde")]
327    #[test]
328    fn test_initial_conditions_serde_roundtrip_with_recent_observations() {
329        let ic = InitialConditions {
330            storage: vec![HydroStorage {
331                hydro_id: EntityId(0),
332                value_hm3: 1_000.0,
333            }],
334            filling_storage: vec![],
335            past_inflows: vec![],
336            recent_observations: vec![
337                RecentObservation {
338                    hydro_id: EntityId(0),
339                    start_date: NaiveDate::from_ymd_opt(2026, 4, 1)
340                        .unwrap_or_else(|| unreachable!("hardcoded date is valid")),
341                    end_date: NaiveDate::from_ymd_opt(2026, 4, 4)
342                        .unwrap_or_else(|| unreachable!("hardcoded date is valid")),
343                    value_m3s: 500.0,
344                },
345                RecentObservation {
346                    hydro_id: EntityId(0),
347                    start_date: NaiveDate::from_ymd_opt(2026, 4, 4)
348                        .unwrap_or_else(|| unreachable!("hardcoded date is valid")),
349                    end_date: NaiveDate::from_ymd_opt(2026, 4, 11)
350                        .unwrap_or_else(|| unreachable!("hardcoded date is valid")),
351                    value_m3s: 480.0,
352                },
353            ],
354        };
355        let json = serde_json::to_string(&ic).unwrap();
356        let deserialized: InitialConditions = serde_json::from_str(&json).unwrap();
357        assert_eq!(ic, deserialized);
358        assert_eq!(deserialized.recent_observations.len(), 2);
359    }
360
361    #[cfg(feature = "serde")]
362    #[test]
363    fn test_initial_conditions_serde_default_recent_observations_absent() {
364        // When recent_observations is absent from JSON, it defaults to an empty Vec.
365        let json = r#"{"storage":[],"filling_storage":[]}"#;
366        let ic: InitialConditions = serde_json::from_str(json).unwrap();
367        assert!(ic.recent_observations.is_empty());
368    }
369
370    #[test]
371    fn test_hydro_past_inflows_with_season_ids() {
372        let hpi = HydroPastInflows {
373            hydro_id: EntityId(5),
374            values_m3s: vec![600.0, 500.0],
375            season_ids: Some(vec![3, 2]),
376        };
377        assert_eq!(hpi.hydro_id, EntityId(5));
378        assert_eq!(hpi.values_m3s, vec![600.0, 500.0]);
379        assert_eq!(hpi.season_ids, Some(vec![3, 2]));
380        // Clone preserves season_ids
381        let cloned = hpi.clone();
382        assert_eq!(cloned, hpi);
383    }
384
385    #[cfg(feature = "serde")]
386    #[test]
387    fn test_hydro_past_inflows_serde_roundtrip_with_season_ids() {
388        let hpi = HydroPastInflows {
389            hydro_id: EntityId(5),
390            values_m3s: vec![600.0, 500.0],
391            season_ids: Some(vec![3, 2]),
392        };
393        let json = serde_json::to_string(&hpi).unwrap();
394        let deserialized: HydroPastInflows = serde_json::from_str(&json).unwrap();
395        assert_eq!(hpi, deserialized);
396        assert_eq!(deserialized.season_ids, Some(vec![3, 2]));
397    }
398
399    #[cfg(feature = "serde")]
400    #[test]
401    fn test_hydro_past_inflows_serde_default_season_ids_absent() {
402        // When season_ids is absent from JSON, it defaults to None.
403        let json = r#"{"hydro_id":0,"values_m3s":[600.0,500.0]}"#;
404        let hpi: HydroPastInflows = serde_json::from_str(json).unwrap();
405        assert_eq!(hpi.hydro_id, EntityId(0));
406        assert_eq!(hpi.values_m3s, vec![600.0, 500.0]);
407        assert_eq!(hpi.season_ids, None);
408    }
409}