Skip to main content

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}