1use 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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum OpfType {
45 #[default]
47 DcOpf,
48 AcOpf,
50 DcScopf,
52 AcScopf,
54 HvdcOpf,
56}
57
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
64pub struct OpfGeneratorResults {
65 pub gen_p_mw: Vec<f64>,
69 pub gen_q_mvar: Vec<f64>,
73 #[serde(default, skip_serializing_if = "Vec::is_empty")]
75 pub gen_bus_numbers: Vec<u32>,
76 #[serde(default, skip_serializing_if = "Vec::is_empty")]
78 pub gen_ids: Vec<String>,
79 #[serde(default, skip_serializing_if = "Vec::is_empty")]
81 pub gen_machine_ids: Vec<String>,
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub shadow_price_pg_min: Vec<f64>,
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub shadow_price_pg_max: Vec<f64>,
88 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub shadow_price_qg_min: Vec<f64>,
92 #[serde(default, skip_serializing_if = "Vec::is_empty")]
94 pub shadow_price_qg_max: Vec<f64>,
95}
96
97#[derive(Debug, Clone, Default, Serialize, Deserialize)]
99pub struct OpfPricing {
100 pub lmp: Vec<f64>,
102 pub lmp_energy: Vec<f64>,
104 pub lmp_congestion: Vec<f64>,
106 pub lmp_loss: Vec<f64>,
108 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub lmp_reactive: Vec<f64>,
111}
112
113#[derive(Debug, Clone, Default, Serialize, Deserialize)]
115pub struct OpfBranchResults {
116 #[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 pub branch_shadow_prices: Vec<f64>,
129 #[serde(default, skip_serializing_if = "Vec::is_empty")]
131 pub shadow_price_angmin: Vec<f64>,
132 #[serde(default, skip_serializing_if = "Vec::is_empty")]
134 pub shadow_price_angmax: Vec<f64>,
135 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub thermal_limit_slack_from_mva: Vec<f64>,
141 #[serde(default, skip_serializing_if = "Vec::is_empty")]
143 pub thermal_limit_slack_to_mva: Vec<f64>,
144 #[serde(default, skip_serializing_if = "Vec::is_empty")]
146 pub flowgate_shadow_prices: Vec<f64>,
147 #[serde(default, skip_serializing_if = "Vec::is_empty")]
149 pub interface_shadow_prices: Vec<f64>,
150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
153 pub shadow_price_vm_min: Vec<f64>,
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
156 pub shadow_price_vm_max: Vec<f64>,
157}
158
159impl OpfBranchResults {
160 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
173pub struct OpfDeviceDispatch {
174 #[serde(default, skip_serializing_if = "Vec::is_empty")]
176 pub switched_shunt_dispatch: Vec<(usize, f64, f64)>,
177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
179 pub tap_dispatch: Vec<(usize, f64, f64)>,
180 #[serde(default, skip_serializing_if = "Vec::is_empty")]
182 pub phase_dispatch: Vec<(usize, f64, f64)>,
183 #[serde(default, skip_serializing_if = "Vec::is_empty")]
185 pub svc_dispatch: Vec<(usize, f64, f64, f64)>,
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
188 pub tcsc_dispatch: Vec<(usize, f64, f64, f64)>,
189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
191 pub storage_net_mw: Vec<f64>,
192 #[serde(default, skip_serializing_if = "Vec::is_empty")]
194 pub dispatchable_load_served_mw: Vec<f64>,
195 #[serde(default, skip_serializing_if = "Vec::is_empty")]
197 pub dispatchable_load_served_q_mvar: Vec<f64>,
198 #[serde(default, skip_serializing_if = "Vec::is_empty")]
202 pub producer_q_reserve_up_mvar: Vec<f64>,
203 #[serde(default, skip_serializing_if = "Vec::is_empty")]
206 pub producer_q_reserve_down_mvar: Vec<f64>,
207 #[serde(default, skip_serializing_if = "Vec::is_empty")]
210 pub consumer_q_reserve_up_mvar: Vec<f64>,
211 #[serde(default, skip_serializing_if = "Vec::is_empty")]
214 pub consumer_q_reserve_down_mvar: Vec<f64>,
215 #[serde(default, skip_serializing_if = "Vec::is_empty")]
219 pub zone_q_reserve_up_shortfall_mvar: Vec<f64>,
220 #[serde(default, skip_serializing_if = "Vec::is_empty")]
223 pub zone_q_reserve_down_shortfall_mvar: Vec<f64>,
224 #[serde(default, skip_serializing_if = "Vec::is_empty")]
233 pub hvdc_p2p_dispatch_mw: Vec<f64>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub discrete_feasible: Option<bool>,
239 #[serde(default, skip_serializing_if = "Vec::is_empty")]
241 pub discrete_violations: Vec<String>,
242}
243
244#[derive(Debug, Clone, Default, Serialize, Deserialize)]
250pub struct OpfSolution {
251 pub opf_type: OpfType,
253 pub base_mva: f64,
255 pub power_flow: PfSolution,
257
258 pub total_cost: f64,
261 pub total_load_mw: f64,
263 pub total_generation_mw: f64,
265 pub total_losses_mw: f64,
267
268 #[serde(flatten)]
271 pub generators: OpfGeneratorResults,
272 #[serde(flatten)]
274 pub pricing: OpfPricing,
275 #[serde(flatten)]
277 pub branches: OpfBranchResults,
278 #[serde(flatten)]
280 pub devices: OpfDeviceDispatch,
281
282 #[serde(default, skip_serializing_if = "Vec::is_empty")]
285 pub par_results: Vec<ParResult>,
286 #[serde(default, skip_serializing_if = "Vec::is_empty")]
288 pub virtual_bid_results: Vec<VirtualBidResult>,
289 #[serde(default, skip_serializing_if = "Vec::is_empty")]
291 pub benders_cut_duals: Vec<f64>,
292 #[serde(default, skip_serializing_if = "Vec::is_empty")]
294 pub objective_terms: Vec<ObjectiveTerm>,
295 #[serde(default)]
297 pub audit: SolutionAuditReport,
298
299 #[serde(default, skip_serializing_if = "Vec::is_empty")]
303 pub bus_q_slack_pos_mvar: Vec<f64>,
304 #[serde(default, skip_serializing_if = "Vec::is_empty")]
306 pub bus_q_slack_neg_mvar: Vec<f64>,
307 #[serde(default, skip_serializing_if = "Vec::is_empty")]
309 pub bus_p_slack_pos_mw: Vec<f64>,
310 #[serde(default, skip_serializing_if = "Vec::is_empty")]
312 pub bus_p_slack_neg_mw: Vec<f64>,
313 #[serde(default, skip_serializing_if = "Vec::is_empty")]
317 pub vm_slack_high_pu: Vec<f64>,
318 #[serde(default, skip_serializing_if = "Vec::is_empty")]
321 pub vm_slack_low_pu: Vec<f64>,
322 #[serde(default, skip_serializing_if = "Vec::is_empty")]
326 pub angle_diff_slack_high_rad: Vec<f64>,
327 #[serde(default, skip_serializing_if = "Vec::is_empty")]
330 pub angle_diff_slack_low_rad: Vec<f64>,
331
332 pub solve_time_secs: f64,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub iterations: Option<u32>,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub solver_name: Option<String>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub solver_version: Option<String>,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub ac_opf_timings: Option<AcOpfTimings>,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub nlp_trace: Option<NlpTrace>,
354}
355
356#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
365pub struct NlpTrace {
366 pub n_vars: u32,
368 pub n_constraints: u32,
370 pub jac_nnz: u32,
372 pub hess_nnz: u32,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub status_code: Option<i32>,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub status_label: Option<String>,
385 pub iterations: u32,
387 pub objective: f64,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub final_primal_inf: Option<f64>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub final_dual_inf: Option<f64>,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub final_mu: Option<f64>,
401 pub converged: bool,
403}
404
405#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
412pub struct AcOpfTimings {
413 pub network_prep_secs: f64,
416 pub solve_setup_secs: f64,
419 pub nlp_build_secs: f64,
422 pub nlp_solve_secs: f64,
425 pub extract_secs: f64,
428 pub total_secs: f64,
431 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 pub fn audit(&self) -> &SolutionAuditReport {
476 &self.audit
477 }
478
479 pub fn has_objective_ledger(&self) -> bool {
481 !self.objective_terms.is_empty()
482 }
483
484 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 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 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}