Skip to main content

surge_solution/
economics.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! Shared objective-ledger types used by OPF and dispatch reporting.
3
4use serde::{Deserialize, Serialize};
5
6/// High-level accounting bucket for an objective term.
7#[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/// Detailed classifier for one objective contribution.
23#[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/// Public subject scope for an objective term.
65#[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/// Unit associated with an optional objective quantity.
83#[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/// Exact objective contribution for one reported component.
99#[derive(Debug, Clone, Default, Serialize, Deserialize)]
100pub struct ObjectiveTerm {
101    /// Stable component label within the term's subject, such as `energy`,
102    /// `startup`, `reserve:spin`, or `curtailment_segment_0`.
103    pub component_id: String,
104    /// High-level accounting bucket.
105    pub bucket: ObjectiveBucket,
106    /// Detailed classifier.
107    pub kind: ObjectiveTermKind,
108    /// Subject scope.
109    pub subject_kind: ObjectiveSubjectKind,
110    /// Stable subject id such as a resource id, reserve requirement id, or
111    /// `system`.
112    pub subject_id: String,
113    /// Exact objective contribution in dollars for the solved interval or
114    /// aggregate horizon view.
115    pub dollars: f64,
116    /// Optional physical quantity associated with the term.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub quantity: Option<f64>,
119    /// Unit for `quantity`.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub quantity_unit: Option<ObjectiveQuantityUnit>,
122    /// Optional unit rate when the term has a meaningful single rate.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub unit_rate: Option<f64>,
125}
126
127/// Scope classifier for an objective-ledger reconciliation check.
128#[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/// Exact-vs-reported mismatch surfaced by audit helpers.
142#[derive(Debug, Clone, Default, Serialize, Deserialize)]
143pub struct ObjectiveLedgerMismatch {
144    /// Where the mismatch was detected.
145    pub scope_kind: ObjectiveLedgerScopeKind,
146    /// Stable scope identifier such as `summary`, `period:3`, or `gen_1_2`.
147    pub scope_id: String,
148    /// Report field that failed reconciliation.
149    pub field: String,
150    /// Expected exact dollars from the objective ledger.
151    pub expected_dollars: f64,
152    /// Actual reported dollars carried on the public result.
153    pub actual_dollars: f64,
154    /// Signed difference `actual - expected`.
155    pub difference: f64,
156}
157
158/// Version tag for the persisted solution-audit schema.
159pub const SOLUTION_AUDIT_SCHEMA_VERSION: &str = "1";
160
161/// Whether the objective-ledger audit should run on solution finalization.
162///
163/// Controlled by the `SURGE_OBJECTIVE_AUDIT` env var; off by default. The
164/// audit is a ledger-sum consistency check — useful when debugging a new
165/// penalty-term or cost-rollup wiring, not useful on every solve. Enable
166/// with `SURGE_OBJECTIVE_AUDIT=1`.
167///
168/// When disabled, `refresh_audit()` is a no-op and the `audit` field on
169/// the solution keeps its serde default (`audit_passed: false`,
170/// `ledger_mismatches: []`). Callers that want to run the audit on
171/// demand can still invoke `objective_ledger_mismatches()` directly —
172/// this gate only affects the auto-populated `audit` block.
173pub 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/// Serialized audit status carried alongside a persisted solution payload.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct SolutionAuditReport {
190    /// Version of the audit-block schema.
191    pub schema_version: String,
192    /// Whether the solution passed all exact objective-ledger checks.
193    pub audit_passed: bool,
194    /// Whether any residual terms remain in the objective ledger.
195    pub has_residual_terms: bool,
196    /// Exact ledger mismatches surfaced during validation.
197    #[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    /// Build a persisted audit block from the exact objective-ledger mismatches.
214    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
239/// Shared hook for solution types that can compute an exact persisted audit block.
240pub trait AuditableSolution {
241    fn computed_solution_audit(&self) -> SolutionAuditReport;
242}