cobre_sddp/simulation/types.rs
1//! Simulation result types produced by the SDDP simulation forward pass.
2//!
3//! These types form the data contract between the simulation pipeline and the
4//! output writer. The simulation loop produces one [`SimulationScenarioResult`]
5//! per completed scenario; each scenario result is sent through a bounded
6//! channel to a background I/O thread that writes Parquet output files.
7//!
8//! ## Layout
9//!
10//! The types use a nested design: per-entity-type [`Vec`]s grouped by stage
11//! inside [`SimulationStageResult`], then stages grouped by scenario inside
12//! [`SimulationScenarioResult`]. The output writer is responsible for the
13//! columnar transpose when writing Parquet.
14//!
15//! ## Derived columns
16//!
17//! Fields present in the Parquet output schemas but absent from these structs
18//! are derived by the output writer from the fields present here together with
19//! block duration metadata. Examples: `generation_mwh`, `net_flow_mw`,
20//! `losses_mw`, `outflow_m3s`. See simulation-architecture.md SS3.4 for the
21//! complete list.
22
23/// Cost breakdown for one (stage, block) pair.
24///
25/// Corresponds to one row in the costs output schema
26/// (output-schemas.md SS5.1). Contains both aggregate totals and
27/// per-category breakdowns used for cost statistics (SS4.2).
28#[derive(Debug, serde::Serialize, serde::Deserialize)]
29pub struct SimulationCostResult {
30 /// Stage index (0-based).
31 pub stage_id: u32,
32 /// Block index within the stage, or [`None`] for stage-level aggregates.
33 pub block_id: Option<u32>,
34 /// Total discounted stage cost: `immediate_cost + future_cost`.
35 pub total_cost: f64,
36 /// Undiscounted stage immediate cost (before applying `discount_factor`).
37 pub immediate_cost: f64,
38 /// Future cost function (theta variable value) at this stage.
39 pub future_cost: f64,
40 /// Cumulative discount factor at this stage for present-value reporting.
41 ///
42 /// `D_0 = 1.0`, `D_t = D_{t-1} * d_{t-1}` where `d_t` is the one-step
43 /// discount factor for the transition departing stage `t`. The present
44 /// value of `immediate_cost` is `discount_factor * immediate_cost`.
45 /// When `annual_discount_rate == 0.0`, this field is `1.0` for all stages.
46 pub discount_factor: f64,
47 // Resource costs
48 /// Cost attributed to thermal generation dispatch.
49 pub thermal_cost: f64,
50 /// Cost attributed to anticipated (forward-committed) thermal generation.
51 ///
52 /// Charged on the anticipated-decision column at the decision stage
53 /// (`cost_per_mwh * delivery_hours * discount[delivery]`), where the
54 /// delivery-stage generation cost is zeroed to avoid double-counting. This
55 /// is the fuel that `immediate_cost` already includes but `thermal_cost`
56 /// (which sums only the per-block generation columns) does not, so reporting
57 /// it as its own category lets the breakdown reconcile to `immediate_cost`.
58 /// Zero for cases with no anticipated thermals.
59 pub anticipated_thermal_cost: f64,
60 /// Cost attributed to contract energy delivery.
61 pub contract_cost: f64,
62 /// Cost of load deficit (emergency energy).
63 pub deficit_cost: f64,
64 /// Cost of load excess (excess energy penalty).
65 pub excess_cost: f64,
66 /// Cost of reservoir storage bound violations.
67 pub storage_violation_cost: f64,
68 /// Cost of filling target violations.
69 pub filling_target_cost: f64,
70 /// Cost of hydro operational constraint violations.
71 pub hydro_violation_cost: f64,
72 /// Cost of minimum outflow violations: sum of `outflow_below_slack` * penalty.
73 pub outflow_violation_below_cost: f64,
74 /// Cost of maximum outflow violations: sum of `outflow_above_slack` * penalty.
75 pub outflow_violation_above_cost: f64,
76 /// Cost of minimum turbining violations: sum of `turbine_below_slack` * penalty.
77 pub turbined_violation_cost: f64,
78 /// Cost of minimum generation violations: sum of `generation_below_slack` * penalty.
79 pub generation_violation_cost: f64,
80 /// Cost of evaporation constraint violations.
81 pub evaporation_violation_cost: f64,
82 /// Cost of water withdrawal constraint violations.
83 pub withdrawal_violation_cost: f64,
84 /// Cost of inflow non-negativity constraint violations.
85 pub inflow_penalty_cost: f64,
86 /// Cost of generic constraint violations.
87 pub generic_violation_cost: f64,
88 /// Regularization cost for reservoir spillage.
89 pub spillage_cost: f64,
90 /// Regularization cost for turbining (applied to every hydro's turbine flow).
91 pub turbined_cost: f64,
92 /// Regularization cost for non-controllable source curtailment.
93 pub curtailment_cost: f64,
94 /// Regularization cost for transmission exchange.
95 pub exchange_cost: f64,
96 /// Imputed cost for pumping station operation.
97 pub pumping_cost: f64,
98}
99
100/// Hydro plant result for one (stage, block, hydro) tuple.
101///
102/// Corresponds to one row in the hydros output schema
103/// (output-schemas.md SS5.2). Derived columns (`generation_mwh`,
104/// `outflow_m3s`) are computed by the output writer.
105#[derive(Debug, serde::Serialize, serde::Deserialize)]
106pub struct SimulationHydroResult {
107 /// Stage index (0-based).
108 pub stage_id: u32,
109 /// Block index within the stage.
110 pub block_id: Option<u32>,
111 /// Hydro plant entity ID.
112 pub hydro_id: i32,
113 /// Turbined flow in m³/s.
114 pub turbined_m3s: f64,
115 /// Spilled flow in m³/s.
116 pub spillage_m3s: f64,
117 /// Evaporation loss in m³/s, or [`None`] if evaporation is not modeled.
118 pub evaporation_m3s: Option<f64>,
119 /// Diverted inflow from upstream diversion in m³/s, or [`None`] if no
120 /// diversion exists.
121 pub diverted_inflow_m3s: Option<f64>,
122 /// Diverted outflow to downstream diversion in m³/s, or [`None`] if no
123 /// diversion exists.
124 pub diverted_outflow_m3s: Option<f64>,
125 /// Incremental (local) natural inflow in m³/s.
126 pub incremental_inflow_m3s: f64,
127 /// Total inflow to the reservoir in m³/s (incremental + upstream).
128 pub inflow_m3s: f64,
129 /// Reservoir storage at the start of the block in hm³.
130 pub storage_initial_hm3: f64,
131 /// Reservoir storage at the end of the block in hm³.
132 pub storage_final_hm3: f64,
133 /// Active power generation in MW.
134 pub generation_mw: f64,
135 /// Equivalent productivity `ρ_eq` \[MW/(m³/s)\]. For `ConstantProductivity` /
136 /// `LinearizedHead` models this is the stored input scalar; for FPHA models
137 /// it is derived from VHA geometry, `ρ_esp`, `V_ref`, and `Q_ref` by
138 /// `EnergyConversionSet`. Always populated.
139 pub equivalent_productivity_mw_per_m3s: f64,
140 /// Accumulated productivity `ρ_acum` \[MW/(m³/s)\]: the sum of `ρ_eq` along
141 /// the downstream cascade. Always populated.
142 pub accumulated_productivity_mw_per_m3s: f64,
143 /// Incremental inflow expressed in energy units
144 /// (`ρ_acum · incremental_inflow_m3s`) \[MW\].
145 pub incremental_inflow_energy_mw: f64,
146 /// Stored energy at the start of the block
147 /// (`(storage_initial_hm3 − V_min) · ρ_acum · 10^6 / 3600`) \[`MWh`\].
148 pub stored_energy_initial_mwh: f64,
149 /// Stored energy at the end of the block
150 /// (`(storage_final_hm3 − V_min) · ρ_acum · 10^6 / 3600`) \[`MWh`\].
151 pub stored_energy_final_mwh: f64,
152 /// Regularization cost for spillage at this plant.
153 pub spillage_cost: f64,
154 /// Water value (dual of the storage balance constraint) in cost/hm³.
155 pub water_value_per_hm3: f64,
156 /// Storage binding code: indicates which storage bound is active.
157 /// Encoded as `i8` matching the Parquet schema.
158 pub storage_binding_code: i8,
159 /// Operative state code for this hydro plant at this block.
160 pub operative_state_code: i8,
161 // Violation slacks
162 /// Turbining capacity slack in m³/s.
163 pub turbined_slack_m3s: f64,
164 /// Minimum outflow violation slack in m³/s.
165 pub outflow_slack_below_m3s: f64,
166 /// Maximum outflow violation slack in m³/s.
167 pub outflow_slack_above_m3s: f64,
168 /// Generation capacity violation slack in MW.
169 pub generation_slack_mw: f64,
170 /// Storage below minimum bound violation in hm³.
171 pub storage_violation_below_hm3: f64,
172 /// Filling target violation in hm³.
173 pub filling_target_violation_hm3: f64,
174 /// Over-evaporation violation in m³/s (evaporated more than target).
175 pub evaporation_violation_pos_m3s: f64,
176 /// Under-evaporation violation in m³/s (evaporated less than target).
177 pub evaporation_violation_neg_m3s: f64,
178 /// Inflow non-negativity constraint slack in m³/s.
179 pub inflow_nonnegativity_slack_m3s: f64,
180 /// Over-withdrawal violation in m³/s (withdrew more than target).
181 /// Zero when no withdrawal is modeled.
182 pub water_withdrawal_violation_pos_m3s: f64,
183 /// Under-withdrawal violation in m³/s (withdrew less than target).
184 /// Zero when no withdrawal is modeled or withdrawal is fully sustained.
185 pub water_withdrawal_violation_neg_m3s: f64,
186}
187
188/// Thermal unit result for one (stage, block, thermal) tuple.
189///
190/// Corresponds to one row in the thermals output schema
191/// (output-schemas.md SS5.3). The derived column `generation_mwh`
192/// is computed by the output writer.
193#[derive(Debug, serde::Serialize, serde::Deserialize)]
194pub struct SimulationThermalResult {
195 /// Stage index (0-based).
196 pub stage_id: u32,
197 /// Block index within the stage.
198 pub block_id: Option<u32>,
199 /// Thermal unit entity ID.
200 pub thermal_id: i32,
201 /// Active power generation in MW.
202 pub generation_mw: f64,
203 /// Variable generation cost for this dispatch.
204 pub generation_cost: f64,
205 /// Whether this unit uses anticipated dispatch.
206 pub is_anticipated: bool,
207 /// Realised delivery commitment in MW, or `None` if not anticipated.
208 pub anticipated_committed_mw: Option<f64>,
209 /// New anticipated commitment decided at this stage in MW, or `None`.
210 pub anticipated_decision_mw: Option<f64>,
211 /// Operative state code for this thermal unit at this block.
212 pub operative_state_code: i8,
213}
214
215/// Exchange (transmission line) result for one (stage, block, line) tuple.
216///
217/// Corresponds to one row in the exchanges output schema
218/// (output-schemas.md SS5.4). Derived columns (`net_flow_mw`,
219/// `losses_mw`, and all `MWh` energy columns) are computed by the output
220/// writer.
221#[derive(Debug, serde::Serialize, serde::Deserialize)]
222pub struct SimulationExchangeResult {
223 /// Stage index (0-based).
224 pub stage_id: u32,
225 /// Block index within the stage.
226 pub block_id: Option<u32>,
227 /// Transmission line entity ID.
228 pub line_id: i32,
229 /// Power flow in the direct (forward) direction in MW.
230 pub direct_flow_mw: f64,
231 /// Power flow in the reverse direction in MW.
232 pub reverse_flow_mw: f64,
233 /// Exchange regularization cost for this line.
234 pub exchange_cost: f64,
235 /// Operative state code for this line at this block.
236 pub operative_state_code: i8,
237}
238
239/// Bus result for one (stage, block, bus) tuple.
240///
241/// Corresponds to one row in the buses output schema
242/// (output-schemas.md SS5.5). Derived columns (`load_mwh`,
243/// `deficit_mwh`, `excess_mwh`) are computed by the output writer.
244#[derive(Debug, serde::Serialize, serde::Deserialize)]
245pub struct SimulationBusResult {
246 /// Stage index (0-based).
247 pub stage_id: u32,
248 /// Block index within the stage.
249 pub block_id: Option<u32>,
250 /// Bus entity ID.
251 pub bus_id: i32,
252 /// Total demand (load) at this bus in MW.
253 pub load_mw: f64,
254 /// Load deficit (unserved demand) at this bus in MW.
255 pub deficit_mw: f64,
256 /// Load excess at this bus in MW.
257 pub excess_mw: f64,
258 /// Marginal cost of energy (spot price) at this bus in cost/MWh.
259 pub spot_price: f64,
260}
261
262/// Pumping station result for one (stage, block, station) tuple.
263///
264/// Corresponds to one row in the `pumping_stations` output schema
265/// (output-schemas.md SS5.6). Derived columns (`pumped_volume_hm3`,
266/// `energy_consumption_mwh`) are computed by the output writer.
267#[derive(Debug, serde::Serialize, serde::Deserialize)]
268pub struct SimulationPumpingResult {
269 /// Stage index (0-based).
270 pub stage_id: u32,
271 /// Block index within the stage.
272 pub block_id: Option<u32>,
273 /// Pumping station entity ID.
274 pub pumping_station_id: i32,
275 /// Pumped flow rate in m³/s.
276 pub pumped_flow_m3s: f64,
277 /// Active power consumed by pumping in MW.
278 pub power_consumption_mw: f64,
279 /// Imputed pumping cost.
280 pub pumping_cost: f64,
281 /// Operative state code for this station at this block.
282 pub operative_state_code: i8,
283}
284
285/// Contract result for one (stage, block, contract) tuple.
286///
287/// Corresponds to one row in the contracts output schema
288/// (output-schemas.md SS5.7). The derived column `energy_mwh` is
289/// computed by the output writer.
290#[derive(Debug, serde::Serialize, serde::Deserialize)]
291pub struct SimulationContractResult {
292 /// Stage index (0-based).
293 pub stage_id: u32,
294 /// Block index within the stage.
295 pub block_id: Option<u32>,
296 /// Contract entity ID.
297 pub contract_id: i32,
298 /// Contracted power in MW.
299 pub power_mw: f64,
300 /// Contract price in cost/MWh.
301 pub price_per_mwh: f64,
302 /// Total cost for this contract at this block.
303 pub total_cost: f64,
304 /// Operative state code for this contract at this block.
305 pub operative_state_code: i8,
306}
307
308/// Non-controllable source result for one (stage, block, source) tuple.
309///
310/// Corresponds to one row in the `non_controllables` output schema
311/// (output-schemas.md SS5.8). Derived columns (`generation_mwh`,
312/// `curtailment_mwh`) are computed by the output writer.
313#[derive(Debug, serde::Serialize, serde::Deserialize)]
314pub struct SimulationNonControllableResult {
315 /// Stage index (0-based).
316 pub stage_id: u32,
317 /// Block index within the stage.
318 pub block_id: Option<u32>,
319 /// Non-controllable source entity ID.
320 pub non_controllable_id: i32,
321 /// Active power injected into the grid in MW.
322 pub generation_mw: f64,
323 /// Maximum available power from this source in MW.
324 pub available_mw: f64,
325 /// Curtailed power (available minus injected) in MW.
326 pub curtailment_mw: f64,
327 /// Curtailment regularization cost.
328 pub curtailment_cost: f64,
329 /// Operative state code for this source at this block.
330 pub operative_state_code: i8,
331}
332
333/// Inflow lag state for one (stage, hydro, `lag_index`) tuple.
334///
335/// Corresponds to one row in the `inflow_lags` output schema
336/// (output-schemas.md SS5.10). Only populated for hydro plants whose
337/// PAR(p) model has AR order > 0.
338#[derive(Debug, serde::Serialize, serde::Deserialize)]
339pub struct SimulationInflowLagResult {
340 /// Stage index (0-based).
341 pub stage_id: u32,
342 /// Hydro plant entity ID.
343 pub hydro_id: i32,
344 /// Lag index within the AR model (0 = most recent past period).
345 pub lag_index: u32,
346 /// Observed inflow at this lag in m³/s.
347 pub inflow_m3s: f64,
348}
349
350/// Generic constraint violation for one (stage, block, constraint) tuple.
351///
352/// Corresponds to one row in the violations/generic output schema
353/// (output-schemas.md SS5.11). Only entries with non-zero slack values
354/// are included.
355#[derive(Debug, serde::Serialize, serde::Deserialize)]
356pub struct SimulationGenericViolationResult {
357 /// Stage index (0-based).
358 pub stage_id: u32,
359 /// Block index within the stage.
360 pub block_id: Option<u32>,
361 /// Generic constraint entity ID.
362 pub constraint_id: i32,
363 /// Violation slack value (non-negative).
364 pub slack_value: f64,
365 /// Cost incurred for this violation.
366 pub slack_cost: f64,
367}
368
369/// All simulation results for a single stage within one scenario.
370///
371/// The simulation loop produces one [`SimulationStageResult`] per stage per
372/// scenario. Each per-entity-type [`Vec`] holds one entry per (block, entity)
373/// pair within the stage. Entity types that are absent from the system or that
374/// produce no violations result in empty [`Vec`]s.
375#[derive(Debug, serde::Serialize, serde::Deserialize)]
376pub struct SimulationStageResult {
377 /// Stage index (0-based).
378 pub stage_id: u32,
379 /// Cost breakdown results for this stage (one entry per block).
380 pub costs: Vec<SimulationCostResult>,
381 /// Hydro plant results for this stage.
382 pub hydros: Vec<SimulationHydroResult>,
383 /// Thermal unit results for this stage.
384 pub thermals: Vec<SimulationThermalResult>,
385 /// Transmission line (exchange) results for this stage.
386 pub exchanges: Vec<SimulationExchangeResult>,
387 /// Bus results for this stage.
388 pub buses: Vec<SimulationBusResult>,
389 /// Pumping station results for this stage.
390 /// Empty if no pumping stations exist in the system.
391 pub pumping_stations: Vec<SimulationPumpingResult>,
392 /// Contract results for this stage.
393 /// Empty if no contracts exist in the system.
394 pub contracts: Vec<SimulationContractResult>,
395 /// Non-controllable source results for this stage.
396 /// Empty if no non-controllable sources exist in the system.
397 pub non_controllables: Vec<SimulationNonControllableResult>,
398 /// Inflow lag state records for this stage.
399 /// Empty if no hydros have AR order > 0.
400 pub inflow_lags: Vec<SimulationInflowLagResult>,
401 /// Generic constraint violation records for this stage.
402 /// Empty if no generic constraints exist or no violations occurred.
403 /// Only non-zero violations are included.
404 pub generic_violations: Vec<SimulationGenericViolationResult>,
405}
406
407/// Per-category cost totals for one scenario, summed across all stages.
408///
409/// Matches the category breakdown in SS4.2 and is retained in the compact
410/// cost buffer even after per-stage detail is streamed to the output writer.
411#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
412pub struct ScenarioCategoryCosts {
413 /// Sum of thermal and contract costs: `thermal_cost + contract_cost`.
414 pub resource_cost: f64,
415 /// Sum of deficit and excess costs: `deficit_cost + excess_cost`.
416 pub recourse_cost: f64,
417 /// Sum of all violation costs: `storage_violation_cost +
418 /// filling_target_cost + hydro_violation_cost + inflow_penalty_cost +
419 /// generic_violation_cost`.
420 pub violation_cost: f64,
421 /// Sum of regularization costs: `spillage_cost + turbined_cost +
422 /// curtailment_cost + exchange_cost`.
423 pub regularization_cost: f64,
424 /// Imputed pumping cost: `pumping_cost`.
425 pub imputed_cost: f64,
426}
427
428/// Complete simulation result for one scenario.
429///
430/// This is the payload type of the bounded channel connecting simulation
431/// threads to the background I/O thread (SS6.1).
432///
433/// # Send bound
434///
435/// [`SimulationScenarioResult`] implements `Send` because it is transferred
436/// across a thread boundary: the simulation thread that produces it sends it
437/// through the channel to the dedicated I/O thread. All constituent types are
438/// `Send`-safe (plain data, no `Rc`, no raw pointers, no non-`Send` interior
439/// mutability).
440///
441/// # Memory lifetime
442///
443/// Each instance is produced by a simulation thread, sent through the channel,
444/// consumed by the I/O thread for Parquet writing, and then dropped. At most
445/// `channel_capacity` instances exist simultaneously (bounded by channel
446/// backpressure). See SS3.3.
447#[derive(Debug, serde::Serialize, serde::Deserialize)]
448pub struct SimulationScenarioResult {
449 /// 0-based scenario identifier, unique across all MPI ranks.
450 /// Determines the Hive partition path:
451 /// `{entity}/scenario_id={scenario_id:04d}/data.parquet`.
452 pub scenario_id: u32,
453
454 /// Total discounted cost for this scenario, summed across all stages.
455 /// Computed as the sum over stages of the cumulative discount factor
456 /// times the stage immediate cost.
457 pub total_cost: f64,
458
459 /// Per-category cost components for this scenario, summed across all
460 /// stages. Used for per-category statistics (SS4.2) and retained in the
461 /// compact cost buffer (SS3.3) even after per-stage detail is streamed to
462 /// the output writer.
463 pub per_category_costs: ScenarioCategoryCosts,
464
465 /// Per-stage detailed results. Present when the output detail level
466 /// (SS6.2) is Stage-level or Full. An empty [`Vec`] when detail level is
467 /// Summary — in that case only `scenario_id`, `total_cost`, and
468 /// `per_category_costs` are populated.
469 pub stages: Vec<SimulationStageResult>,
470}
471
472/// Aggregate simulation statistics computed after all scenarios complete.
473///
474/// Produced by MPI aggregation on rank 0 (simulation-architecture.md SS4.4)
475/// and returned as the `Ok` value of `fn simulate()`.
476///
477/// On non-rank-0 processes, `mean_cost`, `std_cost`, `cvar`, and
478/// `category_stats` reflect only locally computed partial data; the
479/// authoritative values are on rank 0 (SS4.4).
480#[derive(Debug)]
481pub struct SimulationSummary {
482 /// Mean total cost across all scenarios: `mean_cost = (1/S) * sum(C_s for s in 1..=S)`.
483 pub mean_cost: f64,
484 /// Sample standard deviation of total cost: `std_cost = sqrt((1/(S-1)) * sum((C_s - mean_cost)^2 for s in 1..=S))`.
485 pub std_cost: f64,
486 /// Minimum total cost across all scenarios.
487 pub min_cost: f64,
488 /// Maximum total cost across all scenarios.
489 pub max_cost: f64,
490 /// `CVaR` (Conditional Value-at-Risk) at the configured confidence level `cvar_alpha`.
491 /// Mean of the worst `(1 - cvar_alpha)` fraction of scenario costs. See simulation-architecture.md SS4.1.
492 pub cvar: f64,
493 /// Confidence level used for `CVaR` computation. Must be in `(0, 1)`.
494 pub cvar_alpha: f64,
495 /// Per-category cost statistics (mean, max, frequency) for each of the five cost categories.
496 pub category_stats: Vec<CategoryCostStats>,
497 /// Fraction of scenarios with at least one stage having deficit > 0.
498 pub deficit_frequency: f64,
499 /// Total deficit energy (`MWh`) summed across all scenarios and stages.
500 pub total_deficit_mwh: f64,
501 /// Total spillage energy (`MWh`) summed across all scenarios and stages.
502 pub total_spillage_mwh: f64,
503 /// Number of scenarios simulated (across all ranks).
504 pub n_scenarios: u32,
505}
506
507/// Per-category cost statistics for one cost category (SS4.2).
508///
509/// Each of the five cost categories (resource, recourse, violation,
510/// regularization, imputed) produces one `CategoryCostStats` entry in
511/// [`SimulationSummary::category_stats`].
512#[derive(Debug)]
513pub struct CategoryCostStats {
514 /// Category name. Matches the SS4.2 table:
515 /// `"resource"`, `"recourse"`, `"violation"`, `"regularization"`,
516 /// `"imputed"`.
517 pub category: String,
518
519 /// Mean cost for this category across all scenarios.
520 pub mean: f64,
521
522 /// Maximum cost for this category across all scenarios.
523 pub max: f64,
524
525 /// Fraction of scenarios where the category cost is non-zero.
526 ///
527 /// Particularly relevant for deficit (recourse) and constraint
528 /// violations.
529 pub frequency: f64,
530}
531
532const _: fn() = || {
533 fn assert_send<T: Send>() {}
534 assert_send::<SimulationScenarioResult>();
535};
536
537#[cfg(test)]
538mod tests {
539 use super::{
540 CategoryCostStats, ScenarioCategoryCosts, SimulationBusResult, SimulationContractResult,
541 SimulationCostResult, SimulationExchangeResult, SimulationGenericViolationResult,
542 SimulationHydroResult, SimulationInflowLagResult, SimulationNonControllableResult,
543 SimulationPumpingResult, SimulationScenarioResult, SimulationStageResult,
544 SimulationSummary, SimulationThermalResult,
545 };
546
547 #[test]
548 fn cost_result_construction_all_fields() {
549 let r = SimulationCostResult {
550 stage_id: 0,
551 block_id: Some(0),
552 total_cost: 1000.0,
553 immediate_cost: 800.0,
554 future_cost: 200.0,
555 discount_factor: 0.95,
556 thermal_cost: 500.0,
557 anticipated_thermal_cost: 90.0,
558 contract_cost: 100.0,
559 deficit_cost: 50.0,
560 excess_cost: 10.0,
561 storage_violation_cost: 20.0,
562 filling_target_cost: 30.0,
563 hydro_violation_cost: 5.0,
564 outflow_violation_below_cost: 0.0,
565 outflow_violation_above_cost: 0.0,
566 turbined_violation_cost: 0.0,
567 generation_violation_cost: 0.0,
568 evaporation_violation_cost: 0.0,
569 withdrawal_violation_cost: 0.0,
570 inflow_penalty_cost: 3.0,
571 generic_violation_cost: 2.0,
572 spillage_cost: 1.0,
573 turbined_cost: 4.0,
574 curtailment_cost: 7.0,
575 exchange_cost: 8.0,
576 pumping_cost: 60.0,
577 };
578
579 assert_eq!(r.stage_id, 0);
580 assert_eq!(r.block_id, Some(0));
581 assert_eq!(r.total_cost, 1000.0);
582 assert_eq!(r.immediate_cost, 800.0);
583 assert_eq!(r.future_cost, 200.0);
584 assert_eq!(r.discount_factor, 0.95);
585 assert_eq!(r.thermal_cost, 500.0);
586 assert_eq!(r.anticipated_thermal_cost, 90.0);
587 assert_eq!(r.contract_cost, 100.0);
588 assert_eq!(r.deficit_cost, 50.0);
589 assert_eq!(r.excess_cost, 10.0);
590 assert_eq!(r.storage_violation_cost, 20.0);
591 assert_eq!(r.filling_target_cost, 30.0);
592 assert_eq!(r.hydro_violation_cost, 5.0);
593 assert_eq!(r.inflow_penalty_cost, 3.0);
594 assert_eq!(r.generic_violation_cost, 2.0);
595 assert_eq!(r.spillage_cost, 1.0);
596 assert_eq!(r.turbined_cost, 4.0);
597 assert_eq!(r.curtailment_cost, 7.0);
598 assert_eq!(r.exchange_cost, 8.0);
599 assert_eq!(r.pumping_cost, 60.0);
600 }
601
602 #[test]
603 fn hydro_result_optional_fields() {
604 let r = SimulationHydroResult {
605 stage_id: 1,
606 block_id: Some(0),
607 hydro_id: 5,
608 turbined_m3s: 100.0,
609 spillage_m3s: 0.0,
610 evaporation_m3s: None,
611 diverted_inflow_m3s: None,
612 diverted_outflow_m3s: None,
613 incremental_inflow_m3s: 200.0,
614 inflow_m3s: 200.0,
615 storage_initial_hm3: 500.0,
616 storage_final_hm3: 480.0,
617 generation_mw: 50.0,
618 equivalent_productivity_mw_per_m3s: 0.5,
619 accumulated_productivity_mw_per_m3s: 0.5,
620 incremental_inflow_energy_mw: 100.0,
621 stored_energy_initial_mwh: 250.0,
622 stored_energy_final_mwh: 240.0,
623 spillage_cost: 0.0,
624 water_value_per_hm3: 10.0,
625 storage_binding_code: 0,
626 operative_state_code: 1,
627 turbined_slack_m3s: 0.0,
628 outflow_slack_below_m3s: 0.0,
629 outflow_slack_above_m3s: 0.0,
630 generation_slack_mw: 0.0,
631 storage_violation_below_hm3: 0.0,
632 filling_target_violation_hm3: 0.0,
633 evaporation_violation_pos_m3s: 0.0,
634 evaporation_violation_neg_m3s: 0.0,
635 inflow_nonnegativity_slack_m3s: 0.0,
636 water_withdrawal_violation_pos_m3s: 0.0,
637 water_withdrawal_violation_neg_m3s: 0.0,
638 };
639
640 assert_eq!(r.hydro_id, 5);
641 assert_eq!(r.turbined_m3s, 100.0);
642 assert_eq!(r.evaporation_m3s, None);
643 assert_eq!(r.diverted_inflow_m3s, None);
644 assert_eq!(r.diverted_outflow_m3s, None);
645 assert_eq!(r.equivalent_productivity_mw_per_m3s, 0.5);
646 assert_eq!(r.accumulated_productivity_mw_per_m3s, 0.5);
647 assert_eq!(r.incremental_inflow_energy_mw, 100.0);
648 assert_eq!(r.stored_energy_initial_mwh, 250.0);
649 assert_eq!(r.stored_energy_final_mwh, 240.0);
650 }
651
652 #[test]
653 fn simulation_hydro_result_serde_round_trip() {
654 let original = SimulationHydroResult {
655 stage_id: 3,
656 block_id: Some(1),
657 hydro_id: 7,
658 turbined_m3s: 120.0,
659 spillage_m3s: 5.0,
660 evaporation_m3s: Some(0.3),
661 diverted_inflow_m3s: Some(10.0),
662 diverted_outflow_m3s: Some(8.0),
663 incremental_inflow_m3s: 300.0,
664 inflow_m3s: 310.0,
665 storage_initial_hm3: 600.0,
666 storage_final_hm3: 590.0,
667 generation_mw: 65.0,
668 equivalent_productivity_mw_per_m3s: 1.0,
669 accumulated_productivity_mw_per_m3s: 2.0,
670 incremental_inflow_energy_mw: 3.0,
671 stored_energy_initial_mwh: 4.0,
672 stored_energy_final_mwh: 5.0,
673 spillage_cost: 0.1,
674 water_value_per_hm3: 15.0,
675 storage_binding_code: 1,
676 operative_state_code: 1,
677 turbined_slack_m3s: 0.0,
678 outflow_slack_below_m3s: 0.0,
679 outflow_slack_above_m3s: 0.0,
680 generation_slack_mw: 0.0,
681 storage_violation_below_hm3: 0.0,
682 filling_target_violation_hm3: 0.0,
683 evaporation_violation_pos_m3s: 0.0,
684 evaporation_violation_neg_m3s: 0.0,
685 inflow_nonnegativity_slack_m3s: 0.0,
686 water_withdrawal_violation_pos_m3s: 0.0,
687 water_withdrawal_violation_neg_m3s: 0.0,
688 };
689
690 let json = serde_json::to_string(&original).expect("serialize");
691 let decoded: SimulationHydroResult = serde_json::from_str(&json).expect("deserialize");
692
693 assert_eq!(decoded.stage_id, original.stage_id);
694 assert_eq!(decoded.block_id, original.block_id);
695 assert_eq!(decoded.hydro_id, original.hydro_id);
696 assert_eq!(
697 decoded.equivalent_productivity_mw_per_m3s,
698 original.equivalent_productivity_mw_per_m3s
699 );
700 assert_eq!(
701 decoded.accumulated_productivity_mw_per_m3s,
702 original.accumulated_productivity_mw_per_m3s
703 );
704 assert_eq!(
705 decoded.incremental_inflow_energy_mw,
706 original.incremental_inflow_energy_mw
707 );
708 assert_eq!(
709 decoded.stored_energy_initial_mwh,
710 original.stored_energy_initial_mwh
711 );
712 assert_eq!(
713 decoded.stored_energy_final_mwh,
714 original.stored_energy_final_mwh
715 );
716 // Verify the exact bit patterns match for the five new fields.
717 assert_eq!(
718 decoded.equivalent_productivity_mw_per_m3s.to_bits(),
719 original.equivalent_productivity_mw_per_m3s.to_bits()
720 );
721 assert_eq!(
722 decoded.accumulated_productivity_mw_per_m3s.to_bits(),
723 original.accumulated_productivity_mw_per_m3s.to_bits()
724 );
725 assert_eq!(
726 decoded.incremental_inflow_energy_mw.to_bits(),
727 original.incremental_inflow_energy_mw.to_bits()
728 );
729 assert_eq!(
730 decoded.stored_energy_initial_mwh.to_bits(),
731 original.stored_energy_initial_mwh.to_bits()
732 );
733 assert_eq!(
734 decoded.stored_energy_final_mwh.to_bits(),
735 original.stored_energy_final_mwh.to_bits()
736 );
737 }
738
739 #[test]
740 fn thermal_result_anticipated_fields_nullable() {
741 let anticipated = SimulationThermalResult {
742 stage_id: 2,
743 block_id: Some(1),
744 thermal_id: 10,
745 generation_mw: 200.0,
746 generation_cost: 5000.0,
747 is_anticipated: true,
748 anticipated_committed_mw: Some(250.0),
749 anticipated_decision_mw: Some(200.0),
750 operative_state_code: 1,
751 };
752 assert!(anticipated.is_anticipated);
753 assert_eq!(anticipated.anticipated_committed_mw, Some(250.0));
754 assert_eq!(anticipated.anticipated_decision_mw, Some(200.0));
755
756 let non_anticipated = SimulationThermalResult {
757 stage_id: 2,
758 block_id: Some(1),
759 thermal_id: 11,
760 generation_mw: 100.0,
761 generation_cost: 3000.0,
762 is_anticipated: false,
763 anticipated_committed_mw: None,
764 anticipated_decision_mw: None,
765 operative_state_code: 1,
766 };
767 assert!(!non_anticipated.is_anticipated);
768 assert_eq!(non_anticipated.anticipated_committed_mw, None);
769 assert_eq!(non_anticipated.anticipated_decision_mw, None);
770 }
771
772 #[test]
773 fn exchange_result_construction() {
774 let r = SimulationExchangeResult {
775 stage_id: 0,
776 block_id: Some(0),
777 line_id: 3,
778 direct_flow_mw: 150.0,
779 reverse_flow_mw: 0.0,
780 exchange_cost: 10.0,
781 operative_state_code: 1,
782 };
783
784 assert_eq!(r.stage_id, 0);
785 assert_eq!(r.block_id, Some(0));
786 assert_eq!(r.line_id, 3);
787 assert_eq!(r.direct_flow_mw, 150.0);
788 assert_eq!(r.reverse_flow_mw, 0.0);
789 assert_eq!(r.exchange_cost, 10.0);
790 assert_eq!(r.operative_state_code, 1);
791 }
792
793 #[test]
794 fn bus_result_construction() {
795 let r = SimulationBusResult {
796 stage_id: 0,
797 block_id: Some(0),
798 bus_id: 1,
799 load_mw: 300.0,
800 deficit_mw: 0.0,
801 excess_mw: 0.0,
802 spot_price: 120.0,
803 };
804
805 assert_eq!(r.stage_id, 0);
806 assert_eq!(r.block_id, Some(0));
807 assert_eq!(r.bus_id, 1);
808 assert_eq!(r.load_mw, 300.0);
809 assert_eq!(r.deficit_mw, 0.0);
810 assert_eq!(r.excess_mw, 0.0);
811 assert_eq!(r.spot_price, 120.0);
812 }
813
814 #[test]
815 fn pumping_result_construction() {
816 let r = SimulationPumpingResult {
817 stage_id: 0,
818 block_id: Some(0),
819 pumping_station_id: 2,
820 pumped_flow_m3s: 50.0,
821 power_consumption_mw: 25.0,
822 pumping_cost: 500.0,
823 operative_state_code: 1,
824 };
825
826 assert_eq!(r.stage_id, 0);
827 assert_eq!(r.block_id, Some(0));
828 assert_eq!(r.pumping_station_id, 2);
829 assert_eq!(r.pumped_flow_m3s, 50.0);
830 assert_eq!(r.power_consumption_mw, 25.0);
831 assert_eq!(r.pumping_cost, 500.0);
832 assert_eq!(r.operative_state_code, 1);
833 }
834
835 #[test]
836 fn contract_result_construction() {
837 let r = SimulationContractResult {
838 stage_id: 0,
839 block_id: Some(0),
840 contract_id: 7,
841 power_mw: 80.0,
842 price_per_mwh: 200.0,
843 total_cost: 16000.0,
844 operative_state_code: 1,
845 };
846
847 assert_eq!(r.stage_id, 0);
848 assert_eq!(r.block_id, Some(0));
849 assert_eq!(r.contract_id, 7);
850 assert_eq!(r.power_mw, 80.0);
851 assert_eq!(r.price_per_mwh, 200.0);
852 assert_eq!(r.total_cost, 16000.0);
853 assert_eq!(r.operative_state_code, 1);
854 }
855
856 #[test]
857 fn non_controllable_result_construction() {
858 let r = SimulationNonControllableResult {
859 stage_id: 0,
860 block_id: Some(0),
861 non_controllable_id: 4,
862 generation_mw: 60.0,
863 available_mw: 80.0,
864 curtailment_mw: 20.0,
865 curtailment_cost: 200.0,
866 operative_state_code: 1,
867 };
868
869 assert_eq!(r.stage_id, 0);
870 assert_eq!(r.block_id, Some(0));
871 assert_eq!(r.non_controllable_id, 4);
872 assert_eq!(r.generation_mw, 60.0);
873 assert_eq!(r.available_mw, 80.0);
874 assert_eq!(r.curtailment_mw, 20.0);
875 assert_eq!(r.curtailment_cost, 200.0);
876 assert_eq!(r.operative_state_code, 1);
877 }
878
879 #[test]
880 fn inflow_lag_result_construction() {
881 let r = SimulationInflowLagResult {
882 stage_id: 5,
883 hydro_id: 2,
884 lag_index: 1,
885 inflow_m3s: 350.0,
886 };
887
888 assert_eq!(r.stage_id, 5);
889 assert_eq!(r.hydro_id, 2);
890 assert_eq!(r.lag_index, 1);
891 assert_eq!(r.inflow_m3s, 350.0);
892 }
893
894 #[test]
895 fn generic_violation_result_construction() {
896 let r = SimulationGenericViolationResult {
897 stage_id: 3,
898 block_id: Some(2),
899 constraint_id: 15,
900 slack_value: 5.0,
901 slack_cost: 50.0,
902 };
903
904 assert_eq!(r.stage_id, 3);
905 assert_eq!(r.block_id, Some(2));
906 assert_eq!(r.constraint_id, 15);
907 assert_eq!(r.slack_value, 5.0);
908 assert_eq!(r.slack_cost, 50.0);
909 }
910
911 #[test]
912 fn stage_result_empty_optional_vecs() {
913 let stage = SimulationStageResult {
914 stage_id: 0,
915 costs: vec![],
916 hydros: vec![],
917 thermals: vec![],
918 exchanges: vec![],
919 buses: vec![],
920 pumping_stations: vec![],
921 contracts: vec![],
922 non_controllables: vec![],
923 inflow_lags: vec![],
924 generic_violations: vec![],
925 };
926
927 assert!(stage.pumping_stations.is_empty());
928 assert!(stage.contracts.is_empty());
929 assert!(stage.non_controllables.is_empty());
930 assert!(stage.inflow_lags.is_empty());
931 assert!(stage.generic_violations.is_empty());
932 }
933
934 #[test]
935 fn scenario_result_is_send() {
936 // Compile-time Send bound check.
937 fn assert_send<T: Send>() {}
938 assert_send::<SimulationScenarioResult>();
939 }
940
941 #[test]
942 fn scenario_result_with_multiple_stages() {
943 let stages: Vec<SimulationStageResult> = (0..12)
944 .map(|i| SimulationStageResult {
945 stage_id: i,
946 costs: vec![],
947 hydros: vec![],
948 thermals: vec![],
949 exchanges: vec![],
950 buses: vec![],
951 pumping_stations: vec![],
952 contracts: vec![],
953 non_controllables: vec![],
954 inflow_lags: vec![],
955 generic_violations: vec![],
956 })
957 .collect();
958
959 let result = SimulationScenarioResult {
960 scenario_id: 42,
961 total_cost: 1_000_000.0,
962 per_category_costs: ScenarioCategoryCosts {
963 resource_cost: 600_000.0,
964 recourse_cost: 100_000.0,
965 violation_cost: 50_000.0,
966 regularization_cost: 200_000.0,
967 imputed_cost: 50_000.0,
968 },
969 stages,
970 };
971
972 assert_eq!(result.scenario_id, 42);
973 assert_eq!(result.stages.len(), 12);
974 }
975
976 #[test]
977 fn category_costs_construction() {
978 let c = ScenarioCategoryCosts {
979 resource_cost: 1.0,
980 recourse_cost: 2.0,
981 violation_cost: 3.0,
982 regularization_cost: 4.0,
983 imputed_cost: 5.0,
984 };
985
986 assert_eq!(c.resource_cost, 1.0);
987 assert_eq!(c.recourse_cost, 2.0);
988 assert_eq!(c.violation_cost, 3.0);
989 assert_eq!(c.regularization_cost, 4.0);
990 assert_eq!(c.imputed_cost, 5.0);
991 }
992
993 #[test]
994 fn category_cost_stats_construction() {
995 let stats = CategoryCostStats {
996 category: "recourse".to_string(),
997 mean: 500.0,
998 max: 2000.0,
999 frequency: 0.15,
1000 };
1001
1002 assert_eq!(stats.category, "recourse");
1003 assert_eq!(stats.mean, 500.0);
1004 assert_eq!(stats.max, 2000.0);
1005 assert_eq!(stats.frequency, 0.15);
1006 }
1007
1008 #[test]
1009 fn simulation_summary_construction() {
1010 let category_stats: Vec<CategoryCostStats> = (0_i32..5)
1011 .map(|i| CategoryCostStats {
1012 category: format!("cat_{i}"),
1013 mean: f64::from(i) * 100.0,
1014 max: f64::from(i) * 500.0,
1015 frequency: 0.1 * f64::from(i),
1016 })
1017 .collect();
1018
1019 let summary = SimulationSummary {
1020 mean_cost: 1_500_000.0,
1021 std_cost: 200_000.0,
1022 min_cost: 900_000.0,
1023 max_cost: 2_100_000.0,
1024 cvar: 1_900_000.0,
1025 cvar_alpha: 0.95,
1026 category_stats,
1027 deficit_frequency: 0.08,
1028 total_deficit_mwh: 12_500.0,
1029 total_spillage_mwh: 3_200.0,
1030 n_scenarios: 2000,
1031 };
1032
1033 assert_eq!(summary.mean_cost, 1_500_000.0);
1034 assert_eq!(summary.std_cost, 200_000.0);
1035 assert_eq!(summary.min_cost, 900_000.0);
1036 assert_eq!(summary.max_cost, 2_100_000.0);
1037 assert_eq!(summary.cvar, 1_900_000.0);
1038 assert_eq!(summary.cvar_alpha, 0.95);
1039 assert_eq!(summary.category_stats.len(), 5);
1040 assert_eq!(summary.deficit_frequency, 0.08);
1041 assert_eq!(summary.total_deficit_mwh, 12_500.0);
1042 assert_eq!(summary.total_spillage_mwh, 3_200.0);
1043 assert_eq!(summary.n_scenarios, 2000);
1044 }
1045}