Skip to main content

surge_solution/
opf_solution.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! Optimal Power Flow solution types.
3
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5
6use surge_network::market::VirtualBidResult;
7
8use crate::{
9    AuditableSolution, ObjectiveLedgerMismatch, ObjectiveLedgerScopeKind, ObjectiveTerm, ParResult,
10    PfSolution, SolutionAuditReport,
11};
12
13fn serialize_branch_loading_pct<S>(values: &[f64], serializer: S) -> Result<S::Ok, S::Error>
14where
15    S: Serializer,
16{
17    use serde::ser::SerializeSeq;
18
19    let mut seq = serializer.serialize_seq(Some(values.len()))?;
20    for value in values {
21        if value.is_finite() {
22            seq.serialize_element(value)?;
23        } else {
24            seq.serialize_element(&Option::<f64>::None)?;
25        }
26    }
27    seq.end()
28}
29
30fn deserialize_branch_loading_pct<'de, D>(deserializer: D) -> Result<Vec<f64>, D::Error>
31where
32    D: Deserializer<'de>,
33{
34    let values = Vec::<Option<f64>>::deserialize(deserializer)?;
35    Ok(values
36        .into_iter()
37        .map(|value| value.unwrap_or(f64::NAN))
38        .collect())
39}
40
41/// OPF formulation type — identifies which solver produced this solution.
42#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum OpfType {
45    /// DC-OPF via sparse B-θ formulation (lossless, linear).
46    #[default]
47    DcOpf,
48    /// AC-OPF via Ipopt NLP (nonlinear, exact losses).
49    AcOpf,
50    /// DC Security-Constrained OPF (preventive or corrective N-1).
51    DcScopf,
52    /// AC Security-Constrained OPF (Benders N-1, NLP master).
53    AcScopf,
54    /// DC-OPF with embedded HVDC links.
55    HvdcOpf,
56}
57
58// ---------------------------------------------------------------------------
59// Sub-structs
60// ---------------------------------------------------------------------------
61
62/// Generator dispatch results and identity mapping.
63#[derive(Debug, Clone, Default, Serialize, Deserialize)]
64pub struct OpfGeneratorResults {
65    /// Optimal real power dispatch per generator (MW).
66    ///
67    /// Indexed by **in-service** generators in `network.generators` order.
68    pub gen_p_mw: Vec<f64>,
69    /// Optimal reactive power dispatch per generator (MVAr).
70    ///
71    /// Same ordering as `gen_p_mw`. Empty for DC-OPF; populated for AC-OPF.
72    pub gen_q_mvar: Vec<f64>,
73    /// External bus number for each entry in `gen_p_mw` / `gen_q_mvar`.
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub gen_bus_numbers: Vec<u32>,
76    /// Canonical generator identifier for each entry in `gen_p_mw` / `gen_q_mvar`.
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub gen_ids: Vec<String>,
79    /// Machine identifier for each entry in `gen_p_mw` / `gen_q_mvar`.
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub gen_machine_ids: Vec<String>,
82    /// Lower active-power bound duals ($/MWh), one per in-service generator.
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub shadow_price_pg_min: Vec<f64>,
85    /// Upper active-power bound duals ($/MWh), one per in-service generator.
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub shadow_price_pg_max: Vec<f64>,
88    /// Reactive power lower-bound duals ($/MWh per pu), one per in-service generator.
89    /// AC-OPF only; empty for DC.
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub shadow_price_qg_min: Vec<f64>,
92    /// Reactive power upper-bound duals ($/MWh per pu), one per in-service generator.
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub shadow_price_qg_max: Vec<f64>,
95}
96
97/// LMP decomposition per bus.
98#[derive(Debug, Clone, Default, Serialize, Deserialize)]
99pub struct OpfPricing {
100    /// Locational marginal prices per bus ($/MWh).
101    pub lmp: Vec<f64>,
102    /// Energy component of LMP per bus ($/MWh).
103    pub lmp_energy: Vec<f64>,
104    /// Congestion component of LMP per bus ($/MWh).
105    pub lmp_congestion: Vec<f64>,
106    /// Loss component of LMP per bus ($/MWh). Zero for DC-OPF.
107    pub lmp_loss: Vec<f64>,
108    /// Reactive LMP per bus ($/MVAr-h). AC-OPF only; empty for DC.
109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
110    pub lmp_reactive: Vec<f64>,
111}
112
113/// Branch-level results: loading, shadow prices, and constraint duals.
114#[derive(Debug, Clone, Default, Serialize, Deserialize)]
115pub struct OpfBranchResults {
116    /// Branch loading percentage: `max(|Sf|, |St|) / rate_a * 100`.
117    ///
118    /// `NaN` for branches with no positive Rate A limit; serialized as `null`
119    /// in JSON.
120    #[serde(
121        default,
122        skip_serializing_if = "Vec::is_empty",
123        serialize_with = "serialize_branch_loading_pct",
124        deserialize_with = "deserialize_branch_loading_pct"
125    )]
126    pub branch_loading_pct: Vec<f64>,
127    /// Shadow prices on branch thermal flow limits ($/MWh per MW).
128    pub branch_shadow_prices: Vec<f64>,
129    /// Shadow prices on branch angmin constraints ($/MWh per rad).
130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
131    pub shadow_price_angmin: Vec<f64>,
132    /// Shadow prices on branch angmax constraints ($/MWh per rad).
133    #[serde(default, skip_serializing_if = "Vec::is_empty")]
134    pub shadow_price_angmax: Vec<f64>,
135    /// From-side thermal overflow slack (MVA), one entry per network branch.
136    ///
137    /// Non-zero values mean AC-OPF was allowed to exceed the branch's apparent
138    /// power rating on the from side by this amount.
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub thermal_limit_slack_from_mva: Vec<f64>,
141    /// To-side thermal overflow slack (MVA), one entry per network branch.
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub thermal_limit_slack_to_mva: Vec<f64>,
144    /// Shadow prices for flowgate constraints ($/MWh).
145    #[serde(default, skip_serializing_if = "Vec::is_empty")]
146    pub flowgate_shadow_prices: Vec<f64>,
147    /// Shadow prices for interface constraints ($/MWh).
148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
149    pub interface_shadow_prices: Vec<f64>,
150    /// Voltage magnitude lower-bound duals ($/MWh per pu), one per bus.
151    /// AC-OPF only; empty for DC.
152    #[serde(default, skip_serializing_if = "Vec::is_empty")]
153    pub shadow_price_vm_min: Vec<f64>,
154    /// Voltage magnitude upper-bound duals ($/MWh per pu), one per bus.
155    #[serde(default, skip_serializing_if = "Vec::is_empty")]
156    pub shadow_price_vm_max: Vec<f64>,
157}
158
159impl OpfBranchResults {
160    /// Indices of binding branches (|branch_shadow_prices\[i\]| > 1e-6).
161    pub fn binding_branch_indices(&self) -> Vec<usize> {
162        self.branch_shadow_prices
163            .iter()
164            .enumerate()
165            .filter(|(_, price)| price.abs() > 1e-6)
166            .map(|(i, _)| i)
167            .collect()
168    }
169}
170
171/// FACTS device, storage, and transformer dispatch results.
172#[derive(Debug, Clone, Default, Serialize, Deserialize)]
173pub struct OpfDeviceDispatch {
174    /// Switched shunt dispatch: `(bus_idx, continuous_b_pu, rounded_b_pu)`.
175    #[serde(default, skip_serializing_if = "Vec::is_empty")]
176    pub switched_shunt_dispatch: Vec<(usize, f64, f64)>,
177    /// Transformer tap dispatch: `(branch_idx, continuous_tap, rounded_tap)`.
178    #[serde(default, skip_serializing_if = "Vec::is_empty")]
179    pub tap_dispatch: Vec<(usize, f64, f64)>,
180    /// Phase-shifter dispatch: `(branch_idx, continuous_rad, rounded_rad)`.
181    #[serde(default, skip_serializing_if = "Vec::is_empty")]
182    pub phase_dispatch: Vec<(usize, f64, f64)>,
183    /// SVC dispatch: `(device_index, optimal_b_svc_pu, mu_lower, mu_upper)`.
184    #[serde(default, skip_serializing_if = "Vec::is_empty")]
185    pub svc_dispatch: Vec<(usize, f64, f64, f64)>,
186    /// TCSC dispatch: `(device_index, optimal_x_comp_pu, mu_lower, mu_upper)`.
187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
188    pub tcsc_dispatch: Vec<(usize, f64, f64, f64)>,
189    /// Net storage dispatch (MW). Positive = discharging, negative = charging.
190    #[serde(default, skip_serializing_if = "Vec::is_empty")]
191    pub storage_net_mw: Vec<f64>,
192    /// Dispatchable-load real power served (MW), in active resource order.
193    #[serde(default, skip_serializing_if = "Vec::is_empty")]
194    pub dispatchable_load_served_mw: Vec<f64>,
195    /// Dispatchable-load reactive power served (MVAr), in active resource order.
196    #[serde(default, skip_serializing_if = "Vec::is_empty")]
197    pub dispatchable_load_served_q_mvar: Vec<f64>,
198    /// Cleared producer reactive up-reserve award per in-service
199    /// generator (MVAr), in `gen_indices` order. AC-OPF only; empty
200    /// otherwise or when the network has no reactive reserve products.
201    #[serde(default, skip_serializing_if = "Vec::is_empty")]
202    pub producer_q_reserve_up_mvar: Vec<f64>,
203    /// Cleared producer reactive down-reserve award per in-service
204    /// generator (MVAr).
205    #[serde(default, skip_serializing_if = "Vec::is_empty")]
206    pub producer_q_reserve_down_mvar: Vec<f64>,
207    /// Cleared consumer reactive up-reserve award per in-service
208    /// dispatchable load (MVAr).
209    #[serde(default, skip_serializing_if = "Vec::is_empty")]
210    pub consumer_q_reserve_up_mvar: Vec<f64>,
211    /// Cleared consumer reactive down-reserve award per in-service
212    /// dispatchable load (MVAr).
213    #[serde(default, skip_serializing_if = "Vec::is_empty")]
214    pub consumer_q_reserve_down_mvar: Vec<f64>,
215    /// Zonal reactive up-reserve shortfall per (zone, up product) pair
216    /// (MVAr). Parallel to the zonal requirement order the AC-OPF walked
217    /// when building its reactive reserve plan.
218    #[serde(default, skip_serializing_if = "Vec::is_empty")]
219    pub zone_q_reserve_up_shortfall_mvar: Vec<f64>,
220    /// Zonal reactive down-reserve shortfall per (zone, down product)
221    /// pair (MVAr).
222    #[serde(default, skip_serializing_if = "Vec::is_empty")]
223    pub zone_q_reserve_down_shortfall_mvar: Vec<f64>,
224    /// Point-to-point HVDC link P dispatch (MW), in the order the
225    /// joint AC-DC NLP exposes them. Positive = flow from the link's
226    /// from-bus (rectifier) to its to-bus (inverter). Non-empty only
227    /// when at least one in-service `LccHvdcLink`/`VscHvdcLink` has a
228    /// non-degenerate `[p_dc_min_mw, p_dc_max_mw]` range AND the AC
229    /// OPF ran through the joint-NLP path (rather than the legacy
230    /// sequential AC-DC iteration, which reports the same quantity
231    /// via `AcOpfHvdcResult.hvdc_p_dc_mw`).
232    #[serde(default, skip_serializing_if = "Vec::is_empty")]
233    pub hvdc_p2p_dispatch_mw: Vec<f64>,
234    /// Whether the discrete round-and-check verification passed.
235    ///
236    /// `None` = continuous mode. `Some(true)` = passed. `Some(false)` = violations.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub discrete_feasible: Option<bool>,
239    /// Human-readable descriptions of violations introduced by discrete rounding.
240    #[serde(default, skip_serializing_if = "Vec::is_empty")]
241    pub discrete_violations: Vec<String>,
242}
243
244// ---------------------------------------------------------------------------
245// OpfSolution
246// ---------------------------------------------------------------------------
247
248/// Result of an Optimal Power Flow computation.
249#[derive(Debug, Clone, Default, Serialize, Deserialize)]
250pub struct OpfSolution {
251    /// OPF formulation type; required for correct interpretation of all other fields.
252    pub opf_type: OpfType,
253    /// System MVA base used to convert MW/MVAr values to per-unit.
254    pub base_mva: f64,
255    /// Underlying power flow solution (voltages, injections, branch flows).
256    pub power_flow: PfSolution,
257
258    // System totals
259    /// Total objective cost for the solved interval (dollars).
260    pub total_cost: f64,
261    /// Total system load (MW).
262    pub total_load_mw: f64,
263    /// Total generation (MW).
264    pub total_generation_mw: f64,
265    /// Total system losses (MW). Zero for DC-OPF.
266    pub total_losses_mw: f64,
267
268    // Grouped results
269    /// Generator dispatch and dual values.
270    #[serde(flatten)]
271    pub generators: OpfGeneratorResults,
272    /// LMP decomposition.
273    #[serde(flatten)]
274    pub pricing: OpfPricing,
275    /// Branch-level results and constraint duals.
276    #[serde(flatten)]
277    pub branches: OpfBranchResults,
278    /// FACTS device, storage, and transformer dispatch.
279    #[serde(flatten)]
280    pub devices: OpfDeviceDispatch,
281
282    // Supplemental results
283    /// PAR implied shift angles.
284    #[serde(default, skip_serializing_if = "Vec::is_empty")]
285    pub par_results: Vec<ParResult>,
286    /// Virtual bid clearing results.
287    #[serde(default, skip_serializing_if = "Vec::is_empty")]
288    pub virtual_bid_results: Vec<VirtualBidResult>,
289    /// Benders cut dual values from AC-SCOPF.
290    #[serde(default, skip_serializing_if = "Vec::is_empty")]
291    pub benders_cut_duals: Vec<f64>,
292    /// Exact objective decomposition.
293    #[serde(default, skip_serializing_if = "Vec::is_empty")]
294    pub objective_terms: Vec<ObjectiveTerm>,
295    /// Persisted exact-audit status for this solution payload.
296    #[serde(default)]
297    pub audit: SolutionAuditReport,
298
299    /// Per-bus reactive-power balance slack (positive direction, MVAr).
300    /// Entry `i` corresponds to bus index `i` in the network.  Non-empty
301    /// only when `bus_reactive_power_balance_slack_penalty_per_mvar > 0`.
302    #[serde(default, skip_serializing_if = "Vec::is_empty")]
303    pub bus_q_slack_pos_mvar: Vec<f64>,
304    /// Per-bus reactive-power balance slack (negative direction, MVAr).
305    #[serde(default, skip_serializing_if = "Vec::is_empty")]
306    pub bus_q_slack_neg_mvar: Vec<f64>,
307    /// Per-bus active-power balance slack (positive direction, MW).
308    #[serde(default, skip_serializing_if = "Vec::is_empty")]
309    pub bus_p_slack_pos_mw: Vec<f64>,
310    /// Per-bus active-power balance slack (negative direction, MW).
311    #[serde(default, skip_serializing_if = "Vec::is_empty")]
312    pub bus_p_slack_neg_mw: Vec<f64>,
313    /// Per-bus voltage-magnitude high slack (pu), one per bus.
314    /// `σ_high[i] = max(0, vm[i] - vm_max[i])`. Non-empty only when
315    /// `voltage_magnitude_slack_penalty_per_pu > 0`.
316    #[serde(default, skip_serializing_if = "Vec::is_empty")]
317    pub vm_slack_high_pu: Vec<f64>,
318    /// Per-bus voltage-magnitude low slack (pu), one per bus.
319    /// `σ_low[i] = max(0, vm_min[i] - vm[i])`.
320    #[serde(default, skip_serializing_if = "Vec::is_empty")]
321    pub vm_slack_low_pu: Vec<f64>,
322    /// Per-branch angle-difference high slack (radians), indexed by network branch order.
323    /// `sigma_high[i]` allows `Va_from - Va_to` to exceed `angmax` by this amount.
324    /// Non-empty only when `angle_difference_slack_penalty_per_rad > 0`.
325    #[serde(default, skip_serializing_if = "Vec::is_empty")]
326    pub angle_diff_slack_high_rad: Vec<f64>,
327    /// Per-branch angle-difference low slack (radians), indexed by network branch order.
328    /// `sigma_low[i]` allows `Va_from - Va_to` to go below `angmin` by this amount.
329    #[serde(default, skip_serializing_if = "Vec::is_empty")]
330    pub angle_diff_slack_low_rad: Vec<f64>,
331
332    // Solver metadata
333    /// Total solve time in seconds.
334    pub solve_time_secs: f64,
335    /// Number of solver iterations, when the backend exposes it.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub iterations: Option<u32>,
338    /// Name of the LP/NLP solver (e.g. `"HiGHS"`, `"Gurobi"`, `"Ipopt"`).
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub solver_name: Option<String>,
341    /// Version string of the solver.
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub solver_version: Option<String>,
344    /// Per-phase timing breakdown within the OPF solve.
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub ac_opf_timings: Option<AcOpfTimings>,
347    /// NLP solver stats and final-iterate diagnostics. Populated by the
348    /// NLP backend (Ipopt today). Captures problem size, termination
349    /// status, and residuals/barrier at the final iterate so post-mortem
350    /// analysis can distinguish convergence/infeasibility/iteration-limit
351    /// cases without parsing log strings.
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub nlp_trace: Option<NlpTrace>,
354}
355
356/// NLP solver stats for a single `NlpSolver::solve` call.
357///
358/// Populated by the backend on the returned `NlpSolution`. Carries both
359/// problem structure (size, sparsity) and termination state (status code
360/// and mnemonic, iteration count, final primal/dual residuals, barrier
361/// parameter). Enables SCUC-equivalent debugging on the AC SCED side —
362/// e.g. distinguishing a proven-infeasible solve from one that hit
363/// `max_iter` with small residuals.
364#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
365pub struct NlpTrace {
366    /// Number of decision variables.
367    pub n_vars: u32,
368    /// Number of constraints (rows of `g(x)`).
369    pub n_constraints: u32,
370    /// Nonzeros in the constraint Jacobian sparsity pattern.
371    pub jac_nnz: u32,
372    /// Nonzeros in the Hessian sparsity pattern (0 when Hessian is
373    /// approximated, e.g. Ipopt L-BFGS mode).
374    pub hess_nnz: u32,
375    /// Raw solver status code (backend-specific). For Ipopt: 0 =
376    /// Solve_Succeeded, 1 = Solved_To_Acceptable_Level, 2 =
377    /// Infeasible_Problem_Detected, -1 = Maximum_Iterations_Exceeded,
378    /// etc.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub status_code: Option<i32>,
381    /// Human-readable status label (backend mnemonic), e.g.
382    /// `"Solve_Succeeded"`, `"Restoration_Failed"`.
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub status_label: Option<String>,
385    /// Iteration count at termination.
386    pub iterations: u32,
387    /// Final objective value.
388    pub objective: f64,
389    /// Primal infeasibility at the final iterate (max constraint
390    /// violation in the solver's scaled internal representation).
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub final_primal_inf: Option<f64>,
393    /// Dual infeasibility at the final iterate (KKT stationarity residual).
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub final_dual_inf: Option<f64>,
396    /// Final barrier parameter `μ` (Ipopt). Large `μ` at termination
397    /// typically indicates the solver stalled before reaching the
398    /// interior-point endgame.
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub final_mu: Option<f64>,
401    /// Whether the solver considered the problem converged.
402    pub converged: bool,
403}
404
405/// Per-phase timing breakdown for a single AC-OPF solve call.
406///
407/// Populated inside `solve_ac_opf_with_context_once`. Each `nlp_build`
408/// + `nlp_solve` pair corresponds to one NLP construction and Ipopt
409///   call; the constraint-screening fallback path may produce a second
410///   pair, reflected in `nlp_attempts`.
411#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
412pub struct AcOpfTimings {
413    /// FACTS expansion, network canonicalize, validation, missing-cost
414    /// check — everything before solver lookup and warm-start decisions.
415    pub network_prep_secs: f64,
416    /// NLP solver lookup, DC-OPF warm-start decision, constraint
417    /// screening setup — between network prep and NLP construction.
418    pub solve_setup_secs: f64,
419    /// Time spent constructing `AcOpfProblem` (Y-bus, branch admittances,
420    /// Jacobian sparsity enumeration). Cumulative across all attempts.
421    pub nlp_build_secs: f64,
422    /// Time spent inside `NlpSolver::solve()` (the actual interior-point
423    /// iterations). Cumulative across all attempts.
424    pub nlp_solve_secs: f64,
425    /// Time spent extracting the solution from NLP variables into
426    /// `OpfSolution` fields (voltages, dispatch, slacks, LMPs).
427    pub extract_secs: f64,
428    /// Wall-clock total for the entire `solve_ac_opf_with_context_once`
429    /// call (equals `solve_time_secs` on the parent `OpfSolution`).
430    pub total_secs: f64,
431    /// Number of `AcOpfProblem::new()` + `nlp.solve()` pairs executed.
432    /// 1 normally; 2 when constraint-screening fallback activates.
433    pub nlp_attempts: u32,
434}
435
436const OBJECTIVE_LEDGER_TOLERANCE: f64 = 1e-6;
437
438fn objective_term_total(terms: &[ObjectiveTerm]) -> f64 {
439    terms.iter().map(|term| term.dollars).sum()
440}
441
442fn residual_term_total(terms: &[ObjectiveTerm]) -> f64 {
443    terms
444        .iter()
445        .filter(|term| {
446            term.kind == crate::ObjectiveTermKind::Other && term.component_id == "residual"
447        })
448        .map(|term| term.dollars)
449        .sum()
450}
451
452fn maybe_push_objective_ledger_mismatch(
453    mismatches: &mut Vec<ObjectiveLedgerMismatch>,
454    scope_kind: ObjectiveLedgerScopeKind,
455    scope_id: impl Into<String>,
456    field: &str,
457    expected_dollars: f64,
458    actual_dollars: f64,
459) {
460    let difference = actual_dollars - expected_dollars;
461    if difference.abs() > OBJECTIVE_LEDGER_TOLERANCE {
462        mismatches.push(ObjectiveLedgerMismatch {
463            scope_kind,
464            scope_id: scope_id.into(),
465            field: field.to_string(),
466            expected_dollars,
467            actual_dollars,
468            difference,
469        });
470    }
471}
472
473impl OpfSolution {
474    /// Persisted exact-audit status stored on the serialized solution payload.
475    pub fn audit(&self) -> &SolutionAuditReport {
476        &self.audit
477    }
478
479    /// Whether this solution carries an exact objective ledger that can be audited.
480    pub fn has_objective_ledger(&self) -> bool {
481        !self.objective_terms.is_empty()
482    }
483
484    /// Recompute and store the persisted audit block from the exact
485    /// objective ledger. Gated by the `SURGE_OBJECTIVE_AUDIT` env var —
486    /// see [`crate::objective_audit_enabled`]. When the gate is off
487    /// (the default), this is a no-op and the `audit` field stays at
488    /// its serde default.
489    pub fn refresh_audit(&mut self) {
490        if !crate::objective_audit_enabled() {
491            return;
492        }
493        self.audit = <Self as AuditableSolution>::computed_solution_audit(self);
494    }
495
496    /// Return every objective-ledger mismatch found on this OPF solution.
497    pub fn objective_ledger_mismatches(&self) -> Vec<ObjectiveLedgerMismatch> {
498        let mut mismatches = Vec::new();
499        maybe_push_objective_ledger_mismatch(
500            &mut mismatches,
501            ObjectiveLedgerScopeKind::OpfSolution,
502            "opf",
503            "total_cost",
504            objective_term_total(&self.objective_terms),
505            self.total_cost,
506        );
507        maybe_push_objective_ledger_mismatch(
508            &mut mismatches,
509            ObjectiveLedgerScopeKind::OpfSolution,
510            "opf",
511            "residual",
512            0.0,
513            residual_term_total(&self.objective_terms),
514        );
515        mismatches
516    }
517
518    /// Whether the OPF solution's exact objective ledger reconciles cleanly.
519    pub fn objective_ledger_is_consistent(&self) -> bool {
520        self.objective_ledger_mismatches().is_empty()
521    }
522}
523
524impl AuditableSolution for OpfSolution {
525    fn computed_solution_audit(&self) -> SolutionAuditReport {
526        SolutionAuditReport::from_mismatches(self.objective_ledger_mismatches())
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use crate::{ObjectiveBucket, ObjectiveSubjectKind, ObjectiveTermKind};
534
535    fn make_opf(
536        opf_type: OpfType,
537        gen_p_mw: Vec<f64>,
538        gen_q_mvar: Vec<f64>,
539        lmp: Vec<f64>,
540    ) -> OpfSolution {
541        let n_buses = lmp.len();
542        let pf = PfSolution {
543            voltage_magnitude_pu: vec![1.0; n_buses],
544            voltage_angle_rad: vec![0.0; n_buses],
545            ..Default::default()
546        };
547        OpfSolution {
548            opf_type,
549            base_mva: 100.0,
550            power_flow: pf,
551            generators: OpfGeneratorResults {
552                gen_p_mw,
553                gen_q_mvar,
554                ..Default::default()
555            },
556            pricing: OpfPricing {
557                lmp,
558                ..Default::default()
559            },
560            ..Default::default()
561        }
562    }
563
564    #[test]
565    fn test_opf_solution_dc_construction_and_field_access() {
566        let sol = make_opf(
567            OpfType::DcOpf,
568            vec![50.0, 100.0],
569            vec![],
570            vec![25.0, 30.0, 28.0],
571        );
572        assert_eq!(sol.opf_type, OpfType::DcOpf);
573        assert_eq!(sol.base_mva, 100.0);
574        assert_eq!(sol.generators.gen_p_mw, vec![50.0, 100.0]);
575        assert!(
576            sol.generators.gen_q_mvar.is_empty(),
577            "DC-OPF should have empty gen_q_mvar"
578        );
579        assert_eq!(sol.pricing.lmp, vec![25.0, 30.0, 28.0]);
580        assert_eq!(sol.power_flow.voltage_magnitude_pu.len(), 3);
581    }
582
583    #[test]
584    fn test_opf_solution_ac_construction_with_reactive() {
585        let sol = make_opf(OpfType::AcOpf, vec![75.0], vec![20.0], vec![35.0, 40.0]);
586        assert_eq!(sol.opf_type, OpfType::AcOpf);
587        assert_eq!(sol.generators.gen_p_mw, vec![75.0]);
588        assert_eq!(sol.generators.gen_q_mvar, vec![20.0]);
589    }
590
591    #[test]
592    fn test_opf_type_variants() {
593        assert_eq!(OpfType::DcOpf, OpfType::DcOpf);
594        assert_eq!(OpfType::AcOpf, OpfType::AcOpf);
595        assert_eq!(OpfType::DcScopf, OpfType::DcScopf);
596        assert_eq!(OpfType::AcScopf, OpfType::AcScopf);
597        assert_eq!(OpfType::HvdcOpf, OpfType::HvdcOpf);
598        assert_ne!(OpfType::DcOpf, OpfType::AcOpf);
599    }
600
601    #[test]
602    fn test_opf_solution_cost_and_load_fields() {
603        let mut sol = make_opf(OpfType::DcOpf, vec![150.0, 200.0], vec![], vec![30.0]);
604        sol.total_cost = 5000.0;
605        sol.total_load_mw = 340.0;
606        sol.total_generation_mw = 350.0;
607        sol.total_losses_mw = 10.0;
608        assert_eq!(sol.total_cost, 5000.0);
609        assert_eq!(sol.total_load_mw, 340.0);
610        assert_eq!(sol.total_generation_mw, 350.0);
611        assert_eq!(sol.total_losses_mw, 10.0);
612    }
613
614    #[test]
615    fn test_opf_solution_solver_metadata() {
616        let mut sol = make_opf(OpfType::DcOpf, vec![], vec![], vec![]);
617        sol.solver_name = Some("HiGHS".to_string());
618        sol.solver_version = Some("1.7.0".to_string());
619        sol.iterations = Some(42);
620        sol.solve_time_secs = 1.23;
621        assert_eq!(sol.solver_name.as_deref(), Some("HiGHS"));
622        assert_eq!(sol.solver_version.as_deref(), Some("1.7.0"));
623        assert_eq!(sol.iterations, Some(42));
624        assert_eq!(sol.solve_time_secs, 1.23);
625    }
626
627    #[test]
628    fn test_opf_solution_binding_branches() {
629        let mut sol = make_opf(OpfType::DcOpf, vec![], vec![], vec![]);
630        sol.branches.branch_shadow_prices = vec![0.0, 5.2, 0.0, -3.1];
631        let binding = sol.branches.binding_branch_indices();
632        assert_eq!(binding, vec![1, 3]);
633        assert_eq!(sol.branches.branch_shadow_prices[1], 5.2);
634        assert_eq!(sol.branches.branch_shadow_prices[3], -3.1);
635    }
636
637    #[test]
638    fn test_opf_solution_objective_ledger_validation() {
639        let mut sol = make_opf(OpfType::DcOpf, vec![50.0], vec![], vec![30.0]);
640        sol.total_cost = 500.0;
641        sol.objective_terms = vec![ObjectiveTerm {
642            component_id: "energy".to_string(),
643            bucket: ObjectiveBucket::Energy,
644            kind: ObjectiveTermKind::GeneratorEnergy,
645            subject_kind: ObjectiveSubjectKind::Resource,
646            subject_id: "gen_1_0".to_string(),
647            dollars: 500.0,
648            quantity: Some(50.0),
649            quantity_unit: Some(crate::ObjectiveQuantityUnit::Mwh),
650            unit_rate: Some(10.0),
651        }];
652        assert!(sol.objective_ledger_is_consistent());
653
654        sol.total_cost = 400.0;
655        let mismatches = sol.objective_ledger_mismatches();
656        assert_eq!(mismatches.len(), 1);
657        assert_eq!(
658            mismatches[0].scope_kind,
659            ObjectiveLedgerScopeKind::OpfSolution
660        );
661        assert_eq!(mismatches[0].field, "total_cost");
662    }
663}