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] },
26//! ],
27//! };
28//!
29//! assert_eq!(ic.storage.len(), 2);
30//! assert_eq!(ic.filling_storage.len(), 1);
31//! assert_eq!(ic.past_inflows.len(), 1);
32//! ```
33
34use crate::EntityId;
35
36/// Initial storage volume for a single hydro plant.
37///
38/// For operating hydros, `value_hm3` must be within
39/// `[min_storage_hm3, max_storage_hm3]` (validated by `cobre-io`).
40/// For filling hydros (present in [`InitialConditions::filling_storage`]),
41/// `value_hm3` must be within `[0.0, min_storage_hm3]`.
42#[derive(Debug, Clone, PartialEq)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44pub struct HydroStorage {
45 /// Hydro plant identifier. Must reference a hydro entity in the system.
46 pub hydro_id: EntityId,
47 /// Reservoir volume at the start of the study, in hm³.
48 pub value_hm3: f64,
49}
50
51/// Past inflow values for PAR(p) lag initialization for a single hydro plant.
52///
53/// Each entry provides the most-recent inflow history for one hydro plant,
54/// ordered from most recent (lag 1) to oldest (lag p). The length of
55/// `values_m3s` must be >= the hydro's PAR order.
56#[derive(Debug, Clone, PartialEq)]
57#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
58pub struct HydroPastInflows {
59 /// Hydro plant identifier. Must reference a hydro entity in the system.
60 pub hydro_id: EntityId,
61 /// Past inflow values in m³/s, ordered from most recent (index 0 = lag 1)
62 /// to oldest (index p-1 = lag p).
63 pub values_m3s: Vec<f64>,
64}
65
66/// Initial system state at the start of the optimization study.
67///
68/// Produced by parsing `initial_conditions.json` (in `cobre-io`) and stored
69/// inside [`crate::System`]. All arrays are sorted by `hydro_id` after
70/// loading to satisfy the declaration-order invariance requirement.
71///
72/// A hydro must appear in exactly one of the two storage arrays, never both.
73/// Hydros with a `filling` configuration belong in [`filling_storage`]; all
74/// other hydros (including late-entry hydros) belong in
75/// [`storage`](InitialConditions::storage).
76///
77/// [`filling_storage`]: InitialConditions::filling_storage
78#[derive(Debug, Clone, PartialEq)]
79#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
80pub struct InitialConditions {
81 /// Initial storage for operating hydros, in hm³ per hydro.
82 pub storage: Vec<HydroStorage>,
83 /// Initial storage for filling hydros (below dead volume), in hm³ per hydro.
84 pub filling_storage: Vec<HydroStorage>,
85 /// Past inflow values for PAR(p) lag initialization, in m³/s per hydro.
86 ///
87 /// For each hydro, `values_m3s[0]` is the most recent past inflow (lag 1)
88 /// and `values_m3s[p-1]` is the oldest (lag p). Absent when lag
89 /// initialization is not required (no PAR models or `inflow_lags: false`).
90 ///
91 /// In JSON: the field is optional on input (`serde(default)` fills an empty
92 /// `Vec` when the key is absent). The field is always emitted on output —
93 /// omitting it would break postcard round-trips used by MPI broadcast.
94 #[cfg_attr(feature = "serde", serde(default))]
95 pub past_inflows: Vec<HydroPastInflows>,
96}
97
98impl Default for InitialConditions {
99 /// Returns an empty `InitialConditions` (no hydros).
100 fn default() -> Self {
101 Self {
102 storage: Vec::new(),
103 filling_storage: Vec::new(),
104 past_inflows: Vec::new(),
105 }
106 }
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 #[test]
114 fn test_initial_conditions_construction() {
115 let ic = InitialConditions {
116 storage: vec![
117 HydroStorage {
118 hydro_id: EntityId(0),
119 value_hm3: 15_000.0,
120 },
121 HydroStorage {
122 hydro_id: EntityId(1),
123 value_hm3: 8_500.0,
124 },
125 ],
126 filling_storage: vec![HydroStorage {
127 hydro_id: EntityId(10),
128 value_hm3: 200.0,
129 }],
130 past_inflows: vec![HydroPastInflows {
131 hydro_id: EntityId(0),
132 values_m3s: vec![600.0, 500.0],
133 }],
134 };
135
136 assert_eq!(ic.storage.len(), 2);
137 assert_eq!(ic.filling_storage.len(), 1);
138 assert_eq!(ic.past_inflows.len(), 1);
139 assert_eq!(ic.storage[0].hydro_id, EntityId(0));
140 assert_eq!(ic.storage[0].value_hm3, 15_000.0);
141 assert_eq!(ic.storage[1].hydro_id, EntityId(1));
142 assert_eq!(ic.filling_storage[0].hydro_id, EntityId(10));
143 assert_eq!(ic.filling_storage[0].value_hm3, 200.0);
144 assert_eq!(ic.past_inflows[0].hydro_id, EntityId(0));
145 assert_eq!(ic.past_inflows[0].values_m3s, vec![600.0, 500.0]);
146 }
147
148 #[test]
149 fn test_initial_conditions_default_is_empty() {
150 let ic = InitialConditions::default();
151 assert!(ic.storage.is_empty());
152 assert!(ic.filling_storage.is_empty());
153 assert!(ic.past_inflows.is_empty());
154 }
155
156 #[test]
157 fn test_hydro_storage_clone() {
158 let hs = HydroStorage {
159 hydro_id: EntityId(5),
160 value_hm3: 1_234.5,
161 };
162 let cloned = hs.clone();
163 assert_eq!(hs, cloned);
164 assert_eq!(cloned.hydro_id, EntityId(5));
165 assert_eq!(cloned.value_hm3, 1_234.5);
166 }
167
168 #[test]
169 fn test_hydro_past_inflows_clone() {
170 let hpi = HydroPastInflows {
171 hydro_id: EntityId(3),
172 values_m3s: vec![300.0, 200.0, 100.0],
173 };
174 let cloned = hpi.clone();
175 assert_eq!(hpi, cloned);
176 assert_eq!(cloned.hydro_id, EntityId(3));
177 assert_eq!(cloned.values_m3s, vec![300.0, 200.0, 100.0]);
178 }
179
180 #[cfg(feature = "serde")]
181 #[test]
182 fn test_initial_conditions_serde_roundtrip() {
183 let ic = InitialConditions {
184 storage: vec![
185 HydroStorage {
186 hydro_id: EntityId(0),
187 value_hm3: 15_000.0,
188 },
189 HydroStorage {
190 hydro_id: EntityId(1),
191 value_hm3: 8_500.0,
192 },
193 ],
194 filling_storage: vec![HydroStorage {
195 hydro_id: EntityId(10),
196 value_hm3: 200.0,
197 }],
198 past_inflows: vec![HydroPastInflows {
199 hydro_id: EntityId(0),
200 values_m3s: vec![600.0, 500.0],
201 }],
202 };
203
204 let json = serde_json::to_string(&ic).unwrap();
205 let deserialized: InitialConditions = serde_json::from_str(&json).unwrap();
206 assert_eq!(ic, deserialized);
207 }
208
209 #[cfg(feature = "serde")]
210 #[test]
211 fn test_initial_conditions_serde_roundtrip_empty_past_inflows() {
212 // Empty past_inflows is always serialized as [] (never omitted) to keep
213 // postcard round-trips used by MPI broadcast working correctly.
214 let ic = InitialConditions {
215 storage: vec![HydroStorage {
216 hydro_id: EntityId(0),
217 value_hm3: 1_000.0,
218 }],
219 filling_storage: vec![],
220 past_inflows: vec![],
221 };
222
223 let json = serde_json::to_string(&ic).unwrap();
224 let deserialized: InitialConditions = serde_json::from_str(&json).unwrap();
225 assert_eq!(ic, deserialized);
226 // Verify the field round-trips correctly (may or may not be present in JSON).
227 assert_eq!(deserialized.past_inflows.len(), 0);
228 }
229}