1use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum ObjectiveBucket {
10 #[default]
11 Other,
12 Energy,
13 Reserve,
14 NoLoad,
15 Startup,
16 Shutdown,
17 Penalty,
18 Tracking,
19 Adder,
20}
21
22#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ObjectiveTermKind {
26 #[default]
27 Other,
28 GeneratorEnergy,
29 GeneratorNoLoad,
30 GeneratorStartup,
31 GeneratorShutdown,
32 StorageEnergy,
33 StorageOfferEpigraph,
34 DispatchableLoadEnergy,
35 DispatchableLoadTargetTracking,
36 GeneratorTargetTracking,
37 ReserveProcurement,
38 ReserveShortfall,
39 ReactiveReserveProcurement,
40 ReactiveReserveShortfall,
41 VirtualBid,
42 HvdcEnergy,
43 CarbonAdder,
44 PowerBalancePenalty,
45 ReactiveBalancePenalty,
46 ThermalLimitPenalty,
47 FlowgatePenalty,
48 InterfacePenalty,
49 RampPenalty,
50 VoltagePenalty,
51 AngleDifferencePenalty,
52 CommitmentCapacityPenalty,
53 EnergyWindowPenalty,
54 CombinedCycleNoLoad,
55 CombinedCycleTransition,
56 CombinedCycleDispatch,
57 BranchSwitchingStartup,
58 BranchSwitchingShutdown,
59 ExplicitContingencyWorstCase,
60 ExplicitContingencyAverageCase,
61 BendersEta,
62}
63
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum ObjectiveSubjectKind {
68 #[default]
69 Other,
70 System,
71 Resource,
72 Bus,
73 Branch,
74 Flowgate,
75 Interface,
76 ReserveRequirement,
77 HvdcLink,
78 CombinedCyclePlant,
79 VirtualBid,
80}
81
82#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum ObjectiveQuantityUnit {
86 #[default]
87 Other,
88 Mw,
89 Mwh,
90 Mvar,
91 Mva,
92 Pu,
93 PuHour,
94 Rad,
95 Event,
96}
97
98#[derive(Debug, Clone, Default, Serialize, Deserialize)]
100pub struct ObjectiveTerm {
101 pub component_id: String,
104 pub bucket: ObjectiveBucket,
106 pub kind: ObjectiveTermKind,
108 pub subject_kind: ObjectiveSubjectKind,
110 pub subject_id: String,
113 pub dollars: f64,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub quantity: Option<f64>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub quantity_unit: Option<ObjectiveQuantityUnit>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub unit_rate: Option<f64>,
125}
126
127#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130pub enum ObjectiveLedgerScopeKind {
131 #[default]
132 Other,
133 OpfSolution,
134 DispatchSolution,
135 DispatchPeriod,
136 ResourcePeriod,
137 ResourceSummary,
138 CombinedCyclePlant,
139}
140
141#[derive(Debug, Clone, Default, Serialize, Deserialize)]
143pub struct ObjectiveLedgerMismatch {
144 pub scope_kind: ObjectiveLedgerScopeKind,
146 pub scope_id: String,
148 pub field: String,
150 pub expected_dollars: f64,
152 pub actual_dollars: f64,
154 pub difference: f64,
156}
157
158pub const SOLUTION_AUDIT_SCHEMA_VERSION: &str = "1";
160
161pub fn objective_audit_enabled() -> bool {
174 match std::env::var("SURGE_OBJECTIVE_AUDIT") {
175 Ok(value) => {
176 let trimmed = value.trim();
177 !trimmed.is_empty()
178 && !trimmed.eq_ignore_ascii_case("0")
179 && !trimmed.eq_ignore_ascii_case("false")
180 && !trimmed.eq_ignore_ascii_case("off")
181 && !trimmed.eq_ignore_ascii_case("no")
182 }
183 Err(_) => false,
184 }
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct SolutionAuditReport {
190 pub schema_version: String,
192 pub audit_passed: bool,
194 pub has_residual_terms: bool,
196 #[serde(default, skip_serializing_if = "Vec::is_empty")]
198 pub ledger_mismatches: Vec<ObjectiveLedgerMismatch>,
199}
200
201impl Default for SolutionAuditReport {
202 fn default() -> Self {
203 Self {
204 schema_version: SOLUTION_AUDIT_SCHEMA_VERSION.to_string(),
205 audit_passed: false,
206 has_residual_terms: false,
207 ledger_mismatches: Vec::new(),
208 }
209 }
210}
211
212impl SolutionAuditReport {
213 pub fn from_mismatches(mut ledger_mismatches: Vec<ObjectiveLedgerMismatch>) -> Self {
215 ledger_mismatches.sort_by(|lhs, rhs| {
216 (
217 lhs.scope_kind as u8,
218 lhs.scope_id.as_str(),
219 lhs.field.as_str(),
220 )
221 .cmp(&(
222 rhs.scope_kind as u8,
223 rhs.scope_id.as_str(),
224 rhs.field.as_str(),
225 ))
226 });
227 let has_residual_terms = ledger_mismatches
228 .iter()
229 .any(|mismatch| mismatch.field == "residual");
230 Self {
231 schema_version: SOLUTION_AUDIT_SCHEMA_VERSION.to_string(),
232 audit_passed: ledger_mismatches.is_empty(),
233 has_residual_terms,
234 ledger_mismatches,
235 }
236 }
237}
238
239pub trait AuditableSolution {
241 fn computed_solution_audit(&self) -> SolutionAuditReport;
242}