Skip to main content

cobre_sddp/simulation/
error.rs

1//! Error type for the SDDP simulation execution phase.
2//!
3//! [`SimulationError`] is the error type returned by the `fn simulate()`
4//! entry point. It covers LP-level failures, I/O failures from the output
5//! writer, policy compatibility errors, and channel failures.
6//!
7//! A `From<SimulationError> for SddpError` conversion is provided for
8//! contexts where a unified error type is required.
9
10use crate::SddpError;
11
12/// Errors that can occur during simulation execution.
13///
14/// The `fn simulate()` function returns `Result<SimulationSummary, SimulationError>`.
15/// All variants implement `std::error::Error + Send + Sync + 'static`.
16#[derive(Debug, thiserror::Error)]
17pub enum SimulationError {
18    /// LP infeasibility at a simulation stage.
19    ///
20    /// This indicates a system error — recourse slack variables (deficit,
21    /// excess) should always make the LP feasible. If infeasibility occurs,
22    /// it indicates a bug in LP construction or a degenerate system
23    /// configuration. The error includes the scenario, stage, and solver
24    /// diagnostic to aid debugging.
25    #[error("LP infeasible at scenario {scenario_id}, stage {stage_id}: {solver_message}")]
26    LpInfeasible {
27        /// 0-based scenario identifier.
28        scenario_id: u32,
29        /// 0-based stage identifier.
30        stage_id: u32,
31        /// Solver-provided diagnostic message.
32        solver_message: String,
33    },
34
35    /// LP solver returned an unexpected status (e.g., numerical difficulties,
36    /// unbounded). Includes solver-specific diagnostics.
37    #[error("solver error at scenario {scenario_id}, stage {stage_id}: {solver_message}")]
38    SolverError {
39        /// 0-based scenario identifier.
40        scenario_id: u32,
41        /// 0-based stage identifier.
42        stage_id: u32,
43        /// Solver-provided diagnostic message.
44        solver_message: String,
45    },
46
47    /// I/O failure during output writing (disk full, permission denied,
48    /// Parquet encoding error). The simulation cannot continue if the output
49    /// writer fails because results would be lost.
50    #[error("I/O error during simulation output: {message}")]
51    IoError {
52        /// Description of the I/O failure.
53        message: String,
54    },
55
56    /// Policy compatibility validation failed (simulation-architecture.md
57    /// SS2). The trained policy is incompatible with the current system
58    /// configuration.
59    #[error("policy incompatible with current system: {message}")]
60    PolicyIncompatible {
61        /// Description of the compatibility mismatch.
62        message: String,
63    },
64
65    /// Channel send failure — the receiving end (I/O thread) has dropped
66    /// unexpectedly. Indicates a panic or crash in the output writer.
67    #[error("simulation output channel closed unexpectedly")]
68    ChannelClosed,
69
70    /// Forward sampler construction or sampling failure.
71    #[error("stochastic error: {0}")]
72    Stochastic(#[from] cobre_stochastic::StochasticError),
73
74    /// Invalid configuration passed to `simulate`, e.g. baked-template slice length
75    /// does not match `num_stages`.
76    #[error("invalid simulation configuration: {0}")]
77    InvalidConfiguration(String),
78}
79
80impl From<SimulationError> for SddpError {
81    fn from(err: SimulationError) -> Self {
82        Self::Simulation(err.to_string())
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::SimulationError;
89    use crate::SddpError;
90
91    fn assert_send_sync_static<E: std::error::Error + Send + Sync + 'static>() {}
92
93    #[test]
94    fn simulation_error_is_send_sync_static() {
95        assert_send_sync_static::<SimulationError>();
96    }
97
98    #[test]
99    fn simulation_error_lp_infeasible_display() {
100        let err = SimulationError::LpInfeasible {
101            scenario_id: 5,
102            stage_id: 3,
103            solver_message: "primal infeasible".to_string(),
104        };
105        let msg = err.to_string();
106        assert!(msg.contains('5'), "{msg}");
107        assert!(msg.contains('3'), "{msg}");
108        assert!(msg.contains("primal infeasible"), "{msg}");
109    }
110
111    #[test]
112    fn simulation_error_solver_error_display() {
113        let err = SimulationError::SolverError {
114            scenario_id: 10,
115            stage_id: 7,
116            solver_message: "numerical difficulties".to_string(),
117        };
118        let msg = err.to_string();
119        assert!(msg.contains("10"), "{msg}");
120        assert!(msg.contains('7'), "{msg}");
121        assert!(msg.contains("numerical difficulties"), "{msg}");
122    }
123
124    #[test]
125    fn simulation_error_io_error_display() {
126        let err = SimulationError::IoError {
127            message: "disk full".to_string(),
128        };
129        let msg = err.to_string();
130        assert!(msg.contains("disk full"), "{msg}");
131    }
132
133    #[test]
134    fn simulation_error_policy_incompatible_display() {
135        let err = SimulationError::PolicyIncompatible {
136            message: "hydro count mismatch: expected 5, got 6".to_string(),
137        };
138        let msg = err.to_string();
139        assert!(msg.contains("hydro count mismatch"), "{msg}");
140    }
141
142    #[test]
143    fn simulation_error_channel_closed_display() {
144        let err = SimulationError::ChannelClosed;
145        let msg = err.to_string();
146        assert!(!msg.is_empty(), "ChannelClosed display must not be empty");
147        assert!(
148            msg.contains("channel") || msg.contains("closed"),
149            "ChannelClosed display should mention channel or closed: {msg}"
150        );
151    }
152
153    #[test]
154    fn simulation_error_satisfies_std_error_trait() {
155        let variants: Vec<SimulationError> = vec![
156            SimulationError::LpInfeasible {
157                scenario_id: 0,
158                stage_id: 0,
159                solver_message: "infeasible".to_string(),
160            },
161            SimulationError::SolverError {
162                scenario_id: 0,
163                stage_id: 0,
164                solver_message: "error".to_string(),
165            },
166            SimulationError::IoError {
167                message: "disk full".to_string(),
168            },
169            SimulationError::PolicyIncompatible {
170                message: "mismatch".to_string(),
171            },
172            SimulationError::ChannelClosed,
173        ];
174        for err in &variants {
175            let _: &dyn std::error::Error = err;
176        }
177    }
178
179    #[test]
180    fn from_simulation_error_to_sddp_error() {
181        let variants: Vec<SimulationError> = vec![
182            SimulationError::LpInfeasible {
183                scenario_id: 1,
184                stage_id: 2,
185                solver_message: "primal infeasible".to_string(),
186            },
187            SimulationError::SolverError {
188                scenario_id: 3,
189                stage_id: 4,
190                solver_message: "numerical issue".to_string(),
191            },
192            SimulationError::IoError {
193                message: "write failed".to_string(),
194            },
195            SimulationError::PolicyIncompatible {
196                message: "bus count mismatch".to_string(),
197            },
198            SimulationError::ChannelClosed,
199        ];
200
201        for variant in variants {
202            let original_msg = variant.to_string();
203            let sddp_err: SddpError = variant.into();
204            assert!(
205                matches!(sddp_err, SddpError::Simulation(_)),
206                "expected SddpError::Simulation, got {sddp_err:?}"
207            );
208            // The wrapped message must contain the original error description.
209            let sddp_msg = sddp_err.to_string();
210            assert!(
211                sddp_msg.contains(original_msg.as_str()) || sddp_msg.contains("simulation"),
212                "SddpError display '{sddp_msg}' should contain original message or 'simulation'"
213            );
214        }
215    }
216
217    #[test]
218    fn sddp_error_simulation_variant_display() {
219        let err = SddpError::Simulation("test".to_string());
220        let msg = err.to_string();
221        assert!(
222            msg.contains("simulation"),
223            "display must contain 'simulation': {msg}"
224        );
225        assert!(msg.contains("test"), "display must contain 'test': {msg}");
226    }
227}